From 995c24f3edc9576c11798b73b3ecfb99d20218c0 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:00:37 +0100 Subject: [PATCH 1/9] fix(release): make personal build cleanup retry-safe --- .github/workflows/personal-cleanup-check.yml | 34 ++++++ .github/workflows/personal-release.yml | 22 ++-- scripts/personal-release-cleanup.mjs | 69 ++++++++++++ scripts/personal-release-cleanup.test.mjs | 108 +++++++++++++++++++ 4 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/personal-cleanup-check.yml create mode 100644 scripts/personal-release-cleanup.mjs create mode 100644 scripts/personal-release-cleanup.test.mjs diff --git a/.github/workflows/personal-cleanup-check.yml b/.github/workflows/personal-cleanup-check.yml new file mode 100644 index 0000000000000..11f0e620d6f3f --- /dev/null +++ b/.github/workflows/personal-cleanup-check.yml @@ -0,0 +1,34 @@ +name: Personal release cleanup checks + +on: + pull_request: + paths: + - scripts/personal-release-cleanup.mjs + - scripts/personal-release-cleanup.test.mjs + - .github/workflows/personal-release.yml + - .github/workflows/personal-cleanup-check.yml + push: + branches: [personal-updates] + paths: + - scripts/personal-release-cleanup.mjs + - scripts/personal-release-cleanup.test.mjs + - .github/workflows/personal-release.yml + - .github/workflows/personal-cleanup-check.yml + +permissions: + contents: read + +jobs: + cleanup: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + sparse-checkout: scripts + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: node --check scripts/personal-release-cleanup.mjs + - run: node --test scripts/personal-release-cleanup.test.mjs diff --git a/.github/workflows/personal-release.yml b/.github/workflows/personal-release.yml index 6d29d3212d497..2cb0664189fc7 100644 --- a/.github/workflows/personal-release.yml +++ b/.github/workflows/personal-release.yml @@ -249,6 +249,18 @@ jobs: contents: write issues: write steps: + - name: Checkout cleanup script + env: + SOURCE: ${{ github.sha }} + run: | + git init . + git remote add origin https://github.com/Microck/t3code.git + git fetch --no-tags --depth=1 origin "$SOURCE" + git sparse-checkout set --no-cone '/scripts/personal-release-cleanup.mjs' + git -c checkout.workers=0 checkout --detach FETCH_HEAD + - uses: actions/setup-node@v6 + with: + node-version: "24" - name: Report failures and remove the generated build branch env: GH_TOKEN: ${{ github.token }} @@ -266,11 +278,5 @@ jobs: gh issue create --repo Microck/t3code --title 'Personal update needs attention' --body "Update withheld. The installed version is unchanged. Logs: $RUN_URL" fi fi - if [[ "$BRANCH" == personal-build/* ]]; then - gh api --method DELETE "repos/Microck/t3code/git/refs/heads/$BRANCH" - fi - # Release assets are permanent; intermediate Actions artifacts are not needed after the run. - gh api "repos/Microck/t3code/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[].id' | - while read -r artifact; do - gh api --method DELETE "repos/Microck/t3code/actions/artifacts/$artifact" - done + node scripts/personal-release-cleanup.mjs + diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs new file mode 100644 index 0000000000000..504b6cbc3ba26 --- /dev/null +++ b/scripts/personal-release-cleanup.mjs @@ -0,0 +1,69 @@ +import { pathToFileURL } from "node:url"; + +const repository = "Microck/t3code"; + +// Cleanup is repeatable: an absent generated ref/artifact already meets the goal. +// Never decode successful DELETE responses, which normally have no JSON body. +export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = console.log }) { + if (!/^\d+$/.test(runId)) throw new Error("Invalid workflow run ID"); + if (branch && !new RegExp(`^personal-build/${runId}-[1-9]\\d*$`).test(branch)) { + throw new Error("Refusing to clean a branch outside this workflow run"); + } + if (!token) throw new Error("Missing workflow token"); + + async function request(path, method = "GET") { + for (let attempt = 0; attempt < 3; attempt++) { + let response; + try { + response = await fetchImpl(`https://api.github.com/repos/${repository}/${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, + signal: AbortSignal.timeout(15_000), + redirect: "error", + }); + } catch { + if (attempt === 2) throw new Error(`${method} ${path}: transport failed after 3 attempts`); + await sleep(1000 * (attempt + 1)); + continue; + } + if (method === "DELETE" && (response.ok || response.status === 404)) return; + if (!response.ok) { + if (response.status >= 500 && attempt < 2) { + await sleep(1000 * (attempt + 1)); + continue; + } + throw new Error(`${method} ${path}: HTTP ${response.status}`); + } + try { + return await response.json(); + } catch { + if (attempt === 2) throw new Error(`GET ${path}: invalid JSON after 3 attempts`); + await sleep(1000 * (attempt + 1)); + } + } + } + + // Collect every page before deleting anything; deletion shifts pagination. + const artifacts = new Set(); + for (let page = 1; ; page++) { + const data = await request(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`); + if (!Array.isArray(data.artifacts)) throw new Error("Invalid artifact listing"); + for (const artifact of data.artifacts) { + if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0) throw new Error("Invalid artifact ID"); + artifacts.add(artifact.id); + } + if (data.artifacts.length < 100) break; + } + if (branch) { + await request(`git/refs/heads/${branch}`, "DELETE"); + log(`Generated branch absent: ${branch}`); + } + for (const id of artifacts) { + await request(`actions/artifacts/${id}`, "DELETE"); + log(`Intermediate artifact absent: ${id}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await cleanup({ runId: process.env.RUN_ID ?? "", branch: process.env.BRANCH ?? "", token: process.env.GH_TOKEN }); +} diff --git a/scripts/personal-release-cleanup.test.mjs b/scripts/personal-release-cleanup.test.mjs new file mode 100644 index 0000000000000..c304c2a3fde03 --- /dev/null +++ b/scripts/personal-release-cleanup.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { cleanup } from "./personal-release-cleanup.mjs"; + +const json = (data) => new Response(JSON.stringify(data)); +function fixture(responses, overrides = {}) { + const calls = []; + return { + calls, + run: () => cleanup({ + runId: "42", branch: "personal-build/42-1", token: "test-token", + sleep: async () => {}, log: () => {}, + fetchImpl: async (url, options) => { + calls.push({ url, method: options.method }); + assert.equal(options.redirect, "error"); + const response = responses.shift(); + if (response instanceof Error) throw response; + assert.ok(response, "Unexpected API request"); + return response; + }, + ...overrides, + }), + }; +} + +test("empty DELETE bodies succeed and only run-owned objects are deleted", async () => { + const f = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 204 }), new Response(null, { status: 204 })]); + await f.run(); + assert.deepEqual(f.calls.map(({ method, url }) => [method, url.split("/t3code/")[1]]), [ + ["GET", "actions/runs/42/artifacts?per_page=100&page=1"], + ["DELETE", "git/refs/heads/personal-build/42-1"], + ["DELETE", "actions/artifacts/7"], + ]); +}); + +test("already removed ref and artifact make retries idempotent", async () => { + const f = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 404 }), new Response(null, { status: 404 })]); + await f.run(); +}); + +test("truncated listing retries before any destructive request", async () => { + const f = fixture([new Response("{"), json({ artifacts: [] }), new Response(null, { status: 204 })]); + await f.run(); + assert.deepEqual(f.calls.map((c) => c.method), ["GET", "GET", "DELETE"]); +}); + +test("persistent malformed JSON fails visibly without deleting", async () => { + const f = fixture([new Response(""), new Response("{"), new Response("invalid")]); + await assert.rejects(f.run(), /invalid JSON after 3 attempts/); + assert.ok(f.calls.every((c) => c.method === "GET")); +}); + +test("all pages are read before deletion shifts the listing", async () => { + const first = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); + const f = fixture([json({ artifacts: first }), json({ artifacts: [{ id: 101 }] }), ...Array.from({ length: 102 }, () => new Response(null, { status: 204 }))]); + await f.run(); + assert.equal(f.calls.length, 104); + assert.match(f.calls[1].url, /page=2$/); + assert.ok(f.calls.slice(2).every((c) => c.method === "DELETE")); +}); + +for (const status of [401, 403, 404, 429]) { + test(`listing HTTP ${status} is not treated as an empty run`, async () => { + const f = fixture([new Response(null, { status })]); + await assert.rejects(f.run(), new RegExp(`HTTP ${status}`)); + assert.equal(f.calls.length, 1); + }); +} + +test("transient server and uncertain DELETE transport failures are bounded", async () => { + const f = fixture([new Response(null, { status: 502 }), json({ artifacts: [] }), new Error("connection lost"), new Response(null, { status: 404 })]); + await f.run(); + assert.equal(f.calls.length, 4); +}); + +test("persistent DELETE error fails instead of masking cleanup failure", async () => { + const f = fixture([json({ artifacts: [] }), ...Array.from({ length: 3 }, () => new Response(null, { status: 503 }))]); + await assert.rejects(f.run(), /HTTP 503/); + assert.equal(f.calls.length, 4); +}); + +test("malformed artifact IDs fail before branch deletion", async () => { + for (const id of ["7", -1, 1.5, 0, Number.MAX_SAFE_INTEGER + 1]) { + const f = fixture([json({ artifacts: [{ id }] })]); + await assert.rejects(f.run(), /Invalid artifact ID/); + assert.equal(f.calls.length, 1); + } +}); + +test("missing artifact array is not an empty success", async () => { + const f = fixture([json({})]); + await assert.rejects(f.run(), /Invalid artifact listing/); + assert.equal(f.calls.length, 1); +}); + +test("reject unrelated branches, invalid run IDs and missing token before network", async () => { + for (const overrides of [{ branch: "main" }, { branch: "personal-build/43-1" }, { branch: "personal-build/42-1/other" }, { runId: "../42" }, { token: "" }]) { + const f = fixture([], overrides); + await assert.rejects(f.run()); + assert.equal(f.calls.length, 0); + } +}); + +test("no-build run has no branch and no artifacts to remove", async () => { + const f = fixture([json({ artifacts: [] })], { branch: "" }); + await f.run(); + assert.equal(f.calls.length, 1); +}); From 10e3660d4a159d4dd7bc6663da4638319518bd60 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:02:29 +0100 Subject: [PATCH 2/9] fix(ci): use the fork's gitlink-safe checkout for cleanup tests --- .github/workflows/personal-cleanup-check.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/personal-cleanup-check.yml b/.github/workflows/personal-cleanup-check.yml index 11f0e620d6f3f..30463bb245618 100644 --- a/.github/workflows/personal-cleanup-check.yml +++ b/.github/workflows/personal-cleanup-check.yml @@ -23,10 +23,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - sparse-checkout: scripts + # Match the automation checkout: vendored gitlinks lack .gitmodules URLs. + - name: Checkout cleanup tests + env: + SOURCE: ${{ github.sha }} + run: | + git init . + git remote add origin https://github.com/Microck/t3code.git + git fetch --no-tags --depth=1 origin "$SOURCE" + git sparse-checkout set --no-cone '/scripts/personal-release-cleanup.mjs' '/scripts/personal-release-cleanup.test.mjs' + git -c checkout.workers=0 checkout --detach FETCH_HEAD - uses: actions/setup-node@v6 with: node-version: "24" From fa4f84b05429aca23ffcc98d86f5c88ec555810a Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:04:22 +0100 Subject: [PATCH 3/9] fix(release): recognize the exact missing-reference response --- scripts/personal-release-cleanup.mjs | 4 ++++ scripts/personal-release-cleanup.test.mjs | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs index 504b6cbc3ba26..0b5d9ae4f4b6a 100644 --- a/scripts/personal-release-cleanup.mjs +++ b/scripts/personal-release-cleanup.mjs @@ -27,6 +27,10 @@ export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = continue; } if (method === "DELETE" && (response.ok || response.status === 404)) return; + if (method === "DELETE" && path.startsWith("git/refs/heads/") && response.status === 422) { + const error = await response.json().catch(() => null); + if (error?.message === "Reference does not exist") return; + } if (!response.ok) { if (response.status >= 500 && attempt < 2) { await sleep(1000 * (attempt + 1)); diff --git a/scripts/personal-release-cleanup.test.mjs b/scripts/personal-release-cleanup.test.mjs index c304c2a3fde03..0d0f549229874 100644 --- a/scripts/personal-release-cleanup.test.mjs +++ b/scripts/personal-release-cleanup.test.mjs @@ -38,6 +38,18 @@ test("already removed ref and artifact make retries idempotent", async () => { await f.run(); }); +test("GitHub's missing-reference 422 succeeds, but unrelated 422 errors fail", async () => { + const missing = new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 }); + const f = fixture([json({ artifacts: [{ id: 7 }] }), missing, new Response(null, { status: 204 })]); + await f.run(); + for (const body of [JSON.stringify({ message: "Reference update failed" }), "invalid JSON"]) { + const failed = fixture([json({ artifacts: [] }), new Response(body, { status: 422 })]); + await assert.rejects(failed.run(), /HTTP 422/); + } + const artifact = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 204 }), new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 })]); + await assert.rejects(artifact.run(), /HTTP 422/); +}); + test("truncated listing retries before any destructive request", async () => { const f = fixture([new Response("{"), json({ artifacts: [] }), new Response(null, { status: 204 })]); await f.run(); From 98f340cfca44665fc3e4e013a9c9a224863750ec Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:13:11 +0100 Subject: [PATCH 4/9] style(release): format cleanup helper and workflow --- .github/workflows/personal-release.yml | 1 - scripts/personal-release-cleanup.mjs | 24 ++++- scripts/personal-release-cleanup.test.mjs | 108 ++++++++++++++++------ 3 files changed, 99 insertions(+), 34 deletions(-) diff --git a/.github/workflows/personal-release.yml b/.github/workflows/personal-release.yml index 2cb0664189fc7..a33e08154925e 100644 --- a/.github/workflows/personal-release.yml +++ b/.github/workflows/personal-release.yml @@ -279,4 +279,3 @@ jobs: fi fi node scripts/personal-release-cleanup.mjs - diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs index 0b5d9ae4f4b6a..9d57d0adfba08 100644 --- a/scripts/personal-release-cleanup.mjs +++ b/scripts/personal-release-cleanup.mjs @@ -4,7 +4,14 @@ const repository = "Microck/t3code"; // Cleanup is repeatable: an absent generated ref/artifact already meets the goal. // Never decode successful DELETE responses, which normally have no JSON body. -export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), log = console.log }) { +export async function cleanup({ + runId, + branch, + token, + fetchImpl = fetch, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + log = console.log, +}) { if (!/^\d+$/.test(runId)) throw new Error("Invalid workflow run ID"); if (branch && !new RegExp(`^personal-build/${runId}-[1-9]\\d*$`).test(branch)) { throw new Error("Refusing to clean a branch outside this workflow run"); @@ -17,7 +24,11 @@ export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = try { response = await fetchImpl(`https://api.github.com/repos/${repository}/${path}`, { method, - headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, signal: AbortSignal.timeout(15_000), redirect: "error", }); @@ -53,7 +64,8 @@ export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = const data = await request(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`); if (!Array.isArray(data.artifacts)) throw new Error("Invalid artifact listing"); for (const artifact of data.artifacts) { - if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0) throw new Error("Invalid artifact ID"); + if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0) + throw new Error("Invalid artifact ID"); artifacts.add(artifact.id); } if (data.artifacts.length < 100) break; @@ -69,5 +81,9 @@ export async function cleanup({ runId, branch, token, fetchImpl = fetch, sleep = } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await cleanup({ runId: process.env.RUN_ID ?? "", branch: process.env.BRANCH ?? "", token: process.env.GH_TOKEN }); + await cleanup({ + runId: process.env.RUN_ID ?? "", + branch: process.env.BRANCH ?? "", + token: process.env.GH_TOKEN, + }); } diff --git a/scripts/personal-release-cleanup.test.mjs b/scripts/personal-release-cleanup.test.mjs index 0d0f549229874..1c5bcf1217ad8 100644 --- a/scripts/personal-release-cleanup.test.mjs +++ b/scripts/personal-release-cleanup.test.mjs @@ -7,53 +7,85 @@ function fixture(responses, overrides = {}) { const calls = []; return { calls, - run: () => cleanup({ - runId: "42", branch: "personal-build/42-1", token: "test-token", - sleep: async () => {}, log: () => {}, - fetchImpl: async (url, options) => { - calls.push({ url, method: options.method }); - assert.equal(options.redirect, "error"); - const response = responses.shift(); - if (response instanceof Error) throw response; - assert.ok(response, "Unexpected API request"); - return response; - }, - ...overrides, - }), + run: () => + cleanup({ + runId: "42", + branch: "personal-build/42-1", + token: "test-token", + sleep: async () => {}, + log: () => {}, + fetchImpl: async (url, options) => { + calls.push({ url, method: options.method }); + assert.equal(options.redirect, "error"); + const response = responses.shift(); + if (response instanceof Error) throw response; + assert.ok(response, "Unexpected API request"); + return response; + }, + ...overrides, + }), }; } test("empty DELETE bodies succeed and only run-owned objects are deleted", async () => { - const f = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 204 }), new Response(null, { status: 204 })]); - await f.run(); - assert.deepEqual(f.calls.map(({ method, url }) => [method, url.split("/t3code/")[1]]), [ - ["GET", "actions/runs/42/artifacts?per_page=100&page=1"], - ["DELETE", "git/refs/heads/personal-build/42-1"], - ["DELETE", "actions/artifacts/7"], + const f = fixture([ + json({ artifacts: [{ id: 7 }] }), + new Response(null, { status: 204 }), + new Response(null, { status: 204 }), ]); + await f.run(); + assert.deepEqual( + f.calls.map(({ method, url }) => [method, url.split("/t3code/")[1]]), + [ + ["GET", "actions/runs/42/artifacts?per_page=100&page=1"], + ["DELETE", "git/refs/heads/personal-build/42-1"], + ["DELETE", "actions/artifacts/7"], + ], + ); }); test("already removed ref and artifact make retries idempotent", async () => { - const f = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 404 }), new Response(null, { status: 404 })]); + const f = fixture([ + json({ artifacts: [{ id: 7 }] }), + new Response(null, { status: 404 }), + new Response(null, { status: 404 }), + ]); await f.run(); }); test("GitHub's missing-reference 422 succeeds, but unrelated 422 errors fail", async () => { - const missing = new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 }); - const f = fixture([json({ artifacts: [{ id: 7 }] }), missing, new Response(null, { status: 204 })]); + const missing = new Response(JSON.stringify({ message: "Reference does not exist" }), { + status: 422, + }); + const f = fixture([ + json({ artifacts: [{ id: 7 }] }), + missing, + new Response(null, { status: 204 }), + ]); await f.run(); for (const body of [JSON.stringify({ message: "Reference update failed" }), "invalid JSON"]) { const failed = fixture([json({ artifacts: [] }), new Response(body, { status: 422 })]); await assert.rejects(failed.run(), /HTTP 422/); } - const artifact = fixture([json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 204 }), new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 })]); + const artifact = fixture([ + json({ artifacts: [{ id: 7 }] }), + new Response(null, { status: 204 }), + new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 }), + ]); await assert.rejects(artifact.run(), /HTTP 422/); }); test("truncated listing retries before any destructive request", async () => { - const f = fixture([new Response("{"), json({ artifacts: [] }), new Response(null, { status: 204 })]); + const f = fixture([ + new Response("{"), + json({ artifacts: [] }), + new Response(null, { status: 204 }), + ]); await f.run(); - assert.deepEqual(f.calls.map((c) => c.method), ["GET", "GET", "DELETE"]); + assert.deepEqual( + f.calls.map((c) => c.method), + ["GET", "GET", "DELETE"], + ); }); test("persistent malformed JSON fails visibly without deleting", async () => { @@ -64,7 +96,11 @@ test("persistent malformed JSON fails visibly without deleting", async () => { test("all pages are read before deletion shifts the listing", async () => { const first = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); - const f = fixture([json({ artifacts: first }), json({ artifacts: [{ id: 101 }] }), ...Array.from({ length: 102 }, () => new Response(null, { status: 204 }))]); + const f = fixture([ + json({ artifacts: first }), + json({ artifacts: [{ id: 101 }] }), + ...Array.from({ length: 102 }, () => new Response(null, { status: 204 })), + ]); await f.run(); assert.equal(f.calls.length, 104); assert.match(f.calls[1].url, /page=2$/); @@ -80,13 +116,21 @@ for (const status of [401, 403, 404, 429]) { } test("transient server and uncertain DELETE transport failures are bounded", async () => { - const f = fixture([new Response(null, { status: 502 }), json({ artifacts: [] }), new Error("connection lost"), new Response(null, { status: 404 })]); + const f = fixture([ + new Response(null, { status: 502 }), + json({ artifacts: [] }), + new Error("connection lost"), + new Response(null, { status: 404 }), + ]); await f.run(); assert.equal(f.calls.length, 4); }); test("persistent DELETE error fails instead of masking cleanup failure", async () => { - const f = fixture([json({ artifacts: [] }), ...Array.from({ length: 3 }, () => new Response(null, { status: 503 }))]); + const f = fixture([ + json({ artifacts: [] }), + ...Array.from({ length: 3 }, () => new Response(null, { status: 503 })), + ]); await assert.rejects(f.run(), /HTTP 503/); assert.equal(f.calls.length, 4); }); @@ -106,7 +150,13 @@ test("missing artifact array is not an empty success", async () => { }); test("reject unrelated branches, invalid run IDs and missing token before network", async () => { - for (const overrides of [{ branch: "main" }, { branch: "personal-build/43-1" }, { branch: "personal-build/42-1/other" }, { runId: "../42" }, { token: "" }]) { + for (const overrides of [ + { branch: "main" }, + { branch: "personal-build/43-1" }, + { branch: "personal-build/42-1/other" }, + { runId: "../42" }, + { token: "" }, + ]) { const f = fixture([], overrides); await assert.rejects(f.run()); assert.equal(f.calls.length, 0); From b2338e55ce1fe7c83e35f5eeaf17918235efde25 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:14:25 +0100 Subject: [PATCH 5/9] test(release): keep Node cleanup tests on their dedicated runner --- .github/workflows/personal-cleanup-check.yml | 8 ++++---- ...up.test.mjs => personal-release-cleanup.node-test.mjs} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename scripts/{personal-release-cleanup.test.mjs => personal-release-cleanup.node-test.mjs} (100%) diff --git a/.github/workflows/personal-cleanup-check.yml b/.github/workflows/personal-cleanup-check.yml index 30463bb245618..f6f6fea8b8e29 100644 --- a/.github/workflows/personal-cleanup-check.yml +++ b/.github/workflows/personal-cleanup-check.yml @@ -4,14 +4,14 @@ on: pull_request: paths: - scripts/personal-release-cleanup.mjs - - scripts/personal-release-cleanup.test.mjs + - scripts/personal-release-cleanup.node-test.mjs - .github/workflows/personal-release.yml - .github/workflows/personal-cleanup-check.yml push: branches: [personal-updates] paths: - scripts/personal-release-cleanup.mjs - - scripts/personal-release-cleanup.test.mjs + - scripts/personal-release-cleanup.node-test.mjs - .github/workflows/personal-release.yml - .github/workflows/personal-cleanup-check.yml @@ -31,10 +31,10 @@ jobs: git init . git remote add origin https://github.com/Microck/t3code.git git fetch --no-tags --depth=1 origin "$SOURCE" - git sparse-checkout set --no-cone '/scripts/personal-release-cleanup.mjs' '/scripts/personal-release-cleanup.test.mjs' + git sparse-checkout set --no-cone '/scripts/personal-release-cleanup.mjs' '/scripts/personal-release-cleanup.node-test.mjs' git -c checkout.workers=0 checkout --detach FETCH_HEAD - uses: actions/setup-node@v6 with: node-version: "24" - run: node --check scripts/personal-release-cleanup.mjs - - run: node --test scripts/personal-release-cleanup.test.mjs + - run: node --test scripts/personal-release-cleanup.node-test.mjs diff --git a/scripts/personal-release-cleanup.test.mjs b/scripts/personal-release-cleanup.node-test.mjs similarity index 100% rename from scripts/personal-release-cleanup.test.mjs rename to scripts/personal-release-cleanup.node-test.mjs From 94d8fe1926d4e05f699e34ec9d0056ebe0fce69d Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:17:13 +0100 Subject: [PATCH 6/9] test(usage): pin the saved-history retention clock --- apps/server/src/usage/UsageService.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 75de5ec25a4ec..657409571f011 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -2214,6 +2214,8 @@ describe("UsageService", () => { it.live("reports saved usage of a removed directory only for windows it reaches", () => Effect.gen(function* () { + // Keep the August fixture inside the 92-day retention window on every run. + yield* TestClock.setTime(Date.parse("2026-08-02T12:00:00Z")); const { transcript, settings, home } = yield* setup; yield* Effect.promise(async () => { await NodeFSP.writeFile(transcript, claudeLine(1, 5)); @@ -2224,6 +2226,7 @@ describe("UsageService", () => { Effect.provide(layerService({ prefix: "usage-service-saved-window-test", home, settings })), ); const first = yield* service.readSummary(WINDOW); + assert.strictEqual(first.buckets.length, 1); yield* Effect.promise(() => NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), ); @@ -2240,7 +2243,12 @@ describe("UsageService", () => { assert.deepStrictEqual(later.buckets, []); assert.strictEqual(later.sources[0]?.status, "missing"); assert.strictEqual(later.sources[0]?.scannedFiles, 0); - }).pipe(Effect.scoped), + + yield* TestClock.adjust(Duration.days(93)); + const expired = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(expired.buckets, []); + assert.strictEqual(expired.sources[0]?.status, "missing"); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); it.live("does not share an in-flight scan after custom prices change", () => From 153c7f774b4e4cb4eebb3414d6573ea6d0ed2023 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:18:44 +0100 Subject: [PATCH 7/9] fix(release): retain failure reporting when cleanup setup fails --- .github/workflows/personal-release.yml | 1 + scripts/personal-release-cleanup.node-test.mjs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/personal-release.yml b/.github/workflows/personal-release.yml index a33e08154925e..d924fdad9569c 100644 --- a/.github/workflows/personal-release.yml +++ b/.github/workflows/personal-release.yml @@ -262,6 +262,7 @@ jobs: with: node-version: "24" - name: Report failures and remove the generated build branch + if: always() env: GH_TOKEN: ${{ github.token }} RESULTS: ${{ toJSON(needs) }} diff --git a/scripts/personal-release-cleanup.node-test.mjs b/scripts/personal-release-cleanup.node-test.mjs index 1c5bcf1217ad8..28a220047675f 100644 --- a/scripts/personal-release-cleanup.node-test.mjs +++ b/scripts/personal-release-cleanup.node-test.mjs @@ -105,6 +105,10 @@ test("all pages are read before deletion shifts the listing", async () => { assert.equal(f.calls.length, 104); assert.match(f.calls[1].url, /page=2$/); assert.ok(f.calls.slice(2).every((c) => c.method === "DELETE")); + assert.deepEqual( + f.calls.slice(3).map(({ url }) => url.split("/t3code/")[1]), + Array.from({ length: 101 }, (_, i) => `actions/artifacts/${i + 1}`), + ); }); for (const status of [401, 403, 404, 429]) { From 814ea680895c4b7dd81352560b6e6937fb5eaf99 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 19:24:30 +0100 Subject: [PATCH 8/9] style(release): follow the Node namespace import contract --- scripts/personal-release-cleanup.mjs | 4 +- .../personal-release-cleanup.node-test.mjs | 140 +++++++++--------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs index 9d57d0adfba08..0122be351d6e4 100644 --- a/scripts/personal-release-cleanup.mjs +++ b/scripts/personal-release-cleanup.mjs @@ -1,4 +1,4 @@ -import { pathToFileURL } from "node:url"; +import * as NodeURL from "node:url"; const repository = "Microck/t3code"; @@ -80,7 +80,7 @@ export async function cleanup({ } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) { await cleanup({ runId: process.env.RUN_ID ?? "", branch: process.env.BRANCH ?? "", diff --git a/scripts/personal-release-cleanup.node-test.mjs b/scripts/personal-release-cleanup.node-test.mjs index 28a220047675f..a8f9e3e375e46 100644 --- a/scripts/personal-release-cleanup.node-test.mjs +++ b/scripts/personal-release-cleanup.node-test.mjs @@ -1,5 +1,5 @@ -import assert from "node:assert/strict"; -import test from "node:test"; +import * as NodeAssert from "node:assert/strict"; +import * as NodeTest from "node:test"; import { cleanup } from "./personal-release-cleanup.mjs"; const json = (data) => new Response(JSON.stringify(data)); @@ -16,10 +16,10 @@ function fixture(responses, overrides = {}) { log: () => {}, fetchImpl: async (url, options) => { calls.push({ url, method: options.method }); - assert.equal(options.redirect, "error"); + NodeAssert.equal(options.redirect, "error"); const response = responses.shift(); if (response instanceof Error) throw response; - assert.ok(response, "Unexpected API request"); + NodeAssert.ok(response, "Unexpected API request"); return response; }, ...overrides, @@ -27,14 +27,14 @@ function fixture(responses, overrides = {}) { }; } -test("empty DELETE bodies succeed and only run-owned objects are deleted", async () => { +NodeTest.test("empty DELETE bodies succeed and only run-owned objects are deleted", async () => { const f = fixture([ json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 204 }), new Response(null, { status: 204 }), ]); await f.run(); - assert.deepEqual( + NodeAssert.deepEqual( f.calls.map(({ method, url }) => [method, url.split("/t3code/")[1]]), [ ["GET", "actions/runs/42/artifacts?per_page=100&page=1"], @@ -44,7 +44,7 @@ test("empty DELETE bodies succeed and only run-owned objects are deleted", async ); }); -test("already removed ref and artifact make retries idempotent", async () => { +NodeTest.test("already removed ref and artifact make retries idempotent", async () => { const f = fixture([ json({ artifacts: [{ id: 7 }] }), new Response(null, { status: 404 }), @@ -53,48 +53,51 @@ test("already removed ref and artifact make retries idempotent", async () => { await f.run(); }); -test("GitHub's missing-reference 422 succeeds, but unrelated 422 errors fail", async () => { - const missing = new Response(JSON.stringify({ message: "Reference does not exist" }), { - status: 422, - }); - const f = fixture([ - json({ artifacts: [{ id: 7 }] }), - missing, - new Response(null, { status: 204 }), - ]); - await f.run(); - for (const body of [JSON.stringify({ message: "Reference update failed" }), "invalid JSON"]) { - const failed = fixture([json({ artifacts: [] }), new Response(body, { status: 422 })]); - await assert.rejects(failed.run(), /HTTP 422/); - } - const artifact = fixture([ - json({ artifacts: [{ id: 7 }] }), - new Response(null, { status: 204 }), - new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 }), - ]); - await assert.rejects(artifact.run(), /HTTP 422/); -}); +NodeTest.test( + "GitHub's missing-reference 422 succeeds, but unrelated 422 errors fail", + async () => { + const missing = new Response(JSON.stringify({ message: "Reference does not exist" }), { + status: 422, + }); + const f = fixture([ + json({ artifacts: [{ id: 7 }] }), + missing, + new Response(null, { status: 204 }), + ]); + await f.run(); + for (const body of [JSON.stringify({ message: "Reference update failed" }), "invalid JSON"]) { + const failed = fixture([json({ artifacts: [] }), new Response(body, { status: 422 })]); + await NodeAssert.rejects(failed.run(), /HTTP 422/); + } + const artifact = fixture([ + json({ artifacts: [{ id: 7 }] }), + new Response(null, { status: 204 }), + new Response(JSON.stringify({ message: "Reference does not exist" }), { status: 422 }), + ]); + await NodeAssert.rejects(artifact.run(), /HTTP 422/); + }, +); -test("truncated listing retries before any destructive request", async () => { +NodeTest.test("truncated listing retries before any destructive request", async () => { const f = fixture([ new Response("{"), json({ artifacts: [] }), new Response(null, { status: 204 }), ]); await f.run(); - assert.deepEqual( + NodeAssert.deepEqual( f.calls.map((c) => c.method), ["GET", "GET", "DELETE"], ); }); -test("persistent malformed JSON fails visibly without deleting", async () => { +NodeTest.test("persistent malformed JSON fails visibly without deleting", async () => { const f = fixture([new Response(""), new Response("{"), new Response("invalid")]); - await assert.rejects(f.run(), /invalid JSON after 3 attempts/); - assert.ok(f.calls.every((c) => c.method === "GET")); + await NodeAssert.rejects(f.run(), /invalid JSON after 3 attempts/); + NodeAssert.ok(f.calls.every((c) => c.method === "GET")); }); -test("all pages are read before deletion shifts the listing", async () => { +NodeTest.test("all pages are read before deletion shifts the listing", async () => { const first = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); const f = fixture([ json({ artifacts: first }), @@ -102,24 +105,24 @@ test("all pages are read before deletion shifts the listing", async () => { ...Array.from({ length: 102 }, () => new Response(null, { status: 204 })), ]); await f.run(); - assert.equal(f.calls.length, 104); - assert.match(f.calls[1].url, /page=2$/); - assert.ok(f.calls.slice(2).every((c) => c.method === "DELETE")); - assert.deepEqual( + NodeAssert.equal(f.calls.length, 104); + NodeAssert.match(f.calls[1].url, /page=2$/); + NodeAssert.ok(f.calls.slice(2).every((c) => c.method === "DELETE")); + NodeAssert.deepEqual( f.calls.slice(3).map(({ url }) => url.split("/t3code/")[1]), Array.from({ length: 101 }, (_, i) => `actions/artifacts/${i + 1}`), ); }); for (const status of [401, 403, 404, 429]) { - test(`listing HTTP ${status} is not treated as an empty run`, async () => { + NodeTest.test(`listing HTTP ${status} is not treated as an empty run`, async () => { const f = fixture([new Response(null, { status })]); - await assert.rejects(f.run(), new RegExp(`HTTP ${status}`)); - assert.equal(f.calls.length, 1); + await NodeAssert.rejects(f.run(), new RegExp(`HTTP ${status}`)); + NodeAssert.equal(f.calls.length, 1); }); } -test("transient server and uncertain DELETE transport failures are bounded", async () => { +NodeTest.test("transient server and uncertain DELETE transport failures are bounded", async () => { const f = fixture([ new Response(null, { status: 502 }), json({ artifacts: [] }), @@ -127,48 +130,51 @@ test("transient server and uncertain DELETE transport failures are bounded", asy new Response(null, { status: 404 }), ]); await f.run(); - assert.equal(f.calls.length, 4); + NodeAssert.equal(f.calls.length, 4); }); -test("persistent DELETE error fails instead of masking cleanup failure", async () => { +NodeTest.test("persistent DELETE error fails instead of masking cleanup failure", async () => { const f = fixture([ json({ artifacts: [] }), ...Array.from({ length: 3 }, () => new Response(null, { status: 503 })), ]); - await assert.rejects(f.run(), /HTTP 503/); - assert.equal(f.calls.length, 4); + await NodeAssert.rejects(f.run(), /HTTP 503/); + NodeAssert.equal(f.calls.length, 4); }); -test("malformed artifact IDs fail before branch deletion", async () => { +NodeTest.test("malformed artifact IDs fail before branch deletion", async () => { for (const id of ["7", -1, 1.5, 0, Number.MAX_SAFE_INTEGER + 1]) { const f = fixture([json({ artifacts: [{ id }] })]); - await assert.rejects(f.run(), /Invalid artifact ID/); - assert.equal(f.calls.length, 1); + await NodeAssert.rejects(f.run(), /Invalid artifact ID/); + NodeAssert.equal(f.calls.length, 1); } }); -test("missing artifact array is not an empty success", async () => { +NodeTest.test("missing artifact array is not an empty success", async () => { const f = fixture([json({})]); - await assert.rejects(f.run(), /Invalid artifact listing/); - assert.equal(f.calls.length, 1); + await NodeAssert.rejects(f.run(), /Invalid artifact listing/); + NodeAssert.equal(f.calls.length, 1); }); -test("reject unrelated branches, invalid run IDs and missing token before network", async () => { - for (const overrides of [ - { branch: "main" }, - { branch: "personal-build/43-1" }, - { branch: "personal-build/42-1/other" }, - { runId: "../42" }, - { token: "" }, - ]) { - const f = fixture([], overrides); - await assert.rejects(f.run()); - assert.equal(f.calls.length, 0); - } -}); +NodeTest.test( + "reject unrelated branches, invalid run IDs and missing token before network", + async () => { + for (const overrides of [ + { branch: "main" }, + { branch: "personal-build/43-1" }, + { branch: "personal-build/42-1/other" }, + { runId: "../42" }, + { token: "" }, + ]) { + const f = fixture([], overrides); + await NodeAssert.rejects(f.run()); + NodeAssert.equal(f.calls.length, 0); + } + }, +); -test("no-build run has no branch and no artifacts to remove", async () => { +NodeTest.test("no-build run has no branch and no artifacts to remove", async () => { const f = fixture([json({ artifacts: [] })], { branch: "" }); await f.run(); - assert.equal(f.calls.length, 1); + NodeAssert.equal(f.calls.length, 1); }); From 6e052e7366b267c18f6cf921af6dde5441369c97 Mon Sep 17 00:00:00 2001 From: Microck Date: Mon, 2 Nov 2026 21:19:53 +0100 Subject: [PATCH 9/9] fix(release): retry malformed artifact listing shapes --- scripts/personal-release-cleanup.mjs | 8 +++++--- scripts/personal-release-cleanup.node-test.mjs | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs index 0122be351d6e4..f7b995c9daf56 100644 --- a/scripts/personal-release-cleanup.mjs +++ b/scripts/personal-release-cleanup.mjs @@ -50,9 +50,12 @@ export async function cleanup({ throw new Error(`${method} ${path}: HTTP ${response.status}`); } try { - return await response.json(); + const data = await response.json(); + if (!Array.isArray(data?.artifacts)) throw new Error("Invalid artifact listing"); + return data; } catch { - if (attempt === 2) throw new Error(`GET ${path}: invalid JSON after 3 attempts`); + if (attempt === 2) + throw new Error(`GET ${path}: invalid JSON or artifact listing after 3 attempts`); await sleep(1000 * (attempt + 1)); } } @@ -62,7 +65,6 @@ export async function cleanup({ const artifacts = new Set(); for (let page = 1; ; page++) { const data = await request(`actions/runs/${runId}/artifacts?per_page=100&page=${page}`); - if (!Array.isArray(data.artifacts)) throw new Error("Invalid artifact listing"); for (const artifact of data.artifacts) { if (!Number.isSafeInteger(artifact.id) || artifact.id <= 0) throw new Error("Invalid artifact ID"); diff --git a/scripts/personal-release-cleanup.node-test.mjs b/scripts/personal-release-cleanup.node-test.mjs index a8f9e3e375e46..a5c924e10ea65 100644 --- a/scripts/personal-release-cleanup.node-test.mjs +++ b/scripts/personal-release-cleanup.node-test.mjs @@ -93,7 +93,7 @@ NodeTest.test("truncated listing retries before any destructive request", async NodeTest.test("persistent malformed JSON fails visibly without deleting", async () => { const f = fixture([new Response(""), new Response("{"), new Response("invalid")]); - await NodeAssert.rejects(f.run(), /invalid JSON after 3 attempts/); + await NodeAssert.rejects(f.run(), /invalid JSON or artifact listing after 3 attempts/); NodeAssert.ok(f.calls.every((c) => c.method === "GET")); }); @@ -151,9 +151,10 @@ NodeTest.test("malformed artifact IDs fail before branch deletion", async () => }); NodeTest.test("missing artifact array is not an empty success", async () => { - const f = fixture([json({})]); - await NodeAssert.rejects(f.run(), /Invalid artifact listing/); - NodeAssert.equal(f.calls.length, 1); + const f = fixture([json({}), json(null), json({ artifacts: {} })]); + await NodeAssert.rejects(f.run(), /invalid JSON or artifact listing after 3 attempts/); + NodeAssert.equal(f.calls.length, 3); + NodeAssert.ok(f.calls.every((c) => c.method === "GET")); }); NodeTest.test( @@ -178,3 +179,12 @@ NodeTest.test("no-build run has no branch and no artifacts to remove", async () await f.run(); NodeAssert.equal(f.calls.length, 1); }); + +NodeTest.test("malformed listing shape retries before deletion", async () => { + const f = fixture([json(null), json({ artifacts: [] }), new Response(null, { status: 204 })]); + await f.run(); + NodeAssert.deepEqual( + f.calls.map((c) => c.method), + ["GET", "GET", "DELETE"], + ); +});