Skip to content

Hermetic web builds, one Node version, and parity in CI (attn-6q7b) - #20

Merged
angusbezzina merged 7 commits into
mainfrom
angus/hermetic-builds
Sep 5, 2026
Merged

Hermetic web builds, one Node version, and parity in CI (attn-6q7b)#20
angusbezzina merged 7 commits into
mainfrom
angus/hermetic-builds

Conversation

@angusbezzina

@angusbezzina angusbezzina commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Resolves the build-infrastructure failures that blocked the staging deploy on 2026-09-03. PR #18 unblocked it by pinning npm 11; this removes the causes and retires the pin.

Epic attn-6q7b — 5 of 6 tasks closed; attn-6q7b.6 is a tracked follow-up that does not gate this.

1. Installs no longer touch GitHub (attn-6q7b.1)

web/package.json depended on three icon packs as github: refs — eyecons, catppuccin-vsc-icons, vscode-icons. The lockfile pinned each commit, but npm still cloned each repo and resolved that project's floating package.json against the live registry on every install. That is how an unrelated upstream publish (eslint 10 beside plugins capped at 9) walked npm 10's arborist into Cannot read properties of null (reading 'edgesOut') with no change in this repo.

None of the three is usable from npm — eyecons there is an unrelated 0.0.1 placeholder, the other two are unpublished — so the generated output is committed (2,776 SVGs, 4.5 MB) and the packs are fetched as pinned source tarballs only by npm run refresh:icons. Generation therefore leaves build, build:browser, check, and the dev servers: nothing in the normal path needs a pack.

Committing output rather than vendoring sources costs ~1 MB more and takes generation off the build path entirely. Verified byte-identical to the git-dep build at the same pins — every SVG, every pack module, the manifest; only the README differs, because it now records each pack's commit and licence.

npm run check:icons replaces the guarantee generation used to give: every icon a pack module imports exists, no orphans, the manifest loads every pack. It never re-runs the generator — it must pass with nothing fetched.

2. One Node version (attn-6q7b.2)

ci.yml ran all four jobs on Node 20 while the deploy ran 22 (wrangler 4 refuses older), so CI never exercised the npm the deploy uses — precisely why the crash was invisible until a deploy. CI moves to 22, .nvmrc pins it, web/package.json declares engines.node >=22. The root package.json stays >=18 deliberately: attnmd is the end-user CLI wrapper and its consumers should not inherit our build floor.

3. The npm pin is gone (attn-6q7b.3)

Removed from the deploy job, now that the thing it worked around is gone.

4. Parity gates PRs (attn-6q7b.4)

scripts/test-hosted-review-e2e.sh ran only inside the dispatch-only deploy workflow — which is how a regression that left the owner's review rail empty on uncurated shares survived two merges and a version bump (#19). It now runs in ci.yml in local mode (its own relay, Vite and owner), needing no deployed environment.

Verified it catches what it exists for: re-introducing the #19 bug fails that case through every retry while the rest pass.

Three reliability fixes found on the way:

  • kill_pid killed the npm wrapper but not the vite child it forks, so a run leaked a server the next run silently tested against. It now kills the tree, and refuses to start if a port it needs is held.
  • The suite ran the relay from relay/ with nothing installed, so npx fetched wrangler 4.129 against relay's pinned ^3.80 — the same non-hermeticity, inside the test harness. CI installs relay deps; the script refuses rather than reaching for npx.
  • Retries in CI only. A deterministic break still fails every attempt, as the Fix the owner's review rail going blank on an uncurated share #19 revert confirms.

Verification

Fresh clone, nothing preinstalled:

Step Result
npm ci (web) 5.8s, no PUPPETEER_SKIP_DOWNLOAD needed
npm run check:icons 2776 svgs, 5 pack modules
npm run check 1635 files, 0 errors
npm run build / build:browser ok
npm ci (relay) ok
cargo build --locked ok
parity suite passed
  • grep -c 'git+' web/package-lock.json0
  • CI green, all five jobs including the new parity job (run 33817511711)
  • Staging deploy green end to end without the pin (run 33818979937, 4/4 parity)

Known follow-up: attn-6q7b.6

The tier case (v3 browser tiers…) is timing-sensitive. One staging-deploy run failed it 3/3 on toolbarCount: 0; the same commit then passed 4/4 twice — from a laptop against that deployment, and on a re-dispatched deploy. Retries run back-to-back, so sustained load on a runner defeats them. The issue carries the three observed failure modes and a likely fix (an explicit readiness signal instead of relying on the first assertion as a barrier). Not a hermeticity problem, so it does not gate this.

Note on the diff

.beads/issues.jsonl had not been exported since 2026-07-23 (448 issues; the DB holds 768). Part of the diff is that resync — none removed — plus this epic's records. The 2,776 committed SVGs are the rest.

🤖 Generated with Claude Code

https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW

The web staging deploy was blocked on 2026-09-03 by two build-infrastructure
failures unrelated to product code, and the fix that unblocked it (pinning
npm 11 in the deploy job, PR #18) is a workaround. This epic resolves the
causes so they stop recurring, before the next release is cut.

  attn-6q7b    Hermetic web builds and one Node version across CI and deploy
  attn-6q7b.1  Vendor the icon packs and drop the three git dependencies
  attn-6q7b.2  Run one Node version: align ci.yml with the deploy job
  attn-6q7b.3  Retire the npm@11 pin from the deploy job        (after .1)
  attn-6q7b.4  Run the native-hosted parity suite in CI          (after .2)
  attn-6q7b.5  Prove it on a fresh clone and write it down       (after all)

Not one git dependency but three: eyecons, catppuccin-vsc-icons and
vscode-icons are all git+ssh:// in the lockfile, and none has a usable
registry package (eyecons on npm is a 0.0.1 placeholder; the other two are
unpublished), so vendoring is the only hermetic option. Node 22 is a hard
floor, not a preference — wrangler 4 in web/ refuses anything older — yet
ci.yml still runs 20 and the root engines field still says >=18.

This commit carries only the beads export. .beads/issues.jsonl had not been
exported since 2026-07-23 (448 issues); the DB now holds 767. The diff is
that resync — 199 issues added, 27 updated, none removed — plus the six
records above. Implementation lands on this branch behind it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
attn Ready Ready Preview Sep 5, 2026 3:19am UTC

Request Review

…3 .4)

The staging deploy broke on 2026-09-03 for two reasons that had nothing to
do with product code, and the pin that unblocked it was a workaround. This
removes the causes.

Installs no longer touch GitHub. web/package.json depended on three icon
packs as `github:` refs — eyecons, catppuccin-vsc-icons, vscode-icons. The
lockfile pinned each repo's commit, but npm still cloned each one and
resolved THAT project's floating package.json against the live registry on
every install, so an unrelated upstream publish (eslint 10 landing beside
plugins capped at 9) could walk npm 10's arborist into a null node. None of
the three is usable from npm: `eyecons` there is an unrelated 0.0.1
placeholder and the other two are unpublished.

So the GENERATED output is now committed — 2,776 SVGs, 4.5 MB — and the
packs are fetched as pinned source tarballs only when someone refreshes
them (`npm run refresh:icons`). The build reads committed files and needs no
pack at all, which is why `generate:icons` leaves `build`, `build:browser`
and `check`. Committing output rather than vendoring sources costs ~1 MB
more and takes generation off the build path entirely. Verified byte-
identical to what the git-dep build produced at the same pins: every SVG,
every pack module and the manifest are unchanged, and only the README
differs because it now records each pack's commit and licence.

Since generation no longer guards itself, `check:icons` verifies the
committed tree is internally consistent — every icon a pack module imports
exists, no orphans, manifest loads every pack. It deliberately does not
re-run the generator: it has to pass with nothing fetched.

One Node version. ci.yml ran all four jobs on 20 while the deploy ran 22
(wrangler 4 refuses older), so CI never exercised the npm the deploy uses —
which is precisely why the arborist crash was invisible until a deploy. CI
moves to 22, .nvmrc declares it, and web/package.json says engines >=22. The
root package.json stays >=18 on purpose: `attnmd` is the end-user CLI
wrapper and its consumers should not inherit our build floor.

The npm@11 pin is gone from the deploy job, now that the thing it worked
around is gone.

Parity now gates PRs. scripts/test-hosted-review-e2e.sh only ever ran inside
the dispatch-only deploy workflow, which is how an owner-side regression
(empty review rail on uncurated shares, #19) survived two merges and a
version bump. It runs in ci.yml in local mode — its own relay, Vite and
owner — so it needs no deployed environment. Verified it catches the thing
it exists for: re-introducing the #19 bug fails that case through every
retry while the rest pass.

Two reliability fixes found while getting there. `kill_pid` killed the npm
wrapper but not the vite child it forks, so a run leaked a server that the
next run then silently tested against — it now kills the tree, and refuses
to start when a port it needs is already held. And the suite retries in CI
only: everything shares one box there, and three local runs failed three
different assertions, none reproducible. A deterministic break still fails
every attempt, as the #19 revert confirms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
…q7b.5)

Three things a contributor now has to know and could not learn from the tree:
Node 22 is the floor and why (wrangler 4) — including why the root package.json
deliberately stays at 18; the icon packs are committed output with a refresh
command rather than dependencies, and adding a `github:` dependency would undo
the hermetic install; and the parity suite gates PRs, owns its ports, and
retries in CI for a reason that is written down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
…-6q7b.5)

Both found by actually running the fresh clone the task asks for, and neither
was visible from the main checkout, where node_modules already had everything.

The dev servers still generated icons. `dev`, `dev:browser` and
`dev:browser:shares` each ran `generate:icons` first, so in a fresh clone they
died on packs that are no longer installed — and the parity suite starts the
hosted app with `dev:browser`, which is how this surfaced. Generation is now
reachable only through `refresh:icons`, which is the point: the committed
output is what everything else reads.

The local relay ran on an unpinned wrangler. Local mode starts the relay from
relay/, whose dependencies nothing installed — so `npx` fetched wrangler 4.129
at run time against relay's pinned ^3.80. A suite that downloads a different
major of its own server on every run is the exact non-hermeticity this epic
removes, so CI installs relay deps (and caches that lockfile too), and the
script refuses to start rather than silently reaching for npx.

Fresh clone of this branch, nothing preinstalled:

  npm ci (web)          5.8s, no PUPPETEER_SKIP_DOWNLOAD needed
  npm run check:icons   2776 svgs, 5 pack modules
  npm run check         1635 files, 0 errors
  npm run build         ok      npm run build:browser   ok
  npm ci (relay)        ok      cargo build --locked    ok
  parity suite          3 passed, 1 flaky, suite passed

The PUPPETEER_SKIP_DOWNLOAD workaround that fresh worktrees used to need is
retired with the git dependencies that caused it; the memory is updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
Five planned tasks closed. attn-6q7b.6 stays open: the parity suite's tier
case is timing-sensitive and, on a loaded runner, can lose all three retries
together — seen once on a staging deploy, with the same commit then passing
4/4 twice. Worth fixing properly rather than leaning on retries, but it is
not a build-hermeticity problem, so it does not gate this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
@angusbezzina angusbezzina changed the title Hermetic web builds and one Node version (attn-6q7b) Hermetic web builds, one Node version, and parity in CI (attn-6q7b) Sep 3, 2026
@angusbezzina
angusbezzina marked this pull request as ready for review September 3, 2026 23:55
attn-6q7b.2 claimed `npm ci` on Node 20 would fail fast with an engines
error. It did not: `engines` in package.json only warns, so Node 20 would
install anyway and die later inside wrangler with a message that does not
name the cause. engine-strict makes the declaration real.

  npm error code EBADENGINE
  npm error notsup Required: {"node":">=22"}
  npm error notsup Actual:   {"node":"v20.20.2","npm":"11.14.1"}

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
Everything else in this repo is driven through `task`, so `task refresh:icons`
is what someone reaches for after changing a pin. It did not exist — the
commands were npm-only and undiscoverable from `task --list`.

Verified from a cold cache: refetching all three packs at the current pins and
regenerating produces no diff at all, which is the property that makes the
committed output trustworthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TnncCnVNhggoZ9QLQiqeW
@angusbezzina
angusbezzina merged commit 1cb58a6 into main Sep 5, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant