* 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:
@@ -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:
|
||||
- '*'
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
||||
|
||||
- name: Install basic dependencies
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user