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:
Dotta 2026-08-29 10:26:38 -05:00 committed by GitHub
parent d7ff895a9a
commit 39b8ee2960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 196 additions and 22 deletions

View File

@ -22,6 +22,7 @@ jobs:
timeout-minutes: 5
outputs:
runner: ${{ steps.route.outputs.runner }}
full_ci: ${{ steps.scope.outputs.full_ci }}
steps:
- name: Validate PR identity and select runner
@ -217,6 +218,41 @@ jobs:
echo "runner=$aws_runner" >> "$GITHUB_OUTPUT"
echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner'
- name: Select stacked PR CI scope
id: scope
shell: bash
env:
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
full_ci='true'
reason='ordinary pull request'
if jq -e 'type == "object"' <<< "$STACK_JSON" >/dev/null 2>&1; then
stack_position="$(jq -r '.position // empty' <<< "$STACK_JSON")"
stack_size="$(jq -r '.size // empty' <<< "$STACK_JSON")"
stack_base_ref="$(jq -r '.base.ref // empty' <<< "$STACK_JSON")"
if [[ ! "$stack_position" =~ ^[1-9][0-9]*$ ]] ||
[[ ! "$stack_size" =~ ^[1-9][0-9]*$ ]] ||
(( stack_position > stack_size )) ||
[[ -z "$stack_base_ref" ]]; then
reason='malformed stack metadata; defaulting to full CI'
elif (( stack_position == stack_size )); then
reason='top pull request in stack'
elif [[ "$stack_base_ref" == "$PR_BASE_REF" ]]; then
reason='lowest unmerged pull request in stack'
else
full_ci='false'
reason='middle pull request in stack'
fi
fi
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
echo "::notice title=Stacked PR CI scope::$reason; full_ci=$full_ci"
policy:
needs: [gate]
runs-on: ${{ needs.gate.outputs.runner }}
@ -323,6 +359,7 @@ jobs:
typecheck_release_registry:
name: Typecheck + Release Registry
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
@ -362,6 +399,7 @@ jobs:
general_tests:
name: General tests (${{ matrix.group_label }})
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
strategy:
@ -452,24 +490,41 @@ jobs:
# Preserve the legacy required-check name while the underlying work runs in parallel.
name: verify
if: ${{ always() }}
needs: [gate, typecheck_release_registry, general_tests, build]
needs: [gate, policy, typecheck_release_registry, general_tests, build]
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 5
steps:
- name: Fail if any split verify lane failed
env:
FULL_CI: ${{ needs.gate.outputs.full_ci }}
POLICY_RESULT: ${{ needs.policy.result }}
TYPECHECK_RELEASE_REGISTRY_RESULT: ${{ needs.typecheck_release_registry.result }}
GENERAL_TESTS_RESULT: ${{ needs.general_tests.result }}
BUILD_RESULT: ${{ needs.build.result }}
run: |
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$BUILD_RESULT" = "success"
test "$POLICY_RESULT" = "success"
case "$FULL_CI" in
true)
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "success"
test "$GENERAL_TESTS_RESULT" = "success"
test "$BUILD_RESULT" = "success"
;;
false)
test "$TYPECHECK_RELEASE_REGISTRY_RESULT" = "skipped"
test "$GENERAL_TESTS_RESULT" = "skipped"
test "$BUILD_RESULT" = "skipped"
;;
*)
echo "Invalid full_ci decision: $FULL_CI" >&2
exit 1
;;
esac
build:
name: Build
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
@ -509,6 +564,7 @@ jobs:
verify_serialized_server:
name: Verify serialized server suites (${{ matrix.shard_label }})
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
strategy:
@ -570,6 +626,7 @@ jobs:
canary_dry_run:
name: Canary Dry Run
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 20
@ -627,6 +684,7 @@ jobs:
e2e_shards:
name: e2e shard (${{ matrix.shard_label }})
needs: [gate, policy]
if: ${{ needs.gate.outputs.full_ci == 'true' }}
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 30
strategy:
@ -728,12 +786,23 @@ jobs:
# across the matrix above (same pattern as the `verify` aggregate).
name: e2e
if: ${{ always() }}
needs: [gate, e2e_shards]
needs: [gate, policy, e2e_shards]
runs-on: ${{ needs.gate.outputs.runner }}
timeout-minutes: 5
steps:
- name: Fail if any e2e shard failed
env:
FULL_CI: ${{ needs.gate.outputs.full_ci }}
POLICY_RESULT: ${{ needs.policy.result }}
E2E_SHARDS_RESULT: ${{ needs.e2e_shards.result }}
run: test "$E2E_SHARDS_RESULT" = "success"
run: |
test "$POLICY_RESULT" = "success"
case "$FULL_CI" in
true) test "$E2E_SHARDS_RESULT" = "success" ;;
false) test "$E2E_SHARDS_RESULT" = "skipped" ;;
*)
echo "Invalid full_ci decision: $FULL_CI" >&2
exit 1
;;
esac

View File

@ -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.