ci: skip full CI for release-notes-only pull requests (#16207)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Each beta release ends with a pull request that adds one release
notes file under `releases/`
> - PR CI runs the full build, test, and e2e matrix for that pull
request
> - No build, test, or e2e lane reads `releases/v*.md` or
`releases/beta/v*.md`
> - The full matrix takes about 30 minutes, and lost runners or flaky
e2e specs can make it run again
> - This pull request lets the existing CI scope gate skip the heavy
lanes when a pull request changes only release notes
> - The benefit is that release notes pull requests get a green result
in minutes, and code pull requests keep full CI

## Linked Issues or Issue Description

**Issue type**

CI efficiency enhancement.

**Where is the issue?**

The `Select stacked PR CI scope` step in
`.github/workflows/ci-routing.yml`. This step sets the `full_ci` output
for PR CI.

**What's wrong?**

The step sets `full_ci=false` only for middle pull requests in a stack.
A pull request that changes only one release notes markdown file runs
the full matrix. For example, #16053 changed one file in
`releases/beta/`. Its CI ran two times: on the first run, 8 shards lost
their runners, and on the second run, one Playwright spec flaked and
needed a rerun.

**Suggested fix**

Read the pull request diff from the GitHub compare API in the scope
step. When every changed path is release notes, set `full_ci=false`. The
callers already support this value. I found no open pull request or
issue for this change.

## What Changed

- `ci-routing.yml`: the scope step (renamed to `Select PR CI scope`)
also sets `full_ci=false` when every path in the diff matches
`releases/v*.md` or `releases/beta/v*.md`.
- The step reads the diff from `compare/<event base SHA>...<event head
SHA>`. The SHAs come from the event, so a later push cannot change the
decision for this run.
- For a renamed file, the step checks the old path and the new path.
- The step keeps full CI when the event is not `pull_request`, a SHA is
malformed, the API call fails, the diff is empty, or the file list has
300 entries. The compare API can truncate a list at 300 entries.
- The step does not change the stacked pull request rules. It checks the
paths only when the stack rules select full CI.
- `ci-routing.test.mjs`: the GitHub stub now returns `files` for compare
calls. 17 new cases cover release notes paths, mixed diffs, renames,
non-markdown files, nested folders, empty and truncated lists, API
failure, non-PR events, malformed SHAs, and stack interaction.
- `e2e-shard.test.mjs`: the test now uses the new step name.
- `doc/DEVELOPING.md`: added a short description of the CI scope rules.

## Verification

- `node --test .github/scripts/tests/ci-routing.test.mjs
scripts/__tests__/e2e-shard.test.mjs`: 73 pass, 0 fail.
- `node --test '.github/scripts/tests/*.test.mjs'`: all pass.
- I ran the jq filter on real compare API responses. #16053 (one file in
`releases/beta/`) returns `true`. #16082 (10 files) returns `false`.
- After merge: open a pull request that changes only a
`releases/beta/v*.md` file. The `Select trusted runner` job shows the
notice `release notes only; full_ci=false`. The heavy lanes show as
skipped. The `policy`, `verify`, and `e2e` checks pass.

Note: `pr.yml` calls `pr-trusted.yml@master`, and that workflow calls
`ci-routing.yml@master`. This pull request does not change its own CI
run. The change applies after merge.

## Risks

- Low risk. The skip uses the existing `full_ci=false` path. The
`verify` and `e2e` aggregate checks already accept that value, and the
`policy` job always runs.
- A future job that reads release notes files would not run for
notes-only pull requests. Today, only the release workflows read these
files, and those workflows do not run on pull requests.
- The step now uses `GH_TOKEN` to call one more read-only API endpoint.
The workflow still does not check out or run pull request code, and its
permissions do not change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic. Agentic coding with
tool use (shell, file edits, GitHub CLI).

## 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 13:16:34 -07:00
1 parent c30fb7d2b7
commit b226336762
4 files changed
+131 -6

No files matched your search

+48 -2
View File
@@ -215,10 +215,14 @@ jobs:
echo "runner=$aws_runner" >> "$GITHUB_OUTPUT"
echo '::notice title=AWS CI routing::Using an ephemeral RunsOn Fleet runner'
- name: Select stacked PR CI scope
- 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 }}
STACK_JSON: ${{ toJSON(github.event.pull_request.stack) }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
@@ -226,17 +230,53 @@ jobs:
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
@@ -247,6 +287,12 @@ jobs:
fi
fi
if [[ "$full_ci" == 'true' && "$diff_covers_stack" == 'true' ]] &&
release_notes_only; then
full_ci='false'
reason='release notes only'
fi
echo "full_ci=$full_ci" >> "$GITHUB_OUTPUT"
echo "::notice title=Stacked PR CI scope::$reason; full_ci=$full_ci"
echo "::notice title=PR CI scope::$reason; full_ci=$full_ci"