snapdrift 0.2.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.
@@ -0,0 +1,50 @@
1
+ name: SnapDrift Enforce
2
+ description: Evaluate the SnapDrift summary against diff.mode and fail when required.
3
+
4
+ inputs:
5
+ summary-path:
6
+ description: Path to the SnapDrift summary JSON.
7
+ required: false
8
+ default: qa-artifacts/snapdrift/drift/current/summary.json
9
+
10
+ outputs:
11
+ status:
12
+ description: Computed SnapDrift status.
13
+ value: ${{ steps.evaluate.outputs.status }}
14
+
15
+ runs:
16
+ using: composite
17
+ steps:
18
+ - name: Set action root path
19
+ shell: bash
20
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
21
+
22
+ - name: Set up Node.js
23
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
24
+ with:
25
+ node-version: '22'
26
+
27
+ - name: Install shared action dependencies
28
+ shell: bash
29
+ run: npm ci --prefix "$ACTION_ROOT"
30
+
31
+ - id: evaluate
32
+ shell: bash
33
+ env:
34
+ SUMMARY_PATH: ${{ inputs.summary-path }}
35
+ run: |
36
+ node --input-type=module <<'EOF'
37
+ import fs from 'node:fs/promises';
38
+ import path from 'node:path';
39
+ import { pathToFileURL } from 'node:url';
40
+
41
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
42
+ const { shouldFailDriftCheck, formatDriftFailureMessage } = await import(pathToFileURL(modulePath));
43
+ const summary = JSON.parse(await fs.readFile(process.env.SUMMARY_PATH, 'utf8'));
44
+
45
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `status=${summary.status || ''}\n`);
46
+
47
+ if (shouldFailDriftCheck(summary)) {
48
+ throw new Error(formatDriftFailureMessage(summary.diffMode, summary));
49
+ }
50
+ EOF
@@ -0,0 +1,612 @@
1
+ name: SnapDrift PR Diff
2
+ description: Run the end-to-end SnapDrift pull request pipeline, including scope resolution, skipped-report handling, artifact upload, PR reporting, and enforcement.
3
+
4
+ inputs:
5
+ github-token:
6
+ description: Token used to inspect pull request files, fetch baselines, and publish SnapDrift PR reports.
7
+ required: true
8
+ repo-config-path:
9
+ description: Path to the SnapDrift config file.
10
+ required: false
11
+ default: .github/snapdrift.json
12
+ pr-number:
13
+ description: Optional pull request number override.
14
+ required: false
15
+ default: ''
16
+ route-ids:
17
+ description: Optional comma-separated route ids. When provided, changed-file scope detection is skipped.
18
+ required: false
19
+ default: ''
20
+ force-run:
21
+ description: Whether to force a full run when route ids are not explicitly provided.
22
+ required: false
23
+ default: 'false'
24
+ force-run-reason:
25
+ description: Reason to report when force-run is enabled.
26
+ required: false
27
+ default: forced
28
+ baseline-repository:
29
+ description: Repository in owner/name format that publishes the baseline.
30
+ required: false
31
+ default: ''
32
+ baseline-workflow-id:
33
+ description: Workflow id or filename to search for the baseline.
34
+ required: false
35
+ default: ci.yml
36
+ baseline-branch:
37
+ description: Branch that publishes the baseline.
38
+ required: false
39
+ default: main
40
+ artifact-retention-days:
41
+ description: Retention period for the uploaded diff artifact.
42
+ required: false
43
+ default: '30'
44
+ comment-on-pr:
45
+ description: Whether to publish the SnapDrift PR report.
46
+ required: false
47
+ default: 'true'
48
+
49
+ outputs:
50
+ should-run:
51
+ description: Whether the SnapDrift flow ran or intentionally skipped due to scope.
52
+ value: ${{ steps.finalize.outputs.should_run }}
53
+ scope-reason:
54
+ description: Reason for the final scope decision.
55
+ value: ${{ steps.finalize.outputs.scope_reason }}
56
+ selected-route-ids:
57
+ description: Comma-separated route ids selected for the run.
58
+ value: ${{ steps.finalize.outputs.selected_route_ids }}
59
+ baseline-found:
60
+ description: Whether a usable baseline was found.
61
+ value: ${{ steps.finalize.outputs.baseline_found }}
62
+ status:
63
+ description: Final SnapDrift status.
64
+ value: ${{ steps.finalize.outputs.status }}
65
+ summary-path:
66
+ description: Path to the final summary JSON file.
67
+ value: ${{ steps.finalize.outputs.summary_path }}
68
+ markdown-path:
69
+ description: Path to the final summary markdown file.
70
+ value: ${{ steps.finalize.outputs.markdown_path }}
71
+ artifact-name:
72
+ description: Uploaded diff artifact name.
73
+ value: ${{ steps.context.outputs.artifact_name }}
74
+ bundle-dir:
75
+ description: Staged diff artifact bundle directory.
76
+ value: ${{ steps.finalize.outputs.bundle_dir }}
77
+
78
+ runs:
79
+ using: composite
80
+ steps:
81
+ - name: Set action root path
82
+ shell: bash
83
+ run: echo "ACTION_ROOT=$(cd "$GITHUB_ACTION_PATH/../.." && pwd)" >> "$GITHUB_ENV"
84
+
85
+ - name: Set up Node.js
86
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
87
+ with:
88
+ node-version: '22'
89
+
90
+ - id: context
91
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
92
+ env:
93
+ INPUT_PR_NUMBER: ${{ inputs.pr-number }}
94
+ with:
95
+ github-token: ${{ inputs.github-token }}
96
+ script: |
97
+ const explicitPrNumber = (process.env.INPUT_PR_NUMBER || '').trim();
98
+ const prNumber = explicitPrNumber || String(context.payload.pull_request?.number || context.issue?.number || '');
99
+ const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
100
+ const artifactName = `snapdrift-pr-${prNumber || process.env.GITHUB_RUN_ID}`;
101
+
102
+ core.setOutput('pr_number', prNumber);
103
+ core.setOutput('run_url', runUrl);
104
+ core.setOutput('artifact_name', artifactName);
105
+
106
+ - id: scope
107
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
108
+ env:
109
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
110
+ ROUTE_IDS: ${{ inputs.route-ids }}
111
+ FORCE_RUN: ${{ inputs.force-run }}
112
+ FORCE_RUN_REASON: ${{ inputs.force-run-reason }}
113
+ INPUT_PR_NUMBER: ${{ steps.context.outputs.pr_number }}
114
+ with:
115
+ github-token: ${{ inputs.github-token }}
116
+ script: |
117
+ const path = require('path');
118
+ const { pathToFileURL } = require('url');
119
+
120
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
121
+ const { loadSnapdriftConfig, selectConfiguredRoutes, selectRoutesForChangedFiles, splitCommaList } = await import(pathToFileURL(configModulePath));
122
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
123
+ const allRouteIds = (config.routes || []).map((route) => route.id);
124
+ const explicitRouteIds = splitCommaList(process.env.ROUTE_IDS);
125
+
126
+ function setOutputs(scope) {
127
+ core.setOutput('should_run', scope.shouldRun ? 'true' : 'false');
128
+ core.setOutput('reason', scope.reason);
129
+ core.setOutput('selected_route_ids', (scope.selectedRouteIds || []).join(','));
130
+ }
131
+
132
+ if (explicitRouteIds.length > 0) {
133
+ const selected = selectConfiguredRoutes(config, explicitRouteIds);
134
+ setOutputs({
135
+ shouldRun: true,
136
+ reason: 'explicit_route_ids',
137
+ selectedRouteIds: selected.selectedRouteIds
138
+ });
139
+ return;
140
+ }
141
+
142
+ if (process.env.FORCE_RUN === 'true') {
143
+ setOutputs({
144
+ shouldRun: true,
145
+ reason: process.env.FORCE_RUN_REASON || 'forced',
146
+ selectedRouteIds: allRouteIds
147
+ });
148
+ return;
149
+ }
150
+
151
+ const prNumber = Number(process.env.INPUT_PR_NUMBER || context.payload.pull_request?.number || '');
152
+ if (!Number.isFinite(prNumber) || prNumber <= 0) {
153
+ setOutputs({
154
+ shouldRun: true,
155
+ reason: 'missing_pr_number',
156
+ selectedRouteIds: allRouteIds
157
+ });
158
+ return;
159
+ }
160
+
161
+ try {
162
+ const files = await github.paginate(github.rest.pulls.listFiles, {
163
+ owner: context.repo.owner,
164
+ repo: context.repo.repo,
165
+ pull_number: prNumber,
166
+ per_page: 100
167
+ });
168
+
169
+ setOutputs(
170
+ selectRoutesForChangedFiles(
171
+ config,
172
+ files.map((file) => file.filename)
173
+ )
174
+ );
175
+ } catch (error) {
176
+ core.warning(`Unable to inspect PR files for SnapDrift scope; running all configured captures instead. ${error.message}`);
177
+ setOutputs({
178
+ shouldRun: true,
179
+ reason: 'snapdrift_scope_check_failed',
180
+ selectedRouteIds: allRouteIds
181
+ });
182
+ }
183
+
184
+ - id: baseline
185
+ if: steps.scope.outputs.should_run == 'true'
186
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
187
+ env:
188
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
189
+ INPUT_REPOSITORY: ${{ inputs.baseline-repository }}
190
+ INPUT_WORKFLOW_ID: ${{ inputs.baseline-workflow-id }}
191
+ INPUT_BRANCH: ${{ inputs.baseline-branch }}
192
+ with:
193
+ github-token: ${{ inputs.github-token }}
194
+ script: |
195
+ const path = require('path');
196
+ const { pathToFileURL } = require('url');
197
+
198
+ const repository = process.env.INPUT_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
199
+ const [owner, repo] = repository.split('/');
200
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
201
+ const { loadSnapdriftConfig } = await import(pathToFileURL(modulePath));
202
+ const { config } = await loadSnapdriftConfig(process.env.REPO_CONFIG_PATH || undefined);
203
+ const artifactName = config.baselineArtifactName;
204
+
205
+ try {
206
+ const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
207
+ owner,
208
+ repo,
209
+ workflow_id: process.env.INPUT_WORKFLOW_ID || 'ci.yml',
210
+ branch: process.env.INPUT_BRANCH || 'main',
211
+ event: 'push',
212
+ per_page: 20
213
+ });
214
+
215
+ let selectedRun = null;
216
+ let selectedArtifact = null;
217
+
218
+ for (const run of runs) {
219
+ if (run.status !== 'completed' || run.conclusion !== 'success') {
220
+ continue;
221
+ }
222
+
223
+ const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
224
+ owner,
225
+ repo,
226
+ run_id: run.id,
227
+ per_page: 100
228
+ });
229
+
230
+ const match = artifacts.data.artifacts.find((artifact) => artifact.name === artifactName && !artifact.expired);
231
+ if (match) {
232
+ selectedRun = run;
233
+ selectedArtifact = match;
234
+ break;
235
+ }
236
+ }
237
+
238
+ if (!selectedRun || !selectedArtifact) {
239
+ core.setOutput('found', 'false');
240
+ core.setOutput('artifact_name', artifactName);
241
+ core.setOutput('message', `No non-expired SnapDrift baseline artifact named ${artifactName} was found.`);
242
+ return;
243
+ }
244
+
245
+ core.setOutput('found', 'true');
246
+ core.setOutput('artifact_name', selectedArtifact.name);
247
+ core.setOutput('head_sha', selectedRun.head_sha);
248
+ core.setOutput('run_id', String(selectedRun.id));
249
+ core.setOutput('message', '');
250
+ } catch (error) {
251
+ core.setOutput('found', 'false');
252
+ core.setOutput('artifact_name', artifactName);
253
+ core.setOutput('message', `Unable to resolve the SnapDrift baseline artifact: ${error.message}`);
254
+ }
255
+
256
+ - if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
257
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
258
+ with:
259
+ github-token: ${{ inputs.github-token }}
260
+ repository: ${{ inputs.baseline-repository || github.repository }}
261
+ run-id: ${{ steps.baseline.outputs.run_id }}
262
+ name: ${{ steps.baseline.outputs.artifact_name }}
263
+ path: snapdrift-baseline-download
264
+
265
+ - id: baseline_paths
266
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
267
+ shell: bash
268
+ run: |
269
+ set -euo pipefail
270
+ RESULTS_FILE="$(find snapdrift-baseline-download -type f -name 'results.json' | head -n 1)"
271
+ MANIFEST_FILE="$(find snapdrift-baseline-download -type f -name 'manifest.json' | head -n 1)"
272
+ if [ -z "$RESULTS_FILE" ] || [ -z "$MANIFEST_FILE" ]; then
273
+ echo "Downloaded artifact did not contain the expected SnapDrift baseline files."
274
+ find snapdrift-baseline-download -maxdepth 5 -type f | sort || true
275
+ exit 1
276
+ fi
277
+ {
278
+ echo "results_file=$RESULTS_FILE"
279
+ echo "manifest_file=$MANIFEST_FILE"
280
+ echo "run_dir=${GITHUB_WORKSPACE}/snapdrift-baseline-download"
281
+ echo "screenshots_dir=$(dirname "$MANIFEST_FILE")/screenshots"
282
+ } >> "$GITHUB_OUTPUT"
283
+
284
+ - name: Install shared action dependencies
285
+ if: steps.scope.outputs.should_run == 'true'
286
+ shell: bash
287
+ run: npm ci --prefix "$ACTION_ROOT"
288
+
289
+ - name: Install Playwright Chromium
290
+ if: steps.scope.outputs.should_run == 'true'
291
+ shell: bash
292
+ run: |
293
+ "$ACTION_ROOT/node_modules/.bin/playwright" install --with-deps chromium
294
+
295
+ - id: capture
296
+ if: steps.scope.outputs.should_run == 'true'
297
+ shell: bash
298
+ env:
299
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
300
+ ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
301
+ run: |
302
+ node --input-type=module <<'EOF'
303
+ import fs from 'node:fs/promises';
304
+ import path from 'node:path';
305
+ import { pathToFileURL } from 'node:url';
306
+
307
+ const captureModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/capture-routes.mjs');
308
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
309
+ const { runBaselineCapture } = await import(pathToFileURL(captureModulePath));
310
+ const { splitCommaList } = await import(pathToFileURL(configModulePath));
311
+
312
+ const result = await runBaselineCapture({
313
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
314
+ routeIds: splitCommaList(process.env.ROUTE_IDS)
315
+ });
316
+
317
+ const outputs = [
318
+ `results_file=${result.resultsPath}`,
319
+ `manifest_file=${result.manifestPath}`,
320
+ `screenshots_root=${result.screenshotsRoot}`,
321
+ `selected_route_ids=${result.selectedRouteIds.join(',')}`
322
+ ].join('\n') + '\n';
323
+
324
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
325
+ EOF
326
+
327
+ - id: compare
328
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
329
+ shell: bash
330
+ env:
331
+ REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
332
+ ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
333
+ BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
334
+ BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
335
+ BASELINE_RUN_DIR: ${{ steps.baseline_paths.outputs.run_dir }}
336
+ BASELINE_ARTIFACT_NAME: ${{ steps.baseline.outputs.artifact_name }}
337
+ BASELINE_SOURCE_SHA: ${{ steps.baseline.outputs.head_sha }}
338
+ run: |
339
+ node --input-type=module <<'EOF'
340
+ import fs from 'node:fs/promises';
341
+ import path from 'node:path';
342
+ import { pathToFileURL } from 'node:url';
343
+
344
+ const compareModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
345
+ const configModulePath = path.resolve(process.env.ACTION_ROOT, 'lib/snapdrift-config.mjs');
346
+ const { runDriftCheckCli } = await import(pathToFileURL(compareModulePath));
347
+ const { splitCommaList } = await import(pathToFileURL(configModulePath));
348
+
349
+ const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
350
+ const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
351
+ const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
352
+
353
+ await runDriftCheckCli({
354
+ configPath: process.env.REPO_CONFIG_PATH || undefined,
355
+ baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
356
+ baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
357
+ baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
358
+ routeIds: splitCommaList(process.env.ROUTE_IDS),
359
+ baselineArtifactName: process.env.BASELINE_ARTIFACT_NAME || undefined,
360
+ baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
361
+ enforceOutcome: false
362
+ });
363
+
364
+ const summary = JSON.parse(await fs.readFile(resolvedSummaryPath, 'utf8'));
365
+ const outputs = [
366
+ `summary_path=${resolvedSummaryPath}`,
367
+ `markdown_path=${resolvedMarkdownPath}`,
368
+ `status=${summary.status || ''}`
369
+ ].join('\n') + '\n';
370
+
371
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
372
+ EOF
373
+
374
+ - id: skipped_scope
375
+ if: steps.scope.outputs.should_run != 'true'
376
+ shell: bash
377
+ env:
378
+ REASON: ${{ steps.scope.outputs.reason }}
379
+ run: |
380
+ node --input-type=module <<'EOF'
381
+ import fs from 'node:fs/promises';
382
+ import path from 'node:path';
383
+ import { pathToFileURL } from 'node:url';
384
+
385
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
386
+ const { writeDriftSummary } = await import(pathToFileURL(modulePath));
387
+
388
+ const result = await writeDriftSummary({
389
+ reason: process.env.REASON || 'no_snapdrift_relevant_changes',
390
+ selectedRouteIds: []
391
+ });
392
+
393
+ const outputs = [
394
+ `summary_path=${result.summaryPath}`,
395
+ `markdown_path=${result.markdownPath}`,
396
+ `status=${result.summary.status || ''}`
397
+ ].join('\n') + '\n';
398
+
399
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
400
+ EOF
401
+
402
+ - id: skipped_baseline
403
+ if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found != 'true'
404
+ shell: bash
405
+ env:
406
+ MESSAGE: ${{ steps.baseline.outputs.message }}
407
+ CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
408
+ SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
409
+ run: |
410
+ node --input-type=module <<'EOF'
411
+ import fs from 'node:fs/promises';
412
+ import path from 'node:path';
413
+ import { pathToFileURL } from 'node:url';
414
+
415
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/drift-summary.mjs');
416
+ const { writeDriftSummary } = await import(pathToFileURL(modulePath));
417
+
418
+ const result = await writeDriftSummary({
419
+ reason: 'missing_main_baseline_artifact',
420
+ message: process.env.MESSAGE || undefined,
421
+ baselineAvailable: false,
422
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
423
+ selectedRouteIds: process.env.SELECTED_ROUTE_IDS || ''
424
+ });
425
+
426
+ const outputs = [
427
+ `summary_path=${result.summaryPath}`,
428
+ `markdown_path=${result.markdownPath}`,
429
+ `status=${result.summary.status || ''}`
430
+ ].join('\n') + '\n';
431
+
432
+ await fs.appendFile(process.env.GITHUB_OUTPUT, outputs);
433
+ EOF
434
+
435
+ - id: stage
436
+ shell: bash
437
+ env:
438
+ SUMMARY_JSON_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
439
+ SUMMARY_MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
440
+ BASELINE_RESULTS_PATH: ${{ steps.baseline_paths.outputs.results_file }}
441
+ CURRENT_RESULTS_PATH: ${{ steps.capture.outputs.results_file }}
442
+ BASELINE_MANIFEST_PATH: ${{ steps.baseline_paths.outputs.manifest_file }}
443
+ CURRENT_MANIFEST_PATH: ${{ steps.capture.outputs.manifest_file }}
444
+ BASELINE_SCREENSHOTS_DIR: ${{ steps.baseline_paths.outputs.screenshots_dir }}
445
+ CURRENT_SCREENSHOTS_DIR: ${{ steps.capture.outputs.screenshots_root != '' && format('{0}/screenshots', steps.capture.outputs.screenshots_root) || '' }}
446
+ run: |
447
+ node --input-type=module <<'EOF'
448
+ import fs from 'node:fs/promises';
449
+ import path from 'node:path';
450
+ import { pathToFileURL } from 'node:url';
451
+
452
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/stage-artifacts.mjs');
453
+ const { stageArtifacts } = await import(pathToFileURL(modulePath));
454
+
455
+ const result = await stageArtifacts({
456
+ artifactType: 'diff',
457
+ summaryJsonPath: process.env.SUMMARY_JSON_PATH || undefined,
458
+ summaryMarkdownPath: process.env.SUMMARY_MARKDOWN_PATH || undefined,
459
+ baselineResultsPath: process.env.BASELINE_RESULTS_PATH || undefined,
460
+ currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
461
+ baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
462
+ currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
463
+ baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
464
+ currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
465
+ });
466
+
467
+ await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
468
+ EOF
469
+
470
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
471
+ with:
472
+ name: ${{ steps.context.outputs.artifact_name }}
473
+ retention-days: ${{ inputs.artifact-retention-days }}
474
+ if-no-files-found: warn
475
+ path: ${{ steps.stage.outputs.bundle_dir }}
476
+
477
+ - if: always()
478
+ shell: bash
479
+ env:
480
+ SUMMARY_FILE: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path }}
481
+ run: |
482
+ if [ -f "$SUMMARY_FILE" ]; then
483
+ cat "$SUMMARY_FILE" >> "$GITHUB_STEP_SUMMARY"
484
+ fi
485
+
486
+ - if: always() && inputs.comment-on-pr == 'true'
487
+ uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
488
+ env:
489
+ PR_NUMBER: ${{ steps.context.outputs.pr_number }}
490
+ SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
491
+ ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
492
+ RUN_URL: ${{ steps.context.outputs.run_url }}
493
+ with:
494
+ github-token: ${{ inputs.github-token }}
495
+ script: |
496
+ const fs = require('fs');
497
+ const path = require('path');
498
+ const { pathToFileURL } = require('url');
499
+
500
+ const PR_COMMENT_MARKER = '<!-- snapdrift-report -->';
501
+ const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
502
+ const iconUrl = 'https://raw.githubusercontent.com/ranacseruet/snapdrift/main/assets/snapdrift-logo-icon.png';
503
+ const repoUrl = 'https://github.com/ranacseruet/snapdrift';
504
+
505
+ const summaryPath = process.env.SUMMARY_PATH;
506
+ const issueNumber = Number(process.env.PR_NUMBER || context.payload.pull_request?.number || context.issue?.number || '');
507
+ if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
508
+ core.warning('SnapDrift PR report skipped because no pull request number was available.');
509
+ return;
510
+ }
511
+
512
+ if (!summaryPath || !fs.existsSync(summaryPath)) {
513
+ const runUrl = process.env.RUN_URL || '';
514
+ 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>`;
515
+ await github.rest.issues.createComment({
516
+ owner: context.repo.owner,
517
+ repo: context.repo.repo,
518
+ issue_number: issueNumber,
519
+ body
520
+ });
521
+ return;
522
+ }
523
+
524
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/pr-comment.mjs');
525
+ const { buildReportCommentBody } = await import(pathToFileURL(modulePath));
526
+
527
+ const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
528
+ const body = buildReportCommentBody(summary, {
529
+ artifactName: process.env.ARTIFACT_NAME || '',
530
+ runUrl: process.env.RUN_URL || ''
531
+ });
532
+
533
+ const comments = await github.paginate(github.rest.issues.listComments, {
534
+ owner: context.repo.owner,
535
+ repo: context.repo.repo,
536
+ issue_number: issueNumber,
537
+ per_page: 100
538
+ });
539
+ const matchingComments = comments
540
+ .filter((comment) => comment.body && PR_COMMENT_MARKERS.some((marker) => comment.body.includes(marker)))
541
+ .sort((left, right) => {
542
+ const leftTime = new Date(left.updated_at || left.created_at).getTime();
543
+ const rightTime = new Date(right.updated_at || right.created_at).getTime();
544
+ return rightTime - leftTime;
545
+ });
546
+ const existing = matchingComments[0];
547
+
548
+ if (existing) {
549
+ await github.rest.issues.updateComment({
550
+ owner: context.repo.owner,
551
+ repo: context.repo.repo,
552
+ comment_id: existing.id,
553
+ body
554
+ });
555
+ } else {
556
+ await github.rest.issues.createComment({
557
+ owner: context.repo.owner,
558
+ repo: context.repo.repo,
559
+ issue_number: issueNumber,
560
+ body
561
+ });
562
+ }
563
+
564
+ for (const duplicate of matchingComments.slice(1)) {
565
+ await github.rest.issues.deleteComment({
566
+ owner: context.repo.owner,
567
+ repo: context.repo.repo,
568
+ comment_id: duplicate.id
569
+ });
570
+ }
571
+
572
+ - if: steps.scope.outputs.should_run == 'true' && steps.baseline.outputs.found == 'true'
573
+ shell: bash
574
+ run: |
575
+ node --input-type=module <<'EOF'
576
+ import fs from 'node:fs/promises';
577
+ import path from 'node:path';
578
+ import { pathToFileURL } from 'node:url';
579
+
580
+ const modulePath = path.resolve(process.env.ACTION_ROOT, 'lib/compare-results.mjs');
581
+ const { shouldFailDriftCheck, formatDriftFailureMessage } = await import(pathToFileURL(modulePath));
582
+ const summaryModulePath = path.resolve('qa-artifacts/snapdrift/drift/current/summary.json');
583
+ const summary = JSON.parse(await fs.readFile(summaryModulePath, 'utf8'));
584
+
585
+ if (shouldFailDriftCheck(summary)) {
586
+ throw new Error(formatDriftFailureMessage(summary.diffMode, summary));
587
+ }
588
+ EOF
589
+
590
+ - id: finalize
591
+ if: always()
592
+ shell: bash
593
+ env:
594
+ SHOULD_RUN: ${{ steps.scope.outputs.should_run }}
595
+ SCOPE_REASON: ${{ steps.scope.outputs.reason }}
596
+ SELECTED_ROUTE_IDS: ${{ steps.scope.outputs.selected_route_ids }}
597
+ BASELINE_FOUND: ${{ steps.baseline.outputs.found || 'false' }}
598
+ STATUS: ${{ steps.compare.outputs.status || steps.skipped_baseline.outputs.status || steps.skipped_scope.outputs.status || '' }}
599
+ SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path || '' }}
600
+ MARKDOWN_PATH: ${{ steps.compare.outputs.markdown_path || steps.skipped_baseline.outputs.markdown_path || steps.skipped_scope.outputs.markdown_path || '' }}
601
+ BUNDLE_DIR: ${{ steps.stage.outputs.bundle_dir }}
602
+ run: |
603
+ {
604
+ echo "should_run=${SHOULD_RUN:-false}"
605
+ echo "scope_reason=${SCOPE_REASON:-}"
606
+ echo "selected_route_ids=${SELECTED_ROUTE_IDS:-}"
607
+ echo "baseline_found=${BASELINE_FOUND:-false}"
608
+ echo "status=${STATUS:-}"
609
+ echo "summary_path=${SUMMARY_PATH:-}"
610
+ echo "markdown_path=${MARKDOWN_PATH:-}"
611
+ echo "bundle_dir=${BUNDLE_DIR:-}"
612
+ } >> "$GITHUB_OUTPUT"