fix(void): wire signed xbps repo into release, README, and release process
Add build-xbps tag job to release.yml: verifies the tag against src/vocalinux/version.py, fails closed when XBPS_SIGNING_KEY is unset, builds and signs via podman-only repo.sh, and attaches packages, signatures, repodata, and client key files to the release. Fold the xbps-repo artifact into publish-checksums (manifest + SHA256SUMS). Document the Void install in the root README (glibc x86_64 scope, fingerprint check, third-party repo caveat) and the post-tag template version/checksum bump as a pull request in docs/RELEASE_PROCESS.md. Proof: local podman run with a throwaway RSA4096 key built all four templates, signed packages + repodata, and verified voculinux installs from the signed repo in a clean rootdir (fingerprint eb:29:5d:9e:5c:94:c1:63:ae:35:cd:35:db:7a:28:da).
This commit is contained in:
@@ -289,6 +289,21 @@ jobs:
|
||||
`--dangerous` is required because this file is not a Store revision. It will not
|
||||
refresh from the Store. amd64 only.
|
||||
|
||||
### Void Linux (glibc x86_64, signed xbps repo)
|
||||
Also attached below: the signed repository files (`*.x86_64.xbps`
|
||||
with signatures, `x86_64-repodata`, and the client key
|
||||
`FINGERPRINT` plus the fingerprint-named `.plist`). This is a
|
||||
third-party repository published by this project, not upstream
|
||||
Void Linux:
|
||||
```bash
|
||||
# as root (the files come from this release's assets):
|
||||
cp <fingerprint>.plist /var/db/xbps/keys/
|
||||
echo 'repository=<repo-url-from-release-notes>' > /etc/xbps.d/voculinux.conf
|
||||
sudo xbps-install -Sy voculinux
|
||||
```
|
||||
Answer `Y` at the fingerprint prompt only after checking it
|
||||
matches the attached `FINGERPRINT` file.
|
||||
|
||||
### Verifying what you downloaded
|
||||
`SHA256SUMS` covers every file here and is attached once the aarch64 AppImage,
|
||||
both Flatpak builds, and the snap finish, a few minutes after this release appears.
|
||||
@@ -789,7 +804,7 @@ jobs:
|
||||
# missing one looks like the tampered one.
|
||||
publish-checksums:
|
||||
name: Checksums and Provenance
|
||||
needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap]
|
||||
needs: [build-and-release, build-appimage-arm64, build-native, build-native-arm64, build-flatpak-amd64, build-flatpak-arm64, attach-flatpak, attach-snap, build-xbps]
|
||||
runs-on: ubuntu-latest
|
||||
# An attach-job failure must not skip checksums: this job hashes the build
|
||||
# artifacts, which exist regardless of whether an upload to the release
|
||||
@@ -805,7 +820,7 @@ jobs:
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
# Verification-only artifacts must never enter the release manifest.
|
||||
pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'
|
||||
pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
|
||||
@@ -816,6 +831,9 @@ jobs:
|
||||
for pat in '*.whl' '*.tar.gz' '*.AppImage' '*.deb' '*.rpm' '*.flatpak' '*.snap'; do
|
||||
compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; }
|
||||
done
|
||||
for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
|
||||
compgen -G "$pat" > /dev/null || { echo "missing artifact: $pat"; exit 1; }
|
||||
done
|
||||
test "$(compgen -G '*.AppImage' | wc -l)" -eq 2
|
||||
test "$(compgen -G '*.deb' | wc -l)" -eq 2
|
||||
test "$(compgen -G '*.rpm' | wc -l)" -eq 2
|
||||
@@ -827,8 +845,12 @@ jobs:
|
||||
set -euo pipefail
|
||||
ls -la
|
||||
# Bare names, so `sha256sum -c` works in whatever directory the user
|
||||
# downloaded the release into.
|
||||
# downloaded the release into. The xbps repo files hash from their
|
||||
# own directories for the same reason: the release attaches them
|
||||
# flat, and RSA signatures stay the primary trust root.
|
||||
sha256sum -- *.whl *.tar.gz *.AppImage *.deb *.rpm *.flatpak *.snap | tee SHA256SUMS
|
||||
(cd repo && sha256sum -- *.x86_64.xbps *.sig2 *-repodata*) >> SHA256SUMS
|
||||
(cd keys && sha256sum -- FINGERPRINT *.plist) >> SHA256SUMS
|
||||
|
||||
# Attesting from the manifest rather than a second glob: the file we
|
||||
# publish and the set we attest cannot drift apart.
|
||||
@@ -1021,6 +1043,94 @@ jobs:
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_message: 'Release ${{ github.ref_name }}'
|
||||
|
||||
# The signed Void xbps repository (ticket #29): every template builds in
|
||||
# smoke-gate order in the pinned Void container via podman, the full
|
||||
# repository index is signed with the RSA4096 repository key, and the
|
||||
# packages, signatures, repodata, and client key land on this release.
|
||||
# XBPS_SIGNING_KEY is required — an unsigned repository can never
|
||||
# publish, so a missing key fails the job instead of skipping it (unlike
|
||||
# the opt-in Flatpak remote and AUR publishes). The voculinux template
|
||||
# builds the canonical release sdist, so this waits for build-and-release,
|
||||
# and the tag must match src/vocalinux/version.py or the job aborts.
|
||||
build-xbps:
|
||||
name: Build signed xbps repository
|
||||
needs: build-and-release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Verify tag matches the version source of truth
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
PACKAGE_VERSION=$(grep -oP '__version__\s*=\s*"\K[^"]+' src/vocalinux/version.py)
|
||||
echo "Tag version: $TAG"
|
||||
echo "Package version: $PACKAGE_VERSION"
|
||||
if [[ "$PACKAGE_VERSION" != *"$TAG"* ]] && [[ "$TAG" != *"$PACKAGE_VERSION"* ]]; then
|
||||
echo "::error::Tag v$TAG does not match src/vocalinux/version.py ($PACKAGE_VERSION); refusing to ship the signed repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail-closed: no key, no repo, no publish — never a silent skip.
|
||||
- name: Require the repository signing key
|
||||
env:
|
||||
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -z "$XBPS_SIGNING_KEY" ]; then
|
||||
echo "::error::XBPS_SIGNING_KEY is unset; refusing to publish an unsigned repository (packaging/void/README.md)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends podman
|
||||
|
||||
- name: Build and sign the repository
|
||||
env:
|
||||
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
chmod 600 /tmp/xbps-sign-key.pem
|
||||
XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem bash packaging/void/repo.sh
|
||||
rm -f /tmp/xbps-sign-key.pem
|
||||
ls -la publish/xbps/repo publish/xbps/keys
|
||||
|
||||
- name: Upload the signed repository
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: xbps-repo
|
||||
path: |
|
||||
publish/xbps/repo/*
|
||||
publish/xbps/keys/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Attach the signed repository to the release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd publish/xbps
|
||||
# Full signed repo or nothing: binary packages (*.x86_64.xbps)
|
||||
# with their signatures, the signed repository metadata
|
||||
# (*-repodata), and the client key files (FINGERPRINT plus the
|
||||
# fingerprint-named .plist). Each class is required below, so a
|
||||
# partial repo can never attach.
|
||||
compgen -G 'repo/*.x86_64.xbps' > /dev/null || { echo "missing packages"; exit 1; }
|
||||
compgen -G 'repo/*.sig2' > /dev/null || { echo "missing signatures"; exit 1; }
|
||||
compgen -G 'repo/*-repodata*' > /dev/null || { echo "missing repodata"; exit 1; }
|
||||
compgen -G 'keys/FINGERPRINT' > /dev/null || { echo "missing FINGERPRINT"; exit 1; }
|
||||
compgen -G 'keys/*.plist' > /dev/null || { echo "missing client key"; exit 1; }
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
repo/*.x86_64.xbps repo/*.sig2 repo/*-repodata* keys/FINGERPRINT keys/*.plist \
|
||||
--repo ${{ github.repository }} \
|
||||
--clobber
|
||||
|
||||
# GitHub Release first, Store second. A super-privileged plug can still
|
||||
# queue a human review and refuse edge/candidate; the .snap still
|
||||
# has to land on the tag for sideload. grade: stable allows candidate; do
|
||||
|
||||
@@ -236,6 +236,31 @@ nix profile install github:VocaHQ/vocalinux
|
||||
|
||||
The flake also ships `nixosModules.voculinux` and `homeManagerModules.voculinux` for declarative installs (`programs.voculinux.enable = true`). The bundled pywhispercpp builds with Vulkan GPU inference. Details: [packaging/nix/README.md](packaging/nix/README.md).
|
||||
|
||||
### Void Linux (signed xbps repo)
|
||||
|
||||
Void Linux **glibc `x86_64` only**. Each tagged release attaches a signed
|
||||
personal xbps repository: binary packages (`*.x86_64.xbps`), signatures,
|
||||
repository metadata (`x86_64-repodata`), and the client key
|
||||
(`FINGERPRINT` plus the fingerprint-named `.plist`).
|
||||
|
||||
This is a **third-party repository** — it is published by this project,
|
||||
not by upstream Void Linux, and its packages are built from the
|
||||
templates in `packaging/void/srcpkgs/`. Details: [packaging/void/README.md](packaging/void/README.md).
|
||||
|
||||
```bash
|
||||
# as root on the Void system (the files come from the release assets):
|
||||
cp <fingerprint>.plist /var/db/xbps/keys/
|
||||
echo 'repository=<repo-url-from-release-notes>' > /etc/xbps.d/voculinux.conf
|
||||
```
|
||||
|
||||
Prefer the release notes for the exact repository URL. On first sync,
|
||||
answer `Y` at the fingerprint prompt only after checking it matches the
|
||||
`FINGERPRINT` file attached to the same release:
|
||||
|
||||
```bash
|
||||
sudo xbps-install -Sy voculinux
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
|
||||
@@ -54,6 +54,7 @@ Use this checklist for every release:
|
||||
- [ ] `docs/INSTALL.md` - Verify install examples use `main/install.sh` (not version-pinned raw URLs)
|
||||
- [ ] `docs/UPDATE.md` - Add "What's New" section for new version
|
||||
- [ ] `SECURITY.md` - Update supported versions table
|
||||
- [ ] `packaging/void/srcpkgs/` - After the tag, bump template `version`/`checksum` to the new release as a pull request (never direct to `main`)
|
||||
|
||||
### Website
|
||||
- [ ] `web/src/app/page.tsx` - Update `softwareVersion` in schema
|
||||
@@ -310,6 +311,19 @@ Update the supported versions table:
|
||||
| < 0.4 | :x: |
|
||||
```
|
||||
|
||||
#### 3.5 Update Void templates (pull request)
|
||||
|
||||
After the tag is cut, the Void templates in `packaging/void/srcpkgs/` must
|
||||
point at the new release: bump `version` in the `voculinux` template (and
|
||||
any template whose upstream moved) and refresh every pinned `checksum`
|
||||
(`sha256` sums of the release sdist and distfiles). Land the bump as a
|
||||
**pull request** through the normal test, lint, and type gates — never as
|
||||
a direct or bot commit to `main`, and never hand-edited onto the release
|
||||
itself (see `just verify-release`). `tests/test_void_app_template.py`
|
||||
fails if the template version or checksum drifts from
|
||||
`src/vocalinux/version.py` and the published sdist, and the signed xbps
|
||||
repository for the *next* release builds from these templates.
|
||||
|
||||
### Step 4: Update Website
|
||||
|
||||
#### 4.1 Update `web/src/app/page.tsx`
|
||||
|
||||
@@ -118,11 +118,13 @@ def test_release_publication_cannot_bypass_the_wheel_gate():
|
||||
"publish-aur",
|
||||
"publish-aur-bin",
|
||||
"publish-flatpak-remote",
|
||||
"build-xbps",
|
||||
):
|
||||
assert "build-and-release" in _needs(jobs[name]), name
|
||||
checksums = jobs["publish-checksums"]
|
||||
assert "needs.build-and-release.result == 'success'" in checksums
|
||||
assert "pattern: '@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'" in checksums
|
||||
pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64|xbps-repo)'"
|
||||
assert f"pattern: {pattern}" in checksums
|
||||
|
||||
|
||||
def test_aur_bin_publication_depends_only_on_its_appimage_inputs() -> None:
|
||||
@@ -177,6 +179,10 @@ def test_the_manifest_covers_every_kind_of_artifact_we_publish():
|
||||
assert checksum_line, "publish-checksums does not generate SHA256SUMS"
|
||||
for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"):
|
||||
assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed"
|
||||
# The signed xbps repo hashes from its own directories (bare names, same
|
||||
# as the flat files the release attaches), alongside the client key.
|
||||
for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT", "*.plist"):
|
||||
assert pattern in block, f"{pattern} is published but unchecksummed"
|
||||
|
||||
|
||||
def test_provenance_is_generated_from_the_published_manifest():
|
||||
|
||||
@@ -33,6 +33,9 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
|
||||
ROOT_README = REPO_ROOT / "README.md"
|
||||
RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md"
|
||||
VOID = REPO_ROOT / "packaging" / "void"
|
||||
SRC = VOID / "srcpkgs"
|
||||
REPO_SH = VOID / "repo.sh"
|
||||
@@ -198,3 +201,96 @@ def test_readme_documents_the_signed_repo_flow() -> None:
|
||||
"fail" in text.lower() and "closed" in text.lower()
|
||||
), "README must state the fail-closed rule: no key, no repo"
|
||||
assert ".plist" in text, "README must document the client key install"
|
||||
|
||||
|
||||
def _release_workflow_text() -> str:
|
||||
assert RELEASE_WORKFLOW.is_file(), f"missing release workflow: {RELEASE_WORKFLOW}"
|
||||
return RELEASE_WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _xbps_job_text() -> str:
|
||||
"""The release xbps job block only — other jobs have their own runtimes."""
|
||||
text = _release_workflow_text()
|
||||
lines = text.splitlines(keepends=True)
|
||||
start = next(
|
||||
(i for i, line in enumerate(lines) if re.match(r"^ build-xbps:", line)),
|
||||
None,
|
||||
)
|
||||
assert start is not None, "release.yml must define a build-xbps job"
|
||||
end = next(
|
||||
(
|
||||
i
|
||||
for i, line in enumerate(lines[start + 1 :], start + 1)
|
||||
if re.match(r"^ [a-z][a-z0-9-]*:", line)
|
||||
),
|
||||
len(lines),
|
||||
)
|
||||
return "".join(lines[start:end])
|
||||
|
||||
|
||||
def test_release_workflow_builds_signed_xbps_on_tag() -> None:
|
||||
"""The tag pipeline builds the signed repo and attaches it to the release."""
|
||||
text = _xbps_job_text()
|
||||
assert (
|
||||
"packaging/void/repo.sh" in text
|
||||
), "the xbps job must run the signed-repo pipeline, not a second inline flow"
|
||||
assert "podman" in text, "the release xbps job must install podman (never Docker)"
|
||||
assert "docker" not in text.lower().replace(
|
||||
"never docker", ""
|
||||
), "the release xbps job must never invoke Docker"
|
||||
|
||||
|
||||
def test_release_workflow_xbps_job_fails_closed_without_key() -> None:
|
||||
"""An unset XBPS_SIGNING_KEY fails the xbps job — never a silent skip."""
|
||||
text = _xbps_job_text()
|
||||
assert SIGNING_SECRET in text, "the xbps job must read the XBPS_SIGNING_KEY secret"
|
||||
assert re.search(
|
||||
r"XBPS_SIGNING_KEY.{0,400}?exit 1", text, re.DOTALL
|
||||
), "an empty XBPS_SIGNING_KEY must exit 1, not skip the publish"
|
||||
|
||||
|
||||
def test_release_workflow_xbps_job_gates_tag_against_version() -> None:
|
||||
"""The xbps job only ships the repo when the tag matches version.py."""
|
||||
text = _xbps_job_text()
|
||||
assert "version.py" in text, "the xbps job must gate on version.py"
|
||||
assert re.search(
|
||||
r"[Tt]ag.{0,200}?[Vv]ersion|[Vv]ersion.{0,200}?[Tt]ag", text
|
||||
), "release.yml must compare the tag against the version source of truth"
|
||||
|
||||
|
||||
def test_release_workflow_attaches_signed_repo_to_release() -> None:
|
||||
"""Packages, signatures, repodata, and the client key land on the release."""
|
||||
text = _xbps_job_text()
|
||||
assert "gh release upload" in text, "the xbps job must attach its output to the release"
|
||||
for marker in ("*.x86_64.xbps", "repodata", "FINGERPRINT"):
|
||||
assert marker in text, f"the xbps job must attach {marker} to the release"
|
||||
|
||||
|
||||
def test_root_readme_registers_the_void_repo() -> None:
|
||||
"""Root README: Void install from the signed repo, not just the track README."""
|
||||
text = ROOT_README.read_text(encoding="utf-8")
|
||||
assert "xbps" in text.lower(), "root README must name xbps"
|
||||
for marker in ("FINGERPRINT", ".plist", "repository="):
|
||||
assert marker in text, f"root README must document client registration ({marker})"
|
||||
assert "xbps-install" in text, "root README must give the install command"
|
||||
|
||||
|
||||
def test_root_readme_states_void_scope_and_third_party_caveat() -> None:
|
||||
"""glibc x86_64 only, and this is a third-party repo outside upstream Void."""
|
||||
text = ROOT_README.read_text(encoding="utf-8").lower()
|
||||
assert "x86_64" in text, "root README must scope the repo to x86_64"
|
||||
assert "glibc" in text, "root README must scope the repo to glibc"
|
||||
assert (
|
||||
"third-party" in text or "third party" in text
|
||||
), "root README must say the repo is third-party, not upstream Void"
|
||||
|
||||
|
||||
def test_release_process_documents_void_template_bump_as_pr() -> None:
|
||||
"""Template version/checksum bumps land as a pull request, never direct to main."""
|
||||
text = RELEASE_PROCESS.read_text(encoding="utf-8")
|
||||
assert (
|
||||
"srcpkgs" in text or "xbps" in text.lower()
|
||||
), "docs/RELEASE_PROCESS.md must cover the Void template bump"
|
||||
assert re.search(
|
||||
r"[Pp]ull request", text
|
||||
), "docs/RELEASE_PROCESS.md must require the bump as a pull request"
|
||||
|
||||
Reference in New Issue
Block a user