Files
PaperClipAI/.github/workflows/ci-routing.yml
T
DottaandPaperclip 3b84c89885 fix(ci): use complete merge diffs and allow audited documentation (#18362)
## Thinking Path

> - Paperclip manages AI agents and needs reliable checks for each
contribution.
> - CI selects affected suites and then affected Vitest files before
allocating runners.
> - Recent runs compared a tested merge against a stale event base.
> - That included unrelated master changes and sometimes reached the
compare API file cap.
> - Ordinary documentation also forced every suite and invalidated test
caches.
> - This PR reads complete merge trees and exempts a small set of
audited prose inputs.
> - Tests retain conservative coverage when the diff or an input is
uncertain.

## Linked Issues or Issue Description

Refs #18348 and #18214. This is a follow-up to the merged affected-test
selector.
Related CI performance work: #16513 and #16500 address setup and suite
execution. This PR changes diff collection and input classification.

## What Changed

- Fetch the exact tested merge and its parents into a temporary bare
repository. Read the complete tree diff against the actual first parent.
Validate the second parent against the event PR head. Never execute PR
code in the routing job.
- Carry the immutable base, merge and PR-head identities into the plan.
Validate the same complete diff again before file selection.
- Use one prose policy across the planner, Vitest selector and task
cache adapter. Exempt only `doc/DEVELOPING.md`, `doc/DATABASE.md`, and
Markdown under `doc/plans/`.
- Keep the uncached typecheck and CLI guidance guard for the first two
documents. Keep policy checks for historical plans. Keep existing
coverage for all other docs, runtime skills, fixtures and unknown
inputs.
- Extend candidate-workflow qualification to exercise mixed source and
prose changes.

## Verification

- Passed: 15 planner and Git routing tests, including a 353-file diff,
stale base, rename, deletion, wrong parent and fetch-failure cases.
- Passed: 83 routing, Vitest graph, exact selection, workflow and real
Turbo cache checks.
- Passed: `pnpm -r typecheck` and `pnpm build`.
- Full local `pnpm test:run` was attempted and stopped after repeated
embedded PostgreSQL startup failures in native-session and Muse tests.
It did not pass or complete. The exact cause remains unverified. The
changed files do not include those tests or their database setup. Linux
CI results are recorded below.
- Passed: real mixed Inbox/source/prose graph qualification selected
699/911 workspace test files, preserving the source-only selection.
- Passed: both GitHub CI qualification jobs, including real mixed
source/prose Vitest selection. Greptile reviewed commit `10ac6f5ec9` at
5/5 with no findings.
- First full CI attempt: the [chat
shard](https://github.com/paperclipai/paperclip/actions/runs/41111003419/job/124253342684)
received a runner shutdown signal. The [Runner
shard](https://github.com/paperclipai/paperclip/actions/runs/41111003419/job/124253341271)
failed the unchanged Antigravity authentication success assertion. All
11 cases in that file passed when run directly. That Runner lane does
not use the changed selector/cache path. The exact assertion-failure
cause remains unverified. The chat shard passed on the one retry. The
authentication assertion also passed on retry, but the same Runner shard
failed a different unchanged test:
`native-distribution-integrity.test.ts:242`, "retains spawned native
bytes until the exact child exits", exceeded 5000ms. The [retry
log](https://github.com/paperclipai/paperclip/actions/runs/41111003419/job/124259174201)
preserves the failure. The Runner shard and required verify aggregate
are still red; all other checks passed. All 33 native-distribution tests
then passed in a direct local run. This shows recovery in isolation, not
the cause of the Linux timeout. No further blind reruns were issued.
- Replayed 15 real recent PR merge commits through a blob-filtered bare
fetch. Nine needed a corrected base. The four API comparisons that
returned 300 filenames became complete diffs of 3, 57, 66 and 54 files.
The 3-file case (#18326) becomes eligible for graph selection. Most
other samples still correctly use full CI because they include inputs
outside this narrow prose policy.
- Cache proof: prose edits preserve a product-test cache hit; changing
runtime API documentation invalidates it.

## Risks

- Prose exceptions must be removed or revised if product code starts
reading those files. The allowlist is deliberately small. Runtime docs
and skills remain covered.
- A network or Git failure keeps the full-suite fallback. Unexpected
merge topology also keeps full CI. Stack and force-full policies remain
in place.
- This does not claim an end-to-end speedup yet. Many mixed-content PRs
still require full CI. The routing fetch reads only shallow commit/tree
objects; it does not check out or execute PR files.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools and code
execution. The exact served model ID and context-window size are not
exposed to 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
- [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>
2026-10-30 14:29:39 -05:00

383 lines
19 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: |
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 }}
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