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. > - Its PR checks use reserved AWS capacity for authorized maintainers. > - The routing gate waited for GitHub capacity and compared an old test merge with a newer base branch. > - Normal merges into master then sent trusted PR jobs to the GitHub queue. > - This change restores AWS admission and validates the immutable merge selected for the run. > - Current CODEOWNER and rerun identity checks still control PR execution. ## Linked Issues or Issue Description Related: #18376 moved the gate to GitHub-hosted capacity. #16042 introduced reserved AWS admission. **What happened?** In PR #18811, GitHub created test merge `608d862ff1650db108a3832a2c67f8795924f0e7` at 17:17 UTC. Master advanced at 17:25. The gate started at 17:30 and rejected the older merge parent because it did not contain the newer master tip. The authorized PR then selected GitHub-hosted runners. The gate itself also needed a GitHub-hosted slot. **Expected behavior** Authorized maintainers use reserved AWS admission and PR workers. Normal base advancement does not change runner placement. Current owner checks, PR head checks, run-attempt checks, and base-history validation remain required. **Steps to reproduce** 1. Start a PR run whose author and sender are current CI CODEOWNERS. 2. Advance master while its routing job waits. 3. Run the gate against the original event merge and the current base tip. 4. Before this change, the gate selects `ubuntu-latest`. After this change, it selects the public PR AWS fleet. **Paperclip version or commit** Reproduced on master `367f29478d14003cc5c0f161b7ce8f3164993634`. **Deployment mode** GitHub Actions with RunsOn Fleet on AWS. ## What Changed - Restore the reserved routing fleet for the synchronized maintainer admission roster. - Check the immutable event merge and its PR head. Accept base snapshots between the event base and the current base tip. - Allow GitHub to regenerate its live merge ref while the original run waits. - Retain stacked-base validation and all current CODEOWNER, repository, author, sender, and rerun-attempt checks. - Add advancement, regenerated-merge, unrelated-history, and admission-placement regressions, including a stacked parent that descends from the event base but diverges from the live base. - Align the developing and CI profiling guides with reserved AWS admission and current CODEOWNER checks before PR execution. ## Verification - `node --test .github/scripts/tests/ci-routing.test.mjs scripts/__tests__/e2e-shard.test.mjs`: 103 passed. - `node --test .github/scripts/tests/ci-profile.test.mjs .github/scripts/tests/ci-plan.test.mjs`: 31 passed. - `actionlint .github/workflows/ci-routing.yml`: passed. - `git diff --check`: passed. - Mutation check: removing the snapshot-to-live-base comparison in a temporary workflow copy makes the new side-branch regression fail, proving it protects that ancestry guard. - [Greptile: 5/5](https://github.com/paperclipai/paperclip/pull/18821#issuecomment-6612170970) with a successful check on `abd6a8ce347228219d8e5127f34f57151788830e`; both review threads are resolved. - A regression reproduction on the original workflow selected GitHub instead of AWS. The patched workflow passes that scenario. - A read-only execution of the patched routing step against the actual event for run [41682867664](https://github.com/paperclipai/paperclip/actions/runs/41682867664), using live GitHub metadata, selected `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. This did not allocate a runner or rerun CI. - Full repository typecheck, Vitest, and build were not run in this worktree, which has no installed dependencies. The change is limited to workflow routing, its tests, and documentation. Broader CI checks remain queued. ## Risks - A stale admission roster can allocate the five-minute metadata gate. The gate still checks current CODEOWNERS before it authorizes any PR code on AWS. - The routing runner group must authorize `ci-routing.yml@refs/heads/master`. The fleet already exists; live placement still needs confirmation after merge. - Rewritten base history, changed PR heads, and unavailable identity or ancestry evidence continue to deny AWS PR execution. - Existing runs retain their previously resolved workflow. New runs use this change after it reaches master. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, repository inspection, code execution, and live GitHub API tools. The runtime does not expose the exact serving model variant 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' ]]; 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'
|
|
|
|
# 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
|