diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 05c6703..d8f5480 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -51,12 +51,34 @@ jobs: api="${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}/releases" payload=$(jq -n --arg tag "$GITEA_TAG" --arg commit "$GITEA_COMMIT" \ '{tag_name: $tag, name: $tag, target_commitish: $commit, draft: true}') - release=$(curl --fail-with-body --silent --show-error \ + response=$(mktemp) + trap 'rm -f "$response"' EXIT + status=$(curl --silent --show-error --output "$response" --write-out '%{http_code}' \ -H "Authorization: token $GITEA_TOKEN" \ -H 'Content-Type: application/json' \ --data "$payload" "$api") + case "$status" in + 201) release=$(cat "$response") ;; + 409) + tag=$(jq -rn --arg tag "$GITEA_TAG" '$tag | @uri') + if ! release=$(curl --fail-with-body --silent --show-error \ + -H "Authorization: token $GITEA_TOKEN" "$api/tags/$tag"); then + printf '%s\n' "$release" >&2 + exit 1 + fi + ;; + *) + cat "$response" >&2 + exit 1 + ;; + esac id=$(jq -er '.id | select(type == "number" and . > 0)' <<< "$release") for asset in stanp-opencode.tgz stanp-opencode.tgz.sha256; do + asset_ids=$(jq -r --arg name "$asset" '.assets[]? | select(.name == $name) | .id' <<< "$release") + for asset_id in $asset_ids; do + curl --fail-with-body --silent --show-error --request DELETE \ + -H "Authorization: token $GITEA_TOKEN" "$api/$id/assets/$asset_id" + done curl --fail-with-body --silent --show-error \ -H "Authorization: token $GITEA_TOKEN" \ --form "attachment=@$asset" "$api/$id/assets" diff --git a/tests/release.test.ts b/tests/release.test.ts new file mode 100644 index 0000000..7eddfcb --- /dev/null +++ b/tests/release.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { z } from "zod"; + +const workflow = z + .object({ + jobs: z.object({ + release: z.object({ + steps: z.array(z.object({ name: z.string(), run: z.string().optional() })), + }), + }), + }) + .parse( + Bun.YAML.parse( + await Bun.file(new URL("../.gitea/workflows/release.yml", import.meta.url)).text(), + ), + ); +const script = workflow.jobs.release.steps.find( + (step) => step.name === "Publish Gitea release", +)!.run!; +const base = "/api/v1/repos/test/project/releases"; +const tag = "release/v1+rc#1"; +const assets = ["stanp-opencode.tgz", "stanp-opencode.tgz.sha256"]; + +async function publish(status: number) { + const directory = await mkdtemp(join(tmpdir(), "stanp-release-")); + const requests: { method: string; path: string; body: unknown }[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname; + if (request.headers.get("content-type")?.startsWith("multipart/form-data")) { + const attachment = (await request.formData()).get("attachment"); + if (!(attachment instanceof File)) { + return new Response("Missing attachment", { status: 400 }); + } + requests.push({ + method: request.method, + path, + body: { name: attachment.name, content: await attachment.text() }, + }); + } else if (request.method === "POST" || request.method === "PATCH") { + requests.push({ method: request.method, path, body: await request.json() }); + } else { + requests.push({ method: request.method, path, body: null }); + } + if (request.method === "POST" && path === base) { + return Response.json( + status === 201 + ? { id: 42, assets: [] } + : { message: "release creation denied" }, + { status }, + ); + } + if (request.method === "GET" && path === `${base}/tags/${encodeURIComponent(tag)}`) { + return Response.json({ + id: 42, + name: "Keep this title", + body: "Keep these notes", + assets: [ + { id: 10, name: assets[0] }, + { id: 11, name: "stanp-opencode.tgz.sig" }, + { id: 12, name: assets[1] }, + ], + }); + } + if (request.method === "DELETE" && /\/42\/assets\/(10|12)$/.test(path)) { + return new Response(null, { status: 204 }); + } + if (request.method === "POST" && path === `${base}/42/assets`) { + return Response.json({ id: 20 }, { status: 201 }); + } + if (request.method === "PATCH" && path === `${base}/42`) { + return Response.json({ id: 42, draft: false }); + } + return Response.json({ message: "unexpected request" }, { status: 500 }); + }, + }); + try { + await Promise.all( + assets.map((asset) => writeFile(join(directory, asset), `contents of ${asset}`)), + ); + const process = Bun.spawn(["bash", "-c", script], { + cwd: directory, + env: { + ...Bun.env, + GITEA_SERVER_URL: server.url.origin, + GITEA_REPOSITORY: "test/project", + GITEA_TAG: tag, + GITEA_COMMIT: "abc123", + GITEA_TOKEN: "test-token", + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }); + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + new Response(process.stdout).text(), + new Response(process.stderr).text(), + ]); + return { exitCode, output: stdout + stderr, requests }; + } finally { + await server.stop(true); + await rm(directory, { recursive: true, force: true }); + } +} + +for (const status of [201, 409]) { + test(`publishes both assets after release creation returns ${status}`, async () => { + const result = await publish(status); + expect(result.exitCode).toBe(0); + expect(result.requests.filter(({ method }) => method !== "DELETE")).toEqual([ + { + method: "POST", + path: base, + body: { tag_name: tag, name: tag, target_commitish: "abc123", draft: true }, + }, + ...(status === 409 + ? [{ method: "GET", path: `${base}/tags/${encodeURIComponent(tag)}`, body: null }] + : []), + ...assets.map((asset) => ({ + method: "POST", + path: `${base}/42/assets`, + body: { name: asset, content: `contents of ${asset}` }, + })), + { method: "PATCH", path: `${base}/42`, body: { draft: false } }, + ]); + const deletions = result.requests.filter(({ method }) => method === "DELETE"); + expect(deletions).toEqual( + status === 409 + ? [10, 12].map((id) => ({ + method: "DELETE", + path: `${base}/42/assets/${id}`, + body: null, + })) + : [], + ); + const uploads = result.requests.filter( + ({ method, path }) => method === "POST" && path === `${base}/42/assets`, + ); + for (const [index, deletion] of deletions.entries()) { + expect(result.requests.indexOf(deletion)).toBeLessThan( + result.requests.indexOf(uploads[index]!), + ); + } + }, 15_000); +} + +test("non-conflict creation failure stops publishing and shows the response body", async () => { + const result = await publish(403); + expect(result.exitCode).not.toBe(0); + expect(result.output).toContain("release creation denied"); + expect(result.requests.map(({ method, path }) => ({ method, path }))).toEqual([ + { method: "POST", path: base }, + ]); +}, 15_000);