From d797425da7dda99206b15a40df7d3ec033613eee Mon Sep 17 00:00:00 2001 From: KyleTryon Date: Thu, 10 Sep 2026 23:26:27 -0400 Subject: [PATCH] fix(cliparr): check release tag existence through git refs --- tools/release/create-github-release.mjs | 21 ++++-- tools/release/create-github-release.test.mjs | 68 +++++++++++++++++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/tools/release/create-github-release.mjs b/tools/release/create-github-release.mjs index 4b020de..12c3c3b 100644 --- a/tools/release/create-github-release.mjs +++ b/tools/release/create-github-release.mjs @@ -196,14 +196,23 @@ export async function main(argv = process.argv.slice(2)) { } const repositoryPath = `/repos/${arguments_.repository}`; - const existing = await githubApi( - `${repositoryPath}/releases/tags/${arguments_.tag}`, - { token, allowMissing: true }, - ); - const taggedCommit = await githubApi( - `${repositoryPath}/commits/${arguments_.tag}`, + const tag = encodeURIComponent(arguments_.tag); + const existing = await githubApi(`${repositoryPath}/releases/tags/${tag}`, { + token, + allowMissing: true, + }); + const tagReference = await githubApi( + `${repositoryPath}/git/ref/tags/${tag}`, { token, allowMissing: true }, ); + // The commits endpoint returns 422 for an absent tag. Resolve the commit only + // after finding the exact tag ref; this also dereferences annotated tags. + const taggedCommit = tagReference + ? await githubApi( + `${repositoryPath}/commits/${encodeURIComponent(`refs/tags/${arguments_.tag}`)}`, + { token }, + ) + : undefined; if ( (existing && !taggedCommit) || (taggedCommit && taggedCommit.sha !== arguments_.target) diff --git a/tools/release/create-github-release.test.mjs b/tools/release/create-github-release.test.mjs index de2f869..a7e6212 100644 --- a/tools/release/create-github-release.test.mjs +++ b/tools/release/create-github-release.test.mjs @@ -103,7 +103,16 @@ void test("dry-run notes identify the image as planned", () => { assert.doesNotMatch(body, /Published to/u); }); -function mockReleaseApi(context, { existing = false, conflict = false } = {}) { +function mockReleaseApi( + context, + { + existing = false, + conflict = false, + tagExists = existing || conflict, + refStatus = tagExists ? 200 : 404, + commitStatus = tagExists ? 200 : 422, + } = {}, +) { const directory = mkdtempSync(path.join(tmpdir(), "cliparr-release-api-")); const tagsFile = path.join(directory, "tags.txt"); writeFileSync(tagsFile, "example/image:1.0.0\n"); @@ -126,10 +135,19 @@ function mockReleaseApi(context, { existing = false, conflict = false } = {}) { if (pathname.endsWith("/generate-notes")) { return Response.json({ body: "Changes" }); } + if (pathname.includes("/git/ref/tags/")) { + return Response.json( + { ref: "refs/tags/v0.6.1", object: { type: "tag", sha: "tag-object" } }, + { status: refStatus }, + ); + } if (pathname.includes("/commits/")) { - return existing || conflict + return commitStatus === 200 ? Response.json({ sha: conflict ? "other" : "HEAD" }) - : new Response("Missing", { status: 404 }); + : Response.json( + { message: "No commit found for SHA: v0.6.1" }, + { status: commitStatus }, + ); } if (options.method === "GET") { return existing @@ -180,4 +198,48 @@ void test("new candidates are prereleases and never become latest", async (conte ); assert.equal(creation.body.prerelease, true); assert.equal(creation.body.make_latest, "false"); + assert.ok( + api.calls.some((call) => call.pathname.endsWith("/git/ref/tags/v0.6.1")), + ); + assert.ok(!api.calls.some((call) => call.pathname.includes("/commits/"))); +}); + +void test("creates a release for an existing annotated tag after resolving its commit", async (context) => { + const api = mockReleaseApi(context, { tagExists: true }); + await main(api.args); + assert.ok( + api.calls.some((call) => + call.pathname.endsWith("/commits/refs%2Ftags%2Fv0.6.1"), + ), + ); + assert.ok( + api.calls.some( + (call) => call.method === "POST" && call.pathname.endsWith("/releases"), + ), + ); +}); + +void test("refuses an existing release whose tag is missing", async (context) => { + const api = mockReleaseApi(context, { existing: true, tagExists: false }); + await assert.rejects(main(api.args), /points to another commit/u); + assert.ok(!api.calls.some((call) => call.method === "PATCH")); }); + +for (const status of [403, 422, 500]) { + void test(`does not treat ref lookup HTTP ${status} as a missing tag`, async (context) => { + const api = mockReleaseApi(context, { refStatus: status }); + await assert.rejects(main(api.args), new RegExp(`failed: ${status}`, "u")); + assert.ok(!api.calls.some((call) => call.pathname.endsWith("/releases"))); + }); +} + +for (const status of [404, 422]) { + void test(`refuses publication when an existing tag's commit lookup returns HTTP ${status}`, async (context) => { + const api = mockReleaseApi(context, { + tagExists: true, + commitStatus: status, + }); + await assert.rejects(main(api.args), new RegExp(`failed: ${status}`, "u")); + assert.ok(!api.calls.some((call) => call.pathname.endsWith("/releases"))); + }); +}