verify-source-run Action Reference¶
actions/verify-source-run is the selection step of a trusted
workflow_run publisher: it decides which producer run this job is allowed
to report on, resolves the pull request that run belongs to, and unpacks
its artifact as hostile input. It analyses nothing.
Task owner for the whole pattern: Reporting on fork pull requests. Design record: ADR-073.
It exists as an Action because every project attempting the two-workflow split needs the same checks, and they are the ones that are easy to get subtly wrong and impossible to notice when wrong.
Inputs¶
| Input | Default | Description |
|---|---|---|
source-run-id |
(required) | The producer run — github.event.workflow_run.id. |
expect-repository |
current repo | owner/repo the run must belong to. |
expect-workflow |
(empty) | Workflow path (.github/workflows/abi.yml) or name the run must be. Strongly recommended. |
expect-event |
pull_request |
Event the run must have been triggered by. |
expect-run-attempt |
(empty) | Attempt the run must be. Empty means any. |
allowed-conclusions |
success |
Comma-separated. Explicitly empty allows any conclusion — a real choice for a publisher that reports analysis failures. |
artifact-name |
(required) | Artifact to download from that run. |
tested-sha |
(empty) | The commit the producer actually analysed, if it recorded one, as a full 40- or 64-character SHA. Verified against the PR head. Empty means the run's own head SHA. An abbreviated SHA is refused: verification is by exact equality against values the API states in full, and prefix-matching a value that decides which commit a trusted comment claims was analysed would be ambiguous by construction. |
provenance-from |
(empty) | Read the analysed commit out of this artifact, at this relative path inside it — normally aggregate.json, whose analysis_context block actions/aggregate records when asked to (record-analysis-context: 'true'). Replaces the two-pass sequence a caller otherwise has to write; see Reading the analysed commit in one pass. |
require-provenance |
true |
Exactly true or false — any other spelling is refused rather than read as false, since that direction silently disables the requirement. With provenance-from set, what an artifact recording no analysis context means. true refuses: the identity is unestablished, and substituting the PR head would name a tree the analysis never saw. false falls back to the run's head SHA and says so on tested-sha-source. |
report-from |
(empty) | The report this publication is about, at this relative path inside the artifact. Returned on report-path together with the verified identity, so the document a caller renders is the one whose context was checked — which is why, when provenance-from is also set, the two must name the same document. Neither may contain a .. or . component, a leading /, or a backslash: both are resolved inside the extracted artifact and may not name anything outside it. |
claimed-pr-number |
(empty) | A PR number the artifact states. Cross-checked only; a disagreement fails the step. |
destination |
abicheck-source-artifact |
Directory to extract into. |
max-total-bytes |
67108864 |
Total uncompressed bytes the artifact may expand to. |
max-entry-bytes |
33554432 |
Uncompressed bytes any single entry may expand to. |
max-entries |
2000 |
Maximum entries. |
max-ratio |
200 |
Maximum uncompressed-to-compressed expansion ratio. |
github-token |
(required) | Token with actions: read. |
python-version |
3.13 |
For actions/setup-python. |
abicheck-version |
(empty) | PyPI version to install instead of this Action's own checkout. |
Outputs¶
| Output | Description |
|---|---|
verified |
true when every check passed. |
pr-number |
The pull request the API associates with this run. |
pr-head-sha |
The pull request's own head commit. |
tested-sha |
The commit that was actually analysed, verified as the PR head or a merge of it. |
tested-sha-source |
analysis-context when the producer recorded the analysed commit and the API confirmed the association, input when the caller stated it on tested-sha and it was verified against this pull request, run-head when neither applied and the value is the producer run's own head commit. Branch on this, not on whether tested-sha is empty — it always holds something, and a caller-stated commit is not the fallback run-head would read as. |
provenance |
recorded | absent | not-requested. Distinguishes "the producer said what it analysed" from "it did not" from "we never asked". |
report-path |
The verified report named by report-from, or empty. |
report-available |
false when the verified run produced no such member — an unavailable analysis, which is not a clean compatibility result. |
from-fork |
true when the PR head is in a different repository. |
artifact-path |
Directory the artifact was extracted into. |
refusal-code |
Machine-readable refusal reason; empty on success. |
The three questions it answers¶
Is this the run we think it is?¶
The run's repository, workflow identity, triggering event, id, attempt and conclusion are each checked against what the publisher declared. Repository and workflow are the two that make this a security boundary rather than a sanity check: without them the publisher can be handed any run id on GitHub and will fetch, unpack and publish whatever that run produced.
Which pull request, and was the analysed commit really its?¶
The pull request is resolved through
GET /repos/{repo}/commits/{sha}/pulls — not from the run document's own
pull_requests array, which is empty for a fork's pull request (exactly the
case this design exists for), and never from the artifact. A PR number
the artifact supplies is only ever cross-checked; a disagreement is a
refusal, because an artifact that could choose the PR number could make a
fork's analysis post onto an unrelated pull request.
Ambiguity is a failure, not a choice: no associated PR, more than one open one, or a PR whose base is a different repository each refuse.
The PR head SHA and the commit that was actually built are kept
distinct. A pull_request workflow checks out an ephemeral merge commit, so
these routinely differ, and conflating them is a real reporting error in both
directions: reporting the merge SHA as the head makes the comment unmatchable
against the commit list, and reporting the head when a merge was analysed
claims coverage of a tree nothing looked at. The association is verified —
the analysed commit must be the PR head, or a merge that includes it:
two or more parents, one of them the head. "Has the head as a parent" alone
is not enough, because an ordinary single-parent commit built on top of the
pull request satisfies it while carrying a tree CI never built.
Is the archive safe to unpack?¶
The whole central directory is validated before a byte is written — sizes, entry count, entry types and every entry's resolved destination — so a refusal leaves the destination empty rather than a partial tree, and the caps are re-enforced against the bytes actually read so a lying header buys nothing.
Refused: absolute paths, .. traversal, symlinks and every other
non-regular entry — checked by the entry's recorded Unix type, so the rule
covers kinds the zip format could carry rather than a list of the ones worth
naming; and archives over the total, per-entry, entry-count or
compression-ratio caps. Extracted files are written without an executable
bit. Nothing in an artifact is executed, imported, pickle-d or
yaml.load-ed — json.loads is the only consumer.
Reading the analysed commit in one pass¶
On a pull_request run the producer checks out an ephemeral merge
commit. No GitHub API endpoint names it, so only the producer can say what
was analysed — and reporting the PR head instead claims analysis of a tree
nothing looked at.
Before provenance-from, a caller had to write this:
- run this Action once, to get the artifact;
- parse a sidecar file out of it, in the privileged job;
- run this Action a second time with
tested-shaset, to verify the claim — downloading the same artifact again.
That acquires the same bytes twice with no guarantee the second copy is the first (an artifact can be replaced between the two requests), and puts parsing of contributor-produced content in the job holding the write token.
provenance-from closes both. The producer records the commit in the
canonical aggregate document via actions/aggregate's
record-analysis-context; this Action reads that block out of the artifact
it has already extracted, through the same bounded reader every other
member goes through, shape-checks every field before it can reach a step
output, and then verifies the claim's association with the pull request
against the API exactly as a tested-sha input would be. One download, one
extraction, no artifact parsing in the trusted job.
The claim is still a claim. What changes is where it travels and who parses it — not whether it is verified.
- uses: abicheck/abicheck/actions/verify-source-run@<sha>
id: source
with:
source-run-id: ${{ github.event.workflow_run.id }}
expect-repository: ${{ github.repository }}
expect-workflow: .github/workflows/ci.yml
expect-run-attempt: ${{ github.event.workflow_run.run_attempt }}
artifact-name: abicheck-reports-${{ env.PROFILE }}
provenance-from: aggregate.json
report-from: aggregate.json
github-token: ${{ github.token }}
- uses: abicheck/abicheck/actions/report@<sha>
with:
report: ${{ steps.source.outputs.report-path }}
sha: ${{ steps.source.outputs.tested-sha }}
# ...
report-path and tested-sha come back together, so the document rendered
is the one whose context was checked — not a path the caller reassembled
from artifact-path and a filename, which can name a member nothing looked
at. A verified run that produced no such member answers report-available:
'false', which is an explicit unavailable-analysis state rather than a path
that does not resolve.
Refusal codes¶
| Code | Meaning |
|---|---|
run-unreadable |
The run document is not a JSON object. |
wrong-repository |
The run belongs to another repository. |
wrong-workflow |
The run is a different workflow. |
wrong-event |
The run was triggered by a different event. |
wrong-run / wrong-attempt |
The fetched run or attempt is not the declared one. |
wrong-conclusion |
The run's conclusion is not in the allowed set. |
no-pull-request |
No pull request in this repository is associated with the run's head commit. |
ambiguous-pull-request |
Several are; the publisher will not guess. |
pull-request-mismatch |
The artifact claims a different pull request than the API reports. |
require-provenance-invalid |
require-provenance was neither exactly true nor exactly false. Refused rather than read as false, since that direction silently disables the requirement. |
member-name-unsafe |
provenance-from or report-from names something outside the extracted artifact — an absolute path, a backslash, a . or .. component — or contains a control character. |
member-name-mismatch |
provenance-from and report-from name different documents, so tested-sha and report-path could not be one verified identity. |
no-tested-sha |
No analysed commit was recorded. |
analysis-context-absent |
provenance-from named a document that does not exist or carries no analysis_context block, under require-provenance: true. |
analysis-context-malformed |
A recorded field is not in the form that field accepts (a SHA that is not full hex, a ref carrying a control character, a value of the wrong type). |
analysis-context-unsupported-schema |
The block declares a schema this build does not read. |
head-sha-mismatch |
The run's head is not the pull request's head. |
unverified-tested-sha |
A non-head commit was analysed and no commit document was supplied to establish the relationship. |
unassociated-tested-sha |
The analysed commit is neither the PR head nor a merge of it. |
artifact-not-found / ambiguous-artifact / artifact-expired |
The named artifact is missing, duplicated, or gone. |
artifact-unreadable |
The download is not a readable zip, or a document is not readable JSON. |
artifact-bad-path / artifact-absolute-path / artifact-traversal |
An entry names no file, an absolute path, or one outside the destination. |
artifact-symlink / artifact-non-regular |
An entry is a symlink or another non-regular type. |
artifact-too-many-entries / artifact-entry-too-large / artifact-too-large |
A cap was exceeded. |
artifact-compression-bomb |
An entry, or the archive, expands beyond the ratio limit. |
What this boundary does not establish¶
It does not make the report's contents trusted evidence. See the warning at the top of Reporting on fork pull requests: these checks are about recipient and execution safety, and the report's own recorded evidence facts remain the only statement of assurance and provenance.