@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 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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Runs your agent's work and keeps its code good, on one setup for every repo.",
4
- "version": "5.76.0",
4
+ "version": "5.77.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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. A moving action ref changes under the project, a static cache key serves a stale toolchain, artifacts never expire, a broad default token grants unneeded writes, and a missing manual trigger leaves a run reproducible only by pushing a commit. A Linux-only pin also flags a supported macOS job as a departure.
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, and every cache is keyed on the version of what it caches
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.
@@ -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`, and no row is removed
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.76.0",
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": "./src/cli.ts"
8
+ "canon": "src/cli.ts"
9
9
  },
10
10
  "files": [
11
11
  "src",
@@ -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.`,
@@ -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
- * Reads every recorded target, sorted by path.
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
- /** Why a record attempt did not land, so a caller can say so rather than assume it did. */
159
- export type RecordOutcome = 'recorded' | 'unwritten'
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
- * Nothing removes a row either, so a target that was deleted or that dropped
173
- * the toolkit stays here and the count drifts upward. `canon targets pulls`
174
- * meets that on use, since it refuses a path it cannot open rather than
175
- * reading it as a target with no work, but `canon targets list` does not: it
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
- const current = readTargetRegistry(path)
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
  }
@@ -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
- if find "$DIST" -iname '*og-card*' | grep -q .; then
54
- echo "A file named for the card route reached $DIST. See card.config.mjs's srcDir." >&2
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
- run: bunx playwright install-deps chromium
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: bunx playwright install-deps chromium
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
- run: bunx playwright install-deps chromium
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"
@@ -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