Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<published-version> version
npm view opencode-supabase dist-tags --json
```

Expected npm state after a successful run:

```json
Expand Down
27 changes: 14 additions & 13 deletions scripts/release-beta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -188,22 +190,21 @@ async function main() {
console.error("\nrelease:beta: publish command failed; probing npm state.");
let probed: Record<string, string>;
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: ${
publishErr instanceof Error ? publishErr.message : String(publishErr)
}; 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);
Expand All @@ -219,20 +220,21 @@ 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(
`publish command failed; npm beta is "${probed.beta}" (expected "${newVersion}") and latest is "${probed.latest}" (expected "${latestBefore}"). Inspect npm before retrying. Underlying error: ${publishErrMsg}`,
);
}

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}`);
Expand All @@ -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.");
Expand Down
70 changes: 68 additions & 2 deletions test/release-beta-version.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
}
});
});
Loading