Skip to content

Commit eea501b

Browse files
authored
Merge pull request #12 from mariushelf/feat/4-loop-pin-github-actions-to-hashes
pin github actions to hashes
2 parents b55021b + d828f00 commit eea501b

6 files changed

Lines changed: 267 additions & 17 deletions

File tree

‎.github/workflows/ci.yaml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ jobs:
1414
- "3.12"
1515
- "3.13"
1616
steps:
17-
- uses: actions/checkout@v4
17+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1818
- name: Set up Python ${{ matrix.python-version }}
19-
uses: actions/setup-python@v5
19+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
2020
with:
2121
python-version: ${{ matrix.python-version }}
2222
- name: Install uv
23-
uses: astral-sh/setup-uv@v5
23+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
2424
- name: Install dependencies
2525
run: uv sync
2626
- name: Run pytest

‎spec/issue-4.md‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
Pin github actions to version hashes so that they are more robust against supply chain attacks. E.g., instead of `astral-sh/setup-uv@v5`, use `astral-sh/setup-uv@0x123456`
2+
3+
Use the latest versions available.
4+
5+
Do it in both this repo, and in the cicd.yaml in the template.
6+
7+
---
8+
9+
## Spec
10+
11+
**What:** Replace every **third-party** `uses:` reference in all four workflow files with a full
12+
40-character **commit SHA**, followed by a `# vX.Y.Z` comment naming the version that SHA is.
13+
Bump each action to its latest major at the same time (see the pin table). Four files, 17
14+
third-party call sites; the one *local* reusable-workflow ref is deliberately left alone.
15+
16+
**Why:** A mutable ref — `@v4`, `@v10`, and especially `@release/v1` — resolves at run time to
17+
whatever the upstream tag or branch points at *today*. Anyone who can move that tag (upstream
18+
maintainer, or an attacker who compromises the account) executes arbitrary code inside our CI
19+
with our tokens. A commit SHA is immutable, so a compromised upstream cannot retroactively change
20+
what our workflows run. This matters twice over here: once for this repo, and once for the
21+
template, where every generated project inherits the refs verbatim.
22+
23+
The highest-value single fix is `pypa/gh-action-pypi-publish@release/v1` — that is a **branch**,
24+
not even a tag, so it changes under us on every upstream push, and that step holds the PyPI
25+
trusted-publishing identity of every project generated from this template.
26+
27+
### Pin table
28+
29+
Scope confirmed at triage: **all four workflow files**, not only `cicd.yaml`. `publish.yaml` and
30+
`release.yaml` carry the two highest-privilege actions (PyPI publish rights; `contents: write` tag
31+
pushes), so excluding them would leave the ticket's own rationale unserved.
32+
33+
| action | current ref | pin to | version |
34+
|---|---|---|---|
35+
| `actions/checkout` | `@v4` | `3d3c42e5aac5ba805825da76410c181273ba90b1` | `v7.0.1` |
36+
| `actions/setup-python` | `@v5` | `5fda3b95a4ea91299a34e894583c3862153e4b97` | `v7.0.0` |
37+
| `astral-sh/setup-uv` | `@v5` | `20cfd1bf945f4377ade1205e4dbc17946fc9a30d` | `v10.0.1` |
38+
| `pypa/gh-action-pypi-publish` | `@release/v1` | `dc37677b2e1c63e2034f94d8a5b11f265b73ba33` | `v1.14.2` |
39+
| `python-semantic-release/python-semantic-release` | `@v10` | `39dd2052f2ce8282a5d932c31d58a2ca06d2550e` | `v10.6.1` |
40+
41+
Written form, at every call site:
42+
43+
```yaml
44+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
45+
```
46+
47+
The trailing comment is not decoration — it is the only thing that makes the diff reviewable and
48+
tells a future updater what version it is looking at.
49+
50+
### Call sites (17 third-party refs)
51+
52+
- `.github/workflows/ci.yaml` — 3 (checkout, setup-python, setup-uv)
53+
- `template/.github/workflows/cicd.yaml` — 9 (the same three, in each of `lint`, `test`, `docs`)
54+
- `template/.github/workflows/publish.yaml` — 1 (pypi-publish)
55+
- `template/.github/workflows/release.yaml` — 4 (checkout, semantic-release, setup-python, setup-uv)
56+
57+
**Do not pin `release.yaml:28`,** `uses: ./.github/workflows/cicd.yaml`. That is a *local*
58+
reusable workflow inside the generated repo; it is not a supply-chain surface and a SHA there is
59+
meaningless (it would pin the generated project to a commit of its own that does not exist yet).
60+
61+
### Two traps that will silently produce a broken pin
62+
63+
1. **Annotated tags.** `pypa/gh-action-pypi-publish` and `python-semantic-release` publish
64+
*annotated* tags, so `gh api repos/OWNER/REPO/git/ref/tags/vX` returns the **tag object** SHA,
65+
not the commit. Pinning that SHA fails at run time with an unresolvable-action error. It must
66+
be dereferenced (`^{}`). The two SHAs in the table above are already the dereferenced commits.
67+
2. **Do not invent or recall a SHA.** Every hash must be resolved from the API at implementation
68+
time. A hallucinated 40-hex string either fails the run or, far worse, silently pins something
69+
nobody reviewed — which is the exact class of failure this ticket exists to prevent.
70+
71+
Re-resolve all five at build time (upstream may have released since triage; update the table and
72+
say so in the PR body if a version moved):
73+
74+
```bash
75+
for r in actions/checkout actions/setup-python astral-sh/setup-uv \
76+
pypa/gh-action-pypi-publish python-semantic-release/python-semantic-release; do
77+
tag=$(gh api repos/$r/releases/latest --jq .tag_name)
78+
sha=$(gh api "repos/$r/git/ref/tags/$tag" --jq .object.sha)
79+
type=$(gh api "repos/$r/git/ref/tags/$tag" --jq .object.type)
80+
# dereference annotated tags to the commit they point at
81+
[ "$type" = "tag" ] && sha=$(gh api "repos/$r/git/tags/$sha" --jq .object.sha)
82+
echo "$r@$sha # $tag"
83+
done
84+
```
85+
86+
### Major-version bumps: what actually changes
87+
88+
Triage decision: pin at the **latest** major, per the ticket text — not at the current major's
89+
tip. That makes this a `v4→v7` / `v5→v7` / `v5→v10` jump, so the breaking changes were checked
90+
against upstream release notes rather than assumed. Findings:
91+
92+
- **checkout v5+** requires Actions runner ≥ `v2.327.1` (node24). GitHub-hosted `ubuntu-latest`
93+
satisfies this; self-hosted runners in generated projects may not.
94+
- **checkout v7** blocks checking out fork PRs under `pull_request_target` / `workflow_run`.
95+
Neither trigger appears in any of these four files — no impact.
96+
- **setup-python v6** is node24 (same runner floor). **v7** removed the `pip-install` input,
97+
which is not used here.
98+
- **setup-uv v6** removed the `pyproject-file` / `uv-file` inputs, **v8** removed the old custom
99+
version-manifest format. None of the nine setup-uv steps pass *any* `with:` inputs, so both are
100+
no-ops for us.
101+
- **setup-uv v9** changed `prune-cache` to default `false`, and `enable-cache` defaults to `auto`
102+
(caching **on** for hosted runners on `push` / `pull_request`). Consequence: **Actions cache
103+
usage will grow** — here and in every generated project. This is the one real operational cost
104+
of the bump; accepted and recorded, not mitigated.
105+
- **setup-uv v10** makes `auto` *disable* the cache on `release`, tag-push, `pull_request_target`
106+
and `workflow_run` events. `release.yaml` runs on `workflow_dispatch` and `publish.yaml` uses no
107+
setup-uv, so nothing here changes behaviour.
108+
109+
If any of these bumps turns out to break CI, pin that one action at its **current** major's tip
110+
SHA instead and note it in the PR body — hardening the ref is the goal; the version bump is the
111+
part that may be traded away.
112+
113+
### Interaction with #7 and #8 — rebase, not dependency
114+
115+
Both edit the template CI workflow and both declare #4 independent of them:
116+
117+
- **#8** renames `template/.github/workflows/cicd.yaml` → `ci.yaml`. Apply this spec to the
118+
template CI workflow **under whatever name it carries at implementation time**; the file's
119+
content and its nine call sites are unaffected by the rename.
120+
- **#7** adds a `ci-gate` job with **no `uses:` step**, so it adds no pins.
121+
122+
Whichever lands first, the other rebases. There is deliberately **no `Depends-on:`** line: every
123+
file this spec touches exists on `origin/main` today (verified at `b55021b`).
124+
125+
## Acceptance criteria
126+
127+
- [ ] Every third-party `uses:` in `.github/workflows/ci.yaml`,
128+
`template/.github/workflows/{cicd,publish,release}.yaml` (the template CI workflow under its
129+
current name) references a full 40-hex commit SHA — 17 call sites, none left on a tag or
130+
branch ref.
131+
- [ ] Each pinned ref carries a trailing `# vX.Y.Z` comment naming that SHA's version.
132+
- [ ] `pypa/gh-action-pypi-publish` no longer references the `release/v1` **branch**.
133+
- [ ] `release.yaml`'s `uses: ./.github/workflows/<template-ci>.yaml` local reusable-workflow ref
134+
is left unpinned and otherwise unchanged.
135+
- [ ] Every SHA was resolved from the GitHub API during implementation (not recalled), and each
136+
one is a **commit** object — verified for the two annotated-tag actions with
137+
`gh api repos/OWNER/REPO/git/commits/<sha> --jq .sha` returning that same SHA.
138+
- [ ] The PR body states the five `action@sha # version` pairs actually used, and flags any that
139+
differ from the table above because upstream released in the meantime.
140+
- [ ] `git grep -nE 'uses:' -- .github template | grep -v '@[0-9a-f]\{40\}'` returns exactly one
141+
line: the local `./.github/workflows/…` reusable-workflow ref. (On `origin/main` at
142+
`b55021b` this same command returns 18 lines — 17 third-party refs plus that local one.)
143+
- [ ] `uv run pytest tests` is green.
144+
- [ ] This repo's own CI run on the PR passes on the newly pinned `checkout`/`setup-python`/
145+
`setup-uv` majors — i.e. the bump is proven, not just asserted.
146+
- [ ] Commit uses a `ci:` prefix and carries no AI attribution trailer.
147+
148+
## Out of scope
149+
150+
- **Dependabot / Renovate config** to keep the pins fresh. Pinned SHAs never update on their own,
151+
so without an updater they freeze and miss upstream security fixes. Confirmed at triage as its
152+
own follow-up ticket for both this repo and the template — not this PR.
153+
- Pinning tool versions outside GitHub Actions (pre-commit hook revs, `pipx install
154+
python-semantic-release` in `release.yaml`'s release-notes step, base images).
155+
- Any change to what the workflows *do* — jobs, triggers, matrix, steps. Only `uses:` refs move,
156+
plus the incidental behaviour that the major bumps bring with them.
157+
- Renaming `cicd.yaml` (#8) or adding the `ci-gate` job (#7).
158+
159+
## Verification
160+
161+
```bash
162+
# every third-party ref is a 40-hex SHA; only the local reusable workflow may remain
163+
git grep -nE 'uses:' -- .github template | grep -v '@[0-9a-f]\{40\}'
164+
165+
uv run pytest tests # needs network; renders a project and runs its lint/test/pre-commit
166+
```
167+
168+
Then confirm the PR's own CI run is green — that is the only real proof the major bumps hold.

‎template/.github/workflows/cicd.yaml‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,13 @@ jobs:
1616
platform: [ubuntu-latest]
1717
runs-on: ${{ matrix.platform }}
1818
steps:
19-
- uses: actions/checkout@v4
19+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2020
- name: Set up Python
21-
uses: actions/setup-python@v5
21+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
2222
with:
2323
python-version: ${{ matrix.python-version }}
2424
- name: Install uv
25-
uses: astral-sh/setup-uv@v5
25+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
2626
- name: Install dependencies
2727
run: uv sync --no-sources
2828
- name: ruff format
@@ -42,27 +42,27 @@ jobs:
4242
- "3.12"
4343
- "3.13"
4444
steps:
45-
- uses: actions/checkout@v4
45+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
4646
- name: Set up Python ${{ matrix.python-version }}
47-
uses: actions/setup-python@v5
47+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
4848
with:
4949
python-version: ${{ matrix.python-version }}
5050
- name: Install uv
51-
uses: astral-sh/setup-uv@v5
51+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
5252
- name: Install dependencies
5353
run: uv sync --no-sources
5454
- name: Run pytest
5555
run: uv run --no-sources pytest
5656
docs:
5757
runs-on: ubuntu-latest
5858
steps:
59-
- uses: actions/checkout@v4
59+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6060
- name: Set up Python
61-
uses: actions/setup-python@v5
61+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
6262
with:
6363
python-version: "3.12"
6464
- name: Install uv
65-
uses: astral-sh/setup-uv@v5
65+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
6666
- name: Build documentation (strict)
6767
run: make docs-strict
6868
- name: Run docs drift tests

‎template/.github/workflows/publish.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ jobs:
1616
env:
1717
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1818
run: gh release download "${{ github.event.release.tag_name }}" --dir dist --repo "${{ github.repository }}"
19-
- uses: pypa/gh-action-pypi-publish@release/v1
19+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

‎template/.github/workflows/release.yaml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ jobs:
3434
permissions:
3535
contents: write
3636
steps:
37-
- uses: actions/checkout@v4
37+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
3838
with:
3939
ref: ${{ github.ref_name }}
4040
fetch-depth: 0
4141

4242
- name: Semantic Release (version only)
4343
id: semrel
44-
uses: python-semantic-release/python-semantic-release@v10
44+
uses: python-semantic-release/python-semantic-release@39dd2052f2ce8282a5d932c31d58a2ca06d2550e # v10.6.1
4545
with:
4646
github_token: ${{ secrets.GITHUB_TOKEN }}
4747
force: ${{ github.event.inputs.force }}
@@ -52,10 +52,10 @@ jobs:
5252

5353
- name: Build package
5454
if: steps.semrel.outputs.released == 'true'
55-
uses: actions/setup-python@v5
55+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
5656
with: {python-version: "3.12"}
5757
- if: steps.semrel.outputs.released == 'true'
58-
uses: astral-sh/setup-uv@v5
58+
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
5959
- if: steps.semrel.outputs.released == 'true'
6060
run: uv build
6161

‎tests/test_template.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,88 @@ def test_make_test_docs(project):
204204
)
205205

206206

207+
# A `uses:` step reference, capturing the ref and any trailing comment.
208+
USES_LINE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)\s*(?:#\s*(?P<comment>.*))?$")
209+
# A third-party action pinned to a full 40-character commit SHA.
210+
SHA_PINNED_REF = re.compile(r"^[^@]+@[0-9a-f]{40}$")
211+
# The version the pinned SHA corresponds to, e.g. "v7.0.1".
212+
VERSION_COMMENT = re.compile(r"^v\d+\.\d+\.\d+")
213+
214+
215+
def _uses_refs(workflow_dir):
216+
"""Yield ``(path, lineno, ref, comment)`` for every ``uses:`` in a directory."""
217+
for path in sorted(Path(workflow_dir).glob("*.yaml")):
218+
for lineno, line in enumerate(
219+
path.read_text(encoding="utf-8").splitlines(), start=1
220+
):
221+
match = USES_LINE.match(line)
222+
if match:
223+
yield path, lineno, match["ref"], match["comment"]
224+
225+
226+
def assert_actions_are_sha_pinned(workflow_dir):
227+
"""Assert every third-party action in ``workflow_dir`` is pinned to a commit SHA.
228+
229+
A mutable ref (``@v4``, or worse a branch like ``@release/v1``) resolves at run
230+
time to whatever upstream points at today, so anyone who can move that tag runs
231+
arbitrary code in our CI with our tokens. Only an immutable 40-hex commit SHA
232+
closes that hole. Local reusable workflows (``./.github/...``) are exempt: they
233+
live in the same repo and are not a supply-chain surface.
234+
"""
235+
refs = list(_uses_refs(workflow_dir))
236+
assert refs, f"no `uses:` steps found under {workflow_dir}"
237+
238+
unpinned = [
239+
f"{path.name}:{lineno}: {ref}"
240+
for path, lineno, ref, _ in refs
241+
if not ref.startswith("./") and not SHA_PINNED_REF.match(ref)
242+
]
243+
assert not unpinned, "actions not pinned to a 40-hex commit SHA:\n" + "\n".join(
244+
unpinned
245+
)
246+
247+
# The trailing version comment is what makes the pin reviewable and tells a
248+
# future updater which release the opaque SHA actually is.
249+
uncommented = [
250+
f"{path.name}:{lineno}: {ref}"
251+
for path, lineno, ref, comment in refs
252+
if not ref.startswith("./")
253+
and not (comment and VERSION_COMMENT.match(comment.strip()))
254+
]
255+
assert not uncommented, (
256+
"pinned actions missing a `# vX.Y.Z` version comment:\n" + "\n".join(uncommented)
257+
)
258+
259+
260+
def test_own_workflows_pin_actions_to_sha():
261+
"""This repo's own CI must pin its actions, not just the template's."""
262+
assert_actions_are_sha_pinned(TEMPLATE_ROOT / ".github" / "workflows")
263+
264+
265+
def test_generated_workflows_pin_actions_to_sha(project):
266+
"""Every generated project inherits the template's refs, so they must be pinned.
267+
268+
Asserted against the *rendered* project rather than the template source, so the
269+
pins are proven to survive copier rendering into every downstream repo.
270+
"""
271+
assert_actions_are_sha_pinned(project.path / ".github" / "workflows")
272+
273+
274+
def test_generated_release_keeps_local_workflow_ref_unpinned(project):
275+
"""The local reusable-workflow ref must stay a path, not become a SHA.
276+
277+
``uses: ./.github/workflows/...`` refers to a file inside the generated repo
278+
itself. A SHA there would pin the project to one of its own commits that does
279+
not exist yet — so this ref is deliberately excluded from the pinning sweep.
280+
"""
281+
local = [
282+
(path.name, ref)
283+
for path, _, ref, _ in _uses_refs(project.path / ".github" / "workflows")
284+
if ref.startswith("./")
285+
]
286+
assert local == [("release.yaml", "./.github/workflows/cicd.yaml")]
287+
288+
207289
def test_default_slug_is_valid_package_name(tmp_path):
208290
"""The auto-derived project_slug must be a valid Python package name.
209291

0 commit comments

Comments
 (0)