mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its PR checks use reserved AWS capacity for authorized maintainers. > - The routing gate waited for GitHub capacity and compared an old test merge with a newer base branch. > - Normal merges into master then sent trusted PR jobs to the GitHub queue. > - This change restores AWS admission and validates the immutable merge selected for the run. > - Current CODEOWNER and rerun identity checks still control PR execution. ## Linked Issues or Issue Description Related: #18376 moved the gate to GitHub-hosted capacity. #16042 introduced reserved AWS admission. **What happened?** In PR #18811, GitHub created test merge `608d862ff1650db108a3832a2c67f8795924f0e7` at 17:17 UTC. Master advanced at 17:25. The gate started at 17:30 and rejected the older merge parent because it did not contain the newer master tip. The authorized PR then selected GitHub-hosted runners. The gate itself also needed a GitHub-hosted slot. **Expected behavior** Authorized maintainers use reserved AWS admission and PR workers. Normal base advancement does not change runner placement. Current owner checks, PR head checks, run-attempt checks, and base-history validation remain required. **Steps to reproduce** 1. Start a PR run whose author and sender are current CI CODEOWNERS. 2. Advance master while its routing job waits. 3. Run the gate against the original event merge and the current base tip. 4. Before this change, the gate selects `ubuntu-latest`. After this change, it selects the public PR AWS fleet. **Paperclip version or commit** Reproduced on master `367f29478d14003cc5c0f161b7ce8f3164993634`. **Deployment mode** GitHub Actions with RunsOn Fleet on AWS. ## What Changed - Restore the reserved routing fleet for the synchronized maintainer admission roster. - Check the immutable event merge and its PR head. Accept base snapshots between the event base and the current base tip. - Allow GitHub to regenerate its live merge ref while the original run waits. - Retain stacked-base validation and all current CODEOWNER, repository, author, sender, and rerun-attempt checks. - Add advancement, regenerated-merge, unrelated-history, and admission-placement regressions, including a stacked parent that descends from the event base but diverges from the live base. - Align the developing and CI profiling guides with reserved AWS admission and current CODEOWNER checks before PR execution. ## Verification - `node --test .github/scripts/tests/ci-routing.test.mjs scripts/__tests__/e2e-shard.test.mjs`: 103 passed. - `node --test .github/scripts/tests/ci-profile.test.mjs .github/scripts/tests/ci-plan.test.mjs`: 31 passed. - `actionlint .github/workflows/ci-routing.yml`: passed. - `git diff --check`: passed. - Mutation check: removing the snapshot-to-live-base comparison in a temporary workflow copy makes the new side-branch regression fail, proving it protects that ancestry guard. - [Greptile: 5/5](https://github.com/paperclipai/paperclip/pull/18821#issuecomment-6612170970) with a successful check on `abd6a8ce347228219d8e5127f34f57151788830e`; both review threads are resolved. - A regression reproduction on the original workflow selected GitHub instead of AWS. The patched workflow passes that scenario. - A read-only execution of the patched routing step against the actual event for run [41682867664](https://github.com/paperclipai/paperclip/actions/runs/41682867664), using live GitHub metadata, selected `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. This did not allocate a runner or rerun CI. - Full repository typecheck, Vitest, and build were not run in this worktree, which has no installed dependencies. The change is limited to workflow routing, its tests, and documentation. Broader CI checks remain queued. ## Risks - A stale admission roster can allocate the five-minute metadata gate. The gate still checks current CODEOWNERS before it authorizes any PR code on AWS. - The routing runner group must authorize `ci-routing.yml@refs/heads/master`. The fleet already exists; live placement still needs confirmation after merge. - Rewritten base history, changed PR heads, and unavailable identity or ancestry evidence continue to deny AWS PR execution. - Existing runs retain their previously resolved workflow. New runs use this change after it reaches master. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection, code execution, and live GitHub API tools. The runtime does not expose the exact serving model variant or context-window size. ## 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>
320 lines
22 KiB
JavaScript
320 lines
22 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import vm from "node:vm";
|
|
|
|
const workflow = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8");
|
|
const jobs = workflow.split(/^jobs:\n/m)[1];
|
|
|
|
test("authorization uses reserved capacity and runs no PR code or actions", () => {
|
|
assert.deepEqual([...jobs.matchAll(/^ {2}([a-z_]+):$/gm)].map((match) => match[1]), ["gate"]);
|
|
const placement = jobs.match(/^ {4}runs-on: (.+)$/m)?.[1];
|
|
assert.ok(placement, "routing placement must be defined");
|
|
assert.match(placement, /runs-on\/fleet=paperclip-ci-routing-x64\/env=public-ci/);
|
|
assert.doesNotMatch(placement, /fromJSON/);
|
|
assert.doesNotMatch(jobs, /uses:|actions\/checkout|secrets\./);
|
|
assert.doesNotMatch(workflow, /^ {2}(?:actions|contents|pull-requests): write$/m);
|
|
});
|
|
|
|
test("routing exports both placement and stack scope to callers", () => {
|
|
assert.match(workflow, /^ {6}runner:\n {8}value: \$\{\{ jobs\.gate\.outputs\.runner \}\}$/m);
|
|
assert.match(workflow, /^ {6}full_ci:\n {8}value: \$\{\{ jobs\.gate\.outputs\.full_ci \}\}$/m);
|
|
assert.match(jobs, /^ {6}runner: \$\{\{ steps\.route\.outputs\.runner \}\}$/m);
|
|
assert.match(jobs, /^ {6}full_ci: \$\{\{ steps\.scope\.outputs\.full_ci \}\}$/m);
|
|
});
|
|
|
|
const githubStub = "#!/usr/bin/env bash\nset -Eeuo pipefail\n\nendpoint=\"\"\nquery=\"\"\nfor arg in \"$@\"; do\n case \"$arg\" in /repos/*|/users/*) endpoint=\"$arg\" ;; esac\ndone\nif [[ \"$*\" == *\"--jq\"* ]]; then query=\"${*: -1}\"; fi\ncase \"$endpoint\" in\n */contents/scripts/ci-profile/authorize.mjs?ref=*)\n [[ -z \"${TEST_OWNER_HELPER_FAIL:-}\" ]] || exit 1\n [[ \"$endpoint\" == *\"ref=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" ]] || exit 1\n base64 < \"$TEST_OWNER_HELPER\"\n ;;\n */contents/.github/CODEOWNERS?ref=*)\n [[ -z \"${TEST_CODEOWNERS_FAIL:-}\" ]] || exit 1\n [[ \"$endpoint\" == *\"ref=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" ]] || exit 1\n jq -cn --arg content \"$TEST_CODEOWNERS_BASE64\" '{encoding: \"base64\", content: $content}'\n ;;\n /users/ci-owner)\n [[ -z \"${TEST_OWNER_LOOKUP_FAIL:-}\" ]] || exit 1\n jq -cn --argjson id \"${TEST_CI_OWNER_ID:-34892728}\" --arg type \"${TEST_CI_OWNER_TYPE:-User}\" '{id: $id, type: $type}'\n ;;\n */pulls/*)\n jq -cn \\\n --argjson author_id \"${TEST_PR_AUTHOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg base_ref \"${TEST_BASE_REF:-master}\" \\\n --arg base_sha \"${TEST_PR_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg head_sha \"${TEST_PR_HEAD_SHA:-1111111111111111111111111111111111111111}\" \\\n --arg merge_sha \"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\" '{\n state: \"open\",\n user: {id: $author_id},\n base: {repo: {id: $repository_id}, ref: $base_ref, sha: $base_sha},\n head: {sha: $head_sha},\n merge_commit_sha: $merge_sha\n }'\n ;;\n */git/ref/heads/*)\n if [[ \"$query\" == \".object.sha\" ]]; then\n [[ -z \"${TEST_MASTER_FAIL:-}\" ]] || exit 1\n echo \"${TEST_TRUSTED_MASTER_SHA:-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}\"\n exit 0\n fi\n jq -cn \\\n --arg sha \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" '{object: {sha: $sha}}'\n ;;\n */compare/*)\n [[ -z \"${TEST_COMPARE_FAIL:-}\" ]] || exit 1\n comparison=\"${endpoint##*/}\"\n comparison_base=\"${comparison%%...*}\"\n comparison_head=\"${comparison##*...}\"\n default_files='[{\"filename\":\"server/src/index.ts\"}]'\n jq -cn \\\n --arg base \"$comparison_base\" \\\n --arg head \"$comparison_head\" \\\n --arg event_base \"$EVENT_BASE_SHA\" \\\n --arg intermediate \"${TEST_INTERMEDIATE_BASE_SHA:-}\" \\\n --arg current \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" \\\n --arg side_branch \"${TEST_SIDE_BRANCH_SHA:-}\" \\\n --arg merge_base_override \"${TEST_BASE_COMPARISON_MERGE_BASE_SHA:-}\" \\\n --arg status_override \"${TEST_BASE_COMPARISON_STATUS:-}\" \\\n --argjson files \"${TEST_COMPARE_FILES:-$default_files}\" '\n [$event_base, $intermediate, $current] | map(select(length > 0)) as $history |\n ($history | index($base)) as $from |\n ($history | index($head)) as $to |\n (($from != null and $to != null and $from <= $to) or\n ($side_branch != \"\" and $base == $event_base and $head == $side_branch)) as $ancestor |\n {\n merge_base_commit: {sha: (if $merge_base_override != \"\" then $merge_base_override\n elif $ancestor then $base\n elif $base == $side_branch and $to != null then $event_base\n else \"0000000000000000000000000000000000000000\" end)},\n status: (if $status_override != \"\" then $status_override\n elif $base == $head then \"identical\" elif $ancestor then \"ahead\" else \"diverged\" end),\n files: $files\n }'\n ;;\n */git/commits/*)\n requested_sha=\"${endpoint##*/}\"\n live_merge_sha=\"${TEST_PR_MERGE_SHA:-4444444444444444444444444444444444444444}\"\n synthetic_base_merge_sha=\"${TEST_SYNTHETIC_BASE_MERGE_SHA:-}\"\n if [[ \"$requested_sha\" == \"$EVENT_MERGE_SHA\" ]]; then\n tree_sha=\"${TEST_EVENT_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_EVENT_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_EVENT_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ \"$requested_sha\" == \"$live_merge_sha\" ]]; then\n tree_sha=\"${TEST_LIVE_MERGE_TREE_SHA:-5555555555555555555555555555555555555555}\"\n base_sha=\"${TEST_LIVE_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\"\n head_sha=\"${TEST_LIVE_MERGE_HEAD_SHA:-$EVENT_HEAD_SHA}\"\n elif [[ -n \"$synthetic_base_merge_sha\" && \"$requested_sha\" == \"$synthetic_base_merge_sha\" ]]; then\n tree_sha=\"${TEST_SYNTHETIC_BASE_MERGE_TREE_SHA:-8888888888888888888888888888888888888888}\"\n base_sha=\"${TEST_SYNTHETIC_MASTER_PARENT_SHA:-9999999999999999999999999999999999999999}\"\n head_sha=\"${TEST_SYNTHETIC_BASE_REF_PARENT_SHA:-${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}}\"\n else\n echo \"unexpected merge commit: $requested_sha\" >&2\n exit 1\n fi\n jq -cn \\\n --arg sha \"$requested_sha\" \\\n --arg tree_sha \"$tree_sha\" \\\n --arg base_sha \"$base_sha\" \\\n --arg head_sha \"$head_sha\" '{\n sha: $sha,\n tree: {sha: $tree_sha},\n parents: [{sha: $base_sha}, {sha: $head_sha}]\n }'\n ;;\n */actions/runs/*/attempts/*)\n jq -cn \\\n --argjson actor_id \"${TEST_TRIGGERING_ACTOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --argjson run_attempt \"${TEST_RUN_ATTEMPT:-1}\" \\\n --arg event_name \"${TEST_EVENT_NAME:-pull_request}\" '{\n run_attempt: $run_attempt,\n triggering_actor: {id: $actor_id},\n repository: {id: $repository_id},\n event: $event_name\n }'\n ;;\n *)\n echo \"unexpected gh endpoint: $endpoint\" >&2\n exit 1\n ;;\nesac";
|
|
|
|
function stepScript(name) {
|
|
const start = workflow.indexOf(` - name: ${name}\n`);
|
|
assert.ok(start >= 0, `missing step: ${name}`);
|
|
const body = workflow.slice(start);
|
|
const runStart = body.indexOf(" run: |\n");
|
|
assert.ok(runStart >= 0, `missing script: ${name}`);
|
|
const script = body.slice(runStart + " run: |\n".length).split("\n - name:")[0];
|
|
return script.split("\n").map((line) => line.replace(/^ {10}/, "")).join("\n");
|
|
}
|
|
|
|
function executeStep(name, overrides = {}) {
|
|
const directory = mkdtempSync(path.join(tmpdir(), "paperclip-routing-test-"));
|
|
const output = path.join(directory, "output");
|
|
try {
|
|
const gh = path.join(directory, "gh");
|
|
writeFileSync(gh, githubStub);
|
|
chmodSync(gh, 0o700);
|
|
const result = spawnSync("bash", ["-c", stepScript(name)], {
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PATH: `${directory}:${process.env.PATH}`,
|
|
GITHUB_OUTPUT: output,
|
|
RUNNER_TEMP: directory,
|
|
TEST_OWNER_HELPER: path.resolve("scripts/ci-profile/authorize.mjs"),
|
|
TEST_CODEOWNERS_BASE64: Buffer.from(".github/** @ci-owner\n").toString("base64"),
|
|
AWS_CI_ENABLED: "true",
|
|
TRUSTED_USER_IDS: "[34892728]",
|
|
EVENT_NAME: "pull_request",
|
|
EVENT_REPOSITORY: "paperclipai/paperclip",
|
|
EVENT_REPOSITORY_ID: "1170821064",
|
|
EVENT_ACTION: "synchronize",
|
|
EVENT_PR_NUMBER: "12350",
|
|
EVENT_PR_AUTHOR_ID: "34892728",
|
|
EVENT_SENDER_ID: "34892728",
|
|
EVENT_BASE_REPOSITORY_ID: "1170821064",
|
|
EVENT_BASE_REF: "master",
|
|
EVENT_BASE_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
EVENT_HEAD_SHA: "1111111111111111111111111111111111111111",
|
|
EVENT_MERGE_SHA: "2222222222222222222222222222222222222222",
|
|
RUN_ID: "123456789",
|
|
RUN_ATTEMPT: "1",
|
|
...overrides,
|
|
},
|
|
});
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const lines = readFileSync(output, "utf8").trim().split("\n");
|
|
assert.equal(lines.length, 1, "each step must emit exactly one decision");
|
|
return Object.fromEntries(lines.map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
}));
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const awsRunner = "runs-on/fleet=paperclip-public-pr-x64/env=public-ci";
|
|
const stackedBase = {
|
|
EVENT_BASE_REF: "trusted-stack-base",
|
|
TEST_BASE_REF: "trusted-stack-base",
|
|
TEST_PR_BASE_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_LIVE_BASE_REF_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_SYNTHETIC_BASE_MERGE_SHA: "8888888888888888888888888888888888888888",
|
|
TEST_EVENT_MERGE_BASE_SHA: "8888888888888888888888888888888888888888",
|
|
TEST_LIVE_MERGE_BASE_SHA: "8888888888888888888888888888888888888888",
|
|
};
|
|
|
|
const routeCases = [
|
|
["allowlisted PR", {}, awsRunner],
|
|
["allowlisted profile label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "profile-ci" }, awsRunner],
|
|
["unrelated label", { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: "documentation" }],
|
|
["validated stacked base", stackedBase, awsRunner],
|
|
["lagging PR API base snapshot", { TEST_PR_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, awsRunner],
|
|
["base advances while the routing job waits", { TEST_LIVE_BASE_REF_SHA: "7777777777777777777777777777777777777777" }, awsRunner],
|
|
["merge uses a newer base snapshot than the event payload", {
|
|
TEST_LIVE_BASE_REF_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_INTERMEDIATE_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
TEST_EVENT_MERGE_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
}, awsRunner],
|
|
["GitHub regenerates the live merge after base advancement", {
|
|
TEST_LIVE_BASE_REF_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_LIVE_MERGE_BASE_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_LIVE_MERGE_TREE_SHA: "6666666666666666666666666666666666666666",
|
|
}, awsRunner],
|
|
["live merge is temporarily unavailable", { TEST_PR_MERGE_SHA: "unavailable" }, awsRunner],
|
|
["stacked base advances while the routing job waits", {
|
|
...stackedBase,
|
|
TEST_SYNTHETIC_BASE_REF_PARENT_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_INTERMEDIATE_BASE_SHA: "7777777777777777777777777777777777777777",
|
|
TEST_LIVE_BASE_REF_SHA: "cccccccccccccccccccccccccccccccccccccccc",
|
|
}, awsRunner],
|
|
["merge parent outside the base branch history", { TEST_EVENT_MERGE_BASE_SHA: "cccccccccccccccccccccccccccccccccccccccc" }],
|
|
["stacked base parent descends from the event base but diverges from the live base", {
|
|
...stackedBase,
|
|
TEST_SIDE_BRANCH_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
TEST_SYNTHETIC_BASE_REF_PARENT_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
}],
|
|
["base history lookup unavailable", { TEST_COMPARE_FAIL: "1" }],
|
|
["disabled AWS", { AWS_CI_ENABLED: "false" }],
|
|
["missing AWS variables", { AWS_CI_ENABLED: "", TRUSTED_USER_IDS: "" }],
|
|
["unlisted author", { EVENT_PR_AUTHOR_ID: "99999999" }],
|
|
["unlisted sender", { EVENT_SENDER_ID: "99999999" }],
|
|
["unlisted rerun actor", { TEST_TRIGGERING_ACTOR_ID: "99999999" }],
|
|
["allowlisted user removed from CODEOWNERS", { TEST_CI_OWNER_ID: "99999999" }],
|
|
["CODEOWNER not on numeric allowlist", { TRUSTED_USER_IDS: "[99999999]" }],
|
|
["non-owner PR with owner rerun", { EVENT_PR_AUTHOR_ID: "99999999", TEST_PR_AUTHOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
|
|
["non-owner event sender", { EVENT_SENDER_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
|
|
["non-owner rerun actor", { TEST_TRIGGERING_ACTOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
|
|
["CODEOWNERS unavailable", { TEST_CODEOWNERS_FAIL: "1" }],
|
|
["owner identity unavailable", { TEST_OWNER_LOOKUP_FAIL: "1" }],
|
|
["owner is not a person", { TEST_CI_OWNER_TYPE: "Bot" }],
|
|
["trusted helper unavailable", { TEST_OWNER_HELPER_FAIL: "1" }],
|
|
["trusted master unavailable", { TEST_MASTER_FAIL: "1" }],
|
|
["invalid trusted master", { TEST_TRUSTED_MASTER_SHA: "bad" }],
|
|
["documentation owners do not authorize CI", { TEST_CODEOWNERS_BASE64: Buffer.from("doc/** @ci-owner\n").toString("base64") }],
|
|
["ambiguous CODEOWNERS deny access", { TEST_CODEOWNERS_BASE64: Buffer.from(".github/** @ci-owner\n/.github/** @ci-owner\n").toString("base64") }],
|
|
["malformed run attempt", { RUN_ATTEMPT: "bad" }],
|
|
["wrong run attempt", { TEST_RUN_ATTEMPT: "2" }],
|
|
["owner rerun validates its own attempt", { RUN_ATTEMPT: "2", TEST_RUN_ATTEMPT: "2" }, awsRunner],
|
|
["missing base", { EVENT_BASE_REF: "" }],
|
|
["wrong base repository", { EVENT_BASE_REPOSITORY_ID: "999999999" }],
|
|
["replaced base ancestry", { TEST_BASE_COMPARISON_MERGE_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }],
|
|
["stale head", { TEST_PR_HEAD_SHA: "3333333333333333333333333333333333333333" }],
|
|
["event merge has the wrong head", { TEST_EVENT_MERGE_HEAD_SHA: "3333333333333333333333333333333333333333" }],
|
|
["stacked base with wrong parent", { ...stackedBase, TEST_SYNTHETIC_BASE_REF_PARENT_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }],
|
|
["event merge has an invalid tree", { TEST_EVENT_MERGE_TREE_SHA: "invalid" }],
|
|
["wrong repository", { EVENT_REPOSITORY_ID: "999999999" }],
|
|
["malformed allowlist", { TRUSTED_USER_IDS: '["34892728"]' }],
|
|
["manual dispatch", { EVENT_NAME: "workflow_dispatch" }],
|
|
["unsupported PR action", { EVENT_ACTION: "closed" }],
|
|
["malformed commit SHA", { EVENT_HEAD_SHA: "invalid" }],
|
|
];
|
|
|
|
for (const [name, env, expected = "ubuntu-latest"] of routeCases) {
|
|
test(`runner decision: ${name}`, () => {
|
|
assert.equal(executeStep("Validate PR identity and select runner", env).runner, expected);
|
|
});
|
|
}
|
|
|
|
const scopeCases = [
|
|
["ordinary PR", null, "master", "true"],
|
|
["top stack PR", { position: 11, size: 11, base: { ref: "master" } }, "stack-10", "true"],
|
|
["lowest unmerged PR", { position: 1, size: 11, base: { ref: "master" } }, "master", "true"],
|
|
["middle stack PR", { position: 6, size: 11, base: { ref: "master" } }, "stack-5", "false"],
|
|
["malformed position", { position: "invalid", size: 11, base: { ref: "master" } }, "stack-5", "true"],
|
|
["out-of-range position", { position: 12, size: 11, base: { ref: "master" } }, "stack-5", "true"],
|
|
["missing stack base", { position: 6, size: 11 }, "stack-5", "true"],
|
|
["empty metadata", {}, "master", "true"],
|
|
];
|
|
|
|
for (const [name, stack, base, expected] of scopeCases) {
|
|
test(`scope decision: ${name}`, () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify(stack), PR_BASE_REF: base,
|
|
}).full_ci, expected);
|
|
});
|
|
}
|
|
|
|
const files = (...paths) => JSON.stringify(paths.map((filename) => ({ filename })));
|
|
|
|
test("full-suite requests override both middle-stack and release-notes-only skips", () => {
|
|
for (const overrides of [
|
|
{ STACK_JSON: JSON.stringify({ position: 2, size: 3, base: { ref: "master" } }), PR_BASE_REF: "stack-1" },
|
|
{ STACK_JSON: "null", PR_BASE_REF: "master", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") },
|
|
]) {
|
|
assert.equal(executeStep("Select PR CI scope", { ...overrides, PROFILE_CI: "false" }).full_ci, "false");
|
|
for (const request of [{ PROFILE_CI: "true" }, { FORCE_FULL_CI: "true" }, { CI_INCREMENTAL_MODE: "full" }]) {
|
|
assert.equal(executeStep("Select PR CI scope", { ...overrides, ...request }).full_ci, "true");
|
|
}
|
|
}
|
|
});
|
|
const manyReleaseNotes = JSON.stringify(Array.from({ length: 300 }, (_, index) => ({
|
|
filename: `releases/beta/v2026.1012.${index}-beta.0.md`,
|
|
})));
|
|
|
|
const pathScopeCases = [
|
|
["stable release notes", { TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") }, "false"],
|
|
["beta release notes draft", { TEST_COMPARE_FILES: files("releases/beta/v2026.1012.1-beta.0.md") }, "false"],
|
|
["stable and beta notes", { TEST_COMPARE_FILES: files("releases/v2026.1015.0.md", "releases/beta/v2026.1012.1-beta.0.md") }, "false"],
|
|
["removed release notes", { TEST_COMPARE_FILES: JSON.stringify([{ filename: "releases/beta/v2026.1006.0-beta.0.md", status: "removed" }]) }, "false"],
|
|
["release notes and code", { TEST_COMPARE_FILES: files("releases/v2026.1015.0.md", "server/src/index.ts") }, "true"],
|
|
["code renamed into releases", { TEST_COMPARE_FILES: JSON.stringify([{ filename: "releases/v2026.1015.0.md", previous_filename: "scripts/release.sh" }]) }, "true"],
|
|
["non-markdown file in releases", { TEST_COMPARE_FILES: files("releases/beta/v2026.1012.1-beta.0.json") }, "true"],
|
|
["nested releases directory", { TEST_COMPARE_FILES: files("releases/beta/drafts/v2026.1012.1-beta.0.md") }, "true"],
|
|
["markdown outside releases", { TEST_COMPARE_FILES: files("doc/RELEASING.md") }, "true"],
|
|
["file entry without a name", { TEST_COMPARE_FILES: JSON.stringify([{ status: "modified" }]) }, "true"],
|
|
["empty diff", { TEST_COMPARE_FILES: "[]" }, "true"],
|
|
["possibly truncated file list", { TEST_COMPARE_FILES: manyReleaseNotes }, "true"],
|
|
["compare lookup failure", { TEST_COMPARE_FAIL: "1", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") }, "true"],
|
|
["non-PR event", { EVENT_NAME: "workflow_dispatch", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") }, "true"],
|
|
["malformed head SHA", { EVENT_HEAD_SHA: "invalid", TEST_COMPARE_FILES: files("releases/v2026.1015.0.md") }, "true"],
|
|
];
|
|
|
|
for (const [name, env, expected] of pathScopeCases) {
|
|
test(`path scope decision: ${name}`, () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: "null", PR_BASE_REF: "master", ...env,
|
|
}).full_ci, expected);
|
|
});
|
|
}
|
|
|
|
test("path scope keeps full CI for the top PR in a stack that touches code", () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify({ position: 11, size: 11, base: { ref: "master" } }),
|
|
PR_BASE_REF: "stack-10",
|
|
}).full_ci, "true");
|
|
});
|
|
|
|
test("path scope keeps full CI for release notes on top of code-changing stacked PRs", () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify({ position: 11, size: 11, base: { ref: "master" } }),
|
|
PR_BASE_REF: "stack-10",
|
|
TEST_COMPARE_FILES: files("releases/v2026.1015.0.md"),
|
|
}).full_ci, "true");
|
|
});
|
|
|
|
test("path scope keeps full CI for release notes with malformed stack metadata", () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify({ position: 0, size: 2, base: { ref: "master" } }),
|
|
PR_BASE_REF: "master",
|
|
TEST_COMPARE_FILES: files("releases/v2026.1015.0.md"),
|
|
}).full_ci, "true");
|
|
});
|
|
|
|
test("path scope skips full CI for the lowest PR in a stack that only touches release notes", () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify({ position: 1, size: 3, base: { ref: "master" } }),
|
|
PR_BASE_REF: "master",
|
|
TEST_COMPARE_FILES: files("releases/v2026.1015.0.md"),
|
|
}).full_ci, "false");
|
|
});
|
|
|
|
test("path scope skips full CI for a single-PR stack that only touches release notes", () => {
|
|
assert.equal(executeStep("Select PR CI scope", {
|
|
STACK_JSON: JSON.stringify({ position: 1, size: 1, base: { ref: "master" } }),
|
|
PR_BASE_REF: "master",
|
|
TEST_COMPARE_FILES: files("releases/v2026.1015.0.md"),
|
|
}).full_ci, "false");
|
|
});
|
|
|
|
function routingPlacement(vars = {}, github = {}) {
|
|
const source = "[34892728]";
|
|
const expression = jobs.match(/^ {4}runs-on: \$\{\{ (.+) \}\}$/m)?.[1];
|
|
assert.ok(expression, "routing expression is missing");
|
|
return vm.runInNewContext(expression, {
|
|
vars: {
|
|
AWS_CI_ENABLED: "true",
|
|
AWS_CI_TRUSTED_USER_IDS: source,
|
|
AWS_CI_ROUTING_USER_IDS_SOURCE: source,
|
|
AWS_CI_ROUTING_USER_IDS: "|34892728|",
|
|
...vars,
|
|
},
|
|
github: {
|
|
repository: "paperclipai/paperclip",
|
|
repository_id: "1170821064",
|
|
event_name: "pull_request",
|
|
ref: "refs/heads/master",
|
|
actor_id: "34892728",
|
|
event: { pull_request: { user: { id: 34892728 } }, sender: { id: 34892728 } },
|
|
...github,
|
|
},
|
|
contains: (search, item) => String(search).toLowerCase().includes(String(item).toLowerCase()),
|
|
format: (template, ...args) => template.replace(/\{(\d+)\}/g, (_, index) => String(args[Number(index)])),
|
|
}, { timeout: 1000 });
|
|
}
|
|
|
|
const placementCases = [
|
|
["approved PR", {}, {}, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"],
|
|
["unapproved author", {}, { event: { pull_request: { user: { id: 999 } }, sender: { id: 34892728 } } }],
|
|
["unapproved sender", {}, { event: { pull_request: { user: { id: 34892728 } }, sender: { id: 999 } } }],
|
|
["partial ID cannot match", {}, { event: { pull_request: { user: { id: 4892728 } }, sender: { id: 34892728 } } }],
|
|
["invalid JSON keeps hosted fallback", { AWS_CI_TRUSTED_USER_IDS: "[34892728,]" }, {}],
|
|
["stale snapshot", { AWS_CI_TRUSTED_USER_IDS: "[34892728,999]" }, {}],
|
|
["missing snapshot", { AWS_CI_ROUTING_USER_IDS_SOURCE: "" }, {}],
|
|
["missing admission members", { AWS_CI_ROUTING_USER_IDS: "" }, {}],
|
|
["disabled routing", { AWS_CI_ENABLED: "false" }, {}],
|
|
["different repository", {}, { repository: "other/repo" }],
|
|
["different repository ID", {}, { repository_id: "999" }],
|
|
["approved manual master canary", {}, { event_name: "workflow_dispatch" }, "runs-on/fleet=paperclip-ci-routing-x64/env=public-ci"],
|
|
["unapproved manual actor", {}, { event_name: "workflow_dispatch", actor_id: "999" }],
|
|
["manual branch canary", {}, { event_name: "workflow_dispatch", ref: "refs/heads/other" }],
|
|
];
|
|
|
|
for (const [name, vars, github, expected = "ubuntu-latest"] of placementCases) {
|
|
test(`placement before allocation: ${name}`, () => {
|
|
assert.equal(routingPlacement(vars, github), expected);
|
|
});
|
|
}
|