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.
@@ -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';
@@ -340,38 +357,56 @@ runs:
340
357
 
341
358
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
342
359
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
343
- const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
360
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
361
+ const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
362
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
344
363
  const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
364
+ const { captureWithPolicy, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
365
+ const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
345
366
 
346
367
  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({
368
+ const providerName = config.provider || 'local';
369
+ const routeIds = splitCommaList(process.env.ROUTE_IDS);
370
+
371
+ const captured = await captureWithPolicy({
372
+ provider: createProvider(providerName, config),
373
+ providerName,
374
+ config,
375
+ captureOptions: {
352
376
  configPath: process.env.REPO_CONFIG_PATH || undefined,
353
- routeIds: splitCommaList(process.env.ROUTE_IDS)
377
+ routeIds
378
+ },
379
+ baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
380
+ baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
381
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable — skipping visual regression (warn-and-skip).\n'),
382
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable — falling back to local provider.\n')
383
+ });
384
+
385
+ if (captured.outcome === 'skipped') {
386
+ // warn-and-skip is documented as "write a skipped summary and exit 0".
387
+ // Without the summary the report step falls through to its
388
+ // "Capture Failed" body, which is not what happened. See #125.
389
+ const skipped = await writeDriftSummary({
390
+ reason: SNAP_UNAVAILABLE_REASON,
391
+ selectedRouteIds: routeIds
354
392
  });
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
- }
393
+ const skipOutputs = [
394
+ `provider=skipped`,
395
+ `summary_path=${skipped.summaryPath}`,
396
+ `markdown_path=${skipped.markdownPath}`,
397
+ `status=${skipped.summary.status || ''}`
398
+ ].join('\n') + '\n';
399
+ await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
400
+ process.exit(0);
371
401
  }
372
402
 
403
+ const result = captured.result;
373
404
  const outputs = [
374
- `provider=${config.provider || 'local'}`,
405
+ // The *effective* provider, not the configured one: after a
406
+ // fallback-local capture the compare step must not rebuild
407
+ // SnapProvider around local results that carry no run id.
408
+ `provider=${captured.providerName}`,
409
+ `local_screenshots=${captured.localScreenshots ? 'true' : 'false'}`,
375
410
  `results_file=${result.resultsPath}`,
376
411
  `manifest_file=${result.manifestPath}`,
377
412
  `screenshots_root=${result.screenshotsRoot}`,
@@ -394,6 +429,12 @@ runs:
394
429
  BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
395
430
  CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
396
431
  CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
432
+ CURRENT_SCREENSHOTS_ROOT: ${{ steps.capture.outputs.screenshots_root }}
433
+ CAPTURE_PROVIDER: ${{ steps.capture.outputs.provider }}
434
+ LOCAL_SCREENSHOTS: ${{ steps.capture.outputs.local_screenshots }}
435
+ BASELINE_FOUND: ${{ steps.baseline.outputs.found }}
436
+ BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status }}
437
+ BASELINE_RESOLUTION_MESSAGE: ${{ steps.baseline.outputs.message }}
397
438
  run: |
398
439
  node --input-type=module <<'EOF'
399
440
  import fs from 'node:fs/promises';
@@ -402,45 +443,83 @@ runs:
402
443
 
403
444
  const providerModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/provider.mjs');
404
445
  const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
405
- const { createProvider, SnapSkipError, SnapFallbackError } = await import(pathToFileURL(providerModulePath));
446
+ const policyModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/outage-policy.mjs');
447
+ const summaryModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
448
+ const { createProvider } = await import(pathToFileURL(providerModulePath));
406
449
  const { loadSnapdriftConfig, splitCommaList } = await import(pathToFileURL(configModulePath));
450
+ const { diffWithPolicy, MISSING_BASELINE_REASON, SNAP_UNAVAILABLE_REASON } = await import(pathToFileURL(policyModulePath));
451
+ const { writeDriftSummary } = await import(pathToFileURL(summaryModulePath));
407
452
 
408
453
  const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
409
- const provider = createProvider(config.provider || 'local', config);
454
+ // The capture step reports the provider that actually ran, which is
455
+ // "local" once a fallback-local capture has happened.
456
+ const providerName = process.env.CAPTURE_PROVIDER || config.provider || 'local';
457
+ const routeIds = splitCommaList(process.env.ROUTE_IDS);
410
458
 
411
459
  const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
412
460
  const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
413
461
  const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
414
462
 
463
+ const captureOptions = {
464
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
465
+ routeIds
466
+ };
467
+
415
468
  const diffOptions = {
416
469
  configPath: process.env.REPO_CONFIG_PATH || undefined,
417
470
  baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
418
471
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
419
472
  baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
420
- routeIds: splitCommaList(process.env.ROUTE_IDS),
473
+ routeIds,
421
474
  baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
422
475
  baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
423
476
  currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
424
- currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined
477
+ currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
478
+ currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined
425
479
  };
426
480
 
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;
481
+ const diffed = await diffWithPolicy({
482
+ provider: createProvider(providerName, config),
483
+ providerName,
484
+ diffOptions,
485
+ captureOptions,
486
+ localScreenshots: process.env.LOCAL_SCREENSHOTS !== 'false',
487
+ baselineAvailable: process.env.BASELINE_FOUND === 'true',
488
+ baselineResolutionStatus: process.env.BASELINE_RESOLUTION_STATUS,
489
+ baselineResolutionMessage: process.env.BASELINE_RESOLUTION_MESSAGE,
490
+ onSkip: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — skipping (warn-and-skip).\n'),
491
+ onFallback: () => process.stderr.write('[SnapDrift] Snap unavailable during diff — falling back to local provider.\n'),
492
+ onRecapture: () => process.stderr.write('[SnapDrift] The current capture was rendered by Snap, so it has no local screenshots — recapturing locally before diffing.\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');
441
499
  }
500
+ });
501
+
502
+ // Every non-throwing outcome writes a summary. A wrapper run that ends
503
+ // without one makes the report step claim "Capture Failed". See #125.
504
+ if (diffed.outcome !== 'diffed') {
505
+ const skipped = await writeDriftSummary({
506
+ reason: diffed.outcome === 'skipped' ? SNAP_UNAVAILABLE_REASON : MISSING_BASELINE_REASON,
507
+ baselineAvailable: diffed.outcome === 'skipped' ? undefined : false,
508
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
509
+ selectedRouteIds: routeIds,
510
+ outDir: resolvedOutDir
511
+ });
512
+ const skipOutputs = [
513
+ `skipped=true`,
514
+ `summary_path=${skipped.summaryPath}`,
515
+ `markdown_path=${skipped.markdownPath}`,
516
+ `status=${skipped.summary.status || ''}`
517
+ ].join('\n') + '\n';
518
+ await fs.appendFile(process.env.GITHUB_OUTPUT, skipOutputs);
519
+ process.exit(0);
442
520
  }
443
- const { summary, markdown } = diffResult;
521
+
522
+ const { summary, markdown } = diffed.result;
444
523
 
445
524
  await fs.mkdir(resolvedOutDir, { recursive: true });
446
525
  await Promise.all([
@@ -449,9 +528,16 @@ runs:
449
528
  ]);
450
529
 
451
530
  const outputs = [
531
+ `skipped=false`,
452
532
  `summary_path=${resolvedSummaryPath}`,
453
533
  `markdown_path=${resolvedMarkdownPath}`,
454
- `status=${summary.status || ''}`
534
+ `status=${summary.status || ''}`,
535
+ // A fallback-local diff of a remote Snap capture recaptures on the
536
+ // runner; the staged bundle has to carry those PNGs, not the empty
537
+ // placeholder paths the Snap capture step emitted.
538
+ `current_results_file=${diffed.recapture?.resultsPath || ''}`,
539
+ `current_manifest_file=${diffed.recapture?.manifestPath || ''}`,
540
+ `current_screenshots_root=${diffed.recapture?.screenshotsRoot || ''}`
455
541
  ].join('\n') + '\n';
456
542
 
457
543
  await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
@@ -486,7 +572,7 @@ runs:
486
572
  EOF
487
573
 
488
574
  - id: skipped_baseline
489
- if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true' && steps.capture.outputs.provider != 'snap'
575
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.resolution_status == 'missing' && steps.capture.outputs.provider != 'snap' && steps.capture.outputs.provider != 'skipped'
490
576
  shell: bash
491
577
  env:
492
578
  MESSAGE: ${{ steps.baseline.outputs.message }}
@@ -521,14 +607,14 @@ runs:
521
607
  - id: stage
522
608
  shell: bash
523
609
  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 }}
610
+ 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 }}
611
+ 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
612
  BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
527
- CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
613
+ CURRENT_RESULTS_PATH: ${{ steps.compare.outputs.current_results_file || steps.capture.outputs.results_file }}
528
614
  BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
529
- CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
615
+ CURRENT_MANIFEST_PATH: ${{ steps.compare.outputs.current_manifest_file || steps.capture.outputs.manifest_file }}
530
616
  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) || '' }}
617
+ CURRENT_SCREENSHOTS_ROOT: ${{ steps.compare.outputs.current_screenshots_root || steps.capture.outputs.screenshots_root }}
532
618
  run: |
533
619
  node --input-type=module <<'EOF'
534
620
  import fs from 'node:fs/promises';
@@ -538,6 +624,8 @@ runs:
538
624
  const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
539
625
  const { stageArtifacts } = await import(pathToFileURL(modulePath));
540
626
 
627
+ const currentScreenshotsRoot = process.env.CURRENT_SCREENSHOTS_ROOT || '';
628
+
541
629
  const result = await stageArtifacts({
542
630
  artifactType: 'diff',
543
631
  summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
@@ -547,7 +635,7 @@ runs:
547
635
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
548
636
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
549
637
  baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
550
- currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
638
+ currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined
551
639
  });
552
640
 
553
641
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
@@ -563,7 +651,7 @@ runs:
563
651
  - if: always()
564
652
  shell: bash
565
653
  env:
566
- SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
654
+ 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
655
  run: |
568
656
  if [ -f "$SUMMARY_FILE" ]; then
569
657
  cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
@@ -573,12 +661,14 @@ runs:
573
661
  uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
574
662
  env:
575
663
  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 }}
664
+ 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
665
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
578
666
  RUN_URL: ${{ steps.context.outputs.run_url }}
579
667
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
580
668
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
581
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 }}
582
672
  with:
583
673
  github-token: ${{ inputs.github-token }}
584
674
  script: |
@@ -590,6 +680,8 @@ runs:
590
680
  const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
591
681
  const iconUrl = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
592
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));
593
685
 
594
686
  const summaryPath = process.env.SUMMARY_PATH;
595
687
  const issueNumber = Number(process.env.PR_NUMBER || context.payload.pull_request?.number || context.issue?.number || '');
@@ -600,7 +692,12 @@ runs:
600
692
 
601
693
  if (!summaryPath || !fs.existsSync(summaryPath)) {
602
694
  const runUrl = process.env.RUN_URL || '';
603
- 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>`;
604
701
  await github.rest.issues.createComment({
605
702
  owner: context.repo.owner,
606
703
  repo: context.repo.repo,
@@ -674,7 +771,9 @@ runs:
674
771
  });
675
772
  }
676
773
 
677
- - if: steps.scope.outputs.should_run == 'true' && steps.capture.outputs.provider != 'skipped' && (steps.baseline.outputs.found == 'true' || steps.capture.outputs.provider == 'snap')
774
+ # A skipped compare writes a summary with no diff counters, so enforcement
775
+ # has nothing to evaluate — `warn-and-skip` means "do not fail my build".
776
+ - 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
777
  shell: bash
679
778
  run: |
680
779
  node --input-type=module <<'EOF'
@@ -700,9 +799,10 @@ runs:
700
799
  SCOPE_REASON: ${{ steps.scope.outputs.reason }}
701
800
  SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
702
801
  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 || '' }}
802
+ BASELINE_RESOLUTION_STATUS: ${{ steps.baseline.outputs.resolution_status || '' }}
803
+ STATUS: ${{ steps.compare.outputs.status || steps.capture.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
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 || '' }}
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 || '' }}
706
806
  BUNDLE_DIR: ${{ steps.stage.outputs.bundle_dir }}
707
807
  run: |
708
808
  {
@@ -710,6 +810,7 @@ runs:
710
810
  echo "scope_reason=${SCOPE_REASON:-}"
711
811
  echo "selected_route_ids=${SELECTED_ROUTE_IDS:-}"
712
812
  echo "baseline_found=${BASELINE_FOUND:-false}"
813
+ echo "baseline_resolution_status=${BASELINE_RESOLUTION_STATUS:-}"
713
814
  echo "status=${STATUS:-}"
714
815
  echo "summary_path=${SUMMARY_PATH:-}"
715
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"