ci(release): pin build tooling and gate reproducible wheels (#945)
* feat(build): add locked reproducible wheel gate Build independently in clean source trees and virtualenvs, compare wheel bytes, and stage only verified canonical distributions. Safely clean recognized prior outputs on repeat runs. Part of #701 * ci(release): verify wheel reproducibility before publishing Run the shared gate in PR and tag workflows, preserve failure diagnostics, and keep verification artifacts out of release checksums. Make Snap Store publication wait for the gate and reuse the canonical distributions for PyPI. Part of #701 * docs(release): document reproducible wheel validation Describe the locked toolchain, canonical publication boundary, same-platform scope, diagnostic outputs, and safe automatic cleanup. * chore(scripts): update executable permissions * fix(build): isolate release tooling and preserve cleanup retries Exclude host site-packages from build-only virtualenvs so unlocked setuptools plugins cannot affect verified distributions. Delete staged artifacts before canonical ownership evidence to recover safely from interrupted cleanup. Add regression tests and document the build-only environment exception. * fix(release): make reproducible builds retryable and refresh tools Regenerate release build exports from uv.lock. Hold a checkout lock through cleanup and staging, and atomically rename complete archives into dist. Cover interrupted staging, competing processes, and lock release with regression tests. --------- Co-authored-by: Kanishk Pachauri <itskanishkp.py@gmail.com> Co-authored-by: sesav <1636488+sesav@users.noreply.github.com>
This commit is contained in:
co-authored by
Kanishk Pachauri
sesav
parent
b148bca5fa
commit
498d876627
@@ -39,10 +39,10 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build wheel setuptools
|
||||
- name: Set up pinned uv
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.12.23'
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
@@ -63,22 +63,20 @@ jobs:
|
||||
echo "Warning: Version mismatch between tag ($TAG_VERSION) and package ($PACKAGE_VERSION)"
|
||||
fi
|
||||
|
||||
# Archive timestamps are what made two builds of one tag differ. Pinned to
|
||||
# the tagged commit, re-running this workflow reproduces the bytes we
|
||||
# published, so a checksum stays true for the life of the release.
|
||||
- name: Pin the build timestamp to the tagged commit
|
||||
run: |
|
||||
echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct "$GITHUB_SHA")" >> "$GITHUB_ENV"
|
||||
# Two independent builds verify reproducibility, but only the canonical
|
||||
# distributions enter dist/. Every publisher consumes those same bytes.
|
||||
# The script pins SOURCE_DATE_EPOCH, verifies exports, installs the hashed
|
||||
# toolchain, builds without isolation and runs twine check --strict.
|
||||
- name: Build and verify reproducible wheel
|
||||
run: python scripts/build_reproducible_wheel.py --python "$(command -v python)"
|
||||
|
||||
# The only `python -m build` in this workflow. PyPI, the aarch64 AppImage
|
||||
# and the checksums all consume the artifact below, so the wheel on PyPI
|
||||
# is the wheel on the release. It used to be built three times, on three
|
||||
# runners, and nothing guaranteed the three agreed.
|
||||
- name: Build package
|
||||
run: |
|
||||
python -m build
|
||||
echo "Built packages:"
|
||||
ls -la dist/
|
||||
- name: Preserve failed wheel builds and diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheel-reproducibility-diagnostics
|
||||
path: build/reproducible-wheel/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload the built distributions
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -797,7 +795,7 @@ jobs:
|
||||
# artifacts, which exist regardless of whether an upload to the release
|
||||
# succeeded. A missing build artifact still fails the job via the guard
|
||||
# below, so a partial manifest can never publish.
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && needs.build-and-release.result == 'success' }}
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
@@ -806,6 +804,8 @@ jobs:
|
||||
- name: Collect every artifact this run published
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
# Verification-only artifacts must never enter the release manifest.
|
||||
pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
|
||||
@@ -864,17 +864,8 @@ jobs:
|
||||
name: python-dist
|
||||
path: dist
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify package
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install twine
|
||||
twine check dist/*
|
||||
|
||||
# twine check --strict already ran with the locked release toolchain
|
||||
# before these exact distributions were uploaded by build-and-release.
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
with:
|
||||
@@ -1046,7 +1037,7 @@ jobs:
|
||||
|
||||
publish-snap:
|
||||
name: Publish Snap to Store
|
||||
needs: build-snap
|
||||
needs: [build-snap, build-and-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -59,6 +59,7 @@ jobs:
|
||||
- 'scripts/**'
|
||||
- '.pre-commit-config.yaml'
|
||||
- '.github/workflows/unified-pipeline.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
# The AUR gate builds packaging/aur/vocalinux/PKGBUILD from this
|
||||
# tree. Its depends= mirror pyproject.toml, so every python change
|
||||
# reaches the gate through the python filter above; this filter
|
||||
@@ -95,6 +96,34 @@ jobs:
|
||||
fi
|
||||
rm -f "$COMMIT_MSG_FILE"
|
||||
|
||||
# Always run: README, LICENSE and MANIFEST.in also affect the distribution.
|
||||
# This is the same gate that runs before a tag can publish anything.
|
||||
reproducible-wheel:
|
||||
name: Reproducible Python wheel
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Set up pinned uv
|
||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.12.23'
|
||||
- name: Build and verify reproducible wheel
|
||||
run: python scripts/build_reproducible_wheel.py --python "$(command -v python)"
|
||||
- name: Preserve failed wheel builds and diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheel-reproducibility-diagnostics
|
||||
path: build/reproducible-wheel/
|
||||
retention-days: 7
|
||||
|
||||
python-lint:
|
||||
name: 🔍 Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
Reference in New Issue
Block a user