[fix] release: stop the snapshot job starving the release job
Publish snapshot / snapshot (push) Successful in 1m5s

Pushing a release pushes the branch and then the tags seconds apart.
publish-snapshot.yml keys its concurrency group on the branch and
release.yml keys its own on the tag, so the two never gate each other —
on a single runner they race for it and the branch push always wins.

On 1.0.1 the snapshot job wedged extracting a layer of the runner image,
the release job never started, release.mjs gave up after its 20-minute
wait, and two of three tags were left unpushed. The report still printed
a bold "Done" above an empty shipped list, so it read as a success.

- publish-snapshot.yml skips commits whose message starts with "release:".
  A snapshot of a release commit is the same tree the tag is about to
  publish properly, so skipping costs nothing and removes the race.
- waitForRelease() offers to keep waiting instead of giving up. No
  timeout value survives a wedged runner, so the real choice is between
  asking and making the operator finish the release by hand. --yes and a
  non-interactive run still give up; the latter matters because confirm()
  answers with its default without a terminal, which would extend the
  deadline forever.
- The final header says "Blocked" when it is, and labels the packages
  that did ship before the blockage.
- --wait-timeout defaults to 2400s rather than 1200s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCzYTAm9QUhvLNr2EpdagJ
This commit is contained in:
Benjamin Diedrichsen
2026-09-01 15:28:12 +02:00
co-authored by Claude Opus 5
parent eac44b637e
commit 2810d4491f
3 changed files with 73 additions and 14 deletions
+38 -8
View File
@@ -165,10 +165,21 @@ async function versionExists(name, version) {
}
}
/** Polls until `name@version` resolves on npmjs, or the deadline passes. */
async function waitForRelease(name, version, timeoutMs) {
/**
* Polls until `name@version` resolves on npmjs, offering to keep waiting when
* the deadline passes.
*
* The offer is the point. A timeout here is far more often a runner that has
* not started the job than a release that failed, and no timeout value survives
* a runner that has wedged — so the choice is between asking and making the
* operator finish the release by hand. `--yes` and a non-interactive run give
* up instead, the latter because {@link confirm} answers with its default when
* there is no terminal, which here would extend the deadline forever.
*/
async function waitForRelease(name, version, timeoutMs, mayExtend) {
const started = Date.now();
const label = `${name}@${version}`;
let deadline = started + timeoutMs;
process.stdout.write(` waiting for ${label} on npmjs `);
for (;;) {
@@ -177,9 +188,16 @@ async function waitForRelease(name, version, timeoutMs) {
console.log(chalk.green(` published after ${seconds}s`));
return true;
}
if (Date.now() - started > timeoutMs) {
if (Date.now() > deadline) {
console.log(chalk.red(' timed out'));
return false;
if (!mayExtend || !process.stdin.isTTY) return false;
console.log(
chalk.dim(' A queued or wedged runner looks exactly like this — check the run.')
);
if (!(await confirm(`Keep waiting for ${label}?`, true))) return false;
deadline = Date.now() + timeoutMs;
process.stdout.write(` waiting for ${label} on npmjs `);
continue;
}
process.stdout.write('.');
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
@@ -664,7 +682,7 @@ async function release(options) {
console.log(` push ${options.remote} ${branch}, then tags in the order above`);
console.log(
options.wait
? ` wait for each version on npmjs (up to ${options.waitTimeout}s each)\n`
? ` wait for each version on npmjs (asks to continue after ${options.waitTimeout}s)\n`
: ' wait no — tags are pushed back to back\n'
);
@@ -751,7 +769,8 @@ async function release(options) {
const landed = await waitForRelease(
entry.pkg.manifest.name,
entry.version,
options.waitTimeout * 1000
options.waitTimeout * 1000,
!options.yes
);
if (!landed) {
pending.push(entry);
@@ -772,7 +791,13 @@ async function release(options) {
const blocked = pending[0];
const shipped = blocked ? plan.slice(0, plan.indexOf(blocked)) : plan;
console.log(chalk.bold('\n Done\n'));
// The header has to know whether this worked. It was an unconditional 'Done'
// over a `shipped` list that is *empty* when the package that blocked is the
// first one — a success banner above a release that shipped nothing, with the
// diagnosis a screen further down. It read as success and was believed.
console.log(chalk.bold(blocked ? '\n Blocked\n' : '\n Done\n'));
if (blocked && shipped.length > 0) console.log(chalk.dim(' Released before the blockage:\n'));
for (const entry of shipped) {
console.log(` ${entry.pkg.manifest.name}@${entry.version} (${distTag(entry.version)})`);
console.log(chalk.dim(` npm install -g ${entry.pkg.manifest.name}@${entry.version}`));
@@ -817,7 +842,12 @@ program
.option('--no-verify', 'Skip the lint/typecheck/test/build gate')
.option('--no-changelog', 'Do not prompt for release notes')
.option('--no-wait', 'Do not poll npmjs between tag pushes')
.option('--wait-timeout <seconds>', 'How long to wait for each version', Number, 1200)
.option(
'--wait-timeout <seconds>',
'How long to wait before asking to keep waiting',
Number,
2400
)
.addHelpText(
'after',
`