mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
fix(ci): remove the admission CLI import cycle (#18889)
Move the unchanged Actions read transport into an independent module so the admission CLI can evaluate consumers without awaiting a circular import of source verification. Preserve the existing source verification export and all verification and placement contracts. Five child-process cases reproduce the original exit-13 failure before the repair and pass afterward. All 59 source/admission/image checks and 665 workflow contracts pass. Both CLI modes also pass read-only smoke checks against real GitHub metadata. Complete latest-head CI passed on its first attempt, with Greptile 5/5 and no unresolved comments. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
523775f78f
commit
44a4ca1c61
4 files changed
+132
-74
No files matched your search
@@ -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.");
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user