name: CI routing on: workflow_dispatch: workflow_call: outputs: runner: value: ${{ jobs.gate.outputs.runner }} full_ci: value: ${{ jobs.gate.outputs.full_ci }} profile_ci: value: ${{ jobs.gate.outputs.profile_ci }} permissions: actions: read contents: read pull-requests: read jobs: gate: name: Select trusted runner 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: runner: ${{ steps.route.outputs.runner }} full_ci: ${{ steps.scope.outputs.full_ci }} profile_ci: ${{ steps.profile.outputs.profile_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_LABEL_NAME: ${{ github.event.label.name }} 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|labeled)$ ]] || fail_closed 'unsupported pull_request action' if [[ "$EVENT_ACTION" == 'labeled' && "${EVENT_LABEL_NAME:-}" != 'profile-ci' ]]; then fail_closed 'only the profile-ci label can request a profiling run' fi 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: Authorize CODEOWNERS CI profiling id: profile shell: bash env: GH_TOKEN: ${{ github.token }} PROFILE_REQUESTED: ${{ contains(github.event.pull_request.labels.*.name, 'profile-ci') }} run: | set -euo pipefail if [[ "$PROFILE_REQUESTED" != 'true' ]]; then echo 'profile_ci=false' >> "$GITHUB_OUTPUT" exit 0 fi # Fetch only from canonical master, never the PR ref. Pin both the # authorization program and CODEOWNERS to the same trusted revision. if ! TRUSTED_PROFILE_SHA="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" || [[ ! "$TRUSTED_PROFILE_SHA" =~ ^[0-9a-f]{40}$ ]] || ! gh api -H 'Accept: application/vnd.github.raw+json' \ "/repos/paperclipai/paperclip/contents/scripts/ci-profile/authorize.mjs?ref=$TRUSTED_PROFILE_SHA" \ > "$RUNNER_TEMP/ci-profile-authorize.mjs"; then echo 'profile_ci=false' >> "$GITHUB_OUTPUT" exit 0 fi export TRUSTED_PROFILE_SHA node "$RUNNER_TEMP/ci-profile-authorize.mjs" - name: Select PR CI scope id: scope shell: bash env: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PROFILE_CI: ${{ steps.profile.outputs.profile_ci }} 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' # True when the PR's own diff is everything it adds to the stack's # base branch: no stack, or the lowest unmerged PR in one. Above # that, the diff omits lower PRs' code that this run must still test. diff_covers_stack='true' # Release notes PRs touch only releases/v*.md or releases/beta/v*.md, # which no build, test, or e2e lane reads. The diff is pinned to the # event's base and head SHAs, so a later push cannot change the # decision for this run. Any lookup failure keeps full CI. release_notes_only() { [[ "${EVENT_NAME:-}" == 'pull_request' ]] || return 1 [[ "${EVENT_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || return 1 [[ "${EVENT_HEAD_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || return 1 local comparison comparison="$(gh api \ -H 'Accept: application/vnd.github+json' \ -H 'X-GitHub-Api-Version: 2022-11-28' \ "/repos/paperclipai/paperclip/compare/$EVENT_BASE_SHA...$EVENT_HEAD_SHA" 2>/dev/null)" \ || return 1 # The compare API lists at most 300 files, so a full page may be # truncated. Renames count both the old and the new path. jq -e ' (.files | type == "array") and (.files | length > 0 and length < 300) and all(.files[]; (.filename | type == "string") and ([.filename, .previous_filename] | map(select(. != null)) | all(.[]; type == "string" and test("^releases/(beta/)?v[^/]+\\.md$")))) ' <<< "$comparison" >/dev/null 2>&1 } 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 [[ -z "$stack_base_ref" || "$stack_base_ref" != "$PR_BASE_REF" ]]; then diff_covers_stack='false' fi 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' diff_covers_stack='false' 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 if [[ "$full_ci" == 'true' && "$diff_covers_stack" == 'true' ]] && release_notes_only; then full_ci='false' reason='release notes only' fi if [[ "${PROFILE_CI:-false}" == 'true' ]]; then full_ci='true' reason='profile-ci requests the complete PR suite' fi echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT" echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"