Files
PaperClipAI/.github/workflows/ci-routing.yml
T
DottaandPaperclip 6acec4266e feat(ci): profile full CODEOWNER PR suites in Honeycomb (#16378)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its PR workflow runs dozens of parallel build and test jobs.
> - Individual job durations do not show the elapsed cost of the whole
suite.
> - Maintainers need one shared timeline to find slow work and repeated
setup.
> - This pull request adds full-suite profiles for selected CI CODEOWNER
PRs.
> - Honeycomb shows each job, step, test file, and supported test on
that timeline.
> - The benefit is evidence for changes that reduce total CI time.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The main PR workflow has about 48 jobs. Maintainers need to compare
their overlapping work and find which jobs delay completion.

**Current behavior**

GitHub shows job and step durations separately. Test files and tests are
not joined to a full workflow trace.

**Proposed behavior**

A CI CODEOWNER adds `profile-ci` to a PR. The tested code stays on that
PR branch. All main PR lanes run, including lanes normally skipped for
stack position or release notes. A trusted post-run exporter sends one
wall-clock trace to Honeycomb.

**Reason and benefit**

The trace supports decisions about which CI work to speed up or remove.
Ordinary PRs do not export traces. The roadmap and related-PR search
found no duplicate CI profiling work.

## What Changed

- Add label selection and a full-suite override. Verify the PR author,
original actor, and rerun actors against the trusted `.github/**`
CODEOWNERS rule.
- Record Vitest, Playwright, and Node test file/test timings in local
artifacts. Keep ordinary reporter behavior and failing exit codes.
- Add a trusted `workflow_run` exporter. Use actual GitHub timestamps
for all jobs, setup, steps, waiting, and teardown. Mark missing test
data and partial reruns.
- Keep the ingest key out of PR jobs. Store it only in the
`ci-profiling` environment, restricted to `master`. The exporter reads
bounded JSON and never executes artifact code.
- Record expected test invocations outside reporters so a missing
invocation cannot appear complete. Preserve `node --test` on older PR
checkouts. Flush large shard inventories before exit.
- Add security, timing, routing, and real-reporter checks. Document
operation and activation. Protect the exporter with CODEOWNERS.

## Verification

- 509 focused workflow/reporter/shard checks passed. They include
outside authors, outside rerun actors, bots, stale revisions, malicious
ZIP paths, incomplete reports, and partial ingestion failures.
- Final revision `3bf5d9017d` adds multi-file qualification and fixes
short-file step parenting and duplicate Node file-wrapper events.
Greptile reviewed this exact revision at 5/5 with no unresolved threads.
[Main PR CI
run](https://github.com/paperclipai/paperclip/actions/runs/38728040302):
the initial attempt had 54 successful checks, 3 intentional skips, and
one serialized-shard failure from an unchanged workspace-authorization
test exceeding its 15-second timeout. The single failed-shard retry
passed: the final result is 55 successful checks and 3 intentional
skips. The same timeout reproduces locally with profiling disabled; both
assertions pass with a diagnostic 60-second timeout override (about 23
seconds total). No source or CI timeout was changed.
- A dry run against real GitHub attempt 2 of run `38677445871` produced
exactly 3 executed jobs, 40 spans, and 510 seconds, excluding 45
carried-over jobs. The partial trace remains explicitly incomplete; this
validation did not upload data.
- `actionlint` passed for all four changed workflows. `git diff --check`
passed.
- Full Node 24 `pnpm -r typecheck` and `pnpm build` passed. The local
full `pnpm test:run` was stopped after it reported a setup-token timeout
and a distribution-plugin-catalog fixture failure. The 30 setup-token
tests passed on focused rerun. The plugin fixture fails at `rmSync` of a
directory symlink on this macOS/Node 24.11.0 host; the same failure
reproduces with the unchanged base Vitest configuration and unchanged
test/service files (10 other tests pass). The local full test suite is
therefore not claimed green.
- A real root-config Vitest probe passed 13 tests with profiling both
off and on. Multi-file Vitest, Playwright, and Node probes each verify
two distinct filename rows and four correctly parented tests, including
repeated test names across files. Separate passing probes exercise the
full exporter hierarchy `workflow → job → step → filename → tests`.
Intentional failing probes preserve failing exit status.
- Verified all 48 measured jobs in latest-head run `38728040302` used
AWS: one `paperclip-ci-routing-x64` gate and 47
`paperclip-public-pr-x64` jobs. Profiling preserves AWS authorization
and its GitHub-hosted fallback. The separate privileged exporter runs on
`ubuntu-latest` outside the measured suite.
- [Live Honeycomb
baseline](https://ui.honeycomb.io/paperclip/environments/test/datasets/paperclip-ci/trace/oaPMzw8zGwG):
48 jobs, 731 spans, 479 seconds. It matches [GitHub run
38671274294](https://github.com/paperclipai/paperclip/actions/runs/38671274294).
This historical trace has job/step data only; it is not proof of the
future label-triggered test-reporting journey.
- Verified the dedicated Honeycomb key is ingest-only with dataset
creation disabled. Verified the GitHub environment allows only the
`master` branch. No repository-level secret of that name exists. Both
credential values were scanned absent from source and branch history.

After merge, add `profile-ci` to a CI CODEOWNER PR, wait for the entire
PR run and exporter, then follow the run-summary link. Collapse job
children to compare parallel lanes; expand a job to inspect its steps
and tests.

## Risks

- Activation requires merging the trusted workflow definitions to
`master`. This PR continues using the current trusted reusable workflows
until then.
- Selected runs produce many spans and artifacts. Oversized artifacts
fail export instead of truncating data. Missing or cancelled reporters
are marked incomplete.
- GitHub timings have second precision. Node test times use reporter
receipt time. Rust retains job/step timing but lacks per-test wall
timestamps.
- CI CODEOWNERS are individual users in the explicit `.github/**` rule.
Unsupported team rules and identity lookup failures disable profiling.
- An interrupted upload can leave partial data. Retrying uses the same
span IDs, but the Events API does not promise deduplication.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, code execution, repository
tools, and browser verification. The runtime does not expose the exact
serving model 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
- [ ] 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
- [x] 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-15 06:59:50 -05:00

337 lines
16 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 }}
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"