snapdrift 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +7 -2
- package/action.yml +10 -2
- package/actions/pr-diff/action.yml +92 -52
- package/actions/resolve-baseline/action.yml +36 -50
- package/actions/scope/action.yml +24 -1
- package/bin/snapdrift.mjs +6 -3
- package/docs/contracts.md +106 -7
- package/docs/integration-guide.md +48 -9
- package/docs/workflow-templates/refresh-baseline-on-merge.yml +1 -1
- package/lib/cli.mjs +125 -41
- package/lib/init-from-action.mjs +24 -13
- package/lib/outage-policy.mjs +35 -2
- package/lib/provider.mjs +20 -2
- package/lib/resolve-baseline-artifact.mjs +147 -0
- package/lib/resolve-baseline-paths.mjs +40 -0
- package/lib/snap-provider.mjs +896 -280
- package/package.json +13 -9
- package/types/visual-diff-types.d.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 - 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
- **Workspace packages now declare their runtime dependency boundaries** (#148).
|
|
8
|
+
`@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, and an isolated
|
|
9
|
+
packed-consumer gate imports every workspace package with only its declared
|
|
10
|
+
local dependency closure. Repository workspaces can no longer mask a missing
|
|
11
|
+
runtime dependency.
|
|
12
|
+
|
|
13
|
+
- **Published workspace TypeScript contracts resolve under modern module resolution** (#135, #129). All four `@snapdrift/*` packages expose declarations through their export maps, include signatures for every public runtime export, and correct stale declaration references. Strict declaration checks and isolated packed-package consumer fixtures now run in CI.
|
|
14
|
+
|
|
15
|
+
- **Snap transport now has enforceable request and operation deadlines** (#150). JSON requests
|
|
16
|
+
settle stalled headers or bodies within 30 seconds, binary exports within 120 seconds, and each
|
|
17
|
+
Snap operation shares a 10-minute budget across requests, retries, backoff, and polling. Aborted
|
|
18
|
+
or otherwise exhausted transport operations now reach the configured `onUnavailable` policy;
|
|
19
|
+
received 4xx responses remain immediate and non-retryable, while stalled 4xx diagnostics retain
|
|
20
|
+
their HTTP status with a timeout diagnostic.
|
|
21
|
+
- **Renamed files now retain both paths during route scoping** (#149). The
|
|
22
|
+
`scope` and `pr-diff` actions include a nonempty `previous_filename` for
|
|
23
|
+
GitHub rename records, deduplicate the combined paths, and preserve the
|
|
24
|
+
existing force-run and lookup-fallback behavior (with explicit route ids
|
|
25
|
+
still taking precedence in `pr-diff`). If GitHub returns its 3,000-record
|
|
26
|
+
cap, both actions run all configured routes with reason
|
|
27
|
+
`changed_files_truncated` rather than trust an incomplete list. A file moved
|
|
28
|
+
out of a watched directory can no longer silently skip its route.
|
|
29
|
+
- **Sanitized route-id collisions are rejected before writing screenshots** (#126). Local capture now fails before browser work, while Snap baseline export validates every source route after downloading the archive and before returning or writing screenshot files. Both paths share the same route-id sanitizer when distinct ids would map to one filename. Existing manifests with duplicate or flattening `imagePath` values also fail closed, including scoped comparisons; rename the conflicting ids and recapture the affected baseline. The published package floors move `@snapdrift/manifest` to 1.3.0 and `@snapdrift/adapter-fs` to 1.1.0 so consumers receive the shared helper.
|
|
30
|
+
- **Baseline lookup failures no longer masquerade as missing artifacts** (#147). The baseline
|
|
31
|
+
resolver now reports `found`, `missing`, or `error`, fails standalone resolution on API/network/
|
|
32
|
+
malformed responses, and keeps the PR wrapper's hosted Snap path available while failing local
|
|
33
|
+
comparisons and local fallbacks when GitHub cannot provide the baseline. The existing intentional
|
|
34
|
+
first-run skipped summary remains limited to a successful lookup with no non-expired artifact.
|
|
35
|
+
- **Hosted Snap diffs now fail closed on incomplete results** (#146). Hosted comparison now validates `purpose: "diff"`, the persisted run metadata, and expected route/viewport identities, polls until the expected capture set is available, verifies the returned run id and paths, and rejects duplicate, missing, pending, malformed, or invalid captures as `incomplete`. A diffed capture is counted only when both image objects and a finite numeric `diffPct` are present; older results without expected capture identities must be recaptured instead of being treated as clean.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Standalone baseline resolution now fails on lookup errors** (#147). Consumers orchestrating
|
|
40
|
+
`actions/resolve-baseline` should handle its `resolution-status` output: `missing` is the
|
|
41
|
+
intentional first-baseline case, while `error` means the GitHub lookup could not be trusted and
|
|
42
|
+
fails the action. The PR wrapper keeps hosted Snap comparisons available and reports the status
|
|
43
|
+
through `baseline-resolution-status`.
|
|
44
|
+
|
|
45
|
+
### Chore
|
|
46
|
+
|
|
47
|
+
- **The release workflow now fails when the root dispatcher's immutable pin predates wrapper changes** (#133). The Marketplace `action.yml` pins its inner `baseline` / `pr-diff` refs to an immutable commit SHA, but nothing detected when that pin went stale: between releases every change to `actions/baseline` or `actions/pr-diff` stayed invisible to consumers of the root action, so a release could ship bumped wrapper code while the dispatcher still executed old wrapper code at the pinned SHA. `npm run check:pin-stale` (new `scripts/check-pin-stale.mjs`) fails when the pinned SHA is older than the newest commit touching `actions/`. It runs in `publish.yml` (gated on a published release) rather than on every PR, because the pin is expected to lag wrapper changes on feature branches. The currently-stale pin in `action.yml` (which predated the #136 hosted-baseline fix) has been bumped to the latest `main` commit.
|
|
48
|
+
|
|
49
|
+
- **Workspace patch releases ship previously-unpublished fixes.** `@snapdrift/compare-core` 1.0.1 and `@snapdrift/adapter-report-md` 1.1.1 publish the #154 and #158 changes that landed after 1.0.0 / 1.1.0 were already on npm; without new versions those fixes would not have shipped. `@snapdrift/manifest` 1.3.0 and `@snapdrift/adapter-fs` 1.1.0 publish for the first time in this release. Workflow examples in the README, Integration Guide, and baseline template now reference `@v0.9.0`.
|
|
50
|
+
|
|
3
51
|
## 0.8.2 - 2026-08-24
|
|
4
52
|
|
|
5
53
|
### Fixes
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
|
|
|
48
48
|
|
|
49
49
|
```yaml
|
|
50
50
|
- name: SnapDrift Baseline
|
|
51
|
-
uses: ranacseruet/snapdrift@v0.
|
|
51
|
+
uses: ranacseruet/snapdrift@v0.9.0
|
|
52
52
|
with:
|
|
53
53
|
mode: baseline
|
|
54
54
|
repo-config-path: .github/snapdrift.json
|
|
@@ -60,7 +60,7 @@ With `provider: "snap"`, baseline publication is a complete, fail-closed snapsho
|
|
|
60
60
|
|
|
61
61
|
```yaml
|
|
62
62
|
- name: SnapDrift Report
|
|
63
|
-
uses: ranacseruet/snapdrift@v0.
|
|
63
|
+
uses: ranacseruet/snapdrift@v0.9.0
|
|
64
64
|
with:
|
|
65
65
|
mode: pr-diff
|
|
66
66
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -71,6 +71,11 @@ That is the full integration. See the [Integration Guide](docs/integration-guide
|
|
|
71
71
|
|
|
72
72
|
The root action dispatches on `mode`. The same pipelines are also published as standalone actions — `ranacseruet/snapdrift/actions/baseline` and `ranacseruet/snapdrift/actions/pr-diff` — along with the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions for custom orchestration.
|
|
73
73
|
|
|
74
|
+
The lower-level `resolve-baseline` action reports `resolution-status` as `found`, `missing`, or
|
|
75
|
+
`error`. A successful lookup with no non-expired artifact is `missing`; GitHub API, permission,
|
|
76
|
+
network, or malformed-response failures are `error` and fail the standalone action so custom
|
|
77
|
+
workflows do not mistake an unavailable baseline for a first-run skip.
|
|
78
|
+
|
|
74
79
|
## Local CLI
|
|
75
80
|
|
|
76
81
|
SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app. Use it during development to validate UI changes before pushing. Canonical hosted baseline publication is the exception: `provider: "snap"` baselines require the project default branch plus explicit CI publication metadata; GitHub Actions provides that metadata automatically.
|
package/action.yml
CHANGED
|
@@ -20,6 +20,11 @@ branding:
|
|
|
20
20
|
# version uses its upcoming `@vX.Y.Z` tag; the release process replaces it with the commit that holds
|
|
21
21
|
# the wrapper content. The contract test accepts either form and requires the declared version to
|
|
22
22
|
# match package.json.
|
|
23
|
+
#
|
|
24
|
+
# The release workflow also runs `npm run check:pin-stale`, which fails when the pinned SHA predates
|
|
25
|
+
# the newest commit touching `actions/`; that keeps a stale pin from silently serving old wrapper code
|
|
26
|
+
# to Marketplace consumers. The check is release-time only, since the pin is expected to lag wrapper
|
|
27
|
+
# changes on feature branches.
|
|
23
28
|
|
|
24
29
|
inputs:
|
|
25
30
|
mode:
|
|
@@ -120,6 +125,9 @@ outputs:
|
|
|
120
125
|
baseline-found:
|
|
121
126
|
description: Whether a usable baseline was found.
|
|
122
127
|
value: ${{ steps.pr-diff.outputs.baseline-found }}
|
|
128
|
+
baseline-resolution-status:
|
|
129
|
+
description: 'Baseline lookup status: found, missing, or error.'
|
|
130
|
+
value: ${{ steps.pr-diff.outputs.baseline-resolution-status }}
|
|
123
131
|
status:
|
|
124
132
|
description: Final SnapDrift status.
|
|
125
133
|
value: ${{ steps.pr-diff.outputs.status }}
|
|
@@ -147,7 +155,7 @@ runs:
|
|
|
147
155
|
- name: SnapDrift baseline
|
|
148
156
|
id: baseline
|
|
149
157
|
if: inputs.mode == 'baseline'
|
|
150
|
-
uses: ranacseruet/snapdrift/actions/baseline@
|
|
158
|
+
uses: ranacseruet/snapdrift/actions/baseline@331e62a7db4b9e743922667adacfe8fd93e51bb2 # v0.9.0
|
|
151
159
|
with:
|
|
152
160
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
153
161
|
route-ids: ${{ inputs.route-ids }}
|
|
@@ -157,7 +165,7 @@ runs:
|
|
|
157
165
|
- name: SnapDrift pull request diff
|
|
158
166
|
id: pr-diff
|
|
159
167
|
if: inputs.mode == 'pr-diff'
|
|
160
|
-
uses: ranacseruet/snapdrift/actions/pr-diff@
|
|
168
|
+
uses: ranacseruet/snapdrift/actions/pr-diff@331e62a7db4b9e743922667adacfe8fd93e51bb2 # v0.9.0
|
|
161
169
|
with:
|
|
162
170
|
github-token: ${{ inputs.github-token }}
|
|
163
171
|
repo-config-path: ${{ inputs.repo-config-path }}
|
|
@@ -71,6 +71,9 @@ outputs:
|
|
|
71
71
|
baseline-found:
|
|
72
72
|
description: Whether a usable baseline was found.
|
|
73
73
|
value: ${{ steps.finalize.outputs.baseline_found }}
|
|
74
|
+
baseline-resolution-status:
|
|
75
|
+
description: 'Baseline lookup status: found, missing, or error.'
|
|
76
|
+
value: ${{ steps.finalize.outputs.baseline_resolution_status }}
|
|
74
77
|
status:
|
|
75
78
|
description: Final SnapDrift status.
|
|
76
79
|
value: ${{ steps.finalize.outputs.status }}
|
|
@@ -182,10 +185,33 @@ runs:
|
|
|
182
185
|
per_page: 100
|
|
183
186
|
});
|
|
184
187
|
|
|
188
|
+
if (!Array.isArray(files)) {
|
|
189
|
+
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
190
|
+
}
|
|
191
|
+
for (const [index, file] of files.entries()) {
|
|
192
|
+
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
193
|
+
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (files.length >= 3000) {
|
|
197
|
+
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
198
|
+
setOutputs({
|
|
199
|
+
shouldRun: true,
|
|
200
|
+
reason: 'changed_files_truncated',
|
|
201
|
+
selectedRouteIds: allRouteIds
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
206
|
+
file.filename,
|
|
207
|
+
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
208
|
+
? [file.previous_filename]
|
|
209
|
+
: [])
|
|
210
|
+
]))];
|
|
185
211
|
setOutputs(
|
|
186
212
|
selectRoutesForChangedFiles(
|
|
187
213
|
config,
|
|
188
|
-
|
|
214
|
+
changedFiles
|
|
189
215
|
)
|
|
190
216
|
);
|
|
191
217
|
} catch (error) {
|
|
@@ -212,61 +238,40 @@ runs:
|
|
|
212
238
|
const { pathToFileURL } = require('url');
|
|
213
239
|
|
|
214
240
|
const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
|
|
215
|
-
const [owner, repo] = repository.split('/');
|
|
216
241
|
const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
|
|
242
|
+
const resolverModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/resolve-baseline-artifact.mjs');
|
|
217
243
|
const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
|
|
218
|
-
const {
|
|
219
|
-
|
|
244
|
+
const { resolveBaselineArtifact } = await import(pathToFileURL(resolverModulePath));
|
|
245
|
+
let artifactName = '';
|
|
246
|
+
|
|
247
|
+
const setLookupError = (error) => {
|
|
248
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
249
|
+
const message = `Unable to resolve the SnapDrift baseline artifact: ${detail}`;
|
|
250
|
+
core.setOutput('found', 'false');
|
|
251
|
+
core.setOutput('resolution_status', 'error');
|
|
252
|
+
core.setOutput('artifact_name', artifactName);
|
|
253
|
+
core.setOutput('message', message);
|
|
254
|
+
core.warning(message);
|
|
255
|
+
};
|
|
220
256
|
|
|
221
257
|
try {
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
258
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
259
|
+
artifactName = config.baselineArtifactName;
|
|
260
|
+
const result = await resolveBaselineArtifact({
|
|
261
|
+
github,
|
|
262
|
+
repository,
|
|
263
|
+
workflowId: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
|
|
226
264
|
branch: process.env.INPUT_BRANCH || 'main',
|
|
227
|
-
|
|
228
|
-
per_page: 20
|
|
265
|
+
artifactName
|
|
229
266
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
240
|
-
owner,
|
|
241
|
-
repo,
|
|
242
|
-
run_id: run.id,
|
|
243
|
-
per_page: 100
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
|
247
|
-
if (match) {
|
|
248
|
-
selectedRun = run;
|
|
249
|
-
selectedArtifact = match;
|
|
250
|
-
break;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!selectedRun || !selectedArtifact) {
|
|
255
|
-
core.setOutput('found', 'false');
|
|
256
|
-
core.setOutput('artifact_name', artifactName);
|
|
257
|
-
core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
core.setOutput('found', 'true');
|
|
262
|
-
core.setOutput('artifact_name', selectedArtifact.name);
|
|
263
|
-
core.setOutput('head_sha', selectedRun.head_sha);
|
|
264
|
-
core.setOutput('run_id', String(selectedRun.id));
|
|
265
|
-
core.setOutput('message', '');
|
|
267
|
+
core.setOutput('found', String(result.found));
|
|
268
|
+
core.setOutput('resolution_status', result.resolutionStatus);
|
|
269
|
+
core.setOutput('artifact_name', result.artifactName);
|
|
270
|
+
core.setOutput('head_sha', result.headSha || '');
|
|
271
|
+
core.setOutput('run_id', result.runId || '');
|
|
272
|
+
core.setOutput('message', result.message);
|
|
266
273
|
} catch (error) {
|
|
267
|
-
|
|
268
|
-
core.setOutput('artifact_name', artifactName);
|
|
269
|
-
core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
|
|
274
|
+
setLookupError(error);
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
|
|
@@ -320,6 +325,16 @@ runs:
|
|
|
320
325
|
await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
|
|
321
326
|
EOF
|
|
322
327
|
|
|
328
|
+
- id: baseline_resolution_guard
|
|
329
|
+
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.resolution_status == 'error' && steps.config.outputs.provider != 'snap'
|
|
330
|
+
shell: bash
|
|
331
|
+
env:
|
|
332
|
+
BASELINE_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
333
|
+
run: |
|
|
334
|
+
set -euo pipefail
|
|
335
|
+
printf 'SnapDrift baseline lookup failed. %s\n' "$BASELINE_MESSAGE" >&2
|
|
336
|
+
exit 1
|
|
337
|
+
|
|
323
338
|
- name: Install Playwright Chromium
|
|
324
339
|
if: steps.scope.outputs.should_run == 'true' && (steps.config.outputs.provider != 'snap' || steps.config.outputs.on_unavailable == 'fallback-local' || steps.config.outputs.snap_local_capture == 'true')
|
|
325
340
|
shell: bash
|
|
@@ -332,6 +347,8 @@ runs:
|
|
|
332
347
|
env:
|
|
333
348
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
334
349
|
ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
|
|
350
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
351
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
335
352
|
run: |
|
|
336
353
|
node --input-type=module <<'EOF'
|
|
337
354
|
import fs from 'node:fs/promises';
|
|
@@ -359,6 +376,8 @@ runs:
|
|
|
359
376
|
configPath: process.env.REPO_CONFIG_PATH || undefined,
|
|
360
377
|
routeIds
|
|
361
378
|
},
|
|
379
|
+
baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
|
|
380
|
+
baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
|
|
362
381
|
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n'),
|
|
363
382
|
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
|
|
364
383
|
});
|
|
@@ -414,6 +433,8 @@ runs:
|
|
|
414
433
|
CAPTURE_PROVIDER: ${{ steps.capture.outputs.provider }}
|
|
415
434
|
LOCAL_SCREENSHOTS: ${{ steps.capture.outputs.local_screenshots }}
|
|
416
435
|
BASELINE_FOUND: ${{ steps.baseline.outputs.found }}
|
|
436
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
437
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
417
438
|
run: |
|
|
418
439
|
node --input-type=module <<'EOF'
|
|
419
440
|
import fs from 'node:fs/promises';
|
|
@@ -464,10 +485,18 @@ runs:
|
|
|
464
485
|
captureOptions,
|
|
465
486
|
localScreenshots: process.env.LOCAL_SCREENSHOTS !== 'false',
|
|
466
487
|
baselineAvailable: process.env.BASELINE_FOUND === 'true',
|
|
488
|
+
baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
|
|
489
|
+
baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
|
|
467
490
|
onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n'),
|
|
468
491
|
onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — falling back to local provider.\n'),
|
|
469
492
|
onRecapture: () => process.stderr.write('[SnapDrift] The current capture was rendered by Snap, so it has no local screenshots — recapturing locally before diffing.\n'),
|
|
470
|
-
onBaselineUnavailable: () =>
|
|
493
|
+
onBaselineUnavailable: () => {
|
|
494
|
+
if (process.env.BASELINE_RESOLUTION_STATUS === 'error') {
|
|
495
|
+
process.stderr.write(`[SnapDrift] ${process.env.BASELINE_RESOLUTION_MESSAGE || 'The GitHub baseline lookup failed before local diff fallback.'}\n`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
process.stderr.write('[SnapDrift] No baseline artifact is available, so the local diff cannot run.\n');
|
|
499
|
+
}
|
|
471
500
|
});
|
|
472
501
|
|
|
473
502
|
// Every non-throwing outcome writes a summary. A wrapper run that ends
|
|
@@ -543,7 +572,7 @@ runs:
|
|
|
543
572
|
EOF
|
|
544
573
|
|
|
545
574
|
- id: skipped_baseline
|
|
546
|
-
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.
|
|
575
|
+
if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.resolution_status == 'missing' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
|
|
547
576
|
shell: bash
|
|
548
577
|
env:
|
|
549
578
|
MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
@@ -638,6 +667,8 @@ runs:
|
|
|
638
667
|
REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
|
|
639
668
|
MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
|
|
640
669
|
MAX_ERROR_ROWS: ${{ inputs.max-error-rows }}
|
|
670
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
|
|
671
|
+
BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
|
|
641
672
|
with:
|
|
642
673
|
github-token: ${{ inputs.github-token }}
|
|
643
674
|
script: |
|
|
@@ -649,6 +680,8 @@ runs:
|
|
|
649
680
|
const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
|
|
650
681
|
const iconUrl = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
|
|
651
682
|
const repoUrl = 'https://github.com/ranacseruet/snapdrift';
|
|
683
|
+
const prCommentModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
|
|
684
|
+
const { escapeMarkdown } = await import(pathToFileURL(prCommentModulePath));
|
|
652
685
|
|
|
653
686
|
const summaryPath = process.env.SUMMARY_PATH;
|
|
654
687
|
const issueNumber = Number(process.env.PR_NUMBER || context.payload.pull_request?.number || context.issue?.number || '');
|
|
@@ -659,7 +692,12 @@ runs:
|
|
|
659
692
|
|
|
660
693
|
if (!summaryPath || !fs.existsSync(summaryPath)) {
|
|
661
694
|
const runUrl = process.env.RUN_URL || '';
|
|
662
|
-
const
|
|
695
|
+
const lookupFailure = process.env.BASELINE_RESOLUTION_STATUS === 'error';
|
|
696
|
+
const heading = lookupFailure ? 'Baseline Lookup Failed' : 'Capture Failed';
|
|
697
|
+
const detail = escapeMarkdown(lookupFailure
|
|
698
|
+
? process.env.BASELINE_RESOLUTION_MESSAGE || 'The GitHub baseline lookup failed before comparison could run.'
|
|
699
|
+
: 'SnapDrift did not produce a summary. This usually means capture failed before the report could be assembled.');
|
|
700
|
+
const body = `${PR_COMMENT_MARKER}\n<img src="${iconUrl}" alt="SnapDrift" width="20" height="20" />\n\n### ⚠️ SnapDrift Report — ${heading}\n\n${detail}\n\n[View run](${runUrl}) for details.\n\n<div align="right"><sub>Powered by <a href="${repoUrl}">SnapDrift</a></sub></div>`;
|
|
663
701
|
await github.rest.issues.createComment({
|
|
664
702
|
owner: context.repo.owner,
|
|
665
703
|
repo: context.repo.repo,
|
|
@@ -761,6 +799,7 @@ runs:
|
|
|
761
799
|
SCOPE_REASON: ${{ steps.scope.outputs.reason }}
|
|
762
800
|
SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
|
|
763
801
|
BASELINE_FOUND: ${{ steps.baseline.outputs.found || 'false' }}
|
|
802
|
+
BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status || '' }}
|
|
764
803
|
STATUS: ${{ steps.compare.outputs.status || steps.capture.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
|
|
765
804
|
SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
|
|
766
805
|
MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
|
|
@@ -771,6 +810,7 @@ runs:
|
|
|
771
810
|
echo "scope_reason=${SCOPE_REASON:-}"
|
|
772
811
|
echo "selected_route_ids=${SELECTED_ROUTE_IDS:-}"
|
|
773
812
|
echo "baseline_found=${BASELINE_FOUND:-false}"
|
|
813
|
+
echo "baseline_resolution_status=${BASELINE_RESOLUTION_STATUS:-}"
|
|
774
814
|
echo "status=${STATUS:-}"
|
|
775
815
|
echo "summary_path=${SUMMARY_PATH:-}"
|
|
776
816
|
echo "markdown_path=${MARKDOWN_PATH:-}"
|
|
@@ -38,6 +38,9 @@ outputs:
|
|
|
38
38
|
found:
|
|
39
39
|
description: Whether a non-expired baseline was found.
|
|
40
40
|
value: ${{ steps.resolve.outputs.found }}
|
|
41
|
+
resolution-status:
|
|
42
|
+
description: 'Baseline lookup status: found, missing, or error.'
|
|
43
|
+
value: ${{ steps.resolve.outputs.resolution_status }}
|
|
41
44
|
artifact-name:
|
|
42
45
|
description: Resolved artifact name.
|
|
43
46
|
value: ${{ steps.resolve.outputs.artifact_name }}
|
|
@@ -48,7 +51,7 @@ outputs:
|
|
|
48
51
|
description: Workflow run id that produced the baseline.
|
|
49
52
|
value: ${{ steps.resolve.outputs.run_id }}
|
|
50
53
|
message:
|
|
51
|
-
description:
|
|
54
|
+
description: Resolution message for a missing or failed lookup.
|
|
52
55
|
value: ${{ steps.resolve.outputs.message }}
|
|
53
56
|
results-file:
|
|
54
57
|
description: Path to the downloaded baseline results JSON.
|
|
@@ -81,61 +84,40 @@ runs:
|
|
|
81
84
|
const { pathToFileURL } = require('url');
|
|
82
85
|
|
|
83
86
|
const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
|
|
84
|
-
const [owner, repo] = repository.split('/');
|
|
85
87
|
const modulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'snapdrift-config.mjs');
|
|
88
|
+
const resolverModulePath = path.resolve(process.env.GITHUB_WORKSPACE, process.env.GITHUB_ACTION_PATH, '..', '..', 'lib', 'resolve-baseline-artifact.mjs');
|
|
86
89
|
const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
|
|
87
|
-
const {
|
|
88
|
-
|
|
90
|
+
const { resolveBaselineArtifact } = await import(pathToFileURL(resolverModulePath));
|
|
91
|
+
let artifactName = process.env.INPUT_ARTIFACT_NAME || '';
|
|
92
|
+
|
|
93
|
+
const setLookupError = (error) => {
|
|
94
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
95
|
+
const message = `Unable to resolve the SnapDrift baseline artifact: ${detail}`;
|
|
96
|
+
core.setOutput('found', 'false');
|
|
97
|
+
core.setOutput('resolution_status', 'error');
|
|
98
|
+
core.setOutput('artifact_name', artifactName);
|
|
99
|
+
core.setOutput('message', message);
|
|
100
|
+
core.setFailed(message);
|
|
101
|
+
};
|
|
89
102
|
|
|
90
103
|
try {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
|
|
105
|
+
artifactName = process.env.INPUT_ARTIFACT_NAME || config.baselineArtifactName;
|
|
106
|
+
const result = await resolveBaselineArtifact({
|
|
107
|
+
github,
|
|
108
|
+
repository,
|
|
109
|
+
workflowId: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
|
|
95
110
|
branch: process.env.INPUT_BRANCH || 'main',
|
|
96
|
-
|
|
97
|
-
per_page: 20
|
|
111
|
+
artifactName
|
|
98
112
|
});
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
|
109
|
-
owner,
|
|
110
|
-
repo,
|
|
111
|
-
run_id: run.id,
|
|
112
|
-
per_page: 100
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
|
|
116
|
-
if (match) {
|
|
117
|
-
selectedRun = run;
|
|
118
|
-
selectedArtifact = match;
|
|
119
|
-
break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!selectedRun || !selectedArtifact) {
|
|
124
|
-
core.setOutput('found', 'false');
|
|
125
|
-
core.setOutput('artifact_name', artifactName);
|
|
126
|
-
core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
core.setOutput('found', 'true');
|
|
131
|
-
core.setOutput('artifact_name', selectedArtifact.name);
|
|
132
|
-
core.setOutput('head_sha', selectedRun.head_sha);
|
|
133
|
-
core.setOutput('run_id', String(selectedRun.id));
|
|
134
|
-
core.setOutput('message', '');
|
|
113
|
+
core.setOutput('found', String(result.found));
|
|
114
|
+
core.setOutput('resolution_status', result.resolutionStatus);
|
|
115
|
+
core.setOutput('artifact_name', result.artifactName);
|
|
116
|
+
core.setOutput('head_sha', result.headSha || '');
|
|
117
|
+
core.setOutput('run_id', result.runId || '');
|
|
118
|
+
core.setOutput('message', result.message);
|
|
135
119
|
} catch (error) {
|
|
136
|
-
|
|
137
|
-
core.setOutput('artifact_name', artifactName);
|
|
138
|
-
core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
|
|
120
|
+
setLookupError(error);
|
|
139
121
|
}
|
|
140
122
|
|
|
141
123
|
- if: steps.resolve.outputs.found == 'true'
|
|
@@ -164,6 +146,10 @@ runs:
|
|
|
164
146
|
{
|
|
165
147
|
echo "results_file=$RESULTS_FILE"
|
|
166
148
|
echo "manifest_file=$MANIFEST_FILE"
|
|
167
|
-
|
|
149
|
+
case "$DOWNLOAD_PATH" in
|
|
150
|
+
/*) RUN_DIR="$DOWNLOAD_PATH" ;;
|
|
151
|
+
*) RUN_DIR="${GITHUB_WORKSPACE}/${DOWNLOAD_PATH}" ;;
|
|
152
|
+
esac
|
|
153
|
+
echo "run_dir=$RUN_DIR"
|
|
168
154
|
echo "screenshots_dir=$(dirname "$MANIFEST_FILE")/screenshots"
|
|
169
155
|
} >> "$GITHUB_OUTPUT"
|
package/actions/scope/action.yml
CHANGED
|
@@ -105,9 +105,32 @@ runs:
|
|
|
105
105
|
per_page: 100
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
if (!Array.isArray(files)) {
|
|
109
|
+
throw new Error('Malformed GitHub changed-file response: expected an array of file records.');
|
|
110
|
+
}
|
|
111
|
+
for (const [index, file] of files.entries()) {
|
|
112
|
+
if (!file || typeof file !== 'object' || typeof file.filename !== 'string' || file.filename.length === 0) {
|
|
113
|
+
throw new Error(`Malformed GitHub changed-file response: file record ${index} must include a non-empty string filename.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (files.length >= 3000) {
|
|
117
|
+
core.warning('GitHub returned the maximum 3000 changed-file records; the list may be truncated, so running all configured captures.');
|
|
118
|
+
setOutputs({
|
|
119
|
+
shouldRun: true,
|
|
120
|
+
reason: 'changed_files_truncated',
|
|
121
|
+
selectedRouteIds: allRouteIds
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const changedFiles = [...new Set(files.flatMap((file) => [
|
|
126
|
+
file.filename,
|
|
127
|
+
...(file.status === 'renamed' && typeof file.previous_filename === 'string' && file.previous_filename
|
|
128
|
+
? [file.previous_filename]
|
|
129
|
+
: [])
|
|
130
|
+
]))];
|
|
108
131
|
const scope = selectRoutesForChangedFiles(
|
|
109
132
|
config,
|
|
110
|
-
|
|
133
|
+
changedFiles
|
|
111
134
|
);
|
|
112
135
|
setOutputs(scope);
|
|
113
136
|
} catch (error) {
|
package/bin/snapdrift.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-check
|
|
3
3
|
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
5
|
|
|
6
6
|
// Resolve cli.mjs relative to this file so the bin entry works regardless of
|
|
7
|
-
// where the consumer installs the package.
|
|
8
|
-
|
|
7
|
+
// where the consumer installs the package. Use fileURLToPath(new URL(...)) rather
|
|
8
|
+
// than URL.pathname so paths containing spaces or Windows drive letters survive
|
|
9
|
+
// the conversion (URL.pathname percent-encodes them, then pathToFileURL re-encodes
|
|
10
|
+
// the result, producing a path that does not exist).
|
|
11
|
+
const cliPath = fileURLToPath(new URL('../lib/cli.mjs', import.meta.url));
|
|
9
12
|
const { main } = await import(pathToFileURL(cliPath).href);
|
|
10
13
|
|
|
11
14
|
main(process.argv).catch((error) => {
|