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 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.8.2
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.8.2
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@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
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@f1ded4c7c0eb944b35eb8f70f8711f4f84397813 # v0.8.2
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
- files.map((file) => file.filename)
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 { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
219
- const artifactName = config.baselineArtifactName;
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 runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
223
- owner,
224
- repo,
225
- workflow_id: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
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
- event: 'push',
228
- per_page: 20
265
+ artifactName
229
266
  });
230
-
231
- let selectedRun = null;
232
- let selectedArtifact = null;
233
-
234
- for (const run of runs) {
235
- if (run.status !== 'completed' || run.conclusion !== 'success') {
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
- core.setOutput('found', 'false');
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: () => process.stderr.write('[SnapDrift] No baseline artifact is available, so the local diff cannot run.\n')
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.found != 'true' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
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 body = `${PR_COMMENT_MARKER}\n<img src="${iconUrl}" alt="SnapDrift" width="20" height="20" />\n\n### ⚠️ SnapDrift Report — Capture Failed\n\nSnapDrift did not produce a summary. This usually means capture failed before the report could be assembled.\n\n[View run](${runUrl}) for details.\n\n<div align="right"><sub>Powered by <a href="${repoUrl}">SnapDrift</a></sub></div>`;
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: Informational message when no artifact was found.
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 { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
88
- const artifactName = process.env.INPUT_ARTIFACT_NAME || config.baselineArtifactName;
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 runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
92
- owner,
93
- repo,
94
- workflow_id: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
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
- event: 'push',
97
- per_page: 20
111
+ artifactName
98
112
  });
99
-
100
- let selectedRun = null;
101
- let selectedArtifact = null;
102
-
103
- for (const run of runs) {
104
- if (run.status !== 'completed' || run.conclusion !== 'success') {
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
- core.setOutput('found', 'false');
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
- echo "run_dir=${GITHUB_WORKSPACE}/${DOWNLOAD_PATH}"
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"
@@ -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) => {