diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e684638 --- /dev/null +++ b/.github/dependabot.yml @@ -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: + - '*' diff --git a/.github/workflows/codeberg-mirror.yml b/.github/workflows/codeberg-mirror.yml index 38edc3b..3c3438a 100644 --- a/.github/workflows/codeberg-mirror.yml +++ b/.github/workflows/codeberg-mirror.yml @@ -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 diff --git a/.github/workflows/distro-test-matrix.yml b/.github/workflows/distro-test-matrix.yml index 4f5c4f9..0f11e15 100644 --- a/.github/workflows/distro-test-matrix.yml +++ b/.github/workflows/distro-test-matrix.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - name: Install basic dependencies run: | diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index ef0ac28..f98bf62 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -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 diff --git a/.github/workflows/issue-welcome.yml b/.github/workflows/issue-welcome.yml index 73cdf94..6621de5 100644 --- a/.github/workflows/issue-welcome.yml +++ b/.github/workflows/issue-welcome.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 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index f0375f4..6220379 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -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 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index eacaca9..b51635c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ce5722..1ca4895 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/unified-pipeline.yml b/.github/workflows/unified-pipeline.yml index 0e681f4..2646e65 100644 --- a/.github/workflows/unified-pipeline.yml +++ b/.github/workflows/unified-pipeline.yml @@ -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 diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index aba0c96..184f0d6 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -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 --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 diff --git a/tests/test_release_integrity.py b/tests/test_release_integrity.py new file mode 100644 index 0000000..cc6e695 --- /dev/null +++ b/tests/test_release_integrity.py @@ -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" diff --git a/tests/test_workflow_action_pins.py b/tests/test_workflow_action_pins.py new file mode 100644 index 0000000..e55db96 --- /dev/null +++ b/tests/test_workflow_action_pins.py @@ -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"