Skip to content

Commit 744cb87

Browse files
silverwindclaude
andcommitted
Clean up stale drafts on conflict and roll back on failure
Gitea's POST /releases returns 409 "Release is has no Tag" when a draft already exists for the tag. The drafts get there by way of versions' own rollback: when the rollback pushes :refs/tags/X to delete the tag, Gitea's PushUpdateDeleteTags silently flips any existing real release for that tag to is_draft=true (preserving body/title), creating the exact state that blocks the next run. Two complementary fixes: createForgeRelease now POSTs optimistically; on 409 (Gitea) or 422 (GitHub already_exists) it lists drafts, deletes any matching tag_name, and retries once. Happy path is unchanged — single POST. createForgeRelease also returns {id, html_url} now. main() registers a deleteForgeRelease rollback after creation, pushed last so it drains first (LIFO) — the release is gone before the tag-delete push reaches Gitea, so there's nothing to mutate into a draft. withTokens + AuthRetryable factor out the per-token 401/403 fallback shared by createForgeRelease and deleteForgeRelease. Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
1 parent 0207895 commit 744cb87

2 files changed

Lines changed: 338 additions & 47 deletions

File tree

index.test.ts

Lines changed: 230 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
joinStrings, findUp, getFileChanges, write,
88
readVersionFromPackageJson, readVersionFromPyprojectToml,
99
removeIgnoredFiles, getGithubTokens, getGiteaTokens,
10-
getRepoInfo, writeResult, createForgeRelease,
10+
getRepoInfo, writeResult, createForgeRelease, deleteForgeRelease,
1111
readChangelogEntry, updateChangelogHeadingDate,
1212
type RepoInfo,
1313
} from "./index.ts";
@@ -440,40 +440,82 @@ test("updateChangelogHeadingDate", () => {
440440
expect(updateChangelogHeadingDate("## 1.0.0\nbody\n", "9.9.9", today)).toBeNull();
441441
});
442442

443-
test("createForgeRelease github success", async () => {
444-
const mock = vi.fn(() => Promise.resolve(Response.json({html_url: "https://github.com/o/r/releases/tag/1.0.1"}, {status: 201})));
443+
function getCalls(mock: ReturnType<typeof vi.fn>) {
444+
return mock.mock.calls as unknown as Array<[string, RequestInit | undefined]>;
445+
}
446+
447+
function postCall(mock: ReturnType<typeof vi.fn>) {
448+
const found = getCalls(mock).find(([, init]) => init?.method === "POST");
449+
if (!found) throw new Error("no POST call recorded");
450+
return found;
451+
}
452+
453+
function authOf(init: RequestInit | undefined) {
454+
return (init?.headers as Record<string, string> | undefined)?.Authorization;
455+
}
456+
457+
function mockForgePost(create: Response | (() => Response)) {
458+
const mock = vi.fn(() => Promise.resolve(typeof create === "function" ? create() : create));
459+
stubGlobal("fetch", mock);
460+
return mock;
461+
}
462+
463+
// First POST returns conflictStatus; cleanup GET returns drafts; DELETE 204; retry POST returns success.
464+
function mockForgeConflictThenSuccess(conflictStatus: number, drafts: Array<{id: number; tag_name: string; draft: boolean}>, success: Response) {
465+
let postCount = 0;
466+
const mock = vi.fn((_url: string, init?: RequestInit) => {
467+
const method = init?.method ?? "GET";
468+
if (method === "POST") {
469+
postCount += 1;
470+
if (postCount === 1) return Promise.resolve(new Response(`conflict ${conflictStatus}`, {status: conflictStatus, statusText: "Conflict"}));
471+
return Promise.resolve(success);
472+
}
473+
if (method === "GET") return Promise.resolve(Response.json(drafts, {status: 200}));
474+
if (method === "DELETE") return Promise.resolve(new Response(null, {status: 204}));
475+
throw new Error(`unexpected method ${method}`);
476+
});
445477
stubGlobal("fetch", mock);
478+
return mock;
479+
}
480+
481+
test("createForgeRelease github success skips cleanup on happy path", async () => {
482+
const mock = mockForgePost(Response.json({id: 4242, html_url: "https://github.com/o/r/releases/tag/1.0.1"}, {status: 201}));
446483
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
447-
await createForgeRelease(info, "1.0.1", "changelog", ["gh-token"]);
484+
const created = await createForgeRelease(info, "1.0.1", "changelog", ["gh-token"]);
485+
expect(created).toEqual({id: 4242, html_url: "https://github.com/o/r/releases/tag/1.0.1"});
448486
expect(mock).toHaveBeenCalledOnce();
449-
const [url, init] = mock.mock.calls[0] as unknown as [string, any];
487+
const [url, init] = postCall(mock);
450488
expect(url).toEqual("https://api.github.com/repos/o/r/releases");
451-
expect(init.headers.Authorization).toEqual("Bearer gh-token");
452-
const body = JSON.parse(init.body);
489+
expect(authOf(init)).toEqual("Bearer gh-token");
490+
const body = JSON.parse(init!.body as string);
453491
expect(body.tag_name).toEqual("1.0.1");
454492
expect(body.name).toEqual("1.0.1");
455493
expect(body.body).toEqual("changelog");
456494
expect(body.draft).toEqual(false);
457495
expect(body.prerelease).toEqual(false);
458496
});
459497

460-
test("createForgeRelease gitea success", async () => {
461-
const mock = vi.fn(() => Promise.resolve(Response.json({html_url: "https://gitea.example.com/o/r/releases/tag/2.0.0"}, {status: 201})));
462-
stubGlobal("fetch", mock);
498+
test("createForgeRelease returns null when response has no numeric id", async () => {
499+
mockForgePost(Response.json({html_url: "https://github.com/o/r/releases/tag/1.0.0"}, {status: 201}));
500+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
501+
expect(await createForgeRelease(info, "1.0.0", "body", ["tok"])).toBeNull();
502+
});
503+
504+
test("createForgeRelease gitea success skips cleanup on happy path", async () => {
505+
const mock = mockForgePost(Response.json({html_url: "https://gitea.example.com/o/r/releases/tag/2.0.0"}, {status: 201}));
463506
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
464507
await createForgeRelease(info, "2.0.0", "notes", ["gitea-tok"]);
465508
expect(mock).toHaveBeenCalledOnce();
466-
const [url, init] = mock.mock.calls[0] as unknown as [string, any];
509+
const [url, init] = postCall(mock);
467510
expect(url).toEqual("https://gitea.example.com/api/v1/repos/o/r/releases");
468-
expect(init.headers.Authorization).toEqual("token gitea-tok");
511+
expect(authOf(init)).toEqual("token gitea-tok");
469512
});
470513

471514
test("createForgeRelease prerelease tag", async () => {
472-
const mock = vi.fn(() => Promise.resolve(Response.json({}, {status: 201})));
473-
stubGlobal("fetch", mock);
515+
const mock = mockForgePost(Response.json({}, {status: 201}));
474516
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
475517
await createForgeRelease(info, "1.0.0-beta.1", "body", ["tok"]);
476-
expect(JSON.parse((mock.mock.calls[0] as unknown as [string, any])[1].body).prerelease).toEqual(true);
518+
expect(JSON.parse(postCall(mock)[1]!.body as string).prerelease).toEqual(true);
477519
});
478520

479521
test.each([[401, "Unauthorized"], [403, "Forbidden"]])("createForgeRelease token fallback on %i", async (status, text) => {
@@ -486,10 +528,10 @@ test.each([[401, "Unauthorized"], [403, "Forbidden"]])("createForgeRelease token
486528
expect(mock).toHaveBeenCalledTimes(2);
487529
});
488530

489-
test("createForgeRelease throws on non-auth error", async () => {
490-
stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("Validation failed", {status: 422, statusText: "Unprocessable Entity"}))));
531+
test("createForgeRelease throws on non-conflict, non-auth error", async () => {
532+
stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("Server error", {status: 500, statusText: "Internal Server Error"}))));
491533
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
492-
await expect(createForgeRelease(info, "1.0.0", "body", ["tok"])).rejects.toThrow("422");
534+
await expect(createForgeRelease(info, "1.0.0", "body", ["tok"])).rejects.toThrow("500");
493535
});
494536

495537
test("createForgeRelease throws when all tokens fail", async () => {
@@ -507,11 +549,179 @@ test("createForgeRelease network error includes cause", async () => {
507549
});
508550

509551
test("createForgeRelease no html_url in response", async () => {
510-
stubGlobal("fetch", vi.fn(() => Promise.resolve(Response.json({id: 1}, {status: 201}))));
552+
mockForgePost(Response.json({id: 1}, {status: 201}));
511553
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
512554
await createForgeRelease(info, "1.0.0", "body", ["tok"]);
513555
});
514556

557+
test("createForgeRelease cleans up draft on gitea 409 then retries", async () => {
558+
const mock = mockForgeConflictThenSuccess(
559+
409,
560+
[
561+
{id: 35141, tag_name: "v1.2.3", draft: true},
562+
{id: 35142, tag_name: "v1.2.4", draft: true},
563+
{id: 35143, tag_name: "v1.2.3", draft: false},
564+
],
565+
Response.json({html_url: "https://gitea.example.com/o/r/releases/tag/v1.2.3"}, {status: 201}),
566+
);
567+
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
568+
await createForgeRelease(info, "v1.2.3", "notes", ["tok"]);
569+
570+
const calls = getCalls(mock);
571+
const methods = calls.map(([, init]) => init?.method ?? "GET");
572+
expect(methods).toEqual(["POST", "GET", "DELETE", "POST"]);
573+
const deleteCall = calls.find(([, init]) => init?.method === "DELETE")!;
574+
expect(deleteCall[0]).toEqual("https://gitea.example.com/api/v1/repos/o/r/releases/35141");
575+
});
576+
577+
test("createForgeRelease cleans up draft on gitea 422 then retries", async () => {
578+
const mock = mockForgeConflictThenSuccess(
579+
422,
580+
[{id: 7, tag_name: "v9.9.9", draft: true}],
581+
Response.json({html_url: "https://gitea.example.com/o/r/releases/tag/v9.9.9"}, {status: 201}),
582+
);
583+
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
584+
await createForgeRelease(info, "v9.9.9", "body", ["tok"]);
585+
586+
const methods = getCalls(mock).map(([, init]) => init?.method ?? "GET");
587+
expect(methods).toEqual(["POST", "GET", "DELETE", "POST"]);
588+
});
589+
590+
test("createForgeRelease cleans up draft on github 422 then retries", async () => {
591+
const mock = mockForgeConflictThenSuccess(
592+
422,
593+
[{id: 99, tag_name: "v1.0.0", draft: true}],
594+
Response.json({html_url: "https://github.com/o/r/releases/tag/v1.0.0"}, {status: 201}),
595+
);
596+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
597+
await createForgeRelease(info, "v1.0.0", "body", ["tok"]);
598+
599+
const calls = getCalls(mock);
600+
const deleteCall = calls.find(([, init]) => init?.method === "DELETE")!;
601+
expect(deleteCall[0]).toEqual("https://api.github.com/repos/o/r/releases/99");
602+
expect(authOf(deleteCall[1])).toEqual("Bearer tok");
603+
});
604+
605+
test("createForgeRelease propagates conflict when no matching draft to clean up", async () => {
606+
const mock = vi.fn((_url: string, init?: RequestInit) => {
607+
const method = init?.method ?? "GET";
608+
if (method === "POST") return Promise.resolve(new Response("Release is has no Tag", {status: 409, statusText: "Conflict"}));
609+
if (method === "GET") return Promise.resolve(Response.json([{id: 1, tag_name: "other-tag", draft: true}], {status: 200}));
610+
throw new Error(`unexpected method ${method}`);
611+
});
612+
stubGlobal("fetch", mock);
613+
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
614+
await expect(createForgeRelease(info, "v1.0.0", "body", ["tok"])).rejects.toThrow("409");
615+
const methods = getCalls(mock).map(([, init]) => init?.method ?? "GET");
616+
expect(methods).toEqual(["POST", "GET"]);
617+
});
618+
619+
test("createForgeRelease cleans up multiple matching drafts", async () => {
620+
const mock = mockForgeConflictThenSuccess(
621+
409,
622+
[
623+
{id: 10, tag_name: "v1.0.0", draft: true},
624+
{id: 11, tag_name: "v1.0.0", draft: true},
625+
],
626+
Response.json({html_url: "https://gitea.example.com/o/r/releases/tag/v1.0.0"}, {status: 201}),
627+
);
628+
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
629+
await createForgeRelease(info, "v1.0.0", "body", ["tok"]);
630+
631+
const deleteCalls = getCalls(mock).filter(([, init]) => init?.method === "DELETE");
632+
expect(deleteCalls).toHaveLength(2);
633+
});
634+
635+
test("createForgeRelease tolerates 404 on draft delete (already gone)", async () => {
636+
let postCount = 0;
637+
const mock = vi.fn((_url: string, init?: RequestInit) => {
638+
const method = init?.method ?? "GET";
639+
if (method === "POST") {
640+
postCount += 1;
641+
if (postCount === 1) return Promise.resolve(new Response("conflict", {status: 409, statusText: "Conflict"}));
642+
return Promise.resolve(Response.json({}, {status: 201}));
643+
}
644+
if (method === "GET") return Promise.resolve(Response.json([{id: 5, tag_name: "v1.0.0", draft: true}], {status: 200}));
645+
if (method === "DELETE") return Promise.resolve(new Response("not found", {status: 404}));
646+
throw new Error(`unexpected method ${method}`);
647+
});
648+
stubGlobal("fetch", mock);
649+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
650+
await createForgeRelease(info, "v1.0.0", "body", ["tok"]);
651+
const methods = getCalls(mock).map(([, init]) => init?.method ?? "GET");
652+
expect(methods).toEqual(["POST", "GET", "DELETE", "POST"]);
653+
});
654+
655+
test("createForgeRelease throws if draft delete fails non-404", async () => {
656+
stubGlobal("fetch", vi.fn((_url: string, init?: RequestInit) => {
657+
const method = init?.method ?? "GET";
658+
if (method === "POST") return Promise.resolve(new Response("conflict", {status: 409, statusText: "Conflict"}));
659+
if (method === "GET") return Promise.resolve(Response.json([{id: 5, tag_name: "v1.0.0", draft: true}], {status: 200}));
660+
if (method === "DELETE") return Promise.resolve(new Response("server error", {status: 500, statusText: "Internal Server Error"}));
661+
throw new Error(`unexpected method ${method}`);
662+
}));
663+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
664+
await expect(createForgeRelease(info, "v1.0.0", "body", ["tok"])).rejects.toThrow("Failed to delete draft release 5");
665+
});
666+
667+
test("deleteForgeRelease github DELETEs by id", async () => {
668+
const mock = vi.fn(() => Promise.resolve(new Response(null, {status: 204})));
669+
stubGlobal("fetch", mock);
670+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
671+
await deleteForgeRelease(info, 4242, ["gh-tok"]);
672+
expect(mock).toHaveBeenCalledOnce();
673+
const [url, init] = mock.mock.calls[0] as unknown as [string, RequestInit];
674+
expect(url).toEqual("https://api.github.com/repos/o/r/releases/4242");
675+
expect(init.method).toEqual("DELETE");
676+
expect(authOf(init)).toEqual("Bearer gh-tok");
677+
});
678+
679+
test("deleteForgeRelease gitea DELETEs by id", async () => {
680+
const mock = vi.fn(() => Promise.resolve(new Response(null, {status: 204})));
681+
stubGlobal("fetch", mock);
682+
const info: RepoInfo = {owner: "o", repo: "r", host: "gitea.example.com", type: "gitea"};
683+
await deleteForgeRelease(info, 35141, ["gitea-tok"]);
684+
const [url, init] = mock.mock.calls[0] as unknown as [string, RequestInit];
685+
expect(url).toEqual("https://gitea.example.com/api/v1/repos/o/r/releases/35141");
686+
expect(authOf(init)).toEqual("token gitea-tok");
687+
});
688+
689+
test("deleteForgeRelease tolerates 404", async () => {
690+
stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("not found", {status: 404}))));
691+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
692+
await deleteForgeRelease(info, 1, ["tok"]);
693+
});
694+
695+
test("deleteForgeRelease falls back across tokens on 401", async () => {
696+
const mock = vi.fn()
697+
.mockResolvedValueOnce(new Response("auth", {status: 401, statusText: "Unauthorized"}))
698+
.mockResolvedValueOnce(new Response(null, {status: 204}));
699+
stubGlobal("fetch", mock);
700+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
701+
await deleteForgeRelease(info, 7, ["bad", "good"]);
702+
expect(mock).toHaveBeenCalledTimes(2);
703+
});
704+
705+
test("deleteForgeRelease throws on non-auth error", async () => {
706+
stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("server", {status: 500, statusText: "Internal Server Error"}))));
707+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
708+
await expect(deleteForgeRelease(info, 1, ["tok"])).rejects.toThrow("500");
709+
});
710+
711+
test("deleteForgeRelease throws when all tokens fail", async () => {
712+
stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response("auth", {status: 401, statusText: "Unauthorized"}))));
713+
const info: RepoInfo = {owner: "o", repo: "r", host: "github.com", type: "github"};
714+
await expect(deleteForgeRelease(info, 1, ["a", "b"])).rejects.toThrow("401");
715+
});
716+
717+
test("deleteForgeRelease network error includes cause", async () => {
718+
stubGlobal("fetch", vi.fn().mockRejectedValue(
719+
Object.assign(new TypeError("fetch failed"), {cause: new Error("getaddrinfo ENOTFOUND example.com")}),
720+
));
721+
const info: RepoInfo = {owner: "o", repo: "r", host: "example.com", type: "gitea"};
722+
await expect(deleteForgeRelease(info, 1, ["tok"])).rejects.toThrow("getaddrinfo ENOTFOUND example.com");
723+
});
724+
515725
test("release rejects detached HEAD", () => withTmpDir(async (tmpDir) => {
516726
await writeFile(join(tmpDir, "package.json"), JSON.stringify({name: "test-pkg", version: "1.0.0"}, null, 2));
517727

@@ -930,7 +1140,7 @@ test("--remote with --release uses that remote for forge detection", () => withT
9301140
}
9311141
expect(err).toBeInstanceOf(SubprocessError);
9321142
expect(err.exitCode).toEqual(1);
933-
expect(err.output).toContain("Failed to create release");
1143+
expect(err.output).toMatch(/Failed to (create|list) release/);
9341144
expect(err.output).not.toContain("Could not determine repository type");
9351145
}));
9361146

0 commit comments

Comments
 (0)