@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.
- package/CHANGELOG.md +4037 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/fan-in.md +20 -6
- package/claude/skills/canon-cli/SKILL.md +9 -7
- package/claude/skills/ci-workflow/REQUIREMENT.md +3 -3
- package/claude/skills/ci-workflow/SKILL.md +26 -1
- package/claude/skills/ci-workflow/references/workflows.md +28 -0
- package/claude/skills/design-record/references/folder.md +1 -1
- package/claude/skills/design-taste/SKILL.md +2 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/references/pr.md +2 -2
- package/claude/skills/plan-feature/references/batches.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-pr/references/post.md +2 -2
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-check/references/domains.md +8 -6
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-update/REQUIREMENT.md +33 -0
- package/claude/skills/target-update/SKILL.md +136 -0
- package/docs/agents/index.md +1 -1
- package/docs/agents/migrate.md +3 -0
- package/docs/agents/targets.md +8 -1
- package/docs/target/migrations.md +3 -1
- package/docs/target/projects.md +1 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/detect.toml +1 -1
- package/governance/ledger/tooling/ci-workflow.md +6 -0
- package/governance/ledger/ui/design-reference.md +8 -13
- package/governance/rules/tooling/ci-workflow.md +4 -0
- package/governance/rules/ui/design-reference.md +3 -6
- package/package.json +2 -1
- package/scripts/ci/check-browsers.sh +13 -0
- package/scripts/ci/delete-previews.sh +34 -0
- package/scripts/ci/publish-check.sh +29 -0
- package/scripts/ci/refresh-frames-pr.sh +63 -0
- package/scripts/ci/report-baseline.sh +23 -0
- package/scripts/ci/stack-issues.sh +24 -0
- package/scripts/ci/verify-npm-token.sh +14 -0
- package/src/claude/cases/setup.ts +7 -0
- package/src/cli/help.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/commands/changelog.ts +146 -0
- package/src/commands/hooks.ts +7 -5
- package/src/commands/labels.ts +9 -7
- package/src/commands/migrate.ts +77 -0
- package/src/commands/worktrees.ts +273 -0
- package/src/gov/detect.ts +28 -10
- package/src/hooks/post-merge.ts +45 -6
- package/src/migrate/status.ts +132 -0
- package/src/version/changelog.ts +156 -0
- package/src/worktrees/members.ts +540 -0
- package/tooling/base/configs/.husky/post-merge +7 -6
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +1 -1
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +10 -28
- package/tooling/cloudflare/configs/scripts/delete-previews.sh +36 -0
- package/tooling/cloudflare/reference.md +7 -4
- package/tooling/web/reference.md +5 -0
- 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,
|
|
5
|
-
#
|
|
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
|
|
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
|
-
#
|
|
13
|
-
# reclaims nothing and says nothing. Install `gh` to turn
|
|
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
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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.
|