mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Merge current master tooling into qualified idle ingress
This commit is contained in:
commit
3907c4be45
23 files changed
+691
-97
No files matched your search
@@ -4,15 +4,17 @@ 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 hosted capacity and runs no PR code or actions", () => {
|
||||
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.equal(placement, "ubuntu-latest", "verify current owners before allocating any AWS runner");
|
||||
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);
|
||||
});
|
||||
@@ -24,7 +26,7 @@ test("routing exports both placement and stack scope to callers", () => {
|
||||
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 default_files='[{\"filename\":\"server/src/index.ts\"}]'\n jq -cn \\\n --arg merge_base_sha \"${TEST_BASE_COMPARISON_MERGE_BASE_SHA:-$EVENT_BASE_SHA}\" \\\n --arg status \"${TEST_BASE_COMPARISON_STATUS:-ahead}\" \\\n --argjson files \"${TEST_COMPARE_FILES:-$default_files}\" '{\n merge_base_commit: {sha: $merge_base_sha},\n status: $status,\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";
|
||||
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
Line truncated
|
||||
|
||||
function stepScript(name) {
|
||||
const start = workflow.indexOf(` - name: ${name}\n`);
|
||||
@@ -100,6 +102,31 @@ const routeCases = [
|
||||
["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" }],
|
||||
@@ -125,9 +152,9 @@ const routeCases = [
|
||||
["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" }],
|
||||
["changed merge head", { TEST_LIVE_MERGE_HEAD_SHA: "3333333333333333333333333333333333333333" }],
|
||||
["stacked base with wrong parent", { ...stackedBase, TEST_SYNTHETIC_BASE_REF_PARENT_SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }],
|
||||
["changed merge tree", { TEST_LIVE_MERGE_TREE_SHA: "6666666666666666666666666666666666666666" }],
|
||||
["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" }],
|
||||
@@ -241,3 +268,52 @@ test("path scope skips full CI for a single-PR stack that only touches release n
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const fleet = "runs-on/fleet=paperclip-post-merge-x64/env=public-ci";
|
||||
const sha = "a".repeat(40);
|
||||
const base = {
|
||||
repository: "paperclipai/paperclip", repository_id: "1170821064",
|
||||
ref: "refs/heads/master", event_name: "push", sha,
|
||||
ref: "refs/heads/master", event_name: "push", sha, run_attempt: 1,
|
||||
};
|
||||
const expectedJobs = {
|
||||
"cloud-readiness.yml": [],
|
||||
@@ -58,6 +58,11 @@ for (const [file, expectedNames] of Object.entries(expectedJobs)) {
|
||||
cases.push({ name: "preview of master", inputs: { channel: "preview" } });
|
||||
cases.push({ name: "stable release", inputs: { channel: "stable" } });
|
||||
}
|
||||
cases.push({ name: "first attempt as a string", github: { run_attempt: "1" }, expected: fleet });
|
||||
cases.push({ name: "rerun after runner interruption", github: { run_attempt: 2 } });
|
||||
cases.push({ name: "later rerun", github: { run_attempt: 3 } });
|
||||
cases.push({ name: "rerun attempt as a string", github: { run_attempt: "2" } });
|
||||
cases.push({ name: "missing attempt", github: { run_attempt: undefined } });
|
||||
for (const { name, github = {}, inputs: overrides = {}, enabled = "true", expected = "ubuntu-latest" } of cases) {
|
||||
test(`${file}/${job}: ${name}`, () => {
|
||||
const context = { github: { ...defaultContext, ...github }, inputs: { ...inputs, ...overrides }, vars: { AWS_POST_MERGE_CI_ENABLED: enabled } };
|
||||
|
||||
@@ -21,9 +21,7 @@ permissions:
|
||||
jobs:
|
||||
gate:
|
||||
name: Select trusted runner
|
||||
# No AWS capacity is allocated until current CI CODEOWNERS are verified.
|
||||
# A cached numeric allowlist alone can outlive removal from CODEOWNERS.
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.AWS_CI_ENABLED == 'true' && vars.AWS_CI_ROUTING_USER_IDS_SOURCE != '' && vars.AWS_CI_TRUSTED_USER_IDS == vars.AWS_CI_ROUTING_USER_IDS_SOURCE && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && ((github.event_name == 'pull_request' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.pull_request.user.id)) && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.event.sender.id))) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' && contains(vars.AWS_CI_ROUTING_USER_IDS, format('|{0}|', github.actor_id)))) && 'runs-on/fleet=paperclip-ci-routing-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
plan: ${{ steps.plan.outputs.plan }}
|
||||
@@ -145,9 +143,6 @@ jobs:
|
||||
' <<< "$pr_json" >/dev/null 2>&1 \
|
||||
|| fail_closed 'current pull request state does not match the triggering event'
|
||||
|
||||
live_merge_sha="$(jq -r '.merge_commit_sha // empty' <<< "$pr_json")"
|
||||
is_commit_sha "$live_merge_sha" || fail_closed 'current pull request has no valid merge SHA'
|
||||
|
||||
base_ref_json="$(gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||||
@@ -174,12 +169,6 @@ jobs:
|
||||
"/repos/paperclipai/paperclip/git/commits/$EVENT_MERGE_SHA" 2>/dev/null)" \
|
||||
|| fail_closed 'could not inspect the event merge commit'
|
||||
|
||||
live_merge_json="$(gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||||
"/repos/paperclipai/paperclip/git/commits/$live_merge_sha" 2>/dev/null)" \
|
||||
|| fail_closed 'could not inspect the current merge commit'
|
||||
|
||||
validate_merge_commit() {
|
||||
local merge_json="$1"
|
||||
local expected_sha="$2"
|
||||
@@ -195,37 +184,50 @@ jobs:
|
||||
}
|
||||
|
||||
validate_merge_commit "$event_merge_json" "$EVENT_MERGE_SHA" \
|
||||
|| fail_closed 'event merge commit does not match the current base and head'
|
||||
validate_merge_commit "$live_merge_json" "$live_merge_sha" \
|
||||
|| fail_closed 'current merge commit does not match the triggering base and head'
|
||||
|| fail_closed 'event merge commit does not match the triggering head'
|
||||
|
||||
is_base_snapshot() {
|
||||
local snapshot_sha="$1"
|
||||
local comparison
|
||||
[[ "$snapshot_sha" == "$EVENT_BASE_SHA" || "$snapshot_sha" == "$live_base_ref_sha" ]] && return 0
|
||||
comparison="$(gh api "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$snapshot_sha" 2>/dev/null)" || return 1
|
||||
jq -e --arg base "$EVENT_BASE_SHA" '
|
||||
.merge_base_commit.sha == $base and
|
||||
(.status == "ahead" or .status == "identical")
|
||||
' <<< "$comparison" >/dev/null 2>&1 || return 1
|
||||
comparison="$(gh api "/repos/paperclipai/paperclip/compare/$snapshot_sha...$live_base_ref_sha" 2>/dev/null)" || return 1
|
||||
jq -e --arg base "$snapshot_sha" '
|
||||
.merge_base_commit.sha == $base and
|
||||
(.status == "ahead" or .status == "identical")
|
||||
' <<< "$comparison" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
event_merge_parent="$(jq -r '.parents[0].sha' <<< "$event_merge_json")"
|
||||
live_merge_parent="$(jq -r '.parents[0].sha' <<< "$live_merge_json")"
|
||||
[[ "$event_merge_parent" == "$live_merge_parent" ]] \
|
||||
|| fail_closed 'current merge commit uses a different base merge parent'
|
||||
|
||||
if [[ "$event_merge_parent" != "$live_base_ref_sha" ]]; then
|
||||
if ! is_base_snapshot "$event_merge_parent"; then
|
||||
base_merge_json="$(gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
||||
"/repos/paperclipai/paperclip/git/commits/$event_merge_parent" 2>/dev/null)" \
|
||||
|| fail_closed 'could not inspect the stacked base merge commit'
|
||||
jq -e \
|
||||
--arg expected_sha "$event_merge_parent" \
|
||||
--arg live_base_ref_sha "$live_base_ref_sha" '
|
||||
--arg expected_sha "$event_merge_parent" '
|
||||
.sha == $expected_sha and
|
||||
(.parents | length) == 2 and
|
||||
any(.parents[]; .sha == $live_base_ref_sha) and
|
||||
all(.parents[]; .sha | test("^[0-9a-f]{40}$")) and
|
||||
(.tree.sha | test("^[0-9a-f]{40}$"))
|
||||
' <<< "$base_merge_json" >/dev/null 2>&1 \
|
||||
|| fail_closed 'stacked base merge commit does not contain the current base branch'
|
||||
|| fail_closed 'invalid stacked base merge commit'
|
||||
base_snapshot_found=false
|
||||
while read -r parent_sha; do
|
||||
if is_base_snapshot "$parent_sha"; then
|
||||
base_snapshot_found=true
|
||||
break
|
||||
fi
|
||||
done < <(jq -r '.parents[].sha' <<< "$base_merge_json")
|
||||
[[ "$base_snapshot_found" == 'true' ]] \
|
||||
|| fail_closed 'stacked base merge commit does not contain a validated base snapshot'
|
||||
fi
|
||||
|
||||
event_merge_tree="$(jq -r '.tree.sha' <<< "$event_merge_json")"
|
||||
live_merge_tree="$(jq -r '.tree.sha' <<< "$live_merge_json")"
|
||||
[[ "$event_merge_tree" == "$live_merge_tree" ]] \
|
||||
|| fail_closed 'current merge tree differs from the triggering merge tree'
|
||||
|
||||
is_positive_integer "$RUN_ID" || fail_closed 'invalid workflow run ID'
|
||||
is_positive_integer "$RUN_ATTEMPT" || fail_closed 'invalid workflow run attempt'
|
||||
run_json="$(gh api \
|
||||
|
||||
@@ -421,6 +421,9 @@ jobs:
|
||||
PAPERCLIP_TYPECHECK_LOW_MEMORY: "1"
|
||||
run: pnpm --filter @paperclipai/server typecheck
|
||||
|
||||
- name: Typecheck test projects against the server declarations
|
||||
run: node scripts/typecheck-test-projects.mjs --low-memory --reuse-declarations
|
||||
|
||||
- name: Verify repository-wide CLI guidance safety
|
||||
run: pnpm exec vitest run --project @paperclipai/server server/src/__tests__/cli-invocation-safety.test.ts
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
typecheck:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Typecheck
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
general_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: General tests (${{ matrix.group_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
serialized_tests:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Serialized tests (${{ matrix.shard_label }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
runner_workflow_evals:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Runner workflow eval scorer contract
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -313,7 +313,7 @@ jobs:
|
||||
verify_paperclip_runner:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Verify Paperclip Runner (${{ matrix.lane }})
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
build:
|
||||
if: ${{ !inputs.runner_binaries_only }}
|
||||
name: Build
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -94,11 +94,11 @@ jobs:
|
||||
# falls back to GitHub-hosted runners, including raced merge dispatches.
|
||||
name: Check preview artifacts
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && (inputs.channel == 'preview' || inputs.channel == 'cloud-migrator') && !inputs.dry_run
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# Preserve the previous hosted default; only AWS needs the Fleet limit.
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 10 || 360 }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 10 || 360 }}
|
||||
outputs:
|
||||
image: ${{ steps.plan.outputs.image }}
|
||||
packages: ${{ steps.plan.outputs.packages }}
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
name: Build preview migrator
|
||||
needs: plan_preview
|
||||
if: needs.plan_preview.outputs.packages == 'true'
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event_name == 'workflow_dispatch' && inputs.channel == 'cloud-migrator' && github.sha != '' && inputs.source_ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -20,8 +20,8 @@ concurrency:
|
||||
jobs:
|
||||
chaos_and_recovery:
|
||||
name: Restart, replay, trace, and recovery faults
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
|
||||
runs-on: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 'runs-on/fleet=paperclip-post-merge-x64/env=public-ci' || 'ubuntu-latest' }}
|
||||
timeout-minutes: ${{ vars.AWS_POST_MERGE_CI_ENABLED == 'true' && github.run_attempt == 1 && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.sha != '' && inputs.ref == github.sha && 40 || 45 }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
+58
-10
@@ -147,19 +147,26 @@ New workflow versions merged into master then receive runner access without a
|
||||
separate SHA allowlist update. Dependabot leaves this first-party reference on
|
||||
master.
|
||||
|
||||
Runner selection calls `ci-routing.yml@master` on a GitHub-hosted runner. It
|
||||
verifies authorization before allocating any AWS capacity and never checks out
|
||||
or executes PR code. The author, event sender and rerun actor must each be both
|
||||
Runner selection calls `ci-routing.yml@master`. Allowlisted maintainers use the
|
||||
reserved `paperclip-ci-routing-x64` AWS fleet for this bounded metadata job;
|
||||
other callers use GitHub-hosted capacity. The gate never checks out or executes
|
||||
PR code. The author, event sender and rerun actor must each be both
|
||||
in `AWS_CI_TRUSTED_USER_IDS` and a current owner of `.github/**` in CODEOWNERS.
|
||||
The resolver and CODEOWNERS are read from the same protected master revision;
|
||||
PR edits cannot change the trusted owner list. Documentation-only owners,
|
||||
unsupported owner rules and failed identity lookups do not authorize AWS.
|
||||
Repository, head and merge validation also apply. Missing or malformed settings
|
||||
Repository, head and merge validation also apply. The gate validates this run's
|
||||
immutable event merge, including its PR head and a base snapshot between the
|
||||
event's base and the current branch tip. Normal base advancement and regeneration
|
||||
of GitHub's live merge ref do not move an authorized run to GitHub-hosted CI.
|
||||
Stacked synthetic bases must contain a validated base snapshot. Rewritten base
|
||||
history, changed PR heads and unverifiable ancestry still deny AWS execution.
|
||||
Missing or malformed settings
|
||||
and unverifiable authorization use GitHub-hosted runners. A maintainer rerunning
|
||||
an outside contributor's PR does not grant it AWS access.
|
||||
Authorized test jobs and the final `verify` and `e2e` aggregates use the selected
|
||||
AWS fleet. The metadata authorization job remains hosted even for maintainers,
|
||||
so stale admission snapshots cannot allocate an AWS runner before verification.
|
||||
AWS fleet. A stale admission roster can allocate only the short metadata gate;
|
||||
the fresh CODEOWNERS check still denies PR execution for a removed owner.
|
||||
|
||||
The routing workflow also selects the CI scope. Middle PRs in a stack and PRs
|
||||
whose diff changes only release notes (`releases/v*.md` or
|
||||
@@ -339,10 +346,16 @@ explicit. When changing shard layout, update `.github/ci-task-matrix.json` and
|
||||
the full fallback matrices in `pr-trusted.yml`; the contract tests require them
|
||||
to agree. Add new cache-affecting environment settings to `globalEnv`.
|
||||
|
||||
The former reserved routing Fleet is no longer selected by this workflow.
|
||||
Keep its existing authorization until older assigned jobs finish. A manual
|
||||
dispatch of `ci-routing.yml` on master exercises the hosted metadata job without
|
||||
launching the test matrix; its non-PR event returns the hosted test-runner output.
|
||||
The `paperclip-ci-routing` runner group must allow
|
||||
`paperclipai/paperclip/.github/workflows/ci-routing.yml@refs/heads/master`.
|
||||
Keep `AWS_CI_ROUTING_USER_IDS_SOURCE` equal to the exact JSON value of
|
||||
`AWS_CI_TRUSTED_USER_IDS` and `AWS_CI_ROUTING_USER_IDS` as its delimiter-wrapped
|
||||
numeric IDs (for example, `|34892728|139239|`). Missing or mismatched admission
|
||||
settings use GitHub-hosted capacity for the gate. Current CODEOWNERS remain
|
||||
authoritative for PR execution regardless of these admission settings.
|
||||
A maintainer's manual dispatch of `ci-routing.yml` on master exercises the AWS
|
||||
metadata gate without launching the test matrix; its non-PR event returns the
|
||||
hosted test-runner output.
|
||||
|
||||
Keep the `.github/**` rule in `.github/CODEOWNERS` and the active master ruleset's
|
||||
code-owner review requirement enabled. This covers the caller, the trusted
|
||||
@@ -369,6 +382,13 @@ config; an explicit shared test database retains one partition per job.
|
||||
The Runner transport lease-expiry and cold-admission regressions live in their
|
||||
own file so those real-clock waits overlap other files in the existing pool.
|
||||
|
||||
Exact-source release verification uses the post-merge AWS fleet on the first
|
||||
attempt. Reruns of its verification and chaos jobs use GitHub-hosted runners.
|
||||
Exact-source cloud-migrator planning and package builds use the same policy.
|
||||
This gives interrupted jobs an independent recovery path. It keeps the same
|
||||
source, commands, assertions and required gates. A failed test still needs a fix;
|
||||
a successful rerun does not explain an earlier failure.
|
||||
|
||||
The two PR Runner Vitest jobs separate package tests from native server tests.
|
||||
The first runs the complete package inventory; the second runs the complete
|
||||
native server group. The existing `--shard=1/2` and `--shard=2/2` commands route
|
||||
@@ -1188,6 +1208,34 @@ slows `tsc` by up to 20 times when the live heap goes above the limit.
|
||||
Docker image sets `PAPERCLIP_TSC_EMIT_ONLY=1` for that build, which emits
|
||||
JavaScript without checks or declarations.
|
||||
|
||||
Test projects outside `pnpm -r` import server source through relative paths. Each
|
||||
project's `tsconfig.layers.json` reads that source as layer declarations. This
|
||||
command builds the declarations and then checks every project:
|
||||
|
||||
```sh
|
||||
node scripts/typecheck-test-projects.mjs
|
||||
```
|
||||
|
||||
Name a project to check only that project. `pnpm test:e2e:runner:typecheck` and
|
||||
`pnpm test:lifecycle-baseline:typecheck` do this for their projects. If the memory
|
||||
available to the task is limited, add `--low-memory`, or set
|
||||
`PAPERCLIP_TYPECHECK_LOW_MEMORY=1` for the pnpm commands.
|
||||
|
||||
The command fails if a project loads server source, because that is what makes
|
||||
these programs large. It does not limit memory. CI runs it with
|
||||
`--reuse-declarations` straight after the server typecheck, so it does not build
|
||||
the declarations again. Do not use that option when the declarations can be stale.
|
||||
|
||||
Add each new test project that imports server source to `PROJECTS` in
|
||||
`scripts/typecheck-test-projects.mjs`.
|
||||
|
||||
Keep the project references out of each project's `tsconfig.json`. Vitest reads
|
||||
that file, and it fails when a referenced config does not exist yet. The
|
||||
`tsconfig.json` files remain single programs for editors, and
|
||||
`tests/runner-e2e/native-completion-checks.mjs` still checks that project as one
|
||||
program. That program peaks at about 15 GiB at default settings and about 8 GiB on
|
||||
one thread.
|
||||
|
||||
#### Reused Runner build in the server build
|
||||
|
||||
The server build vendors `packages/paperclip-runner/dist`, so its
|
||||
|
||||
+7
-2
@@ -24,8 +24,13 @@ Ordinary PRs do not write timing artifacts or export traces. The exporter job is
|
||||
skipped for ordinary run names. Adding another label does not run the CI matrix.
|
||||
|
||||
Profiling preserves the existing trusted AWS runner routing. Eligible PR jobs
|
||||
use `paperclip-public-pr-x64`; the routing gate uses `ubuntu-latest` to verify
|
||||
current CI CODEOWNERS before allocating any AWS capacity.
|
||||
use `paperclip-public-pr-x64`. The synchronized maintainer admission roster
|
||||
places the metadata gate on the reserved `paperclip-ci-routing-x64` AWS fleet.
|
||||
The gate checks current CI CODEOWNERS before authorizing any PR code on AWS;
|
||||
PR edits cannot grant access. A stale roster can allocate only the bounded
|
||||
metadata gate. Normal base-branch advancement does not change an authorized
|
||||
run's placement. See [Trusted PR Workflow](DEVELOPING.md#trusted-pr-workflow)
|
||||
for the admission settings and immutable merge validation.
|
||||
The existing fail-closed GitHub-hosted fallback still applies if AWS routing
|
||||
cannot be authorized. The separate post-run Honeycomb exporter also uses
|
||||
`ubuntu-latest`; it is outside the measured PR suite.
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@
|
||||
"test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts",
|
||||
"test:e2e:runner:unit": "node tests/runner-e2e/run-unit.mjs",
|
||||
"test:e2e:runner:stock-harness": "node tests/runner-e2e/stock-harness-checks.mjs",
|
||||
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
|
||||
"test:e2e:runner:typecheck": "node scripts/typecheck-test-projects.mjs tests/runner-e2e",
|
||||
"test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts",
|
||||
"test:runner-workflow-evals": "pnpm --filter @paperclipai/paperclip-eval-kernel build && pnpm --filter @paperclipai/paperclip-runner test:runner-workflow-evals",
|
||||
"test:e2e:mcp-user-stories": "node scripts/e2e-mcp-user-stories.mjs",
|
||||
@@ -94,7 +94,7 @@
|
||||
"connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs",
|
||||
"test:lifecycle-baseline": "node tests/lifecycle-baseline/run.mjs",
|
||||
"test:lifecycle-baseline:support": "node --test tests/lifecycle-baseline/report.test.mjs",
|
||||
"test:lifecycle-baseline:typecheck": "tsc -p tests/lifecycle-baseline/tsconfig.json"
|
||||
"test:lifecycle-baseline:typecheck": "node scripts/typecheck-test-projects.mjs tests/lifecycle-baseline"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
|
||||
@@ -216,11 +216,11 @@ test("the trusted PR workflow keeps a stable aggregate check named e2e over the
|
||||
}
|
||||
});
|
||||
|
||||
test("runner authorization uses hosted capacity and executes metadata checks only", () => {
|
||||
test("runner authorization uses reserved capacity and executes metadata checks only", () => {
|
||||
const workflow = readFileSync(routingWorkflow, "utf8");
|
||||
const jobs = readWorkflowJobs(workflow.split(/^jobs:\n/m)[1]);
|
||||
assert.deepEqual([...jobs.keys()], ["gate"]);
|
||||
assert.match(jobs.get("gate"), /^ {4}runs-on: ubuntu-latest$/m);
|
||||
assert.match(jobs.get("gate"), /runs-on\/fleet=paperclip-ci-routing-x64\/env=public-ci/);
|
||||
assert.doesNotMatch(jobs.get("gate"), /uses:|actions\/checkout|secrets\./);
|
||||
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);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const STEPS = [
|
||||
["-r", "--workspace-concurrency=1", "typecheck"],
|
||||
];
|
||||
|
||||
function runPnpm(args, options) {
|
||||
export function runPnpm(args, options) {
|
||||
const result = spawnSync("pnpm", args, {
|
||||
...options,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
// Typechecks the test projects outside `pnpm -r`.
|
||||
//
|
||||
// Each project imports server source through relative paths, so its tsconfig.json
|
||||
// is one program over most of the server. Its tsconfig.layers.json reads that
|
||||
// source as the declarations from server/scripts/typecheck-layers.mjs, which this
|
||||
// script builds first. It then fails if a project still loads server source,
|
||||
// because that is what makes these programs large. See "Typecheck memory" in
|
||||
// doc/DEVELOPING.md.
|
||||
//
|
||||
// --reuse-declarations skips the build. Use it only straight after a server
|
||||
// typecheck on the same commit, because stale declarations pass silently.
|
||||
//
|
||||
// Usage: node scripts/typecheck-test-projects.mjs [--low-memory] [--reuse-declarations] [project ...]
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { LOW_MEMORY_VARIABLE, runTsc } from "./run-tsc.mjs";
|
||||
import { runPnpm } from "./typecheck-low-memory.mjs";
|
||||
import { isTestFile, typecheckLayers } from "../server/scripts/typecheck-layers.mjs";
|
||||
|
||||
export const PROJECTS = [
|
||||
"server/src/__tests__/helpers/maintenance",
|
||||
"tests/lifecycle-baseline",
|
||||
"tests/runner-e2e",
|
||||
"tests/speko-native",
|
||||
];
|
||||
|
||||
// The same steps as the server's typecheck script.
|
||||
export const PREPARE_STEPS = [
|
||||
["--filter", "@paperclipai/paperclip-runner", "run", "build:typescript"],
|
||||
["--filter", "@paperclipai/plugin-sdk", "ensure-build-deps"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Finds the server source in a program's file list, as paths relative to the
|
||||
* server. Test support files have no layer, and declaration and JSON files add
|
||||
* little, so only other source files count.
|
||||
*/
|
||||
export function findServerSource(files, serverRoot) {
|
||||
return files
|
||||
.map((file) => path.relative(serverRoot, file).split(path.sep).join("/"))
|
||||
.filter(
|
||||
(file) =>
|
||||
file.startsWith("src/") && /\.[cm]?[jt]sx?$/.test(file) && !/\.d\.[cm]?ts$/.test(file) && !isTestFile(file),
|
||||
);
|
||||
}
|
||||
|
||||
function listProgramFiles(config, { cwd, env }) {
|
||||
const tsc = path.join(cwd, "node_modules", "typescript", "bin", "tsc");
|
||||
const result = spawnSync(process.execPath, [tsc, "-p", config, "--listFilesOnly"], {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1 << 26,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return { status: result.status ?? 1, files: result.stdout.split("\n").filter(Boolean), output: result.stdout };
|
||||
}
|
||||
|
||||
export function typecheckTestProjects({
|
||||
root,
|
||||
projects = PROJECTS,
|
||||
reuseDeclarations = false,
|
||||
env = process.env,
|
||||
pnpm = runPnpm,
|
||||
run = runTsc,
|
||||
listFiles = listProgramFiles,
|
||||
log = console.log,
|
||||
logError = console.error,
|
||||
}) {
|
||||
const serverRoot = path.join(root, "server");
|
||||
if (!reuseDeclarations) {
|
||||
for (const args of PREPARE_STEPS) {
|
||||
const status = pnpm(args, { cwd: root, env });
|
||||
if (status !== 0) return status;
|
||||
}
|
||||
|
||||
const layers = typecheckLayers({
|
||||
serverRoot,
|
||||
declarationsOnly: true,
|
||||
run: (args, options) => run(args, { ...options, env }),
|
||||
log,
|
||||
});
|
||||
// Report the server's own errors first. They would repeat in each project.
|
||||
if (layers !== 0) return layers;
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
for (const project of projects) {
|
||||
log(`typecheck:test-projects: ${project}`);
|
||||
const config = path.posix.join(project, "tsconfig.layers.json");
|
||||
const { status, signal } = run(["-p", config], { cwd: root, env });
|
||||
if (signal) return status || 1;
|
||||
if (status !== 0) exitCode = status;
|
||||
|
||||
const listing = listFiles(config, { cwd: root, env });
|
||||
if (listing.status !== 0) {
|
||||
logError(`typecheck:test-projects: could not list the files of ${project}\n${listing.output ?? ""}`);
|
||||
exitCode ||= listing.status;
|
||||
continue;
|
||||
}
|
||||
const source = findServerSource(listing.files, serverRoot);
|
||||
if (source.length > 0) {
|
||||
logError(
|
||||
`typecheck:test-projects: ${project} loads ${source.length} server source files. ` +
|
||||
`It must read them as layer declarations. First files: ${source.slice(0, 5).join(", ")}`,
|
||||
);
|
||||
exitCode ||= 1;
|
||||
}
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const flags = args.filter((arg) => arg.startsWith("--"));
|
||||
const projects = args.filter((arg) => !arg.startsWith("--"));
|
||||
const unknownFlags = flags.filter((flag) => !["--low-memory", "--reuse-declarations"].includes(flag));
|
||||
const unknown = projects.filter((project) => !PROJECTS.includes(project));
|
||||
if (unknownFlags.length > 0) {
|
||||
console.error(`Unknown option: ${unknownFlags.join(", ")}`);
|
||||
process.exitCode = 2;
|
||||
} else if (unknown.length > 0) {
|
||||
console.error(`Unknown project: ${unknown.join(", ")}. Known projects: ${PROJECTS.join(", ")}`);
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
process.exitCode = typecheckTestProjects({
|
||||
root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
projects: projects.length > 0 ? projects : PROJECTS,
|
||||
reuseDeclarations: flags.includes("--reuse-declarations"),
|
||||
env: args.includes("--low-memory") ? { ...process.env, [LOW_MEMORY_VARIABLE]: "1" } : process.env,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,11 @@
|
||||
// server/tsconfig.json stays the single program that editors and `pnpm build`
|
||||
// use.
|
||||
//
|
||||
// Usage: node scripts/typecheck-layers.mjs
|
||||
// Projects outside the server read the source layers as declarations through a
|
||||
// `tsconfig.layers.json` that references tsconfig.declarations.json in the output
|
||||
// directory. --declarations-only builds those layers and skips the tests layer.
|
||||
//
|
||||
// Usage: node scripts/typecheck-layers.mjs [--declarations-only]
|
||||
|
||||
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -30,7 +34,7 @@ import { runTsc } from "../../scripts/run-tsc.mjs";
|
||||
const IMPORT_SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)["'](\.[^"']+)["']/g;
|
||||
const OUTPUT_DIRECTORY = path.join("node_modules", ".cache", "paperclip-typecheck");
|
||||
|
||||
const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__");
|
||||
export const isTestFile = (file) => /\.test\.tsx?$/.test(file) || file.split("/").includes("__tests__");
|
||||
// tsconfig.json excludes this tree. A test reaches its helpers through imports.
|
||||
const isExcludedFromProgram = (file) => file.startsWith("src/__tests__/");
|
||||
|
||||
@@ -212,8 +216,20 @@ export function layerConfig(layer, { emptyLayers = new Set() } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds the config other projects reference to read the source layers as declarations. */
|
||||
export function declarationsConfig(layers) {
|
||||
return {
|
||||
compilerOptions: { composite: true },
|
||||
files: [],
|
||||
references: layers
|
||||
.filter((layer) => layer.name !== "tests")
|
||||
.map((layer) => ({ path: `./tsconfig.${layer.name}.json` })),
|
||||
};
|
||||
}
|
||||
|
||||
export function typecheckLayers({
|
||||
serverRoot,
|
||||
declarationsOnly = false,
|
||||
run = runTsc,
|
||||
log = console.log,
|
||||
}) {
|
||||
@@ -236,8 +252,14 @@ export function typecheckLayers({
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(outputRoot, "tsconfig.declarations.json"),
|
||||
`${JSON.stringify(declarationsConfig(layers), null, 2)}\n`,
|
||||
);
|
||||
|
||||
let exitCode = 0;
|
||||
for (const layer of layers) {
|
||||
if (declarationsOnly && layer.name === "tests") continue;
|
||||
log(`typecheck:layers: ${layer.name} (${layer.sourceFiles.length} files)`);
|
||||
const config = path.join(OUTPUT_DIRECTORY, `tsconfig.${layer.name}.json`);
|
||||
const { status, signal } = run(["-p", config], { cwd: serverRoot });
|
||||
@@ -253,5 +275,6 @@ export function typecheckLayers({
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
process.exitCode = typecheckLayers({
|
||||
serverRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
|
||||
declarationsOnly: process.argv.includes("--declarations-only"),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -8,7 +8,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { emitOnlyTscArgs, lowMemoryTscArgs, runTsc } from "../../scripts/run-tsc.mjs";
|
||||
import { STEPS, typecheckLowMemory } from "../../scripts/typecheck-low-memory.mjs";
|
||||
import { layerConfig, listSourceFiles, planLayers, readImports, typecheckLayers } from "./typecheck-layers.mjs";
|
||||
import { findServerSource, PREPARE_STEPS, PROJECTS, typecheckTestProjects } from "../../scripts/typecheck-test-projects.mjs";
|
||||
import { declarationsConfig, layerConfig, listSourceFiles, planLayers, readImports, typecheckLayers } from "./typecheck-layers.mjs";
|
||||
|
||||
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcesOf = (layers, name) => layers.find((layer) => layer.name === name)?.sourceFiles;
|
||||
@@ -159,6 +160,16 @@ describe("layerConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("declarationsConfig", () => {
|
||||
it("references the source layers that exist, and not tests", () => {
|
||||
const config = declarationsConfig([{ name: "below" }, { name: "above" }, { name: "tests" }]);
|
||||
|
||||
expect(config.compilerOptions.composite).toBe(true);
|
||||
expect(config.files).toEqual([]);
|
||||
expect(config.references).toEqual([{ path: "./tsconfig.below.json" }, { path: "./tsconfig.above.json" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("typecheckLayers", () => {
|
||||
const roots = [];
|
||||
|
||||
@@ -202,6 +213,30 @@ describe("typecheckLayers", () => {
|
||||
expect(JSON.parse(readFileSync(path.join(output, "package.json"), "utf8"))).toEqual({ type: "module" });
|
||||
});
|
||||
|
||||
it("skips the tests layer when only declarations are needed", () => {
|
||||
const root = serverFixture();
|
||||
const configs = [];
|
||||
|
||||
const status = typecheckLayers({
|
||||
serverRoot: root,
|
||||
declarationsOnly: true,
|
||||
log: () => {},
|
||||
run: (args) => {
|
||||
configs.push(path.basename(args[1]));
|
||||
return { status: 0, signal: null };
|
||||
},
|
||||
});
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(configs).toEqual(["tsconfig.below.json", "tsconfig.cycle.json", "tsconfig.above.json"]);
|
||||
const output = path.join(root, "node_modules", ".cache", "paperclip-typecheck");
|
||||
expect(JSON.parse(readFileSync(path.join(output, "tsconfig.declarations.json"), "utf8")).references).toEqual([
|
||||
{ path: "./tsconfig.below.json" },
|
||||
{ path: "./tsconfig.cycle.json" },
|
||||
{ path: "./tsconfig.above.json" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports type errors from every layer", () => {
|
||||
const root = serverFixture();
|
||||
const configs = [];
|
||||
@@ -308,6 +343,57 @@ describe("typecheckLayers with the real compiler", () => {
|
||||
expect(existsSync(path.join(declarations, "route.test.d.ts"))).toBe(false);
|
||||
}, 60_000);
|
||||
|
||||
it("lets a project outside the server read the source layers as declarations", () => {
|
||||
const root = compilerFixture();
|
||||
const outside = path.join(root, "outside");
|
||||
mkdirSync(outside);
|
||||
writeFileSync(path.join(outside, "use.ts"), `import { route } from "../src/route.js";\nexport const used: number = route();\n`);
|
||||
const config = path.join(outside, "tsconfig.layers.json");
|
||||
writeFileSync(
|
||||
config,
|
||||
`${JSON.stringify({
|
||||
extends: "../tsconfig.json",
|
||||
compilerOptions: { noEmit: true, rootDir: "..", outDir: null },
|
||||
include: ["*.ts"],
|
||||
references: [{ path: "../node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }],
|
||||
})}\n`,
|
||||
);
|
||||
const buildDeclarations = () =>
|
||||
typecheckLayers({
|
||||
serverRoot: root,
|
||||
declarationsOnly: true,
|
||||
log: () => {},
|
||||
run: (args, { cwd }) => {
|
||||
const result = spawnSync(process.execPath, [tsc, ...args], { cwd, encoding: "utf8" });
|
||||
return { status: result.status ?? 1, signal: result.signal };
|
||||
},
|
||||
});
|
||||
const tscOutside = (...args) => spawnSync(process.execPath, [tsc, "-p", config, ...args], { cwd: root, encoding: "utf8" });
|
||||
|
||||
expect(buildDeclarations()).toBe(0);
|
||||
const passing = tscOutside();
|
||||
expect(passing.stdout).toBe("");
|
||||
expect(passing.status).toBe(0);
|
||||
const loaded = tscOutside("--listFilesOnly").stdout;
|
||||
expect(loaded).toContain("paperclip-typecheck/dts/route.d.ts");
|
||||
expect(loaded).not.toContain("src/route.ts");
|
||||
expect(findServerSource(loaded.split("\n").filter(Boolean), root)).toEqual([]);
|
||||
|
||||
const layered = readFileSync(config, "utf8");
|
||||
const { references: _references, ...withoutReferences } = JSON.parse(layered);
|
||||
writeFileSync(config, `${JSON.stringify(withoutReferences)}\n`);
|
||||
const allSource = tscOutside("--listFilesOnly").stdout.split("\n").filter(Boolean);
|
||||
expect(findServerSource(allSource, root)).toContain("src/route.ts");
|
||||
writeFileSync(config, layered);
|
||||
|
||||
writeFileSync(path.join(root, "src", "route.ts"), `import { one } from "./one.js";\nexport const route = () => String(one());\n`);
|
||||
expect(buildDeclarations()).toBe(0);
|
||||
const failing = tscOutside();
|
||||
expect(failing.stdout).toContain("outside/use.ts");
|
||||
expect(failing.stdout).toContain("TS2322");
|
||||
expect(failing.status).not.toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it("reports a changed export in the layers that depend on it", () => {
|
||||
const root = compilerFixture();
|
||||
writeFileSync(path.join(root, "src", "leaf.ts"), `export const renamedLeaf = 1;\n`);
|
||||
@@ -404,6 +490,203 @@ describe("typecheckLowMemory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("typecheckTestProjects", () => {
|
||||
const roots = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function repositoryFixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "typecheck-test-projects-"));
|
||||
roots.push(root);
|
||||
mkdirSync(path.join(root, "server", "src"), { recursive: true });
|
||||
writeFileSync(path.join(root, "server", "src", "leaf.ts"), "");
|
||||
writeFileSync(path.join(root, "server", "src", "one.ts"), `import "./two.js"; import "./leaf.js";`);
|
||||
writeFileSync(path.join(root, "server", "src", "two.ts"), `import "./one.js";`);
|
||||
writeFileSync(path.join(root, "server", "src", "route.ts"), `import "./one.js";`);
|
||||
writeFileSync(path.join(root, "server", "src", "route.test.ts"), `import "./route.js";`);
|
||||
return root;
|
||||
}
|
||||
|
||||
function runWith(
|
||||
root,
|
||||
{
|
||||
pnpmStatus = () => 0,
|
||||
tscResult = () => ({ status: 0, signal: null }),
|
||||
listFiles = () => ({ status: 0, files: [] }),
|
||||
...options
|
||||
} = {},
|
||||
) {
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
const status = typecheckTestProjects({
|
||||
root,
|
||||
listFiles,
|
||||
log: () => {},
|
||||
logError: (message) => errors.push(message),
|
||||
pnpm: (args, { env }) => {
|
||||
calls.push({ tool: "pnpm", args, env });
|
||||
return pnpmStatus(args);
|
||||
},
|
||||
run: (args, { env }) => {
|
||||
const target = path.basename(path.dirname(args[1])) === "paperclip-typecheck" ? path.basename(args[1]) : args[1];
|
||||
calls.push({ tool: "tsc", target, env });
|
||||
return tscResult(target);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
return { status, calls, errors };
|
||||
}
|
||||
|
||||
it("prepares, builds declarations without the tests layer, and then checks the projects", () => {
|
||||
const env = { MARKER: "1" };
|
||||
|
||||
const { status, calls } = runWith(repositoryFixture(), { projects: ["tests/a", "tests/b"], env });
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(calls.filter((call) => call.tool === "pnpm").map((call) => call.args)).toEqual(PREPARE_STEPS);
|
||||
expect(calls.filter((call) => call.tool === "tsc").map((call) => call.target)).toEqual([
|
||||
"tsconfig.below.json",
|
||||
"tsconfig.cycle.json",
|
||||
"tsconfig.above.json",
|
||||
"tests/a/tsconfig.layers.json",
|
||||
"tests/b/tsconfig.layers.json",
|
||||
]);
|
||||
expect(calls.every((call) => call.env === env)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops when the preparation or the server layers fail", () => {
|
||||
const prepare = runWith(repositoryFixture(), { pnpmStatus: () => 3 });
|
||||
expect(prepare.status).toBe(3);
|
||||
expect(prepare.calls).toHaveLength(1);
|
||||
|
||||
const layers = runWith(repositoryFixture(), {
|
||||
projects: ["tests/a"],
|
||||
tscResult: (target) => ({ status: target === "tsconfig.cycle.json" ? 2 : 0, signal: null }),
|
||||
});
|
||||
expect(layers.status).toBe(2);
|
||||
expect(layers.calls.some((call) => call.target === "tests/a/tsconfig.layers.json")).toBe(false);
|
||||
});
|
||||
|
||||
it("checks every project after a type error, and stops when a process is killed", () => {
|
||||
const errors = runWith(repositoryFixture(), {
|
||||
projects: ["tests/a", "tests/b"],
|
||||
tscResult: (target) => ({ status: target === "tests/a/tsconfig.layers.json" ? 2 : 0, signal: null }),
|
||||
});
|
||||
expect(errors.status).toBe(2);
|
||||
expect(errors.calls.at(-1).target).toBe("tests/b/tsconfig.layers.json");
|
||||
|
||||
const killed = runWith(repositoryFixture(), {
|
||||
projects: ["tests/a", "tests/b"],
|
||||
tscResult: (target) =>
|
||||
target === "tests/a/tsconfig.layers.json" ? { status: 1, signal: "SIGKILL" } : { status: 0, signal: null },
|
||||
});
|
||||
expect(killed.status).toBe(1);
|
||||
expect(killed.calls.at(-1).target).toBe("tests/a/tsconfig.layers.json");
|
||||
});
|
||||
|
||||
it("reuses declarations without preparing or building layers", () => {
|
||||
const { status, calls } = runWith(repositoryFixture(), { projects: ["tests/a"], reuseDeclarations: true });
|
||||
|
||||
expect(status).toBe(0);
|
||||
expect(calls.map((call) => call.target ?? call.tool)).toEqual(["tests/a/tsconfig.layers.json"]);
|
||||
});
|
||||
|
||||
it("fails when a project loads server source, even if its types pass", () => {
|
||||
const root = repositoryFixture();
|
||||
const server = path.join(root, "server");
|
||||
|
||||
const { status, errors } = runWith(root, {
|
||||
projects: ["tests/a", "tests/b"],
|
||||
reuseDeclarations: true,
|
||||
listFiles: (config) => ({
|
||||
status: 0,
|
||||
files: config.startsWith("tests/a/") ? [path.join(server, "src", "route.ts")] : [],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(status).toBe(1);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain("tests/a loads 1 server source files");
|
||||
expect(errors[0]).toContain("src/route.ts");
|
||||
});
|
||||
|
||||
it("fails when a project's files cannot be listed, and keeps a type error's status", () => {
|
||||
const listing = runWith(repositoryFixture(), {
|
||||
projects: ["tests/a"],
|
||||
reuseDeclarations: true,
|
||||
listFiles: () => ({ status: 3, files: [], output: "TS6053" }),
|
||||
});
|
||||
expect(listing.status).toBe(3);
|
||||
expect(listing.errors[0]).toContain("TS6053");
|
||||
|
||||
const both = runWith(repositoryFixture(), {
|
||||
projects: ["tests/a"],
|
||||
reuseDeclarations: true,
|
||||
tscResult: () => ({ status: 2, signal: null }),
|
||||
listFiles: () => ({ status: 3, files: [], output: "" }),
|
||||
});
|
||||
expect(both.status).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only source files that a layer should provide", () => {
|
||||
const server = path.join(path.sep, "repo", "server");
|
||||
const file = (relative) => path.join(server, ...relative.split("/"));
|
||||
|
||||
expect(
|
||||
findServerSource(
|
||||
[
|
||||
file("src/route.ts"),
|
||||
file("src/view.tsx"),
|
||||
file("src/legacy.js"),
|
||||
file("src/types/express.d.ts"),
|
||||
file("src/data.json"),
|
||||
file("src/route.test.ts"),
|
||||
file("src/__tests__/helpers/home.ts"),
|
||||
file("node_modules/.cache/paperclip-typecheck/dts/route.d.ts"),
|
||||
file("test-baselines/lifecycle.test.ts"),
|
||||
path.join(path.sep, "repo", "tests", "a.ts"),
|
||||
],
|
||||
server,
|
||||
),
|
||||
).toEqual(["src/route.ts", "src/view.tsx", "src/legacy.js"]);
|
||||
});
|
||||
|
||||
it("covers every project under tests/, or names why it does not", () => {
|
||||
// This project imports one small server file, so it stays one small program.
|
||||
const singlePrograms = ["tests/runner-acceptance"];
|
||||
const testsRoot = path.join(serverRoot, "..", "tests");
|
||||
const projects = readdirSync(testsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && existsSync(path.join(testsRoot, entry.name, "tsconfig.json")))
|
||||
.map((entry) => `tests/${entry.name}`);
|
||||
|
||||
expect(projects.sort()).toEqual(
|
||||
[...PROJECTS.filter((project) => project.startsWith("tests/")), ...singlePrograms].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prepares the same way as the server typecheck", () => {
|
||||
const { scripts } = JSON.parse(readFileSync(path.join(serverRoot, "package.json"), "utf8"));
|
||||
for (const step of PREPARE_STEPS) {
|
||||
expect(scripts.typecheck).toContain(`pnpm ${step.join(" ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(PROJECTS)("keeps the generated references out of the tsconfig.json of %s", (project) => {
|
||||
const directory = path.join(serverRoot, "..", project);
|
||||
const readConfig = (name) => JSON.parse(readFileSync(path.join(directory, name), "utf8"));
|
||||
|
||||
// Vitest reads tsconfig.json and fails when a referenced config does not exist yet.
|
||||
expect(readConfig("tsconfig.json").references).toBeUndefined();
|
||||
const layered = readConfig("tsconfig.layers.json");
|
||||
expect(layered.extends).toBe("./tsconfig.json");
|
||||
expect(layered.references.map((reference) => path.resolve(directory, reference.path))).toEqual([
|
||||
path.join(serverRoot, "node_modules", ".cache", "paperclip-typecheck", "tsconfig.declarations.json"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runTsc", () => {
|
||||
it("emits JavaScript without checks or declarations in emit-only mode", () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "run-tsc-emit-only-"));
|
||||
|
||||
@@ -209,33 +209,14 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => {
|
||||
await db.delete(companies);
|
||||
}
|
||||
|
||||
async function waitForRunToLeaveActiveStates(runId: string, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (run && !["queued", "running"].includes(run.status)) return run;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return await heartbeat.getRun(runId);
|
||||
async function waitForRunToLeaveActiveStates(runId: string) {
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
return heartbeat.getRun(runId);
|
||||
}
|
||||
|
||||
// A deferral does two writes in order: first it cancels the original run,
|
||||
// then it inserts the scheduled-retry row. waitForRunToLeaveActiveStates
|
||||
// returns after the first write, so a read of the retry row can land before
|
||||
// the second write and find nothing. Poll until the retry row exists so the
|
||||
// retry-row assertions never observe the gap between the two writes.
|
||||
async function waitForRetryRun(originalRunId: string, timeoutMs = 10_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const retryRun = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.retryOfRunId, originalRunId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (retryRun) return retryRun;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return await db
|
||||
async function waitForRetryRun(originalRunId: string) {
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
return db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.retryOfRunId, originalRunId))
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../../../node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
@@ -70,11 +70,11 @@ describe.skipIf(!support.supported && !externalDatabaseUrl)("heartbeat workspace
|
||||
if (external) await db.execute(sql`truncate table environments restart identity cascade`);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
afterAll(async () => {
|
||||
await db?.$client.end();
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await database?.cleanup();
|
||||
vi.unstubAllEnvs();
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
beforeEach(async () => {
|
||||
persistenceBefore = new Map(persistenceKeys.map(key => [key, process.env[key]]));
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -47,7 +47,7 @@ pnpm test:lifecycle-baseline integration
|
||||
pnpm test:lifecycle-baseline grading
|
||||
pnpm test:lifecycle-baseline
|
||||
pnpm test:lifecycle-baseline:support
|
||||
pnpm exec tsc -p tests/lifecycle-baseline/tsconfig.json
|
||||
pnpm test:lifecycle-baseline:typecheck
|
||||
```
|
||||
|
||||
All four lanes are credential-free. The integration lane uses disposable embedded
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"types": ["node"],
|
||||
"typeRoots": ["../../cli/node_modules/@types", "../../server/node_modules/@types"]
|
||||
},
|
||||
"include": ["./*.ts", "../../server/test-baselines/*.ts"]
|
||||
"include": ["./*.ts", "../../server/test-baselines/*.ts", "../../server/src/types/express.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"references": [{ "path": "../../server/node_modules/.cache/paperclip-typecheck/tsconfig.declarations.json" }]
|
||||
}
|
||||
Reference in new issue
Block a user