Skip to content

Commit f578e0e

Browse files
authored
ci: two-stage trusted-publishing release workflow (#61)
* ci: add two-stage trusted-publishing release workflow Tag-driven pipeline (v* tags): build sdist+wheel once, then promote the same artifacts through TestPyPI and PyPI, each behind its own GitHub Environment approval gate. Auth is OIDC trusted publishing (no stored tokens). Prerelease tags (.devN/aN/bN/rcN) stop at TestPyPI; clean vX.Y.Z tags also publish to PyPI. A guard fails the build if the tag does not match the version in pyproject.toml. * ci: drop pip cache from release workflow (cache-poisoning hardening) setup-python's pip cache is branch-scoped and writable by less-trusted runs (PRs, ci.yml on other branches). A tag-triggered publish job could restore a poisoned cache and taint the wheel uploaded to PyPI, so the privileged release workflow now builds without a shared cache. Addresses CodeRabbit / zizmor cache-poisoning finding on PR #61. * ci: pin release actions to commit SHAs + add Dependabot Pin every action in release.yml to a full-length commit SHA (with a trailing # vX.Y.Z comment) so a moved tag can't swap out the code a privileged, id-token-write publish job runs: actions/checkout v4.3.1 actions/setup-python v5.6.0 actions/upload-artifact v4.6.2 actions/download-artifact v4.3.0 pypa/gh-action-pypi-publish v1.14.0 Add .github/dependabot.yml (github-actions, weekly, grouped) so the pins still receive update PRs instead of silently rotting.
1 parent 6e40f84 commit f578e0e

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

.github/dependabot.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
version: 2
2+
updates:
3+
# Keep GitHub Actions pinned to commit SHAs (supply-chain hardening) while
4+
# still receiving updates: Dependabot opens a PR whenever a pinned action has
5+
# a newer release, bumping the SHA and the trailing "# vX.Y.Z" comment.
6+
- package-ecosystem: github-actions
7+
directory: "/"
8+
schedule:
9+
interval: weekly
10+
groups:
11+
# One consolidated PR per week instead of one per action.
12+
github-actions:
13+
patterns:
14+
- "*"

.github/workflows/release.yml

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
name: Release
2+
3+
# Two-stage trusted-publishing pipeline, driven by git tags:
4+
#
5+
# git tag v0.2.1.dev1 && git push origin v0.2.1.dev1 -> build + TestPyPI only
6+
# git tag v0.2.1 && git push origin v0.2.1 -> build + TestPyPI + PyPI
7+
#
8+
# The SAME artifacts built once in `build` are promoted through both indexes, so
9+
# what lands on PyPI is byte-identical to what you smoke-tested on TestPyPI.
10+
#
11+
# Auth is OIDC trusted publishing (no API tokens stored). The manual approval
12+
# gates are GitHub Environment "required reviewers", configured in
13+
# Settings -> Environments (NOT in this file). The trusted-publisher registration
14+
# on each index must match, exactly:
15+
# owner=thad0ctor repo=Gefen-X workflow=release.yml environment=testpypi / pypi
16+
# Renaming this file breaks the OIDC handshake -- keep it release.yml.
17+
18+
on:
19+
push:
20+
tags:
21+
- "v*"
22+
23+
# Only the publish jobs need privilege (id-token), and they widen it locally.
24+
permissions:
25+
contents: read
26+
27+
# Never cancel a release that is mid-flight (e.g. waiting on an approval gate).
28+
concurrency:
29+
group: release-${{ github.ref }}
30+
cancel-in-progress: false
31+
32+
jobs:
33+
build:
34+
name: Build & verify artifacts
35+
runs-on: ubuntu-latest
36+
outputs:
37+
version: ${{ steps.ver.outputs.version }}
38+
prerelease: ${{ steps.ver.outputs.prerelease }}
39+
steps:
40+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
41+
with:
42+
persist-credentials: false
43+
44+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
45+
with:
46+
python-version: "3.12"
47+
# No pip cache in this privileged publish workflow: GitHub caches are
48+
# branch-scoped and writable by less-trusted runs, so a poisoned cache
49+
# could taint the wheel uploaded to PyPI. Build tooling installs fast.
50+
51+
- name: Install build tooling
52+
run: python -m pip install --upgrade pip build twine
53+
54+
- name: Build sdist + wheel
55+
# Pure-Python build (kernels JIT at runtime), so no CUDA toolchain needed.
56+
run: python -m build
57+
58+
- name: twine check
59+
run: python -m twine check dist/*
60+
61+
- name: Verify tag matches package version
62+
id: ver
63+
# Guards against tagging vX.Y.Z while pyproject.toml still says the old
64+
# version (the version is hard-coded there, so this is easy to forget).
65+
run: |
66+
TAG="${GITHUB_REF_NAME#v}"
67+
WHEEL=$(ls dist/*.whl)
68+
PKG_VER=$(basename "$WHEEL" | sed -E 's/^gefen_x-([^-]+)-py3.*/\1/')
69+
echo "tag=$TAG package=$PKG_VER"
70+
if [ "$TAG" != "$PKG_VER" ]; then
71+
echo "::error::Tag v$TAG does not match built package version $PKG_VER -- bump version in pyproject.toml"
72+
exit 1
73+
fi
74+
echo "version=$PKG_VER" >> "$GITHUB_OUTPUT"
75+
# PEP 440 prerelease markers (.devN / aN / bN / rcN) => TestPyPI only.
76+
if echo "$PKG_VER" | grep -Eq '(\.dev|a|b|rc)[0-9]+$'; then
77+
echo "prerelease=true" >> "$GITHUB_OUTPUT"
78+
else
79+
echo "prerelease=false" >> "$GITHUB_OUTPUT"
80+
fi
81+
82+
- name: Upload artifacts
83+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
84+
with:
85+
name: dist
86+
path: dist/
87+
88+
testpypi:
89+
name: Publish to TestPyPI (gate 1)
90+
needs: build
91+
runs-on: ubuntu-latest
92+
# Approval gate #1: the `testpypi` environment's required reviewers.
93+
environment:
94+
name: testpypi
95+
url: https://test.pypi.org/project/gefen-x/${{ needs.build.outputs.version }}/
96+
permissions:
97+
id-token: write # mint the short-lived OIDC token; no stored secret
98+
steps:
99+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
100+
with:
101+
name: dist
102+
path: dist/
103+
- name: Publish to TestPyPI
104+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
105+
with:
106+
repository-url: https://test.pypi.org/legacy/
107+
# A real release also passes through here; if that version was already
108+
# tested on TestPyPI, don't hard-fail on the duplicate.
109+
skip-existing: true
110+
111+
pypi:
112+
name: Publish to PyPI (gate 2)
113+
needs: [build, testpypi]
114+
# Prerelease tags stop at TestPyPI; only clean vX.Y.Z tags reach PyPI.
115+
if: needs.build.outputs.prerelease == 'false'
116+
runs-on: ubuntu-latest
117+
# Approval gate #2: the `pypi` environment's required reviewers.
118+
environment:
119+
name: pypi
120+
url: https://pypi.org/project/gefen-x/${{ needs.build.outputs.version }}/
121+
permissions:
122+
id-token: write
123+
steps:
124+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
125+
with:
126+
name: dist
127+
path: dist/
128+
- name: Publish to PyPI
129+
# Default index is PyPI. No skip-existing: re-releasing an existing
130+
# version should hard-fail, not silently no-op.
131+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0

0 commit comments

Comments
 (0)