From 8e7b5db822ac69893da3857241c36e30b962786b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:14:35 -0500 Subject: [PATCH] ci: add protected routing workflow for reserved AWS capacity (#16039) ## Thinking Path > - Paperclip uses CI to verify changes before release. > - Trusted PR tests can run on isolated AWS workers. > - Their runner-selection job currently waits for GitHub-hosted capacity. > - A large hosted test queue therefore blocks admission to AWS. > - A dedicated routing workflow lets us reserve capacity for metadata validation. > - This PR adds that workflow without changing the active PR caller. ## Linked Issues or Issue Description **Issue type** CI reliability enhancement. **Where is the issue?** The Select trusted runner job in PR CI. **What's wrong?** The routing job competes with a large hosted test queue. Its sampled median wait was about 20 minutes. AWS tests cannot begin before this job finishes. **Suggested fix** Add a protected reusable routing workflow. Deploy its dedicated AWS pool, then switch the PR caller in a follow-up PR. No overlapping open PR or issue was found. ## What Changed - Add ci-routing.yml with the existing author, sender, rerun actor, repository, head, merge, and stack checks. - Target the dedicated paperclip-ci-routing-x64 Fleet. - Export runner and full_ci decisions to reusable workflow callers. - Add tests for metadata-only execution, reserved placement, and output forwarding. - Keep the active PR caller unchanged until the pool is deployed. ## Verification - The routing tests passed (30 tests), including 20 executed runner-selection cases and 8 executed stack-scope cases. - Existing routing allow/deny scenarios also passed with the extracted script (16 cases). - A live qualification job selected AWS and passed on the reserved routing pool in https://github.com/paperclipai/paperclip/actions/runs/38308312153/job/115007621128 while the hosted gate remained queued. - actionlint passed with the dedicated custom runner label declared in its local configuration. - git diff --check passed. - Full pnpm typecheck/test/build cannot run in this worktree because dependencies are not installed. The change is limited to workflow YAML and Node tests. - After merge, configure the restricted runner group, deploy the pool, and dispatch ci-routing.yml on master. The unused workflow does not affect current PR placement. ## Risks - The maintainer explicitly approved merging this rollout after current-head Greptile 5/5 and focused verification, while the hosted CI checks remain queued. - The dedicated Fleet must exist before the workflow can receive a runner. This PR introduces the workflow without switching active PR CI. - The group must authorize only ci-routing.yml on protected master and canonical repository ID 1170821064. - The routing workflow runs no checkout or PR code and has read-only permissions. The test admission checks are unchanged. ## Model Used - OpenAI GPT-6 via Codex, with reasoning, shell tools, and file editing. The exact runtime model ID and context window are not exposed in this session. ## 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 (focused routing and workflow tests; full pnpm commands are blocked by missing worktree dependencies) - [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 - [x] Required CI was checked; the maintainer explicitly approved an admin bypass for queued checks after clean review - [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 --- .github/scripts/tests/ci-routing.test.mjs | 137 ++++++++++++ .github/workflows/ci-routing.yml | 252 ++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 .github/scripts/tests/ci-routing.test.mjs create mode 100644 .github/workflows/ci-routing.yml diff --git a/.github/scripts/tests/ci-routing.test.mjs b/.github/scripts/tests/ci-routing.test.mjs new file mode 100644 index 00000000000..fedc277482a --- /dev/null +++ b/.github/scripts/tests/ci-routing.test.mjs @@ -0,0 +1,137 @@ +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"; + +const workflow = readFileSync(new URL("../../workflows/ci-routing.yml", import.meta.url), "utf8"); +const jobs = workflow.split(/^jobs:\n/m)[1]; + +test("routing uses reserved capacity and runs no PR code or actions", () => { + assert.deepEqual([...jobs.matchAll(/^ {2}([a-z_]+):$/gm)].map((match) => match[1]), ["gate"]); + assert.match(jobs, /^ {4}runs-on: runs-on\/fleet=paperclip-ci-routing-x64\/env=public-ci$/m); + 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=\"${*: -1}\"\ncase \"$endpoint\" in\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 jq -cn \\\n --arg sha \"${TEST_LIVE_BASE_REF_SHA:-$EVENT_BASE_SHA}\" '{object: {sha: $sha}}'\n ;;\n */compare/*)\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 merge_base_commit: {sha: $merge_base_sha},\n status: $status\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/*)\n jq -cn \\\n --argjson actor_id \"${TEST_TRIGGERING_ACTOR_ID:-34892728}\" \\\n --argjson repository_id \"${TEST_REPOSITORY_ID:-1170821064}\" \\\n --arg event_name \"${TEST_EVENT_NAME:-pull_request}\" '{\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, + 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", + ...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], + ["validated stacked base", stackedBase, awsRunner], + ["lagging PR API base snapshot", { TEST_PR_BASE_SHA: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, awsRunner], + ["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" }], + ["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" }], + ["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" }], + ["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 stacked PR CI scope", { + STACK_JSON: JSON.stringify(stack), PR_BASE_REF: base, + }).full_ci, expected); + }); +} diff --git a/.github/workflows/ci-routing.yml b/.github/workflows/ci-routing.yml new file mode 100644 index 00000000000..b8d2e93fa5c --- /dev/null +++ b/.github/workflows/ci-routing.yml @@ -0,0 +1,252 @@ +name: CI routing + +on: + workflow_dispatch: + workflow_call: + outputs: + runner: + value: ${{ jobs.gate.outputs.runner }} + full_ci: + value: ${{ jobs.gate.outputs.full_ci }} + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + gate: + name: Select trusted runner + runs-on: runs-on/fleet=paperclip-ci-routing-x64/env=public-ci + timeout-minutes: 5 + outputs: + runner: ${{ steps.route.outputs.runner }} + full_ci: ${{ steps.scope.outputs.full_ci }} + + steps: + - name: Validate PR identity and select runner + id: route + shell: bash + env: + GH_TOKEN: ${{ github.token }} + AWS_CI_ENABLED: ${{ vars.AWS_CI_ENABLED }} + TRUSTED_USER_IDS: ${{ vars.AWS_CI_TRUSTED_USER_IDS }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REPOSITORY: ${{ github.repository }} + EVENT_REPOSITORY_ID: ${{ github.repository_id }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_PR_AUTHOR_ID: ${{ github.event.pull_request.user.id }} + EVENT_SENDER_ID: ${{ github.event.sender.id }} + EVENT_BASE_REPOSITORY_ID: ${{ github.event.pull_request.base.repo.id }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_MERGE_SHA: ${{ github.sha }} + RUN_ID: ${{ github.run_id }} + run: | + set -u + + github_runner='ubuntu-latest' + aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci' + + fail_closed() { + echo "runner=$github_runner" >> "$GITHUB_OUTPUT" + echo "::notice title=AWS CI routing::Using GitHub-hosted runner: $1" + exit 0 + } + + is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] + } + + is_commit_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ ]] + } + + is_allowed() { + local user_id="$1" + jq -e --argjson user_id "$user_id" 'index($user_id) != null' \ + <<< "$TRUSTED_USER_IDS" >/dev/null + } + + [[ "$AWS_CI_ENABLED" == 'true' ]] || fail_closed 'AWS_CI_ENABLED is not true' + [[ "$EVENT_NAME" == 'pull_request' ]] || fail_closed 'event is not pull_request' + [[ "$EVENT_REPOSITORY" == 'paperclipai/paperclip' ]] || fail_closed 'unexpected repository' + [[ "$EVENT_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected repository ID' + [[ "$EVENT_BASE_REPOSITORY_ID" == '1170821064' ]] || fail_closed 'unexpected base repository ID' + [[ -n "$EVENT_BASE_REF" ]] || fail_closed 'missing base branch' + [[ "$EVENT_ACTION" =~ ^(opened|reopened|synchronize)$ ]] || fail_closed 'unsupported pull_request action' + + jq -e ' + type == "array" and + length > 0 and + all(.[]; type == "number" and . > 0 and floor == .) + ' <<< "$TRUSTED_USER_IDS" >/dev/null 2>&1 || fail_closed 'trusted user ID list is malformed' + + for user_id in "$EVENT_PR_AUTHOR_ID" "$EVENT_SENDER_ID"; do + is_positive_integer "$user_id" || fail_closed 'event contains a malformed user ID' + is_allowed "$user_id" || fail_closed "GitHub user ID $user_id is not allowlisted" + done + + for commit_sha in "$EVENT_BASE_SHA" "$EVENT_HEAD_SHA" "$EVENT_MERGE_SHA"; do + is_commit_sha "$commit_sha" || fail_closed 'event contains a malformed commit SHA' + done + + pr_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/pulls/$EVENT_PR_NUMBER" 2>/dev/null)" \ + || fail_closed 'could not refresh pull request state' + + jq -e \ + --argjson repository_id 1170821064 \ + --argjson author_id "$EVENT_PR_AUTHOR_ID" \ + --arg base_ref "$EVENT_BASE_REF" \ + --arg head_sha "$EVENT_HEAD_SHA" \ + ' + .state == "open" and + .user.id == $author_id and + .base.repo.id == $repository_id and + .base.ref == $base_ref and + .head.sha == $head_sha + ' <<< "$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' \ + "/repos/paperclipai/paperclip/git/ref/heads/$EVENT_BASE_REF" 2>/dev/null)" \ + || fail_closed 'could not inspect the current base branch' + live_base_ref_sha="$(jq -r '.object.sha // empty' <<< "$base_ref_json")" + is_commit_sha "$live_base_ref_sha" || fail_closed 'current base branch has no valid commit SHA' + + base_comparison="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$live_base_ref_sha" 2>/dev/null)" \ + || fail_closed 'could not compare the triggering and current base branches' + jq -e \ + --arg event_base_sha "$EVENT_BASE_SHA" ' + .merge_base_commit.sha == $event_base_sha and + (.status == "ahead" or .status == "identical") + ' <<< "$base_comparison" >/dev/null 2>&1 \ + || fail_closed 'current base branch does not descend from the triggering base snapshot' + + event_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_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" + jq -e \ + --arg expected_sha "$expected_sha" \ + --arg head_sha "$EVENT_HEAD_SHA" ' + .sha == $expected_sha and + (.parents | length) == 2 and + .parents[1].sha == $head_sha and + (.parents[0].sha | test("^[0-9a-f]{40}$")) and + (.tree.sha | test("^[0-9a-f]{40}$")) + ' <<< "$merge_json" >/dev/null 2>&1 + } + + 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' + + 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 + 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" ' + .sha == $expected_sha and + (.parents | length) == 2 and + any(.parents[]; .sha == $live_base_ref_sha) 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' + 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' + + run_json="$(gh api \ + -H 'Accept: application/vnd.github+json' \ + -H 'X-GitHub-Api-Version: 2022-11-28' \ + "/repos/paperclipai/paperclip/actions/runs/$RUN_ID" 2>/dev/null)" \ + || fail_closed 'could not refresh workflow run state' + + triggering_actor_id="$(jq -r '.triggering_actor.id // empty' <<< "$run_json")" + is_positive_integer "$triggering_actor_id" || fail_closed 'workflow run has no valid triggering actor ID' + is_allowed "$triggering_actor_id" || fail_closed "triggering GitHub user ID $triggering_actor_id is not allowlisted" + + jq -e \ + --argjson repository_id 1170821064 \ + --arg event_name "$EVENT_NAME" ' + .repository.id == $repository_id and + .event == $event_name + ' <<< "$run_json" >/dev/null 2>&1 \ + || fail_closed 'current workflow run does not match the expected repository event' + + echo "runner=$aws_runner" >> "$GITHUB_OUTPUT" + echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner' + + - name: Select stacked PR CI scope + id: scope + shell: bash + env: + STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + + full_ci='true' + reason='ordinary pull request' + + if jq -e 'type == "object"' <<< "$STACK_JSON" >/dev/null 2>&1; then + stack_position="$(jq -r '.position // empty' <<< "$STACK_JSON")" + stack_size="$(jq -r '.size // empty' <<< "$STACK_JSON")" + stack_base_ref="$(jq -r '.base.ref // empty' <<< "$STACK_JSON")" + + if [[ ! "$stack_position" =~ ^[1-9][0-9]*$ ]] || + [[ ! "$stack_size" =~ ^[1-9][0-9]*$ ]] || + (( stack_position > stack_size )) || + [[ -z "$stack_base_ref" ]]; then + reason='malformed stack metadata; defaulting to full CI' + elif (( stack_position == stack_size )); then + reason='top pull request in stack' + elif [[ "$stack_base_ref" == "$PR_BASE_REF" ]]; then + reason='lowest unmerged pull request in stack' + else + full_ci='false' + reason='middle pull request in stack' + fi + fi + + echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT" + echo "::notice title=Stacked PR CI scope::$reason; full_ci=$full_ci" +