diff --git a/bot/src/github.ts b/bot/src/github.ts index 75e1cf3..7ce8654 100644 --- a/bot/src/github.ts +++ b/bot/src/github.ts @@ -56,6 +56,11 @@ export interface GitHubApi { head: string, token: string, ): Promise; + tagObject( + repository: string, + sha: string, + token: string, + ): Promise; listComments( repository: string, issue: number, @@ -235,6 +240,14 @@ export function githubApi(fetchFn: typeof fetch = fetch): GitHubApi { const path = `/repos/${repository}/issues/comments/${id}`; return expectOk(await call("PATCH", path, token, { body })); }, + async tagObject(repository, sha, token) { + const result = await call<{ object: { sha: string } }>( + "GET", + `/repos/${repository}/git/tags/${sha}`, + token, + ); + return result.status === 404 ? undefined : expectOk(result).object.sha; + }, async revoke(token) { expectOk(await call("DELETE", "/installation/token", token)); }, diff --git a/bot/src/index.ts b/bot/src/index.ts index 4321b25..20ec177 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -194,10 +194,15 @@ async function decideRef( ): Promise { const repository = await deps.github.repository(claims.repository, token); if (claims.ref.startsWith("refs/tags/")) { + // An annotated tag's sha names the tag object; judge the commit under it. + // Tag objects are immutable, so peeling keeps the sha claim authoritative. + const commit = + (await deps.github.tagObject(claims.repository, claims.sha, token)) ?? + claims.sha; const comparison = await deps.github.compare( claims.repository, repository.default_branch, - claims.sha, + commit, token, ); return decideTag(claims.ref, comparison.status); diff --git a/bot/test/github.test.ts b/bot/test/github.test.ts index c1472ea..2ecc21a 100644 --- a/bot/test/github.test.ts +++ b/bot/test/github.test.ts @@ -253,3 +253,18 @@ test("createComment and updateComment send the body", async () => { [{ body: "b" }, { body: "c" }], ); }); + +test("tagObject peels an annotated tag and is undefined for a commit", async () => { + const { api } = fakeGitHub({ + "GET /repos/acme/mylib/git/tags/aaaa": { + status: 200, + body: { object: { sha: "bbbb", type: "commit" } }, + }, + "GET /repos/acme/mylib/git/tags/cccc": { + status: 404, + body: { message: "Not Found" }, + }, + }); + assert.equal(await api.tagObject("acme/mylib", "aaaa", "ghs_x"), "bbbb"); + assert.equal(await api.tagObject("acme/mylib", "cccc", "ghs_x"), undefined); +}); diff --git a/bot/test/index.test.ts b/bot/test/index.test.ts index b9d971f..683b43d 100644 --- a/bot/test/index.test.ts +++ b/bot/test/index.test.ts @@ -41,6 +41,7 @@ function fakeGitHub( defaultBranch?: string; compareStatus?: string; comments?: IssueComment[]; + tagObjects?: Record; } = {}, ): Fake { const fake: Fake = { @@ -66,6 +67,9 @@ function fakeGitHub( fake.compared.push(head); return { status: opts.compareStatus ?? "diverged" }; }, + async tagObject(_repository, sha) { + return opts.tagObjects?.[sha]; + }, async listComments() { return fake.comments; }, @@ -289,6 +293,20 @@ test("a tag is judged by the commit that ran, not the tag name", async () => { assert.deepEqual(fake.compared, [claims().sha]); }); +test("an annotated tag's object sha is peeled to its commit before the compare", async () => { + const objectSha = "9999999999999999999999999999999999999999"; + const fake = fakeGitHub({ + compareStatus: "behind", + tagObjects: { [objectSha]: "abc123" }, + }); + const { status } = await request( + { ref: "refs/tags/v1.0.0", sha: objectSha }, + fake, + ); + assert.equal(status, 200); + assert.deepEqual(fake.compared, ["abc123"]); +}); + test("a tag whose commit is off the default branch is refused and revoked", async () => { const fake = fakeGitHub({ compareStatus: "diverged" }); const { status, body } = await request({ ref: "refs/tags/v1.0.0" }, fake);