Files
PaperClipAI/.github/scripts/tests/ci-routing.test.mjs
T
DottaandPaperclip 61e8d5c7eb fix(ci): keep trusted full-ci label runs on AWS (#18855)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Public pull requests need CI without access to private runner
capacity.
> - Trusted CI CODEOWNERS use the AWS runner fleet after identity and
revision checks.
> - The PR caller accepts both `profile-ci` and `full-ci` label events.
> - The runner gate accepted only `profile-ci`, so `full-ci` sent
trusted work to GitHub.
> - This change accepts both labels and keeps the existing authorization
checks.

## Linked Issues or Issue Description

Refs #18821. Observed on #18848 in [run
41711436968](https://github.com/paperclipai/paperclip/actions/runs/41711436968).

**What happened?**

A CODEOWNER added `full-ci` to a trusted PR. The metadata gate ran on
AWS, then sent the test matrix to GitHub with the notice `only the
profile-ci label can request a profiling run`.

**Expected behavior**

Both supported labels keep authorized CI on AWS. A label must not grant
an outside contributor access to AWS.

**Steps to reproduce**

Add `full-ci` to an open PR whose author and event sender satisfy the CI
owner and numeric allowlist checks. Before this change, the runner
decision returns `ubuntu-latest` before it checks those identities.

## What Changed

- Accept `full-ci` and `profile-ci` label events in the trusted runner
gate.
- Test both labels with trusted users, unlisted users, and
numeric-allowlisted users who are not CI owners.
- Document that label selection changes CI scope and preserves runner
authorization.

## Verification

- The new trusted `full-ci` regression failed before the fix with
`ubuntu-latest` and passes after the fix.
- `node --test .github/scripts/tests/ci-routing.test.mjs
.github/scripts/tests/ci-plan.test.mjs
.github/scripts/tests/ci-profile.test.mjs
scripts/__tests__/e2e-shard.test.mjs`: 147 passed.
- `actionlint -shellcheck='' .github/workflows/ci-routing.yml`: passed.
- `git diff --check`: passed.
- This isolated checkout has no installed workspace dependencies.
Repository-wide typecheck, tests, and build are delegated to this PR's
CI; local verification covers the changed routing and its callers.
- A read-only replay against this PR's real GitHub identities and
commits returned GitHub with the old `full-ci` guard and AWS with the
corrected guard. No PR code was executed by the replay.
- Greptile reviewed current head
`47f7159f67c8633830d7732cb68c6d43d8243fe5`: 5/5, successful check, no
unresolved review threads. Both incremental-CI qualification jobs and
commitperclip review passed.
- Full CI is not green. Attempt 1 lost workers to verified Spot
reclamations in `m5.xlarge/us-east-1c`. After excluding that type from
new placements, one failed-job retry retained 19 successful checks and
reran 27 interrupted checks on AWS. The policy check passed, including
these regressions. AWS then confirmed seven more reclaimed
`m8a.xlarge/us-east-1c` workers and two reclaimed
`m8i-flex.xlarge/us-east-1b` workers. The affected checks show runner
shutdowns; unaffected jobs remain running. [Recovery
attempt](https://github.com/paperclipai/paperclip/actions/runs/41714303495/attempts/2).

## Risks

This changes the event allowlist before authorization. The author, event
sender, rerun actor, repository, CODEOWNERS, numeric allowlist, and
immutable revision checks still apply. Unrelated labels remain denied.
Separate helper workflows that explicitly choose GitHub runners are
outside this change.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, shell, Git, and GitHub API
tools. The runtime does not expose a more specific model build ID 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>
2026-11-03 15:09:36 -06:00

333 lines
23 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],
...["profile-ci", "full-ci"].flatMap((label) => {
const event = { EVENT_ACTION: "labeled", EVENT_LABEL_NAME: label };
return [
[`allowlisted ${label} label`, event, awsRunner],
[`${label} with unlisted author`, { ...event, EVENT_PR_AUTHOR_ID: "99999999" }],
[`${label} with unlisted sender`, { ...event, EVENT_SENDER_ID: "99999999" }],
[`${label} with unlisted rerun actor`, { ...event, TEST_TRIGGERING_ACTOR_ID: "99999999" }],
[`${label} on non-owner PR with owner sender and rerun`, {
...event, EVENT_PR_AUTHOR_ID: "99999999", TEST_PR_AUTHOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]",
}],
[`${label} with non-owner sender`, { ...event, EVENT_SENDER_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
[`${label} with non-owner rerun actor`, { ...event, TEST_TRIGGERING_ACTOR_ID: "99999999", TRUSTED_USER_IDS: "[34892728,99999999]" }],
];
}),
["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);
});
}