Skip to content

chore(ci): pin every workflow action to a full commit SHA - #20

Merged
abrichr merged 1 commit into
mainfrom
chore/pin-workflow-actions-to-shas
Jul 27, 2026
Merged

chore(ci): pin every workflow action to a full commit SHA#20
abrichr merged 1 commit into
mainfrom
chore/pin-workflow-actions-to-shas

Conversation

@abrichr

@abrichr abrichr commented Jul 27, 2026

Copy link
Copy Markdown
Member

What was floating

ci.yml and release.yml were the only two workflows in this repo still on mutable refs. codeql.yml, dependency-review.yml and secret-scan.yml were already fully SHA-pinned, as are openadapt-ops and openadapt-tray. Twelve uses: lines in total:

Workflow Action Was
ci.yml actions/checkout @v7
ci.yml actions/setup-python @v7
release.yml actions/checkout (×3) @v7
release.yml actions/setup-python @v7
release.yml actions/setup-node @v7
release.yml actions/upload-artifact (×2) @v7
release.yml actions/download-artifact @v8
release.yml pypa/gh-action-pypi-publish (×2) @release/v1

The last one is the reason this is not a style nit. release/v1 is a branch, not a tag. It sits on the pypi-publish job, which runs with id-token: write and environment: pypi — i.e. this repo's PyPI Trusted Publishing identity. Whoever can move that branch chooses what code runs with those credentials, on a tag push, when nobody is watching. Major tags like @v7 are mutable for the same reason, just less pointed.

How each SHA was resolved

Pinning only — no action was upgraded. Every SHA came from the upstream repo's own refs, not from another repo's workflow and not from a search result:

  • git ls-remote --tags <upstream> for each action. None of the actions/* tags produced a ^{} peel line, so they are lightweight tags whose ref is the commit.
  • Re-confirmed each SHA is a real commit in that repo via gh api repos/<owner>/<repo>/commits/<sha>.
  • Checked each floating ref against the exact version tag, so the pin is byte-identical to what runs today:
Action Pinned SHA Tag Floating ref resolves to
actions/checkout 3d3c42e5aac5ba805825da76410c181273ba90b1 v7.0.1 v7 == v7.0.1
actions/setup-python 5fda3b95a4ea91299a34e894583c3862153e4b97 v7.0.0 v7 == v7.0.0
actions/setup-node 820762786026740c76f36085b0efc47a31fe5020 v7.0.0 v7 == v7.0.0
actions/upload-artifact 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a v7.0.1 v7 == v7.0.1
actions/download-artifact 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c v8.0.1 v8 == v8.0.1
pypa/gh-action-pypi-publish ba38be9e461d3875417946c167d0b5f3d385a247 v1.14.1 release/v1 HEAD == v1.14.1 ✅

No floating ref resolved to anything older than a pin used elsewhere in the org, so nothing needed noting and nothing was silently upgraded. checkout@v7.0.1 and setup-python@v7.0.0 independently matched the SHAs already in this repo's codeql.yml and in openadapt-ops.

The PyPI publish action specifically

pypa/gh-action-pypi-publish's latest v1.x tag is v1.14.1 (annotated tag object 2834a314…, dereferencing to commit ba38be9e461d3875417946c167d0b5f3d385a247). The release/v1 branch head is currently that same commit — so this pin changes nothing about what executes, it only stops the ref moving underneath us. openadapt-tray already pins this exact SHA.

Regression guard

test_release_actions_are_pinned_to_commits is ported from openadapt-tray's test of the same name. It lives in tests/test_distribution.py rather than a new file because the release workflow already runs that file specifically as its pre-publish version-consistency gate — so the guard now sits on the publish path itself, not only in CI. A companion test_every_workflow_action_is_pinned_to_a_commit covers the other four workflows and additionally requires the trailing # vX.Y.Z comment, so the human-readable version can't be lost to the SHA.

Both were verified to actually bite: run against the pre-change workflows they fail and name all 12 floating refs; stripping a version comment fails the second one.

Scope

Pinning only. No trigger, job, step, permission, condition or version was changed — the diff is uses: lines plus the two tests. python -m pytest -q → 74 passed; ruff check src tests scripts → clean; all five workflows still parse as YAML.

Note on the publish path

Green checks here do not exercise publishing, and I'm not claiming they do. release.yml's path filter includes .github/workflows/release.yml, so this PR runs the validate job (explicit dry run, no publish) — that covers the pinned checkout / setup-python / setup-node / upload-artifact. The pypi-publish job is gated on push of a v* tag or a published Release, neither of which this PR does, so the pinned download-artifact and gh-action-pypi-publish are not executed. The correctness argument for those is the resolution table above: each pinned SHA is exactly the commit its floating ref points at today, so the publish job runs identical action code. openadapt-agent on PyPI (latest 2.0.1, 2 releases, last upload 2026-07-23) must be unchanged after this merges, and will be checked.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM

release.yml and ci.yml were the outliers in this org: codeql.yml,
dependency-review.yml and secret-scan.yml in this same repo were already
SHA-pinned, as are openadapt-ops and openadapt-tray.

The one that mattered was `pypa/gh-action-pypi-publish@release/v1` on the
PyPI publish job. `release/v1` is a *branch*, not a tag, so whoever can
move that branch decides what code runs with this repo's PyPI Trusted
Publishing OIDC identity (`id-token: write`, `environment: pypi`) at a
moment nobody is watching. The remaining eleven `uses:` lines floated on
major tags, which are mutable for the same reason.

Pinning only -- no action was upgraded. Each SHA was resolved from the
upstream repo's own tag refs (`git ls-remote --tags`, all lightweight, so
each ref is already the commit) and re-confirmed as a commit object via
the GitHub API, then checked to be exactly what the floating ref resolves
to today:

  actions/checkout@v7          -> 3d3c42e5 (v7.0.1; v7 == v7.0.1)
  actions/setup-python@v7      -> 5fda3b95 (v7.0.0; v7 == v7.0.0)
  actions/setup-node@v7        -> 820762786 (v7.0.0; v7 == v7.0.0)
  actions/upload-artifact@v7   -> 043fb46d (v7.0.1; v7 == v7.0.1)
  actions/download-artifact@v8 -> 3e5f45b2 (v8.0.1; v8 == v8.0.1)
  pypa/gh-action-pypi-publish  -> ba38be9e (v1.14.1)

For the publish action, `release/v1` currently points at the same commit
as the v1.14.1 tag, so pinning to the tag's commit is byte-identical to
what runs today -- it just stops moving underneath us.

`test_release_actions_are_pinned_to_commits` is ported from
openadapt-tray's test of the same name so this cannot regress; it lives in
tests/test_distribution.py, which the release workflow already runs as its
own version-consistency gate before any publish step. A companion test
covers the other workflows and requires the trailing `# vX.Y.Z` comment so
the human-readable version is never lost to the SHA. Both fail against the
pre-change workflows (12 floating refs) and pass after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NyCHrzA1psrKMFfroYbzaM
@abrichr
abrichr merged commit bd42825 into main Jul 27, 2026
10 checks passed
@abrichr
abrichr deleted the chore/pin-workflow-actions-to-shas branch July 27, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant