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);