From 08c66f9203d02c295947e7c2019d99478baf0280 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 5 Aug 2026 07:33:55 +0300 Subject: [PATCH] fix(release): pin and quarantine the dashboard install at publish time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepublishOnly` ran `cd dashboard && bun install` with no flags, so `npm publish` performed a dependency resolution AFTER Typecheck, Test and Build had all passed. The reviewed tree and the published tree were separated by a resolution no gate could observe. Two distinct exposures, both measured on bun 1.3.14: * Unpinned. On a drifted lockfile, plain `bun install` exits 0, silently re-resolves and rewrites bun.lock ("Saved lockfile"). With --frozen-lockfile the same state exits 1 and leaves the lockfile untouched. * Unquarantined. The release-age quarantine on a workstation comes entirely from ~/.bunfig.toml, which does not exist on a GitHub runner. The identical install of a 5-day-old package exits 1 with a real HOME and exits 0 with an empty one. That is why the root install in release.yml already passes --minimum-release-age explicitly instead of relying on the environment; the dashboard install was never given the same treatment. The two flags are not interchangeable and this commit does not pretend they are. --frozen-lockfile is load-bearing: it removes resolution from the publish boundary entirely. --minimum-release-age is defence in depth, enforced at resolution time only — it does NOT re-validate versions already pinned in the lockfile. A lockfile that pins a too-new version is caught by review of the lockfile diff, not by these flags. release.yml now builds the dashboard in the gated part of the workflow, before the publish boundary, and fails if dashboard/dist is missing: `files` ships dashboard/dist/ and npm omits a missing listed path silently rather than failing, so a dashboard that never built would publish as a tarball quietly missing its web UI. ci.yml now runs the same command, because no CI job installed or built dashboard/ at all. Its dependency tree was previously first exercised by the publish itself. Without this, hardening publish would turn a silent problem into a late release failure instead of a red check on the PR that caused it. Agent: Silvanus --- .github/workflows/ci.yml | 9 ++++++++ .github/workflows/release.yml | 43 +++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c9a02a..ccd808e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,15 @@ jobs: - run: bun run typecheck - run: bun test + # dashboard/ carries its own package.json and its own bun.lock, and the + # install above does not reach it. Without this step nothing in CI ever + # installs or builds that tree, so a drifted dashboard/bun.lock stayed + # invisible until `npm publish` — which now fails on it, because + # build:dashboard runs --frozen-lockfile. Exercising it on the pull request + # turns a late release failure into an ordinary red check on the PR that + # caused it. This is the same command the release workflow runs. + - run: bun run build:dashboard + # The macOS shell in Sources/ is 500+ lines of Swift the job above cannot see: # it runs on ubuntu and never invokes `swift`, so its green tick was green # regardless of what Sources/ contained — up to and including code that does not diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0791565..3a6f3ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,6 +139,49 @@ jobs: - name: Build run: bun run build + # dashboard/ is a SECOND dependency tree with its own lockfile, and no gate + # above touches it: ci.yml installs the root only, and `bun run build` does + # not enter dashboard/. Until this step existed, that tree's first and only + # install happened inside `npm publish` below, via prepublishOnly — after + # Typecheck, Test and Build had all gone green, so the reviewed thing and + # the published thing were separated by a dependency resolution that no + # gate could see. + # + # It ran unprotected in two distinct ways, both measured on bun 1.3.14: + # * unpinned — plain `bun install` on a drifted lockfile exits 0, silently + # re-resolves, and rewrites bun.lock ("Saved lockfile"). With + # --frozen-lockfile the same state exits 1. + # * unquarantined — the release-age quarantine on a workstation comes + # ENTIRELY from ~/.bunfig.toml, which does not exist on this runner. + # The identical install of a 5-day-old package exits 1 with a real HOME + # and exits 0 with an empty one. That is precisely why the root install + # above passes --minimum-release-age explicitly rather than relying on + # the environment, and the dashboard install needs the same treatment. + # + # Running it here, before the publish boundary, means the tree that ships is + # resolved inside the gated part of the workflow and is visible in these logs. + # prepublishOnly still rebuilds it, now under the same flags, so any other + # publish path is protected too. + # + # Note what each flag actually buys, because they are not interchangeable: + # --frozen-lockfile is load-bearing — it removes resolution from publish + # entirely. --minimum-release-age is defence in depth only; it is enforced + # at RESOLUTION time and does NOT re-validate versions already pinned in the + # lockfile (measured: a frozen install of a lock pinning a 5-day-old version + # exits 0). A too-new pin is caught by review of the lockfile diff, not here. + - name: Build dashboard with locked, quarantined dependencies + run: bun run build:dashboard + + # `files` in package.json ships dashboard/dist/. npm silently OMITS a listed + # path that does not exist rather than failing, so a dashboard that never + # built would publish as a tarball quietly missing its web UI — an artefact + # defect with no error anywhere upstream. Turn that silence into a failure. + - name: Require the dashboard build output + run: | + set -euo pipefail + test -f dashboard/dist/index.html + echo "dashboard/dist/index.html present" + # No NODE_AUTH_TOKEN, and no token of any kind. npm detects the Actions # OIDC environment and exchanges the id-token for a short-lived, # publish-scoped credential. --provenance is passed explicitly: npm diff --git a/package.json b/package.json index 9c2638b..ebe0164 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "clean": "rm -rf dist bin", "build": "bun run clean && bun build ./src/cli/index.tsx --outdir ./bin --target bun --external ink --external react --external chalk && bun build ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build ./src/server/serve-entry.ts --outfile ./bin/serve.js --target bun && bun build ./src/hooks/blocker-hook.ts --outfile ./bin/hook.js --target bun && bun build ./src/index.ts ./src/sdk/index.ts --outdir ./dist --target bun && (tsc --emitDeclarationOnly --declaration --outDir dist || true)", - "build:dashboard": "cd dashboard && bun install && bun run build", + "build:dashboard": "cd dashboard && bun install --frozen-lockfile --minimum-release-age 604800 && bun run build", "test": "bun test", "dev": "bun run ./src/cli/index.tsx", "serve": "bun run ./src/server/serve-entry.ts",