mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
perf(ci): admit release candidates and isolate native image capacity (#18869)
Admit full source verification only for exact-source Docker or npm release work, while superseded pending sources complete metadata-only admission. Restart only strictly deferred runs; failed or cancelled checks require an explicit request. Route canonical master native images to dedicated 32-core Spot profiles and keep source checks on protected verification capacity. Use the four-slot GitHub-hosted publication pool for registry work and signing, with proof waits outside that pool and a fresh source-proof check before tagging. Trim historical checkout blobs and prepared-runner disk cleanup while preserving tag and fork publication behavior. Validation: release/source and source/artifact regressions, 665 workflow contracts, actionlint, complete latest-head PR CI, and Greptile 5/5 with all comments resolved. Existing CLI and large-file repository timing tests also passed independently; their CI shards received one scoped retry. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
8771606fd8
commit
f774992832
13 files changed
+886
-63
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createActionsReader, readSourceVerification, sourceVerificationJob, waitForSourceVerification } from "./cloud-source-verification.mjs";
|
||||
import test, { describe } from "node:test";
|
||||
import { createActionsReader, readSourceVerification, sourceVerificationJob, waitForSourceVerification, SourceVerificationDeferred } from "./cloud-source-verification.mjs";
|
||||
|
||||
import { releaseAdmitted, deferredProducer, requestDeferredProducer, sourceAdmissionJob, migratorAdmissionJob, createReleaseRerunner } from "./cloud-release-admission.mjs";
|
||||
|
||||
import { cancelImageRun, runnerWaits, watchReleaseSource } from "./watch-release-source.mjs";
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" };
|
||||
@@ -62,7 +66,8 @@ test("job identities and terminal failures fail closed", async () => {
|
||||
{ head_sha: "b".repeat(40) }, { run_id: 999 }, { run_attempt: 1 },
|
||||
{ conclusion: "failure" }, { conclusion: "cancelled" }, { conclusion: "skipped" },
|
||||
]) {
|
||||
const { api } = fixture({ jobs: [{ ...baseJob, ...overrides }] });
|
||||
const { api } = fixture({ jobs: [{ ...baseJob, ...overrides }],
|
||||
current: overrides.conclusion === "skipped" ? { ...baseRun, status: "completed", conclusion: "success" } : baseRun });
|
||||
await assert.rejects(readSourceVerification(sha, api), /did not pass/);
|
||||
}
|
||||
});
|
||||
@@ -231,3 +236,357 @@ test("retries stop at the caller's deadline rather than outliving the poll", asy
|
||||
assert.equal(seen.length, 1);
|
||||
assert.deepEqual(waits, []);
|
||||
});
|
||||
|
||||
|
||||
describe("image build source monitor", () => {
|
||||
const sha = "a".repeat(40);
|
||||
const runId = 50;
|
||||
const attempt = 1;
|
||||
const workflow = { id: 123, path: ".github/workflows/cloud-readiness.yml" };
|
||||
const sourceRun = { id: 99, workflow_id: 123, path: workflow.path, run_attempt: 1,
|
||||
repository: { full_name: "paperclipai/paperclip" }, head_repository: { full_name: "paperclipai/paperclip" },
|
||||
head_sha: sha, head_branch: "master", event: "push", status: "in_progress" };
|
||||
const imageRun = { id: runId, run_attempt: attempt, head_sha: sha, head_branch: "master", event: "push",
|
||||
repository: { id: 1170821064 }, head_repository: { id: 1170821064 },
|
||||
path: ".github/workflows/docker.yml", status: "in_progress" };
|
||||
const proof = { id: 111, name: "Cloud source verified v1", run_id: 99, run_attempt: 1,
|
||||
head_sha: sha, status: "completed", conclusion: "success" };
|
||||
|
||||
function fixture({ jobs = [proof], run = sourceRun, current = run, image = imageRun, imageJobs = [], missing = false } = {}) {
|
||||
const cancellations = [];
|
||||
const messages = [];
|
||||
const api = async path => {
|
||||
if (path.endsWith("/workflows/cloud-readiness.yml")) return workflow;
|
||||
if (path.includes("/workflows/123/runs?")) return { workflow_runs: missing ? [] : [run], total_count: missing ? 0 : 1 };
|
||||
if (path.includes("/runs/99/attempts/1/jobs?")) return { jobs };
|
||||
if (path.endsWith("/runs/99")) return current;
|
||||
if (path.includes(`/runs/${runId}/attempts/${attempt}/jobs?`)) return { jobs: imageJobs, total_count: imageJobs.length };
|
||||
if (path.endsWith(`/runs/${runId}`)) return image;
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
};
|
||||
return { sha, runId, attempt, api, cancel: async path => cancellations.push(path),
|
||||
cancellations, messages, log: message => messages.push(message), summary: async message => messages.push(message) };
|
||||
}
|
||||
|
||||
test("successful exact-source proof permits publication without canceling image work", async () => {
|
||||
const f = fixture();
|
||||
assert.equal((await watchReleaseSource(f)).jobId, proof.id);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("a required check failing cancels the same image attempt before siblings finish", async () => {
|
||||
const f = fixture({ jobs: [{ ...proof, name: "General tests", conclusion: "failure" }] });
|
||||
await assert.rejects(watchReleaseSource(f), /General tests/);
|
||||
assert.deepEqual(f.cancellations, ["/repos/paperclipai/paperclip/actions/runs/99/cancel", `/repos/paperclipai/paperclip/actions/runs/${runId}/cancel`]);
|
||||
});
|
||||
|
||||
test("a superseded readiness attempt cancels image work with no deployable future", async () => {
|
||||
const f = fixture({ jobs: [], run: { ...sourceRun, status: "completed", conclusion: "cancelled" } });
|
||||
await assert.rejects(watchReleaseSource(f), /did not pass/);
|
||||
assert.equal(f.cancellations.length, 1);
|
||||
});
|
||||
|
||||
test("a rerun racing a failed job is retried rather than canceling the image", async () => {
|
||||
const f = fixture({ jobs: [{ ...proof, name: "General tests", conclusion: "failure" }],
|
||||
current: { ...sourceRun, run_attempt: 2 } });
|
||||
let time = 0;
|
||||
const api = f.api;
|
||||
f.api = async path => time > 0 ? fixture().api(path) : api(path);
|
||||
const result = await watchReleaseSource({ ...f, now: () => time, sleep: async ms => { time += ms; } });
|
||||
assert.equal(result.jobId, proof.id);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("a transport failure does not issue a destructive cancellation", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(watchReleaseSource({ ...f, api: async () => { throw new Error("GitHub unavailable"); } }), /unavailable/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
});
|
||||
|
||||
test("the observation window ends without cancelling healthy pending verification", async () => {
|
||||
const f = fixture({ missing: true });
|
||||
let time = 0;
|
||||
const proof = await watchReleaseSource({ ...f, now: () => time, sleep: async ms => { time += ms; }, timeoutMs: 20 });
|
||||
assert.equal(proof, undefined);
|
||||
assert.equal(time, 20);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
assert.ok(f.messages.some(message => message.includes("final publication still requires exact-source verification")));
|
||||
});
|
||||
|
||||
test("final publication requires proof even after the early observation window ends", async () => {
|
||||
const f = fixture({ missing: true });
|
||||
let time = 0;
|
||||
await assert.rejects(watchReleaseSource({ ...f, requireProof: true, now: () => time,
|
||||
sleep: async ms => { time += ms; }, timeoutMs: 20 }), /before the publication deadline/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
assert.equal((await watchReleaseSource({ ...fixture(), requireProof: true })).jobId, proof.id);
|
||||
});
|
||||
|
||||
test("cancellation rejects another SHA, repository, ref, workflow, or attempt", async () => {
|
||||
for (const change of [{ head_sha: "b".repeat(40) }, { head_branch: "v1" }, { event: "pull_request" },
|
||||
{ path: ".github/workflows/release.yml" }, { run_attempt: 2 }, { id: 51 },
|
||||
{ repository: { id: 42 } }, { head_repository: { id: 42 } }]) {
|
||||
const f = fixture({ image: { ...imageRun, ...change } });
|
||||
await assert.rejects(cancelImageRun(f), /different image workflow identity/);
|
||||
assert.deepEqual(f.cancellations, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("queue measurement does not count running time or skipped dependency jobs", () => {
|
||||
const created_at = "2020-01-01T00:00:00Z";
|
||||
const started_at = "2020-01-01T00:00:20Z";
|
||||
const job = { id: 1, name: "amd64", labels: ["release"], created_at, started_at, runner_id: 10, status: "in_progress" };
|
||||
const now = Date.parse(created_at) + 120_000;
|
||||
assert.deepEqual(runnerWaits([job, { ...job, status: "completed", conclusion: "skipped" }], now), [{ id: 1, job: "amd64", seconds: 20 }]);
|
||||
assert.equal(runnerWaits([{ ...job, status: "queued", runner_id: 0 }], now)[0].seconds, 120);
|
||||
});
|
||||
|
||||
test("a runner wait over 60 seconds produces an Actions warning and summary", async () => {
|
||||
const f = fixture({ imageJobs: [{ id: 1, name: "amd64", labels: ["release"], status: "queued", runner_id: 0, created_at: "2020-01-01T00:00:00Z" }] });
|
||||
await watchReleaseSource({ ...f, now: () => Date.parse("2020-01-01T00:02:00Z") });
|
||||
assert.ok(f.messages.some(message => message.startsWith("::warning") && message.includes("120s")));
|
||||
assert.ok(f.messages.some(message => message.includes("Release runner wait: amd64")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("release candidate admission", () => {
|
||||
const repository = { id: 1170821064, full_name: "paperclipai/paperclip" };
|
||||
const ids = { "docker.yml": 101, "release.yml": 102, "cloud-readiness.yml": 123, "cloud-migrator-artifacts.yml": 124 };
|
||||
function run(file, change = {}) {
|
||||
return { id: ids[file] + 1000, workflow_id: ids[file], path: `.github/workflows/${file}`,
|
||||
repository, head_repository: repository, head_sha: sha, head_branch: "master", event: "push",
|
||||
run_attempt: 1, status: "in_progress", conclusion: null, ...change };
|
||||
}
|
||||
function job(run, name, conclusion = "skipped") {
|
||||
return { id: 789, run_id: run.id, run_attempt: run.run_attempt, head_sha: run.head_sha,
|
||||
name, status: "completed", conclusion };
|
||||
}
|
||||
function fixture(file = "cloud-readiness.yml", change = {}) {
|
||||
const producer = run(file, { status: "completed", conclusion: "success", ...change });
|
||||
const admission = file === "cloud-migrator-artifacts.yml" ? migratorAdmissionJob : sourceAdmissionJob;
|
||||
const f = { runs: { [file]: [producer] }, jobs: { [producer.id]: [job(producer, admission, "success"), job(producer, sourceVerificationJob)] },
|
||||
current: {}, writes: [], calls: [], requested: new Set(), file, sha, admissionJob: admission };
|
||||
f.api = async path => {
|
||||
f.calls.push(path);
|
||||
const url = new URL(`https://api.github.com${path}`);
|
||||
const fileMatch = path.match(/\/workflows\/([^/?]+\.yml)$/);
|
||||
if (fileMatch) return { id: ids[fileMatch[1]], path: `.github/workflows/${fileMatch[1]}` };
|
||||
const listMatch = path.match(/\/workflows\/(\d+)\/runs\?/);
|
||||
if (listMatch) {
|
||||
const name = Object.keys(ids).find(file => ids[file] === Number(listMatch[1]));
|
||||
const runs = f.runs[name] ?? [];
|
||||
return { workflow_runs: runs, total_count: f.total ?? runs.length };
|
||||
}
|
||||
const jobMatch = path.match(/\/runs\/(\d+)\/attempts\/(\d+)\/jobs\?/);
|
||||
if (jobMatch) {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const jobs = f.jobs[Number(jobMatch[1])] ?? [];
|
||||
return { total_count: f.jobTotal ?? jobs.length, jobs: jobs.slice((page - 1) * 100, page * 100) };
|
||||
}
|
||||
const currentMatch = path.match(/\/runs\/(\d+)$/);
|
||||
if (currentMatch) return f.current[Number(currentMatch[1])] ?? Object.values(f.runs).flat().find(run => run.id === Number(currentMatch[1]));
|
||||
throw new Error(`Unexpected admission API path: ${path}`);
|
||||
};
|
||||
f.rerun = async path => f.writes.push(path);
|
||||
return f;
|
||||
}
|
||||
|
||||
test("only a running image or npm release admits a normal push", async () => {
|
||||
for (const file of ["docker.yml", "release.yml"]) {
|
||||
const f = fixture();
|
||||
assert.equal(await releaseAdmitted(sha, f), false);
|
||||
f.runs[file] = [run(file)];
|
||||
assert.equal(await releaseAdmitted(sha, f), true);
|
||||
assert.equal(await releaseAdmitted(sha, { ...f, imageOnly: true }), file === "docker.yml");
|
||||
for (const status of ["queued", "pending", "completed"]) {
|
||||
f.runs[file] = [run(file, { status })];
|
||||
assert.equal(await releaseAdmitted(sha, f), false, status);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("manual and retry requests admit exact source without consumer discovery", async () => {
|
||||
const api = () => assert.fail("explicit request needs no discovery");
|
||||
assert.equal(await releaseAdmitted(sha, { api, event: "workflow_dispatch" }), true);
|
||||
assert.equal(await releaseAdmitted(sha, { api, attempt: 2 }), true);
|
||||
assert.equal(await releaseAdmitted(sha, { api, event: "pull_request", attempt: 2 }), false);
|
||||
for (const attempt of [0, "2"]) {
|
||||
await assert.rejects(releaseAdmitted(sha, { api, attempt }), /Invalid release attempt/);
|
||||
}
|
||||
});
|
||||
|
||||
test("forks, stale identities, tags, and non-push consumers do not admit work", async () => {
|
||||
for (const change of [{ repository: { ...repository, id: 2 } }, { head_repository: { ...repository, id: 2 } },
|
||||
{ repository: { ...repository, full_name: "fork/paperclip" } }, { path: ".github/workflows/pr.yml" },
|
||||
{ workflow_id: 999 }, { head_sha: "b".repeat(40) }, { head_branch: "v1" }, { event: "workflow_dispatch" },
|
||||
{ id: 0 }, { run_attempt: 0 }]) {
|
||||
const f = fixture();
|
||||
f.runs["docker.yml"] = [run("docker.yml", change)];
|
||||
assert.equal(await releaseAdmitted(sha, f), false, JSON.stringify(change));
|
||||
}
|
||||
const f = fixture();
|
||||
const active = run("docker.yml");
|
||||
f.runs["docker.yml"] = [active];
|
||||
f.current[active.id] = { ...active, run_attempt: 2 };
|
||||
assert.equal(await releaseAdmitted(sha, f), false);
|
||||
});
|
||||
|
||||
test("incomplete discovery and transport failure fail closed", async () => {
|
||||
for (const total of [1, 101, -1]) {
|
||||
const f = fixture();
|
||||
f.total = total;
|
||||
await assert.rejects(releaseAdmitted(sha, f), /Incomplete/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
await assert.rejects(releaseAdmitted(sha, { api: async () => { throw new Error("API unavailable"); } }), /unavailable/);
|
||||
await assert.rejects(releaseAdmitted("master", { api: () => assert.fail() }), /full source SHA/);
|
||||
});
|
||||
|
||||
test("a selected consumer restarts only a strictly deferred producer, once", async () => {
|
||||
for (const file of ["cloud-readiness.yml", "cloud-migrator-artifacts.yml"]) {
|
||||
const f = fixture(file);
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.deepEqual(f.writes, [`/repos/paperclipai/paperclip/actions/runs/${ids[file] + 1000}/rerun`]);
|
||||
await requestDeferredProducer(f);
|
||||
assert.equal(f.writes.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed, incomplete, ambiguous, or mismatched work is never automatically retried", async () => {
|
||||
for (const change of [jobs => { jobs[1].conclusion = "failure"; }, jobs => { jobs[0].conclusion = "failure"; },
|
||||
jobs => { jobs[1].run_attempt = 2; }, jobs => { jobs[1].head_sha = "b".repeat(40); },
|
||||
jobs => { jobs[1].run_id = 2; }, jobs => { jobs[1].status = "queued"; },
|
||||
jobs => { jobs.push({ ...jobs[0] }); }, jobs => { jobs.splice(1); }]) {
|
||||
const f = fixture();
|
||||
change(f.jobs[1123]);
|
||||
assert.equal(await requestDeferredProducer(f), "finished");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
const f = fixture();
|
||||
for (const change of [{ run_attempt: 2 }, { status: "in_progress" }, { head_sha: "b".repeat(40) }, { id: 2 }]) {
|
||||
f.current[1123] = { ...f.runs[f.file][0], ...change };
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicitly cancelled or failed producer cannot be restarted by admission", async () => {
|
||||
for (const conclusion of ["cancelled", "failure", "timed_out"]) {
|
||||
const f = fixture("cloud-readiness.yml", { conclusion });
|
||||
assert.equal(await requestDeferredProducer(f), "finished");
|
||||
assert.deepEqual(f.writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("a truncated job page cannot hide a failed check and trigger a retry", async () => {
|
||||
const f = fixture();
|
||||
f.jobTotal = 3;
|
||||
await assert.rejects(requestDeferredProducer(f), /Incomplete release producer job list/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
f.jobTotal = "2";
|
||||
await assert.rejects(requestDeferredProducer(f), /Invalid release producer job list/);
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("producer retries reject arbitrary workflow and admission contracts", async () => {
|
||||
const f = fixture();
|
||||
for (const change of [{ file: "docker.yml" }, { admissionJob: migratorAdmissionJob }]) {
|
||||
await assert.rejects(requestDeferredProducer({ ...f, ...change }), /Unknown deferred/);
|
||||
}
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("a deferred completion racing the run listing does not become a source failure", async () => {
|
||||
const f = fixture();
|
||||
const completed = f.runs[f.file][0];
|
||||
f.runs[f.file] = [{ ...completed, status: "in_progress", conclusion: null }];
|
||||
f.current[completed.id] = completed;
|
||||
await assert.rejects(readSourceVerification(sha, f.api), SourceVerificationDeferred);
|
||||
delete f.current[completed.id];
|
||||
assert.equal(await readSourceVerification(sha, f.api), undefined);
|
||||
});
|
||||
|
||||
test("a competing consumer's accepted retry does not fail the losing consumer", async () => {
|
||||
const f = fixture();
|
||||
f.rerun = async () => {
|
||||
f.current[1123] = run(f.file, { run_attempt: 2 });
|
||||
throw new Error("duplicate restart rejected");
|
||||
};
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.equal(f.requested.size, 1);
|
||||
const rejected = fixture();
|
||||
rejected.rerun = async () => { throw new Error("token denied"); };
|
||||
await assert.rejects(requestDeferredProducer(rejected), /token denied/);
|
||||
assert.equal(rejected.requested.size, 0);
|
||||
});
|
||||
|
||||
test("a queued migrator is pending until its build actually starts", async () => {
|
||||
const f = fixture("cloud-migrator-artifacts.yml", { status: "in_progress", conclusion: null });
|
||||
const producer = f.runs[f.file][0];
|
||||
const build = { ...job(producer, "Build migrator", null), status: "queued", labels: ["cloud-release-32"] };
|
||||
f.jobs[producer.id] = [job(producer, migratorAdmissionJob, "success"), build];
|
||||
assert.equal(await requestDeferredProducer(f), "pending");
|
||||
build.status = "in_progress";
|
||||
assert.equal(await requestDeferredProducer(f), "admitted");
|
||||
build.run_attempt = 2;
|
||||
assert.equal(await requestDeferredProducer(f), "pending", "a stale build cannot admit work");
|
||||
assert.deepEqual(f.writes, []);
|
||||
});
|
||||
|
||||
test("a rejected duplicate with stale read visibility remains pending without claiming admission", async () => {
|
||||
for (const status of [409, 422, 403]) {
|
||||
const f = fixture();
|
||||
f.rerun = async () => { throw Object.assign(new Error("retry rejected"), { status }); };
|
||||
if (status === 403) await assert.rejects(requestDeferredProducer(f), /retry rejected/);
|
||||
else assert.equal(await requestDeferredProducer(f), "pending");
|
||||
assert.equal(f.requested.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("source proof waits for admission, restarts it, and accepts only the new attempt", async () => {
|
||||
const f = fixture();
|
||||
await assert.rejects(readSourceVerification(sha, f.api), SourceVerificationDeferred);
|
||||
let time = 0;
|
||||
const proof = await waitForSourceVerification(sha, { ...f, now: () => time, intervalMs: 10, timeoutMs: 30, log: () => {},
|
||||
sleep: async ms => {
|
||||
time += ms;
|
||||
const selected = run(f.file, { run_attempt: 2 });
|
||||
f.runs[f.file] = [selected];
|
||||
f.jobs[selected.id] = [job(selected, sourceAdmissionJob, "success"), job(selected, sourceVerificationJob, "success")];
|
||||
} });
|
||||
assert.equal(proof.attempt, 2);
|
||||
assert.equal(time, 10);
|
||||
assert.equal(f.writes.length, 1);
|
||||
});
|
||||
|
||||
test("the image monitor admits a late migrator even when source proof is ready", async () => {
|
||||
const f = fixture("cloud-migrator-artifacts.yml");
|
||||
const source = run("cloud-readiness.yml");
|
||||
f.runs["cloud-readiness.yml"] = [source];
|
||||
f.jobs[source.id] = [job(source, sourceVerificationJob, "success")];
|
||||
const image = run("docker.yml");
|
||||
f.runs["docker.yml"] = [image];
|
||||
f.jobs[image.id] = [];
|
||||
let time = 0;
|
||||
const proof = await watchReleaseSource({ ...f, runId: image.id, attempt: 1, cancel: () => assert.fail("must not cancel"),
|
||||
now: () => time, intervalMs: 10, timeoutMs: 30, log: () => {}, summary: async () => {}, sleep: async ms => {
|
||||
time += ms;
|
||||
const selected = run(f.file, { run_attempt: 2 });
|
||||
f.runs[f.file] = [selected];
|
||||
f.jobs[selected.id] = [{ ...job(selected, "Build migrator", null), status: "in_progress", labels: ["cloud-release-32"] }];
|
||||
} });
|
||||
assert.equal(proof.sha, sha);
|
||||
assert.equal(time, 10);
|
||||
assert.equal(f.writes.length, 1);
|
||||
});
|
||||
|
||||
test("the retry transport is a bounded POST to a canonical run", async () => {
|
||||
const calls = [];
|
||||
const rerun = createReleaseRerunner("fixture", async (url, options) => { calls.push({ url, options }); return { ok: true }; });
|
||||
await assert.rejects(rerun("/repos/fork/paperclip/actions/runs/1/rerun"), /Invalid release retry/);
|
||||
await rerun("/repos/paperclipai/paperclip/actions/runs/1/rerun");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].options.method, "POST");
|
||||
assert.equal(calls[0].options.redirect, "error");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user