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
21 changes: 20 additions & 1 deletion .github/actions/npm-independent-release/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
39 changes: 37 additions & 2 deletions .github/actions/npm-independent-release/state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading