chore(release): make a release verifiable, and pin what builds it (epic #701, phase 5) (#759)

* chore(release): build once, and publish sha256sums and provenance (epic #701, phase 5)

* chore(ci): pin every third-party action by commit sha (epic #701, phase 5)

* test(ci): anchor the dependabot guard on the ecosystem it must cover
This commit is contained in:
Sergey
2026-08-31 14:32:20 -07:00
committed by GitHub
parent 9816fe2d16
commit 0ba029b015
12 changed files with 480 additions and 90 deletions
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
# Actions are pinned by commit SHA (tests/test_workflow_action_pins.py), which
# is only safe if something moves the pins: an action nobody bumps stops
# receiving its own security fixes, and the pin hides that it has gone stale.
# Grouped so this arrives as one pull request a week rather than fifteen.
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- '*'
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'VocaHQ/vocalinux'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Install basic dependencies
run: |
+2 -2
View File
@@ -51,10 +51,10 @@ jobs:
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Build and bundle
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6
with:
bundle: vocalinux-${{ matrix.arch }}.flatpak
manifest-path: packaging/flatpak/com.vocalinux.Vocalinux.yml
+4
View File
@@ -13,4 +13,8 @@ permissions:
jobs:
welcome:
# Deliberately unpinned: this is our own org's shared workflow, inside the
# same trust boundary as this repository, and the point of it is that a
# change to the welcome text reaches every VocaHQ repo without 30 bumps.
# Everything third-party is pinned by SHA (tests/test_workflow_action_pins.py).
uses: VocaHQ/.github/.github/workflows/issue-welcome.yml@main
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Label PR
uses: actions/labeler@v6
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
sync-labels: true
+7 -7
View File
@@ -18,13 +18,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: main
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
cache: 'pip'
@@ -67,7 +67,7 @@ jobs:
# glslc is built from the pinned shaderc commit inside the container -
# Ubuntu 22.04 packages none - and depends on nothing but that commit.
- name: Cache AppImage build tools
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/vocalinux-appimage
key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }}
@@ -232,7 +232,7 @@ jobs:
echo "tag=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
- name: Upload artifacts (backup)
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: vocalinux-nightly-${{ steps.version.outputs.version }}
path: dist/
@@ -246,12 +246,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
ref: main
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
cache: 'pip'
@@ -278,7 +278,7 @@ jobs:
# glslc is built from the pinned shaderc commit inside the container -
# Ubuntu 22.04 packages none - and depends on nothing but that commit.
- name: Cache AppImage build tools
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/vocalinux-appimage
key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }}
+140 -50
View File
@@ -5,10 +5,11 @@ on:
tags:
- 'v*'
# Least privilege, granted per job. `id-token: write` at the top level handed an
# OIDC token to every job in the workflow, including publish-aur, which runs a
# third-party action with our AUR signing key.
permissions:
contents: write
packages: write
id-token: write # Required for PyPI trusted publishing
contents: read
env:
# Build against the supported floor (requires-python in pyproject.toml).
@@ -18,14 +19,16 @@ jobs:
build-and-release:
name: Build and Create Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
@@ -33,7 +36,7 @@ jobs:
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine wheel setuptools
pip install build wheel setuptools
- name: Extract version from tag
id: get_version
@@ -54,16 +57,37 @@ 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"
# 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: Upload the built distributions
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: python-dist
path: |
dist/*.whl
dist/*.tar.gz
if-no-files-found: error
retention-days: 7
# glslc is built from the pinned shaderc commit inside the container -
# Ubuntu 22.04 packages none - and depends on nothing but that commit.
- name: Cache AppImage build tools
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/vocalinux-appimage
key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }}
@@ -75,9 +99,17 @@ jobs:
bash packaging/appimage/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist
ls -la dist/*.AppImage
- name: Upload the x86_64 AppImage
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: appimage-x86_64
path: dist/*.AppImage
if-no-files-found: error
retention-days: 7
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
dist/*.whl
@@ -129,6 +161,19 @@ jobs:
```
Still requires `xdotool`/`wtype`/`ydotool` on the host for text injection, same as the PyPI path.
### Verifying what you downloaded
`SHA256SUMS` covers every file here and is attached once the aarch64 build finishes,
a few minutes after this release appears. The wheel and sdist below are the same bytes
published to PyPI.
```bash
sha256sum -c --ignore-missing SHA256SUMS
```
Each artifact also carries GitHub build provenance, which ties it to this workflow run:
```bash
gh attestation verify Vocalinux-${{ steps.get_version.outputs.VERSION }}-x86_64.AppImage \
--repo ${{ github.repository }}
```
---
See [CHANGELOG](https://github.com/VocaHQ/vocalinux/releases) for detailed changes.
@@ -136,20 +181,11 @@ jobs:
name: Build and Attach AppImage (aarch64)
needs: build-and-release
runs-on: ubuntu-24.04-arm
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build wheel setuptools
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Extract version from tag
id: get_version
@@ -157,15 +193,19 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/v}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Build package
run: |
python -m build
ls -la dist/
# The wheel is py3-none-any, so this is the wheel the release and PyPI
# carry. Building it again here made a third copy of the same version that
# no published checksum could describe.
- name: Download the built distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-dist
path: dist
# glslc is built from the pinned shaderc commit inside the container -
# Ubuntu 22.04 packages none - and depends on nothing but that commit.
- name: Cache AppImage build tools
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/vocalinux-appimage
key: appimage-tools-${{ runner.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }}
@@ -177,6 +217,14 @@ jobs:
bash packaging/appimage/docker-build.sh dist/*.whl "${{ steps.get_version.outputs.VERSION }}" dist
ls -la dist/*.AppImage
- name: Upload the aarch64 AppImage
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: appimage-aarch64
path: dist/*.AppImage
if-no-files-found: error
retention-days: 7
- name: Attach to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -186,49 +234,89 @@ jobs:
--repo ${{ github.repository }} \
--clobber
# Runs last because the aarch64 AppImage lands after the release is created:
# a checksum manifest that covers three of the four files is worse than none,
# since the missing one looks like the tampered one.
publish-checksums:
name: Checksums and Provenance
needs: [build-and-release, build-appimage-arm64]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Collect every artifact this run published
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
merge-multiple: true
path: dist
- name: Generate SHA256SUMS
working-directory: dist
run: |
set -euo pipefail
ls -la
# Bare names, so `sha256sum -c` works in whatever directory the user
# downloaded the release into.
sha256sum -- *.whl *.tar.gz *.AppImage | tee SHA256SUMS
# Attesting from the manifest rather than a second glob: the file we
# publish and the set we attest cannot drift apart.
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
with:
subject-checksums: dist/SHA256SUMS
- name: Attach SHA256SUMS to the release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
dist/SHA256SUMS \
--repo ${{ github.repository }} \
--clobber
publish-pypi:
name: Publish to PyPI
needs: build-and-release
runs-on: ubuntu-latest
permissions:
id-token: write # PyPI trusted publishing; there is no API token to leak
environment:
name: pypi
url: https://pypi.org/project/vocalinux/
steps:
- name: Checkout code
uses: actions/checkout@v5
# Not a rebuild: the artifact attached to the GitHub release, so one
# checksum describes both.
- name: Download the built distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-dist
path: dist
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: 'pip'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: |
python -m build
echo "Built packages:"
ls -la dist/
- name: Verify package
run: |
python -m pip install --upgrade pip
pip install twine
twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: |
twine upload dist/* --skip-existing
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
skip-existing: true
publish-aur:
name: Publish to AUR
needs: build-and-release
runs-on: ubuntu-latest
permissions:
contents: read
# Secrets: AUR_SSH_PRIVATE_KEY, AUR_USERNAME, AUR_EMAIL — see docs/AUR.md
steps:
- name: Check for AUR credentials
@@ -245,7 +333,7 @@ jobs:
- name: Checkout code
if: steps.aur.outputs.configured == 'true'
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Bump PKGBUILD version
if: steps.aur.outputs.configured == 'true'
@@ -258,7 +346,7 @@ jobs:
- name: Publish AUR package
if: steps.aur.outputs.configured == 'true'
uses: KSXGitHub/github-actions-deploy-aur@v4.2.0
uses: KSXGitHub/github-actions-deploy-aur@084b0d9b15415bf9cdb65d44dad1efe37a354050 # v4.2.0
with:
pkgname: vocalinux
pkgbuild: packaging/aur/vocalinux/PKGBUILD
@@ -272,12 +360,14 @@ jobs:
name: Deploy Website
runs-on: ubuntu-latest
needs: [build-and-release, publish-pypi]
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: '24'
cache: 'npm'
@@ -319,7 +409,7 @@ jobs:
npm run deploy
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./web/out
+21 -21
View File
@@ -25,8 +25,8 @@ jobs:
python: ${{ steps.filter.outputs.python }}
deploy_website: ${{ steps.deploy_check.outputs.deploy }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
id: filter
with:
filters: |
@@ -79,10 +79,10 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
enable-cache: true
@@ -116,10 +116,10 @@ jobs:
python-version: ['3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -162,14 +162,14 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-results-${{ matrix.python-version }}
path: junit-${{ matrix.python-version }}.xml
- name: Upload coverage to Codecov
if: matrix.python-version == '3.13'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
@@ -182,10 +182,10 @@ jobs:
needs: [changes, python-lint]
if: needs.changes.outputs.python == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Set up uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
# Caches uv's download cache, not .venv/, so the checkout below stays
# the fresh clone this job exists to exercise.
@@ -231,10 +231,10 @@ jobs:
- arch: aarch64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
cache: 'pip'
@@ -248,7 +248,7 @@ jobs:
# glslc is built from the pinned shaderc commit inside the container -
# Ubuntu 22.04 packages none - and depends on nothing but that commit.
- name: Cache AppImage build tools
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: ~/.cache/vocalinux-appimage
key: appimage-tools-${{ matrix.arch }}-${{ hashFiles('packaging/appimage/tool_checksums.txt') }}
@@ -263,7 +263,7 @@ jobs:
ls -la dist/*.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: vocalinux-appimage-${{ matrix.arch }}
path: dist/*.AppImage
@@ -289,10 +289,10 @@ jobs:
- archlinux:latest
- opensuse/tumbleweed
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Download the AppImage built above
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: vocalinux-appimage-x86_64
path: dist
@@ -313,10 +313,10 @@ jobs:
needs: changes
if: needs.changes.outputs.deploy_website == 'true'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: '24'
cache: 'npm'
@@ -371,10 +371,10 @@ jobs:
needs.changes.outputs.deploy_website == 'true' &&
needs.web-build.result == 'success'
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: '24'
cache: 'npm'
@@ -387,7 +387,7 @@ jobs:
npm run deploy
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./web/out
+43 -7
View File
@@ -10,6 +10,33 @@ Vocalinux uses [Semantic Versioning](https://semver.org/) with pre-release suffi
- **RC** (Release Candidate): Final testing before stable (e.g., `0.5.0-rc1`)
- **Stable**: Production-ready (e.g., `1.0.0`)
## Release Infrastructure (one-time setup)
These are configured once per repository. A tag pushed before they exist will fail
in the corresponding job.
### PyPI trusted publishing (required)
`publish-pypi` authenticates with a short-lived OIDC token instead of a stored API
token, so there is no `PYPI_API_TOKEN` secret to leak or rotate. It only works once
PyPI knows about this workflow. On https://pypi.org/manage/project/vocalinux/settings/publishing/
add a GitHub publisher:
| field | value |
|---|---|
| Owner | `VocaHQ` |
| Repository | `vocalinux` |
| Workflow name | `release.yml` |
| Environment | `pypi` |
Until that publisher exists, `publish-pypi` fails with `invalid-publisher`. The old
`PYPI_API_TOKEN` secret can be deleted from the repository once the first trusted
publish succeeds.
### AUR (optional)
`publish-aur` skips itself when `AUR_SSH_PRIVATE_KEY` is unset. See docs/AUR.md.
## Quick Release Checklist
Use this checklist for every release:
@@ -350,20 +377,29 @@ git push origin v0.5.0-beta
After pushing the tag, the GitHub Actions workflow will automatically:
1. Build the Python package (wheel and sdist)
2. Build and attach AppImages for x86_64 and aarch64
1. Build the Python package (wheel and sdist) — **once**, with `SOURCE_DATE_EPOCH`
pinned to the tagged commit. Every later job downloads that artifact instead of
rebuilding, so the wheel on PyPI is byte-for-byte the wheel on the release
2. Build and attach AppImages for x86_64 and aarch64, both from that same wheel
3. Create a GitHub Release with auto-generated notes
4. Publish to PyPI
5. Publish the AUR package (when the `AUR_SSH_PRIVATE_KEY` secret is configured)
6. Deploy the website to vocalinux.com
7. Mark as pre-release if version contains alpha/beta/rc
4. Attach `SHA256SUMS` covering all four artifacts, and generate build provenance
attestations from that manifest (runs after the aarch64 AppImage lands, so a
partial manifest never gets published)
5. Publish to PyPI via trusted publishing
6. Publish the AUR package (when the `AUR_SSH_PRIVATE_KEY` secret is configured)
7. Deploy the website to vocalinux.com
8. Mark as pre-release if version contains alpha/beta/rc
Monitor at: https://github.com/VocaHQ/vocalinux/actions
### Step 9: Post-Release Tasks
- [ ] Verify GitHub Release was created correctly
- [ ] Verify PyPI package was published (if applicable)
- [ ] Verify `SHA256SUMS` is attached and lists all four artifacts (wheel, sdist,
both AppImages) — the release notes tell users to run `sha256sum -c` against it
- [ ] Verify provenance: `gh attestation verify <artifact> --repo VocaHQ/vocalinux`
- [ ] Verify PyPI package was published (if applicable), and that its wheel sha256
matches the line for that wheel in `SHA256SUMS`
- [ ] Verify website was deployed (check vocalinux.com)
- [ ] Announce on social media/communities
- [ ] Update any pinned issues or discussions
+162
View File
@@ -0,0 +1,162 @@
"""Guard what a published release promises about itself.
v0.16.1 shipped four assets — two AppImages of ~100 MB, a wheel and an sdist —
with no checksum, no signature and no provenance, in a project that pins the
digest of all 67 models it downloads. It also built the same version three times
on three runners (`build-and-release`, `build-appimage-arm64`, `publish-pypi`),
so the wheel on PyPI was never the wheel on the GitHub release, and a checksum
for one would not have described the other.
These tests pin the shape of the fix: build once, publish that, and cover every
artifact with one manifest. Parsed as text rather than YAML on purpose — the
sibling workflow guards do the same, and the only YAML parser on hand reaches
the venv transitively through pre-commit.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE = REPO_ROOT / ".github" / "workflows" / "release.yml"
#: The artifact holding the wheel and sdist that every other job consumes.
DIST_ARTIFACT = "python-dist"
def _text() -> str:
return RELEASE.read_text(encoding="utf-8")
def _without_comments(text: str) -> str:
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _jobs() -> dict:
"""job name -> its block of the workflow, split on the 2-space indent."""
text = _text()
start = text.index("\njobs:\n")
headers = list(re.finditer(r"^ ([a-z0-9][a-z0-9-]*):$", text[start:], re.M))
assert headers, "no jobs found; release.yml's layout moved"
jobs = {}
for index, header in enumerate(headers):
end = headers[index + 1].start() if index + 1 < len(headers) else len(text) - start
jobs[header.group(1)] = text[start:][header.start() : end]
return jobs
def _needs(block: str) -> set:
match = re.search(r"^ needs: (.+)$", block, re.M)
if not match:
return set()
return set(re.findall(r"[a-z0-9][a-z0-9-]*", match.group(1)))
def _permissions(block: str) -> dict:
match = re.search(r"^ permissions:\n((?: \S+: \S+.*\n)+)", block, re.M)
if not match:
return {}
return dict(re.findall(r"^ (\S+): (\S+)", match.group(1), re.M))
def _top_level_permissions() -> dict:
text = _text()
match = re.search(r"^permissions:\n((?: \S+: \S+.*\n)+)", text, re.M)
assert match, "release.yml declares no top-level permissions"
return dict(re.findall(r"^ (\S+): (\S+)", match.group(1), re.M))
def test_the_release_is_built_exactly_once():
"""Three builds meant three sets of bytes and no `SOURCE_DATE_EPOCH`, so
neither a checksum nor an attestation could speak for the whole release."""
builds = _without_comments(_text()).count("python -m build")
assert builds == 1, f"the release is built {builds} times; build once and pass the artifact"
def test_the_build_timestamp_is_pinned_to_the_tagged_commit():
"""Otherwise re-running the tag produces bytes the published checksum no
longer matches."""
block = _jobs()["build-and-release"]
assert "SOURCE_DATE_EPOCH=" in block
assert block.index("SOURCE_DATE_EPOCH=") < block.index(
"python -m build\n"
), "SOURCE_DATE_EPOCH is set after the build, which is the same as not setting it"
def test_every_consumer_downloads_the_build_instead_of_repeating_it():
jobs = _jobs()
for name in ("build-appimage-arm64", "publish-pypi"):
block = jobs[name]
assert "actions/download-artifact" in block, f"{name} does not consume the build"
assert f"name: {DIST_ARTIFACT}" in block, f"{name} downloads some other artifact"
assert "python -m build" not in _without_comments(
block
), f"{name} rebuilds what build-and-release already published"
def _jobs_that_attach_to_the_release() -> set:
return {
name
for name, block in _jobs().items()
if "gh release upload" in block or "softprops/action-gh-release" in block
}
def test_the_manifest_waits_for_every_artifact_it_has_to_cover():
"""A SHA256SUMS listing three of the four files is worse than none: the
missing one is indistinguishable from a tampered one. The aarch64 AppImage
lands in its own job after the release exists, which is what makes this
ordering a real constraint rather than a formality."""
jobs = _jobs()
assert "publish-checksums" in jobs, "nothing generates a checksum manifest"
producers = _jobs_that_attach_to_the_release() - {"publish-checksums"}
missing = producers - _needs(jobs["publish-checksums"])
assert not missing, f"publish-checksums runs before {sorted(missing)} attach their artifacts"
def test_the_manifest_covers_every_kind_of_artifact_we_publish():
block = _jobs()["publish-checksums"]
assert "merge-multiple: true" in block, "it collects one artifact, not all of them"
checksum_line = re.search(r"sha256sum -- (.+)$", block, re.M)
assert checksum_line, "publish-checksums does not generate SHA256SUMS"
for pattern in ("*.whl", "*.tar.gz", "*.AppImage"):
assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed"
def test_provenance_is_generated_from_the_published_manifest():
"""Attesting a second glob would let the file users check and the set we
attest drift apart."""
block = _jobs()["publish-checksums"]
assert "actions/attest-build-provenance" in block, "no build provenance is generated"
assert (
"subject-checksums: dist/SHA256SUMS" in block
), "provenance is not driven by the manifest we publish"
assert _permissions(block).get("attestations") == "write"
def test_pypi_is_published_without_a_stored_token():
text = _text()
assert "PYPI_API_TOKEN" not in text, "trusted publishing needs no API token"
assert "TWINE_PASSWORD" not in text
block = _jobs()["publish-pypi"]
assert "pypa/gh-action-pypi-publish" in block
assert _permissions(block).get("id-token") == "write"
def test_only_the_jobs_that_need_it_can_mint_an_oidc_token():
"""`id-token: write` at the top level handed one to publish-aur too, which
runs a third-party action holding our AUR signing key."""
assert "id-token" not in _top_level_permissions()
minters = {
name for name, block in _jobs().items() if _permissions(block).get("id-token") == "write"
}
assert minters == {"publish-pypi", "publish-checksums"}, minters
def test_the_release_notes_tell_users_how_to_verify_the_download():
"""Publishing a manifest nobody is told about verifies nothing."""
body = _jobs()["build-and-release"]
assert "SHA256SUMS" in body, "the release notes never mention the manifest"
assert "sha256sum -c" in body, "the notes do not show how to check it"
assert "gh attestation verify" in body, "the notes do not show how to check provenance"
+84
View File
@@ -0,0 +1,84 @@
"""Pin what a third-party action actually resolves to when CI runs it.
Every `uses:` in this repository floated on a moving tag — `@v4`, `@v5`,
`@release/v1`. A tag is writable by whoever owns the action, so
`KSXGitHub/github-actions-deploy-aur@v4.2.0`, which is handed the SSH key that
can push to our AUR package, was whatever that tag happened to point at on the
morning of a release. Same for the action that uploads our release assets and
the one that publishes to PyPI.
Parsed as text rather than YAML for the same reason as the sibling guards: the
only YAML parser in the venv arrives transitively through pre-commit.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
DEPENDABOT = REPO_ROOT / ".github" / "dependabot.yml"
#: Owner whose refs may float: our own org is inside this repository's trust
#: boundary, and a shared workflow exists to propagate without 30 version bumps.
OWN_ORG = "vocahq"
SHA = re.compile(r"^[0-9a-f]{40}$")
def _uses() -> list:
"""(workflow, line number, ref, trailing comment) for every `uses:`."""
found = []
for workflow in sorted(WORKFLOWS.glob("*.yml")):
for number, line in enumerate(workflow.read_text(encoding="utf-8").splitlines(), 1):
match = re.match(r"\s*uses:\s*(\S+)\s*(?:#\s*(.*))?$", line)
if match:
found.append((workflow.name, number, match.group(1), match.group(2)))
return found
def test_the_workflows_are_still_where_this_test_thinks_they_are():
"""A guard that silently scans nothing passes forever."""
entries = _uses()
assert len(entries) > 40, f"only found {len(entries)} `uses:` entries"
def test_every_third_party_action_is_pinned_to_a_commit():
floating = [
f"{workflow}:{number} {ref}"
for workflow, number, ref, _ in _uses()
if not SHA.match(ref.rpartition("@")[2]) and not ref.lower().startswith(f"{OWN_ORG}/")
]
assert not floating, "these run with our secrets off a moving tag:\n" + "\n".join(floating)
def test_only_our_own_org_may_float():
"""The exemption is a rule about trust boundaries, not a list to append to."""
for workflow, number, ref, _ in _uses():
if SHA.match(ref.rpartition("@")[2]):
continue
owner = ref.split("/")[0].lower()
assert owner == OWN_ORG, f"{workflow}:{number} exempts {owner}, which we do not own"
def test_every_pin_records_the_version_it_came_from():
"""A bare 40-hex SHA tells a reader nothing and gives Dependabot nothing to
bump against."""
unlabelled = [
f"{workflow}:{number} {ref}"
for workflow, number, ref, comment in _uses()
if SHA.match(ref.rpartition("@")[2]) and not comment
]
assert not unlabelled, "pinned with no version comment:\n" + "\n".join(unlabelled)
def test_something_moves_the_pins():
"""A pin nobody bumps is an action that stops getting its own security
fixes, with the pin hiding that it has gone stale."""
assert DEPENDABOT.exists(), "actions are pinned but nothing updates them"
config = DEPENDABOT.read_text(encoding="utf-8")
# Anchored on the ecosystem declaration, not the bare word: "github-actions"
# is also the group name and appears in the comment, so a looser check passed
# while the ecosystem had been switched to pip.
assert re.search(
r"^\s*-\s*package-ecosystem:\s*[\"']?github-actions[\"']?\s*$", config, re.M
), "dependabot.yml does not update the action pins"