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
2 changes: 1 addition & 1 deletion .github/workflows/action-fixture.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [20, 22, 24]
node: [22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ jobs:
test:
strategy:
matrix:
# Keep the required Node 20 branch-protection check while testing the
# supported Node 22 and 24 runtimes. The package engine floor remains 22.
node: [20, 22, 24]
runs-on: ubuntu-latest
steps:
Expand All @@ -32,6 +34,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, windows-latest]
node: [22, 24]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
Expand All @@ -40,7 +43,7 @@ jobs:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 20
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci --ignore-scripts
- run: npm run build
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Add versioned, privacy-safe baselines that classify findings as new, existing, or resolved in CLI and Action reviews.
- Breaking: require Node.js 22 or newer for the CLI; Node.js 20 is no longer supported. CI covers Node.js 22 and 24; the GitHub Action remains on `node24`.

## 0.6.0

- Report per-subscription Events change categories while ignoring equivalent query and filter formatting.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ You retain copyright in your contributions. By submitting a contribution, you ag

## Development

Requires Node.js 20 or newer. CI covers Node 20, 22, and 24.
Requires Node.js 22 or newer. CI covers Node.js 22 and 24; the bundled GitHub Action uses the Node.js 24 runtime.

```bash
npm ci --ignore-scripts
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ ChangeGuard is an offline, read-only semantic reviewer for `shopify.app*.toml` c

Maintained by RexCode Digital Ltd.

Part of the **RexCode Shopify developer tools** suite. Requires Node.js 20 or later for the CLI. [Releases](https://github.com/RexCode-Digital/shopify-app-changeguard/releases) · [npm](https://www.npmjs.com/package/shopify-app-changeguard) · [Marketplace](https://github.com/marketplace/actions/changeguard-shopify-app-config-review)
Part of the **RexCode Shopify developer tools** suite. Requires Node.js 22 or later for the CLI. [Releases](https://github.com/RexCode-Digital/shopify-app-changeguard/releases) · [npm](https://www.npmjs.com/package/shopify-app-changeguard) · [Marketplace](https://github.com/marketplace/actions/changeguard-shopify-app-config-review)

## Quick start

Expand Down Expand Up @@ -104,17 +104,21 @@ Use an exact patch release tag or a full commit SHA. The historical `v0.5` alias
| `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` |
| `baseline_path` | Optional repository-relative path to a versioned baseline JSON file |
| `fail_on_new` | Fail when the current review includes findings absent from the baseline |

### Action outputs

`outcome`, `finding_count`, `highest_severity`, `highest_risk`, `risk_threshold_exceeded`, `report`, `reviewed_file_count`, `unreviewed_count`, and `rule_ids`.
`outcome`, `finding_count`, `highest_severity`, `highest_risk`, `risk_threshold_exceeded`, `new_finding_count`, `existing_finding_count`, `resolved_finding_count`, `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.

When `baseline_path` is set, the report classifies findings as `NEW`, `EXISTING`, or `RESOLVED`. `fail_on_new: true` fails only for new findings; without a baseline file, findings in the selected comparison are all new. The independent `fail_on` and `fail_on_risk` policies still apply to every current finding. Lifecycle count outputs are empty only when neither `baseline_path` nor `fail_on_new` enables classification. A missing or malformed selected baseline fails the Action instead of being treated as an empty review.

## What it reviews

| Area | Review behaviour |
Expand Down Expand Up @@ -155,6 +159,8 @@ changeguard --before FILE --after FILE [--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 ...]
changeguard baseline create --baseline changeguard-baseline.json --base-ref main --head-ref HEAD --all-configs
changeguard baseline check --baseline changeguard-baseline.json --base-ref main --head-ref HEAD --all-configs --fail-on-new
```

Examples:
Expand All @@ -178,6 +184,10 @@ npx shopify-app-changeguard --base-ref main --head-ref HEAD --all-configs --form

`--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.

Baselines snapshot findings from one explicit comparison (`configuration-pair`, `git-file`, or `git-all`); they do not store commit IDs, timestamps, configuration values, absolute paths, or finding summaries. Check a baseline only with the same comparison kind. Fingerprints are SHA-256 digests of the rule, normalized repository-relative configuration path, field, stable risk metadata, and redacted semantic summary. A rename is a new path identity, so findings at the new path are new and findings at the old path are resolved. Baseline creation requires a complete analysis and refuses to overwrite a file. Explicit file-pair comparisons outside the repository need `--config-path` to provide a safe relative identity.

`--fail-on-new` fails when at least one finding is new. `--fail-on` and `--fail-on-risk` remain independent and inspect all current findings, including `EXISTING` findings. Incomplete Git reviews fail with exit code 2 when `--fail-on-new` is selected. A missing, malformed, unsupported-version, symlinked, or out-of-repository baseline is an input error; it is never treated as an empty baseline.

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

## Security and privacy
Expand Down Expand Up @@ -220,6 +230,7 @@ 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.
With side-by-side source checkouts, maintainers can run `node scripts/ecosystem-smoke.mjs` from ChangeGuard to exercise a synthetic consumer app against all four local CLIs under Node.js 22 or 24.

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Now

- Keep comparisons deterministic, redacted, and well tested.
- Keep comparisons and baseline lifecycle deterministic, redacted, and well tested.
- Publish a trustworthy bundled Action and smoke-tested npm CLI.
- Document supported fields and limitations without implying Shopify affiliation.

Expand Down
13 changes: 13 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ inputs:
description: 'Optional risk threshold: none, low, medium, or high'
required: false
default: none
baseline_path:
description: Optional repository-relative baseline JSON file to classify findings
required: false
fail_on_new:
description: Fail when the report contains findings absent from the selected baseline
required: false
default: 'false'

outputs:
outcome:
Expand All @@ -32,6 +39,12 @@ outputs:
description: Highest deterministic review risk, or none
risk_threshold_exceeded:
description: Whether any finding reaches fail_on_risk
new_finding_count:
description: Number of new findings when baseline classification is enabled
existing_finding_count:
description: Number of existing findings when baseline classification is enabled
resolved_finding_count:
description: Number of resolved findings when baseline classification is enabled
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.

208 changes: 208 additions & 0 deletions dist/baseline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { createHash } from 'node:crypto';
import { lstat, open, readFile, realpath } from 'node:fs/promises';
import { isAbsolute, relative, resolve, sep, win32 } from 'node:path';
const MAX_BASELINE_BYTES = 1024 * 1024;
const MAX_BASELINE_FINDINGS = 10_000;
const hashPattern = /^[a-f0-9]{64}$/;
const rulePattern = /^[A-Z0-9][A-Z0-9_-]{0,99}$/;
export function normaliseRepositoryPath(value) {
if (!value || value.includes('\0'))
throw new Error('Baseline configuration paths must be repository-relative.');
const path = value.replaceAll('\\', '/');
if (path.startsWith('/') || /^[A-Za-z]:/.test(path) || isAbsolute(value) || win32.isAbsolute(value)) {
throw new Error('Baseline configuration paths must be repository-relative.');
}
const parts = path.split('/');
if (parts.some((part) => part === '..'))
throw new Error('Baseline configuration paths must not traverse directories.');
const normalised = parts.filter((part) => part && part !== '.').join('/');
if (!normalised || normalised.length > 2048 || normalised.includes(':')) {
throw new Error('Baseline configuration paths must be repository-relative.');
}
return normalised;
}
export function findingFingerprint(finding, repositoryPath) {
const path = normaliseRepositoryPath(repositoryPath);
const identity = [
'changeguard-baseline-v1',
finding.ruleId,
path,
finding.field,
finding.category ?? 'unspecified',
finding.riskLevel ?? 'medium',
finding.summary,
];
return createHash('sha256').update(JSON.stringify(identity), 'utf8').digest('hex');
}
export function createBaseline(kind, current) {
const grouped = new Map();
for (const item of current) {
const path = normaliseRepositoryPath(item.path);
const fingerprint = findingFingerprint(item.finding, path);
const existing = grouped.get(fingerprint);
if (existing) {
existing.count++;
}
else {
grouped.set(fingerprint, {
fingerprint,
ruleId: item.finding.ruleId,
path,
field: item.finding.field,
riskLevel: item.finding.riskLevel ?? 'medium',
count: 1,
});
}
}
if (grouped.size > MAX_BASELINE_FINDINGS)
throw new Error('Too many findings for a ChangeGuard baseline.');
return {
schemaVersion: 1,
comparisonKind: kind,
findings: [...grouped.values()].sort(compareBaselineFinding),
};
}
function compareBaselineFinding(a, b) {
return compareText(a.path, b.path) || compareText(a.ruleId, b.ruleId) || compareText(a.field, b.field) || compareText(a.fingerprint, b.fingerprint);
}
function compareText(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
export function validateBaseline(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new Error('Baseline must be a JSON object.');
const baseline = value;
if (baseline.schemaVersion !== 1)
throw new Error('Unsupported ChangeGuard baseline schema version.');
if (!['configuration-pair', 'git-file', 'git-all'].includes(String(baseline.comparisonKind))) {
throw new Error('Baseline comparisonKind is invalid.');
}
if (!Array.isArray(baseline.findings) || baseline.findings.length > MAX_BASELINE_FINDINGS) {
throw new Error('Baseline findings must be a bounded array.');
}
let total = 0;
const seen = new Set();
const findings = baseline.findings.map((raw) => {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
throw new Error('Baseline finding is invalid.');
const item = raw;
if (typeof item.fingerprint !== 'string' || !hashPattern.test(item.fingerprint) || seen.has(item.fingerprint)) {
throw new Error('Baseline finding fingerprint is invalid or duplicated.');
}
if (typeof item.ruleId !== 'string' || !rulePattern.test(item.ruleId))
throw new Error('Baseline finding ruleId is invalid.');
if (typeof item.path !== 'string' || normaliseRepositoryPath(item.path) !== item.path)
throw new Error('Baseline finding path is invalid.');
if (typeof item.field !== 'string' || !item.field || item.field.length > 256 || /[\r\n\0]/.test(item.field)) {
throw new Error('Baseline finding field is invalid.');
}
if (item.riskLevel !== 'low' && item.riskLevel !== 'medium' && item.riskLevel !== 'high') {
throw new Error('Baseline finding riskLevel is invalid.');
}
if (!Number.isSafeInteger(item.count) || item.count < 1 || item.count > MAX_BASELINE_FINDINGS) {
throw new Error('Baseline finding count is invalid.');
}
total += item.count;
if (total > MAX_BASELINE_FINDINGS)
throw new Error('Baseline contains too many findings.');
seen.add(item.fingerprint);
return {
fingerprint: item.fingerprint,
ruleId: item.ruleId,
path: item.path,
field: item.field,
riskLevel: item.riskLevel,
count: item.count,
};
});
findings.sort(compareBaselineFinding);
return { schemaVersion: 1, comparisonKind: baseline.comparisonKind, findings };
}
export function classifyFindings(current, baseline, kind) {
if (baseline && baseline.comparisonKind !== kind)
throw new Error('Baseline comparison type does not match the selected review.');
const known = new Map((baseline?.findings ?? []).map((item) => [item.fingerprint, item.count]));
const used = new Map();
const counts = { newCount: 0, existingCount: 0, resolvedCount: 0 };
const findings = current.map(({ path, finding }) => {
const fingerprint = findingFingerprint(finding, path);
const occurrence = (used.get(fingerprint) ?? 0) + 1;
used.set(fingerprint, occurrence);
const lifecycle = occurrence <= (known.get(fingerprint) ?? 0) ? 'EXISTING' : 'NEW';
if (lifecycle === 'EXISTING')
counts.existingCount++;
else
counts.newCount++;
return { ...finding, lifecycle };
});
const resolvedFindings = [];
if (baseline) {
for (const item of baseline.findings) {
const resolvedCount = item.count - (used.get(item.fingerprint) ?? 0);
if (resolvedCount > 0) {
counts.resolvedCount += resolvedCount;
resolvedFindings.push({ ...item, count: resolvedCount, lifecycle: 'RESOLVED' });
}
}
}
return { findings, resolvedFindings, counts };
}
async function checkedBaselinePath(userPath, cwd) {
if (!userPath || userPath.includes('\0') || isAbsolute(userPath) || win32.isAbsolute(userPath)) {
throw new Error('Baseline paths must be relative to the repository root.');
}
const path = userPath.replaceAll('\\', '/');
const parts = path.split('/');
if (parts.some((part) => part === '..'))
throw new Error('Baseline paths must not traverse directories.');
const clean = parts.filter((part) => part && part !== '.').join('/');
if (!clean)
throw new Error('Baseline path is invalid.');
const root = await realpath(cwd);
const absolute = resolve(root, clean);
const rel = relative(root, absolute);
if (!rel || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel))
throw new Error('Baseline paths must stay within the repository root.');
const relativeParts = rel.split(sep);
let cursor = root;
for (const part of relativeParts.slice(0, -1)) {
cursor = resolve(cursor, part);
const info = await lstat(cursor).catch(() => undefined);
if (!info?.isDirectory() || info.isSymbolicLink())
throw new Error('Baseline parent path must be an existing regular directory.');
}
return { absolute, relative: rel.split(sep).join('/') };
}
export async function readBaseline(userPath, cwd = process.cwd()) {
const checked = await checkedBaselinePath(userPath, cwd);
const info = await lstat(checked.absolute).catch(() => undefined);
if (!info?.isFile() || info.isSymbolicLink())
throw new Error('Baseline must be an existing regular file.');
if (info.size > MAX_BASELINE_BYTES)
throw new Error('Baseline exceeds the supported size limit.');
let parsed;
try {
parsed = JSON.parse(await readFile(checked.absolute, 'utf8'));
}
catch {
throw new Error('Baseline is not valid JSON.');
}
return validateBaseline(parsed);
}
export async function writeBaseline(userPath, baseline, cwd = process.cwd()) {
const checked = await checkedBaselinePath(userPath, cwd);
const body = `${JSON.stringify(validateBaseline(baseline), null, 2)}\n`;
if (Buffer.byteLength(body, 'utf8') > MAX_BASELINE_BYTES)
throw new Error('Baseline exceeds the supported size limit.');
let handle;
try {
handle = await open(checked.absolute, 'wx', 0o600);
await handle.writeFile(body, 'utf8');
await handle.close();
}
catch {
await handle?.close().catch(() => undefined);
throw new Error('Unable to create baseline; the destination may already exist or be unavailable.');
}
return checked.relative;
}
Loading
Loading