snapdrift 0.8.1 → 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.
@@ -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
- files.map((file) => file.filename)
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
- const cliPath = new URL('../lib/cli.mjs', import.meta.url).pathname;
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) => {
package/docs/contracts.md CHANGED
@@ -22,7 +22,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
22
22
 
23
23
  | Field | Type | Description |
24
24
  |:------|:-----|:------------|
25
- | `id` | `string` | Unique route identifier across runs |
25
+ | `id` | `string` | Unique route identifier across runs. Its sanitized screenshot filename must also be unique across the full configured route set. |
26
26
  | `path` | `string` | URL path appended to `baseUrl` |
27
27
  | `viewport` | `string` or `object` | Preset name (`"desktop"`, `"mobile"`) or a custom object `{ "width": number, "height": number }` |
28
28
  | `changePaths` | `string[]` | Optional prefixes used for changed-file scoping |
@@ -43,6 +43,24 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
43
43
 
44
44
  When `provider: "snap"` is set, the `snap` block is required. Exactly one of `snap.apiKeyEnv` or `snap.apiKey` must be present. `snap.apiKey` accepts `${VAR}` interpolation (for example `"${SNAP_API_KEY}"`); the referenced environment variable must be set at runtime or the config loader throws.
45
45
 
46
+ Changed-file scoping treats a GitHub `renamed` record as both the current
47
+ `filename` and its nonempty `previous_filename`. This keeps a route selected
48
+ when a watched file moves out of its configured `changePaths`, and also detects
49
+ renames into watched or shared paths. Duplicate paths are ignored; other change
50
+ statuses use only their current filename. The `pr-diff` wrapper's explicit
51
+ `route-ids` and `force-run` inputs take precedence over this lookup; the
52
+ standalone `scope` action supports `force-run` and otherwise derives its
53
+ selection from the changed files. If GitHub returns the maximum 3,000 file
54
+ records, both actions run all configured routes with reason
55
+ `changed_files_truncated` because the list may be incomplete.
56
+
57
+ Route ids are sanitized before local captures and Snap baseline exports write
58
+ `screenshots/<route-id>.png`: `..` becomes `_`, path separators become `_`, and
59
+ control characters are removed. Distinct route ids that produce the same
60
+ sanitized filename are rejected before capture starts. Rename one of the route
61
+ ids and recapture the affected baseline; existing screenshot filenames remain
62
+ unchanged for noncolliding ids.
63
+
46
64
  ### Example (local provider)
47
65
 
48
66
  ```json
@@ -101,6 +119,27 @@ The published baseline bundle contains:
101
119
  | `manifest.json` | Screenshot manifest with ids, paths, and dimensions |
102
120
  | `screenshots/*.png` | Captured screenshot images |
103
121
 
122
+ Every manifest entry must use a unique staged screenshot filename (the basename
123
+ of `imagePath`). Duplicate paths or paths that flatten to the same filename are
124
+ rejected before comparison, even when the run selects only one of the affected
125
+ route ids, because they can point two logical screenshots at the same pixels.
126
+ Rename the conflicting route ids and recapture the baseline and current bundle.
127
+
128
+ ### Baseline resolution
129
+
130
+ The `actions/resolve-baseline` action and the `actions/pr-diff` wrapper distinguish three lookup
131
+ outcomes: `found` means a successful search selected a non-expired artifact, `missing` means a
132
+ successful search found no usable artifact, and `error` means GitHub returned an API, network, or
133
+ malformed-response failure. The boolean `found` output remains available for compatibility; use
134
+ `resolution-status` on `resolve-baseline` or `baseline-resolution-status` on `pr-diff` when the
135
+ reason matters. A missing baseline may produce the intentional first-run skipped summary. A lookup
136
+ error fails local comparisons and local Snap fallbacks, so infrastructure failures cannot be
137
+ reported as a clean or intentional missing-baseline result. Healthy hosted Snap diffs may continue
138
+ using their hosted baseline path after a GitHub artifact lookup error.
139
+ The standalone `actions/resolve-baseline` action fails its step for `error`; custom workflows
140
+ should branch on `resolution-status` rather than treating `found: false` as proof that no baseline
141
+ exists.
142
+
104
143
  ## Drift artifact
105
144
 
106
145
  The pull request drift bundle contains:
@@ -210,6 +249,12 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
210
249
  | `fail-on-incomplete` | Errors, dimension shifts, or missing captures occur |
211
250
  | `strict` | Any drift or incomplete comparison appears |
212
251
 
252
+ A summary with `status: "skipped"` — or any summary carrying no `diff.mode` —
253
+ never stops the run, whichever mode is configured. A skipped run has no diff
254
+ counters to enforce against, so `shouldFailDriftCheck` returns `false` for it
255
+ before any mode branch is reached. This holds for `actions/enforce` invoked
256
+ directly, not just for the guarded enforcement step inside `actions/pr-diff`.
257
+
213
258
  ## Viewport presets
214
259
 
215
260
  | Preset | Width | Height | Scale factor | Mobile | Touch |
@@ -316,6 +361,16 @@ Two files are written: `.github/snapdrift.json` and `.github/MIGRATION_NOTES.md`
316
361
 
317
362
  When `provider: "snap"`, every `capture` / `diff` / `publishBaseline` call goes through `SnapProvider`, which talks to Snap's hosted `/v1/visual/*` API instead of writing to the runner filesystem. The local provider keeps working exactly as before.
318
363
 
364
+ ### Complete hosted baseline publication
365
+
366
+ A hosted baseline is an authoritative complete snapshot. For `purpose: "baseline"`, `SnapProvider.capture()` requires the selected route ids to equal the complete configured route set; `route-ids`, `--routes`, and `SNAPDRIFT_ROUTE_IDS` may not reduce it. It requires a resolved branch, 40-character commit SHA, publication workflow ref, and positive publication sequence. GitHub Actions provides these as `GITHUB_REF_NAME`, `GITHUB_SHA`, `GITHUB_WORKFLOW_REF`, and `GITHUB_RUN_NUMBER`; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Local-provider baselines, non-publishing hosted captures, and hosted PR-diff runs retain scoped behavior.
367
+
368
+ The capture `results.json` records `configuredRouteIds`, `selectedRouteIds`, and `expectedCaptures` (route id, path, and viewport descriptor), plus one resolved `refBranch` / `refSha`, publication workflow ref, and publication sequence. The run-creation request sends the same immutable set and sequence as `capturePlan` and sends the ref as `branch` / `prHeadSha`. Before publication, SnapDrift requires the source run to belong to the configured project, settle to terminal `new`, and contain exactly one successful `new` capture with a non-empty `currentObjectKey` for every expected route/viewport identity. It keeps polling while any expected capture remains non-terminal, even when an older server reports the run itself as terminal, but fails fast after a stable incomplete capture set. Failed, missing, duplicate, extra, path-mismatched, or malformed captures abort before the baseline request. If another default-branch baseline wins first, Snap returns `409 baseline_stale_source`; rerun the current baseline job.
369
+
370
+ Hosted PR diffs use the same persisted `expectedCaptures` identities. `SnapProvider.diff()` requires `purpose: "diff"` plus a nonempty, unique expected set consistent with `selectedRouteIds`; results written by older SnapDrift versions without that set must be recaptured. It polls until the expected capture count is available, verifies the returned run id, and reconciles every returned capture by route id, path, and normalized viewport descriptor. Missing expected captures, duplicate or unexpected identities, pending or unknown statuses, run or capture errors, missing current objects, and invalid `diffPct` values produce an `incomplete` summary and are never counted as clean matches. A valid comparison requires a current object, a baseline object, and a finite numeric mismatch ratio from 0 through 1. The `diff.mode` setting then controls whether that incomplete summary fails the check.
371
+
372
+ Publication sends `publicationMode: "complete"` and the source run id both as top-level `sourceRunId` and as `manifest.sourceRunId`. The baseline id is deterministically derived from the run id, and manifest routes are sorted, so a retry sends the same identity and payload. Reusing that id with different persisted fields is rejected; rerun the baseline job to obtain a new source run. `refBranch` / `refSha` come from the persisted capture metadata rather than resolving git a second time. Snap accepts complete publication only from the project's default branch (effective default `main`).
373
+
319
374
  ### Local-capture hybrid
320
375
 
321
376
  If `baseUrl` resolves to a local address (see [`isLocalBaseUrl`](#islocalbaseurl-detection) below), SnapDrift runs Playwright on the runner to render the page and uploads the resulting screenshots to Snap. This is necessary whenever your `baseUrl` is a server only the runner can reach (the common case) — Snap's render worker cannot reach a `127.0.0.1` or `localhost` server.
@@ -328,10 +383,10 @@ SnapDrift uses a small, stable subset of the Snap API:
328
383
 
329
384
  | Endpoint | Used by |
330
385
  |:---------|:--------|
331
- | `POST /v1/visual/projects/:id/runs` | Create a run; client passes `baseUrl`, `trigger`, optional `baselineId` and `branch` |
386
+ | `POST /v1/visual/projects/:id/runs` | Create a run; client passes `baseUrl`, `trigger`, optional `baselineId` and `branch`, plus the immutable configured/selected route and expected-capture plan; baseline runs also pass the resolved commit as `prHeadSha` |
332
387
  | `POST /v1/visual/runs/:run_id/captures` | Submit each route as a capture; client passes `routeId`, `routePath`, `viewportDescriptorJson` |
333
388
  | `POST /v1/visual/captures/:capture_id/local-result` | Hybrid path: upload the locally rendered PNG and its dimensions |
334
- | `POST /v1/visual/projects/:id/baselines` | Publish a baseline from a run's rendered captures (primary path) or upload a pre-built local baseline (migration path) |
389
+ | `POST /v1/visual/projects/:id/baselines` | Publish a complete default-branch baseline from a source run's validated captures |
335
390
  | `GET /v1/visual/projects/:id/baselines/latest` | Resolve the latest accepted baseline for diff runs |
336
391
  | `GET /v1/visual/runs/:run_id` | Poll a run until it reaches a terminal state (`pass`, `fail`, `error`, or `new`) |
337
392
 
@@ -342,9 +397,21 @@ SnapDrift uses a small, stable subset of the Snap API:
342
397
  The Snap HTTP client classifies responses and applies the following rules:
343
398
 
344
399
  - **2xx** — success, return the parsed body.
345
- - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix.
346
- - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, capped at `30 s` total). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
347
- - **Network errors** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
400
+ - **4xx** — non-retryable. The client throws `SnapApiError(status, message, path)` immediately. `onUnavailable` is **not** consulted for 4xx — a 404 from `/baselines/latest` is a "no baseline yet" signal, but a 4xx from `/runs` is a configuration error that retrying won't fix. If the diagnostic body itself stalls or is unavailable, the same `SnapApiError` retains the HTTP status and includes that diagnostic failure.
401
+ - **5xx** — retryable up to 3 attempts with exponential backoff (`1 s` → `2 s` → `4 s`, with each delay capped at `30 s`). If the final attempt still returns 5xx, the client falls through to the `onUnavailable` handler.
402
+ - **Network errors and transport timeouts** — same retry/backoff behavior as 5xx. After exhaustion, falls through to the `onUnavailable` handler.
403
+
404
+ Every JSON request attempt has a 30-second limit; binary export attempts have a
405
+ 120-second limit. Headers and response bodies share the applicable attempt
406
+ limit, and each public Snap operation has one 10-minute deadline covering all
407
+ of its requests, retries, backoff, and polling. The client passes an
408
+ `AbortSignal` to fetch and aborts/cancels stalled bodies; injected transports
409
+ that ignore cancellation are still released by the client-side deadline.
410
+ Retry and poll waits are clipped to the remaining operation time, so no new
411
+ request starts after the operation deadline. Deadline exhaustion is treated as
412
+ Snap unavailability and follows `onUnavailable`; received 4xx responses keep
413
+ their immediate non-retryable behavior, and a stalled 4xx diagnostic body
414
+ retains its HTTP status with a timeout diagnostic.
348
415
 
349
416
  `onUnavailable` is consulted once retries are exhausted:
350
417
 
@@ -352,6 +419,25 @@ The Snap HTTP client classifies responses and applies the following rules:
352
419
  - `"warn-and-skip"` — log a warning, throw `SnapSkipError`. The wrapper actions catch this and write a skipped summary, exiting 0.
353
420
  - `"fallback-local"` — log a warning, throw `SnapFallbackError`. The wrapper actions catch this and continue the pipeline with `LocalProvider`.
354
421
 
422
+ ### Outage policy
423
+
424
+ Snap can go down at any phase of a run, and the configured `onUnavailable`
425
+ behavior has to hold for all of them. `lib/outage-policy.mjs` is the single
426
+ implementation, used by `lib/cli.mjs`, `actions/baseline` and `actions/pr-diff`
427
+ alike:
428
+
429
+ | Phase | `warn-and-skip` | `fallback-local` |
430
+ |:------|:----------------|:-----------------|
431
+ | Capture | Write a skipped `summary.json`/`summary.md` with reason `snap_unavailable`, expose the summary outputs, stage the report, exit 0. | Capture with `LocalProvider` and report the **effective** provider (`local`) so the rest of the pipeline uses local artifacts and the local pixel engine. |
432
+ | Diff | Same skipped summary, exit 0. | Diff with `LocalProvider`. A capture that Snap rendered server-side has no PNGs on the runner, so the routes are **recaptured locally first**; the recaptured paths replace the Snap ones in the staged bundle. If baseline resolution succeeded but no artifact was found, the run reports `missing_main_baseline_artifact`; a lookup error fails instead of entering the missing-baseline path. |
433
+ | Baseline publish | Skip the publish and exit 0 without an artifact. | Capture locally and stage/upload that bundle, so the run still leaves a usable baseline. |
434
+
435
+ Enforcement of `diff.mode` never runs against a skipped summary — a skipped run
436
+ carries no diff counters, and `warn-and-skip` means "do not fail my build".
437
+
438
+ A `fallback-local` capture leaves nothing on Snap: no run is published and no
439
+ baseline is created. The fallback is a local run, not a deferred hosted one.
440
+
355
441
  ### `isLocalBaseUrl` detection
356
442
 
357
443
  `isLocalBaseUrl(baseUrl)` returns `true` when the URL hostname is:
@@ -369,12 +455,16 @@ All four error classes are exported from `lib/provider.mjs` and `lib/snap-provid
369
455
 
370
456
  | Class | Thrown when | Typical handler |
371
457
  |:------|:------------|:----------------|
372
- | `SnapApiError` | A 4xx response was received, or a 5xx/network error was retried to exhaustion. Carries `status` and `path` properties. | Surface the message; do not retry. |
373
- | `SnapUnavailableError` | A network error or 5xx was retried to exhaustion (used internally; usually re-wrapped as `SnapApiError`). | Treat as a temporary outage. |
458
+ | `SnapApiError` | A 4xx response was received, including a response whose diagnostic body stalled. Carries `status` and `path` properties. A final 5xx response also retains its status when it reaches the outage handler. | Surface the message; do not retry. |
459
+ | `SnapUnavailableError` | A retryable network error, transport timeout, or operation deadline was exhausted. In fail mode this may be surfaced directly; skip and fallback modes wrap it in their policy error. | Treat as a temporary outage. |
374
460
  | `SnapFallbackError` | `onUnavailable: "fallback-local"` is set and Snap could not be reached. | Catch and switch to `LocalProvider` for the rest of the pipeline. |
375
461
  | `SnapSkipError` | `onUnavailable: "warn-and-skip"` is set and Snap could not be reached. | Catch and exit cleanly with a skipped summary. |
376
462
 
377
- The wrapper actions (`actions/baseline`, `actions/pr-diff`) catch `SnapSkipError` and `SnapFallbackError` themselves. Custom orchestrations that call `provider.capture()` or `provider.diff()` directly should do the same.
463
+ The wrapper actions (`actions/baseline`, `actions/pr-diff`) and the CLI both handle `SnapSkipError` and `SnapFallbackError` through `lib/outage-policy.mjs` (`captureWithPolicy`, `diffWithPolicy`, `publishBaselineWithPolicy`). Custom orchestrations that call `provider.capture()`, `provider.diff()` or `provider.publishBaseline()` directly should use those helpers rather than re-implementing the matrix.
464
+
465
+ `SnapTransportTimeoutError` is an internal diagnostic name used while a
466
+ request or response body is being bounded. It is not an additional public
467
+ error class; callers should handle the exported error classes above.
378
468
 
379
469
  ## Primary entrypoints
380
470
 
@@ -472,3 +562,45 @@ The `VisualProvider` interface requires `buildCommentBody(summary, meta?)`:
472
562
 
473
563
  - **`LocalProvider`** — delegates to `buildReportCommentBody` without a `dashboardUrl`.
474
564
  - **`SnapProvider`** — constructs `dashboardUrl` from `{apiUrl}/projects/{projectId}/runs/{lastRunId}` and passes it through.
565
+
566
+
567
+ ## Workspace TypeScript contracts
568
+
569
+ The four `@snapdrift/*` workspace packages expose their declarations through a
570
+ `types` export condition before the existing JavaScript entrypoint. ESM consumers
571
+ can use Bundler, Node16, or NodeNext module resolution with `strict: true` and
572
+ `skipLibCheck: false`; Node16/NodeNext consumers should use an ESM package or an
573
+ `.mts` entrypoint. The top-level `types` field remains for older tooling.
574
+
575
+ Install `typescript` and `@types/node` in the consuming development environment
576
+ for APIs that use Node buffers. Cross-package declaration dependencies are
577
+ declared by the package that needs them. `npm run check:package-types` packs the
578
+ workspace packages and checks isolated consumers in all three resolution modes,
579
+ including rejected invalid calls. `npm run typecheck` also checks declaration
580
+ bodies without skipping library checks.
581
+
582
+ These guarantees cover the workspace package entrypoints. They do not add type
583
+ entrypoints for the root `snapdrift` package's JavaScript subpaths. Package release
584
+ publication is required before existing registry consumers receive these fixes.
585
+
586
+ Comment renderers accept `VisualReportSummary`: a complete comparison summary or
587
+ a `VisualDriftStatusSummary` with a required status and reason. `buildDriftSummary`
588
+ and `writeDriftSummary` return the latter, preserving their supported non-skipped
589
+ statuses as well as intentional skips. Unrelated object literals are rejected.
590
+ Known viewport presets have complete descriptors; arbitrary string lookups may
591
+ return `undefined` and must be checked by consumers.
592
+
593
+ The packed type gate runs in PR CI and before release publication. It validates
594
+ the candidate workspace tarballs together, not already-published registry
595
+ versions. Release preparation must bump every changed published package and
596
+ raise sibling dependency floors to the versions that contain these type fixes
597
+ before publication; the gate alone does not validate that release-version step.
598
+
599
+ Each workspace package also declares every runtime package it imports. In
600
+ particular, `@snapdrift/adapter-fs` declares `@snapdrift/adapter-report-md`, so
601
+ it can be installed and imported without the SnapDrift repository or a sibling
602
+ workspace masking a missing dependency. `npm run check:package-runtime` packs
603
+ each workspace package, installs it in an isolated temporary consumer together
604
+ with only its declared local workspace dependency closure, and imports its
605
+ public entrypoint. The gate runs in pull-request and release workflows; publish
606
+ the updated package before registry consumers can receive a dependency fix.
@@ -46,14 +46,14 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
46
46
 
47
47
  ```yaml
48
48
  - name: SnapDrift Baseline
49
- uses: ranacseruet/snapdrift@v0.8.1
49
+ uses: ranacseruet/snapdrift@v0.9.0
50
50
  with:
51
51
  mode: baseline
52
52
  repo-config-path: .github/snapdrift.json
53
53
  artifact-retention-days: '30'
54
54
  ```
55
55
 
56
- If you want only part of the route set, pass `route-ids`. If you don't want to upload the artifact (for example when using the Snap provider, which uploads via its own API), set `upload-artifact: 'false'`.
56
+ For a local-provider baseline, `route-ids` may select part of the route set. A hosted Snap baseline is authoritative and must run in GitHub Actions on the project default branch with every configured route, so a partial `route-ids` value or unresolved CI commit fails before a run is created. Route scoping remains supported in `pr-diff` mode. If you don't want to upload the artifact (for example when using the Snap provider, which uploads via its own API), set `upload-artifact: 'false'`.
57
57
 
58
58
  ## Step 3: Run the pull request report
59
59
 
@@ -71,7 +71,7 @@ Then add SnapDrift after the app is running:
71
71
 
72
72
  ```yaml
73
73
  - name: SnapDrift Report
74
- uses: ranacseruet/snapdrift@v0.8.1
74
+ uses: ranacseruet/snapdrift@v0.9.0
75
75
  with:
76
76
  mode: pr-diff
77
77
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -86,8 +86,8 @@ The wrappers remain published in their own right, and are equivalent to the disp
86
86
 
87
87
  | Entry point | Equivalent to |
88
88
  |-------------|---------------|
89
- | `ranacseruet/snapdrift@v0.8.1` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.8.1` |
90
- | `ranacseruet/snapdrift@v0.8.1` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.8.1` |
89
+ | `ranacseruet/snapdrift@v0.9.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.9.0` |
90
+ | `ranacseruet/snapdrift@v0.9.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.9.0` |
91
91
 
92
92
  Use the wrappers directly when you want to skip the dispatcher's input indirection, and the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions when you need to orchestrate the stages yourself.
93
93
 
@@ -130,7 +130,7 @@ jobs:
130
130
  done
131
131
 
132
132
  - name: SnapDrift Report
133
- uses: ranacseruet/snapdrift@v0.8.1
133
+ uses: ranacseruet/snapdrift@v0.9.0
134
134
  with:
135
135
  mode: pr-diff
136
136
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -170,7 +170,7 @@ jobs:
170
170
  done
171
171
 
172
172
  - name: SnapDrift Report
173
- uses: ranacseruet/snapdrift@v0.8.1
173
+ uses: ranacseruet/snapdrift@v0.9.0
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -188,6 +188,15 @@ jobs:
188
188
  - Upserts the PR report
189
189
  - Enforces `diff.mode` after publication completes
190
190
 
191
+ When the wrapper scopes a pull request from GitHub's changed-file list, a
192
+ renamed file contributes both its new and previous paths. A move out of a
193
+ watched directory therefore still selects the route that owned the old path,
194
+ while a move into a watched or shared path selects the new scope as well.
195
+ Repeated paths are deduplicated, and `route-ids` or `force-run` keep their
196
+ existing precedence. GitHub caps this file listing at 3,000 records; if that
197
+ limit is reached, the actions run all configured routes with scope reason
198
+ `changed_files_truncated` instead of trusting a potentially incomplete list.
199
+
191
200
  ## Useful overrides
192
201
 
193
202
  | Input | Default | Purpose |
@@ -205,7 +214,7 @@ jobs:
205
214
  | `max-changed-rows` | `20` | Max changed-route rows shown in the PR comment before truncation |
206
215
  | `max-error-rows` | `10` | Max error rows shown in the PR comment before truncation |
207
216
 
208
- The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. See the wrapper action's `outputs:` block for the canonical list.
217
+ The `pr-diff` action also exposes outputs you can use in subsequent steps: `should-run`, `scope-reason`, `selected-route-ids`, `baseline-found`, `baseline-resolution-status`, `status`, `summary-path`, `markdown-path`, `artifact-name`, `bundle-dir`. `baseline-resolution-status` is `found`, `missing`, or `error`; `missing` is the intentional first-baseline case, while `error` means the GitHub lookup failed and causes local comparisons or local Snap fallbacks to fail. Healthy hosted Snap diffs can continue without the downloaded GitHub artifact. See the wrapper action's `outputs:` block for the canonical list.
209
218
 
210
219
  ## Hosted Snap provider
211
220
 
@@ -230,7 +239,11 @@ By default SnapDrift writes baselines and reports to the runner filesystem (`pro
230
239
  - `"warn-and-skip"` — log a warning, write a skipped summary, and exit 0.
231
240
  - `"fallback-local"` — log a warning and run the rest of the pipeline with `LocalProvider`.
232
241
 
233
- The Snap API client retries 5xx and network errors with exponential backoff (3 attempts, 1 s → 2 s → 4 s, capped at 30 s). 4xx errors never retry and never fall back.
242
+ Both behaviors apply at every phase of a run — capture, diff, and baseline publish. `fallback-local` switches the whole remainder of the pipeline to local artifacts and the local pixel engine; if Snap had already rendered the current capture server-side, the routes are recaptured on the runner first, so `actions/pr-diff` needs Playwright available (the wrapper installs it whenever `onUnavailable` is `fallback-local`). Nothing is published to Snap on a fallback run. See [Outage policy](contracts.md#outage-policy) for the full matrix.
243
+
244
+ Hosted baseline publication is complete and default-branch-only. SnapDrift rejects scoped baseline capture before creating the run, records the configured/selected route ids and exact route/viewport identities, and publishes only when every expected capture finishes successfully with a stored image. The run records one resolved branch and commit (`branch` / `prHeadSha`) plus a publication workflow ref and monotonic sequence. GitHub Actions provides these automatically; other CI systems may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. Snap uses the sequence for same-workflow ordering and source-run timestamps for cross-workflow freshness. Missing, failed, duplicate, or unexpected captures abort publication without replacing the accepted baseline. If a source run loses the race to a newer candidate, the API returns `409 baseline_stale_source`; an inconsistent manifest/source workflow identity returns `409 baseline_source_run_mismatch` with `details.reason = "workflow_mismatch"`. Workflow-ref rotation itself is supported. This restriction does not apply to non-publishing hosted captures, hosted PR-diff runs, or local-provider baselines.
245
+
246
+ The Snap API client retries 5xx, network errors, and transport timeouts with exponential backoff (3 attempts, 1 s → 2 s → 4 s, with each delay capped at 30 s). JSON request attempts are limited to 30 seconds and binary baseline exports to 120 seconds. Each Snap operation has a 10-minute deadline covering its requests, retries, backoff, and polling; stalled headers and response bodies are aborted and cannot keep the workflow waiting indefinitely. Retry and poll waits are clipped to the remaining operation time. Received 4xx errors never retry or fall back; if a 4xx diagnostic body stalls, the response remains a non-retryable error with its HTTP status and timeout diagnostic. When a deadline or other retryable transport failure is exhausted, `onUnavailable` controls fail, warn-and-skip, or fallback-local behavior.
234
247
 
235
248
  When `provider: "snap"` and `baseUrl` points to a local address (localhost, `127.0.0.0/8`, `::1`, or `0.0.0.0`), SnapDrift uses a **local-capture hybrid**: Playwright runs on the runner to render the page, then SnapDrift uploads the resulting screenshots to Snap. This makes it possible to point SnapDrift at a server that only the runner can reach (a typical case) without exposing the server to Snap's render worker.
236
249
 
@@ -263,7 +276,9 @@ The `pr-diff` wrapper composes the following low-level steps. They're still avai
263
276
  - `actions/capture` — capture routes and emit `results.json` + `manifest.json`
264
277
  - `actions/compare` — diff current capture against a baseline
265
278
  - `actions/scope` — decide whether to run and which routes to select from changed files
266
- - `actions/resolve-baseline` — find and download the latest successful baseline artifact
279
+ - `actions/resolve-baseline` — find and download the latest successful baseline artifact; its
280
+ `resolution-status` output distinguishes `found`, `missing`, and `error`; the action fails for
281
+ `error`, while `missing` remains the intentional first-baseline result
267
282
  - `actions/stage` — assemble the baseline or diff bundle for upload
268
283
  - `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
269
284
  - `actions/comment` — upsert a PR comment from a summary (provider-aware)
@@ -302,15 +317,19 @@ Use the provided workflow template to refresh the baseline automatically on ever
302
317
 
303
318
  Drop a copy into your repo at `.github/workflows/snapdrift-refresh-baseline.yml`, then fill in the `TODO` blocks with your app's build and start steps — the same steps you use in your PR workflow. The template uses a `push` trigger on the default branch so published artifacts are discoverable by the baseline resolver in `actions/pr-diff`.
304
319
 
305
- ### Label-gated refreshes
306
-
307
- If you don't want to republish the baseline on every push, the template includes a commented `if` condition that gates on a label (e.g. `snapdrift:refresh-baseline`). A preceding job can check the most recent merged PR for the label and set an output to control whether the baseline refresh runs.
320
+ Run this job after every successful default-branch build. Hosted publication is the only operation that may intentionally remove a configured route from the canonical baseline, so skipping merges can leave later PRs comparing against stale route configuration.
308
321
 
309
322
  ## Troubleshooting
310
323
 
311
324
  **"No non-expired SnapDrift baseline artifact was found"**
312
325
  The baseline workflow has not completed successfully on `main`, or the artifact expired. With the Snap provider, the equivalent situation is a 404 from `/v1/visual/projects/:id/baselines/latest`; `SnapProvider` swallows that 404 and proceeds without a baseline — `onUnavailable` is **not** consulted for this case (a 404 is the legitimate "no baseline yet" signal, not a Snap outage). If you want the PR pipeline to tolerate the first-run case, set `diff.mode: "report-only"`; `onUnavailable: "warn-and-skip"` will not help here.
313
326
 
327
+ **"Unable to resolve the SnapDrift baseline artifact"**
328
+ GitHub failed while listing workflow runs or artifacts. Check the job's `actions: read` permission,
329
+ repository/workflow/branch inputs, and GitHub API availability. Snap hosted diffs can still use the
330
+ hosted comparison path, but local comparisons and `fallback-local` require a successful GitHub
331
+ artifact lookup.
332
+
314
333
  **403 when posting the PR report**
315
334
  Grant `issues: write` and `pull-requests: write` to the job.
316
335
 
@@ -325,3 +344,25 @@ Capture failed before comparison. Confirm the app is fully ready and reachable b
325
344
 
326
345
  **Playwright install runs even though `provider: "snap"`**
327
346
  That's expected for the Snap local-capture hybrid. Playwright runs locally to render the page, then the resulting screenshot is uploaded to Snap. The hybrid kicks in only when `baseUrl` points to a local address; for remote `baseUrl` Snap's render worker captures directly.
347
+
348
+
349
+ ## TypeScript package consumers
350
+
351
+ Import APIs and types from the public `@snapdrift/*` package names. Their export
352
+ maps support Bundler, Node16, and NodeNext resolution; enable `strict: true` and
353
+ `skipLibCheck: false` to validate the contracts in your project. For Node16 or
354
+ NodeNext, use `"type": "module"` or an `.mts` entrypoint. Install `typescript` and
355
+ `@types/node` as development dependencies when using the Node buffer APIs.
356
+
357
+ No TypeScript path aliases or imports into workspace source directories are
358
+ needed. Root `snapdrift/lib/*` subpath typing is separate from these workspace
359
+ contracts. The fixes become available to registry consumers after the packages
360
+ are released and their installed versions are updated.
361
+
362
+ The workspace packages are independently installable runtime packages. Install
363
+ the package you use from the registry and let npm resolve its declared runtime
364
+ dependencies; do not rely on a repository checkout or workspace links. The
365
+ filesystem adapter includes its reporting dependency, so `@snapdrift/adapter-fs`
366
+ can load config, capture, comparison, and report entrypoints from an isolated
367
+ consumer. Release a new adapter version before expecting an existing registry
368
+ installation to include a dependency correction.
package/docs/local-cli.md CHANGED
@@ -31,11 +31,11 @@ snapdrift baseline [options]
31
31
  ```
32
32
 
33
33
  - **`provider: "local"`** — identical to `snapdrift capture`: the screenshots written to the baseline directory *are* the baseline.
34
- - **`provider: "snap"`** — captures each route through Snap, waits for the hosted renders to finish, then publishes a baseline referencing the stored objects. `snapdrift capture` alone does **not** do this: it submits the run and stops, leaving the project with no baseline.
34
+ - **`provider: "snap"`** — canonical baseline publication is CI-only. Run it on the Snap project's default branch; GitHub Actions supplies the publication metadata automatically, while another CI system may set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`. It captures every configured route, waits for the complete hosted run, then publishes a baseline referencing the stored objects. A local checkout without those metadata values fails before creating a hosted run, and partial `--routes` selection is rejected. `snapdrift capture` may still be used locally for non-publishing diagnostics.
35
35
 
36
36
  `snap.onUnavailable` is honoured across both phases — the capture *and* the publish that follows it. `warn-and-skip` exits 0 without a baseline; `fallback-local` captures locally so you still end up with one.
37
37
 
38
- **Baseline attribution.** The published baseline records the branch and commit it was cut from. Inside GitHub Actions these come from `GITHUB_REF_NAME` / `GITHUB_HEAD_REF` and `GITHUB_SHA`; run locally, they are read from git (`rev-parse --abbrev-ref HEAD` and `rev-parse HEAD`). Outside a git repository they fall back to `main` and `unknown`, so run the command from your checkout if you want the baseline attributed correctly.
38
+ **Baseline attribution.** A hosted publication records a 40-character commit and branch from GitHub Actions or the current git checkout, plus a publication workflow identity and sequence. Outside GitHub Actions, set `SNAPDRIFT_PUBLICATION_WORKFLOW_REF` and `SNAPDRIFT_PUBLICATION_SEQUENCE`; without them SnapDrift refuses to create a canonical hosted baseline.
39
39
 
40
40
  **`snap.projectId` must be an explicit `prj_...` id** for local runs. `"auto"` derives the id from `GITHUB_REPOSITORY`, which is not set outside Actions — the command fails early telling you to set an explicit id.
41
41
 
@@ -44,13 +44,14 @@ snapdrift baseline [options]
44
44
  | Flag | Default | Description |
45
45
  |:-----|:--------|:------------|
46
46
  | `--config <path>` | `.github/snapdrift.json` | Path to the config file |
47
- | `--routes <ids>` | all routes | Comma-separated route IDs to capture |
47
+ | `--routes <ids>` | all routes | Comma-separated route IDs for a local-provider baseline; hosted baselines require all configured routes |
48
48
  | `--baseline-dir <path>` | `.snapdrift/baseline` | Directory to write baseline screenshots and metadata (local provider) |
49
49
 
50
50
  **Example**
51
51
 
52
52
  ```bash
53
53
  snapdrift baseline
54
+ # Scoped baseline capture remains available with provider: "local"
54
55
  snapdrift baseline --routes home-desktop,home-mobile
55
56
  ```
56
57
 
@@ -296,7 +297,7 @@ The `report.html` path is printed whenever the status is anything other than `cl
296
297
  |:---------|:-----------|:------------|
297
298
  | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behavior for apps with shared session or auth state. |
298
299
  | `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
299
- | `SNAPDRIFT_ROUTE_IDS` | `capture`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`. |
300
+ | `SNAPDRIFT_ROUTE_IDS` | `capture`, `baseline`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`; it may not reduce a hosted baseline's complete route set. |
300
301
 
301
302
  `SNAPDRIFT_CAPTURE_CONCURRENCY` is the same env var consumed by the GitHub Actions wrapper; tweak it the same way for both environments.
302
303
 
@@ -304,7 +305,7 @@ The `report.html` path is printed whenever the status is anything other than `cl
304
305
 
305
306
  ## Tips
306
307
 
307
- - **Partial runs**: use `--routes` to capture or compare only the routes you are actively changing.
308
+ - **Partial runs**: use `--routes` to capture or compare only the routes you are actively changing. A hosted baseline is the exception: its GitHub Actions run always requires the complete configured route set.
308
309
  - **Multiple baselines**: use `--baseline-dir` to maintain separate baselines per branch or feature.
309
310
  - **CI parity**: the CLI uses the same capture and comparison engine as the GitHub Actions workflow, so results are comparable.
310
311
  - **Self-contained HTML report**: `report.html` embeds baseline, current, and diff images as base64. Open it from any machine — no server required, and no relative-path resolution surprises. (The HTML report is a local-CLI feature; the GitHub Actions wrapper ships only `summary.json` + `summary.md` in its artifact bundle.)
@@ -17,12 +17,6 @@
17
17
  # resolver in actions/pr-diff (which filters on event: 'push'). A merge into
18
18
  # the default branch produces a push event, so this fires exactly when needed.
19
19
  #
20
- # Optional — refresh only on a specific label:
21
- # If you don't want to refresh on every push, you can gate on a label applied
22
- # before merging. Use the pull_request: labeled event in a separate workflow to
23
- # set an output, or simply restrict this workflow to specific paths.
24
- # See the commented block below for a label-based gate using a preceding job.
25
-
26
20
  name: SnapDrift Refresh Baseline
27
21
 
28
22
  on:
@@ -35,12 +29,12 @@ permissions:
35
29
 
36
30
  jobs:
37
31
  refresh-baseline:
38
- # Optional: gate on a label to avoid refreshing on every push.
39
- # Requires a preceding job that checks for the label on the most recent
40
- # merged PR and sets an output. Replace "snapdrift:refresh-baseline" with
41
- # your label name.
42
- # if: needs.check-label.outputs.refresh == 'true'
43
-
32
+ # Preserve the in-flight publisher and queue the next merge behind it. This
33
+ # keeps older SnapDrift clients safe during a rolling release; SnapDrift's
34
+ # workflow-scoped sequence is an additional stale-source guard.
35
+ concurrency:
36
+ group: snapdrift-baseline-${{ github.repository }}-${{ github.ref }}
37
+ cancel-in-progress: false
44
38
  runs-on: ubuntu-latest
45
39
 
46
40
  steps:
@@ -69,8 +63,26 @@ jobs:
69
63
  # done
70
64
  # ────────────────────────────────────────────────────────────────────────
71
65
 
66
+ - name: Verify current default-branch candidate
67
+ id: baseline_candidate
68
+ shell: bash
69
+ run: |
70
+ set -euo pipefail
71
+ latest_sha="$(git ls-remote origin "refs/heads/${GITHUB_REF_NAME}" | cut -f1)"
72
+ if [ -z "$latest_sha" ]; then
73
+ echo "::error::Could not resolve the current default-branch commit."
74
+ exit 1
75
+ fi
76
+ if [ "$GITHUB_SHA" = "$latest_sha" ]; then
77
+ echo "current=true" >> "$GITHUB_OUTPUT"
78
+ else
79
+ echo "current=false" >> "$GITHUB_OUTPUT"
80
+ echo "Skipping stale baseline candidate $GITHUB_SHA; current branch head is $latest_sha."
81
+ fi
82
+
72
83
  - name: Publish SnapDrift baseline
73
- uses: ranacseruet/snapdrift@v0.8.1
84
+ if: steps.baseline_candidate.outputs.current == 'true'
85
+ uses: ranacseruet/snapdrift@v0.9.0
74
86
  with:
75
87
  mode: baseline
76
88
  repo-config-path: .github/snapdrift.json