Skip to content

Commit 8fb1bf7

Browse files
authored
feat(release): publish verified package artifacts (#2848)
1 parent 8bc274c commit 8fb1bf7

5 files changed

Lines changed: 614 additions & 5 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
name: Release Artifacts
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- ".github/workflows/release-artifacts.yml"
7+
- "examples/release-artifacts-smoke.py"
8+
- "loopx/**"
9+
- "pyproject.toml"
10+
- "README.md"
11+
- "scripts/release_artifacts.py"
12+
release:
13+
types: [published]
14+
workflow_dispatch:
15+
inputs:
16+
tag:
17+
description: "Existing GitHub Release tag containing this release tooling"
18+
required: true
19+
type: string
20+
21+
permissions:
22+
contents: read
23+
24+
concurrency:
25+
group: release-artifacts-${{ github.event.release.tag_name || inputs.tag || github.ref }}
26+
cancel-in-progress: false
27+
28+
jobs:
29+
build:
30+
if: github.repository == 'huangruiteng/loopx'
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 20
33+
permissions:
34+
contents: read
35+
id-token: write
36+
attestations: write
37+
outputs:
38+
release-tag: ${{ steps.identity.outputs.release-tag }}
39+
steps:
40+
- name: Check out release source
41+
uses: actions/checkout@v7
42+
with:
43+
fetch-depth: 0
44+
ref: ${{ github.event.release.tag_name || inputs.tag || github.sha }}
45+
46+
- name: Set up Python
47+
uses: actions/setup-python@v6
48+
with:
49+
python-version: "3.11"
50+
cache: pip
51+
52+
- name: Resolve and validate release identity
53+
id: identity
54+
env:
55+
EVENT_RELEASE_TAG: ${{ github.event.release.tag_name }}
56+
INPUT_RELEASE_TAG: ${{ inputs.tag }}
57+
run: |
58+
set -euo pipefail
59+
release_tag="${EVENT_RELEASE_TAG:-${INPUT_RELEASE_TAG:-}}"
60+
if [[ -z "${release_tag}" ]]; then
61+
release_tag="$(python scripts/release_artifacts.py expected-tag)"
62+
fi
63+
python scripts/release_artifacts.py validate-tag "${release_tag}"
64+
source_date_epoch="$(git show -s --format=%ct HEAD)"
65+
echo "release-tag=${release_tag}" >> "${GITHUB_OUTPUT}"
66+
echo "RELEASE_TAG=${release_tag}" >> "${GITHUB_ENV}"
67+
echo "SOURCE_DATE_EPOCH=${source_date_epoch}" >> "${GITHUB_ENV}"
68+
echo "PYTHONHASHSEED=0" >> "${GITHUB_ENV}"
69+
70+
- name: Install release build tools
71+
run: python -m pip install --disable-pip-version-check build==1.4.4 twine==6.2.0
72+
73+
- name: Build wheel and source distribution
74+
run: python -m build --sdist --wheel --outdir dist/packages
75+
76+
- name: Normalize source distribution metadata
77+
run: >-
78+
python scripts/release_artifacts.py normalize-sdist
79+
--dist-dir dist/packages
80+
--source-date-epoch "${SOURCE_DATE_EPOCH}"
81+
82+
- name: Validate package metadata
83+
run: python -m twine check dist/packages/*
84+
85+
- name: Generate and verify checksums
86+
run: |
87+
set -euo pipefail
88+
python scripts/release_artifacts.py write-checksums \
89+
--dist-dir dist/packages \
90+
--output dist/SHA256SUMS
91+
python scripts/release_artifacts.py verify-checksums \
92+
--dist-dir dist/packages \
93+
--checksum-file dist/SHA256SUMS
94+
95+
- name: Verify wheel in a clean environment
96+
run: |
97+
set -euo pipefail
98+
python -m venv "${RUNNER_TEMP}/loopx-wheel"
99+
"${RUNNER_TEMP}/loopx-wheel/bin/python" -m pip install \
100+
--disable-pip-version-check \
101+
--no-deps \
102+
dist/packages/*.whl
103+
test "$("${RUNNER_TEMP}/loopx-wheel/bin/loopx" --version)" = \
104+
"loopx ${RELEASE_TAG#v}"
105+
106+
- name: Exercise release contract smoke
107+
run: python examples/release-artifacts-smoke.py
108+
109+
- name: Attest release packages and checksum manifest
110+
if: github.event_name != 'pull_request'
111+
uses: actions/attest@v4
112+
with:
113+
subject-path: |
114+
dist/packages/*
115+
dist/SHA256SUMS
116+
117+
- name: Upload validated release bundle
118+
uses: actions/upload-artifact@v7
119+
with:
120+
name: loopx-${{ steps.identity.outputs.release-tag }}
121+
path: dist
122+
if-no-files-found: error
123+
retention-days: 30
124+
125+
upload-release:
126+
if: github.event_name != 'pull_request'
127+
needs: build
128+
runs-on: ubuntu-latest
129+
timeout-minutes: 10
130+
permissions:
131+
contents: write
132+
steps:
133+
- name: Check out release source
134+
uses: actions/checkout@v7
135+
with:
136+
ref: ${{ needs.build.outputs.release-tag }}
137+
138+
- name: Download validated release bundle
139+
uses: actions/download-artifact@v7
140+
with:
141+
name: loopx-${{ needs.build.outputs.release-tag }}
142+
path: dist
143+
144+
- name: Verify checksums before upload
145+
run: >-
146+
python scripts/release_artifacts.py verify-checksums
147+
--dist-dir dist/packages
148+
--checksum-file dist/SHA256SUMS
149+
150+
- name: Upload immutable GitHub Release assets
151+
env:
152+
GH_TOKEN: ${{ github.token }}
153+
RELEASE_TAG: ${{ needs.build.outputs.release-tag }}
154+
run: |
155+
set -euo pipefail
156+
gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null
157+
gh release upload "${RELEASE_TAG}" \
158+
dist/packages/* \
159+
dist/SHA256SUMS \
160+
--repo "${GITHUB_REPOSITORY}"
161+
162+
publish-pypi:
163+
if: >-
164+
github.event_name != 'pull_request' &&
165+
vars.PYPI_PUBLISH_ENABLED == 'true'
166+
needs:
167+
- build
168+
- upload-release
169+
runs-on: ubuntu-latest
170+
timeout-minutes: 10
171+
permissions:
172+
id-token: write
173+
environment:
174+
name: pypi
175+
url: https://pypi.org/project/loopx/
176+
steps:
177+
- name: Download validated release bundle
178+
uses: actions/download-artifact@v7
179+
with:
180+
name: loopx-${{ needs.build.outputs.release-tag }}
181+
path: dist
182+
183+
- name: Publish distributions with PyPI Trusted Publishing
184+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
185+
with:
186+
packages-dir: dist/packages

docs/product/release-readiness.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,34 @@ Before moving `stable`, maintainers should:
9595
`loopx update --execute` when the check recommends or when they want to
9696
refresh to the named stable release.
9797

98-
This is a lightweight GitHub release contract, not a PyPI publishing
99-
requirement. A future package registry can reuse the same version/tag contract
100-
instead of inventing a second release identity.
98+
The release workflow builds a wheel and source distribution from the tagged
99+
commit. Its release assets include a canonical `SHA256SUMS` file, and GitHub
100+
records build-provenance attestations for both packages and the checksum
101+
manifest. Verify a downloaded bundle before installation:
102+
103+
```bash
104+
sha256sum --check SHA256SUMS
105+
gh attestation verify loopx-X.Y.Z-py3-none-any.whl --repo huangruiteng/loopx
106+
gh attestation verify loopx-X.Y.Z.tar.gz --repo huangruiteng/loopx
107+
```
108+
109+
The checksum proves that the downloaded bytes match the release manifest. The
110+
attestation separately binds those bytes to the repository, workflow, commit,
111+
and build event; neither mechanism claims that the package is vulnerability
112+
free.
113+
114+
PyPI publication is an explicit, fail-closed extension of the same build. The
115+
release workflow publishes only when maintainers have configured all of these:
116+
117+
- a PyPI project named `loopx` with a Trusted Publisher for
118+
`huangruiteng/loopx` and `.github/workflows/release-artifacts.yml`;
119+
- a protected GitHub environment named `pypi` that matches the Trusted
120+
Publisher configuration;
121+
- the repository variable `PYPI_PUBLISH_ENABLED=true`.
122+
123+
Do not add a long-lived PyPI token. Without every condition above, GitHub
124+
Release packages and their verification material are still produced, while
125+
the PyPI job remains skipped.
101126

102127
## Public Release Timeline
103128

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
"""Exercise the public release artifact identity and checksum contract."""
3+
4+
from __future__ import annotations
5+
6+
import importlib.util
7+
import gzip
8+
import hashlib
9+
import io
10+
import subprocess
11+
import sys
12+
import tarfile
13+
import tempfile
14+
from pathlib import Path
15+
16+
17+
ROOT = Path(__file__).resolve().parents[1]
18+
SCRIPT = ROOT / "scripts" / "release_artifacts.py"
19+
SPEC = importlib.util.spec_from_file_location("release_artifacts", SCRIPT)
20+
assert SPEC is not None and SPEC.loader is not None
21+
release_artifacts = importlib.util.module_from_spec(SPEC)
22+
sys.modules[SPEC.name] = release_artifacts
23+
SPEC.loader.exec_module(release_artifacts)
24+
25+
26+
def run(*args: str) -> subprocess.CompletedProcess[str]:
27+
return subprocess.run(
28+
[sys.executable, str(SCRIPT), "--project-root", str(ROOT), *args],
29+
check=False,
30+
capture_output=True,
31+
text=True,
32+
)
33+
34+
35+
def write_sdist(path: Path, *, mtime: int) -> None:
36+
with path.open("wb") as raw_stream:
37+
with gzip.GzipFile(filename="", mode="wb", fileobj=raw_stream, mtime=mtime) as gzip_stream:
38+
with tarfile.open(fileobj=gzip_stream, mode="w", format=tarfile.PAX_FORMAT) as archive:
39+
payload = b"fixture package metadata\n"
40+
member = tarfile.TarInfo("loopx-fixture/PKG-INFO")
41+
member.size = len(payload)
42+
member.mtime = mtime
43+
archive.addfile(member, io.BytesIO(payload))
44+
45+
46+
def digest(path: Path) -> str:
47+
return hashlib.sha256(path.read_bytes()).hexdigest()
48+
49+
50+
def main() -> int:
51+
identity = release_artifacts.load_identity(ROOT)
52+
assert identity.name == "loopx", identity
53+
assert run("expected-tag").stdout.strip() == identity.tag
54+
assert run("validate-tag", identity.tag).returncode == 0
55+
56+
invalid_tag = run("validate-tag", "v999.0.0")
57+
assert invalid_tag.returncode == 2, invalid_tag
58+
assert "does not match package tag" in invalid_tag.stderr, invalid_tag.stderr
59+
60+
with tempfile.TemporaryDirectory(prefix="loopx-release-artifacts-") as temporary:
61+
root = Path(temporary)
62+
dist_dir = root / "packages"
63+
dist_dir.mkdir()
64+
wheel = dist_dir / f"loopx-{identity.version}-py3-none-any.whl"
65+
sdist = dist_dir / f"loopx-{identity.version}.tar.gz"
66+
wheel.write_bytes(b"wheel fixture\n")
67+
write_sdist(sdist, mtime=1_700_000_001)
68+
checksums = root / "SHA256SUMS"
69+
70+
normalized = run(
71+
"normalize-sdist",
72+
"--dist-dir",
73+
str(dist_dir),
74+
"--source-date-epoch",
75+
"1700000000",
76+
)
77+
assert normalized.returncode == 0, normalized
78+
normalized_digest = digest(sdist)
79+
write_sdist(sdist, mtime=1_700_000_099)
80+
normalized_again = run(
81+
"normalize-sdist",
82+
"--dist-dir",
83+
str(dist_dir),
84+
"--source-date-epoch",
85+
"1700000000",
86+
)
87+
assert normalized_again.returncode == 0, normalized_again
88+
assert digest(sdist) == normalized_digest
89+
90+
written = run(
91+
"write-checksums",
92+
"--dist-dir",
93+
str(dist_dir),
94+
"--output",
95+
str(checksums),
96+
)
97+
assert written.returncode == 0, written
98+
manifest = checksums.read_text(encoding="ascii")
99+
lines = manifest.splitlines()
100+
assert len(lines) == 2, lines
101+
assert lines == sorted(lines, key=lambda line: line.split(" ", 1)[1]), lines
102+
assert all(len(line.split(" ", 1)[0]) == 64 for line in lines), lines
103+
104+
verified = run(
105+
"verify-checksums",
106+
"--dist-dir",
107+
str(dist_dir),
108+
"--checksum-file",
109+
str(checksums),
110+
)
111+
assert verified.returncode == 0, verified
112+
113+
wheel.write_bytes(b"tampered wheel\n")
114+
tampered = run(
115+
"verify-checksums",
116+
"--dist-dir",
117+
str(dist_dir),
118+
"--checksum-file",
119+
str(checksums),
120+
)
121+
assert tampered.returncode == 2, tampered
122+
assert "does not match release distributions" in tampered.stderr, tampered.stderr
123+
124+
extra = dist_dir / "unexpected.txt"
125+
extra.write_text("not a release asset\n", encoding="utf-8")
126+
rejected = run(
127+
"write-checksums",
128+
"--dist-dir",
129+
str(dist_dir),
130+
"--output",
131+
str(checksums),
132+
)
133+
assert rejected.returncode == 2, rejected
134+
assert "unexpected files" in rejected.stderr, rejected.stderr
135+
136+
workflow = (ROOT / ".github" / "workflows" / "release-artifacts.yml").read_text(
137+
encoding="utf-8"
138+
)
139+
required_contract = (
140+
"release:\n types: [published]",
141+
"python scripts/release_artifacts.py validate-tag",
142+
"python scripts/release_artifacts.py normalize-sdist",
143+
"python scripts/release_artifacts.py write-checksums",
144+
"python scripts/release_artifacts.py verify-checksums",
145+
"uses: actions/attest@v4",
146+
"gh release upload",
147+
"vars.PYPI_PUBLISH_ENABLED == 'true'",
148+
"environment:\n name: pypi",
149+
"pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33",
150+
)
151+
for text in required_contract:
152+
assert text in workflow, text
153+
assert "password:" not in workflow
154+
assert "--clobber" not in workflow
155+
156+
print("release-artifacts-smoke ok")
157+
return 0
158+
159+
160+
if __name__ == "__main__":
161+
raise SystemExit(main())

0 commit comments

Comments
 (0)