Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/personal-cleanup-check.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 14 additions & 8 deletions .github/workflows/personal-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- name: Report failures and remove the generated build branch
if: always()
env:
GH_TOKEN: ${{ github.token }}
RESULTS: ${{ toJSON(needs) }}
Expand All @@ -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
10 changes: 9 additions & 1 deletion apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 }),
);
Expand All @@ -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", () =>
Expand Down
91 changes: 91 additions & 0 deletions scripts/personal-release-cleanup.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
Microck marked this conversation as resolved.
Resolved
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,
});
}
190 changes: 190 additions & 0 deletions scripts/personal-release-cleanup.node-test.mjs
Original file line number Diff line number Diff line change
@@ -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"],
);
});
Loading