From eaefef18916f91cd5087d57914a9e5a5f8b986d4 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sat, 8 Aug 2026 08:15:05 +0200 Subject: [PATCH] fix(release): tolerate a release repeated across an offset page boundary Distinguish the same release seen twice from two distinct releases sharing a tag, so a paginated re-read during draft creation no longer fails the publish. --- .../actions/npm-independent-release/state.mjs | 21 +++++++++- .../npm-independent-release/state.test.mjs | 39 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.github/actions/npm-independent-release/state.mjs b/.github/actions/npm-independent-release/state.mjs index acecbcb..14f1d6f 100644 --- a/.github/actions/npm-independent-release/state.mjs +++ b/.github/actions/npm-independent-release/state.mjs @@ -42,6 +42,23 @@ export const validateArtifactRun = ({ return artifactRun; }; +/** + * Two releases sharing a tag is genuinely ambiguous: GitHub allows any number + * of drafts on one tag, and publishing an arbitrary one of them is not a choice + * this action may make. + * + * Seeing the same release twice is not that. The releases endpoint is offset + * paginated, and `prepare` creates drafts immediately before re-reading the + * list, so a draft becoming visible between two page requests shifts every + * later entry down and returns whichever release sat on the page boundary on + * both pages. That is the same replication lag `waitForStagedState` already + * retries through, so failing on it converts a transient read into a failed + * release. Distinguishing the two cases by id keeps the ambiguity fatal and + * lets the retry absorb the shift. + * + * The opposite shift, an entry skipped rather than repeated, needs no handling + * here: a missing draft leaves its entry pending and the same retry re-reads. + */ export const indexReleases = (pages) => { const releases = new Map(); for (const page of pages) { @@ -50,9 +67,11 @@ export const indexReleases = (pages) => { if (typeof release.tag_name !== "string" || !release.tag_name) { fail("GitHub release is missing its tag name."); } - if (releases.has(release.tag_name)) { + const seen = releases.get(release.tag_name); + if (seen && seen.id !== release.id) { fail(`GitHub returned duplicate releases for '${release.tag_name}'.`); } + if (seen) continue; releases.set(release.tag_name, { id: release.id, draft: release.draft, diff --git a/.github/actions/npm-independent-release/state.test.mjs b/.github/actions/npm-independent-release/state.test.mjs index ecb7609..15dcaa4 100644 --- a/.github/actions/npm-independent-release/state.test.mjs +++ b/.github/actions/npm-independent-release/state.test.mjs @@ -100,6 +100,37 @@ test("indexes draft releases returned by the paginated releases endpoint", () => assert.equal(releases.get("@scope/b@2.0.0").draft, false); }); +test("tolerates one release repeated across an offset page boundary", () => { + // A draft becoming visible between the two page requests shifts the boundary + // entry down, so the same release id is returned on both pages. + const boundary = { + id: 7, + tag_name: "@scope/a@1.2.3", + draft: false, + prerelease: false, + assets: [{ id: 8, name: "a-1.2.3.tgz" }], + }; + + const releases = indexReleases([ + [boundary], + [ + { ...boundary }, + { + id: 9, + tag_name: "@scope/b@2.0.0", + draft: false, + prerelease: false, + assets: [{ id: 10, name: "b-2.0.0.tgz" }], + }, + ], + ]); + + assert.equal(releases.size, 2); + assert.equal(releases.get("@scope/a@1.2.3").id, 7); + assert.equal(releases.get("@scope/b@2.0.0").id, 9); +}); + + test("accepts recovery artifacts only from the same workflow and release source", () => { const currentRun = { path: ".github/workflows/publish.yml" }; const artifactRun = { @@ -132,15 +163,19 @@ test("accepts recovery artifacts only from the same workflow and release source" }); test("rejects duplicate drafts for the same package tag", () => { + // Two DISTINCT drafts, which is what makes the tag ambiguous. The same draft + // seen twice is a paging artifact and is covered above. const draft = { - id: 1, tag_name: "@scope/a@1.2.3", draft: true, prerelease: false, assets: [{ id: 2, name: "a-1.2.3.tgz" }], }; - assert.throws(() => indexReleases([[draft], [draft]]), /duplicate releases/); + assert.throws( + () => indexReleases([[{ ...draft, id: 1 }], [{ ...draft, id: 3 }]]), + /duplicate releases/, + ); }); test("rejects missing, duplicate, unexpected, and version-mismatched tarballs", () => {