GitHub Actions · intermediate
How to verify GitHub Actions artifact lineage
Verify that a GitHub Actions artifact downloaded by a consumer was produced by the intended workflow run, ref, and commit before trusting its contents. This playbook walks through collecting run metadata, comparing it to local expectations, and confirming artifact identity using read-only GitHub CLI commands anchored to the Actions workflow syntax documentation.
The symptoms
- •The downloaded artifact is treated as belonging to the wrong workflow, branch, tag, or commit than the one currently in production or under review.
- •Consumers report version drift: the artifact contents do not match the commit SHA deployed to a related environment.
- •Two artifacts share a name but differ in contents, so a pipeline step or downstream job picked up a stale upload from a prior run.
- •Reproducible-build or supply-chain checks fail because the artifact's declared provenance cannot be reconciled with the run that produced it.
- •An audit or incident review requires evidence linking an artifact back to a specific workflow_run id, ref, and commit SHA.
Likely causes
- •The artifact name collides with an earlier upload, so actions/upload-artifact returned a renamed or overwritten entry without the consumer noticing.
- •A consumer downloaded the artifact via the repository's latest-run listing instead of pinning the workflow_run id or the commit SHA.
- •Inputs such as ref, workflow, or commit SHA were not pinned when locating the artifact, so the lookup silently matched a different branch or run.
- •Upload and download steps disagree on artifact name, path, or retention, causing a partial or substituted archive to be retrieved.
- •The artifact was re-uploaded or re-tagged by a later job, decoupling the on-disk archive from the originally observed run.
First ten minutes
- 01Capture the exact evidence on hand: the consumer's reported commit SHA, branch or tag, workflow file, and the artifact filename in question.
- 02Identify the repository and owner precisely; do not rely on display names when more than one fork or organization copy exists.
- 03Confirm that the GitHub CLI is authenticated for the target repository with read access to Actions artifacts before any inspection command.
- 04List recent workflow runs for the suspected workflow and record the workflow_run id, head branch, and head SHA associated with each.
- 05Pull the artifact metadata for the suspected run and compare name, id, and size against the consumer's expectation before opening any archive.
Evidence to collect
- •The workflow_run id, display title, head branch or tag, and head SHA from the Actions run list for the suspected workflow.
- •Artifact metadata for the suspected run: artifact id, name, size in bytes, archive download URL host, created_at timestamp, and expiry if present.
- •The triggering event and actor for the run, taken from the run's metadata, to corroborate who and what started it.
- •Local checksum (for example SHA-256) of the downloaded archive compared against the artifact's stated size to detect truncation or substitution.
- •The workflow YAML's inputs and the literal artifact name declared in the upload step that produced the archive in question.
Where to look
- •The Actions run list for the target repository and workflow, exposed via `gh run list`, restricted to the workflow name in question.
- •The Actions artifact list for a specific run, exposed via `gh api` on the run's artifacts endpoint, where each artifact has a stable id.
- •The workflow file checked into the repository at the head SHA of the suspected run, to read the literal artifact name and upload step.
- •The Actions run's triggering event payload, available via `gh api` on the run endpoint, to confirm ref, event name, and actor.
- •The consumer's own invocation record, showing which artifact id, run id, or URL the consumer actually requested.
Diagnostic steps
- 01Run `gh run list --workflow <workflow> --limit <n>` to enumerate candidate runs and record workflow_run id, head SHA, and head branch for each.
- 02Run `gh api repos/<owner>/<repo>/actions/runs/<run_id>/artifacts` to list artifacts attached to the suspected run and capture each artifact's id, name, size, and archive URL host.
- 03Run `gh api repos/<owner>/<repo>/actions/runs/<run_id>` to read the run's head_branch, head_sha, event, and triggering actor and compare against the consumer's expectation.
- 04Run `gh api repos/<owner>/<repo>/contents/<path>?ref=<head_sha>` for the workflow file to confirm the artifact name declared at the upload step matches the downloaded name.
- 05Compute the local SHA-256 of the downloaded archive and compare it byte-for-byte with the artifact's declared size; mismatches indicate truncation or substitution rather than a naming error.
- 06Cross-check the consumer's invocation: confirm that the artifact id or archive URL it used resolves to the same artifact id returned by the run's artifacts endpoint.
Common mistakes
- •Trusting the artifact name alone, which can collide with prior uploads on the same workflow and is not unique across runs.
- •Resolving an artifact by listing "latest" artifacts instead of pinning the workflow_run id and commit SHA explicitly.
- •Assuming that a successful download URL proves lineage, when the URL only proves that the archive was retrieved, not which run produced it.
- •Comparing only the head branch and ignoring the head SHA, so a re-run or post-commit push silently satisfies a partial identity check.
- •Ignoring the workflow file contents and treating the artifact as self-describing, when the upload step's literal name determines what the archive is called.
Safe fixes
- •Pin every consumer lookup to the workflow_run id and head SHA rather than to branch name or "latest" semantics.
- •If names collide, fix the consumer to fetch by artifact id rather than by artifact name; the artifact id is stable for the lifetime of the run.
- •When evidence shows the consumer downloaded from a different run, update the consumer to reference the correct artifact id and record the resolved run id alongside the artifact id.
- •When the workflow's declared artifact name does not match the download, update the consumer to use the literal name from the upload step at the head SHA that produced the run.
- •If the archive's local size does not match the artifact's declared size, discard the download and re-fetch by artifact id from the verified run; do not retry the original URL.
Prove the fix
- 01The artifact id resolved by the consumer equals the artifact id returned by the run's artifacts endpoint for the intended workflow_run id.
- 02The run's head SHA, head branch, and event match the commit and event the consumer expected, recorded by the lookup itself.
- 03The artifact name matches the literal value declared in the workflow file at the run's head SHA, with no rename or collision detected.
- 04The local SHA-256 and byte size of the downloaded archive match the artifact metadata reported by the artifacts endpoint.
- 05A follow-up retrieval using only the recorded artifact id and workflow_run id reproduces the same archive contents, demonstrating that the lineage is stable and not dependent on implicit ordering.
Prevention and next steps
- •Make consumers fetch artifacts by artifact id tied to a recorded workflow_run id, and reject lookups that resolve only by name or branch.
- •Standardize artifact names per workflow so collisions across runs are detectable by string comparison, and audit the workflow file at the head SHA before relying on the name.
- •Record the resolved run id, head SHA, and artifact id in the consumer's log so any future mismatch is diagnosable from the consumer side alone.
- •Restrict the set of workflows permitted to upload a given artifact name, and alert when an artifact with that name appears on an unexpected workflow_run.
Safe commands and checks
gh run list --workflow <workflow> --limit <n> gh api repos/<owner>/<repo>/actions/runs/<run_id>/artifacts gh api repos/<owner>/<repo>/actions/runs/<run_id> gh api repos/<owner>/<repo>/contents/<path>?ref=<head_sha> sha256sum <downloaded-archive-path> stat -c '%s' <downloaded-archive-path>