@erclx/canon 5.50.0 → 5.52.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +4037 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/fan-in.md +20 -6
  4. package/claude/skills/canon-cli/SKILL.md +9 -7
  5. package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
  6. package/claude/skills/ci-workflow/SKILL.md +26 -1
  7. package/claude/skills/ci-workflow/references/workflows.md +28 -0
  8. package/claude/skills/design-record/references/folder.md +1 -1
  9. package/claude/skills/design-taste/SKILL.md +2 -1
  10. package/claude/skills/git-followup/SKILL.md +1 -0
  11. package/claude/skills/git-pr/references/pr.md +2 -2
  12. package/claude/skills/plan-feature/references/batches.md +1 -1
  13. package/claude/skills/review-address/SKILL.md +1 -1
  14. package/claude/skills/review-address/references/post-review-findings.md +1 -1
  15. package/claude/skills/review-pr/references/post.md +2 -2
  16. package/claude/skills/target-check/SKILL.md +2 -0
  17. package/claude/skills/target-check/references/domains.md +8 -6
  18. package/claude/skills/target-setup/SKILL.md +1 -1
  19. package/claude/skills/target-update/REQUIREMENT.md +33 -0
  20. package/claude/skills/target-update/SKILL.md +136 -0
  21. package/docs/agents/index.md +1 -1
  22. package/docs/agents/migrate.md +3 -0
  23. package/docs/agents/targets.md +8 -1
  24. package/docs/target/migrations.md +3 -1
  25. package/docs/target/projects.md +1 -1
  26. package/docs/target/sync.md +1 -1
  27. package/docs/workflow/parallel-features.md +1 -1
  28. package/docs/workflow/skill-map.md +1 -0
  29. package/governance/detect.toml +1 -1
  30. package/governance/ledger/tooling/ci-workflow.md +6 -0
  31. package/governance/ledger/ui/design-reference.md +8 -13
  32. package/governance/rules/tooling/ci-workflow.md +4 -0
  33. package/governance/rules/ui/design-reference.md +3 -6
  34. package/package.json +2 -1
  35. package/scripts/ci/check-browsers.sh +13 -0
  36. package/scripts/ci/delete-previews.sh +34 -0
  37. package/scripts/ci/publish-check.sh +29 -0
  38. package/scripts/ci/refresh-frames-pr.sh +63 -0
  39. package/scripts/ci/report-baseline.sh +23 -0
  40. package/scripts/ci/stack-issues.sh +24 -0
  41. package/scripts/ci/verify-npm-token.sh +14 -0
  42. package/src/claude/cases/setup.ts +7 -0
  43. package/src/cli/help.ts +1 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/changelog.ts +146 -0
  46. package/src/commands/hooks.ts +7 -5
  47. package/src/commands/labels.ts +9 -7
  48. package/src/commands/migrate.ts +77 -0
  49. package/src/commands/worktrees.ts +273 -0
  50. package/src/gov/detect.ts +28 -10
  51. package/src/hooks/post-merge.ts +45 -6
  52. package/src/migrate/status.ts +132 -0
  53. package/src/version/changelog.ts +156 -0
  54. package/src/worktrees/members.ts +540 -0
  55. package/tooling/base/configs/.husky/post-merge +7 -6
  56. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
  57. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
  58. package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
  59. package/tooling/cloudflare/reference.md +7 -4
  60. package/tooling/web/reference.md +5 -0
  61. package/tooling/web/seeds/canon/design/index.md +23 -0
@@ -0,0 +1,540 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git/env'
3
+ import {
4
+ type ReclaimOptions,
5
+ reclaimReport,
6
+ type Refusal,
7
+ type Unreadable,
8
+ type WorktreeVerdict,
9
+ } from '@/worktrees/reclaim'
10
+ import { removeReclaimable } from '@/worktrees/remove'
11
+
12
+ const GH_TIMEOUT_MS = 30_000
13
+ const GIT_TIMEOUT_MS = 60_000
14
+
15
+ /** Matches the reclaim's merged read, so an open pull request older than this is missed the same way. */
16
+ const OPEN_LIMIT = 200
17
+
18
+ /**
19
+ * The fixed lead the integrator's body carries, stated in the auto-ship
20
+ * fan-in reference. Anything outside this one line is never read, so a branch
21
+ * named in prose is never a candidate.
22
+ */
23
+ const MEMBER_LINE = /^[ \t]*(?:[-*][ \t]+)?Fan-in members:(.*)$/m
24
+ const BACKTICKED = /`([^`\s]+)`/g
25
+
26
+ /**
27
+ * Why the whole run was refused before any member was judged. Every one of
28
+ * these deletes nothing at all, since each is an input the per-branch checks
29
+ * stand on or a body that names something no merge retires.
30
+ */
31
+ export type MembersRefusal =
32
+ | Extract<
33
+ Unreadable,
34
+ 'gh-missing' | 'gh-failed' | 'sessions-unreadable' | 'sessions-unchecked'
35
+ >
36
+ | 'not-merged'
37
+ | 'no-members'
38
+ | 'trunk-named'
39
+ | 'head-named'
40
+ | 'open-pull-request'
41
+ | 'head-unreadable'
42
+
43
+ /**
44
+ * Why one half of one member was kept. The worktree reasons are the reclaim's
45
+ * own, since the same reading decided them.
46
+ */
47
+ export type MemberRefusal =
48
+ | 'not-in-merged-head'
49
+ | 'remote-unreadable'
50
+ | 'worktree-kept'
51
+ | Exclude<Refusal, 'no-merged-pull-request'>
52
+
53
+ export type HalfState =
54
+ | 'deleted'
55
+ | 'would-delete'
56
+ | 'kept'
57
+ | 'absent'
58
+ | 'failed'
59
+
60
+ export interface Half {
61
+ readonly state: HalfState
62
+ /** Every reason the half was kept, empty for any other state. */
63
+ readonly reasons: readonly MemberRefusal[]
64
+ /** The git error on a failed half, null otherwise. */
65
+ readonly detail: string | null
66
+ }
67
+
68
+ export interface MemberOutcome {
69
+ readonly branch: string
70
+ readonly origin: Half
71
+ /** Null when no worktree holds the branch. */
72
+ readonly worktree: (Half & { readonly path: string }) | null
73
+ readonly local: Half
74
+ }
75
+
76
+ export type MembersReport =
77
+ | {
78
+ readonly kind: 'refused'
79
+ readonly reason: MembersRefusal
80
+ readonly detail: string
81
+ /** The branches the member line named, empty when no line was read. */
82
+ readonly members: readonly string[]
83
+ }
84
+ | { readonly kind: 'read'; readonly members: readonly MemberOutcome[] }
85
+
86
+ export interface CommandResult {
87
+ readonly exitCode: number
88
+ readonly stdout: string
89
+ readonly stderr: string
90
+ }
91
+
92
+ type Runner = (cwd: string, args: readonly string[]) => Promise<CommandResult>
93
+
94
+ export interface MembersOptions {
95
+ /** Where the reclaim reading runs, which is what refuses the worktree the caller stands in. */
96
+ readonly cwd: string
97
+ /** The main worktree root, where every git write runs, for the reason `removeReclaimable` states. */
98
+ readonly root: string
99
+ readonly dryRun?: boolean
100
+ /** Null when `gh` is not on the path. */
101
+ readonly gh?: Runner | null
102
+ readonly git?: Runner
103
+ /** Overrides for the reclaim reading, past the merged read this module always supplies. */
104
+ readonly reclaim?: Omit<ReclaimOptions, 'cwd' | 'mergedPullRequests'>
105
+ }
106
+
107
+ async function run(
108
+ command: string,
109
+ cwd: string,
110
+ args: readonly string[],
111
+ timeout: number,
112
+ ): Promise<CommandResult> {
113
+ // A hook exports git's resolution variables, which beat both `-C` and the
114
+ // working directory `gh` resolves its repository through.
115
+ const result = await execa(command, [...args], {
116
+ cwd,
117
+ timeout,
118
+ env: gitEnv(),
119
+ extendEnv: false,
120
+ reject: false,
121
+ })
122
+ return {
123
+ exitCode: result.exitCode ?? 1,
124
+ stdout: result.stdout,
125
+ stderr: result.stderr.trim(),
126
+ }
127
+ }
128
+
129
+ const runGit: Runner = (cwd, args) => run('git', cwd, args, GIT_TIMEOUT_MS)
130
+ const runGh: Runner = (cwd, args) => run('gh', cwd, args, GH_TIMEOUT_MS)
131
+
132
+ /**
133
+ * Reads the branches an integrator's body names on its member line, in the
134
+ * order named. Only backticked names after the fixed lead count, and a body
135
+ * carrying no such line names nobody.
136
+ */
137
+ export function parseMembers(body: string): string[] {
138
+ const line = MEMBER_LINE.exec(body)
139
+ if (line === null) return []
140
+
141
+ const names = [...(line[1] ?? '').matchAll(BACKTICKED)].map(
142
+ (match) => match[1] ?? '',
143
+ )
144
+ return [...new Set(names.filter((name) => name.length > 0))]
145
+ }
146
+
147
+ interface PullRequestView {
148
+ readonly state: string
149
+ readonly body: string
150
+ readonly headRefName: string
151
+ readonly headRefOid: string
152
+ readonly baseRefName: string
153
+ }
154
+
155
+ function isView(value: unknown): value is PullRequestView {
156
+ if (value === null || typeof value !== 'object') return false
157
+ const fields = value as Record<string, unknown>
158
+ return ['state', 'body', 'headRefName', 'headRefOid', 'baseRefName'].every(
159
+ (key) => typeof fields[key] === 'string',
160
+ )
161
+ }
162
+
163
+ function parseJson(stdout: string): unknown {
164
+ try {
165
+ return JSON.parse(stdout)
166
+ } catch {
167
+ return undefined
168
+ }
169
+ }
170
+
171
+ function refused(
172
+ reason: MembersRefusal,
173
+ detail: string,
174
+ members: readonly string[] = [],
175
+ ): MembersReport {
176
+ return { kind: 'refused', reason, detail, members }
177
+ }
178
+
179
+ const kept = (...reasons: MemberRefusal[]): Half => ({
180
+ state: 'kept',
181
+ reasons,
182
+ detail: null,
183
+ })
184
+ const plain = (state: HalfState, detail: string | null = null): Half => ({
185
+ state,
186
+ reasons: [],
187
+ detail,
188
+ })
189
+
190
+ interface Plan {
191
+ readonly branch: string
192
+ /** The tip origin holds, null when origin has no such branch. */
193
+ readonly originTip: string | null
194
+ readonly origin: Half
195
+ readonly verdict: WorktreeVerdict | null
196
+ readonly localTip: string | null
197
+ readonly worktree: Half | null
198
+ readonly local: Half
199
+ }
200
+
201
+ /**
202
+ * Retires the fan-in members an integrator's merged pull request names: each
203
+ * one's origin branch, its worktree, and its local branch.
204
+ *
205
+ * Merged here means reachable from the integrator's merged head, not merged
206
+ * into the trunk. The integrator squash merges, so no member is ever an
207
+ * ancestor of the trunk, but every member was merged into the integrator's
208
+ * branch before it shipped. A commit landing on a member after that merge is
209
+ * work the squash did not carry, and its tip stops being an ancestor.
210
+ *
211
+ * Origin and local are judged apart, since an unpushed local commit and a
212
+ * commit pushed from elsewhere each keep only their own half. The worktree
213
+ * takes the reclaim's reading with the members injected as its merged answer,
214
+ * so a held, dirty, or current worktree is refused by the same rules as
215
+ * everywhere else, and its local branch stays with it because git refuses to
216
+ * delete a branch a registered worktree holds.
217
+ *
218
+ * Every input is read before anything is deleted, and an unreadable one
219
+ * refuses the whole run, for the reason `reclaimReport` states.
220
+ */
221
+ export async function retireMembers(
222
+ pullRequest: number,
223
+ opts: MembersOptions,
224
+ ): Promise<MembersReport> {
225
+ const { cwd, root } = opts
226
+ const dryRun = opts.dryRun ?? false
227
+ const gh = opts.gh === undefined ? (Bun.which('gh') ? runGh : null) : opts.gh
228
+ const git = opts.git ?? runGit
229
+
230
+ if (gh === null) {
231
+ return refused(
232
+ 'gh-missing',
233
+ 'gh is not on the path, so the pull request could not be read.',
234
+ )
235
+ }
236
+
237
+ const viewed = await gh(root, [
238
+ 'pr',
239
+ 'view',
240
+ String(pullRequest),
241
+ '--json',
242
+ 'state,body,headRefName,headRefOid,baseRefName',
243
+ ])
244
+ const view = viewed.exitCode === 0 ? parseJson(viewed.stdout) : undefined
245
+ if (!isView(view)) {
246
+ return refused(
247
+ 'gh-failed',
248
+ viewed.stderr || `gh pr view ${pullRequest} returned no readable record.`,
249
+ )
250
+ }
251
+
252
+ const members = parseMembers(view.body)
253
+ if (view.state !== 'MERGED') {
254
+ return refused(
255
+ 'not-merged',
256
+ `#${pullRequest} is ${view.state.toLowerCase()}, so nothing it names has shipped.`,
257
+ members,
258
+ )
259
+ }
260
+ if (members.length === 0) {
261
+ return refused(
262
+ 'no-members',
263
+ `#${pullRequest} carries no "Fan-in members:" line.`,
264
+ )
265
+ }
266
+
267
+ if (members.includes(view.baseRefName)) {
268
+ return refused(
269
+ 'trunk-named',
270
+ `The member line names ${view.baseRefName}, the branch #${pullRequest} merged into.`,
271
+ members,
272
+ )
273
+ }
274
+ if (members.includes(view.headRefName)) {
275
+ return refused(
276
+ 'head-named',
277
+ `The member line names ${view.headRefName}, the integrator's own branch.`,
278
+ members,
279
+ )
280
+ }
281
+
282
+ const listed = await gh(root, [
283
+ 'pr',
284
+ 'list',
285
+ '--state',
286
+ 'open',
287
+ '--limit',
288
+ String(OPEN_LIMIT),
289
+ '--json',
290
+ 'headRefName,number',
291
+ ])
292
+ const open = listed.exitCode === 0 ? parseJson(listed.stdout) : undefined
293
+ if (!Array.isArray(open)) {
294
+ return refused(
295
+ 'gh-failed',
296
+ listed.stderr || 'gh pr list returned no readable record.',
297
+ members,
298
+ )
299
+ }
300
+ const openHeads = new Set(
301
+ open.flatMap((row: unknown) =>
302
+ row !== null &&
303
+ typeof row === 'object' &&
304
+ typeof (row as Record<string, unknown>).headRefName === 'string'
305
+ ? [(row as Record<string, string>).headRefName]
306
+ : [],
307
+ ),
308
+ )
309
+ const stillOpen = members.filter((branch) => openHeads.has(branch))
310
+ if (stillOpen.length > 0) {
311
+ return refused(
312
+ 'open-pull-request',
313
+ `${stillOpen.join(', ')} has an open pull request of its own, so it is not a member this merge retires.`,
314
+ members,
315
+ )
316
+ }
317
+
318
+ const reading = await reclaimReport({
319
+ ...opts.reclaim,
320
+ cwd,
321
+ mergedPullRequests: async () => ({
322
+ kind: 'read',
323
+ merged: members.map((branch) => ({ branch, number: pullRequest })),
324
+ }),
325
+ })
326
+ if (reading.kind === 'unreadable') {
327
+ // The merged read is injected, so only the session refusals reach here.
328
+ const reason =
329
+ reading.reason === 'sessions-unchecked'
330
+ ? 'sessions-unchecked'
331
+ : 'sessions-unreadable'
332
+ return refused(reason, reading.detail, members)
333
+ }
334
+
335
+ // With head-branch deletion on, the integrator's branch is gone from origin
336
+ // by now, and GitHub keeps this ref for every pull request it has opened.
337
+ const fetched = await git(root, [
338
+ 'fetch',
339
+ '--quiet',
340
+ '--no-tags',
341
+ 'origin',
342
+ `refs/pull/${pullRequest}/head`,
343
+ ])
344
+ const fetchHead =
345
+ fetched.exitCode === 0
346
+ ? await git(root, ['rev-parse', '--verify', '--quiet', 'FETCH_HEAD'])
347
+ : null
348
+ if (fetchHead === null || fetchHead.stdout.trim() !== view.headRefOid) {
349
+ return refused(
350
+ 'head-unreadable',
351
+ fetched.stderr ||
352
+ `refs/pull/${pullRequest}/head does not resolve to ${view.headRefOid}, the head GitHub reports merged.`,
353
+ members,
354
+ )
355
+ }
356
+
357
+ const merged = async (tip: string) =>
358
+ (await git(root, ['merge-base', '--is-ancestor', tip, view.headRefOid]))
359
+ .exitCode === 0
360
+
361
+ const plans: Plan[] = []
362
+ for (const branch of members) {
363
+ plans.push(
364
+ await judge(branch, {
365
+ git,
366
+ root,
367
+ merged,
368
+ dryRun,
369
+ verdict:
370
+ reading.worktrees.find((entry) => entry.branch === branch) ?? null,
371
+ }),
372
+ )
373
+ }
374
+
375
+ if (dryRun) {
376
+ return {
377
+ kind: 'read',
378
+ members: plans.map(({ branch, origin, verdict, worktree, local }) => ({
379
+ branch,
380
+ origin,
381
+ worktree:
382
+ verdict === null || worktree === null
383
+ ? null
384
+ : { ...worktree, path: verdict.path },
385
+ local,
386
+ })),
387
+ }
388
+ }
389
+
390
+ const outcomes: MemberOutcome[] = []
391
+ for (const plan of plans) outcomes.push(await act(plan, git, root))
392
+ return { kind: 'read', members: outcomes }
393
+ }
394
+
395
+ interface JudgeContext {
396
+ readonly git: Runner
397
+ readonly root: string
398
+ readonly merged: (tip: string) => Promise<boolean>
399
+ readonly dryRun: boolean
400
+ readonly verdict: WorktreeVerdict | null
401
+ }
402
+
403
+ /** Decides each half of one member from its own tip, deleting nothing. */
404
+ async function judge(branch: string, context: JudgeContext): Promise<Plan> {
405
+ const { git, root, merged, dryRun, verdict } = context
406
+ const go: Half = plain(dryRun ? 'would-delete' : 'deleted')
407
+
408
+ // Read from origin itself rather than the tracking ref, which holds only
409
+ // what the last fetch saw and misses a commit pushed since.
410
+ const remote = await git(root, [
411
+ 'ls-remote',
412
+ '--heads',
413
+ 'origin',
414
+ `refs/heads/${branch}`,
415
+ ])
416
+ const originTip =
417
+ remote.exitCode === 0 ? (remote.stdout.trim().split(/\s/)[0] ?? '') : null
418
+ const origin =
419
+ originTip === null
420
+ ? kept('remote-unreadable')
421
+ : originTip === ''
422
+ ? plain('absent')
423
+ : (await merged(originTip))
424
+ ? go
425
+ : kept('not-in-merged-head')
426
+
427
+ const head = await git(root, [
428
+ 'rev-parse',
429
+ '--verify',
430
+ '--quiet',
431
+ `refs/heads/${branch}`,
432
+ ])
433
+ const localTip = head.exitCode === 0 ? head.stdout.trim() : null
434
+ const localMerged = localTip !== null && (await merged(localTip))
435
+
436
+ const worktreeReasons: MemberRefusal[] = [
437
+ ...(verdict?.refusals.filter(
438
+ (refusal): refusal is Exclude<Refusal, 'no-merged-pull-request'> =>
439
+ refusal !== 'no-merged-pull-request',
440
+ ) ?? []),
441
+ ...(localTip !== null && !localMerged
442
+ ? (['not-in-merged-head'] as const)
443
+ : []),
444
+ ]
445
+ const worktree =
446
+ verdict === null
447
+ ? null
448
+ : worktreeReasons.length > 0
449
+ ? kept(...worktreeReasons)
450
+ : go
451
+
452
+ const local =
453
+ localTip === null
454
+ ? plain('absent')
455
+ : !localMerged
456
+ ? kept('not-in-merged-head')
457
+ : worktree !== null && worktree.state === 'kept'
458
+ ? kept('worktree-kept')
459
+ : go
460
+
461
+ return {
462
+ branch,
463
+ originTip: originTip === '' ? null : originTip,
464
+ origin,
465
+ verdict,
466
+ localTip,
467
+ worktree,
468
+ local,
469
+ }
470
+ }
471
+
472
+ /** Carries out one member's plan: origin first, then the worktree and its branch. */
473
+ async function act(
474
+ plan: Plan,
475
+ git: Runner,
476
+ root: string,
477
+ ): Promise<MemberOutcome> {
478
+ const { branch, verdict } = plan
479
+
480
+ let origin = plan.origin
481
+ if (origin.state === 'deleted' && plan.originTip !== null) {
482
+ // The lease names the tip judged above, so a commit pushed between the
483
+ // judgment and the push keeps the branch rather than going with it.
484
+ const pushed = await git(root, [
485
+ 'push',
486
+ '--quiet',
487
+ `--force-with-lease=refs/heads/${branch}:${plan.originTip}`,
488
+ 'origin',
489
+ `:refs/heads/${branch}`,
490
+ ])
491
+ if (pushed.exitCode !== 0) origin = plain('failed', pushed.stderr || null)
492
+ }
493
+
494
+ if (verdict !== null && plan.worktree !== null) {
495
+ if (plan.worktree.state !== 'deleted') {
496
+ return {
497
+ branch,
498
+ origin,
499
+ worktree: { ...plan.worktree, path: verdict.path },
500
+ local: plan.local,
501
+ }
502
+ }
503
+
504
+ const [removed] = (
505
+ await removeReclaimable([{ ...verdict, reclaimable: true }], {
506
+ cwd: root,
507
+ git,
508
+ })
509
+ ).outcomes
510
+ const worktreeGone = removed !== undefined && removed.failedAt !== 'remove'
511
+ const worktree = worktreeGone
512
+ ? plain('deleted')
513
+ : plain('failed', removed?.detail ?? null)
514
+ const local = !worktreeGone
515
+ ? kept('worktree-kept')
516
+ : removed.failedAt === 'branch'
517
+ ? plain('failed', removed.detail)
518
+ : plain('deleted')
519
+ return {
520
+ branch,
521
+ origin,
522
+ worktree: { ...worktree, path: verdict.path },
523
+ local,
524
+ }
525
+ }
526
+
527
+ let local = plan.local
528
+ if (local.state === 'deleted' && plan.localTip !== null) {
529
+ // The old value guards the delete the way the lease guards the push.
530
+ const deleted = await git(root, [
531
+ 'update-ref',
532
+ '-d',
533
+ `refs/heads/${branch}`,
534
+ plan.localTip,
535
+ ])
536
+ if (deleted.exitCode !== 0) local = plain('failed', deleted.stderr || null)
537
+ }
538
+
539
+ return { branch, origin, worktree: null, local }
540
+ }
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env sh
2
2
 
3
3
  # Runs the post-merge steps through `canon hooks post-merge`: archive the task a
4
- # merged pull request closed, push the records, reclaim merged worktrees,
5
- # reinstall the CLI, and update the plugin cache. The verb owns the order and
6
- # each line it prints.
4
+ # merged pull request closed, push the records, retire the fan-in members a
5
+ # merged integrator named, reclaim merged worktrees, reinstall the CLI, and
6
+ # update the plugin cache. The verb owns the order and each line it prints.
7
7
  #
8
- # CANON_SKIP_RECLAIM=1 turns off the worktree reclaim.
8
+ # CANON_SKIP_RECLAIM=1 turns off the member retirement and the worktree
9
+ # reclaim, since both remove worktrees.
9
10
  # CANON_SKIP_UPGRADE=1 turns off the CLI reinstall.
10
11
  # CANON_SKIP_PLUGIN_UPDATE=1 turns off the plugin cache update.
11
12
  #
12
- # The reclaim reads merge state through `gh`, so a machine without it on PATH
13
- # reclaims nothing and says nothing. Install `gh` to turn it on.
13
+ # Both read merge state through `gh`, so a machine without it on PATH retires
14
+ # and reclaims nothing and says nothing. Install `gh` to turn them on.
14
15
  #
15
16
  # Husky runs this as `sh -e`, and `post-rewrite` sources it on a rebase, so
16
17
  # every test sits inside an `if` or an `||` and nothing here exits non-zero.
@@ -121,7 +121,7 @@ check() {
121
121
 
122
122
  # gh sends POST when a field flag is present and no method is named, and the
123
123
  # explicit method wins when one is. A call naming neither is a read.
124
- if [[ $call =~ (-X|--method)[[:space:]=]*([A-Za-z]+) ]]; then
124
+ if [[ $call =~ [[:space:]](-X|--method)[[:space:]=]*([A-Za-z]+) ]]; then
125
125
  method=$(printf '%s' "${BASH_REMATCH[2]}" | tr '[:lower:]' '[:upper:]')
126
126
  elif [[ $call =~ [[:space:]](-f|-F|--field|--raw-field|--input)([[:space:]=]|$) ]]; then
127
127
  method=POST
@@ -130,36 +130,18 @@ jobs:
130
130
  if: github.event_name == 'pull_request'
131
131
  runs-on: ubuntu-latest
132
132
  steps:
133
- # Deployments are listed through the REST API rather than wrangler, whose
134
- # listing carries no branch filter. `force=true` is what lets a deployment
135
- # still holding its branch alias be deleted. An absent secret skips the
136
- # job the way the deploy's own guard does.
133
+ # The default branch, since a closed pull request may have no merge ref to
134
+ # check out, and the script that holds the Cloudflare token should not
135
+ # come from the branch being closed.
136
+ - name: Checkout
137
+ uses: actions/checkout@v7
138
+ with:
139
+ ref: ${{ github.event.repository.default_branch }}
140
+
137
141
  - name: Delete preview deployments
138
142
  env:
139
143
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
140
144
  CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
141
- # Fill in the same Pages project name the Publish step deploys to.
142
- PROJECT_NAME: REPLACE_WITH_PROJECT_NAME
143
145
  BRANCH: ${{ github.head_ref }}
144
- run: |
145
- if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
146
- echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping preview cleanup."
147
- exit 0
148
- fi
149
- api="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$PROJECT_NAME/deployments"
150
- auth="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
151
- page=1
152
- ids=""
153
- while :; do
154
- listing=$(curl -fsS -H "$auth" "$api?env=preview&per_page=25&page=$page")
155
- [ "$(echo "$listing" | jq '.result | length')" -eq 0 ] && break
156
- ids="$ids $(echo "$listing" | jq -r --arg branch "$BRANCH" '.result[] | select(.deployment_trigger.metadata.branch == $branch) | .id')"
157
- page=$((page + 1))
158
- done
159
- deleted=0
160
- for id in $ids; do
161
- curl -fsS -X DELETE -H "$auth" "$api/$id?force=true" >/dev/null
162
- echo "Deleted $id"
163
- deleted=$((deleted + 1))
164
- done
165
- echo "Deleted $deleted preview deployments for $BRANCH."
146
+ # Fill in the same Pages project name the Publish step deploys to.
147
+ run: bash scripts/delete-previews.sh REPLACE_WITH_PROJECT_NAME "$BRANCH"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Deletes the Cloudflare Pages preview deployments minted for one branch, called
3
+ # by the cleanup-preview job in .github/workflows/deploy.yml with the Pages
4
+ # project name and the branch. CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID
5
+ # come from the step env, and an absent secret skips the job the way the
6
+ # deploy's own guard does.
7
+ #
8
+ # Deployments are listed through the REST API rather than wrangler, whose
9
+ # listing carries no branch filter. `force=true` is what lets a deployment
10
+ # still holding its branch alias be deleted.
11
+ set -e
12
+
13
+ PROJECT_NAME=$1
14
+ BRANCH=$2
15
+
16
+ if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
17
+ echo "::notice::CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID is not set, skipping preview cleanup."
18
+ exit 0
19
+ fi
20
+ api="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$PROJECT_NAME/deployments"
21
+ auth="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
22
+ page=1
23
+ ids=""
24
+ while :; do
25
+ listing=$(curl -fsS -H "$auth" "$api?env=preview&per_page=25&page=$page")
26
+ [ "$(echo "$listing" | jq '.result | length')" -eq 0 ] && break
27
+ ids="$ids $(echo "$listing" | jq -r --arg branch "$BRANCH" '.result[] | select(.deployment_trigger.metadata.branch == $branch) | .id')"
28
+ page=$((page + 1))
29
+ done
30
+ deleted=0
31
+ for id in $ids; do
32
+ curl -fsS -X DELETE -H "$auth" "$api/$id?force=true" >/dev/null
33
+ echo "Deleted $id"
34
+ deleted=$((deleted + 1))
35
+ done
36
+ echo "Deleted $deleted preview deployments for $BRANCH."
@@ -2,7 +2,9 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The cloudflare stack ships one parameterized GitHub Actions workflow that deploys a static build to Cloudflare Pages. It is keyed on host rather than on framework and carries no dependencies, seeds, or scripts of its own, so it syncs onto a project already carrying `astro`, `vite-react`, or any other stack without colliding.
5
+ The cloudflare stack ships one parameterized GitHub Actions workflow that deploys a static build to Cloudflare Pages. It is keyed on host rather than on framework and carries no dependencies or seeds of its own, so it syncs onto a project already carrying `astro`, `vite-react`, or any other stack without colliding.
6
+
7
+ It ships one script, `scripts/delete-previews.sh`, which the cleanup job calls and which overwrites a project's own file of that name on every sync.
6
8
 
7
9
  ## Deploy workflow
8
10
 
@@ -28,7 +30,8 @@ Every branch gets a preview URL rather than only `main`.
28
30
 
29
31
  A `pull_request` trigger on `closed` runs only the `cleanup-preview` job, which deletes every preview deployment Cloudflare holds for that branch.
30
32
 
31
- - It lists them through the Pages REST API, because the wrangler listing has no branch filter.
33
+ - It checks out the default branch and runs `scripts/delete-previews.sh` with the Pages project name and the closing branch as arguments.
34
+ - The script lists them through the Pages REST API, because the wrangler listing has no branch filter.
32
35
  - It deletes each with `force=true`, so a deployment still holding the branch alias goes too.
33
36
  - It skips with a notice when the secrets are absent, the same as the deploy job.
34
37
 
@@ -38,9 +41,9 @@ Three values in the seeded file have no generic default, and a project sets them
38
41
 
39
42
  - The `paths:` filter under the `push` trigger ships as a single `src/**` glob. Widen it to every path the build reads when the build draws on catalogs or generated content outside `src/`.
40
43
  - The `concurrency.group` name ships as `deploy-${{ github.ref_name }}`, enough for one deploy. Rename the `deploy` prefix only for a second deploy workflow, and keep the ref suffix.
41
- - The Pages project name appears twice as `REPLACE_WITH_PROJECT_NAME`, in the `--project-name` flag and as `PROJECT_NAME` in the cleanup job.
44
+ - The Pages project name appears twice as `REPLACE_WITH_PROJECT_NAME`, in the `--project-name` flag and as the first argument to `scripts/delete-previews.sh` in the cleanup job.
42
45
  - Set both to the name the `deploy-app` skill creates the project under.
43
46
 
44
47
  ## Re-sync
45
48
 
46
- `configs/` is golden and always overwrites. Re-running `canon tooling sync cloudflare . --write` after hand-editing any of the three parameters above discards that edit and restores the shipped defaults. Re-fill the parameters after any re-sync rather than expecting the file to preserve them.
49
+ `configs/` is golden and always overwrites, `scripts/delete-previews.sh` included. Re-running `canon tooling sync cloudflare . --write` after hand-editing any of the three parameters above discards that edit and restores the shipped defaults. Re-fill the parameters after any re-sync rather than expecting the file to preserve them.