From 65f7a18a835239e38628a4cd4bf42cdd9c1bdc0b Mon Sep 17 00:00:00 2001 From: Lakshman Turlapati Date: Thu, 24 Sep 2026 10:44:10 -0500 Subject: [PATCH] fix: give the post-publish read-back a window npm can meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateAfterPublish` retried 6 times at 1.5s. Publishing 0.4.0 measured roughly two and a half minutes between a successful `npm publish` and the version becoming readable, so every one of the five packages published correctly and then failed its own verification. The publish job advanced exactly one package per run and took six runs to place a five-package set. The check was right to insist on reading the bytes back; it was wrong about how long npm takes to serve them. It now retries for five minutes and says how long it waited when it gives up. Nothing about what is verified changes — a version that never appears, or appears with foreign bytes, provenance, or tag, still raises `[PUBLISH_AMBIGUOUS]` and stops the set. The job's 15-minute timeout could not fit five packages at that rate either, so it moves to 45. A resumable publisher still leaves a partial publication to reconcile by hand if the job is killed mid-set. Two RELEASING.md claims that this release disproved: `npm trust list` reports `permissions: publish, stage publish` even when only `--allow-publish` is passed. npm grants both and the CLI offers no way to decline the second, so instructing the reader not to grant it described a choice they do not have. npm points `latest` at the first published version even when `--tag bootstrap` is the only tag requested, so the bootstrap step cannot leave `latest` absent. It is corrected when the real release publishes, and `[REGISTRY_TAG]` refuses to finish until every package's `latest` is the shared version. --- .github/workflows/release.yml | 7 +++++-- RELEASING.md | 22 ++++++++++++++++------ scripts/release/publisher.mjs | 26 +++++++++++++++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1413b37..a2241dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -225,7 +225,10 @@ jobs: publish: needs: [seal, browser_e2e] runs-on: ubuntu-latest - timeout-minutes: 15 + # Read-back after publish is minutes per package on npm, so five packages + # do not fit in fifteen. The publisher is resumable, but a job killed + # mid-set is still a partial publication to reconcile by hand. + timeout-minutes: 45 environment: npm-production permissions: id-token: write @@ -289,7 +292,7 @@ jobs: const pinned = { "config.mjs": "17f2f25fd40aea7d99024c0da5c7fc8b137dc272646fa1b8d47947bfb3735866", - "release-publisher.mjs": "3ddab82fbf3ef5479f823618b8d1a79a73a1862b297459120d3596a2534a3f3b", + "release-publisher.mjs": "e78adc75f57feec3b003b0f4929b55a69b6e873908f7850a4e875fd77315dcc7", "release-line.json": "d650e2ac9707867db77b5f7b8fd2dd97f8fd0399f578a991091c73ee791dca73", }; const expected = ["release-seal.json"]; diff --git a/RELEASING.md b/RELEASING.md index 08e8a99..014ca1d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -88,13 +88,20 @@ npm pkg set exports='./index.js' npm publish --access public --tag bootstrap --otp='' ``` -Use a fresh temporary directory for each package. Confirm that only `bootstrap` -points to the inert version and that `latest` is absent: +Use a fresh temporary directory for each package. Confirm that `bootstrap` +points to the inert version: ```sh npm view @full-self-browsing/concierge dist-tags --json ``` +npm also points `latest` at that first version even though `--tag bootstrap` +was the only tag requested, so expect `latest` to be present rather than +absent. It is corrected when the real release publishes with `--tag latest`, +and the publisher's `[REGISTRY_TAG]` check refuses to finish until every +package's `latest` is the shared release version. A record whose only version +is the inert bootstrap is the state this step is meant to produce. + Do not publish any repository-built `0.1.0`, assign `latest`, or use an automation token for bootstrap. Do not unpublish the inert version after launch; registry history is immutable evidence. @@ -125,12 +132,15 @@ done The workflow field is the filename `release.yml`, not its full path. All names are case-sensitive. `--allow-publish` is required by current npm trust -configuration and grants only the command used by this release workflow; do -not also grant `--allow-stage-publish`. npm currently permits one trusted +configuration; pass it and nothing else. npm currently permits one trusted publisher per package. -Verify each relationship and its publish-only permission with -`npm trust list `. In each package's npm settings, set publishing +Verify each relationship with `npm trust list `. It reports +`permissions: publish, stage publish` even when only `--allow-publish` was +passed — npm grants both, and the extra grant cannot be declined from the CLI. +Treat that output as expected rather than as a misconfigured relationship. The +workflow only ever runs `npm publish`, and the sealed publisher is the only +tool the protected environment can reach. In each package's npm settings, set publishing access to require 2FA and disallow traditional tokens. No `NPM_TOKEN` or write token belongs in the repository or GitHub secrets. See npm's [trusted-publisher requirements](https://docs.npmjs.com/trusted-publishers/) diff --git a/scripts/release/publisher.mjs b/scripts/release/publisher.mjs index 6c19392..5488218 100644 --- a/scripts/release/publisher.mjs +++ b/scripts/release/publisher.mjs @@ -516,9 +516,28 @@ function wait(milliseconds) { return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); } +/** + * Read a just-published version back until the registry serves it. + * + * **The window is minutes, because npm's is.** This waited 6 attempts at 1.5s + * — about nine seconds — and the 0.4.0 release measured roughly two and a half + * minutes between a successful `npm publish` and the version becoming readable. + * Every package published correctly and then failed its own verification, so a + * five-package set took six runs of this job instead of one, advancing one + * package per run. The check was not wrong to insist on reading the bytes back; + * it was wrong about how long that takes. + * + * Nothing here relaxes what is verified. A version that never appears, or that + * appears with foreign bytes, provenance, or tag, still raises + * `[PUBLISH_AMBIGUOUS]` and stops the set. Waiting longer only stops the + * publisher from reporting a lagging read replica as an ambiguous publish. + */ +const VERIFY_ATTEMPTS = 60; +const VERIFY_INTERVAL_MS = 5_000; + async function validateAfterPublish(archive, registry, inputs) { let lastError = null; - for (let attempt = 0; attempt < 6; attempt += 1) { + for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) { try { const state = registry.query(archive); if (state.kind === "present") { @@ -528,10 +547,11 @@ async function validateAfterPublish(archive, registry, inputs) { } catch (error) { lastError = error; } - if (attempt < 5) await wait(1_500); + if (attempt < VERIFY_ATTEMPTS - 1) await wait(VERIFY_INTERVAL_MS); } + const waited = Math.round((VERIFY_ATTEMPTS * VERIFY_INTERVAL_MS) / 1000); throw new Error( - `[PUBLISH_AMBIGUOUS] ${archive.name}@${archive.version} could not be verified after publish: ${String(lastError ?? "not visible")}`, + `[PUBLISH_AMBIGUOUS] ${archive.name}@${archive.version} could not be verified ${waited}s after publish: ${String(lastError ?? "not visible")}`, ); }