test/scripts/release-workflow.test.ts
Rama main · réplica de lectura del repositorio original.
import { assert, assertStringIncludes } from "@std/assert";
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import { expectIncludesAll } from "#test-utils/assertions.ts";
import { walkSourceFiles } from "#test-utils/walk-src.ts";
const imagePath = ".github/workflows/publish-image.yml";
const releasePath = ".github/workflows/release.yml";
const readImage = (): string => Deno.readTextFileSync(imagePath);
const publishImage = async (response: string, status = 0, failCommand = "") => {
const workflow = readImage();
const script = workflow
.slice(workflow.lastIndexOf(" run: |\n"))
.split("\n")
.slice(1)
.map((line) => line.slice(10))
.join("\n");
const result = await new Deno.Command("bash", {
args: [
"-c",
`
podman() {
printf 'engine %s\\n' "$*"
if [ "$1" = login ]; then read -r token; fi
[ "$*" != "$FAIL_COMMAND" ]
}
curl() {
printf 'request %s\\n' "$*" >&2
printf '%s' "$RESPONSE"
return "$STATUS"
}
${script}
`,
],
env: {
FAIL_COMMAND: failCommand,
GHCR_TOKEN: "test-token",
GHCR_USER: "test-owner",
GITHUB_REPOSITORY: "chobbledotcom/tickets",
GITHUB_SHA: "release-commit",
RELEASE_TAG: "release-tag",
RESPONSE: response,
STATUS: String(status),
},
stderr: "piped",
stdout: "piped",
}).output();
return {
code: result.code,
stderr: new TextDecoder().decode(result.stderr),
stdout: new TextDecoder().decode(result.stdout),
};
};
describe("Release image publication", () => {
test("accepts only a reusable workflow call", () => {
const triggers = readImage().match(/^on:\n([\s\S]*?)(?=^\S)/m)?.[1];
expect(triggers?.trim()).toBe(
[
"workflow_call:",
" inputs:",
" release_tag:",
" required: true",
" type: string",
].join("\n"),
);
});
test("publishes only after the release job succeeds", () => {
const workflow = Deno.readTextFileSync(releasePath);
assertStringIncludes(
workflow,
[
" publish-image:",
" needs: release",
" permissions:",
" contents: read",
" packages: write",
" uses: ./.github/workflows/publish-image.yml",
" with:",
[" release_tag: $", "{{ needs.release.outputs.tag }}"].join(""),
].join("\n"),
);
expect(workflow).not.toMatch(
/always\(\)|continue-on-error:|secrets: inherit/,
);
expect(workflow).toMatch(
/tag: \$\{\{ steps\.create-release\.outputs\.tag }}/,
);
assertStringIncludes(workflow, "id: create-release");
expect(
workflow.indexOf('echo "tag=$TAG" >> "$GITHUB_OUTPUT"'),
).toBeGreaterThan(workflow.indexOf('gh release create "$TAG"'));
});
test("grants registry writes only to the image job", () => {
const workflow = Deno.readTextFileSync(releasePath);
expectIncludesAll(workflow, [
"\npermissions:\n contents: read\n",
" release:\n permissions:\n contents: write\n",
]);
expect(workflow.match(/packages: write/g)).toHaveLength(1);
});
test("has no caller outside Create Release", () => {
const callers = walkSourceFiles(".github/workflows", [
".yml",
".yaml",
]).filter((path) =>
Deno.readTextFileSync(path).includes(`uses: ./${imagePath}`),
);
expect(callers).toEqual([releasePath]);
});
test("retains the repository and main branch restrictions", () => {
assertStringIncludes(
readImage(),
"if: github.repository == 'chobbledotcom/tickets' && github.ref == 'refs/heads/main'",
);
});
test("builds the release commit before a serialised publication", () => {
const workflow = readImage();
expect(workflow).toMatch(/ref: \$\{\{ github\.sha }}/);
expect(workflow).toMatch(/RELEASE_TAG: \$\{\{ inputs\.release_tag }}/);
assertStringIncludes(
workflow,
"group: publish-image\n cancel-in-progress: false",
);
const commands = [
"run: container-build",
"run: container-load",
"run: container-check",
"name: Copy the image",
];
const positions = commands.map((command) => workflow.indexOf(command));
expect(positions.every((position) => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
});
test("queues release images without replacement by older retries", () => {
assertStringIncludes(
readImage(),
"group: publish-image\n cancel-in-progress: false\n queue: max",
);
});
test("promotes the latest release even if main advances", async () => {
const result = await publishImage(
'{"tag_name":"release-tag","sha":"new-main-commit"}',
);
expect(result.code).toBe(0);
assertStringIncludes(
result.stderr,
"/repos/chobbledotcom/tickets/releases/latest",
);
expect(result.stdout.trim().split("\n")).toEqual([
"engine login ghcr.io -u test-owner --password-stdin",
"engine tag ghcr.io/chobbledotcom/tickets:latest ghcr.io/chobbledotcom/tickets:sha-release-commit",
"engine push ghcr.io/chobbledotcom/tickets:sha-release-commit",
"engine push ghcr.io/chobbledotcom/tickets:latest",
]);
});
test("keeps an older release retry away from latest", async () => {
const result = await publishImage('{"tag_name":"new-release"}');
expect(result.code).toBe(0);
assertStringIncludes(
result.stdout,
"engine push ghcr.io/chobbledotcom/tickets:sha-release-commit",
);
assert(
!result.stdout.includes(
"engine push ghcr.io/chobbledotcom/tickets:latest",
),
);
});
for (const [label, response, status] of [
["failed API request", '{"tag_name":"release-tag"}', 22],
["invalid JSON", "not json", 0],
["missing tag", "{}", 0],
["empty tag", '{"tag_name":""}', 0],
["non-string tag", '{"tag_name":42}', 0],
] as const) {
test(`refuses latest on ${label}`, async () => {
const result = await publishImage(response, status);
expect(result.code).not.toBe(0);
assert(
!result.stdout.includes(
"engine push ghcr.io/chobbledotcom/tickets:latest",
),
);
});
}
test("stops publication when the commit image push fails", async () => {
const result = await publishImage(
'{"tag_name":"release-tag"}',
0,
"push ghcr.io/chobbledotcom/tickets:sha-release-commit",
);
expect(result.code).not.toBe(0);
expect(result.stderr).toBe("");
assert(
!result.stdout.includes(
"engine push ghcr.io/chobbledotcom/tickets:latest",
),
);
});
test("reports a failed latest push instead of success", async () => {
const result = await publishImage(
'{"tag_name":"release-tag"}',
0,
"push ghcr.io/chobbledotcom/tickets:latest",
);
expect(result.code).not.toBe(0);
assertStringIncludes(
result.stdout,
"engine push ghcr.io/chobbledotcom/tickets:sha-release-commit",
);
});
});