Skip to content

fix(pr-validation): close two paths past the breaking-change gate - #940

Merged
bedatty merged 3 commits into
developfrom
fix/gate-forged-reuse-and-dispatch-supersession
Oct 20, 2026
Merged

bedatty merged 3 commits into
developfrom
fix/gate-forged-reuse-and-dispatch-supersession

Conversation

@bedatty

@bedatty bedatty commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Description

The two findings @fredcamaral reproduced at 7c0c8b5 on #926. Both are real, both reproduce, and both let someone inside the organisation pass the gate with no approval. Neither reached main — #926 was closed rather than merged — but both are in develop and in the v1.86.0-beta pre-releases.


🔴 A forged record could reuse someone else's approval

breaking-change-approval.yml took the fingerprint from the record itself. Everything it verified established that an approval happened on this branch; nothing established what that approval covered.

The fingerprint is derived from public commit SHAs, so it is computable before the commit exists:

  1. Member computes fp(A,B) for the breaking commit B they are about to push.
  2. They post a comment carrying that fingerprint and pointing at the run that approved only A.
  3. They push B.

Every check passed — the record is from a MEMBER, the run is on this branch, the run really did carry an Environment approval for breaking-approval — and the verdict came back approved, exit 0, with no reviewer involved. Commenting needs no write access.

The record now says only which run to ask, and the run answers.

  • breaking-change-commits gains an at-sha mode that resolves the breaking set by comparing base-ref...at-sha. The pull request's commits endpoint cannot do this — it always reports the current state.
  • The lookup points it at the approving run's own head_sha, which nobody can write, and hands over a candidate rather than a verdict.
  • Reuse requires that resolved fingerprint to equal this one.

Three ways to not reuse, all of which ask the reviewer again: no verified record, an unresolvable approved set (deleted run, unreadable comparison, past the commit cap), or a set that differs. Not knowing is not evidence that an approval still applies.

The 250-commit refusal applies to the comparison too, measured against total_commits. It matters more here than anywhere else: an omitted breaking commit would leave the fingerprint unchanged, and the approval would be honoured for a change it never covered.


🟠 A dispatched run could pass a cancelled approval as superseded

The newer-run check counted any newer run of this workflow on the branch. Dispatch one by hand, cancel the pending approval, and the verdict reads superseded — which exits 0. pr-validation.yml reads only the exit code, so the result=failure sitting in the outputs blocks nothing. A green gate with no approval, for anybody with write access. self-pr-validation.yml enables workflow_dispatch and skips validation on it, so the dispatched run does not even have to do anything.

The (.pull_requests | length) == 0 arm is mine, from #932. CodeRabbit proposed the strict filter there and I widened it so cancellations on forks would not read as unresolved. That is the hole: it accepted any run with no pull request attached, which is exactly what a dispatched run looks like.

Runs must now be pull_request — filtered both as a query parameter and in the selection, since the parameter is the server's promise and this decides whether a merge is allowed. Forks still have no pull_requests and are matched on the head instead, which ties the run to this revision rather than to the branch at large.


Type of Change

  • feat: New workflow or new input/output/step in an existing workflow
  • fix: Bug fix in a workflow (incorrect behavior, broken step, wrong condition)
  • perf: Performance improvement (e.g. caching, parallelism, reduced steps)
  • refactor: Internal restructuring with no behavior change
  • docs: Documentation only (README, docs/, inline comments)
  • ci: Changes to self-CI (workflows under .github/workflows/ that run on this repo)
  • chore: Dependency bumps, config updates, maintenance
  • test: Adding or updating tests

Breaking Changes

None. at-sha and base-ref are new optional inputs on breaking-change-commits, both defaulting to empty, and empty keeps the existing path exactly. No reusable-workflow input or output changes.

An approval granted before this merges is still reused, provided it covers the current set — which is the whole point. What stops being reused is one that never covered it.

Testing

  • YAML syntax validated locally
  • Triggered a real workflow run on a caller repository using @this-branch or the beta tag
  • Verified all existing inputs still work with default values
  • Confirmed no secrets or tokens are printed in logs
  • Checked that unrelated workflows are not affected

actionlint clean, shellcheck clean, typos clean. Suites: 112 workflow tests, 60 detector, 32 gate-scope, 28 commit-list (6 new), 22 CODEOWNERS.

Both attacks are encoded as tests, and both were verified by mutation rather than by passing:

  • test_a_forged_record_cannot_reuse_someone_elses_approval — runs the confirmation step with APPROVED_FINGERPRINT=fp(A) and FINGERPRINT=fp(A+B), which is the forged case, and requires false. Removing the comparison makes it pass again.
  • test_a_dispatched_run_is_not_supersession — workflow_dispatch with and without a pull request attached, plus schedule. Removing the event filter makes all three pass as superseded again.

Plus test_the_approved_set_is_read_from_the_run_not_the_record (the lookup no longer decides reuse by itself), test_a_run_from_a_fork_is_matched_on_the_head_not_the_branch, and six cases in the composite suite covering the at-sha path, its own cap refusal, and that adding a breaking commit changes the fingerprint.

Related Issues

Addresses #926 (comment).

Two things @fredcamaral listed as unchecked are still unchecked here: the outcome job after a whole run is cancelled by hand (it relies on if: always()), and whether GitHub reports MEMBER for private organisation memberships — which would widen who can write an accepted record. Worth a follow-up; the fix above does not depend on either, since the record's author no longer determines what gets reused.

Reported by @fredcamaral on #926 and reproduced. The newer-run check
counted any newer run of this workflow on the branch, so: dispatch a run
by hand, cancel the pending approval, and the verdict reads `superseded`
- which exits 0. A green gate with no approval, available to anybody with
write access. `self-pr-validation.yml` enables `workflow_dispatch` and
skips validation on it, so the dispatched run does not even have to do
anything.

The `(.pull_requests | length) == 0` arm is mine, from #932. CodeRabbit
proposed the strict filter there and I widened it so cancellations on
forks would not read as unresolved. That is the hole: it accepted any run
with no pull request attached, which is exactly what a dispatched run
looks like.

Runs must now be `pull_request`, filtered both as a query parameter and
in the selection - the parameter is the server's promise, and this
decides whether a merge is allowed. Forks still have no `pull_requests`
and are matched on the head instead, which ties the run to this revision
rather than to the branch at large.

Verified by reverting the filter: the three dispatched and scheduled
cases pass as superseded again, and the fork case stops distinguishing
heads.
…ecord

Reported by @fredcamaral on #926. The reuse lookup took the fingerprint
from the record itself, so it only ever established that *an* approval
happened on this branch - never what that approval covered.

The fingerprint is derived from public commit SHAs, so it is computable
before the commit exists. A member posts a record carrying fp(A,B) that
points at the run which approved only A, then pushes the breaking commit
B. Every check passed: the record is from a MEMBER, the run is on this
branch, the run really did carry an Environment approval. The verdict
came back approved, exit 0, no reviewer involved. Commenting needs no
write access.

The record now says only which run to ask, and the run answers.
`breaking-change-commits` gains an `at-sha` mode that resolves the set
from a comparison against a past revision - the pull request's commits
endpoint cannot, it always reports the current state - and the lookup
points it at the approving run's own `head_sha`, which nobody can write.
Reuse requires that resolved fingerprint to equal this one.

Three ways to not reuse, all of which ask the reviewer again: no verified
record, an unresolvable approved set (deleted run, unreadable comparison,
past the commit cap), or a set that differs. Not knowing is not evidence
that an approval still applies.

The 250-commit refusal applies to the comparison too, measured against
`total_commits`. Answering from a truncated one matters more here than
anywhere else: an omitted breaking commit would leave the fingerprint
unchanged and the approval would be honoured for a change it never
covered.

Verified by removing the comparison: the forged case passes again.
@bedatty
bedatty requested a review from a team as a code owner October 20, 2026 13:05
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Warning

Review limit reached

The included review limit has been reached and this organization has disabled usage-based review continuation. Wait for reviews to reset or ask a billing admin to change After included review limits.

  • Ask an admin to enable usage-based reviews

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Next included review available in 43 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 92 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LerianStudio/github-actions-shared-workflows/.coderabbit.yml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 822aaadd-1a9a-4958-8d9f-97882398a823
📥 Commits

Reviewing files that changed from the base of the PR and between da3bf82 and 2ee4cdb.

📒 Files selected for processing: 3
  • src/validate/breaking-change-commits/README.md
  • src/validate/breaking-change-commits/action.yml
  • src/validate/breaking-change-guard/test-workflow.py

Walkthrough

The workflow now verifies a candidate approval against the breaking-commit fingerprint resolved at the approving run’s head. The resolver supports revision-specific comparisons. Supersession checks now filter newer runs by workflow, event type, and pull request.

Changes

Breaking-change approval

Layer / File(s) Summary
Revision-specific commit resolution
src/validate/breaking-change-commits/action.yml, src/validate/breaking-change-commits/resolve.sh, src/validate/breaking-change-commits/test.sh, src/validate/breaking-change-commits/README.md
The action accepts optional at-sha and base-ref inputs. The resolver compares revisions through the repository comparison API and rejects failures or truncated results. Tests cover revision resolution, commit limits, fingerprint changes, and missing base references.
Approval reuse verification
.github/workflows/breaking-change-approval.yml, docs/breaking-change-approval.md, src/validate/breaking-change-guard/test-workflow.py
The workflow records an approval candidate and its head SHA, then resolves its breaking-commit set and compares fingerprints before reusing approval. Documentation and tests cover the verification and rejection cases.
Cancelled-run supersession filtering
.github/workflows/breaking-change-approval.yml, src/validate/breaking-change-guard/test-workflow.py
Supersession checks require a newer run from the same workflow and a matching pull request event and pull request. Fork runs without pull-request metadata can match by head SHA. Tests reject dispatched and scheduled runs as supersession evidence.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Merge Risk: 🔵 Low · up to da3bf

This change tightens breaking-change approval reuse and supersession, and both paths fail closed. One gap remains: direct users of the commit resolver's new revision-specific mode need repository-contents read access, which the docs do not mention. Without it, approvals are re-requested on every push and nothing explains why. This is safe to merge with a small documentation follow-up.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 3 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly identifies the primary change: closing two approval-gate paths in PR validation.
Description check Passed The description is complete and directly addresses the two security fixes, behavior changes, breaking-change impact, testing, affected workflows, and related issue. It also records that a real caller …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is complete and directly addresses the two security fixes, behavior changes, breaking-change impact, testing, affected workflows, and related issue. It also records that a real caller workflow run was not performed.

Full details: Docstring Coverage

Explanation

Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 3 files. (4 skipped: 4 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@lerian-studio lerian-studio added size/L PR changes 500–999 lines documentation Improvements or additions to documentation workflow Changes to one or more reusable workflow files validate Changes to PR validation composite actions (src/validate/) composite Changes to any composite action manifest (src/**/*.yml) scripts Changes to shell scripts (src/**/*.sh) labels Oct 20, 2026
@lerian-studio

lerian-studio commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🔍 Lint Analysis

Check Files Scanned Status
YAML Lint 2 file(s) ✅ success
Action Lint 1 file(s) ✅ success
Pinned Actions 2 file(s) ✅ success
Markdown Link Check 2 file(s) ✅ success
Spelling Check 7 file(s) ✅ success
Shell Check 2 file(s) ✅ success
README Check 2 file(s) ✅ success
Composite Schema 1 file(s) ✅ success
Deployment Matrix no changes ⏭️ skipped

🔍 View full scan logs

@lerian-studio

lerian-studio commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🔍 PR Validation Summary

✅ PR Mergeable — no blocking failures

Check Status Blocking
Source Branch ✅ success yes
PR Title ✅ success yes
PR Description ✅ success yes
Breaking Change Guard ✅ success yes
Commit Signatures ✅ success yes
PR Size ✅ success no
Auto Labels ✅ success no
PR Metadata ✅ success no

🔍 View workflow run

@lerian-studio

lerian-studio commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

🛡️ CodeQL Analysis Results

Languages analyzed: actions

✅ No security issues found.


🔍 View full scan logs | 🛡️ Security tab

@lerian-studio

This comment has been minimized.

@lerian-studio lerian-studio added the review-ready Required checks passed — CodeRabbit is cleared to review label Oct 20, 2026
@coderabbitai

This comment has been minimized.

coderabbitai[bot]

This comment was marked as resolved.

CodeRabbit on #940. The `at-sha` path reads the comparison API, which is
repository contents rather than pull request data, while the token
description and the README still said `pull-requests: read` alone.

The failure mode is the quiet kind: a caller granting exactly the
documented scope gets a failed comparison, the gate reads that as "cannot
confirm what that approval covered", and the reviewer is asked again on
every push with nothing saying why. The step is `continue-on-error`, so
there is no red check to follow either.

`breaking-change-approval.yml` already grants `contents: read` to its
`request` job, so the gate itself was never affected - direct callers of
the composite were. A test now pins that grant, since the reuse path
depends on it and losing it would fail the same silent way.
@lerian-studio

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bedatty
bedatty merged commit 6eeb4d8 into develop Oct 20, 2026
47 checks passed
@github-actions
github-actions Bot deleted the fix/gate-forged-reuse-and-dispatch-supersession branch October 20, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

composite Changes to any composite action manifest (src/**/*.yml) documentation Improvements or additions to documentation review-ready Required checks passed — CodeRabbit is cleared to review scripts Changes to shell scripts (src/**/*.sh) size/L PR changes 500–999 lines validate Changes to PR validation composite actions (src/validate/) workflow Changes to one or more reusable workflow files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants