Skip to content

Commit 69ed1ca

Browse files
authored
Harden release workflow checks (#18)
1 parent 3b98aec commit 69ed1ca

4 files changed

Lines changed: 236 additions & 44 deletions

File tree

.github/workflows/release.yml

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,57 @@ concurrency:
5151
cancel-in-progress: false
5252

5353
jobs:
54+
validate-inputs:
55+
name: Validate release inputs
56+
runs-on: ubuntu-24.04
57+
58+
steps:
59+
- name: Check out repository
60+
uses: actions/checkout@v6
61+
with:
62+
ref: ${{ inputs.target }}
63+
64+
- name: Validate dispatch inputs
65+
env:
66+
CREATE_GITHUB_RELEASE: ${{ inputs.create_github_release }}
67+
DRY_RUN: ${{ inputs.dry_run }}
68+
PUBLISH_PYPI: ${{ inputs.publish_pypi }}
69+
PUBLISH_TESTPYPI: ${{ inputs.publish_testpypi }}
70+
TAG_NAME: ${{ inputs.tag_name }}
71+
run: |
72+
set -euo pipefail
73+
74+
project_version="$(
75+
python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])'
76+
)"
77+
expected_tag="v${project_version}"
78+
79+
needs_tag=false
80+
if [ "$CREATE_GITHUB_RELEASE" = "true" ] || [ "$PUBLISH_TESTPYPI" = "true" ] || [ "$PUBLISH_PYPI" = "true" ]; then
81+
needs_tag=true
82+
fi
83+
84+
if [ "$needs_tag" = "true" ] && [ -z "$TAG_NAME" ]; then
85+
echo "::error::tag_name is required when creating a release or publishing to a package index"
86+
exit 1
87+
fi
88+
89+
if [ -n "$TAG_NAME" ] && [ "$TAG_NAME" != "$expected_tag" ]; then
90+
echo "::error::tag_name must be ${expected_tag} for pyproject version ${project_version}"
91+
exit 1
92+
fi
93+
94+
if [ "$DRY_RUN" = "true" ] && { [ "$CREATE_GITHUB_RELEASE" = "true" ] || [ "$PUBLISH_TESTPYPI" = "true" ] || [ "$PUBLISH_PYPI" = "true" ]; }; then
95+
echo "::warning::dry_run=true prevents release creation and package publishing jobs from running"
96+
fi
97+
98+
if [ "$PUBLISH_PYPI" = "true" ] && [ "$CREATE_GITHUB_RELEASE" != "true" ]; then
99+
echo "::warning::publishing PyPI without creating the GitHub release uses a separate build from release assets"
100+
fi
101+
54102
build:
55103
name: Build release artifacts
104+
needs: validate-inputs
56105
runs-on: ubuntu-24.04
57106

58107
steps:
@@ -101,27 +150,6 @@ jobs:
101150
name: mini-eq-dist
102151
path: dist
103152

104-
- name: Validate release inputs
105-
env:
106-
TAG_NAME: ${{ inputs.tag_name }}
107-
run: |
108-
set -euo pipefail
109-
110-
if [ -z "$TAG_NAME" ]; then
111-
echo "::error::tag_name is required when create_github_release is true"
112-
exit 1
113-
fi
114-
115-
project_version="$(
116-
python3 -c 'from pathlib import Path; import tomllib; print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])'
117-
)"
118-
expected_tag="v${project_version}"
119-
120-
if [ "$TAG_NAME" != "$expected_tag" ]; then
121-
echo "::error::tag_name must be ${expected_tag} for pyproject version ${project_version}"
122-
exit 1
123-
fi
124-
125153
- name: Create GitHub release
126154
env:
127155
DRAFT: ${{ inputs.draft }}

docs/release.md

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,16 @@ as the release check when app/runtime routing behavior changed.
202202
## Package Channels
203203

204204
Use the `Release` workflow from GitHub Actions after local checks pass. Keep
205-
`dry_run=true` for packaging workflow changes. For real releases, keep the
206-
GitHub release as a draft first, review generated notes and assets, and publish
207-
the draft only after package-index checks pass.
205+
`dry_run=true` for packaging workflow changes. Every package-index or release
206+
dispatch must pass a `tag_name` that matches `pyproject.toml`.
207+
208+
For real releases, keep the GitHub release as a draft first, review generated
209+
notes and assets, and publish the draft only after package-index checks pass.
210+
After TestPyPI validation, prefer one production workflow dispatch that creates
211+
the draft GitHub release and publishes to PyPI from the same built artifacts.
212+
That keeps the GitHub release files and PyPI files byte-for-byte comparable.
213+
Use a separate PyPI-only dispatch only as a recovery path, and document that it
214+
creates a second build.
208215

209216
Use Trusted Publishing/OIDC for TestPyPI and PyPI. Do not use long-lived PyPI
210217
API tokens. Keep the `pypi` environment protected with required review before
@@ -252,10 +259,15 @@ python3 tools/release_post_publish.py "$version"
252259

253260
`tools/release_post_publish.py` verifies that the GitHub release is no longer a
254261
draft, asset URLs use the stable tag instead of temporary `untagged-*` draft
255-
URLs, the remote tag exists, PyPI can see the version, and the downloaded source
256-
archive SHA-256 matches the GitHub release asset digest. Do not use draft
257-
release asset URLs for Flathub; use the printed source archive SHA-256 after
258-
the GitHub release is published.
262+
URLs, the remote tag exists, PyPI can see the exact version, the expected PyPI
263+
files exist, and downloaded GitHub release assets match their GitHub digest
264+
metadata. It also compares GitHub release asset hashes with PyPI artifact
265+
hashes and warns when they differ. Use `--strict-artifact-match` for releases
266+
that were intentionally published from a single workflow build and should have
267+
matching artifacts across channels.
268+
269+
Do not use draft release asset URLs for Flathub; use the printed source archive
270+
SHA-256 after the GitHub release is published.
259271

260272
## Flathub Handoff
261273

tests/test_release_post_publish.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
6+
import pytest
7+
8+
from tools import release_post_publish
9+
10+
11+
def pypi_version_payload(version: str, *, sdist_sha: str, wheel_sha: str) -> bytes:
12+
return json.dumps(
13+
{
14+
"info": {"version": version},
15+
"urls": [
16+
{
17+
"filename": release_post_publish.SDIST_NAME.format(version=version),
18+
"digests": {"sha256": sdist_sha},
19+
},
20+
{
21+
"filename": release_post_publish.WHEEL_NAME.format(version=version),
22+
"digests": {"sha256": wheel_sha},
23+
},
24+
],
25+
}
26+
).encode()
27+
28+
29+
def test_post_publish_checks_exact_pypi_version_and_warns_on_artifact_mismatch(monkeypatch, capsys) -> None:
30+
version = "0.7.0"
31+
sdist_name = release_post_publish.SDIST_NAME.format(version=version)
32+
wheel_name = release_post_publish.WHEEL_NAME.format(version=version)
33+
34+
def fake_fetch_url(url: str, *, method: str = "GET") -> bytes:
35+
assert method == "GET"
36+
if url == release_post_publish.PYPI_VERSION_JSON_URL.format(version=version):
37+
return pypi_version_payload(version, sdist_sha="pypi-sdist", wheel_sha="same-wheel")
38+
if url == release_post_publish.PYPI_JSON_URL:
39+
return json.dumps({"info": {"version": version}}).encode()
40+
raise AssertionError(f"unexpected URL: {url}")
41+
42+
monkeypatch.setattr(release_post_publish, "fetch_url", fake_fetch_url)
43+
44+
release_post_publish.check_pypi(
45+
version,
46+
{sdist_name: "github-sdist", wheel_name: "same-wheel"},
47+
strict_artifact_match=False,
48+
)
49+
50+
captured = capsys.readouterr()
51+
assert "PyPI version JSON reports: 0.7.0" in captured.out
52+
assert "WARNING: PyPI and GitHub release artifact SHA-256 differ" in captured.err
53+
54+
55+
def test_post_publish_can_require_pypi_and_github_artifact_match(monkeypatch) -> None:
56+
version = "0.7.0"
57+
sdist_name = release_post_publish.SDIST_NAME.format(version=version)
58+
wheel_name = release_post_publish.WHEEL_NAME.format(version=version)
59+
60+
def fake_fetch_url(url: str, *, method: str = "GET") -> bytes:
61+
assert method == "GET"
62+
if url == release_post_publish.PYPI_VERSION_JSON_URL.format(version=version):
63+
return pypi_version_payload(version, sdist_sha="pypi-sdist", wheel_sha="same-wheel")
64+
raise AssertionError(f"unexpected URL: {url}")
65+
66+
monkeypatch.setattr(release_post_publish, "fetch_url", fake_fetch_url)
67+
68+
with pytest.raises(SystemExit, match="PyPI and GitHub release artifact SHA-256 differ"):
69+
release_post_publish.check_pypi(
70+
version,
71+
{sdist_name: "github-sdist", wheel_name: "same-wheel"},
72+
strict_artifact_match=True,
73+
)
74+
75+
76+
def test_post_publish_reads_stable_github_release_asset_hashes(monkeypatch) -> None:
77+
version = "0.7.0"
78+
tag = f"v{version}"
79+
sdist_name = release_post_publish.SDIST_NAME.format(version=version)
80+
wheel_name = release_post_publish.WHEEL_NAME.format(version=version)
81+
82+
monkeypatch.setattr(
83+
release_post_publish,
84+
"gh_json",
85+
lambda _command: {
86+
"tagName": tag,
87+
"isDraft": False,
88+
"isPrerelease": False,
89+
"url": f"https://github.com/bhack/mini-eq/releases/tag/{tag}",
90+
"assets": [
91+
{
92+
"name": sdist_name,
93+
"url": f"https://github.com/bhack/mini-eq/releases/download/{tag}/{sdist_name}",
94+
"digest": "sha256:sdist-sha",
95+
},
96+
{
97+
"name": wheel_name,
98+
"url": f"https://github.com/bhack/mini-eq/releases/download/{tag}/{wheel_name}",
99+
"digest": "sha256:wheel-sha",
100+
},
101+
],
102+
},
103+
)
104+
monkeypatch.setattr(
105+
release_post_publish,
106+
"sha256_url",
107+
lambda url: "sdist-sha" if url.endswith(".tar.gz") else "wheel-sha",
108+
)
109+
monkeypatch.setattr(
110+
release_post_publish,
111+
"run",
112+
lambda command: subprocess.CompletedProcess(command, 0, stdout=f"abc123\trefs/tags/{tag}\n", stderr=""),
113+
)
114+
115+
assert release_post_publish.check_github_release(version, tag, "bhack/mini-eq") == {
116+
sdist_name: "sdist-sha",
117+
wheel_name: "wheel-sha",
118+
}

tools/release_post_publish.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import shutil
88
import subprocess
9+
import sys
910
import tomllib
1011
import urllib.error
1112
import urllib.request
@@ -15,6 +16,7 @@
1516
ROOT = Path(__file__).resolve().parents[1]
1617
DEFAULT_REPO = "bhack/mini-eq"
1718
PYPI_JSON_URL = "https://pypi.org/pypi/mini-eq/json"
19+
PYPI_VERSION_JSON_URL = "https://pypi.org/pypi/mini-eq/{version}/json"
1820
PYPI_VERSION_URL = "https://pypi.org/project/mini-eq/{version}/"
1921
SDIST_NAME = "mini_eq-{version}.tar.gz"
2022
WHEEL_NAME = "mini_eq-{version}-py3-none-any.whl"
@@ -70,7 +72,7 @@ def asset_by_name(release: dict[str, Any], name: str) -> dict[str, Any]:
7072
raise SystemExit(f"GitHub release is missing asset: {name}")
7173

7274

73-
def check_github_release(version: str, tag: str, repo: str) -> str:
75+
def check_github_release(version: str, tag: str, repo: str) -> dict[str, str]:
7476
release = gh_json(
7577
[
7678
"gh",
@@ -90,33 +92,60 @@ def check_github_release(version: str, tag: str, repo: str) -> str:
9092
raise SystemExit(f"GitHub release {tag} is still a draft")
9193

9294
expected_names = (SDIST_NAME.format(version=version), WHEEL_NAME.format(version=version))
95+
asset_shas: dict[str, str] = {}
9396
for name in expected_names:
9497
asset = asset_by_name(release, name)
9598
url = asset["url"]
9699
if f"/download/{tag}/" not in url:
97100
raise SystemExit(f"GitHub release asset still has an unstable URL: {url}")
101+
asset_sha = sha256_url(url)
102+
asset_shas[name] = asset_sha
103+
expected_digest = asset.get("digest")
104+
if expected_digest and expected_digest != f"sha256:{asset_sha}":
105+
raise SystemExit(
106+
f"Downloaded GitHub release asset SHA-256 does not match the asset digest: "
107+
f"{name}: {asset_sha} != {expected_digest}"
108+
)
98109

99110
tag_lookup = run(["git", "ls-remote", "--tags", "origin", tag])
100111
if not tag_lookup.stdout.strip():
101112
raise SystemExit(f"Remote tag not found on origin: {tag}")
102113

103-
sdist = asset_by_name(release, expected_names[0])
104-
sdist_sha = sha256_url(sdist["url"])
105-
expected_digest = sdist.get("digest")
106-
if expected_digest and expected_digest != f"sha256:{sdist_sha}":
107-
raise SystemExit(
108-
f"Downloaded sdist SHA-256 does not match the GitHub release asset digest: {sdist_sha} != {expected_digest}"
109-
)
110-
111114
print(f"GitHub release is published: {release['url']}")
112115
print(f"Remote tag exists: {tag_lookup.stdout.strip()}")
113-
print(f"Flathub source archive SHA-256: {sdist_sha}")
114-
return sdist_sha
116+
print(f"Flathub source archive SHA-256: {asset_shas[expected_names[0]]}")
117+
return asset_shas
115118

116119

117-
def check_pypi(version: str) -> None:
118-
pypi_json = json.loads(fetch_url(PYPI_JSON_URL))
119-
json_version = pypi_json["info"]["version"]
120+
def check_pypi(version: str, github_shas: dict[str, str], *, strict_artifact_match: bool) -> None:
121+
version_json = json.loads(fetch_url(PYPI_VERSION_JSON_URL.format(version=version)))
122+
version_json_version = version_json["info"]["version"]
123+
if version_json_version != version:
124+
raise SystemExit(f"PyPI version JSON mismatch: expected {version}, got {version_json_version}")
125+
126+
pypi_files = {file["filename"]: file for file in version_json["urls"]}
127+
expected_names = (SDIST_NAME.format(version=version), WHEEL_NAME.format(version=version))
128+
for name in expected_names:
129+
if name not in pypi_files:
130+
raise SystemExit(f"PyPI is missing artifact: {name}")
131+
pypi_sha = pypi_files[name]["digests"]["sha256"]
132+
print(f"PyPI artifact: {name} sha256:{pypi_sha}")
133+
134+
github_sha = github_shas.get(name)
135+
if github_sha and github_sha != pypi_sha:
136+
message = (
137+
f"PyPI and GitHub release artifact SHA-256 differ for {name}: "
138+
f"pypi={pypi_sha} github={github_sha}. Publish both channels from the same release workflow run "
139+
"when artifact parity is required."
140+
)
141+
if strict_artifact_match:
142+
raise SystemExit(message)
143+
print(f"WARNING: {message}", file=sys.stderr)
144+
145+
print(f"PyPI version JSON reports: {version_json_version}")
146+
147+
project_json = json.loads(fetch_url(PYPI_JSON_URL))
148+
json_version = project_json["info"]["version"]
120149
version_url = PYPI_VERSION_URL.format(version=version)
121150

122151
if json_version == version:
@@ -142,6 +171,11 @@ def parse_args() -> argparse.Namespace:
142171
help="release version to verify; defaults to pyproject.toml",
143172
)
144173
parser.add_argument("--repo", default=DEFAULT_REPO, help=f"GitHub repository; defaults to {DEFAULT_REPO}")
174+
parser.add_argument(
175+
"--strict-artifact-match",
176+
action="store_true",
177+
help="fail when GitHub release asset SHA-256 values differ from PyPI artifact SHA-256 values",
178+
)
145179
return parser.parse_args()
146180

147181

@@ -151,8 +185,8 @@ def main() -> int:
151185
tag = f"v{version}"
152186

153187
require_tools("gh", "git")
154-
check_github_release(version, tag, args.repo)
155-
check_pypi(version)
188+
github_shas = check_github_release(version, tag, args.repo)
189+
check_pypi(version, github_shas, strict_artifact_match=args.strict_artifact_match)
156190
print("Post-publish checks passed.")
157191
return 0
158192

0 commit comments

Comments
 (0)