@erclx/canon 4.95.0 → 4.97.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/codebase-layout/REQUIREMENT.md +46 -0
- package/claude/skills/codebase-layout/SKILL.md +57 -0
- package/claude/skills/codebase-layout/references/adopted.md +34 -0
- package/claude/skills/codebase-layout/references/frontend.md +43 -0
- package/claude/skills/codebase-layout/references/tests.md +28 -0
- package/claude/skills/git-pr/REQUIREMENT.md +3 -0
- package/claude/skills/git-pr/SKILL.md +25 -0
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +2 -1
- package/claude/skills/test-craft/REQUIREMENT.md +9 -0
- package/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/pr-evidence.md +9 -1
- package/docs/agents/pr-preview.md +103 -0
- package/docs/workflow/ai-workflow.md +1 -0
- package/governance/rules/core/000-constitution.md +1 -1
- package/governance/rules/lib/300-testing-ts.md +1 -0
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/commands/pr.ts +300 -13
- package/src/pr/evidence.ts +36 -1
- package/src/pr/preview.ts +178 -0
- package/tooling/astro/configs/playwright.config.ts +3 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +64 -2
- package/tooling/cloudflare/reference.md +6 -3
- package/tooling/nextjs/configs/playwright.config.ts +3 -1
- package/tooling/vite-react/configs/playwright.config.ts +3 -1
- package/tooling/web/configs/e2e/home.spec.ts +8 -0
package/src/commands/pr.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import { readFile } from 'node:fs/promises'
|
|
2
|
-
import { resolve } from 'node:path'
|
|
3
|
+
import { basename, join, resolve } from 'node:path'
|
|
3
4
|
import { $ } from 'bun'
|
|
4
5
|
import type { Command } from 'commander'
|
|
5
6
|
import { execa } from 'execa'
|
|
@@ -22,11 +23,20 @@ import {
|
|
|
22
23
|
import { type CheckRunListing, collapseChecks } from '@/pr/checks'
|
|
23
24
|
import {
|
|
24
25
|
findEvidenceCommentId,
|
|
26
|
+
findEvidencePreview,
|
|
25
27
|
groupEvidence,
|
|
26
28
|
renderEvidenceBody,
|
|
27
29
|
} from '@/pr/evidence'
|
|
28
30
|
import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
|
|
29
31
|
import { KEY_CHANGES } from '@/pr/paths'
|
|
32
|
+
import {
|
|
33
|
+
findDeployWorkflow,
|
|
34
|
+
mintPreview,
|
|
35
|
+
type PreviewRefusal,
|
|
36
|
+
type PreviewRunner,
|
|
37
|
+
type RunRow,
|
|
38
|
+
type WorkflowFile,
|
|
39
|
+
} from '@/pr/preview'
|
|
30
40
|
import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
|
|
31
41
|
import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
|
|
32
42
|
|
|
@@ -58,6 +68,37 @@ interface ReadOptions {
|
|
|
58
68
|
readonly json?: boolean
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
interface EvidenceOptions extends ReadOptions {
|
|
72
|
+
readonly preview?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PreviewOptions extends ReadOptions {
|
|
76
|
+
readonly timeout?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** How long `canon pr preview` waits on the deploy run by default, in minutes. */
|
|
80
|
+
const PREVIEW_TIMEOUT_MINUTES = 15
|
|
81
|
+
|
|
82
|
+
/** How often the deploy run is re-read while waiting on it. */
|
|
83
|
+
const PREVIEW_POLL_MS = 15_000
|
|
84
|
+
|
|
85
|
+
const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
|
|
86
|
+
'bad-timeout': '--timeout takes a positive number of minutes.',
|
|
87
|
+
'no-deploy':
|
|
88
|
+
'No workflow under .github/workflows/ runs pages deploy and carries a workflow_dispatch trigger, so there is nothing to dispatch.',
|
|
89
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
|
|
90
|
+
unfenced:
|
|
91
|
+
'The deploy workflow passes no --branch=${{ github.ref_name }}, so a dispatch from this branch would publish it to production. Add the flag before asking for a preview.',
|
|
92
|
+
'no-alias':
|
|
93
|
+
'The deploy never printed a canon-preview-alias line, so no address could be read. Add the alias step from the cloudflare stack reference to the workflow.',
|
|
94
|
+
'gh-failed':
|
|
95
|
+
'gh could not list or dispatch the deploy workflow for this branch.',
|
|
96
|
+
'run-failed':
|
|
97
|
+
'The deploy run finished without succeeding, so no preview was published. Read the run log.',
|
|
98
|
+
timeout:
|
|
99
|
+
'The deploy run did not finish inside the bound. The preview may still land. Re-run with a longer --timeout or read the run.',
|
|
100
|
+
}
|
|
101
|
+
|
|
61
102
|
/** Why a head-sensitive read produced no answer about a commit. */
|
|
62
103
|
type PullRefusal =
|
|
63
104
|
| 'gh-missing'
|
|
@@ -324,6 +365,10 @@ export function register(program: Command): void {
|
|
|
324
365
|
.helpOption('-h, --help', 'Show this help message')
|
|
325
366
|
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
326
367
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
368
|
+
.option(
|
|
369
|
+
'--preview <url>',
|
|
370
|
+
'Open the body with this preview address, from canon pr preview',
|
|
371
|
+
)
|
|
327
372
|
.addHelpText(
|
|
328
373
|
'after',
|
|
329
374
|
[
|
|
@@ -342,6 +387,12 @@ export function register(program: Command): void {
|
|
|
342
387
|
' no-evidence nothing in the diff carries an evidence/ segment, which',
|
|
343
388
|
' is an ordinary, silent no-op rather than a refusal',
|
|
344
389
|
'',
|
|
390
|
+
"--preview puts the address on the body's first line. With no evidence",
|
|
391
|
+
'in the diff, the body is that line and the marker alone, reported as',
|
|
392
|
+
'ok, so the preview still lands in one comment a later call can edit.',
|
|
393
|
+
'Without --preview, an address the marked comment already opens with',
|
|
394
|
+
'is carried into the new body, so a re-render after a push keeps it.',
|
|
395
|
+
'',
|
|
345
396
|
'Exit codes:',
|
|
346
397
|
' 0 read, whether it produced a body or reported no-evidence',
|
|
347
398
|
' 1 refused, with the reason on stderr or in the JSON record',
|
|
@@ -349,12 +400,63 @@ export function register(program: Command): void {
|
|
|
349
400
|
'Examples:',
|
|
350
401
|
' canon pr evidence --json',
|
|
351
402
|
' canon pr evidence 1341 --json',
|
|
403
|
+
' canon pr evidence 1341 --preview https://feat-x.site.pages.dev --json',
|
|
352
404
|
'',
|
|
353
405
|
].join('\n'),
|
|
354
406
|
)
|
|
355
|
-
.action(async (number: string | undefined, opts:
|
|
407
|
+
.action(async (number: string | undefined, opts: EvidenceOptions) => {
|
|
356
408
|
process.exitCode = await runEvidence(number, opts)
|
|
357
409
|
})
|
|
410
|
+
|
|
411
|
+
pr.command('preview')
|
|
412
|
+
.description(
|
|
413
|
+
"Deploy the pull request's branch to a Cloudflare Pages preview and report its address",
|
|
414
|
+
)
|
|
415
|
+
.argument('[number]', 'Pull request to read, defaulting to this branch')
|
|
416
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
417
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
418
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
419
|
+
.option(
|
|
420
|
+
'--timeout <minutes>',
|
|
421
|
+
'How long to wait on the deploy run',
|
|
422
|
+
String(PREVIEW_TIMEOUT_MINUTES),
|
|
423
|
+
)
|
|
424
|
+
.addHelpText(
|
|
425
|
+
'after',
|
|
426
|
+
[
|
|
427
|
+
'',
|
|
428
|
+
'Finds the workflow under .github/workflows/ that runs pages deploy with',
|
|
429
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
|
|
430
|
+
'--branch=${{ github.ref_name }} and a workflow_dispatch trigger,',
|
|
431
|
+
"dispatches it on the pull request's head branch, waits on that run,",
|
|
432
|
+
'and reads the address from the canon-preview-alias line the workflow',
|
|
433
|
+
'prints. The address is read rather than computed from the branch name,',
|
|
434
|
+
'since Cloudflare flattens and may truncate a branch into its alias.',
|
|
435
|
+
'',
|
|
436
|
+
'A deploy without the --branch flag is refused before anything runs,',
|
|
437
|
+
'because Pages publishes an unfenced deploy to production.',
|
|
438
|
+
'',
|
|
439
|
+
'Read `reason` on the JSON record:',
|
|
440
|
+
' ok the preview was published, with its address in `url`',
|
|
441
|
+
' no-deploy no dispatchable workflow runs pages deploy',
|
|
442
|
+
' unfenced the deploy passes no --branch, so nothing was dispatched',
|
|
443
|
+
' no-alias the workflow prints no canon-preview-alias line',
|
|
444
|
+
' run-failed the deploy run finished without succeeding',
|
|
445
|
+
' timeout the run did not finish inside --timeout minutes',
|
|
446
|
+
'',
|
|
447
|
+
'Exit codes:',
|
|
448
|
+
' 0 a preview was published',
|
|
449
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
450
|
+
'',
|
|
451
|
+
'Examples:',
|
|
452
|
+
' canon pr preview --json',
|
|
453
|
+
' canon pr preview 1341 --json --timeout 20',
|
|
454
|
+
'',
|
|
455
|
+
].join('\n'),
|
|
456
|
+
)
|
|
457
|
+
.action(async (number: string | undefined, opts: PreviewOptions) => {
|
|
458
|
+
process.exitCode = await runPreview(number, opts)
|
|
459
|
+
})
|
|
358
460
|
}
|
|
359
461
|
|
|
360
462
|
interface PullRequestRead {
|
|
@@ -1105,7 +1207,7 @@ async function listTreePaths(
|
|
|
1105
1207
|
|
|
1106
1208
|
async function runEvidence(
|
|
1107
1209
|
number: string | undefined,
|
|
1108
|
-
opts:
|
|
1210
|
+
opts: EvidenceOptions,
|
|
1109
1211
|
): Promise<number> {
|
|
1110
1212
|
const root = resolve(opts.root ?? process.cwd())
|
|
1111
1213
|
const emitJson = opts.json ?? false
|
|
@@ -1157,7 +1259,7 @@ async function runEvidence(
|
|
|
1157
1259
|
baseTree.has(path),
|
|
1158
1260
|
)
|
|
1159
1261
|
|
|
1160
|
-
if (grouped.kind === 'refused') {
|
|
1262
|
+
if (grouped.kind === 'refused' && opts.preview === undefined) {
|
|
1161
1263
|
logStep('Skipped')
|
|
1162
1264
|
logInfo(
|
|
1163
1265
|
'No changed path carries an evidence/ segment, so there is nothing to post.',
|
|
@@ -1190,9 +1292,8 @@ async function runEvidence(
|
|
|
1190
1292
|
)
|
|
1191
1293
|
}
|
|
1192
1294
|
|
|
1193
|
-
const body = renderEvidenceBody(grouped.states, repo, base, identity.head)
|
|
1194
|
-
|
|
1195
1295
|
let commentId: number | undefined
|
|
1296
|
+
let carriedPreview: string | undefined
|
|
1196
1297
|
if (identity.number !== undefined) {
|
|
1197
1298
|
const commentsRow = await gh(root, [
|
|
1198
1299
|
'pr',
|
|
@@ -1201,24 +1302,44 @@ async function runEvidence(
|
|
|
1201
1302
|
'--json',
|
|
1202
1303
|
'comments',
|
|
1203
1304
|
])
|
|
1305
|
+
// An unread thread refuses rather than rendering, since a body built
|
|
1306
|
+
// without it knows neither the comment to edit nor the preview address
|
|
1307
|
+
// to carry, and posting it would duplicate the comment and drop the link.
|
|
1308
|
+
let comments: readonly { url?: string; body: string }[] | undefined
|
|
1204
1309
|
if (commentsRow !== null) {
|
|
1205
1310
|
try {
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1311
|
+
comments = (
|
|
1312
|
+
JSON.parse(commentsRow) as {
|
|
1313
|
+
comments?: readonly { url?: string; body: string }[]
|
|
1314
|
+
}
|
|
1315
|
+
).comments
|
|
1210
1316
|
} catch {
|
|
1211
|
-
|
|
1317
|
+
comments = undefined
|
|
1212
1318
|
}
|
|
1213
1319
|
}
|
|
1320
|
+
if (comments === undefined) {
|
|
1321
|
+
return refuseWith(
|
|
1322
|
+
'gh-failed',
|
|
1323
|
+
EVIDENCE_REFUSALS['gh-failed'],
|
|
1324
|
+
emitJson,
|
|
1325
|
+
root,
|
|
1326
|
+
)
|
|
1327
|
+
}
|
|
1328
|
+
commentId = findEvidenceCommentId(comments)
|
|
1329
|
+
carriedPreview = findEvidencePreview(comments)
|
|
1214
1330
|
}
|
|
1215
1331
|
|
|
1216
|
-
const
|
|
1332
|
+
const preview = opts.preview ?? carriedPreview
|
|
1333
|
+
const states = grouped.kind === 'read' ? grouped.states : []
|
|
1334
|
+
const body = renderEvidenceBody(states, repo, base, identity.head, preview)
|
|
1335
|
+
|
|
1336
|
+
const caseCount = states.reduce((n, s) => n + s.items.length, 0)
|
|
1217
1337
|
|
|
1218
1338
|
logStep('Scope')
|
|
1219
1339
|
logInfo(
|
|
1220
|
-
`${plural(caseCount, 'case')} across ${plural(
|
|
1340
|
+
`${plural(caseCount, 'case')} across ${plural(states.length, 'state')}`,
|
|
1221
1341
|
)
|
|
1342
|
+
if (preview !== undefined) logInfo(`preview ${preview}`)
|
|
1222
1343
|
|
|
1223
1344
|
outro()
|
|
1224
1345
|
|
|
@@ -1239,6 +1360,172 @@ async function runEvidence(
|
|
|
1239
1360
|
return 0
|
|
1240
1361
|
}
|
|
1241
1362
|
|
|
1363
|
+
async function readWorkflows(root: string): Promise<WorkflowFile[]> {
|
|
1364
|
+
const dir = join(root, '.github', 'workflows')
|
|
1365
|
+
if (!existsSync(dir)) return []
|
|
1366
|
+
const paths = [
|
|
1367
|
+
...new Bun.Glob('*.{yml,yaml}').scanSync({ cwd: dir, onlyFiles: true }),
|
|
1368
|
+
]
|
|
1369
|
+
return Promise.all(
|
|
1370
|
+
paths.map(async (name) => ({
|
|
1371
|
+
path: `.github/workflows/${name}`,
|
|
1372
|
+
text: await readFile(join(dir, name), 'utf8'),
|
|
1373
|
+
})),
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function parseRun(stdout: string | null): RunRow | undefined {
|
|
1378
|
+
if (stdout === null) return undefined
|
|
1379
|
+
try {
|
|
1380
|
+
return JSON.parse(stdout) as RunRow
|
|
1381
|
+
} catch {
|
|
1382
|
+
return undefined
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* The `gh` half of a preview. `--workflow` and `gh workflow run` both take the
|
|
1388
|
+
* workflow's file name rather than its repository path.
|
|
1389
|
+
*/
|
|
1390
|
+
function ghPreviewRunner(root: string): PreviewRunner {
|
|
1391
|
+
return {
|
|
1392
|
+
async dispatch(workflow, ref) {
|
|
1393
|
+
const out = await gh(root, [
|
|
1394
|
+
'workflow',
|
|
1395
|
+
'run',
|
|
1396
|
+
basename(workflow),
|
|
1397
|
+
'--ref',
|
|
1398
|
+
ref,
|
|
1399
|
+
])
|
|
1400
|
+
return out !== null
|
|
1401
|
+
},
|
|
1402
|
+
async listRuns(workflow, ref) {
|
|
1403
|
+
const out = await gh(root, [
|
|
1404
|
+
'run',
|
|
1405
|
+
'list',
|
|
1406
|
+
'--workflow',
|
|
1407
|
+
basename(workflow),
|
|
1408
|
+
'--branch',
|
|
1409
|
+
ref,
|
|
1410
|
+
'--event',
|
|
1411
|
+
'workflow_dispatch',
|
|
1412
|
+
'--limit',
|
|
1413
|
+
'20',
|
|
1414
|
+
'--json',
|
|
1415
|
+
'databaseId,status,conclusion',
|
|
1416
|
+
])
|
|
1417
|
+
if (out === null) return undefined
|
|
1418
|
+
try {
|
|
1419
|
+
return JSON.parse(out) as RunRow[]
|
|
1420
|
+
} catch {
|
|
1421
|
+
return undefined
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
async viewRun(id) {
|
|
1425
|
+
return parseRun(
|
|
1426
|
+
await gh(root, [
|
|
1427
|
+
'run',
|
|
1428
|
+
'view',
|
|
1429
|
+
String(id),
|
|
1430
|
+
'--json',
|
|
1431
|
+
'databaseId,status,conclusion',
|
|
1432
|
+
]),
|
|
1433
|
+
)
|
|
1434
|
+
},
|
|
1435
|
+
async readLog(id) {
|
|
1436
|
+
return (await gh(root, ['run', 'view', String(id), '--log'])) ?? undefined
|
|
1437
|
+
},
|
|
1438
|
+
now: () => Date.now(),
|
|
1439
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function runPreview(
|
|
1444
|
+
number: string | undefined,
|
|
1445
|
+
opts: PreviewOptions,
|
|
1446
|
+
): Promise<number> {
|
|
1447
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
1448
|
+
const emitJson = opts.json ?? false
|
|
1449
|
+
|
|
1450
|
+
intro('canon pr preview')
|
|
1451
|
+
|
|
1452
|
+
const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
|
|
1453
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
1454
|
+
return refuseWith(
|
|
1455
|
+
'bad-timeout',
|
|
1456
|
+
PREVIEW_REFUSALS['bad-timeout'],
|
|
1457
|
+
emitJson,
|
|
1458
|
+
root,
|
|
1459
|
+
)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Read ahead of the pull request, so a project with no fenced deploy is
|
|
1463
|
+
// refused without a network call and never reaches a dispatch.
|
|
1464
|
+
const pick = findDeployWorkflow(await readWorkflows(root))
|
|
1465
|
+
if (pick.kind === 'refused') {
|
|
1466
|
+
return refuseWith(
|
|
1467
|
+
pick.reason,
|
|
1468
|
+
PREVIEW_REFUSALS[pick.reason],
|
|
1469
|
+
emitJson,
|
|
1470
|
+
root,
|
|
1471
|
+
)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
const read = await readIdentity(root, number)
|
|
1475
|
+
if (read.kind === 'refused') {
|
|
1476
|
+
return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
|
|
1477
|
+
}
|
|
1478
|
+
const { identity } = read
|
|
1479
|
+
|
|
1480
|
+
logStep('Deploy')
|
|
1481
|
+
logInfo(`${pick.path} on ${identity.branch}`)
|
|
1482
|
+
|
|
1483
|
+
const result = await mintPreview(ghPreviewRunner(root), {
|
|
1484
|
+
workflow: pick.path,
|
|
1485
|
+
branch: identity.branch,
|
|
1486
|
+
timeoutMs: minutes * 60_000,
|
|
1487
|
+
pollMs: PREVIEW_POLL_MS,
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
if (result.kind === 'refused') {
|
|
1491
|
+
logStep('Refused')
|
|
1492
|
+
logWarn(PREVIEW_REFUSALS[result.reason])
|
|
1493
|
+
outro()
|
|
1494
|
+
if (emitJson) {
|
|
1495
|
+
process.stdout.write(
|
|
1496
|
+
`${JSON.stringify({
|
|
1497
|
+
root,
|
|
1498
|
+
...(identity.number !== undefined && { number: identity.number }),
|
|
1499
|
+
reason: result.reason,
|
|
1500
|
+
message: PREVIEW_REFUSALS[result.reason],
|
|
1501
|
+
workflow: pick.path,
|
|
1502
|
+
...(result.runId !== undefined && { runId: result.runId }),
|
|
1503
|
+
})}\n`,
|
|
1504
|
+
)
|
|
1505
|
+
}
|
|
1506
|
+
return 1
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
logStep('Preview')
|
|
1510
|
+
logInfo(result.url)
|
|
1511
|
+
outro()
|
|
1512
|
+
|
|
1513
|
+
if (emitJson) {
|
|
1514
|
+
process.stdout.write(
|
|
1515
|
+
`${JSON.stringify({
|
|
1516
|
+
root,
|
|
1517
|
+
...(identity.number !== undefined && { number: identity.number }),
|
|
1518
|
+
reason: 'ok',
|
|
1519
|
+
url: result.url,
|
|
1520
|
+
workflow: pick.path,
|
|
1521
|
+
runId: result.runId,
|
|
1522
|
+
})}\n`,
|
|
1523
|
+
)
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
return 0
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1242
1529
|
/**
|
|
1243
1530
|
* Frames a refusal on stderr in both modes and puts the record on stdout alone,
|
|
1244
1531
|
* so an operator reading the terminal sees the reason rather than a command
|
package/src/pr/evidence.ts
CHANGED
|
@@ -4,6 +4,9 @@ const EVIDENCE_SEGMENT = 'evidence'
|
|
|
4
4
|
/** Prefix of the trailing marker this module writes and reads back. */
|
|
5
5
|
const MARKER_PREFIX = '<!-- pr-evidence:'
|
|
6
6
|
|
|
7
|
+
/** How the first line of a body names the branch's preview deployment. */
|
|
8
|
+
const PREVIEW_PREFIX = '**Preview:**'
|
|
9
|
+
|
|
7
10
|
/** A `#issuecomment-<id>` suffix, which is the REST comment id `gh pr view` never returns directly. */
|
|
8
11
|
const ISSUE_COMMENT_ID = /#issuecomment-(\d+)$/
|
|
9
12
|
|
|
@@ -109,13 +112,24 @@ export function evidenceMarker(head: string): string {
|
|
|
109
112
|
* a Base/Head row per case, and the trailing marker naming the head this body
|
|
110
113
|
* describes. Every image URL is pinned to a commit sha rather than a branch,
|
|
111
114
|
* so the comment keeps showing what it claimed even after the branch moves.
|
|
115
|
+
*
|
|
116
|
+
* A preview address opens the body, and with no states it is the whole body
|
|
117
|
+
* apart from the marker, so a pull request whose screenshots did not change
|
|
118
|
+
* still gets one comment a later call can find and edit in place.
|
|
112
119
|
*/
|
|
113
120
|
export function renderEvidenceBody(
|
|
114
121
|
states: readonly EvidenceState[],
|
|
115
122
|
repo: string,
|
|
116
123
|
base: string,
|
|
117
124
|
head: string,
|
|
125
|
+
preview?: string,
|
|
118
126
|
): string {
|
|
127
|
+
const opening =
|
|
128
|
+
preview === undefined ? [] : [`${PREVIEW_PREFIX} ${preview}`, '']
|
|
129
|
+
if (states.length === 0) {
|
|
130
|
+
return [...opening, evidenceMarker(head)].join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
119
133
|
const sections = states.map((entry) => {
|
|
120
134
|
const rows = entry.items.map((item) => {
|
|
121
135
|
const before = item.added
|
|
@@ -137,7 +151,14 @@ export function renderEvidenceBody(
|
|
|
137
151
|
].join('\n')
|
|
138
152
|
})
|
|
139
153
|
|
|
140
|
-
return [
|
|
154
|
+
return [
|
|
155
|
+
...opening,
|
|
156
|
+
'## Evidence',
|
|
157
|
+
'',
|
|
158
|
+
...sections,
|
|
159
|
+
'',
|
|
160
|
+
evidenceMarker(head),
|
|
161
|
+
].join('\n')
|
|
141
162
|
}
|
|
142
163
|
|
|
143
164
|
export interface EvidenceComment {
|
|
@@ -169,3 +190,17 @@ export function findEvidenceCommentId(
|
|
|
169
190
|
}
|
|
170
191
|
return undefined
|
|
171
192
|
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The preview address the marked comment already opens with. A re-render
|
|
196
|
+
* after a later push passes no address of its own, and carrying this one
|
|
197
|
+
* forward keeps that edit from deleting the link a reviewer is using.
|
|
198
|
+
*/
|
|
199
|
+
export function findEvidencePreview(
|
|
200
|
+
comments: readonly EvidenceComment[],
|
|
201
|
+
): string | undefined {
|
|
202
|
+
const marked = comments.find((comment) => hasEvidenceMarker(comment.body))
|
|
203
|
+
const first = marked?.body.split('\n')[0]?.trim() ?? ''
|
|
204
|
+
if (!first.startsWith(PREVIEW_PREFIX)) return undefined
|
|
205
|
+
return first.slice(PREVIEW_PREFIX.length).trim() || undefined
|
|
206
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line a deploy workflow prints once Cloudflare returns the branch alias.
|
|
3
|
+
* The workflow and this reader agree on it and on nothing else, so the text is
|
|
4
|
+
* a contract with `tooling/cloudflare/configs/.github/workflows/deploy.yml`.
|
|
5
|
+
*/
|
|
6
|
+
const ALIAS_MARKER = 'canon-preview-alias:'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Anchored on the address itself, since GitHub echoes a step's script above
|
|
10
|
+
* its output and that echo carries the marker with `$ALIAS_URL` unexpanded.
|
|
11
|
+
*/
|
|
12
|
+
const ALIAS_LINE = /canon-preview-alias:\s*(https:\/\/\S+)/
|
|
13
|
+
|
|
14
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
|
|
15
|
+
const BRANCH_FENCE = '--branch=${{ github.ref_name }}'
|
|
16
|
+
|
|
17
|
+
export type PreviewRefusal =
|
|
18
|
+
| 'no-deploy'
|
|
19
|
+
| 'unfenced'
|
|
20
|
+
| 'no-alias'
|
|
21
|
+
| 'gh-failed'
|
|
22
|
+
| 'run-failed'
|
|
23
|
+
| 'timeout'
|
|
24
|
+
|
|
25
|
+
export interface WorkflowFile {
|
|
26
|
+
readonly path: string
|
|
27
|
+
readonly text: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type WorkflowPick =
|
|
31
|
+
| { readonly kind: 'found'; readonly path: string }
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'refused'
|
|
34
|
+
readonly reason: 'no-deploy' | 'unfenced' | 'no-alias'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface WorkflowShape {
|
|
38
|
+
readonly path: string
|
|
39
|
+
readonly deployLines: readonly string[]
|
|
40
|
+
readonly isDispatchable: boolean
|
|
41
|
+
readonly hasMarker: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads a workflow's lines with YAML comment lines dropped, since a comment
|
|
46
|
+
* naming the fence would otherwise pass for a fenced command.
|
|
47
|
+
*/
|
|
48
|
+
function readShape(file: WorkflowFile): WorkflowShape {
|
|
49
|
+
const lines = file.text
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
52
|
+
return {
|
|
53
|
+
path: file.path,
|
|
54
|
+
deployLines: lines.filter((line) => line.includes('pages deploy')),
|
|
55
|
+
isDispatchable: lines.some((line) => /^\s*workflow_dispatch:/.test(line)),
|
|
56
|
+
hasMarker: lines.some((line) => line.includes(ALIAS_MARKER)),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Picks the workflow a preview dispatch runs: one that deploys to Pages and
|
|
62
|
+
* can be dispatched. A workflow with any `pages deploy` line passing no
|
|
63
|
+
* `--branch` is refused rather than dispatched, because Pages then publishes
|
|
64
|
+
* whatever ref ran to production.
|
|
65
|
+
*/
|
|
66
|
+
export function findDeployWorkflow(
|
|
67
|
+
files: readonly WorkflowFile[],
|
|
68
|
+
): WorkflowPick {
|
|
69
|
+
const dispatchable = [...files]
|
|
70
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
71
|
+
.map(readShape)
|
|
72
|
+
.filter((shape) => shape.deployLines.length > 0 && shape.isDispatchable)
|
|
73
|
+
if (dispatchable.length === 0) return { kind: 'refused', reason: 'no-deploy' }
|
|
74
|
+
|
|
75
|
+
const fenced = dispatchable.filter((shape) =>
|
|
76
|
+
shape.deployLines.every((line) => line.includes(BRANCH_FENCE)),
|
|
77
|
+
)
|
|
78
|
+
if (fenced.length === 0) return { kind: 'refused', reason: 'unfenced' }
|
|
79
|
+
|
|
80
|
+
const marked = fenced.find((shape) => shape.hasMarker)
|
|
81
|
+
if (marked === undefined) return { kind: 'refused', reason: 'no-alias' }
|
|
82
|
+
|
|
83
|
+
return { kind: 'found', path: marked.path }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readPreviewAlias(log: string): string | undefined {
|
|
87
|
+
for (const line of log.split('\n')) {
|
|
88
|
+
const match = ALIAS_LINE.exec(line)
|
|
89
|
+
if (match?.[1] !== undefined) return match[1]
|
|
90
|
+
}
|
|
91
|
+
return undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface RunRow {
|
|
95
|
+
readonly databaseId: number
|
|
96
|
+
readonly status: string
|
|
97
|
+
readonly conclusion: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
|
|
101
|
+
export interface PreviewRunner {
|
|
102
|
+
dispatch(workflow: string, ref: string): Promise<boolean>
|
|
103
|
+
listRuns(
|
|
104
|
+
workflow: string,
|
|
105
|
+
ref: string,
|
|
106
|
+
): Promise<readonly RunRow[] | undefined>
|
|
107
|
+
viewRun(id: number): Promise<RunRow | undefined>
|
|
108
|
+
readLog(id: number): Promise<string | undefined>
|
|
109
|
+
now(): number
|
|
110
|
+
sleep(ms: number): Promise<void>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PreviewSettings {
|
|
114
|
+
readonly workflow: string
|
|
115
|
+
readonly branch: string
|
|
116
|
+
readonly timeoutMs: number
|
|
117
|
+
readonly pollMs: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type PreviewResult =
|
|
121
|
+
| { readonly kind: 'minted'; readonly runId: number; readonly url: string }
|
|
122
|
+
| {
|
|
123
|
+
readonly kind: 'refused'
|
|
124
|
+
readonly reason: PreviewRefusal
|
|
125
|
+
readonly runId?: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Dispatches the deploy on the branch, waits for that run within the bound,
|
|
130
|
+
* and reads the alias out of its log.
|
|
131
|
+
*
|
|
132
|
+
* `gh workflow run` reports no run id, so the run is found as the one id
|
|
133
|
+
* absent from a listing taken before the dispatch. Comparing ids rather than
|
|
134
|
+
* creation times keeps the match independent of this machine's clock.
|
|
135
|
+
*/
|
|
136
|
+
export async function mintPreview(
|
|
137
|
+
runner: PreviewRunner,
|
|
138
|
+
settings: PreviewSettings,
|
|
139
|
+
): Promise<PreviewResult> {
|
|
140
|
+
const { workflow, branch, timeoutMs, pollMs } = settings
|
|
141
|
+
const deadline = runner.now() + timeoutMs
|
|
142
|
+
|
|
143
|
+
const before = await runner.listRuns(workflow, branch)
|
|
144
|
+
if (before === undefined) return { kind: 'refused', reason: 'gh-failed' }
|
|
145
|
+
const known = new Set(before.map((row) => row.databaseId))
|
|
146
|
+
|
|
147
|
+
if (!(await runner.dispatch(workflow, branch))) {
|
|
148
|
+
return { kind: 'refused', reason: 'gh-failed' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let runId: number | undefined
|
|
152
|
+
while (runId === undefined) {
|
|
153
|
+
if (runner.now() >= deadline) return { kind: 'refused', reason: 'timeout' }
|
|
154
|
+
await runner.sleep(pollMs)
|
|
155
|
+
const rows = await runner.listRuns(workflow, branch)
|
|
156
|
+
runId = rows?.find((row) => !known.has(row.databaseId))?.databaseId
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (;;) {
|
|
160
|
+
const row = await runner.viewRun(runId)
|
|
161
|
+
if (row?.status === 'completed') {
|
|
162
|
+
if (row.conclusion !== 'success') {
|
|
163
|
+
return { kind: 'refused', reason: 'run-failed', runId }
|
|
164
|
+
}
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
if (runner.now() >= deadline) {
|
|
168
|
+
return { kind: 'refused', reason: 'timeout', runId }
|
|
169
|
+
}
|
|
170
|
+
await runner.sleep(pollMs)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const log = await runner.readLog(runId)
|
|
174
|
+
const url = log === undefined ? undefined : readPreviewAlias(log)
|
|
175
|
+
if (url === undefined) return { kind: 'refused', reason: 'no-alias', runId }
|
|
176
|
+
|
|
177
|
+
return { kind: 'minted', runId, url }
|
|
178
|
+
}
|
|
@@ -14,7 +14,9 @@ export default defineConfig({
|
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
16
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
-
|
|
17
|
+
// It is a context option rather than a top-level one, so written beside
|
|
18
|
+
// `baseURL` it type-checks nowhere and reaches no page.
|
|
19
|
+
contextOptions: { reducedMotion: 'reduce' },
|
|
18
20
|
},
|
|
19
21
|
projects: [
|
|
20
22
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|