diff --git a/.github/workflows/personal-cleanup-check.yml b/.github/workflows/personal-cleanup-check.yml new file mode 100644 index 0000000000000..f6f6fea8b8e29 --- /dev/null +++ b/.github/workflows/personal-cleanup-check.yml @@ -0,0 +1,40 @@ +name: Personal release cleanup checks + +on: + pull_request: + paths: + - scripts/personal-release-cleanup.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.node-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: + # 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.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.node-test.mjs diff --git a/.github/workflows/personal-release.yml b/.github/workflows/personal-release.yml index 6d29d3212d497..d924fdad9569c 100644 --- a/.github/workflows/personal-release.yml +++ b/.github/workflows/personal-release.yml @@ -249,7 +249,20 @@ 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 + if: always() env: GH_TOKEN: ${{ github.token }} RESULTS: ${{ toJSON(needs) }} @@ -266,11 +279,4 @@ 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/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", () => diff --git a/scripts/personal-release-cleanup.mjs b/scripts/personal-release-cleanup.mjs new file mode 100644 index 0000000000000..f7b995c9daf56 --- /dev/null +++ b/scripts/personal-release-cleanup.mjs @@ -0,0 +1,91 @@ +import * as NodeURL 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 (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)); + continue; + } + throw new Error(`${method} ${path}: HTTP ${response.status}`); + } + try { + 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 or artifact listing 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}`); + 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 === NodeURL.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.node-test.mjs b/scripts/personal-release-cleanup.node-test.mjs new file mode 100644 index 0000000000000..a5c924e10ea65 --- /dev/null +++ b/scripts/personal-release-cleanup.node-test.mjs @@ -0,0 +1,190 @@ +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)); +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 }); + NodeAssert.equal(options.redirect, "error"); + const response = responses.shift(); + if (response instanceof Error) throw response; + NodeAssert.ok(response, "Unexpected API request"); + return response; + }, + ...overrides, + }), + }; +} + +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(); + NodeAssert.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"], + ], + ); +}); + +NodeTest.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(); +}); + +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/); + }, +); + +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(); + NodeAssert.deepEqual( + f.calls.map((c) => c.method), + ["GET", "GET", "DELETE"], + ); +}); + +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 or artifact listing after 3 attempts/); + NodeAssert.ok(f.calls.every((c) => c.method === "GET")); +}); + +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 }), + json({ artifacts: [{ id: 101 }] }), + ...Array.from({ length: 102 }, () => new Response(null, { status: 204 })), + ]); + await f.run(); + 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]) { + NodeTest.test(`listing HTTP ${status} is not treated as an empty run`, async () => { + const f = fixture([new Response(null, { status })]); + await NodeAssert.rejects(f.run(), new RegExp(`HTTP ${status}`)); + NodeAssert.equal(f.calls.length, 1); + }); +} + +NodeTest.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(); + NodeAssert.equal(f.calls.length, 4); +}); + +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 NodeAssert.rejects(f.run(), /HTTP 503/); + NodeAssert.equal(f.calls.length, 4); +}); + +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 NodeAssert.rejects(f.run(), /Invalid artifact ID/); + NodeAssert.equal(f.calls.length, 1); + } +}); + +NodeTest.test("missing artifact array is not an empty success", async () => { + 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( + "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); + } + }, +); + +NodeTest.test("no-build run has no branch and no artifacts to remove", async () => { + const f = fixture([json({ artifacts: [] })], { branch: "" }); + 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"], + ); +});