|
| 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. |
0 commit comments