protect-committed-baseline.yml Reference¶
A workflow_call reusable workflow that closes a specific self-approval gap
in a committed baseline: an ordinary PR that both changes the compared
binary/headers and updates the baseline file it's compared against can
make an incompatible change look compatible, because the comparison never
reads the baseline as it stood at the PR's base commit — it reads whatever
the PR's own working tree has, which the same PR just edited.
The gap this closes¶
- name: ABI compatibility check
uses: abicheck/abicheck@v0.6.0
with:
old-library: abi/libfoo.abicheck.json
new-library: build/libfoo.so
old-library: abi/libfoo.abicheck.json resolves that path from whatever is
checked out — on a PR run, that's the PR's own head commit. If the PR also
updates abi/libfoo.abicheck.json to match the (possibly incompatible) new
binary, the comparison silently passes, because it never diffs against the
file's content at the PR's base commit.
Two independent fixes address this, and they compose:
- Read the baseline from the base commit, not the working tree — see
Storing Baselines's recipe using
git show "${{ github.event.pull_request.base.sha }}:path". This is correct by construction: the baseline file the comparison reads can never be the one the PR itself just wrote. - This workflow — a defense-in-depth trusted gate for a project that hasn't (yet, or ever) adopted recipe 1: an ordinary PR that touches a configured baseline path fails outright, forcing a genuine refresh through a dedicated trusted (push-triggered, not PR-diff-triggered) workflow, or an explicit human-reviewed bypass label.
Use either independently, or both together (recipe 1 removes the risk entirely; this workflow catches it even for a baseline path recipe 1 wasn't applied to, or a workflow this project doesn't control the source of).
Inputs¶
| Input | Required | Default | Meaning |
|---|---|---|---|
protected-paths |
yes | — | Newline-separated glob patterns naming committed baseline files, e.g. abi/** or baselines/*/*.abicheck.json. ** matches zero or more path segments (crosses /); a lone * matches within one path segment only (never crosses /); ? matches one character (never /). |
bypass-label |
no | '' |
A PR label that opts a specific PR out of this check (e.g. for an explicitly reviewed, human-approved manual baseline refresh). Empty (default) disables the bypass entirely — every PR touching a protected path fails unconditionally. |
base-sha |
no | github.event.pull_request.base.sha |
Override for a non-pull_request-triggered caller. |
head-sha |
no | github.event.pull_request.head.sha |
Override for a non-pull_request-triggered caller. |
No outputs — this workflow either passes (job succeeds) or fails (job
exits 1 with an ::error:: naming every protected file the PR touched).
.github/workflows/** is always protected too, independent of
protected-paths. This workflow's own protected-paths/bypass-label
configuration is supplied by the calling workflow file, and for an
ordinary pull_request trigger (this workflow's own required, fork-safe
trigger) that file is read from the PR's own head commit — so a PR that
edits the calling workflow (e.g. narrowing protected-paths to a glob
that no longer matches, or removing this check's invocation entirely)
could otherwise silently defeat this whole protection in the same change
that touches the committed baseline. A change under .github/workflows/
therefore always counts as a hit, uses the same bypass-label gate, and
gets its own dedicated error message distinguishing it from an ordinary
protected-paths hit.
Fork safety¶
Runs entirely on an ordinary pull_request trigger: read-only
contents: read/pull-requests: read permissions, the default
GITHUB_TOKEN. Never wire this into a pull_request_target caller —
that would hand a fork PR's own workflow changes elevated permissions for
no benefit this check needs, the same rule
ADR-047 §12
already states for the baseline-publishing workflows.
Residual gap: pin this check with a Ruleset, not just branch protection¶
The .github/workflows/** guard above closes "a PR reconfigures
protected-paths/bypass-label while also calling this reusable
workflow" — it does not close "a PR removes the calling job/step
that invokes this workflow entirely, or replaces it with an unrelated job
that trivially succeeds under the same required-check name". Both are the
same underlying, structural limitation of GitHub's "required status
check, matched by name" model: for an ordinary pull_request trigger,
the calling workflow file — which decides whether/how this reusable
workflow is even invoked — is read from the PR's own head commit, and a
check's identity to branch protection is its name, not which workflow
file (or whether any workflow at all) actually produced it. No logic
inside a workflow_call reusable workflow, this one included, can
observe or prevent the calling workflow choosing not to invoke it.
Closing this fully needs a mechanism the calling repository controls, sourced from a ref a PR cannot rewrite — plain branch-protection "required status checks" (matched by name alone) is not sufficient:
- GitHub Repository Rulesets's "Require workflows to pass before merging" pins a required workflow to a specific file path and ref (the default branch, not the PR head) — unlike a bare required-status-check name, a PR cannot satisfy it by simply not invoking (or renaming) the workflow, since the ruleset itself names which workflow file must run. This is the recommended mitigation for a repository adopting this check.
pull_request_target(reading the calling workflow from the base branch) is deliberately rejected — see "Fork safety" above — since it would hand a fork PR's own workflow/code changes elevated permissions for no benefit this specific check needs.
A single reusable workflow answering "does this PR touch a protected path" cannot also answer "was I actually invoked" — that second question is inherently the calling repository's own branch-protection/Ruleset configuration to answer, not this workflow's.
Example¶
name: Protect committed ABI baseline
on:
pull_request:
# `labeled`/`unlabeled`, not just the default `opened`/`synchronize`/
# `reopened` -- this check's whole approval/revocation flow depends on
# `bypass-label`, and a label-only event carries the SAME head commit
# as the run that just failed. Without these two types, adding
# `baseline-refresh` after a failed check starts no new run at all
# (even a manual rerun of the old job replays its ORIGINAL event
# payload, so `github.event.pull_request.labels` still reads
# pre-label); removing the label afterward is the same gap in
# reverse, leaving a stale success as the required check for an
# unchanged head SHA.
types: [opened, synchronize, reopened, labeled, unlabeled]
paths:
- 'abi/**'
permissions:
contents: read
pull-requests: read
jobs:
protect-baseline:
uses: abicheck/abicheck/.github/workflows/protect-committed-baseline.yml@<PINNED_COMMIT_OR_RELEASE>
with:
protected-paths: |
abi/**
bypass-label: baseline-refresh
A maintainer doing a genuine, reviewed baseline refresh in a normal PR adds
the baseline-refresh label before merging; every other PR touching
abi/** fails until the change is reverted or the baseline update is moved
to a dedicated trusted workflow.
See also¶
- Storing Baselines — the base-commit-read
recipe (fix 1 above), and the
abi-baseline/baseline-set-archive fetch recipes this workflow complements. - Resolving Baselines — the canonical reference for how a baseline (committed file, release asset, or baseline-set archive) is located and validated in the first place; this workflow only protects the committed-file case from silent self-approval once resolved.