Repository navigation
fix(release): make personal build cleanup retry-safe #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
995c24f
fix(release): make personal build cleanup retry-safe
Microck 10e3660
fix(ci): use the fork's gitlink-safe checkout for cleanup tests
Microck fa4f84b
fix(release): recognize the exact missing-reference response
Microck 98f340c
style(release): format cleanup helper and workflow
Microck b2338e5
test(release): keep Node cleanup tests on their dedicated runner
Microck 94d8fe1
test(usage): pin the saved-history retention clock
Microck 153c7f7
fix(release): retain failure reporting when cleanup setup fails
Microck 814ea68
style(release): follow the Node namespace import contract
Microck 9775795
Merge the isolated retention fixture repair for combined validation
Microck 6e052e7
fix(release): retry malformed artifact listing shapes
Microck File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
|
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, | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"], | ||
| ); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.