mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Public pull requests need CI without access to private runner capacity. > - Trusted CI CODEOWNERS use the AWS runner fleet after identity and revision checks. > - The PR caller accepts both `profile-ci` and `full-ci` label events. > - The runner gate accepted only `profile-ci`, so `full-ci` sent trusted work to GitHub. > - This change accepts both labels and keeps the existing authorization checks. ## Linked Issues or Issue Description Refs #18821. Observed on #18848 in [run 41711436968](https://github.com/paperclipai/paperclip/actions/runs/41711436968). **What happened?** A CODEOWNER added `full-ci` to a trusted PR. The metadata gate ran on AWS, then sent the test matrix to GitHub with the notice `only the profile-ci label can request a profiling run`. **Expected behavior** Both supported labels keep authorized CI on AWS. A label must not grant an outside contributor access to AWS. **Steps to reproduce** Add `full-ci` to an open PR whose author and event sender satisfy the CI owner and numeric allowlist checks. Before this change, the runner decision returns `ubuntu-latest` before it checks those identities. ## What Changed - Accept `full-ci` and `profile-ci` label events in the trusted runner gate. - Test both labels with trusted users, unlisted users, and numeric-allowlisted users who are not CI owners. - Document that label selection changes CI scope and preserves runner authorization. ## Verification - The new trusted `full-ci` regression failed before the fix with `ubuntu-latest` and passes after the fix. - `node --test .github/scripts/tests/ci-routing.test.mjs .github/scripts/tests/ci-plan.test.mjs .github/scripts/tests/ci-profile.test.mjs scripts/__tests__/e2e-shard.test.mjs`: 147 passed. - `actionlint -shellcheck='' .github/workflows/ci-routing.yml`: passed. - `git diff --check`: passed. - This isolated checkout has no installed workspace dependencies. Repository-wide typecheck, tests, and build are delegated to this PR's CI; local verification covers the changed routing and its callers. - A read-only replay against this PR's real GitHub identities and commits returned GitHub with the old `full-ci` guard and AWS with the corrected guard. No PR code was executed by the replay. - Greptile reviewed current head `47f7159f67c8633830d7732cb68c6d43d8243fe5`: 5/5, successful check, no unresolved review threads. Both incremental-CI qualification jobs and commitperclip review passed. - Full CI is not green. Attempt 1 lost workers to verified Spot reclamations in `m5.xlarge/us-east-1c`. After excluding that type from new placements, one failed-job retry retained 19 successful checks and reran 27 interrupted checks on AWS. The policy check passed, including these regressions. AWS then confirmed seven more reclaimed `m8a.xlarge/us-east-1c` workers and two reclaimed `m8i-flex.xlarge/us-east-1b` workers. The affected checks show runner shutdowns; unaffected jobs remain running. [Recovery attempt](https://github.com/paperclipai/paperclip/actions/runs/41714303495/attempts/2). ## Risks This changes the event allowlist before authorization. The author, event sender, rerun actor, repository, CODEOWNERS, numeric allowlist, and immutable revision checks still apply. Unrelated labels remain denied. Separate helper workflows that explicitly choose GitHub runners are outside this change. ## Model Used OpenAI GPT-6 through Codex, with reasoning, shell, Git, and GitHub API tools. The runtime does not expose a more specific model build ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
411 lines
20 KiB
YAML
411 lines
20 KiB
YAML
name: CI routing
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
workflow_call:
|
|
outputs:
|
|
runner:
|
|
value: ${{ jobs.gate.outputs.runner }}
|
|
full_ci:
|
|
value: ${{ jobs.gate.outputs.full_ci }}
|
|
plan:
|
|
value: ${{ jobs.gate.outputs.plan }}
|
|
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:
|
|
plan: ${{ steps.plan.outputs.plan }}
|
|
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_ATTEMPT: ${{ github.run_attempt }}
|
|
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" --argjson owners "$owner_ids" \
|
|
'index($user_id) != null and ($owners | 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|full-ci)$ ]]; then
|
|
fail_closed 'only the profile-ci or full-ci labels can request CI'
|
|
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'
|
|
|
|
# Both the resolver and CODEOWNERS come from the same protected master
|
|
# revision. Never use the PR's CODEOWNERS or execute its source here.
|
|
trusted_sha="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" \
|
|
|| fail_closed 'could not resolve trusted CODEOWNERS revision'
|
|
is_commit_sha "$trusted_sha" || fail_closed 'invalid trusted CODEOWNERS revision'
|
|
owner_helper="$RUNNER_TEMP/aws-ci-owners.mjs"
|
|
if ! gh api "/repos/paperclipai/paperclip/contents/scripts/ci-profile/authorize.mjs?ref=$trusted_sha" \
|
|
--jq '.content' > "$owner_helper.base64" ||
|
|
! base64 --decode < "$owner_helper.base64" > "$owner_helper"; then
|
|
fail_closed 'could not load trusted CODEOWNERS resolver'
|
|
fi
|
|
owner_ids="$(node --input-type=module - "$owner_helper" "$trusted_sha" <<'NODE'
|
|
import { pathToFileURL } from 'node:url';
|
|
const { trustedOwnerIds } = await import(pathToFileURL(process.argv[2]));
|
|
console.log(JSON.stringify(trustedOwnerIds(undefined, process.argv[3])));
|
|
NODE
|
|
)" || fail_closed 'could not verify current CI CODEOWNERS'
|
|
|
|
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 an allowlisted CI CODEOWNER"
|
|
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'
|
|
|
|
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'
|
|
|
|
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 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")"
|
|
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" '
|
|
.sha == $expected_sha and
|
|
(.parents | length) == 2 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 '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
|
|
|
|
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 \
|
|
-H 'Accept: application/vnd.github+json' \
|
|
-H 'X-GitHub-Api-Version: 2022-11-28' \
|
|
"/repos/paperclipai/paperclip/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT" 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 an allowlisted CI CODEOWNER"
|
|
|
|
jq -e \
|
|
--argjson repository_id 1170821064 \
|
|
--argjson run_attempt "$RUN_ATTEMPT" \
|
|
--arg event_name "$EVENT_NAME" '
|
|
.repository.id == $repository_id and
|
|
.run_attempt == $run_attempt 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 }}
|
|
FORCE_FULL_CI: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
|
CI_INCREMENTAL_MODE: ${{ vars.CI_INCREMENTAL_MODE || 'enabled' }}
|
|
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' || "${FORCE_FULL_CI:-false}" == 'true' || "${CI_INCREMENTAL_MODE:-enabled}" == 'full' ]]; then
|
|
full_ci='true'
|
|
reason='profile-ci, full-ci or full mode requests the complete PR suite'
|
|
fi
|
|
|
|
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
|
|
echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"
|
|
|
|
|
|
- name: Plan affected CI tasks from trusted definitions
|
|
id: plan
|
|
shell: bash
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
FULL_CI: ${{ steps.scope.outputs.full_ci }}
|
|
PROFILE_CI: ${{ steps.profile.outputs.profile_ci }}
|
|
FORCE_FULL_CI: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
|
CI_INCREMENTAL_MODE: ${{ vars.CI_INCREMENTAL_MODE || 'enabled' }}
|
|
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
|
|
EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
EVENT_MERGE_SHA: ${{ github.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
fallback() { echo 'plan=' >> "$GITHUB_OUTPUT"; exit 0; }
|
|
[[ "$FULL_CI" == 'true' ]] || fallback
|
|
[[ "$EVENT_HEAD_SHA" =~ ^[0-9a-f]{40}$ && "$EVENT_MERGE_SHA" =~ ^[0-9a-f]{40}$ ]] || fallback
|
|
trusted="$(gh api /repos/paperclipai/paperclip/git/ref/heads/master --jq .object.sha)" || fallback
|
|
[[ "$trusted" =~ ^[0-9a-f]{40}$ ]] || fallback
|
|
directory="$(mktemp -d)"
|
|
trap 'rm -rf "$directory"' EXIT
|
|
for file in .github/scripts/ci-plan.mjs .github/scripts/ci-inputs.mjs .github/scripts/ci-diff.mjs .github/ci-task-matrix.json turbo.json; do
|
|
gh api -H 'Accept: application/vnd.github.raw+json' \
|
|
"/repos/paperclipai/paperclip/contents/$file?ref=$trusted" \
|
|
> "$directory/${file##*/}" || fallback
|
|
done
|
|
# Read the exact tested merge and both parents. The event's base SHA can
|
|
# lag behind its actual first parent. A bare tree diff has no API file cap.
|
|
git init --bare "$directory/repository.git" >/dev/null || fallback
|
|
git -C "$directory/repository.git" fetch --quiet --no-tags --filter=blob:none --depth=2 \
|
|
https://github.com/paperclipai/paperclip.git "$EVENT_MERGE_SHA" || fallback
|
|
if [[ "$PROFILE_CI" == 'true' || "$FORCE_FULL_CI" == 'true' ]]; then
|
|
export CI_FORCE_REASON='Full suite requested by label'
|
|
elif [[ "$STACK_JSON" != 'null' && -n "$STACK_JSON" ]]; then
|
|
export CI_FORCE_REASON='Stack verification includes lower pull requests'
|
|
fi
|
|
export CI_TASK_CONFIG="$directory/turbo.json"
|
|
export CI_TASK_MATRIX="$directory/ci-task-matrix.json"
|
|
export CI_DIFF_REPOSITORY="$directory/repository.git"
|
|
node "$directory/ci-plan.mjs" || fallback
|