@erclx/canon 5.76.0 → 5.77.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 +13 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
- package/claude/skills/ci-workflow/SKILL.md +3 -0
- package/docs/agents/targets.md +3 -1
- package/package.json +2 -2
- package/src/commands/targets.ts +11 -1
- package/src/targets/registry.ts +99 -14
- package/src/targets/resolve.ts +3 -1
- package/tooling/astro/configs/card.config.mjs +4 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
- package/tooling/astro/reference.md +2 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
- package/tooling/web/configs/.github/workflows/verify.yml +5 -2
- package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
- package/tooling/web/reference.md +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.77.0](https://github.com/erclx/canon/compare/v5.76.0...v5.77.0) (2026-10-31)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **targets:** keep only real projects in the target index ([#2867](https://github.com/erclx/canon/issues/2867)) ([340cbff](https://github.com/erclx/canon/commit/340cbff8edda3987af6d4933aa27391e07b312a2))
|
|
9
|
+
* **tooling:** bound and retry the shipped playwright installs ([#2865](https://github.com/erclx/canon/issues/2865)) ([842d5f9](https://github.com/erclx/canon/commit/842d5f957602136d8bbbdfba524fbba1340df76a))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **tooling:** turn off the card toolbar and narrow the exclusion check ([#2864](https://github.com/erclx/canon/issues/2864)) ([188c1e9](https://github.com/erclx/canon/commit/188c1e950c83cd00ad22e07c43d5232bf01e6ad8))
|
|
15
|
+
|
|
3
16
|
## [5.76.0](https://github.com/erclx/canon/compare/v5.75.0...v5.76.0) (2026-10-31)
|
|
4
17
|
|
|
5
18
|
|
|
@@ -9,12 +9,18 @@ description: Scope boundary for pipeline structure against job contents, and the
|
|
|
9
9
|
|
|
10
10
|
Without this skill, a session chains jobs with `needs` to express reading order rather than a data dependency, so the pipeline costs the sum of its jobs. The same session splits a one-minute gate into parallel jobs, each repaying checkout, install, and toolchain setup, so the gate gets slower while reporting no earlier.
|
|
11
11
|
|
|
12
|
-
The rest surface as flakes
|
|
12
|
+
The rest surface as flakes or stalls:
|
|
13
|
+
|
|
14
|
+
- A moving action ref changes under the project, and a static cache key serves a stale toolchain
|
|
15
|
+
- Artifacts never expire, and a broad default token grants unneeded writes
|
|
16
|
+
- A missing manual trigger leaves a run reproducible only by pushing a commit
|
|
17
|
+
- A Linux-only pin flags a supported macOS job as a departure
|
|
18
|
+
- A Playwright install calling apt directly hangs on a stalled mirror and, with no step bound, holds the runner until the six-hour job default
|
|
13
19
|
|
|
14
20
|
## Must
|
|
15
21
|
|
|
16
22
|
- A job is gated only on a data dependency or a cost that justifies the wait, and checks sharing setup stay in one job until a run log puts the gate past roughly two minutes
|
|
17
|
-
- Every action is pinned to a tag that cannot cross a major version,
|
|
23
|
+
- Every action is pinned to a tag that cannot cross a major version, every cache is keyed on the version of what it caches, and every Playwright install is bounded and retried
|
|
18
24
|
- Every workflow carries a manual trigger beside its primary one, except one that commits back
|
|
19
25
|
- The token defaults to read-only, with a write scope granted only on the job that needs it, and artifact upload is bounded to failures and expires
|
|
20
26
|
- A job runs on Linux unless the project states a platform for a layer that differs by platform
|
|
@@ -60,6 +60,8 @@ Linux stays the default. A job leaves it only for a layer whose result differs b
|
|
|
60
60
|
- Use `oven-sh/setup-bun@v2` with `bun-version: latest`.
|
|
61
61
|
- Install with `bun install --frozen-lockfile`.
|
|
62
62
|
- Cache Playwright browsers keyed on the Playwright version string, never a static key.
|
|
63
|
+
- Bound each Playwright install step, 10 minutes on the system packages and 5 on the browser. A stalled apt mirror hangs rather than fails, and an unbounded job waits out the six-hour default.
|
|
64
|
+
- Install the system packages through a script that kills and retries a stalled attempt, never a direct `install-deps` or `--with-deps`, since a bound alone turns the hang red.
|
|
63
65
|
|
|
64
66
|
## Commit-back
|
|
65
67
|
|
|
@@ -106,3 +108,4 @@ Before responding, also verify:
|
|
|
106
108
|
Before responding, also verify:
|
|
107
109
|
|
|
108
110
|
- No `run:` step passes five lines or carries more than one exit-or-output guard, a loop, a trap, or a function. A step that does calls a script, with its inputs as arguments or `env`.
|
|
111
|
+
- Every Playwright install step carries `timeout-minutes`, and the system packages install through a retried script.
|
package/docs/agents/targets.md
CHANGED
|
@@ -9,7 +9,9 @@ kind: reference
|
|
|
9
9
|
`canon targets list` reports the projects this toolkit installed into. `canon targets pulls [paths…]` reports each target's open pull requests, or every target's when none is named. Both take `--json`. Read the record's fields, since a wrapping shell function can flatten the exit.
|
|
10
10
|
|
|
11
11
|
- `list` takes `--sweep <path…>`, `--depth <n>` (default 4), and `--record`, which seeds the index from swept stamps and needs `--sweep`
|
|
12
|
-
- Each stamping sync records its target in `$XDG_STATE_HOME/canon/targets.json`,
|
|
12
|
+
- Each stamping sync records its target in `$XDG_STATE_HOME/canon/targets.json`, except a folder under the OS temp folder, the state folder, `~/.claude/jobs/`, or any `.canon/tmp/`
|
|
13
|
+
- Every write drops a row whose folder is gone or sits under one of those, and `list` leaves the same rows out. A target on an unmounted drive drops until its next sync records it again.
|
|
14
|
+
- `--record` counts a swept checkout under one of those folders as skipped, not failed
|
|
13
15
|
- A sweep finds installs the index never held
|
|
14
16
|
|
|
15
17
|
`canon changelog` prints the releases a target has not seen, from the `CHANGELOG.md` the installed package ships. It reads no registry and needs no `gh`.
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erclx/canon",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.77.0",
|
|
5
5
|
"description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
|
-
"canon": "
|
|
8
|
+
"canon": "src/cli.ts"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
package/src/commands/targets.ts
CHANGED
|
@@ -267,6 +267,8 @@ function refuseDepth(opts: ListOptions): number {
|
|
|
267
267
|
interface BackfillSummary {
|
|
268
268
|
readonly recorded: number
|
|
269
269
|
readonly skipped: number
|
|
270
|
+
/** Skipped too, but for sitting under a scratch root, so its line names that cause. */
|
|
271
|
+
readonly scratch: number
|
|
270
272
|
readonly failed: number
|
|
271
273
|
}
|
|
272
274
|
|
|
@@ -285,16 +287,18 @@ async function runBackfill(
|
|
|
285
287
|
|
|
286
288
|
let recorded = 0
|
|
287
289
|
let skipped = 0
|
|
290
|
+
let scratch = 0
|
|
288
291
|
let failed = 0
|
|
289
292
|
|
|
290
293
|
for (const path of paths) {
|
|
291
294
|
const outcome = backfillTarget({ path, stampedAt: stampedAtOf(path) })
|
|
292
295
|
if (outcome === 'recorded') recorded++
|
|
293
296
|
else if (outcome === 'no-stamp') skipped++
|
|
297
|
+
else if (outcome === 'scratch') scratch++
|
|
294
298
|
else failed++
|
|
295
299
|
}
|
|
296
300
|
|
|
297
|
-
return { recorded, skipped, failed }
|
|
301
|
+
return { recorded, skipped, scratch, failed }
|
|
298
302
|
}
|
|
299
303
|
|
|
300
304
|
/**
|
|
@@ -340,6 +344,12 @@ function reportBackfill(summary: BackfillSummary): void {
|
|
|
340
344
|
)
|
|
341
345
|
}
|
|
342
346
|
|
|
347
|
+
if (summary.scratch > 0) {
|
|
348
|
+
logWarn(
|
|
349
|
+
`${plural(summary.scratch, 'target')} skipped, sitting under a scratch folder the index never records.`,
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
343
353
|
if (summary.failed > 0) {
|
|
344
354
|
logError(
|
|
345
355
|
`${plural(summary.failed, 'target')} failed to write, so its row is unchanged.`,
|
package/src/targets/registry.ts
CHANGED
|
@@ -2,11 +2,12 @@ import {
|
|
|
2
2
|
existsSync,
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readFileSync,
|
|
5
|
+
realpathSync,
|
|
5
6
|
renameSync,
|
|
6
7
|
writeFileSync,
|
|
7
8
|
} from 'node:fs'
|
|
8
|
-
import { homedir } from 'node:os'
|
|
9
|
-
import { dirname, join, resolve } from 'node:path'
|
|
9
|
+
import { homedir, tmpdir } from 'node:os'
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* One project the toolkit has installed into, as the install recorded it.
|
|
@@ -116,15 +117,90 @@ function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
+
* The folders whose contents are never a project, resolved at call time so a
|
|
121
|
+
* test's `CANON_STATE_DIR` override moves the state root with it.
|
|
122
|
+
*
|
|
123
|
+
* Sandbox runs and replays live under the state folder, background jobs keep
|
|
124
|
+
* their scratch under `~/.claude/jobs/`, and unit tests stamp into the OS temp
|
|
125
|
+
* folder. Each one passes through a stamping sync, so without this list every
|
|
126
|
+
* run leaves a row behind for a folder that is gone a minute later.
|
|
127
|
+
*/
|
|
128
|
+
export function scratchRoots(): readonly string[] {
|
|
129
|
+
return [tmpdir(), stateDir(), join(homedir(), '.claude', 'jobs')]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a path sits under a scratch root or inside any `.canon/tmp/` folder.
|
|
134
|
+
*
|
|
135
|
+
* Both sides are compared in their given and their real form, since macOS
|
|
136
|
+
* hands out its temp folder as `/var/folders/...` while a path that went
|
|
137
|
+
* through a symlink resolution reads `/private/var/folders/...`. A
|
|
138
|
+
* `.claude/worktrees/` checkout is not scratch: it is a real checkout of a real
|
|
139
|
+
* project, and only a root on the list or a `.canon/tmp/` segment refuses it.
|
|
140
|
+
*/
|
|
141
|
+
export function isScratchPath(
|
|
142
|
+
target: string,
|
|
143
|
+
roots: readonly string[] = scratchRoots(),
|
|
144
|
+
): boolean {
|
|
145
|
+
const forms = bothForms(resolve(target))
|
|
146
|
+
const rootForms = roots.flatMap((root) => bothForms(resolve(root)))
|
|
147
|
+
|
|
148
|
+
return forms.some(
|
|
149
|
+
(form) =>
|
|
150
|
+
hasCanonTmpSegment(form) ||
|
|
151
|
+
rootForms.some((root) => isWithin(form, root)),
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bothForms(path: string): readonly string[] {
|
|
156
|
+
try {
|
|
157
|
+
const real = realpathSync(path)
|
|
158
|
+
return real === path ? [path] : [path, real]
|
|
159
|
+
} catch {
|
|
160
|
+
return [path]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasCanonTmpSegment(path: string): boolean {
|
|
165
|
+
const segments = path.split(sep)
|
|
166
|
+
return segments.some(
|
|
167
|
+
(segment, index) => segment === '.canon' && segments[index + 1] === 'tmp',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isWithin(path: string, root: string): boolean {
|
|
172
|
+
const rel = relative(root, path)
|
|
173
|
+
return (
|
|
174
|
+
rel === '' ||
|
|
175
|
+
(rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether a row still names a project worth listing: a folder that exists and
|
|
181
|
+
* sits under no scratch root. The read filter and the write prune both call
|
|
182
|
+
* this, so the two can never disagree on a row.
|
|
183
|
+
*/
|
|
184
|
+
function isLive(row: TargetRecord, roots: readonly string[]): boolean {
|
|
185
|
+
return existsSync(row.path) && !isScratchPath(row.path, roots)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reads every recorded target that is still live, sorted by path.
|
|
120
190
|
*
|
|
121
191
|
* A row missing either field is dropped rather than reported. This module is
|
|
122
192
|
* the file's only writer, so a malformed row is a hand edit or a truncated
|
|
123
193
|
* write and neither is a finding the caller can act on. What a caller can act
|
|
124
194
|
* on is the file being absent, which is its own kind above.
|
|
195
|
+
*
|
|
196
|
+
* A row whose folder is gone or under a scratch root is left out too, so the
|
|
197
|
+
* list is right on a machine whose file predates the prune, before any sync
|
|
198
|
+
* rewrites it. A target on a drive that is unmounted reads as gone here, and
|
|
199
|
+
* its next sync records it again.
|
|
125
200
|
*/
|
|
126
201
|
export function readTargetRegistry(
|
|
127
202
|
path: string = registryPath(),
|
|
203
|
+
roots: readonly string[] = scratchRoots(),
|
|
128
204
|
): TargetRegistry {
|
|
129
205
|
let text: string
|
|
130
206
|
try {
|
|
@@ -150,38 +226,46 @@ export function readTargetRegistry(
|
|
|
150
226
|
.filter((row): row is TargetRecord =>
|
|
151
227
|
isRecord(row as Partial<TargetRecord>),
|
|
152
228
|
)
|
|
229
|
+
.filter((row) => isLive(row, roots))
|
|
153
230
|
.sort((a, b) => a.path.localeCompare(b.path))
|
|
154
231
|
|
|
155
232
|
return { kind: 'read', path, targets }
|
|
156
233
|
}
|
|
157
234
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Why a record attempt did not land, so a caller can say so rather than assume
|
|
237
|
+
* it did. `scratch` is a deliberate refusal of a path under a scratch root,
|
|
238
|
+
* not a failure.
|
|
239
|
+
*/
|
|
240
|
+
export type RecordOutcome = 'recorded' | 'unwritten' | 'scratch'
|
|
160
241
|
|
|
161
242
|
/**
|
|
162
243
|
* Records one target, keyed by its resolved path and replacing any row already
|
|
163
|
-
* held for it.
|
|
244
|
+
* held for it, and refuses a path under a scratch root.
|
|
164
245
|
*
|
|
165
246
|
* The write is a temp file plus a rename, so a reader never meets a half
|
|
166
247
|
* written file. Two syncs finishing together still resolve last-writer-wins on
|
|
167
248
|
* the merged set, which can drop the row the loser added. That is left rather
|
|
168
249
|
* than locked: the authoritative record of an install is the stamp inside the
|
|
169
250
|
* target, this index is a cache over those, and the next sync of the dropped
|
|
170
|
-
* target restores its row.
|
|
251
|
+
* target restores its row. The prune reads the same merged set the write
|
|
252
|
+
* already reads, so it adds no second read and does not widen that window.
|
|
171
253
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* never opens a recorded path, and the count is its whole output.
|
|
254
|
+
* Every write also drops the rows `readTargetRegistry` leaves out, being a
|
|
255
|
+
* folder that is gone or one under a scratch root, so the file shrinks back to
|
|
256
|
+
* the live projects rather than growing with every sandbox run. A target on a
|
|
257
|
+
* drive that is unmounted drops with them, and its next sync restores it.
|
|
177
258
|
*/
|
|
178
259
|
export function recordTarget(
|
|
179
260
|
target: string,
|
|
180
261
|
now: Date,
|
|
181
262
|
path: string = registryPath(),
|
|
263
|
+
roots: readonly string[] = scratchRoots(),
|
|
182
264
|
): RecordOutcome {
|
|
183
265
|
const resolved = resolve(target)
|
|
184
|
-
|
|
266
|
+
if (isScratchPath(resolved, roots)) return 'scratch'
|
|
267
|
+
|
|
268
|
+
const current = readTargetRegistry(path, roots)
|
|
185
269
|
const existing = current.kind === 'read' ? current.targets : []
|
|
186
270
|
|
|
187
271
|
const targets = [
|
|
@@ -220,7 +304,8 @@ export type BackfillOutcome = RecordOutcome | 'no-stamp'
|
|
|
220
304
|
export function backfillTarget(
|
|
221
305
|
target: BackfillTarget,
|
|
222
306
|
path: string = registryPath(),
|
|
307
|
+
roots: readonly string[] = scratchRoots(),
|
|
223
308
|
): BackfillOutcome {
|
|
224
309
|
if (target.stampedAt === null) return 'no-stamp'
|
|
225
|
-
return recordTarget(target.path, new Date(target.stampedAt), path)
|
|
310
|
+
return recordTarget(target.path, new Date(target.stampedAt), path, roots)
|
|
226
311
|
}
|
package/src/targets/resolve.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface ResolveTargetsOptions extends SweepOptions {
|
|
|
45
45
|
/** Roots to walk, supplementing the record rather than replacing it. */
|
|
46
46
|
readonly sweep?: readonly string[]
|
|
47
47
|
readonly registryFile?: string
|
|
48
|
+
/** Folders whose recorded rows are left out, defaulting to `scratchRoots()`. */
|
|
49
|
+
readonly scratchRoots?: readonly string[]
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
/**
|
|
@@ -71,7 +73,7 @@ export async function resolveTargets(
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const file = opts.registryFile ?? registryPath()
|
|
74
|
-
const registry = readTargetRegistry(file)
|
|
76
|
+
const registry = readTargetRegistry(file, opts.scratchRoots)
|
|
75
77
|
|
|
76
78
|
const recorded: KnownTarget[] =
|
|
77
79
|
registry.kind === 'read'
|
|
@@ -14,7 +14,11 @@ const portOffset = Number(process.env.WORKTREE_PORT_OFFSET) || 0
|
|
|
14
14
|
// The base port sits outside the band `astro.config.mjs` serves from, so a card
|
|
15
15
|
// server and a dev server of one worktree never contend, and two worktrees
|
|
16
16
|
// separate here the way they separate there.
|
|
17
|
+
//
|
|
18
|
+
// The dev toolbar is off because this server exists to be captured, and the
|
|
19
|
+
// toolbar paints over the viewport the capture cuts the card from.
|
|
17
20
|
export default defineConfig({
|
|
21
|
+
devToolbar: { enabled: false },
|
|
18
22
|
srcDir: './card-src',
|
|
19
23
|
outDir: './card-dist',
|
|
20
24
|
publicDir: './public',
|
|
@@ -50,8 +50,13 @@ if grep -rl "$MARKER" "$DIST" >/dev/null 2>&1; then
|
|
|
50
50
|
exit 1
|
|
51
51
|
fi
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
# Built pages only, since an image or font may carry the route's name without
|
|
54
|
+
# being the route. The path test covers both formats Astro emits, being
|
|
55
|
+
# og-card/index.html under build.format 'directory' and og-card.html under
|
|
56
|
+
# 'file'. The search runs from inside dist so a project checked out under a
|
|
57
|
+
# directory like blog-card-site cannot match on its own prefix.
|
|
58
|
+
if (cd "$DIST" && find . -ipath '*og-card*' -name '*.html' | grep -q .); then
|
|
59
|
+
echo "A page named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
|
|
55
60
|
exit 1
|
|
56
61
|
fi
|
|
57
62
|
|
|
@@ -64,7 +64,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
|
|
|
64
64
|
## Social card route
|
|
65
65
|
|
|
66
66
|
- Keep the social card as a route the project owns, so a re-render after a rename is one string edit and a re-capture rather than another `design-identity` pick loop
|
|
67
|
-
- Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server
|
|
67
|
+
- Expect `canon:design-identity` to write the picked composition into the route and capture it through the card server, which runs with Astro's dev toolbar off so it never paints over the card
|
|
68
68
|
- `card.config.mjs` is a second Astro config whose `srcDir` points at `./card-src`, which the main page router never reads, so the published build excludes the card structurally
|
|
69
69
|
- It serves on base port `4421` plus `WORKTREE_PORT_OFFSET`, outside the band `astro.config.mjs` serves from, so a worktree's card and dev servers never contend
|
|
70
70
|
- `bun run card:dev` serves the route and `bun run card:check` runs the exclusion check, which base's `scripts/verify.ts` also runs after its build stage
|
|
@@ -72,6 +72,7 @@ The astro stack covers Astro + TypeScript projects, being content sites, marketi
|
|
|
72
72
|
## Card exclusion and size
|
|
73
73
|
|
|
74
74
|
- `scripts/check-card-exclusion.sh` fails when the route's `<meta name="og-card-marker">` tag reaches `dist/`, and when the route dropped the tag the leak test reads
|
|
75
|
+
- The filename test reads built HTML pages only, under an `og-card` path in either build format, so an image or font may carry the route's name
|
|
75
76
|
- Keep the exclusion a check rather than a convention, since the card server and the capture read the route and the published build must not carry it
|
|
76
77
|
- Declare `.card` at 600x315, half the 1200x630 target, since `canon capture` shoots at a fixed 2x device scale and 1200x630 would capture at 2400x1260
|
|
77
78
|
|
|
@@ -74,10 +74,12 @@ jobs:
|
|
|
74
74
|
# Unconditional: the cache holds browser binaries and nothing apt
|
|
75
75
|
# wrote, so gating this on a cache hit leaves a warm run launching
|
|
76
76
|
# chromium without the libraries it needs.
|
|
77
|
-
|
|
77
|
+
timeout-minutes: 10
|
|
78
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
78
79
|
|
|
79
80
|
- name: Install Playwright Browser
|
|
80
81
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
82
|
+
timeout-minutes: 5
|
|
81
83
|
run: bunx playwright install chromium
|
|
82
84
|
|
|
83
85
|
- name: Capture README Screenshot
|
|
@@ -72,7 +72,7 @@ jobs:
|
|
|
72
72
|
- name: Install Playwright System Dependencies
|
|
73
73
|
if: steps.browser.outputs.enabled == 'true'
|
|
74
74
|
timeout-minutes: 10
|
|
75
|
-
run:
|
|
75
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
76
76
|
|
|
77
77
|
- name: Cache Playwright Browsers
|
|
78
78
|
id: playwright-cache
|
|
@@ -84,6 +84,7 @@ jobs:
|
|
|
84
84
|
|
|
85
85
|
- name: Install Playwright Chromium
|
|
86
86
|
if: steps.browser.outputs.enabled == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
|
87
|
+
timeout-minutes: 5
|
|
87
88
|
run: bunx playwright install chromium
|
|
88
89
|
|
|
89
90
|
- name: Run Tests
|
|
@@ -151,7 +152,8 @@ jobs:
|
|
|
151
152
|
path: dist/
|
|
152
153
|
|
|
153
154
|
- name: Install Playwright System Dependencies
|
|
154
|
-
|
|
155
|
+
timeout-minutes: 10
|
|
156
|
+
run: bash scripts/install-playwright-deps.sh chromium
|
|
155
157
|
|
|
156
158
|
- name: Cache Playwright Browsers
|
|
157
159
|
id: playwright-cache
|
|
@@ -162,6 +164,7 @@ jobs:
|
|
|
162
164
|
|
|
163
165
|
- name: Install Playwright Chromium
|
|
164
166
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
167
|
+
timeout-minutes: 5
|
|
165
168
|
run: bunx playwright install chromium
|
|
166
169
|
|
|
167
170
|
- name: Run E2E Tests
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Installs the Playwright system packages for the named browsers, killing an
|
|
3
|
+
# attempt that outlives its bound and retrying it. A degraded apt mirror hangs
|
|
4
|
+
# one connection rather than failing it, so a step timeout alone turns the
|
|
5
|
+
# hang into a red check and never recovers it, while a fresh attempt usually
|
|
6
|
+
# lands on a healthy backend.
|
|
7
|
+
#
|
|
8
|
+
# Usage: install-playwright-deps.sh [--package <spec>] <browser>...
|
|
9
|
+
# Exits 0 on the first success, 2 when no browser is named, and the last
|
|
10
|
+
# attempt's code when every attempt fails.
|
|
11
|
+
set -e
|
|
12
|
+
|
|
13
|
+
package=playwright
|
|
14
|
+
if [ "$1" = "--package" ]; then
|
|
15
|
+
package=$2
|
|
16
|
+
shift 2
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if [ $# -eq 0 ]; then
|
|
20
|
+
echo "Usage: install-playwright-deps.sh [--package <spec>] <browser>..." >&2
|
|
21
|
+
exit 2
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
attempts=3
|
|
25
|
+
bound=${INSTALL_ATTEMPT_SECONDS:-150}
|
|
26
|
+
|
|
27
|
+
# The runner image sets these, yet a stalled log shows gaps of minutes, so
|
|
28
|
+
# print what apt actually reads to settle whether they took effect
|
|
29
|
+
apt-config dump | grep -E '^Acquire::(Retries|http::Timeout) ' || true
|
|
30
|
+
|
|
31
|
+
status=0
|
|
32
|
+
for attempt in $(seq 1 "$attempts"); do
|
|
33
|
+
if [ "$attempt" -gt 1 ]; then
|
|
34
|
+
# timeout runs as the runner user, and Linux drops its signal to the root
|
|
35
|
+
# apt-get playwright spawns through sudo. sudo relays it only to its own
|
|
36
|
+
# child shell, so a root apt-get or dpkg can outlive the kill and hold the
|
|
37
|
+
# lock the next attempt needs.
|
|
38
|
+
sudo pkill -KILL -x 'apt-get|dpkg' || true
|
|
39
|
+
# A kill during unpack leaves dpkg interrupted, which refuses the next
|
|
40
|
+
# install. Its own failure must not end the loop.
|
|
41
|
+
sudo dpkg --configure -a || echo "dpkg --configure -a failed before attempt ${attempt}" >&2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
status=0
|
|
45
|
+
timeout --kill-after=10 "$bound" bunx "$package" install-deps "$@" || status=$?
|
|
46
|
+
if [ "$status" -eq 0 ]; then
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
echo "attempt ${attempt}/${attempts} failed with exit ${status}" >&2
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
exit "$status"
|
package/tooling/web/reference.md
CHANGED
|
@@ -88,6 +88,11 @@ The starter `e2e/home.spec.ts` is seeded once. Sync copies it into a project tha
|
|
|
88
88
|
- `.github/workflows/verify.yml` runs `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests`. `unit-tests` installs Chromium and runs `test:browser` only when the project defines that script.
|
|
89
89
|
- Read `governance/rules/tooling/ci-workflow.md` for when a workflow's commit-back is right and when a report is
|
|
90
90
|
|
|
91
|
+
## Playwright installs in CI
|
|
92
|
+
|
|
93
|
+
- Both workflows install Playwright's system packages through `scripts/install-playwright-deps.sh`, which sits beside `worktree-port.sh` and retries an attempt a stalled apt mirror hangs
|
|
94
|
+
- Expect each system install bounded at 10 minutes and each browser download at 5, while the jobs carry no bound, since test and build time differs per project
|
|
95
|
+
|
|
91
96
|
## Design references
|
|
92
97
|
|
|
93
98
|
- Expect a seeded `canon/design/index.md` stub carrying `auto: false`, copied only when missing, so a project's own index is never replaced
|