fix(ci): validate stacked PR merge refs (#12457)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Pull request checks protect the application and its contributors.
> - The trusted CI gate verifies signed event data against live GitHub
state.
> - GitHub gives a stacked pull request a synthetic merge commit for its
base stack.
> - The current validator expects the raw base SHA as the first merge
parent.
> - That assumption sends safe stacked pull requests to GitHub-hosted
runners.
> - This pull request validates the synthetic base merge and its
ancestry explicitly.
> - The benefit is safe automatic AWS routing for approved stacked pull
requests.

## Linked Issues or Issue Description

Refs #12455
Refs #12456

**What existing behavior does this improve?**

The trusted runner gate validates merge commits for master-based pull
requests but rejects GitHub synthetic base merges for stacked pull
requests.

**Subsystem affected**

GitHub Actions pull request identity and merge-state validation.

**Current behavior**

A trusted stacked pull request passes all numeric identity checks. The
gate fails closed because the first event merge parent is a GitHub
synthetic base merge instead of the raw base snapshot SHA.

**Proposed behavior**

The gate verifies the current base-ref tip, base-snapshot ancestry,
identical event and live merge parents, the child head parent, the
synthetic base merge parents, and identical event and live merge trees.

**Reason and benefit**

The change preserves fail-closed live-state validation while allowing
approved stacked pull requests to use the isolated AWS Fleet.

**Breaking changes**

None for untrusted contributors. Trusted stacked pull requests can
select AWS after the caller pins this workflow version.

**Additional context**

GitHub builds a stacked test merge in two steps. It first merges the
stack base into its own current base. It then uses that synthetic commit
as the first parent of the child test merge.

## What Changed

- Fetch and validate the current base branch ref.
- Require the event base snapshot to remain an ancestor of that ref.
- Validate direct and synthetic base merge parent shapes.
- Preserve the existing child-head, live-state, merge-tree, repository,
and actor checks.

## Verification

- Ran actionlint on the trusted workflow.
- Ran the external routing suite.
- Added positive coverage for the GitHub stacked merge shape.
- Added fail-closed coverage for replaced base ancestry and a synthetic
merge that omits the current base ref.
- Replayed the checks against the live merge shape for pull request
#12340.

## Risks

The gate has more GitHub API reads. Its five-minute timeout and
fail-closed behavior limit the effect of API errors. The accepted
synthetic commit must be the same in the event and live merge, must
include the current base branch as a direct parent, and must produce the
same merge tree.

> For core feature work, check ROADMAP.md first and discuss it in #dev
before opening the PR. Feature PRs that overlap with planned core work
may need to be redirected — check the roadmap first. See
CONTRIBUTING.md.

## Model Used

OpenAI Codex on GPT-5. The exact deployment ID and context-window size
are not exposed. The model used reasoning, tool use, GitHub API access,
and local code execution.

## 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 linked existing public items and described the issue in-PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [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
This commit is contained in:
Dotta 2026-08-28 13:34:36 -05:00 committed by GitHub
parent e48e0bd3c2
commit 7b199fcafa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 43 additions and 2 deletions

View File

@ -120,6 +120,26 @@ jobs:
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' \
@ -137,12 +157,11 @@ jobs:
local expected_sha="$2"
jq -e \
--arg expected_sha "$expected_sha" \
--arg base_sha "$EVENT_BASE_SHA" \
--arg head_sha "$EVENT_HEAD_SHA" '
.sha == $expected_sha and
(.parents | length) == 2 and
.parents[0].sha == $base_sha 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
}
@ -152,6 +171,28 @@ jobs:
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" ]] \