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")}`, ); }