@@ -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"
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user