snapdrift 0.8.0 → 0.8.2

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 - 2026-08-24
4
+
5
+ ### Fixes
6
+
7
+ - **Hosted baseline publication is now complete and fail-closed** — a scoped `route-ids` / `--routes` baseline run could publish only the changed routes and replace a larger accepted baseline, causing every omitted route to appear missing on the next PR (i2Dev-com/snap#720). `provider: "snap"` baseline capture now requires every configured route (local baselines, non-publishing captures, and hosted PR diffs remain scopeable), records the exact configured/selected route and viewport set plus one resolved branch/commit and publication workflow identity/sequence, and publishes only when the source run contains a successful stored capture for every expected identity. Complete requests send `publicationMode: "complete"` and `sourceRunId`, reuse the run's `branch` / `prHeadSha` as publication `refBranch` / `refSha`, and derive the baseline id deterministically from the run id so retries are stable. GitHub Actions supplies publication metadata automatically; other CI systems may set the explicit publication metadata variables. Missing, failed, duplicate, unexpected, or stale captures abort without publishing.
8
+ - **Snap outage handling is consistent across the CLI and both wrapper actions** — `warn-and-skip` and `fallback-local` were re-implemented independently in `lib/cli.mjs`, `actions/pr-diff` and `actions/baseline`, and the copies had drifted. `lib/outage-policy.mjs` now holds the single implementation (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`) that all three call, and the following are fixed as a result (#125):
9
+ - A `fallback-local` capture in `actions/pr-diff` captured locally but still emitted `provider=snap`, so the compare step rebuilt `SnapProvider` and handed it local results carrying no run id. The steps now receive the *effective* provider.
10
+ - A `fallback-local` diff swapped only the diff provider. A capture Snap had rendered server-side has a manifest but no PNGs on the runner, so the local pixel engine produced a dimension-only result rather than a comparison. The routes are now recaptured locally first, and the recaptured artifacts are what get staged and uploaded. When no baseline artifact was resolved, the run reports `missing_main_baseline_artifact` instead of crashing the local diff on a missing results file.
11
+ - `warn-and-skip` exited the wrapper without writing the documented skipped summary, so the PR comment fell through to its "Capture Failed" body. Capture-time and diff-time skips now write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, and exit 0. `diff.mode` is not enforced against a skipped summary.
12
+ - `actions/baseline` applied no policy at all to `publishBaseline()`. `warn-and-skip` now exits 0 without an artifact, and `fallback-local` captures locally and stages/uploads that bundle, so the run still leaves a usable baseline.
13
+ - `snapdrift diff` let a capture-time `SnapSkipError` reach the CLI entry point, which exits 1 — the opposite of what `warn-and-skip` is configured to do.
14
+ - **`shouldFailDriftCheck` no longer throws on a skipped summary** — it assumed every summary carried the full diff shape, so the final branch dereferenced `summaryData.errors.length` on `undefined` for the `{ status, reason, message, selectedRoutes }` shape a skipped run writes. `actions/enforce` defaults `summary-path` to exactly where those summaries land, so anyone orchestrating with the low-level actions rather than `actions/pr-diff` got a raw `TypeError` instead of a clean pass whenever a run was skipped for scope, a missing baseline, or a Snap outage. A summary with `status: "skipped"` or no `diff.mode` now returns `false` before any mode branch, and both `shouldFailDriftCheck` and `determineDriftStatus` default their array and counter reads (#132).
15
+
16
+ ## 0.8.1 - 2026-08-06
17
+
18
+ ### Fixes
19
+
20
+ - **The root action can actually be published to the Marketplace** — its description was 130 characters, and GitHub rejects the listing at 125 or more, so ticking "Publish this Action to the GitHub Marketplace" on the `v0.8.0` release failed validation. Shortened to 115 characters, leading with "visual regression" so the listing matches what people search for. `npm run validate:actions` and `tests/marketplace-metadata.test.js` now enforce the limit against the root action; the sub-action descriptions are unaffected, since the rule only applies to the published entry point.
21
+
3
22
  ## 0.8.0 - 2026-08-06
4
23
 
5
24
  ### Features
package/README.md CHANGED
@@ -48,17 +48,19 @@ 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.8.0
51
+ uses: ranacseruet/snapdrift@v0.8.2
52
52
  with:
53
53
  mode: baseline
54
54
  repo-config-path: .github/snapdrift.json
55
55
  ```
56
56
 
57
+ With `provider: "snap"`, baseline publication is a complete, fail-closed snapshot: it must run from the project's default branch and capture every configured route. Snap records the publication workflow identity plus a monotonic sequence so the API can reject stale overlapping publishers. GitHub Actions supplies these values automatically; other CI systems can provide `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Route scoping remains available for pull request diffs, non-publishing `snapdrift capture`, and local-provider baselines.
58
+
57
59
  **3. Run SnapDrift on pull requests:**
58
60
 
59
61
  ```yaml
60
62
  - name: SnapDrift Report
61
- uses: ranacseruet/snapdrift@v0.8.0
63
+ uses: ranacseruet/snapdrift@v0.8.2
62
64
  with:
63
65
  mode: pr-diff
64
66
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -71,10 +73,10 @@ The root action dispatches on `mode`. The same pipelines are also published as s
71
73
 
72
74
  ## Local CLI
73
75
 
74
- SnapDrift ships a `snapdrift` CLI for running captures, diffs, migrations, and config initialization locally against a running app — no GitHub Actions required. Use it during development to validate UI changes before pushing.
76
+ 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.
75
77
 
76
78
  ```bash
77
- # Establish a baseline — for provider "snap" this also publishes it
79
+ # Establish a local-provider baseline
78
80
  snapdrift baseline
79
81
 
80
82
  # Capture screenshots without publishing anything
@@ -111,6 +113,7 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
111
113
  - One global `diff.threshold`
112
114
  - Dimension shifts are reported separately from pixel drift
113
115
  - Local provider writes artifacts to the runner filesystem; for a hosted backend with a dashboard and a shared baseline store, configure `provider: "snap"` (see the [Integration Guide](docs/integration-guide.md#hosted-snap-provider))
116
+ - Hosted Snap baselines publish only from the default branch and always include the complete configured route set
114
117
 
115
118
  ## Outgrew GitHub artifacts?
116
119
 
package/action.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: SnapDrift
2
- description: Capture, compare, and report UI drift for Playwright-based apps — baseline publishing and pull request visual diffs in one action.
2
+ description: Visual regression for Playwright apps — capture routes, diff against a baseline, and report drift on pull requests.
3
3
  author: CodeSamplez
4
4
 
5
5
  branding:
@@ -15,10 +15,11 @@ branding:
15
15
  # action's own directory. `tests/marketplace-metadata.test.js` fails the build if those pins drift
16
16
  # from the root `package.json` version.
17
17
  #
18
- # The pins are commit SHAs carrying the version in a trailing comment, so that a consumer pinning
19
- # this action to a SHA gets an immutable chain rather than a mutable tag one level down. The SHA is
20
- # the commit that introduced the release's wrapper actions; the release commit cannot reference its
21
- # own SHA. The test accepts a plain `@vX.Y.Z` tag too, for unreleased refs.
18
+ # Released versions use commit SHAs, so a consumer pinning this action to a SHA gets an immutable
19
+ # chain rather than a mutable tag one level down. Before that release commit exists, an unreleased
20
+ # version uses its upcoming `@vX.Y.Z` tag; the release process replaces it with the commit that holds
21
+ # the wrapper content. The contract test accepts either form and requires the declared version to
22
+ # match package.json.
22
23
 
23
24
  inputs:
24
25
  mode:
@@ -30,7 +31,7 @@ inputs:
30
31
  required: false
31
32
  default: .github/snapdrift.json
32
33
  route-ids:
33
- description: Optional comma-separated route ids. In `pr-diff` mode, providing these skips changed-file scope detection.
34
+ description: Optional comma-separated route ids for PR diffs or local baselines. Hosted baselines must capture every configured route.
34
35
  required: false
35
36
  default: ''
36
37
  artifact-retention-days:
@@ -146,7 +147,7 @@ runs:
146
147
  - name: SnapDrift baseline
147
148
  id: baseline
148
149
  if: inputs.mode == 'baseline'
149
- uses: ranacseruet/snapdrift/actions/baseline@539953ab07065be805be393d69bd3c9b6d53cf6e # v0.8.0
150
+ uses: ranacseruet/snapdrift/actions/baseline@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
150
151
  with:
151
152
  repo-config-path: ${{ inputs.repo-config-path }}
152
153
  route-ids: ${{ inputs.route-ids }}
@@ -156,7 +157,7 @@ runs:
156
157
  - name: SnapDrift pull request diff
157
158
  id: pr-diff
158
159
  if: inputs.mode == 'pr-diff'
159
- uses: ranacseruet/snapdrift/actions/pr-diff@539953ab07065be805be393d69bd3c9b6d53cf6e # v0.8.0
160
+ uses: ranacseruet/snapdrift/actions/pr-diff@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
160
161
  with:
161
162
  github-token: ${{ inputs.github-token }}
162
163
  repo-config-path: ${{ inputs.repo-config-path }}
@@ -11,7 +11,7 @@ inputs:
11
11
  required: false
12
12
  default: .github/snapdrift.json
13
13
  route-ids:
14
- description: Optional comma-separated route ids to capture.
14
+ description: Optional comma-separated route ids for local-provider baselines. Hosted Snap baselines must capture every configured route.
15
15
  required: false
16
16
  default: ''
17
17
  artifact-retention-days:
@@ -32,16 +32,16 @@ outputs:
32
32
  value: ${{ steps.stage.outputs.bundle_dir }}
33
33
  results-file:
34
34
  description: Absolute path to the generated baseline results file.
35
- value: ${{ steps.capture.outputs.results_file }}
35
+ value: ${{ steps.publish.outputs.results_file || steps.capture.outputs.results_file }}
36
36
  manifest-file:
37
37
  description: Absolute path to the generated baseline manifest file.
38
- value: ${{ steps.capture.outputs.manifest_file }}
38
+ value: ${{ steps.publish.outputs.manifest_file || steps.capture.outputs.manifest_file }}
39
39
  screenshots-root:
40
40
  description: Absolute path to the generated screenshots root.
41
- value: ${{ steps.capture.outputs.screenshots_root }}
41
+ value: ${{ steps.publish.outputs.screenshots_root || steps.capture.outputs.screenshots_root }}
42
42
  selected-route-ids:
43
43
  description: Comma-separated route ids captured in this run.
44
- value: ${{ steps.capture.outputs.selected_route_ids }}
44
+ value: ${{ steps.publish.outputs.selected_route_ids || steps.capture.outputs.selected_route_ids }}
45
45
 
46
46
  runs:
47
47
  using: composite
@@ -103,49 +103,37 @@ runs:
103
103
 
104
104
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
105
105
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
106
- const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
106
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
107
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
107
108
  const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
109
+ const { captureWithPolicy } = await import(pathToFileURL(policyModulePath));
108
110
 
109
111
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
110
- const provider = createProvider(config.provider || 'local', config);
112
+ const providerName = config.provider || 'local';
111
113
 
112
- let result;
113
- try {
114
- result = await provider.capture({
114
+ const captured = await captureWithPolicy({
115
+ provider: createProvider(providerName, config),
116
+ providerName,
117
+ config,
118
+ captureOptions: {
115
119
  configPath: process.env.REPO_CONFIG_PATH || undefined,
116
120
  routeIds: splitCommaList(process.env.ROUTE_IDS),
117
121
  purpose: 'baseline'
118
- });
119
- } catch (err) {
120
- if (err instanceof SnapSkipError) {
121
- process.stderr.write(`[SnapDrift] Snap unavailable — skipping baseline capture (warn-and-skip).\n`);
122
- await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
123
- process.exit(0);
124
- }
125
- if (err instanceof SnapFallbackError) {
126
- process.stderr.write(`[SnapDrift] Snap unavailable — falling back to local provider.\n`);
127
- const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
128
- result = await cp2('local', config).capture({
129
- configPath: process.env.REPO_CONFIG_PATH || undefined,
130
- routeIds: splitCommaList(process.env.ROUTE_IDS)
131
- });
132
- // Report as local so publish is skipped and stage/upload run instead
133
- const outputs = [
134
- `provider=local`,
135
- `results_file=${result.resultsPath}`,
136
- `manifest_file=${result.manifestPath}`,
137
- `screenshots_root=${result.screenshotsRoot}`,
138
- `selected_route_ids=${result.selectedRouteIds.join(',')}`
139
- ].join('\n') + '\n';
140
- await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
141
- process.exit(0);
142
- } else {
143
- throw err;
144
- }
122
+ },
123
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping baseline capture (warn-and-skip).\n'),
124
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
125
+ });
126
+
127
+ if (captured.outcome === 'skipped') {
128
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
129
+ process.exit(0);
145
130
  }
146
131
 
132
+ const result = captured.result;
147
133
  const outputs = [
148
- `provider=${config.provider || 'local'}`,
134
+ // The effective provider: reporting "local" after a fallback-local
135
+ // capture is what makes publish skip and stage/upload run instead.
136
+ `provider=${captured.providerName}`,
149
137
  `results_file=${result.resultsPath}`,
150
138
  `manifest_file=${result.manifestPath}`,
151
139
  `screenshots_root=${result.screenshotsRoot}`,
@@ -160,34 +148,67 @@ runs:
160
148
  shell: bash
161
149
  env:
162
150
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
151
+ ROUTE_IDS: ${{ inputs.route-ids }}
163
152
  RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
164
153
  run: |
165
154
  node --input-type=module <<'EOF'
155
+ import fs from 'node:fs/promises';
166
156
  import path from 'node:path';
167
157
  import { pathToFileURL } from 'node:url';
168
158
 
169
159
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
170
160
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
161
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
171
162
  const { createProvider } = await import(pathToFileURL(providerModulePath));
172
- const { loadSnapdriftConfig } = await import(pathToFileURL(configModulePath));
163
+ const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
164
+ const { publishBaselineWithPolicy } = await import(pathToFileURL(policyModulePath));
173
165
 
174
166
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
175
- const provider = createProvider('snap', config);
176
167
 
177
- await provider.publishBaseline({
178
- resultsPath: process.env.RESULTS_PATH || undefined
168
+ // Snap can go down after the capture submission succeeds — while polling
169
+ // the render or posting the baseline. onUnavailable has to cover that
170
+ // window too, or warn-and-skip still fails the build and fallback-local
171
+ // still leaves the repo with no baseline artifact. See #125.
172
+ const published = await publishBaselineWithPolicy({
173
+ provider: createProvider('snap', config),
174
+ publishOptions: { resultsPath: process.env.RESULTS_PATH || undefined },
175
+ captureOptions: {
176
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
177
+ routeIds: splitCommaList(process.env.ROUTE_IDS),
178
+ purpose: 'baseline'
179
+ },
180
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping baseline publish (warn-and-skip).\n'),
181
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during publish — capturing locally so the run still produces a baseline artifact.\n')
179
182
  });
180
183
 
184
+ if (published.outcome === 'skipped') {
185
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
186
+ process.exit(0);
187
+ }
188
+
189
+ if (published.outcome === 'fell-back') {
190
+ const recapture = published.recapture;
191
+ const outputs = [
192
+ `provider=local`,
193
+ `results_file=${recapture.resultsPath}`,
194
+ `manifest_file=${recapture.manifestPath}`,
195
+ `screenshots_root=${recapture.screenshotsRoot}`,
196
+ `selected_route_ids=${recapture.selectedRouteIds.join(',')}`
197
+ ].join('\n') + '\n';
198
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
199
+ process.exit(0);
200
+ }
201
+
181
202
  process.stdout.write('[SnapDrift] Snap baseline published successfully.\n');
182
203
  EOF
183
204
 
184
205
  - id: stage
185
- if: steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
206
+ if: (steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped') || steps.publish.outputs.provider == 'local'
186
207
  shell: bash
187
208
  env:
188
- RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
189
- MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
190
- SCREENSHOTS_DIR: ${{ steps.capture.outputs.screenshots_root }}/screenshots
209
+ RESULTS_PATH: ${{ steps.publish.outputs.results_file || steps.capture.outputs.results_file }}
210
+ MANIFEST_PATH: ${{ steps.publish.outputs.manifest_file || steps.capture.outputs.manifest_file }}
211
+ SCREENSHOTS_DIR: ${{ steps.publish.outputs.screenshots_root || steps.capture.outputs.screenshots_root }}/screenshots
191
212
  run: |
192
213
  node --input-type=module <<'EOF'
193
214
  import fs from 'node:fs/promises';
@@ -207,7 +228,7 @@ runs:
207
228
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
208
229
  EOF
209
230
 
210
- - if: inputs.upload-artifact == 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
231
+ - if: inputs.upload-artifact == 'true' && ((steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped') || steps.publish.outputs.provider == 'local')
211
232
  uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
212
233
  with:
213
234
  name: ${{ steps.config.outputs.artifact_name }}
@@ -340,38 +340,54 @@ runs:
340
340
 
341
341
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
342
342
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
343
- const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
343
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
344
+ const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
345
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
344
346
  const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
347
+ const { captureWithPolicy, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
348
+ const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
345
349
 
346
350
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
347
- const provider = createProvider(config.provider || 'local', config);
348
-
349
- let result;
350
- try {
351
- result = await provider.capture({
351
+ const providerName = config.provider || 'local';
352
+ const routeIds = splitCommaList(process.env.ROUTE_IDS);
353
+
354
+ const captured = await captureWithPolicy({
355
+ provider: createProvider(providerName, config),
356
+ providerName,
357
+ config,
358
+ captureOptions: {
352
359
  configPath: process.env.REPO_CONFIG_PATH || undefined,
353
- routeIds: splitCommaList(process.env.ROUTE_IDS)
360
+ routeIds
361
+ },
362
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n'),
363
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
364
+ });
365
+
366
+ if (captured.outcome === 'skipped') {
367
+ // warn-and-skip is documented as "write a skipped summary and exit 0".
368
+ // Without the summary the report step falls through to its
369
+ // "Capture Failed" body, which is not what happened. See #125.
370
+ const skipped = await writeDriftSummary({
371
+ reason: SNAP_UNAVAILABLE_REASON,
372
+ selectedRouteIds: routeIds
354
373
  });
355
- } catch (err) {
356
- if (err instanceof SnapSkipError) {
357
- process.stderr.write(`[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n`);
358
- await fs.appendFile(process.env.GITHUB_OUTPUT, `provider=skipped\n`);
359
- process.exit(0);
360
- }
361
- if (err instanceof SnapFallbackError) {
362
- process.stderr.write(`[SnapDrift] Snap unavailable — falling back to local provider.\n`);
363
- const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
364
- result = await cp2('local', config).capture({
365
- configPath: process.env.REPO_CONFIG_PATH || undefined,
366
- routeIds: splitCommaList(process.env.ROUTE_IDS)
367
- });
368
- } else {
369
- throw err;
370
- }
374
+ const skipOutputs = [
375
+ `provider=skipped`,
376
+ `summary_path=${skipped.summaryPath}`,
377
+ `markdown_path=${skipped.markdownPath}`,
378
+ `status=${skipped.summary.status || ''}`
379
+ ].join('\n') + '\n';
380
+ await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
381
+ process.exit(0);
371
382
  }
372
383
 
384
+ const result = captured.result;
373
385
  const outputs = [
374
- `provider=${config.provider || 'local'}`,
386
+ // The *effective* provider, not the configured one: after a
387
+ // fallback-local capture the compare step must not rebuild
388
+ // SnapProvider around local results that carry no run id.
389
+ `provider=${captured.providerName}`,
390
+ `local_screenshots=${captured.localScreenshots ? 'true' : 'false'}`,
375
391
  `results_file=${result.resultsPath}`,
376
392
  `manifest_file=${result.manifestPath}`,
377
393
  `screenshots_root=${result.screenshotsRoot}`,
@@ -394,6 +410,10 @@ runs:
394
410
  BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
395
411
  CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
396
412
  CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
413
+ CURRENT_SCREENSHOTS_ROOT: ${{ steps.capture.outputs.screenshots_root }}
414
+ CAPTURE_PROVIDER: ${{ steps.capture.outputs.provider }}
415
+ LOCAL_SCREENSHOTS: ${{ steps.capture.outputs.local_screenshots }}
416
+ BASELINE_FOUND: ${{ steps.baseline.outputs.found }}
397
417
  run: |
398
418
  node --input-type=module <<'EOF'
399
419
  import fs from 'node:fs/promises';
@@ -402,45 +422,75 @@ runs:
402
422
 
403
423
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
404
424
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
405
- const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
425
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
426
+ const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
427
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
406
428
  const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
429
+ const { diffWithPolicy, MISSING_BASELINE_REASON, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
430
+ const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
407
431
 
408
432
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
409
- const provider = createProvider(config.provider || 'local', config);
433
+ // The capture step reports the provider that actually ran, which is
434
+ // "local" once a fallback-local capture has happened.
435
+ const providerName = process.env.CAPTURE_PROVIDER || config.provider || 'local';
436
+ const routeIds = splitCommaList(process.env.ROUTE_IDS);
410
437
 
411
438
  const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
412
439
  const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
413
440
  const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
414
441
 
442
+ const captureOptions = {
443
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
444
+ routeIds
445
+ };
446
+
415
447
  const diffOptions = {
416
448
  configPath: process.env.REPO_CONFIG_PATH || undefined,
417
449
  baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
418
450
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
419
451
  baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
420
- routeIds: splitCommaList(process.env.ROUTE_IDS),
452
+ routeIds,
421
453
  baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
422
454
  baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
423
455
  currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
424
- currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined
456
+ currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
457
+ currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined
425
458
  };
426
459
 
427
- let diffResult;
428
- try {
429
- diffResult = await provider.diff(diffOptions);
430
- } catch (err) {
431
- if (err instanceof SnapSkipError) {
432
- process.stderr.write(`[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n`);
433
- process.exit(0);
434
- }
435
- if (err instanceof SnapFallbackError) {
436
- process.stderr.write(`[SnapDrift] Snap unavailable during diff — falling back to local provider.\n`);
437
- const { createProvider: cp2 } = await import(pathToFileURL(providerModulePath));
438
- diffResult = await cp2('local', config).diff(diffOptions);
439
- } else {
440
- throw err;
441
- }
460
+ const diffed = await diffWithPolicy({
461
+ provider: createProvider(providerName, config),
462
+ providerName,
463
+ diffOptions,
464
+ captureOptions,
465
+ localScreenshots: process.env.LOCAL_SCREENSHOTS !== 'false',
466
+ baselineAvailable: process.env.BASELINE_FOUND === 'true',
467
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n'),
468
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — falling back to local provider.\n'),
469
+ 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: () => process.stderr.write('[SnapDrift] No baseline artifact is available, so the local diff cannot run.\n')
471
+ });
472
+
473
+ // Every non-throwing outcome writes a summary. A wrapper run that ends
474
+ // without one makes the report step claim "Capture Failed". See #125.
475
+ if (diffed.outcome !== 'diffed') {
476
+ const skipped = await writeDriftSummary({
477
+ reason: diffed.outcome === 'skipped' ? SNAP_UNAVAILABLE_REASON : MISSING_BASELINE_REASON,
478
+ baselineAvailable: diffed.outcome === 'skipped' ? undefined : false,
479
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
480
+ selectedRouteIds: routeIds,
481
+ outDir: resolvedOutDir
482
+ });
483
+ const skipOutputs = [
484
+ `skipped=true`,
485
+ `summary_path=${skipped.summaryPath}`,
486
+ `markdown_path=${skipped.markdownPath}`,
487
+ `status=${skipped.summary.status || ''}`
488
+ ].join('\n') + '\n';
489
+ await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
490
+ process.exit(0);
442
491
  }
443
- const { summary, markdown } = diffResult;
492
+
493
+ const { summary, markdown } = diffed.result;
444
494
 
445
495
  await fs.mkdir(resolvedOutDir, { recursive: true });
446
496
  await Promise.all([
@@ -449,9 +499,16 @@ runs:
449
499
  ]);
450
500
 
451
501
  const outputs = [
502
+ `skipped=false`,
452
503
  `summary_path=${resolvedSummaryPath}`,
453
504
  `markdown_path=${resolvedMarkdownPath}`,
454
- `status=${summary.status || ''}`
505
+ `status=${summary.status || ''}`,
506
+ // A fallback-local diff of a remote Snap capture recaptures on the
507
+ // runner; the staged bundle has to carry those PNGs, not the empty
508
+ // placeholder paths the Snap capture step emitted.
509
+ `current_results_file=${diffed.recapture?.resultsPath || ''}`,
510
+ `current_manifest_file=${diffed.recapture?.manifestPath || ''}`,
511
+ `current_screenshots_root=${diffed.recapture?.screenshotsRoot || ''}`
455
512
  ].join('\n') + '\n';
456
513
 
457
514
  await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
@@ -486,7 +543,7 @@ runs:
486
543
  EOF
487
544
 
488
545
  - id: skipped_baseline
489
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap'
546
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
490
547
  shell: bash
491
548
  env:
492
549
  MESSAGE: ${{ steps.baseline.outputs.message }}
@@ -521,14 +578,14 @@ runs:
521
578
  - id: stage
522
579
  shell: bash
523
580
  env:
524
- SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
525
- SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
581
+ SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
582
+ SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
526
583
  BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
527
- CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
584
+ CURRENT_RESULTS_PATH: ${{ steps.compare.outputs.current_results_file || steps.capture.outputs.results_file }}
528
585
  BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
529
- CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
586
+ CURRENT_MANIFEST_PATH: ${{ steps.compare.outputs.current_manifest_file || steps.capture.outputs.manifest_file }}
530
587
  BASELINE_SCREENSHOTS_DIR: ${{ steps.baseline_paths.outputs.screenshots_dir }}
531
- CURRENT_SCREENSHOTS_DIR: ${{ steps.capture.outputs.screenshots_root != '' && format('{0}/screenshots', steps.capture.outputs.screenshots_root) || '' }}
588
+ CURRENT_SCREENSHOTS_ROOT: ${{ steps.compare.outputs.current_screenshots_root || steps.capture.outputs.screenshots_root }}
532
589
  run: |
533
590
  node --input-type=module <<'EOF'
534
591
  import fs from 'node:fs/promises';
@@ -538,6 +595,8 @@ runs:
538
595
  const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
539
596
  const { stageArtifacts } = await import(pathToFileURL(modulePath));
540
597
 
598
+ const currentScreenshotsRoot = process.env.CURRENT_SCREENSHOTS_ROOT || '';
599
+
541
600
  const result = await stageArtifacts({
542
601
  artifactType: 'diff',
543
602
  summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
@@ -547,7 +606,7 @@ runs:
547
606
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
548
607
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
549
608
  baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
550
- currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
609
+ currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined
551
610
  });
552
611
 
553
612
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
@@ -563,7 +622,7 @@ runs:
563
622
  - if: always()
564
623
  shell: bash
565
624
  env:
566
- SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
625
+ SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
567
626
  run: |
568
627
  if [ -f "$SUMMARY_FILE" ]; then
569
628
  cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
@@ -573,7 +632,7 @@ runs:
573
632
  uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
574
633
  env:
575
634
  PR_NUMBER: ${{ steps.context.outputs.pr_number }}
576
- SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
635
+ SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
577
636
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
578
637
  RUN_URL: ${{ steps.context.outputs.run_url }}
579
638
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
@@ -674,7 +733,9 @@ runs:
674
733
  });
675
734
  }
676
735
 
677
- - if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
736
+ # A skipped compare writes a summary with no diff counters, so enforcement
737
+ # has nothing to evaluate — `warn-and-skip` means "do not fail my build".
738
+ - if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && steps.compare.outputs.skipped != 'true' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
678
739
  shell: bash
679
740
  run: |
680
741
  node --input-type=module <<'EOF'
@@ -700,9 +761,9 @@ runs:
700
761
  SCOPE_REASON: ${{ steps.scope.outputs.reason }}
701
762
  SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
702
763
  BASELINE_FOUND: ${{ steps.baseline.outputs.found || 'false' }}
703
- STATUS: ${{ steps.compare.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
704
- SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
705
- MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
764
+ STATUS: ${{ steps.compare.outputs.status || steps.capture.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
765
+ 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
+ MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.capture.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
706
767
  BUNDLE_DIR: ${{ steps.stage.outputs.bundle_dir }}
707
768
  run: |
708
769
  {