diff --git a/scripts/cloud-release-admission.mjs b/scripts/cloud-release-admission.mjs index c7ed134ba4b..31ca67fa9db 100644 --- a/scripts/cloud-release-admission.mjs +++ b/scripts/cloud-release-admission.mjs @@ -1,5 +1,6 @@ import { appendFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; +import { createActionsReader } from "./github-actions-reader.mjs"; const repository = "paperclipai/paperclip"; const repositoryId = 1170821064; @@ -115,7 +116,6 @@ export function createReleaseRerunner(token, fetchImpl = fetch) { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { if (process.env.GITHUB_REPOSITORY !== repository || process.env.GITHUB_REF !== "refs/heads/master" || !process.env.GITHUB_TOKEN) throw new Error("Canonical master admission context required."); - const { createActionsReader } = await import("./cloud-source-verification.mjs"); const admitted = await releaseAdmitted(process.env.GITHUB_SHA, { api: createActionsReader({ token: process.env.GITHUB_TOKEN }), event: process.env.GITHUB_EVENT_NAME, attempt: Number(process.env.GITHUB_RUN_ATTEMPT), imageOnly: process.argv[2] === "image-only" }); console.log(admitted ? "Release candidate admitted." : "Full build deferred until a release consumer admits this source."); diff --git a/scripts/cloud-source-verification.mjs b/scripts/cloud-source-verification.mjs index 6a6f316da6f..09c4687fed1 100644 --- a/scripts/cloud-source-verification.mjs +++ b/scripts/cloud-source-verification.mjs @@ -1,5 +1,6 @@ import { appendFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; +import { createActionsReader } from "./github-actions-reader.mjs"; import { deferredProducer, requestDeferredProducer, sourceAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs"; const repository = "paperclipai/paperclip"; @@ -85,79 +86,7 @@ export async function readSourceVerification(sha, api, { failFast = false, onJob return undefined; } -// Statuses that say "ask again", not "the answer is no". A release must not be -// blocked because GitHub returned a gateway error during a 60-minute poll. -const TRANSIENT_READ_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); - -// A rate-limited read also says "ask again", and GitHub reports both primary -// and secondary rate limits as 403. Only the headers separate that from a -// token that may not read Actions, which must still fail at once. -function rateLimited(response) { - if (response.status !== 403) return false; - const header = (name) => response.headers?.get?.(name) ?? null; - return header("retry-after") !== null || header("x-ratelimit-remaining") === "0"; -} - -/** Milliseconds from a Retry-After header, when it carries a sane delay. */ -function retryAfterMs(response, capMs) { - const value = Number(response?.headers?.get?.("retry-after")); - if (!Number.isFinite(value) || value <= 0) return null; - return Math.min(value * 1_000, capMs); -} - -/** - * The GitHub Actions read used by the polling below, with transient transport - * failures retried: network errors, the statuses above, rate-limited 403s, and - * a body that fails while it is being read. Any other non-OK status throws on - * the first response, because waiting out a wrong token only delays the news. - * - * `deadlineAt` bounds retries by the caller's own polling deadline, so a read - * cannot extend the wait past the timeout it belongs to. - */ -export function createActionsReader({ - token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - attempts = 4, backoffMs = 1_000, log = console.log, - now = Date.now, deadlineAt = () => Infinity, maxRetryAfterMs = 60_000, -} = {}) { - return async (path) => { - for (let attempt = 1; ; attempt += 1) { - // Retry only while both the attempt budget and the caller's deadline - // leave room for the wait this attempt would cost. - const waitMs = backoffMs * attempt; - const retryable = attempt < attempts && now() + waitMs < deadlineAt(); - const pause = (response) => sleep(retryAfterMs(response, maxRetryAfterMs) ?? waitMs); - let response; - try { - response = await fetchImpl(`https://api.github.com${path}`, { - headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, - signal: AbortSignal.timeout(30_000), redirect: "error", - }); - } catch (cause) { - if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`); - log(`GitHub Actions read failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`); - await pause(); - continue; - } - if (response.ok) { - try { - // A 200 whose body is truncated or undecodable is a transport - // failure like any other, so it belongs inside the retry. - return await response.json(); - } catch (cause) { - if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`); - log(`GitHub Actions read body failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`); - await pause(); - continue; - } - } - if (!retryable || !(TRANSIENT_READ_STATUSES.has(response.status) || rateLimited(response))) { - throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`); - } - log(`GitHub Actions read failed (HTTP ${response.status}); retrying (${attempt}/${attempts - 1}).`); - await pause(response); - } - }; -} +export { createActionsReader }; export async function waitForSourceVerification(sha, { api, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), diff --git a/scripts/cloud-source-verification.test.mjs b/scripts/cloud-source-verification.test.mjs index 8221bba4bd3..6e055607267 100644 --- a/scripts/cloud-source-verification.test.mjs +++ b/scripts/cloud-source-verification.test.mjs @@ -1,5 +1,10 @@ import assert from "node:assert/strict"; import test, { describe } from "node:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createActionsReader, readSourceVerification, sourceVerificationJob, waitForSourceVerification, SourceVerificationDeferred } from "./cloud-source-verification.mjs"; import { releaseAdmitted, deferredProducer, requestDeferredProducer, sourceAdmissionJob, migratorAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs"; @@ -7,6 +12,57 @@ import { releaseAdmitted, deferredProducer, requestDeferredProducer, sourceAdmis import { cancelImageRun, runnerWaits, watchReleaseSource } from "./watch-release-source.mjs"; const sha = "a".repeat(40); + +function runAdmissionCli({ active = false, imageOnly = false, repositoryName = "paperclipai/paperclip" } = {}) { + const root = mkdtempSync(join(tmpdir(), "release-admission-cli-")); + try { + const preload = join(root, "github-fixture.mjs"); + const installFixture = (active) => { + const repository = { id: 1170821064, full_name: "paperclipai/paperclip" }; + const run = { id: 77, workflow_id: 21, path: ".github/workflows/docker.yml", head_sha: "a".repeat(40), + repository, head_repository: repository, head_branch: "master", event: "push", run_attempt: 1, status: "in_progress" }; + globalThis.fetch = async url => { + const parsed = new URL(url); + if (parsed.origin !== "https://api.github.com") throw new Error("Unexpected admission request origin"); + const path = parsed.pathname; + if (path.endsWith("/docker.yml")) return Response.json({ id: 21, path: ".github/workflows/docker.yml" }); + if (path.endsWith("/release.yml")) return Response.json({ id: 22, path: ".github/workflows/release.yml" }); + if (path.endsWith("/workflows/21/runs")) return Response.json({ total_count: active ? 1 : 0, workflow_runs: active ? [run] : [] }); + if (path.endsWith("/workflows/22/runs")) return Response.json({ total_count: 0, workflow_runs: [] }); + if (path.endsWith("/runs/77")) return Response.json(run); + throw new Error("Unexpected admission request path"); + }; + }; + writeFileSync(preload, "(" + installFixture.toString() + ")(" + JSON.stringify(active) + ");\n"); + const output = join(root, "output"); + const result = spawnSync(process.execPath, ["--import", preload, fileURLToPath(new URL("./cloud-release-admission.mjs", import.meta.url)), ...(imageOnly ? ["image-only"] : [])], { + encoding: "utf8", timeout: 5_000, + env: { ...process.env, GITHUB_REPOSITORY: repositoryName, GITHUB_REF: "refs/heads/master", GITHUB_SHA: "a".repeat(40), + GITHUB_EVENT_NAME: "push", GITHUB_RUN_ATTEMPT: "1", GITHUB_TOKEN: "fixture", GITHUB_OUTPUT: output }, + }); + return { ...result, output: result.status === 0 ? readFileSync(output, "utf8") : "" }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("admission CLI startup", () => { + for (const imageOnly of [false, true]) { + for (const active of [false, true]) { + test("executes " + (imageOnly ? "image-only" : "source") + " admission with active consumer=" + active, () => { + const result = runAdmissionCli({ active, imageOnly }); + assert.equal(result.status, 0, result.stderr || result.error?.message); + assert.equal(result.output, "admitted=" + active + "\n"); + }); + } + } + test("rejects a noncanonical CLI context before discovering consumers", () => { + const result = runAdmissionCli({ repositoryName: "fork/paperclip" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Canonical master admission context required/); + assert.equal(result.output, ""); + }); +}); const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" }; const baseRun = { id: 456, workflow_id: workflow.id, path: workflow.path, run_attempt: 2, diff --git a/scripts/github-actions-reader.mjs b/scripts/github-actions-reader.mjs new file mode 100644 index 00000000000..1898164792a --- /dev/null +++ b/scripts/github-actions-reader.mjs @@ -0,0 +1,73 @@ +// Statuses that say "ask again", not "the answer is no". A release must not be +// blocked because GitHub returned a gateway error during a 60-minute poll. +const TRANSIENT_READ_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); + +// A rate-limited read also says "ask again", and GitHub reports both primary +// and secondary rate limits as 403. Only the headers separate that from a +// token that may not read Actions, which must still fail at once. +function rateLimited(response) { + if (response.status !== 403) return false; + const header = (name) => response.headers?.get?.(name) ?? null; + return header("retry-after") !== null || header("x-ratelimit-remaining") === "0"; +} + +/** Milliseconds from a Retry-After header, when it carries a sane delay. */ +function retryAfterMs(response, capMs) { + const value = Number(response?.headers?.get?.("retry-after")); + if (!Number.isFinite(value) || value <= 0) return null; + return Math.min(value * 1_000, capMs); +} + +/** + * The GitHub Actions read used by the polling below, with transient transport + * failures retried: network errors, the statuses above, rate-limited 403s, and + * a body that fails while it is being read. Any other non-OK status throws on + * the first response, because waiting out a wrong token only delays the news. + * + * `deadlineAt` bounds retries by the caller's own polling deadline, so a read + * cannot extend the wait past the timeout it belongs to. + */ +export function createActionsReader({ + token, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + attempts = 4, backoffMs = 1_000, log = console.log, + now = Date.now, deadlineAt = () => Infinity, maxRetryAfterMs = 60_000, +} = {}) { + return async (path) => { + for (let attempt = 1; ; attempt += 1) { + // Retry only while both the attempt budget and the caller's deadline + // leave room for the wait this attempt would cost. + const waitMs = backoffMs * attempt; + const retryable = attempt < attempts && now() + waitMs < deadlineAt(); + const pause = (response) => sleep(retryAfterMs(response, maxRetryAfterMs) ?? waitMs); + let response; + try { + response = await fetchImpl(`https://api.github.com${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, + signal: AbortSignal.timeout(30_000), redirect: "error", + }); + } catch (cause) { + if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`); + log(`GitHub Actions read failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`); + await pause(); + continue; + } + if (response.ok) { + try { + // A 200 whose body is truncated or undecodable is a transport + // failure like any other, so it belongs inside the retry. + return await response.json(); + } catch (cause) { + if (!retryable) throw new Error(`GitHub Actions read failed: ${cause.message}`); + log(`GitHub Actions read body failed (${cause.message}); retrying (${attempt}/${attempts - 1}).`); + await pause(); + continue; + } + } + if (!retryable || !(TRANSIENT_READ_STATUSES.has(response.status) || rateLimited(response))) { + throw new Error(`GitHub Actions read failed (HTTP ${response.status}).`); + } + log(`GitHub Actions read failed (HTTP ${response.status}); retrying (${attempt}/${attempts - 1}).`); + await pause(response); + } + }; +}