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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/actions/baseline/action.yml +142 -0
- package/actions/capture/action.yml +78 -0
- package/actions/comment/action.yml +104 -0
- package/actions/compare/action.yml +163 -0
- package/actions/enforce/action.yml +50 -0
- package/actions/pr-diff/action.yml +612 -0
- package/actions/resolve-baseline/action.yml +165 -0
- package/actions/scope/action.yml +107 -0
- package/actions/stage/action.yml +116 -0
- package/bin/snapdrift.mjs +14 -0
- package/docs/contracts.md +233 -0
- package/docs/integration-guide.md +222 -0
- package/docs/local-cli.md +174 -0
- package/lib/capture-routes.mjs +370 -0
- package/lib/cli.mjs +241 -0
- package/lib/compare-results.mjs +643 -0
- package/lib/drift-summary.mjs +156 -0
- package/lib/pr-comment.mjs +147 -0
- package/lib/report.mjs +268 -0
- package/lib/snapdrift-config.mjs +280 -0
- package/lib/stage-artifacts.mjs +125 -0
- package/package.json +94 -0
- package/types/visual-diff-types.d.ts +137 -0
|
@@ -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"
|