mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
ci: optimize checks for stacked pull requests (#12507)
## Thinking Path > - Paperclip uses GitHub Actions to protect changes before they enter `master`. > - GitHub evaluates every pull request in a native stack against the stack base. > - The current workflow therefore starts the complete CI matrix for every layer in a stack. > - A large stack can queue many copies of the same integrated verification and delay every pull request. > - GitHub provides stack position and base metadata so workflows can select merge-relevant layers. > - This pull request keeps policy and required check names on every layer, but runs full CI only for ordinary pull requests, the top layer, and the lowest unmerged layer. > - The benefit is much lower CI load without weakening the required-check contract. ## Linked Issues or Issue Description **What existing behavior does this improve?** The trusted pull request workflow currently runs every test, build, canary, and E2E lane for every pull request in a native stack. **Subsystem affected** GitHub Actions pull request verification. **Current behavior** A stack with 61 pull requests can start 61 complete CI matrices after a cascading rebase. **Proposed behavior** Run the always-on policy job and stable required-check aggregators for every layer. Run the complete verification matrix only for ordinary pull requests, the top stack layer, and the lowest unmerged stack layer. **Reason and benefit** The top layer verifies the integrated stack. The lowest unmerged layer verifies the current merge candidate. Middle layers keep branch-protection checks without consuming the complete runner matrix. **Breaking changes** Middle stack layers no longer run the complete CI matrix. Their `ci / verify` and `ci / e2e` checks still require the policy job to pass and require every expensive lane to be intentionally skipped. ## What Changed - Add a fail-safe stack scope decision to the trusted PR runner gate. - Run typecheck, general tests, build, serialized tests, canary, and E2E shards only for ordinary, top, and lowest-unmerged pull requests. - Preserve the required `ci / verify` and `ci / e2e` names on every layer. - Make the required aggregators distinguish valid middle-layer skips from failures or missing scope decisions. - Add regression coverage for ordinary, top, bottom, middle, and malformed stack metadata. ## Verification - `node --test scripts/__tests__/e2e-shard.test.mjs` — 11 tests passed. - `actionlint .github/workflows/pr-trusted.yml .github/workflows/pr.yml` — passed. - `git diff --check origin/master...HEAD` — passed. - The caller remains pinned to the current trusted workflow. A separate activation change must advance the immutable SHA after this pull request lands. ## Risks - Incorrect stack classification could skip important jobs. Missing or malformed stack metadata defaults to full CI. - Middle-layer required checks depend on the policy job and verify that all expensive jobs have the `skipped` result. - The reusable workflow change does not become active until the immutable caller SHA advances in a separate change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with GPT-5. The exact deployment suffix and context window are not exposed. The model used reasoning, repository tools, code execution, Git, and GitHub API access. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d7ff895a9a
commit
39b8ee2960
2 files changed
+196
-22
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
@@ -39,6 +40,58 @@ function readPinnedTrustedPrWorkflow() {
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function readWorkflowJobs(workflow) {
|
||||
const jobs = new Map();
|
||||
let current = null;
|
||||
for (const line of workflow.split("\n")) {
|
||||
const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
||||
if (header) {
|
||||
current = header[1];
|
||||
jobs.set(current, []);
|
||||
continue;
|
||||
}
|
||||
if (current && /^\S/.test(line)) current = null;
|
||||
if (current) jobs.get(current).push(line);
|
||||
}
|
||||
for (const [id, lines] of jobs) jobs.set(id, lines.join("\n"));
|
||||
return jobs;
|
||||
}
|
||||
|
||||
function runStackScope(stack, prBaseRef) {
|
||||
const workflow = readFileSync(trustedPrWorkflow, "utf8");
|
||||
const match = workflow.match(
|
||||
/ - name: Select stacked PR CI scope[\s\S]*? run: \|\n([\s\S]*?)\n\n policy:/,
|
||||
);
|
||||
assert.ok(match, "trusted workflow must define the stacked PR scope script");
|
||||
const script = match[1]
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^ {10}/, ""))
|
||||
.join("\n");
|
||||
const scratch = mkdtempSync(path.join(tmpdir(), "paperclip-stack-scope-"));
|
||||
const output = path.join(scratch, "github-output");
|
||||
|
||||
try {
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_OUTPUT: output,
|
||||
PR_BASE_REF: prBaseRef,
|
||||
STACK_JSON: JSON.stringify(stack),
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return Object.fromEntries(
|
||||
readFileSync(output, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.split("=")),
|
||||
);
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("the e2e shards form a complete, non-overlapping partition", () => {
|
||||
const specs = listE2eSpecs();
|
||||
assert.ok(specs.length > 0, "expected a non-empty e2e spec set");
|
||||
@@ -115,19 +168,7 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the
|
||||
// as `e2e shard (n/3)`, so the aggregate job below is what keeps the
|
||||
// required-check contract intact — same pattern as the `verify` aggregate.
|
||||
const workflow = readPinnedTrustedPrWorkflow();
|
||||
const jobs = new Map();
|
||||
let current = null;
|
||||
for (const line of workflow.split("\n")) {
|
||||
const header = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
|
||||
if (header) {
|
||||
current = header[1];
|
||||
jobs.set(current, []);
|
||||
continue;
|
||||
}
|
||||
if (current && /^\S/.test(line)) current = null;
|
||||
if (current) jobs.get(current).push(line);
|
||||
}
|
||||
for (const [id, lines] of jobs) jobs.set(id, lines.join("\n"));
|
||||
const jobs = readWorkflowJobs(workflow);
|
||||
|
||||
const aggregate = jobs.get("e2e");
|
||||
assert.ok(aggregate, "pr-trusted.yml must define an `e2e` job to satisfy branch protection");
|
||||
@@ -135,8 +176,8 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the
|
||||
assert.match(aggregate, /^ {4}if: \$\{\{ always\(\) \}\}$/m, "the aggregate must run even when a shard fails");
|
||||
assert.match(
|
||||
aggregate,
|
||||
/^ {4}needs: \[gate, e2e_shards\]$/m,
|
||||
"the aggregate must depend on the runner gate and shard matrix",
|
||||
/^ {4}needs: \[gate, (?:policy, )?e2e_shards\]$/m,
|
||||
"the aggregate must depend on the runner gate, optional policy gate, and shard matrix",
|
||||
);
|
||||
assert.match(
|
||||
aggregate,
|
||||
@@ -168,6 +209,70 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the
|
||||
}
|
||||
});
|
||||
|
||||
test("the trusted PR workflow limits full CI to merge-relevant stack layers", () => {
|
||||
const workflow = readFileSync(trustedPrWorkflow, "utf8");
|
||||
const jobs = readWorkflowJobs(workflow);
|
||||
const gate = jobs.get("gate");
|
||||
|
||||
assert.match(gate, /^ {6}full_ci: \$\{\{ steps\.scope\.outputs\.full_ci \}\}$/m);
|
||||
assert.match(gate, /STACK_JSON: \$\{\{ toJSON\(github\.event\.pull_request\.stack\) \}\}/);
|
||||
assert.match(gate, /stack_position == stack_size/);
|
||||
assert.match(gate, /"\$stack_base_ref" == "\$PR_BASE_REF"/);
|
||||
|
||||
for (const jobId of [
|
||||
"typecheck_release_registry",
|
||||
"general_tests",
|
||||
"build",
|
||||
"verify_serialized_server",
|
||||
"canary_dry_run",
|
||||
"e2e_shards",
|
||||
]) {
|
||||
assert.match(
|
||||
jobs.get(jobId),
|
||||
/^ {4}if: \$\{\{ needs\.gate\.outputs\.full_ci == 'true' \}\}$/m,
|
||||
`${jobId} must run only when the gate selects full CI`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.doesNotMatch(
|
||||
jobs.get("policy"),
|
||||
/needs\.gate\.outputs\.full_ci/,
|
||||
"the policy job must run on every PR layer",
|
||||
);
|
||||
|
||||
const verify = jobs.get("verify");
|
||||
assert.match(verify, /^ {4}needs: \[gate, policy, typecheck_release_registry, general_tests, build\]$/m);
|
||||
assert.match(verify, /POLICY_RESULT: \$\{\{ needs\.policy\.result \}\}/);
|
||||
assert.match(verify, /test "\$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"/);
|
||||
assert.match(verify, /test "\$GENERAL_TESTS_RESULT" = "skipped"/);
|
||||
assert.match(verify, /test "\$BUILD_RESULT" = "skipped"/);
|
||||
|
||||
const e2e = jobs.get("e2e");
|
||||
assert.match(e2e, /^ {4}needs: \[gate, policy, e2e_shards\]$/m);
|
||||
assert.match(e2e, /POLICY_RESULT: \$\{\{ needs\.policy\.result \}\}/);
|
||||
assert.match(e2e, /false\) test "\$E2E_SHARDS_RESULT" = "skipped"/);
|
||||
});
|
||||
|
||||
test("the stacked PR scope selector runs full CI only where intended", () => {
|
||||
assert.equal(runStackScope(null, "master").full_ci, "true");
|
||||
assert.equal(
|
||||
runStackScope({ position: 11, size: 11, base: { ref: "master" } }, "stack-10").full_ci,
|
||||
"true",
|
||||
);
|
||||
assert.equal(
|
||||
runStackScope({ position: 1, size: 11, base: { ref: "master" } }, "master").full_ci,
|
||||
"true",
|
||||
);
|
||||
assert.equal(
|
||||
runStackScope({ position: 6, size: 11, base: { ref: "master" } }, "stack-5").full_ci,
|
||||
"false",
|
||||
);
|
||||
assert.equal(
|
||||
runStackScope({ position: "invalid", size: 11, base: { ref: "master" } }, "stack-5").full_ci,
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("the trusted PR workflow passes the shard's spec filter to Playwright without a literal --", () => {
|
||||
// `pnpm run test:e2e -- $specs` forwards the literal separator to Playwright,
|
||||
// so the specs after it are not applied as file filters.
|
||||
|
||||
Reference in new issue
Block a user