From 429a235f873c3a7557d089705b1262f18fc6d9f3 Mon Sep 17 00:00:00 2001 From: Dave Oster Date: Tue, 27 Oct 2026 12:03:18 -0500 Subject: [PATCH] Let a started npm release finish after newer merges The automatic release checked a second time, right before npm publish, that master still pointed at its source, and failed if another merge had landed during the build and checks. The workflow already runs the whole release in one concurrency group that never cancels a run in progress, so the newer merge waits and publishes next. Keep the check at the start, which still refuses a rerun of an older commit, and drop only the later comparison. Every other identity, registry and integrity check still runs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish-npm.yml | 1 + docs/releases.md | 2 +- scripts/release-auto.mjs | 9 ++++----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 1aca15a..1013c49 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -12,6 +12,7 @@ permissions: contents: read concurrency: group: cli-npm-release + # The release in progress always finishes. One run waits behind it, and a newer push replaces that waiting run, so the newest commit publishes next. cancel-in-progress: false jobs: publish: diff --git a/docs/releases.md b/docs/releases.md index 26c43ce..4adda1b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -10,7 +10,7 @@ The committed stable version, initially `0.4.0`, sets the minimum release versio The job updates the version only in its temporary build checkout. Both npm manifests contain `dealmachineRelease.sourceSha`; the workflow does not create a source commit or Git tag. The source version is a floor, so it can differ from an installed package's actual runtime version. The receipt and published metadata identify the exact source and package versions. -Releases run serially. A job whose source is no longer current `master` stops before publication. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward. +Releases run serially in one concurrency group: the release in progress always finishes, one release waits behind it, and a newer `master` push replaces that waiting release. A job starts only when its source is the current `master`, so a rerun of an older commit never publishes. Once started, it publishes even if newer merges land during its checks; the newest merge publishes next as a higher patch version. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward. After an upload, verification waits up to three minutes for npm to expose the version and its distribution tag. Registry reads retry temporary connection failures and use fresh connections after the build. Package identity, source, archive integrity, alias dependency and channel checks still have to pass before the release succeeds. diff --git a/scripts/release-auto.mjs b/scripts/release-auto.mjs index 2d1894b..6529c1a 100644 --- a/scripts/release-auto.mjs +++ b/scripts/release-auto.mjs @@ -12,9 +12,6 @@ assert(['dry-run', 'publish'].includes(mode) && !extra.length, 'Use release:auto const execute = (command, args, options = {}) => execFileSync(command, args, { cwd: root, stdio: 'inherit', ...options }); const git = (...args) => execute('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); const sourceSha = git('rev-parse', 'HEAD'); -const assertCurrentMaster = () => { - assert.equal(git('ls-remote', 'origin', 'refs/heads/master').split(/\s/)[0], sourceSha, 'Source is no longer current master. Let the newest master run publish.'); -}; if (mode === 'publish') { assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Automatic publishing runs only in GitHub Actions; use release:publish for an authorized local release.'); assert.equal(process.env.GITHUB_REPOSITORY, 'DealMachine/dealmachine-cli'); @@ -22,7 +19,10 @@ if (mode === 'publish') { assert.equal(process.env.GITHUB_SHA, sourceSha); assert(['push', 'workflow_dispatch'].includes(process.env.GITHUB_EVENT_NAME), 'Only master push and recovery dispatch may publish.'); assert.equal(git('status', '--porcelain'), '', 'Start from the clean committed release source.'); - assertCurrentMaster(); + // Only the current master may start a release, so a rerun of an older commit never publishes. Once started, the + // release finishes even if newer merges land during its checks: the workflow runs it in one concurrency group that + // never cancels a run in progress, so the newer merge waits and publishes next. + assert.equal(git('ls-remote', 'origin', 'refs/heads/master').split(/\s/)[0], sourceSha, 'Source is no longer current master. Let the newest master run publish.'); } const registries = Object.fromEntries(await Promise.all(releasePackages.map(async name => [name, await registry(name)]))); @@ -49,7 +49,6 @@ try { for (const artifact of record.artifacts) { if (registries[artifact.name].versions?.[plan.version]) verifyPublishedPackage(artifact, registries[artifact.name], plan, { requireTag: true }); } - if (mode === 'publish') assertCurrentMaster(); for (const artifact of record.artifacts) { if (registries[artifact.name].versions?.[plan.version]) { console.log(`Verified existing ${artifact.name}@${plan.version}; resuming without republishing it.`);