| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | const assert = require("node:assert/strict"); |
| 4 | const crypto = require("node:crypto"); |
| 5 | const fs = require("node:fs"); |
| 6 | const os = require("node:os"); |
| 7 | const path = require("node:path"); |
| 8 | const test = require("node:test"); |
| 9 | |
| 10 | const { allReleaseAssetNames, CHECKSUM_MANIFEST } = require("../../npm/codewhale/scripts/artifacts"); |
| 11 | const { run } = require("./verify-release-inventory"); |
| 12 | |
| 13 | const REPO = "codewhale-hq/CodeWhale"; |
| 14 | const TAG = "v0.10.1"; |
| 15 | |
| 16 | function localAssets(t) { |
| 17 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-release-inventory-")); |
| 18 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 19 | for (const [index, name] of allReleaseAssetNames().entries()) fs.writeFileSync(path.join(dir, name), "x".repeat(index + 1)); |
| 20 | return dir; |
| 21 | } |
| 22 | |
| 23 | function uploaded(names, sizeOf = (index) => index + 1) { |
| 24 | return names.map((name, index) => ({ name, state: "uploaded", size: sizeOf(index), |
| 25 | digest: `sha256:${crypto.hash("sha256", "x".repeat(sizeOf(index)))}` })); |
| 26 | } |
| 27 | |
| 28 | /** A gh double over one repository's releases; records every call. */ |
| 29 | function fakeGh(releases, { manifest = "", pages = [releases] } = {}) { |
| 30 | const calls = []; |
| 31 | const gh = (args) => { |
| 32 | calls.push(args.join(" ")); |
| 33 | if (args[0] === "release" && args[1] === "download") return manifest; |
| 34 | const [, method, endpoint] = args[1] === "-X" ? [null, args[2], args[3]] : [null, "GET", args[1]]; |
| 35 | if (method === "PATCH") { |
| 36 | const id = Number(endpoint.split("/").pop()); |
| 37 | for (const release of releases) if (release.id === id) release.draft = false; |
| 38 | return "{}"; |
| 39 | } |
| 40 | if (endpoint.startsWith(`repos/${REPO}/releases/tags/`)) { |
| 41 | const found = releases.find((release) => release.tag_name === TAG && !release.draft); |
| 42 | if (!found) throw new Error("gh api failed: HTTP 404"); |
| 43 | return JSON.stringify(found); |
| 44 | } |
| 45 | if (endpoint.startsWith(`repos/${REPO}/releases?`)) { |
| 46 | assert.ok(args.includes("--paginate") && args.includes("--slurp")); |
| 47 | return JSON.stringify(pages); |
| 48 | } |
| 49 | throw new Error(`unexpected gh call: ${args.join(" ")}`); |
| 50 | }; |
| 51 | return { gh, calls }; |
| 52 | } |
| 53 | |
| 54 | test("a complete draft is published at once, only after its inventory verifies", (t) => { |
| 55 | const dir = localAssets(t); |
| 56 | const releases = [{ id: 7, tag_name: TAG, draft: true, assets: uploaded(allReleaseAssetNames()) }]; |
| 57 | const { gh, calls } = fakeGh(releases); |
| 58 | run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {}); |
| 59 | assert.equal(releases[0].draft, false); |
| 60 | const patch = calls.findIndex((call) => call.startsWith("api -X PATCH")); |
| 61 | assert.ok(patch > 0, "publishing follows the draft lookup"); |
| 62 | assert.equal(calls[patch], `api -X PATCH repos/${REPO}/releases/7 -F draft=false`); |
| 63 | }); |
| 64 | |
| 65 | test("a partial, unfinished or resized draft is never published", (t) => { |
| 66 | const dir = localAssets(t); |
| 67 | const names = allReleaseAssetNames(); |
| 68 | const cases = [ |
| 69 | ["missing", uploaded(names.slice(0, -1)), /missing: /], |
| 70 | ["starter", uploaded(names).map((asset, index) => (index === 3 ? { ...asset, state: "starter" } : asset)), /not fully uploaded/], |
| 71 | ["size", uploaded(names, () => 1), /bytes; the verified local asset has/], |
| 72 | ["unexpected", [...uploaded(names), { name: "stray.txt", state: "uploaded", size: 1 }], /unexpected: stray\.txt/], |
| 73 | ]; |
| 74 | for (const [label, assets, message] of cases) { |
| 75 | const releases = [{ id: 7, tag_name: TAG, draft: true, assets }]; |
| 76 | const { gh, calls } = fakeGh(releases); |
| 77 | assert.throws(() => run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {}), message, label); |
| 78 | assert.equal(releases[0].draft, true, label); |
| 79 | assert.equal(calls.some((call) => call.includes("PATCH")), false, label); |
| 80 | } |
| 81 | }); |
| 82 | |
| 83 | test("zero or several drafts for the tag need a maintainer, not a guess", () => { |
| 84 | for (const drafts of [[], [1, 2]]) { |
| 85 | const releases = drafts.map((id) => ({ id, tag_name: TAG, draft: true, assets: uploaded(allReleaseAssetNames()) })); |
| 86 | const { gh } = fakeGh(releases); |
| 87 | assert.throws(() => run(["--draft", REPO, TAG], gh, () => {}), /Expected exactly one draft release/); |
| 88 | } |
| 89 | }); |
| 90 | |
| 91 | test("republish derives only from a release carrying exactly its own checksum manifest", () => { |
| 92 | const names = ["codewhale-linux-x64", "codewhale-macos-arm64"]; |
| 93 | const manifest = names.map((name) => `${"a".repeat(64)} ${name}`).join("\n"); |
| 94 | const complete = [{ id: 3, tag_name: TAG, draft: false, assets: uploaded([...names, CHECKSUM_MANIFEST]) }]; |
| 95 | assert.doesNotThrow(() => run(["--manifest", REPO, TAG], fakeGh(complete, { manifest }).gh, () => {})); |
| 96 | const partial = [{ id: 3, tag_name: TAG, draft: false, assets: uploaded([names[0], CHECKSUM_MANIFEST]) }]; |
| 97 | assert.throws(() => run(["--manifest", REPO, TAG], fakeGh(partial, { manifest }).gh, () => {}), /missing: codewhale-macos-arm64/); |
| 98 | }); |
| 99 | |
| 100 | test("usage refuses publish without draft and manifest with a local directory", () => { |
| 101 | assert.throws(() => run(["--publish", REPO, TAG], () => "[]", () => {}), /Usage/); |
| 102 | assert.throws(() => run(["--draft", "--publish", REPO, TAG], () => "[]", () => {}), /Usage/); |
| 103 | assert.throws(() => run(["--manifest", "--asset-dir", "x", REPO, TAG], () => "[]", () => {}), /Usage/); |
| 104 | }); |
| 105 | |
| 106 | test("same-size stale bytes and missing digests cannot publish a draft", (t) => { |
| 107 | const dir = localAssets(t); |
| 108 | for (const digest of [`sha256:${crypto.hash("sha256", "y")}`, null]) { |
| 109 | const assets = uploaded(allReleaseAssetNames()); |
| 110 | assets[0].digest = digest; |
| 111 | const releases = [{ id: 7, tag_name: TAG, draft: true, assets }]; |
| 112 | const { gh, calls } = fakeGh(releases); |
| 113 | assert.throws(() => run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {}), /SHA-256 digest/); |
| 114 | assert.equal(releases[0].draft, true); |
| 115 | assert.equal(calls.some((call) => call.includes("PATCH")), false); |
| 116 | } |
| 117 | }); |
| 118 | |
| 119 | test("drafts beyond the first release page are verified and duplicates refused", (t) => { |
| 120 | const dir = localAssets(t); |
| 121 | const draft = { id: 7, tag_name: TAG, draft: true, assets: uploaded(allReleaseAssetNames()) }; |
| 122 | const older = Array.from({ length: 100 }, (_, id) => ({ id: 100 + id, tag_name: `v0.9.${id}`, draft: false })); |
| 123 | const { gh } = fakeGh([draft], { pages: [older, [draft]] }); |
| 124 | run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], gh, () => {}); |
| 125 | assert.equal(draft.draft, false); |
| 126 | draft.draft = true; |
| 127 | const duplicate = { ...draft, id: 8 }; |
| 128 | const second = fakeGh([draft, duplicate], { pages: [[draft], [duplicate]] }); |
| 129 | assert.throws(() => run(["--draft", "--asset-dir", dir, "--publish", REPO, TAG], second.gh, () => {}), /found 2/); |
| 130 | assert.equal(second.calls.some((call) => call.includes("PATCH")), false); |
| 131 | }); |
| 132 |