Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,3 @@ jobs:
fs.writeFileSync('release-notes.md', `## ${section.trim()}\n`);
NODE
gh release create "$GITHUB_REF_NAME" --verify-tag --title "ChangeGuard $GITHUB_REF_NAME" --notes-file release-notes.md --generate-notes

maintain-minor-alias:
name: Maintain minor release alias
permissions:
contents: write
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
- name: Move minor alias to released commit
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
alias="${GITHUB_REF_NAME%.*}"
git tag -f "$alias" "$GITHUB_SHA"
git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git"
git push --force origin "refs/tags/$alias"
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Report per-subscription Events change categories while ignoring equivalent query and filter formatting.
- Add Markdown reports, an optional risk threshold for CLI and Action CI failures, and file-level Action annotations.
- Include the new CLI modules in npm packages and document multi-configuration reporting without guessing environment names.

## 0.5.2

- Refine npm search metadata and align the Action listing description with configuration review in CI.
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ Keep PRs focused. Explain:

AI-assisted contributions are welcome, but the human submitter must review and understand the change.

These repositories use a solo-maintainer review model. Keep changes in pull requests and merge only after the required automated checks pass. CODEOWNERS may route review requests, but human approval is not a merge requirement.

## Coverage

Current coverage gates protect comparison and redaction paths. Do not weaken them to land a change.
Expand Down
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ For security-sensitive workflows, pin third-party Actions to a reviewed immutabl

See the [Action guide](docs/github-action.md) and [copy-paste workflow example](examples/changeguard-workflow.yml).

For convenience, workflows may use the movable minor release alias `RexCode-Digital/shopify-app-changeguard@v0.5`. For high-assurance supply-chain usage, resolve the current patch release to a full commit SHA; minor aliases are not immutable.
Use an exact patch release tag or a full commit SHA. The historical `v0.5` alias is retained but is no longer moved by the release workflow.

### Action inputs

Expand All @@ -103,10 +103,15 @@ For convenience, workflows may use the movable minor release alias `RexCode-Digi
| `base_sha` | Pull request base commit SHA |
| `head_sha` | Pull request head commit SHA |
| `fail_on` | Failure policy: `never`, `review`, or `unreviewed` |
| `fail_on_risk` | Optional independent risk threshold: `none` (default), `low`, `medium`, or `high` |

### Action outputs

`outcome`, `finding_count`, `highest_severity`, `highest_risk`, `report`, `reviewed_file_count`, `unreviewed_count`, and `rule_ids`.
`outcome`, `finding_count`, `highest_severity`, `highest_risk`, `risk_threshold_exceeded`, `report`, `reviewed_file_count`, `unreviewed_count`, and `rule_ids`.

The optional `fail_on_risk` policy is independent of `fail_on`: either policy can fail the job. `none` disables risk-based failure. The existing `fail_on` default and behaviour are unchanged; when it is `never`, an incomplete result remains visible as `outcome: incomplete` and is not represented as a complete review. Findings produce file-level GitHub warning annotations when a changed configuration file is known; no source line is inferred.

The example above pins v0.5.2, which does not include `fail_on_risk` or `risk_threshold_exceeded`. These Action additions, along with the Markdown and risk-threshold CLI options below, are part of the unreleased changes here and are not available from npm v0.5.2.

The bundled Action runs on GitHub's `node24` JavaScript Action runtime. Consumer jobs do not install project dependencies or compile ChangeGuard.

Expand Down Expand Up @@ -147,8 +152,9 @@ It is not a claim that a configuration is secure, valid, or approved by Shopify.

```text
changeguard --before FILE --after FILE [--json]
changeguard --base-ref REF --head-ref REF --file shopify.app.toml [--fail-on ...]
changeguard --base-ref REF --head-ref REF --all-configs [--json]
changeguard --before FILE --after FILE [--format human|json|markdown] [--fail-on ...] [--fail-on-risk none|low|medium|high]
changeguard --base-ref REF --head-ref REF --file shopify.app.toml [--format human|json|markdown] [--fail-on ...] [--fail-on-risk ...]
changeguard --base-ref REF --head-ref REF --all-configs [--format human|json|markdown] [--fail-on ...] [--fail-on-risk ...]
```

Examples:
Expand All @@ -165,9 +171,12 @@ npx shopify-app-changeguard --base-ref main --head-ref HEAD --all-configs --json

# Fail CI when review findings exist
npx shopify-app-changeguard --base-ref main --head-ref HEAD --all-configs --fail-on review

# Write a Markdown report and fail only for high-risk findings
npx shopify-app-changeguard --base-ref main --head-ref HEAD --all-configs --format markdown --fail-on-risk high
```

`--fail-on never` is the default. `review` exits 1 when findings exist. Input errors exit 2. In Git-range mode, incomplete reviews exit 2 under `review` or `unreviewed`; `never` reports them without failing.
`--fail-on never` and `--fail-on-risk none` are the defaults. `review` exits 1 when findings exist; a risk threshold exits 1 when a finding reaches that level. Input errors exit 2. In Git-range mode, incomplete reviews exit 2 under `review` or `unreviewed`; `never` reports them with an incomplete status without failing. Repository-wide reports group findings by configuration file and do not infer environment names from arbitrary filenames.

See the [CLI reference](docs/cli.md).

Expand Down Expand Up @@ -210,6 +219,8 @@ Building or maintaining Shopify apps?

All four tools run offline and require no Shopify credentials.

See the [coordinated workflow example](docs/ecosystem-workflow.md) to run all four published Actions in one pull-request workflow.

## Contributing

Contributions are welcome, especially around new evidence-backed configuration semantics, redaction hardening, privacy-safe fixtures, and deterministic review quality.
Expand Down Expand Up @@ -243,4 +254,4 @@ The Action example pins the reviewed v0.5.2 release commit. Verify the release r
git fetch --tags origin && git rev-parse 'v0.5.2^{commit}'
```

Published patch tags are retained; existing minor aliases are movable. A reviewed full commit SHA is the immutable execution reference.
Published patch tags are retained. The release workflow does not move existing tags; a reviewed full commit SHA is the immutable execution reference.
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
description: 'Failure policy: never, review, or unreviewed'
required: false
default: unreviewed
fail_on_risk:
description: 'Optional risk threshold: none, low, medium, or high'
required: false
default: none

outputs:
outcome:
Expand All @@ -26,6 +30,8 @@ outputs:
description: Highest finding severity, or none
highest_risk:
description: Highest deterministic review risk, or none
risk_threshold_exceeded:
description: Whether any finding reaches fail_on_risk
report:
description: JSON review report
reviewed_file_count:
Expand Down
2 changes: 1 addition & 1 deletion dist/action/index.js

Large diffs are not rendered by default.

48 changes: 40 additions & 8 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ import { compareConfigs } from './core.js';
import { readConfigAtRef, resolveCommitRef } from './git-refs.js';
import { reviewGitRange } from './pr-review.js';
import { CHANGEGUARD_VERSION } from './generated-version.js';
import { renderMarkdown } from './markdown.js';
import { matchesRiskThreshold, validateRiskThreshold } from './risk-policy.js';
const VERSION = CHANGEGUARD_VERSION;
function usage(exitCode = 2) {
const output = `ChangeGuard ${VERSION}

Usage:
changeguard --before FILE --after FILE [--json] [--fail-on LEVEL]
changeguard --base-ref REF --head-ref REF --file PATH [--json] [--fail-on LEVEL]
changeguard --base-ref REF --head-ref REF --all-configs [--json] [--fail-on LEVEL]
changeguard --before FILE --after FILE [--format human|json|markdown] [--fail-on LEVEL] [--fail-on-risk LEVEL]
changeguard --base-ref REF --head-ref REF --file PATH [--format human|json|markdown] [--fail-on LEVEL] [--fail-on-risk LEVEL]
changeguard --base-ref REF --head-ref REF --all-configs [--format human|json|markdown] [--fail-on LEVEL] [--fail-on-risk LEVEL]

Options:
--json Emit a machine-readable report.
--json Emit a machine-readable report (alias for --format json).
--format FORMAT Emit human, json, or markdown output.
--fail-on LEVEL never (default), review, or unreviewed.
--fail-on-risk LEVEL Fail when any finding reaches none, low, medium, or high risk.
--all-configs Review every changed shopify.app*.toml file in a Git range.
--help Show this help.
--version Show the version.
Expand Down Expand Up @@ -48,11 +52,13 @@ async function main() {
let before;
let after;
let json = false;
let format;
let baseRef;
let headRef;
let file;
let allConfigs = false;
let failOn = 'never';
let failOnRisk = 'none';
const valueFor = (name, index) => {
const value = args[index + 1];
if (!value || value.startsWith('--'))
Expand All @@ -69,6 +75,12 @@ async function main() {
}
else if (arg === '--json')
json = true;
else if (arg === '--format') {
const value = valueFor(arg, i++);
if (value !== 'human' && value !== 'json' && value !== 'markdown')
usage(2);
format = value;
}
else if (arg === '--before')
before = valueFor(arg, i++);
else if (arg === '--after')
Expand All @@ -87,22 +99,37 @@ async function main() {
usage(2);
failOn = value;
}
else if (arg === '--fail-on-risk') {
const value = valueFor(arg, i++);
try {
validateRiskThreshold(value);
}
catch {
usage(2);
}
failOnRisk = value;
}
else
usage();
}
const fileMode = before !== undefined || after !== undefined;
const gitMode = baseRef !== undefined || headRef !== undefined || file !== undefined;
if (fileMode === gitMode)
usage();
if (json && format !== undefined && format !== 'json')
usage(2);
format = json ? 'json' : format ?? 'human';
let oldConfig;
let newConfig;
if (gitMode) {
if (!baseRef || !headRef || (file === undefined && !allConfigs) || (file !== undefined && allConfigs))
usage();
if (allConfigs) {
const report = reviewGitRange(resolveCommitRef(baseRef), resolveCommitRef(headRef));
if (json)
if (format === 'json')
console.log(JSON.stringify(report, null, 2));
else if (format === 'markdown')
console.log(renderMarkdown(report));
else {
console.log(`ChangeGuard v${VERSION}`);
console.log(report.note);
Expand All @@ -115,6 +142,8 @@ async function main() {
}
if (failOn !== 'never' && report.unreviewed.length > 0)
process.exitCode = 2;
else if (matchesRiskThreshold(report.files.flatMap((item) => item.findings), failOnRisk))
process.exitCode = 1;
else if (failOn === 'review' && report.files.some((item) => item.findings.length > 0))
process.exitCode = 1;
return;
Expand All @@ -132,8 +161,11 @@ async function main() {
}
const findings = compareConfigs(oldConfig, newConfig);
const note = 'Only documented semantic changes in supported Shopify app configuration fields are examined; Shopify CLI remains responsible for schema validation and unsupported fields are not checked.';
if (json)
console.log(JSON.stringify({ schemaVersion: 1, note, findings }, null, 2));
const report = { schemaVersion: 1, note, findings };
if (format === 'json')
console.log(JSON.stringify(report, null, 2));
else if (format === 'markdown')
console.log(renderMarkdown(report));
else {
console.log(`ChangeGuard v${VERSION}`);
console.log(note);
Expand All @@ -143,7 +175,7 @@ async function main() {
console.log(`[REVIEW] ${finding.ruleId}: ${finding.summary}`);
console.log(`${findings.length} finding(s).`);
}
if (failOn === 'review' && findings.length > 0)
if (matchesRiskThreshold(findings, failOnRisk) || failOn === 'review' && findings.length > 0)
process.exitCode = 1;
}
main().catch((err) => {
Expand Down
81 changes: 59 additions & 22 deletions dist/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,33 +41,26 @@ function readEvents(config) {
if (!/^[A-Za-z0-9_-]{1,50}$/.test(handle)) {
throw new Error('events.subscription.handle must be alphanumeric, _, or - and at most 50 characters');
}
readString(item, 'topic', label);
const topic = readString(item, 'topic', label);
const actions = readStringSet(item, 'actions', label);
if (actions.some((action) => !['create', 'update', 'delete'].includes(action))) {
throw new Error('events.subscription.actions must contain only create, update, or delete');
}
readString(item, 'uri', label);
const destination = readString(item, 'uri', label);
if (actions.includes('update')) {
readStringSet(item, 'triggers', label);
}
else if (item.triggers !== undefined) {
readStringSet(item, 'triggers', label);
}
if (item.query !== undefined)
readString(item, 'query', label);
if (item.query_filter !== undefined)
readString(item, 'query_filter', label);
const query = item.query === undefined ? undefined : readString(item, 'query', label);
const filter = item.query_filter === undefined ? undefined : normalizeFilter(readString(item, 'query_filter', label));
if (subscriptions.has(handle))
throw new Error('Events subscription handles must be unique');
const fingerprint = JSON.stringify({
actions,
query: item.query,
query_filter: item.query_filter,
topic: item.topic,
triggers: item.triggers === undefined ? undefined : [...new Set(item.triggers.map((value) => value.trim()))].sort(),
uri: item.uri,
});
subscriptions.set(handle, { handle, fingerprint });
const normalizedQuery = query === undefined ? undefined : normalizeGraphQL(query);
const triggers = item.triggers === undefined ? [] : [...new Set(item.triggers.map((value) => value.trim()))].sort();
const values = { actions, query: normalizedQuery, filter, topic, triggers, destination };
subscriptions.set(handle, { handle, fingerprint: JSON.stringify(values), ...values });
}
return { version: version, subscriptions };
}
Expand All @@ -83,26 +76,70 @@ export function compareEvents(before, after) {
summary: 'Events API version changed; review preview/runtime compatibility',
});
}
let added = 0;
let removed = 0;
let modified = 0;
let created = 0, removed = 0, modified = 0;
const changes = { topic: 0, destination: 0, actions: 0, triggers: 0, query: 0, filter: 0 };
for (const handle of new Set([...oldEvents.subscriptions.keys(), ...newEvents.subscriptions.keys()])) {
const previous = oldEvents.subscriptions.get(handle);
const next = newEvents.subscriptions.get(handle);
if (!previous)
added++;
created++;
else if (!next)
removed++;
else if (previous.fingerprint !== next.fingerprint)
else if (previous.fingerprint !== next.fingerprint) {
modified++;
for (const field of Object.keys(changes))
if (JSON.stringify(previous[field]) !== JSON.stringify(next[field]))
changes[field]++;
}
}
if (added || removed || modified) {
if (created || removed || modified) {
const detail = modified ? `; modified fields: ${Object.entries(changes).filter(([, count]) => count > 0).map(([field, count]) => `${field === 'destination' ? 'destination' : field === 'filter' ? 'query filter' : field} ${count}`).join(', ')}` : '';
findings.push({
ruleId: 'EVENTS_SUBSCRIPTIONS_CHANGED',
severity: 'review',
field: 'events.subscription',
summary: `Events subscriptions changed (added: ${added}; removed: ${removed}; modified: ${modified}); review topic, actions, triggers, destination and delivery query settings`,
summary: `Events subscriptions changed (created: ${created}; removed: ${removed}; modified: ${modified}${detail}); review the affected subscription configuration`,
});
}
return findings;
}
function normalizeGraphQL(query) {
const tokens = [];
const tokenPattern = /#[^\r\n]*|"""[\s\S]*?"""|"(?:\\.|[^"\\])*"|[A-Za-z_][A-Za-z0-9_]*|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\.\.|[^\s,]/g;
for (const match of query.matchAll(tokenPattern)) {
if (match[0].startsWith('#'))
continue;
tokens.push(match[0]);
}
return tokens.length ? tokens.join(' ') : query;
}
function normalizeFilter(value) {
let result = '', quote = '', escaped = false, pendingSpace = false;
for (const character of value.trim()) {
if (quote) {
result += character;
if (escaped)
escaped = false;
else if (character === '\\')
escaped = true;
else if (character === quote)
quote = '';
}
else if (character === '"' || character === "'") {
if (pendingSpace && result)
result += ' ';
pendingSpace = false;
quote = character;
result += character;
}
else if (/\s/.test(character))
pendingSpace = true;
else {
if (pendingSpace && result)
result += ' ';
pendingSpace = false;
result += character;
}
}
return result;
}
Loading
Loading