diff --git a/docs/releasing.md b/docs/releasing.md index 4e5c545..46d29e4 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -371,9 +371,18 @@ What the script does: 6. Aborts if the generated version is not a `-beta.*` prerelease. 7. Requires typing `publish beta` before publishing. 8. Publishes with `changeset publish --no-git-tag` (no commit, no push, no git tag). -9. Verifies npm `latest` is unchanged and `beta` points at the new version. +9. Polls npm every five seconds for up to five minutes until `beta` points at the new version. Stops immediately if `latest` changes. 10. Removes the temp worktree. +A successful npm publish can precede registry visibility. The same polling budget applies when the publish command fails, because npm can accept a package before the command reports an error. + +If verification times out, the publish outcome remains unresolved. Do not republish or change dist-tags based only on stale metadata. Check the exact version and current tags first: + +```bash +npm view opencode-supabase@ version +npm view opencode-supabase dist-tags --json +``` + Expected npm state after a successful run: ```json diff --git a/scripts/release-beta.ts b/scripts/release-beta.ts index 6b6caee..2c7733d 100644 --- a/scripts/release-beta.ts +++ b/scripts/release-beta.ts @@ -21,6 +21,8 @@ import { $ } from "bun"; const CONFIRM_PHRASE = "publish beta"; const PACKAGE_NAME = "opencode-supabase"; +// npm can accept a publish before the version and dist-tags become visible. +export const BETA_DIST_TAG_TIMEOUT_MS = 5 * 60_000; class ReleaseBetaError extends Error {} @@ -188,7 +190,14 @@ async function main() { console.error("\nrelease:beta: publish command failed; probing npm state."); let probed: Record; try { - probed = await npmDistTags(); + probed = await waitForBetaDistTag({ + expectedBeta: newVersion, + expectedLatest: latestBefore, + readTags: npmDistTags, + sleep, + timeoutMs: BETA_DIST_TAG_TIMEOUT_MS, + intervalMs: 5_000, + }); } catch (probeErr) { abort( `publish command failed and npm dist-tag probe failed; state unknown. Inspect npm manually before retrying. Underlying error: ${ @@ -196,14 +205,6 @@ async function main() { }; probe error: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`, ); } - probed = await waitForBetaDistTag({ - expectedBeta: newVersion, - expectedLatest: latestBefore, - readTags: npmDistTags, - sleep, - timeoutMs: 90_000, - intervalMs: 5_000, - }); const betaOk = probed.beta === newVersion; const latestOk = probed.latest === latestBefore; const publishErrMsg = publishErr instanceof Error ? publishErr.message : String(publishErr); @@ -219,7 +220,7 @@ async function main() { } if (!betaOk && latestOk) { abort( - `publish command failed; npm beta is "${probed.beta}", expected "${newVersion}". Inspect npm before retrying. Underlying error: ${publishErrMsg}`, + `publish command failed; npm beta is "${probed.beta}", expected "${newVersion}" after waiting for propagation. Publication may still be processing; do NOT republish or change dist-tags until npm state is checked. Underlying error: ${publishErrMsg}`, ); } abort( @@ -227,12 +228,13 @@ async function main() { ); } + console.log(`\nWaiting up to ${BETA_DIST_TAG_TIMEOUT_MS / 60_000} minutes for npm beta propagation...`); const afterTags = await waitForBetaDistTag({ expectedBeta: newVersion, expectedLatest: latestBefore, readTags: npmDistTags, sleep, - timeoutMs: 90_000, + timeoutMs: BETA_DIST_TAG_TIMEOUT_MS, intervalMs: 5_000, }); console.log(`\nnpm latest after: ${afterTags.latest}`); @@ -246,8 +248,7 @@ async function main() { } if (afterTags.beta !== newVersion) { abort( - `npm beta is "${afterTags.beta}", expected "${newVersion}"; ` + - `fix with: npm dist-tag add ${PACKAGE_NAME}@${newVersion} beta`, + `publish command succeeded, but npm beta is still "${afterTags.beta}", expected "${newVersion}"; verification timed out. Publication may still be processing; do NOT republish or change dist-tags yet. Recheck with: npm view ${PACKAGE_NAME} dist-tags --json; check the version with: npm view ${PACKAGE_NAME}@${newVersion} version`, ); } console.log("latest unchanged, beta points at new version. OK."); diff --git a/test/release-beta-version.test.ts b/test/release-beta-version.test.ts index 6b49da5..2ecea4e 100644 --- a/test/release-beta-version.test.ts +++ b/test/release-beta-version.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; -import { makeTimestampedBetaVersion, waitForBetaDistTag } from "../scripts/release-beta"; +import { BETA_DIST_TAG_TIMEOUT_MS, makeTimestampedBetaVersion, waitForBetaDistTag } from "../scripts/release-beta"; describe("release beta version suffix", () => { test("replaces changesets beta counter with UTC datetime and git SHA", () => { @@ -62,4 +62,70 @@ describe("release beta dist-tag polling", () => { }); expect(sleeps).toEqual([1_000, 1_000]); }); + + test("production polling budget tolerates two-minute npm processing after a successful publish", async () => { + const expectedBeta = "0.5.0-beta.20260630t143015z.sha.gabcdef123456"; + let elapsed = 0; + const clock = spyOn(Date, "now").mockImplementation(() => elapsed); + try { + const result = await waitForBetaDistTag({ + expectedBeta, + expectedLatest: "0.4.2", + readTags: async () => ({ + latest: "0.4.2", + beta: elapsed >= 120_000 ? expectedBeta : "0.5.0-beta.0", + }), + sleep: async (ms) => { elapsed += ms; }, + timeoutMs: BETA_DIST_TAG_TIMEOUT_MS, + intervalMs: 5_000, + }); + + expect(result.beta).toBe(expectedBeta); + expect(elapsed).toBe(120_000); + } finally { + clock.mockRestore(); + } + }); + + test("stops polling immediately if latest changes", async () => { + const changed = { latest: "0.5.0-beta.0", beta: "0.5.0-beta.0" }; + let reads = 0; + const sleeps: number[] = []; + const result = await waitForBetaDistTag({ + expectedBeta: "new-beta", + expectedLatest: "0.4.2", + readTags: async () => { + reads++; + return changed; + }, + sleep: async (ms) => { sleeps.push(ms); }, + timeoutMs: BETA_DIST_TAG_TIMEOUT_MS, + intervalMs: 5_000, + }); + + expect(result).toEqual(changed); + expect(reads).toBe(1); + expect(sleeps).toEqual([]); + }); + + test("stops at the deadline if npm beta never changes", async () => { + const stale = { latest: "0.4.2", beta: "0.5.0-beta.0" }; + let elapsed = 0; + const clock = spyOn(Date, "now").mockImplementation(() => elapsed); + try { + const result = await waitForBetaDistTag({ + expectedBeta: "new-beta", + expectedLatest: "0.4.2", + readTags: async () => stale, + sleep: async (ms) => { elapsed += ms; }, + timeoutMs: BETA_DIST_TAG_TIMEOUT_MS, + intervalMs: 5_000, + }); + + expect(result).toEqual(stale); + expect(elapsed).toBe(BETA_DIST_TAG_TIMEOUT_MS); + } finally { + clock.mockRestore(); + } + }); });