fix(void): build the xbps release from the tag, not an attached sdist (#37)
Every release is built from its tag alone by packaging/void/build-release.sh, shared by the tag workflow and local dry runs. Signs in a network-less container with the key mounted read-only and publishes to the tag release and the rolling latest release.
This commit was merged in pull request #37.
This commit is contained in:
@@ -1,28 +1,28 @@
|
||||
name: xbps-release
|
||||
|
||||
# Signed Void xbps repository release (ticket #29). A human pushes a version
|
||||
# tag; this workflow builds every Void template in the pinned Void glibc
|
||||
# container with podman (packaging/void/repo.sh), indexes the full x86_64
|
||||
# repository, signs every package and the repository metadata with the RSA4096
|
||||
# repository key, and attaches the complete signed repository to two releases:
|
||||
# Signed Void xbps repository release (tickets #28 and #29). A human pushes a
|
||||
# version tag; this job does everything else through packaging/void/
|
||||
# build-release.sh, the same script a local dry run and the smoke gate call:
|
||||
#
|
||||
# - the tag's own release (the permanent record of what that tag shipped), and
|
||||
# - the rolling release named `latest`, because the client registration URL
|
||||
# (README.md) serves the newest release's downloads:
|
||||
# https://git.bongbetic.com/xavierk/voculinux/releases/download/latest
|
||||
# 1. the tag must EXACTLY equal "v" + __version__ (src/vocalinux/version.py),
|
||||
# and the committed Void template must already name that version;
|
||||
# 2. the XBPS_SIGNING_KEY secret must be non-empty and RSA 4096, and its public
|
||||
# half must be packaging/void/repository-key.plist;
|
||||
# 3. the canonical sdist is built from the tag (Python 3.11,
|
||||
# uv 0.12/0.13, scripts/build_reproducible_wheel.py);
|
||||
# 4. every template builds in the pinned Void glibc image with podman (no
|
||||
# signing key in that phase), then the repository is signed in a second
|
||||
# container with no network, then voculinux is installed and smoked in a
|
||||
# clean image with no key;
|
||||
# 5. the sdist, the signed repository and the client key are published to the
|
||||
# tag's release and to the rolling `latest` release that the client
|
||||
# registration URL serves (README.md).
|
||||
#
|
||||
# Hard gates, each exiting 1 before anything is published:
|
||||
# 1. the tag must EXACTLY equal "v" + __version__ in src/vocalinux/version.py;
|
||||
# 2. the XBPS_SIGNING_KEY secret must be non-empty (no key, no repo, never a
|
||||
# silent skip and never an unsigned upload);
|
||||
# 3. every class of repository file must exist, nothing but glibc x86_64 data
|
||||
# may exist, every package must carry its signature, and the signing key's
|
||||
# fingerprint must equal the one the README tells users to pin.
|
||||
#
|
||||
# Nothing here commits or pushes to any branch, and nothing bumps the Void
|
||||
# templates: the post-release template bump is a normal pull request
|
||||
# (docs/RELEASE_PROCESS.md). The private key exists only as the CI secret and a
|
||||
# 0600 temp file that the last step removes. Never Docker: podman only.
|
||||
# Nothing here is built or uploaded by hand, nothing commits or pushes to any
|
||||
# branch, and no template is bumped: the post-release template bump is a normal
|
||||
# pull request (docs/RELEASE_PROCESS.md section 3.5). The private key lives only
|
||||
# as this secret and a 0600 file on tmpfs that the last step removes. Podman
|
||||
# only, never Docker. Runner requirements: packaging/void/README.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -40,14 +40,13 @@ jobs:
|
||||
xbps-release:
|
||||
name: Build, sign and publish the xbps repository
|
||||
runs-on: bongbetic-ci
|
||||
timeout-minutes: 180
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux
|
||||
# Must equal the fingerprint in README.md, packaging/void/README.md and
|
||||
# packaging/void/repository-key.plist; tests/test_xbps_release_workflow.py
|
||||
# enforces it. A secret holding any other key fails before publishing.
|
||||
XBPS_KEY_FINGERPRINT: "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
|
||||
XBPS_KEY_FILE: /tmp/xbps-sign-key-${{ gitea.run_id }}.pem
|
||||
# The key file lives on tmpfs (/dev/shm). Its name is fixed per run so the
|
||||
# cleanup step can find it; it is created with noclobber, so a file that
|
||||
# already exists makes the key step fail rather than be reused.
|
||||
XBPS_KEY_FILE: /dev/shm/xbps-sign-key-${{ gitea.run_id }}.pem
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
@@ -58,26 +57,20 @@ jobs:
|
||||
run: |
|
||||
git init -q . && git remote add origin "${SERVER_URL}/${REPOSITORY}.git" && git -c http.extraheader="Authorization: token ${GITEA_TOKEN}" fetch -q --depth=1 origin "${COMMIT_SHA}" && git checkout -q FETCH_HEAD
|
||||
|
||||
# Fail first and cheaply: the gate needs no tools and no secret.
|
||||
- name: Require the tag to equal the version in version.py
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' src/vocalinux/version.py)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::could not read __version__ from src/vocalinux/version.py; refusing to publish" >&2
|
||||
packaging/void/build-release.sh check-tag "$TAG" || {
|
||||
echo "::error::tag ${TAG} does not match src/vocalinux/version.py and the committed Void template; refusing to publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag: ${TAG}"
|
||||
echo "version: v${VERSION}"
|
||||
if [ "$TAG" != "v${VERSION}" ]; then
|
||||
echo "::error::tag ${TAG} is not exactly v${VERSION} (src/vocalinux/version.py); refusing to publish the signed repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Fail closed: no key, no repo, no publish. This runs before any
|
||||
# container so a missing secret costs seconds, not a build.
|
||||
# Fail closed: no key, no repository. The key is written with noclobber
|
||||
# (set -C) to a tmpfs file with mode 0600, and never echoed.
|
||||
- name: Require the repository signing key
|
||||
shell: bash
|
||||
env:
|
||||
@@ -88,95 +81,52 @@ jobs:
|
||||
echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2
|
||||
exit 1
|
||||
fi
|
||||
KEY_DIR=$(dirname -- "$XBPS_KEY_FILE")
|
||||
if [ "$(stat -f -c %T "$KEY_DIR")" != "tmpfs" ]; then
|
||||
echo "::error::${KEY_DIR} is not tmpfs; the signing key must never touch disk (packaging/void/README.md)" >&2
|
||||
exit 1
|
||||
fi
|
||||
umask 077
|
||||
rm -f "$XBPS_KEY_FILE"
|
||||
printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE"
|
||||
(set -C; printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE")
|
||||
chmod 600 "$XBPS_KEY_FILE"
|
||||
|
||||
- name: Install the tools repo.sh and the publish step need
|
||||
- name: Check the runner has the release tools
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=""
|
||||
for tool in podman openssl curl python3; do
|
||||
for tool in podman openssl git sha256sum python3.11 uv; do
|
||||
command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
SUDO=""
|
||||
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# shellcheck disable=SC2086
|
||||
$SUDO apt-get install -y --no-install-recommends $missing
|
||||
echo "::error::the bongbetic-ci runner lacks:${missing}; install them on the runner (packaging/void/README.md, Runner requirements)" >&2
|
||||
exit 1
|
||||
fi
|
||||
uv_version=$(uv --version | awk '{print $2}')
|
||||
case "$uv_version" in
|
||||
0.12.* | 0.13.*) ;;
|
||||
*)
|
||||
echo "::error::uv ${uv_version} is outside the pinned 0.12.x / 0.13.x range" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
podman --version
|
||||
|
||||
# repo.sh is the only path that builds or signs anything: pinned Void
|
||||
# glibc image, every template in smoke-gate order, full x86_64 index,
|
||||
# signed packages and metadata. It re-validates the key (RSA4096) before
|
||||
# any container starts.
|
||||
- name: Build and sign the repository
|
||||
# The only path that builds, signs or verifies anything. It stages the
|
||||
# complete set under publish/ and exits 1 before publishing when any check
|
||||
# fails.
|
||||
- name: Build, sign and stage the repository
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
XBPS_SIGNING_KEY_FILE="$XBPS_KEY_FILE" bash packaging/void/repo.sh
|
||||
ls -la publish/xbps/repo publish/xbps/keys
|
||||
|
||||
- name: Verify the signed repository is complete
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT=$PWD
|
||||
cd publish/xbps
|
||||
fail() { echo "::error::$*" >&2; exit 1; }
|
||||
|
||||
# Every class must exist, so a partial repository never attaches.
|
||||
for pat in 'repo/*.x86_64.xbps' 'repo/*.sig2' 'repo/*-repodata*' 'keys/FINGERPRINT' 'keys/*.plist'; do
|
||||
compgen -G "$pat" > /dev/null || fail "missing from the signed repository: $pat"
|
||||
done
|
||||
|
||||
# Every template the smoke gate builds must be in the repository.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$ROOT/packaging/void/smoke.sh")
|
||||
[ -n "$PKGS" ] || fail "could not read the package order from packaging/void/smoke.sh"
|
||||
for name in $PKGS; do
|
||||
compgen -G "repo/${name}-[0-9]*.x86_64.xbps" > /dev/null || fail "package missing from the repository: ${name}"
|
||||
done
|
||||
|
||||
# Glibc x86_64 data only: no musl, no other architecture, no noarch.
|
||||
while IFS= read -r name; do
|
||||
case "$name" in
|
||||
*musl*) fail "musl data in the repository: $name" ;;
|
||||
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
|
||||
*) fail "unexpected file (only glibc x86_64 data is published): repo/$name" ;;
|
||||
esac
|
||||
done < <(find repo -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
|
||||
while IFS= read -r name; do
|
||||
case "$name" in
|
||||
FINGERPRINT | *.plist) ;;
|
||||
*) fail "unexpected file among the client keys: keys/$name" ;;
|
||||
esac
|
||||
done < <(find keys -mindepth 1 -maxdepth 1 -printf '%f\n' | sort)
|
||||
|
||||
# Never publish an unsigned package, or a signature without its package.
|
||||
for pkg in repo/*.x86_64.xbps; do
|
||||
[ -s "${pkg}.sig2" ] || fail "package has no signature: ${pkg}"
|
||||
done
|
||||
for sig in repo/*.sig2; do
|
||||
[ -f "${sig%.sig2}" ] || fail "signature without its package: ${sig}"
|
||||
done
|
||||
|
||||
# The repository must be signed by the key the README tells users to
|
||||
# pin, and the staged client key must be that key.
|
||||
FP=$(sed -n 's/^fingerprint: //p' keys/FINGERPRINT)
|
||||
if [ "$FP" != "$XBPS_KEY_FINGERPRINT" ]; then
|
||||
fail "signing key fingerprint ${FP:-<none>} is not the pinned ${XBPS_KEY_FINGERPRINT}; refusing to publish"
|
||||
fi
|
||||
[ "$(find keys -name '*.plist' | wc -l)" -eq 1 ] || fail "expected exactly one client key plist"
|
||||
[ -s "keys/${FP}.plist" ] || fail "client key plist keys/${FP}.plist is missing"
|
||||
echo "OK: complete signed glibc x86_64 repository, key ${FP}"
|
||||
packaging/void/build-release.sh release "$TAG" --key-file "$XBPS_KEY_FILE" --out-dir publish
|
||||
ls -la publish publish/sdist publish/xbps/repo publish/xbps/keys
|
||||
|
||||
# Releases go through the Gitea REST API. Any non-2xx response fails the
|
||||
# job. Only files staged by repo.sh upload; nothing is built or signed
|
||||
# here. Package files, signatures and the client key go first and the
|
||||
# job. Only what build-release.sh staged under publish/ is uploaded. The
|
||||
# sdist, packages and signatures and the client key go first and the
|
||||
# repodata goes last, so a client never sees an index that lists packages
|
||||
# which are not there yet.
|
||||
- name: Publish to the tag release and the latest release
|
||||
@@ -187,7 +137,7 @@ jobs:
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd publish/xbps
|
||||
cd publish
|
||||
BODY=$(mktemp)
|
||||
trap 'rm -f "$BODY"' EXIT
|
||||
|
||||
@@ -288,14 +238,18 @@ jobs:
|
||||
|| fail "release $1 is missing uploaded assets"
|
||||
}
|
||||
|
||||
# Packages, signatures and the client key first, the repodata last.
|
||||
mapfile -t FILES < <(printf '%s\n' repo/*.x86_64.xbps repo/*.sig2 keys/FINGERPRINT keys/*.plist repo/*-repodata* | awk '!seen[$0]++')
|
||||
# The sdist first, then packages and signatures and the client key,
|
||||
# then the repodata last.
|
||||
mapfile -t FILES < <(printf '%s\n' sdist/*.tar.gz xbps/repo/*.x86_64.xbps xbps/repo/*.sig2 \
|
||||
xbps/keys/FINGERPRINT xbps/keys/*.plist xbps/repo/*-repodata* | awk '!seen[$0]++')
|
||||
NAMES=$(for f in "${FILES[@]}"; do basename "$f"; done)
|
||||
echo "assets to publish: $(printf '%s\n' "$NAMES" | wc -l)"
|
||||
|
||||
NOTES=$(printf '%s\n\n%s\n' \
|
||||
"Signed xbps repository, Void Linux glibc x86_64. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
|
||||
"$(cat keys/FINGERPRINT)")
|
||||
SDIST_SHA256=$(cat SDIST_SHA256)
|
||||
NOTES=$(printf '%s\n\n%s\n%s\n' \
|
||||
"Signed xbps repository, Void Linux glibc x86_64, built from ${TAG} by the release workflow. This is a third-party repository published by this project, not by upstream Void Linux. Registration: README.md." \
|
||||
"$(cat xbps/keys/FINGERPRINT)" \
|
||||
"sdist sha256: ${SDIST_SHA256} (voculinux-*.tar.gz, the source the packages are built from)")
|
||||
|
||||
echo "== tag release ${TAG}"
|
||||
ensure_release "$TAG" "$TAG" "$NOTES"
|
||||
|
||||
+60
-53
@@ -40,19 +40,21 @@ publish succeeds.
|
||||
### Void xbps repository (required for a Void release)
|
||||
|
||||
The signed Void repository is released by `.gitea/workflows/xbps-release.yml`
|
||||
on the fork's Gitea host, not by `release.yml`. Set it up once:
|
||||
on the fork's Gitea host, not by `release.yml`. The whole release is one script,
|
||||
`packaging/void/build-release.sh`, which the workflow and a local dry run both
|
||||
call. Set it up once:
|
||||
|
||||
| what | value |
|
||||
|---|---|
|
||||
| Secret | `XBPS_SIGNING_KEY`: the PEM-encoded RSA 4096 repository private key (Gitea repository settings, Actions, Secrets) |
|
||||
| Public key | `packaging/void/repository-key.plist`, committed, fingerprint `fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25` |
|
||||
| Runner | `bongbetic-ci` with podman (the workflow installs it with apt when absent). Never Docker |
|
||||
| Runner | `bongbetic-ci`, a host job with podman (rootless, with user namespaces), python3.11, uv 0.12/0.13 and tmpfs `/dev/shm`. Requirements: `packaging/void/README.md`, "Runner requirements". Never Docker |
|
||||
| Token | the job token must be allowed to create releases and upload release assets |
|
||||
|
||||
Without the secret the workflow fails and publishes nothing; it never signs
|
||||
with a different key either, because the fingerprint of the key that signed
|
||||
must equal the one above. The private key is never committed; only the public
|
||||
`.plist` is. Key generation and the check that a private key matches the
|
||||
with a different key either: the release path refuses any key whose public half
|
||||
is not `repository-key.plist`. The private key is never committed; only the
|
||||
public `.plist` is. Key generation and the check that a private key matches the
|
||||
committed public key are in `packaging/void/README.md`.
|
||||
|
||||
## Quick Release Checklist
|
||||
@@ -72,7 +74,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`)
|
||||
- [ ] `packaging/void/srcpkgs/voculinux/template` - set `version` (the xbps spelling, the hyphen removed) and `_tag` to this release, in the same release PR as `version.py`. The tag gate refuses a tag whose template names another release. The checksum is recorded after the release (section 3.5)
|
||||
|
||||
### Website
|
||||
- [ ] `web/src/app/page.tsx` - Update `softwareVersion` in schema
|
||||
@@ -329,26 +331,32 @@ Update the supported versions table:
|
||||
| < 0.4 | :x: |
|
||||
```
|
||||
|
||||
#### 3.5 Update Void templates (pull request)
|
||||
#### 3.5 Void template: version in the release PR, checksum after the tag (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). The `voculinux` template
|
||||
builds the fork's release asset `voculinux-X.Y.Z.tar.gz`; its `checksum` is
|
||||
the sha256 of that exact downloaded asset, never a value copied from
|
||||
upstream, and stays `PENDING_FORK_RELEASE_SHA256` (which fails closed in
|
||||
`xbps-src`) until the asset exists. 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.
|
||||
The Void template `packaging/void/srcpkgs/voculinux/template` names one release.
|
||||
It has two parts, handled at two times, and both go through a normal pull request:
|
||||
|
||||
Nothing automates this: the xbps workflow never commits or pushes. The
|
||||
signed repository a tag publishes is built from the templates as committed
|
||||
at that tag, so a bump merged after tag `vX.Y.Z` first ships with the next
|
||||
tag push.
|
||||
- **Version and tag: in the release pull request**, with `version.py`. Set
|
||||
`version` to the xbps spelling of `__version__` (the hyphen removed:
|
||||
`0.19.0-beta` becomes `0.19.0beta`) and `_tag` to the tag (`v0.19.0-beta`).
|
||||
`build-release.sh check-tag` refuses any tag whose template names another
|
||||
release, so a stale template stops the tag, before any build.
|
||||
- **Checksum: after the tag, in a pull request.** The release build replaces
|
||||
`checksum=` in its build tree only, with the sha256 of the sdist it builds, and
|
||||
prints `checksum=<sha256>` in the workflow log and in the release notes
|
||||
(`sdist sha256:`). Copy that value into the template in a pull request through
|
||||
the normal test, lint and type gates. Never commit it directly to `main`, and
|
||||
never hand-edit a release asset.
|
||||
|
||||
The committed template carries a 64-zero placeholder for `checksum=` until that
|
||||
first pull request lands. The placeholder is never a real digest and is never
|
||||
published: every release build replaces it. `tests/test_void_app_template.py`
|
||||
fails if the template's version or `_tag` drifts from `src/vocalinux/version.py`.
|
||||
|
||||
The release build never commits or pushes. Its signed repository is built from
|
||||
the templates as committed at the tag, so a checksum pull request merged after
|
||||
tag `vX.Y.Z` is recorded for the next release, and the next tag builds with it
|
||||
(the build tree always takes the digest of the sdist it builds).
|
||||
|
||||
### Step 4: Update Website
|
||||
|
||||
@@ -541,32 +549,30 @@ Pushing the tag to `origin` (the fork's Gitea repository) also starts
|
||||
`.gitea/workflows/xbps-release.yml`. Pushing a tag is a human act and is
|
||||
approved each time: the workflow has no schedule and no manual trigger.
|
||||
|
||||
1. **Tag equals version.** The tag must be exactly `v` plus `__version__` in
|
||||
`src/vocalinux/version.py`. Any other tag, including one that merely
|
||||
contains the version, exits 1 and publishes nothing.
|
||||
2. **Signing key required.** An empty `XBPS_SIGNING_KEY` exits 1 before any
|
||||
container runs. The key is written to a 0600 temp file, passed to
|
||||
`packaging/void/repo.sh`, and removed by a step that always runs.
|
||||
3. **Build and sign.** `repo.sh` is the only path that builds or signs: the
|
||||
pinned Void glibc container under podman, every template in smoke-gate
|
||||
order, the full `x86_64` index, every package and the metadata signed.
|
||||
4. **Verify.** Packages, signatures, repodata, `FINGERPRINT` and the client
|
||||
key must all be present, nothing but glibc `x86_64` data may be present,
|
||||
every package must be signed, and the signing fingerprint must be the
|
||||
pinned one. A partial or unsigned repository never attaches.
|
||||
5. **Publish.** The complete signed set attaches to the tag's release on
|
||||
Gitea and replaces the assets of the rolling `latest` release, which the
|
||||
client registration URL
|
||||
1. **Tag gate.** `build-release.sh check-tag`: the tag must be exactly `v` plus
|
||||
`__version__`, and the committed template must name that release. Any other
|
||||
tag exits 1 and publishes nothing.
|
||||
2. **Key gate.** The `XBPS_SIGNING_KEY` secret must be non-empty. It is written
|
||||
with noclobber to a `0600` file on tmpfs and removed by a final step that
|
||||
always runs.
|
||||
3. **Runner check.** podman, python3.11, uv 0.12/0.13, openssl, git and curl
|
||||
must be on the runner, or the job names what is missing and stops.
|
||||
4. **Release.** `build-release.sh release` builds the canonical sdist from the
|
||||
tag, builds every template in the pinned Void glibc container
|
||||
without the key, signs the repository in a second container with no network,
|
||||
and installs `voculinux` from that repository in a clean image without the key.
|
||||
A local dry run is `just void-release vX.Y.Z` with `XBPS_SIGNING_KEY_FILE`
|
||||
set: the same script, the same containers, nothing published.
|
||||
5. **Publish.** The staged set (sdist, packages, signatures, `x86_64-repodata`,
|
||||
`FINGERPRINT`, the client `.plist`) attaches to the tag's release on Gitea and
|
||||
replaces the assets of the rolling `latest` release, which the client
|
||||
registration URL
|
||||
(`https://git.bongbetic.com/xavierk/voculinux/releases/download/latest`)
|
||||
serves. Push tags in version order: pushing an older tag refreshes
|
||||
`latest` with the older repository.
|
||||
serves. Push tags in version order: pushing an older tag refreshes `latest`
|
||||
with the older repository.
|
||||
|
||||
The workflow never commits or pushes to any branch and never bumps a
|
||||
template; that is the pull request in section 3.5. The `voculinux` template
|
||||
fetches the fork's `vX.Y.Z` release asset, so that asset must exist and its
|
||||
checksum must be in the template before the build can pass. A tag whose
|
||||
templates still carry the pending checksum fails at the build step, by
|
||||
design.
|
||||
Nothing in this run is built or uploaded by hand, and the workflow never commits
|
||||
or pushes to any branch. The post-release checksum pull request is section 3.5.
|
||||
|
||||
### Step 9: Post-Release Tasks
|
||||
|
||||
@@ -588,11 +594,12 @@ design.
|
||||
matches the line for that wheel in `SHA256SUMS`
|
||||
- [ ] Verify website was deployed (check voculinux.com)
|
||||
- [ ] Verify the Gitea xbps run succeeded, and that both the tag release and
|
||||
`latest` carry the full signed set (`*.x86_64.xbps`, `*.sig2`,
|
||||
`x86_64-repodata`, `FINGERPRINT`, the `.plist`) and that the fingerprint
|
||||
matches the one in `README.md`
|
||||
- [ ] Open the Void template bump pull request (section 3.5); never commit
|
||||
it directly to `main`
|
||||
`latest` carry the full signed set (`sdist/voculinux-*.tar.gz`,
|
||||
`*.x86_64.xbps`, `*.sig2`, `x86_64-repodata`, `FINGERPRINT`, the `.plist`),
|
||||
that the fingerprint matches the one in `README.md`, and that the sdist
|
||||
sha256 in the release notes matches the `checksum=` line the run printed
|
||||
- [ ] Open the Void checksum pull request (section 3.5) with that `checksum=`
|
||||
value; never commit it directly to `main`
|
||||
- [ ] If the Flatpak remote is configured, verify it picked up the tag:
|
||||
`flatpak remote-info vocahq com.voculinux.Voculinux` (docs/FLATPAK_REMOTE.md)
|
||||
- [ ] Announce on social media/communities
|
||||
|
||||
@@ -134,14 +134,16 @@ native-smoke distro="debian:12":
|
||||
void-smoke:
|
||||
bash packaging/void/smoke.sh
|
||||
|
||||
# Build every Void template, index the full repository and sign packages
|
||||
# plus metadata with the RSA4096 repository key, in the pinned Void
|
||||
# container via podman (never Docker), as ticket #29 pipelines. Fails
|
||||
# closed without a key: set XBPS_SIGNING_KEY_FILE (CI writes the
|
||||
# XBPS_SIGNING_KEY secret to a file first). Needs no host Void install;
|
||||
# run `just void-smoke` first — the repo assembles tested packages.
|
||||
void-repo:
|
||||
bash packaging/void/repo.sh
|
||||
# Dry-run or perform the signed Void release for TAG (vX.Y.Z[-phase]) exactly
|
||||
# as the Gitea tag workflow does: packaging/void/build-release.sh release.
|
||||
# Stages the complete set under publish/ and publishes nothing. Needs
|
||||
# XBPS_SIGNING_KEY_FILE (an RSA 4096 private key whose public half is
|
||||
# packaging/void/repository-key.plist) and the release tools in
|
||||
# packaging/void/README.md. Podman only, never Docker.
|
||||
void-release TAG:
|
||||
bash packaging/void/build-release.sh release "{{TAG}}" \
|
||||
--key-file "${XBPS_SIGNING_KEY_FILE:?set XBPS_SIGNING_KEY_FILE to the RSA 4096 key}" \
|
||||
--out-dir publish
|
||||
|
||||
# Build the canonical sdist, prepare an explicitly unattested local PKGBUILD,
|
||||
# then build/install it unchanged on current Arch (needs uv and docker).
|
||||
|
||||
+143
-100
@@ -10,27 +10,30 @@ repository first.
|
||||
```
|
||||
packaging/void/
|
||||
├── README.md this file
|
||||
├── build.sh podman harness: lint + xbps-src build + import smoke
|
||||
├── smoke.sh ticket #28 gate: every template, one command
|
||||
├── repo.sh ticket #29 pipeline: build all, index, sign
|
||||
├── container-repo.sh ticket #29 container half (index + sign + verify)
|
||||
├── build-release.sh the release path: gate, sdist, signed repo, install smoke
|
||||
├── container-release.sh the container half: build, sign, install (one per phase)
|
||||
├── render-voculinux.sh pins version, _tag and checksum in the build tree only
|
||||
├── smoke.sh ticket #28 gate: the release path with a throwaway key
|
||||
├── xbps-fingerprint.py the repository key's fingerprint and client plist
|
||||
├── repository-key.plist the repository's PUBLIC key (never a private key)
|
||||
├── tool_checksums.txt pinned base image and void-packages commit
|
||||
└── srcpkgs/
|
||||
├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding
|
||||
├── ... ticket #25: small Python dependencies
|
||||
├── python3-pynput/ ticket #25: the input library Void does not ship
|
||||
├── python3-PyAudio/ ticket #25: the audio binding, updated past Void's copy
|
||||
└── voculinux ticket #27: the application
|
||||
```
|
||||
|
||||
`srcpkgs/` mirrors void-packages so the tree overlays cleanly:
|
||||
`cp -r packaging/void/srcpkgs/. /path/to/void-packages/srcpkgs/`
|
||||
(`build.sh` does this inside the container.)
|
||||
(the release path does this inside the container).
|
||||
|
||||
## Templates
|
||||
|
||||
| Template | Ticket | Notes |
|
||||
|---|---|---|
|
||||
| `python3-pywhispercpp` | #26 | Vulkan backend on by default; see below |
|
||||
| `python3-pynput`, `python3-PyAudio` | #25 | Dependencies Void does not ship at the needed version |
|
||||
| `voculinux` | #27 | The app, from the canonical release sdist; see below |
|
||||
|
||||
## python3-pywhispercpp
|
||||
@@ -40,20 +43,17 @@ Version `1.5.0`, pinned to the same version `install.sh`
|
||||
The sdist checksum in the template must equal the `sha256` uv.lock records
|
||||
for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise.
|
||||
|
||||
- **Vulkan** is a build option, on by default. Rebuild CPU-only with
|
||||
`build.sh python3-pywhispercpp -- -o ~vulkan pkg`. There is no CUDA
|
||||
build: Void ships no CUDA toolkit package. Rebuilding locally with a
|
||||
hand-installed toolkit (`GGML_CUDA=1`) is a documented, unsupported
|
||||
escape hatch.
|
||||
- **Vulkan** is a build option, on by default. Rebuild CPU-only in a
|
||||
void-packages checkout with the overlay applied: `./xbps-src -o ~vulkan pkg python3-pywhispercpp`.
|
||||
There is no CUDA build: Void ships no CUDA toolkit package. Rebuilding
|
||||
locally with a hand-installed toolkit (`GGML_CUDA=1`) is a documented,
|
||||
unsupported escape hatch.
|
||||
- **Portable CPU flags**: every ggml SIMD option is pinned off
|
||||
(`-DGGML_NATIVE=OFF -DGGML_SSE42=OFF ...`), the plain x86-64 baseline
|
||||
already used by the Flatpak (`#938`, `#957`). The offline test asserts
|
||||
each pin in the template (the build configuration), and the harness
|
||||
scans the shipped `libggml-cpu` (`.GCC.command.line`, `.comment`) for
|
||||
recorded `-march=native`/`-mcpu=native`/`-mtune=native` flags and fails
|
||||
on a match. The recorded-flags scan is a tripwire, not a proof: the
|
||||
proof is the pinned configuration plus `setup.py`'s documented
|
||||
`CMAKE_ARGS` passthrough.
|
||||
already used by the Flatpak (`#938`, `#957`). The install smoke scans the
|
||||
shipped `libggml-cpu` (`.GCC.command.line`) for `-march=native`,
|
||||
`-mcpu=native` and `-mtune=native` and fails on a match. The scan is a
|
||||
tripwire, not a proof: the proof is the pinned configuration.
|
||||
- **Shared libraries**: `BUILD_SHARED_LIBS=ON` with an `$ORIGIN` rpath
|
||||
(as `install.sh` does), so the extension resolves the whisper.cpp
|
||||
libraries installed beside it with no `LD_LIBRARY_PATH`.
|
||||
@@ -61,19 +61,13 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise.
|
||||
ggml sources; the build fetches nothing beyond the declared distfiles.
|
||||
`PYWHISPERCPP_VERSION` is injected because the sdist carries no
|
||||
`version.txt` (its version otherwise comes from git metadata).
|
||||
- **Runtime deps** (`python3-numpy`, `python3-requests`, `python3-tqdm`,
|
||||
`python3-platformdirs`) are the sdist's own `install_requires` and were
|
||||
verified to exist in current void-packages; the container build
|
||||
re-verifies via dependency resolution.
|
||||
|
||||
## voculinux
|
||||
|
||||
Version `0.18.5`, matching `src/vocalinux/version.py` — never newest
|
||||
upstream. The template builds the fork's canonical release source
|
||||
distribution, `voculinux-0.18.5.tar.gz`, attached to the fork's `v0.18.5`
|
||||
release on git.bongbetic.com, with a pinned `checksum` (see
|
||||
[Checksum](#checksum)); `tests/test_void_app_template.py` fails if the
|
||||
version drifts from `version.py`.
|
||||
The template builds the fork's canonical release source distribution. It is
|
||||
built from the release tag, never attached by hand, and never fetched from
|
||||
upstream. Read [Version mapping](#version-mapping) and [Checksum](#checksum)
|
||||
before changing `version`, `_tag` or `checksum`.
|
||||
|
||||
- **Runtime deps** are Void's own interpreter, GTK and desktop bindings,
|
||||
the packaged Python libraries, the three templates from tickets #25
|
||||
@@ -85,7 +79,7 @@ version drifts from `version.py`.
|
||||
container build re-verifies via dependency resolution.
|
||||
- **No payload beyond the app**: no speech models (they download on
|
||||
first run), no vendored wheels, no pip at build or install time. The
|
||||
container smoke fails the build if the payload carries model weights
|
||||
install smoke fails the release if the payload carries model weights
|
||||
or wheels.
|
||||
- **Installed names**: the `voculinux.desktop` entry, the hicolor icons,
|
||||
and a `usr/share/voculinux/packaged` marker the app reads to present
|
||||
@@ -102,71 +96,106 @@ version drifts from `version.py`.
|
||||
page) presents the package-manager path
|
||||
(`src/vocalinux/utils/packaging.py`).
|
||||
|
||||
### Version mapping
|
||||
|
||||
xbps versions cannot contain `-`. The xbps spelling of a release is
|
||||
`__version__` with the hyphen removed. `build-release.sh names TAG` prints it.
|
||||
|
||||
| `__version__` (tag `v` + it) | xbps `version` | distfile and release asset |
|
||||
|---|---|---|
|
||||
| `0.18.5` | `0.18.5` | `voculinux-0.18.5.tar.gz` |
|
||||
| `0.19.0-alpha` | `0.19.0alpha` | `voculinux-0.19.0alpha.tar.gz` |
|
||||
| `0.19.0-beta` | `0.19.0beta` | `voculinux-0.19.0beta.tar.gz` |
|
||||
| `0.19.0-rc1` | `0.19.0rc1` | `voculinux-0.19.0rc1.tar.gz` |
|
||||
|
||||
Only `X.Y.Z` and `X.Y.Z-alpha`, `-beta` or `-rcN` are release forms; anything
|
||||
else is refused. xbps sorts these spellings below the final release
|
||||
(`xbps-uhelper cmpver`, checked in `tests/test_void_release_build.py`), so a
|
||||
pre-release never looks newer than the release it precedes. The sdist keeps its
|
||||
PEP 440 name inside (`voculinux-0.19.0b0/`), which xbps-src's extract hook
|
||||
renames to the package's `wrksrc`, so the two spellings need not match.
|
||||
|
||||
The distfile URL is `releases/download/${_tag}/voculinux-${version}.tar.gz`.
|
||||
`_tag` keeps the hyphen (`v0.19.0-beta`), so the URL names the tag that exists.
|
||||
|
||||
## Checksum
|
||||
|
||||
The template's `checksum=` is `PENDING_FORK_RELEASE_SHA256` until the
|
||||
fork's `v0.18.5` release asset (`voculinux-0.18.5.tar.gz` on
|
||||
git.bongbetic.com/xavierk/voculinux) exists. Once it does, set `checksum=`
|
||||
to the `sha256sum` of that exact downloaded file, never a value copied from
|
||||
upstream, and land the change in a pull request. Until then `xbps-src`
|
||||
fails closed on the pending value, so neither the smoke gate nor the signed
|
||||
repository can build `voculinux`.
|
||||
The template's `checksum=` is a placeholder of 64 zeros in the committed
|
||||
template until the first fork release has a recorded digest. That placeholder is
|
||||
never a real digest. Every release build replaces it, in its **build tree only**,
|
||||
with the sha256 of the sdist it builds (`render-voculinux.sh`). The release run
|
||||
refuses to continue unless exactly one `checksum=` line exists and reads back as
|
||||
the computed digest, and it refuses if the committed template changed.
|
||||
|
||||
After a release, the digest of the published sdist is recorded in the template
|
||||
by a **pull request**: the workflow log and the release notes both print
|
||||
`checksum=<sha256>`, and the pull request copies it into
|
||||
`packaging/void/srcpkgs/voculinux/template`. Nothing commits to `main`.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
packaging/void/build.sh python3-pywhispercpp
|
||||
packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg
|
||||
packaging/void/build.sh voculinux
|
||||
just void-smoke # the gate: build, sign with a throwaway key, install, smoke
|
||||
just void-release v0.18.5 # a dry run of the release, with XBPS_SIGNING_KEY_FILE set
|
||||
packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to
|
||||
packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate
|
||||
```
|
||||
|
||||
Podman only, never Docker. The base image digest and the void-packages
|
||||
commit come from `tool_checksums.txt`; bump them there, never in the
|
||||
script.
|
||||
Podman only, never Docker. The base image digest and the void-packages commit
|
||||
come from `tool_checksums.txt`; bump them there, never in a script.
|
||||
|
||||
## Smoke gate
|
||||
|
||||
One command runs the whole gate from a clean checkout — no host Void
|
||||
install — linting, building, installing and smoke-testing every
|
||||
template under `srcpkgs` in dependency order (the app last):
|
||||
`packaging/void/smoke.sh` runs the release path for the current version with a
|
||||
throwaway RSA 4096 key generated in a temporary directory. The gate needs no
|
||||
host Void install and nothing is published. Stages, in order, each fails the run:
|
||||
|
||||
```sh
|
||||
packaging/void/smoke.sh
|
||||
# or: just void-smoke
|
||||
```
|
||||
1. the tag gate (`check-tag`) and the committed template's version and `_tag`;
|
||||
2. the canonical sdist, built from the working tree;
|
||||
3. `build`: every template in `PKGS` order (`smoke.sh` owns the list), linted
|
||||
with `xlint`, built with `xbps-src`, the sdist resolved by `xbps-src show`;
|
||||
4. `sign`: the packages indexed and signed, with no network;
|
||||
5. `install`: in a clean image with no key, an unsigned install is refused, the
|
||||
signed repository is trusted, `voculinux` installs, `voculinux --version`
|
||||
shows the version, the payload checks pass (desktop entry, icon, marker, no
|
||||
models, no pre-rename names, the Vulkan backend, no host-native CPU tuning),
|
||||
and the imports load (`pynput` through its dummy backend, `evdev`, `gi` with
|
||||
Gtk 3, `pywhispercpp.model`, `pyaudio`, and the app's own modules, which keep their retained internal name).
|
||||
|
||||
Each package delegates to `build.sh`, so the pinned container and the
|
||||
per-package stages (xlint, xbps-src, install, payload/version/import
|
||||
smoke) stay implemented once. The gate fails fast: a broken package
|
||||
prints `FAIL: <pkgname>` naming which package broke, above it the
|
||||
harness echoes which stage broke. `tests/test_void_smoke_gate.py`
|
||||
asserts offline that the gate stays wired to every `srcpkgs` template,
|
||||
so a renamed or added template cannot silently drop out.
|
||||
`tests/test_void_smoke_gate.py` asserts offline that the gate stays wired to
|
||||
every `srcpkgs` template.
|
||||
|
||||
## Signed repository
|
||||
|
||||
`packaging/void/repo.sh` builds every template in smoke-gate order, indexes
|
||||
the full repository (`x86_64-repodata`), and signs every binary package
|
||||
(`*.sig2`) plus the repository metadata with the RSA 4096 repository key —
|
||||
one command, same pinned container via podman, no host Void install:
|
||||
`packaging/void/build-release.sh release TAG --key-file FILE --out-dir DIR`
|
||||
is the whole release, and the only path that signs anything. It is the path the
|
||||
tag workflow runs, and `just void-release` runs it locally.
|
||||
|
||||
```sh
|
||||
# Run the smoke gate first: the repo assembles tested packages.
|
||||
packaging/void/smoke.sh
|
||||
# or: just void-smoke
|
||||
Fail-closed, in this order, before any container starts:
|
||||
|
||||
printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
|
||||
# or: XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem just void-repo
|
||||
```
|
||||
- the tag must be exactly `v` plus `__version__`, and the committed template must
|
||||
name that version and tag;
|
||||
- the key must be non-empty, valid, RSA 4096, and its public half must equal
|
||||
`repository-key.plist` byte for byte (a different key never publishes);
|
||||
- the canonical sdist must build with Python 3.11 and uv 0.12/0.13. Only its
|
||||
wheel is compared byte for byte: a second build of the same tag has identical
|
||||
contents, but the tar and gzip timestamps differ, so the release records the
|
||||
digest of the sdist it publishes, not of a rebuild.
|
||||
|
||||
The pipeline stages `publish/xbps/repo/` (packages, signatures, repodata)
|
||||
plus `publish/xbps/keys/` (the client `.plist` named by key fingerprint,
|
||||
and a `FINGERPRINT` file carrying the signer identity and fingerprint for
|
||||
release notes). `publish/` is local build output and stays uncommitted.
|
||||
Then three containers, each with only what it needs:
|
||||
|
||||
Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any
|
||||
container runs, so an unsigned repository can never publish.
|
||||
| phase | privileges | what runs |
|
||||
|---|---|---|
|
||||
| build | root, network on, **no key** | void-packages at the pinned commit, the build tree rendered from the tag, every template built as the `builder` user |
|
||||
| sign | root, **no network**, key mounted read-only | `xbps-rindex` indexes and signs; no build code runs |
|
||||
| install | root, network on (dependencies), **no key** | an unsigned install is refused; the signed repository installs `voculinux`; the smoke above |
|
||||
|
||||
The committed templates must be unchanged after the build; the run fails if they
|
||||
are not. `DIR` must be absent or empty, and the run writes `DIR/sdist/`,
|
||||
`DIR/xbps/repo/` (packages, signatures, `x86_64-repodata`), `DIR/xbps/keys/`
|
||||
(the client `.plist` named by its fingerprint, and `FINGERPRINT`) and
|
||||
`DIR/SDIST_SHA256`. A final check accepts only that set: nothing from musl, no
|
||||
other architecture, every package signed, the key the pinned one.
|
||||
|
||||
### The repository key
|
||||
|
||||
@@ -180,7 +209,7 @@ fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25
|
||||
(the same file xbps keeps in `/var/db/xbps/keys/`, named by the
|
||||
fingerprint). Signer identity in the file: `Bongbetic <soubarna@live.in>`.
|
||||
`tests/test_void_signed_repo.py` derives the fingerprint from this file
|
||||
and fails if either README, or the release workflow, shows a different one.
|
||||
and fails if either README shows a different one.
|
||||
- **Private half**: never committed, never in the repository, never on a
|
||||
build host's disk beyond the run. It exists as the `XBPS_SIGNING_KEY`
|
||||
CI secret (PEM text, newlines intact) and in the maintainer's own backup.
|
||||
@@ -206,31 +235,46 @@ python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('pac
|
||||
A human pushes a version tag; `.gitea/workflows/xbps-release.yml` does the
|
||||
rest. It never runs on a schedule or a manual dispatch.
|
||||
|
||||
1. The tag must equal `v` plus `__version__` in `src/vocalinux/version.py`,
|
||||
exactly; any other tag exits 1 and publishes nothing.
|
||||
2. The CI secret `XBPS_SIGNING_KEY` must be set; empty exits 1 before any
|
||||
container runs. The key is written to a 0600 temp file, handed to
|
||||
`repo.sh` as `XBPS_SIGNING_KEY_FILE`, and removed by a final step that
|
||||
always runs.
|
||||
3. `repo.sh` builds and signs inside the pinned container (podman, never
|
||||
Docker).
|
||||
4. The output is checked: every class of file present (`*.x86_64.xbps`,
|
||||
`*.sig2`, `*-repodata*`, `FINGERPRINT`, the `.plist`), every package
|
||||
signed, nothing but glibc `x86_64` data, and the signing key's
|
||||
fingerprint equal to the one above.
|
||||
5. Everything attaches to the tag's release and to the rolling `latest`
|
||||
release (replacing that release's old assets), through the Gitea API.
|
||||
`latest` is what the client registration URL serves.
|
||||
1. **Tag gate.** `build-release.sh check-tag` (see above). Nothing else runs first.
|
||||
2. **Key gate.** The CI secret `XBPS_SIGNING_KEY` must be non-empty. The key is
|
||||
written with noclobber to a `0600` file on tmpfs (`/dev/shm`), never to disk.
|
||||
3. **Runner check.** The tools in [Runner requirements](#runner-requirements)
|
||||
must be present, or the job stops and names what is missing.
|
||||
4. **Release.** `build-release.sh release`, with the key file (above).
|
||||
5. **Publish.** The staged set goes to the tag's release and to the rolling
|
||||
`latest` release, which the client registration URL serves. Assets:
|
||||
`sdist/voculinux-<version>.tar.gz`, the packages, their `.sig2` signatures,
|
||||
`x86_64-repodata` (last), `FINGERPRINT` and the `.plist`. Nothing is built or
|
||||
uploaded by hand.
|
||||
6. **Cleanup.** The key file is removed by a step that always runs.
|
||||
|
||||
The workflow never commits or pushes. Bumping the templates to the new
|
||||
release is a normal pull request (`docs/RELEASE_PROCESS.md`).
|
||||
Then the template's checksum is recorded by a pull request (above), and the
|
||||
release notes show the sdist digest to copy from.
|
||||
|
||||
Secret setup, once: Gitea repository settings, Actions, Secrets, add
|
||||
`XBPS_SIGNING_KEY` with the PEM private key. The runner (`bongbetic-ci`)
|
||||
needs podman (the workflow installs it with apt when absent), and the job
|
||||
token must be allowed to create releases.
|
||||
`XBPS_SIGNING_KEY` with the PEM private key. The job token must be allowed to
|
||||
create releases and upload release assets.
|
||||
|
||||
### Client setup
|
||||
### Runner requirements
|
||||
|
||||
The job runs on the `bongbetic-ci` label as a host job. It is not a `container:`
|
||||
job, and it never uses Docker. It needs:
|
||||
|
||||
| requirement | why | check |
|
||||
|---|---|---|
|
||||
| `podman` (rootless with `subuid`/`subgid` ranges, or rootful) | the three phases | `podman run --rm alpine true` |
|
||||
| unprivileged user namespaces | `xbps-src` builds in a user-namespace chroot (`uunshare`) | the build phase fails with a clear error otherwise |
|
||||
| `/dev/shm` mounted as tmpfs | the key must never touch disk | `stat -f -c %T /dev/shm` prints `tmpfs` |
|
||||
| `python3.11` on `PATH` | the sdist is built with Python 3.11, as CI does | `python3.11 --version` |
|
||||
| `uv` 0.12.x or 0.13.x on `PATH` | the locked release-build tools | `uv --version` |
|
||||
| `openssl`, `git`, `sha256sum`, `curl`, `python3` | key checks, checkout, digests, Gitea API | `command -v` |
|
||||
| about 25 GiB free disk | the void-packages bootstrap and the whisper.cpp build | `df -h` |
|
||||
| egress to `github.com`, `repo-default.voidlinux.org`, `ghcr.io`, `pypi.org`, `files.pythonhosted.org`, `git.bongbetic.com` | clone, bootstrap, base image, locked tools, distfiles, Gitea API | the build phase |
|
||||
|
||||
The job's `timeout-minutes` is 240. A cold build takes most of that: the
|
||||
whisper.cpp Vulkan build is the long step.
|
||||
|
||||
## Client setup
|
||||
|
||||
```sh
|
||||
# as root on the Void system
|
||||
@@ -244,7 +288,6 @@ The key file is named by its fingerprint, so xbps finds it on the first
|
||||
sync. If xbps shows a fingerprint prompt anyway, answer `N` unless it
|
||||
reads exactly the fingerprint above and matches the `FINGERPRINT` file on
|
||||
the release; do not add `-y` to the first sync. `tests/test_void_signed_repo.py`
|
||||
asserts offline that the pipeline stays wired to every `srcpkgs` template,
|
||||
stays podman-only on the pinned inputs, and keeps the fail-closed key
|
||||
contract; `tests/test_xbps_release_workflow.py` runs the workflow's gates
|
||||
against fixtures.
|
||||
asserts offline that the registration text stays correct, and
|
||||
`tests/test_void_release_build.py` asserts the release path refuses to
|
||||
publish under any other key.
|
||||
|
||||
Executable
+367
@@ -0,0 +1,367 @@
|
||||
#!/bin/sh
|
||||
# The one Void release path (spec #16 "Release pipeline and upstream intake";
|
||||
# tickets #28 and #29). The Gitea workflow (.gitea/workflows/xbps-release.yml),
|
||||
# a local dry run and the smoke gate (packaging/void/smoke.sh) all call this
|
||||
# script, so a dry run exercises exactly the code a tag runs.
|
||||
#
|
||||
# Usage:
|
||||
# build-release.sh names TAG
|
||||
# print what a tag maps to. Offline; builds nothing.
|
||||
# build-release.sh check-tag TAG
|
||||
# the tag gate. Offline: TAG must be exactly v + __version__ from
|
||||
# src/vocalinux/version.py, and the committed Void template must already
|
||||
# name that version and tag. Exits 1 otherwise.
|
||||
# build-release.sh release TAG --key-file FILE --out-dir DIR
|
||||
# the whole release: gate, canonical sdist, signed repository,
|
||||
# completeness check. Writes DIR/sdist and DIR/xbps. Publishes nothing;
|
||||
# the workflow publishes DIR. DIR must be absent or empty.
|
||||
# build-release.sh smoke
|
||||
# the same pipeline for the current version, signed with a throwaway
|
||||
# RSA4096 key in a temporary directory. Proves the path works; nothing is
|
||||
# signed with the release key and nothing is published.
|
||||
#
|
||||
# Version mapping. xbps versions cannot contain '-', so the xbps spelling of a
|
||||
# release is __version__ with the hyphen removed: 0.18.5 -> 0.18.5,
|
||||
# 0.19.0-beta -> 0.19.0beta, 0.19.0-rc1 -> 0.19.0rc1. xbps sorts the pre-release
|
||||
# forms below the final release (checked with xbps-uhelper cmpver in
|
||||
# tests/test_void_release_build.py). Only X.Y.Z, X.Y.Z-alpha, X.Y.Z-beta and
|
||||
# X.Y.Z-rcN are release forms; anything else is refused.
|
||||
#
|
||||
# The distfile and the release asset are both voculinux-<xbps version>.tar.gz.
|
||||
# The sdist keeps its PEP 440 name inside (voculinux-0.19.0b0/); the xbps-src
|
||||
# do-extract hook renames the sdist's single top-level directory to the
|
||||
# package's wrksrc, so the two spellings need not match.
|
||||
#
|
||||
# Environment: PYTHON (default python3.11) and uv 0.12.x or 0.13.x on PATH, both
|
||||
# for the sdist. The signing key is never echoed; it reaches the signing
|
||||
# container only as a read-only mount.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
TEMPLATE="$HERE/srcpkgs/voculinux/template"
|
||||
PLIST="$HERE/repository-key.plist"
|
||||
PYTHON=${PYTHON:-python3.11}
|
||||
SIGNEDBY="Voculinux <xbps@voculinux.com>"
|
||||
MODE=""
|
||||
WORK=""
|
||||
|
||||
fail() {
|
||||
echo "build-release.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: build-release.sh names TAG
|
||||
build-release.sh check-tag TAG
|
||||
build-release.sh release TAG --key-file FILE --out-dir DIR
|
||||
build-release.sh smoke
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || fail "pins missing from tool_checksums.txt"
|
||||
# The package order is owned by smoke.sh and shared with every release path.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
|
||||
[ -n "$PKGS" ] || fail "could not read the package order from smoke.sh"
|
||||
|
||||
# source_version: __version__ from src/vocalinux/version.py, defined once.
|
||||
source_version() {
|
||||
count=$(sed -n '/^__version__ = /p' "$ROOT/src/vocalinux/version.py" | wc -l)
|
||||
[ "$count" -eq 1 ] || fail "src/vocalinux/version.py must define __version__ exactly once"
|
||||
value=$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "$ROOT/src/vocalinux/version.py")
|
||||
[ -n "$value" ] || fail "could not read __version__ from src/vocalinux/version.py"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
# xbps_version VERSION: the xbps spelling, or nothing when VERSION is not a
|
||||
# release form.
|
||||
xbps_version() {
|
||||
printf '%s\n' "$1" |
|
||||
sed -n -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(-(alpha|beta|rc)[0-9]*)?$/\1\2/p' |
|
||||
tr -d '-'
|
||||
}
|
||||
|
||||
# derive TAG: sets TAG, VERSION, XBPS_VERSION and SDIST_NAME, or fails.
|
||||
derive() {
|
||||
TAG=${1:-}
|
||||
VERSION=$(source_version)
|
||||
[ "$TAG" = "v$VERSION" ] ||
|
||||
fail "tag '$TAG' is not exactly v$VERSION (src/vocalinux/version.py); refusing to publish"
|
||||
XBPS_VERSION=$(xbps_version "$VERSION")
|
||||
[ -n "$XBPS_VERSION" ] ||
|
||||
fail "version $VERSION is not a release form (X.Y.Z, or X.Y.Z-alpha, -beta, -rcN)"
|
||||
SDIST_NAME="voculinux-$XBPS_VERSION.tar.gz"
|
||||
}
|
||||
|
||||
# present PATTERN: true when at least one file matches the glob PATTERN. A plain
|
||||
# POSIX loop: the release runs under /bin/sh, which is dash on Void, and bash-only
|
||||
# builtins are not available there.
|
||||
present() {
|
||||
for match in $1; do
|
||||
[ -e "$match" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# template_value KEY: the value of the single KEY= line of the committed template.
|
||||
template_value() {
|
||||
count=$(grep -c "^$1=" "$TEMPLATE" || true)
|
||||
[ "$count" -eq 1 ] || fail "$TEMPLATE must hold exactly one $1= line (found $count)"
|
||||
sed -n "s/^$1=//p" "$TEMPLATE"
|
||||
}
|
||||
|
||||
# check_template: the committed template must already name this release. The
|
||||
# build tree is pinned from the tag; the committed file is what a reader sees
|
||||
# and what the next release starts from.
|
||||
check_template() {
|
||||
have=$(template_value version)
|
||||
[ "$have" = "$XBPS_VERSION" ] ||
|
||||
fail "committed template version is '$have', not '$XBPS_VERSION' for $TAG; update packaging/void/srcpkgs/voculinux/template in the release pull request"
|
||||
have=$(template_value _tag)
|
||||
[ "$have" = "$TAG" ] ||
|
||||
fail "committed template _tag is '$have', not '$TAG'; update packaging/void/srcpkgs/voculinux/template"
|
||||
}
|
||||
|
||||
# check_key KEY_FILE: RSA 4096, and for a release the public half must be the
|
||||
# committed repository-key.plist key. Sets FINGERPRINT and writes the client
|
||||
# key to $WORK/client.plist.
|
||||
check_key() {
|
||||
KEY_FILE=$1
|
||||
[ -n "$KEY_FILE" ] ||
|
||||
fail "no signing key: pass --key-file (the workflow writes the XBPS_SIGNING_KEY secret to a file first)"
|
||||
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] ||
|
||||
fail "signing key missing or empty; refusing to publish an unsigned repository"
|
||||
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 ||
|
||||
fail "the signing key is not a valid RSA private key; refusing to publish"
|
||||
openssl rsa -in "$KEY_FILE" -noout -text 2>/dev/null | head -n1 | grep -q "4096 bit" ||
|
||||
fail "the signing key is not RSA 4096; refusing to publish"
|
||||
openssl rsa -in "$KEY_FILE" -pubout -out "$WORK/pub.pem" 2>/dev/null ||
|
||||
fail "could not derive the public key from the signing key"
|
||||
FINGERPRINT=$(python3 -I "$HERE/xbps-fingerprint.py" fingerprint "$WORK/pub.pem")
|
||||
if [ "$MODE" = release ]; then
|
||||
python3 -I -c 'import plistlib, sys; sys.stdout.buffer.write(plistlib.load(open(sys.argv[1], "rb"))["public-key"])' \
|
||||
"$PLIST" >"$WORK/plist-pub.pem"
|
||||
cmp -s "$WORK/pub.pem" "$WORK/plist-pub.pem" ||
|
||||
fail "the signing key does not match packaging/void/repository-key.plist; refusing to publish"
|
||||
cp "$PLIST" "$WORK/client.plist"
|
||||
else
|
||||
python3 -I "$HERE/xbps-fingerprint.py" plist "$WORK/pub.pem" "$SIGNEDBY" "$WORK/client.plist"
|
||||
fi
|
||||
echo "signing key: RSA 4096, fingerprint $FINGERPRINT ($MODE)"
|
||||
}
|
||||
|
||||
# build_sdist: the canonical sdist (the reproducible build path; only its wheel is
|
||||
# compared byte for byte), copied under its xbps name.
|
||||
build_sdist() {
|
||||
command -v "$PYTHON" >/dev/null 2>&1 ||
|
||||
fail "$PYTHON not found: the sdist is built with Python 3.11 (set PYTHON)"
|
||||
"$PYTHON" -c 'import sys; sys.exit(0 if sys.version_info[:2] == (3, 11) else 1)' ||
|
||||
fail "$PYTHON is not Python 3.11"
|
||||
command -v uv >/dev/null 2>&1 || fail "uv not found: the release pins uv 0.12.x or 0.13.x"
|
||||
UV_VERSION=$(uv --version | awk '{print $2}')
|
||||
case "$UV_VERSION" in
|
||||
0.12.* | 0.13.*) ;;
|
||||
*) fail "uv $UV_VERSION is outside the pinned 0.12.x / 0.13.x range" ;;
|
||||
esac
|
||||
(cd "$ROOT" && "$PYTHON" scripts/build_reproducible_wheel.py --python "$PYTHON" \
|
||||
--output "$WORK/dist" --diagnostics "$WORK/diagnostics") ||
|
||||
fail "the reproducible build failed; diagnostics are in $WORK/diagnostics"
|
||||
set -- "$WORK"/dist/voculinux-*.tar.gz
|
||||
[ "$#" -eq 1 ] && [ -f "$1" ] || fail "expected exactly one sdist in $WORK/dist"
|
||||
mkdir -p "$WORK/inputs"
|
||||
cp "$1" "$WORK/inputs/$SDIST_NAME"
|
||||
SDIST_SHA256=$(sha256sum "$WORK/inputs/$SDIST_NAME" | cut -d' ' -f1)
|
||||
echo "sdist: $SDIST_NAME sha256 $SDIST_SHA256"
|
||||
}
|
||||
|
||||
# The podman phases. Only the build phase runs build scripts (with network);
|
||||
# only the sign phase sees the key (and has no network); only the install phase
|
||||
# runs the packages (in a clean image, without the key).
|
||||
phase_build() {
|
||||
mkdir -p "$WORK/packages"
|
||||
podman pull -q "$BASE_IMAGE" >/dev/null
|
||||
podman run --rm \
|
||||
-v "$HERE/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-v "$HERE/render-voculinux.sh:/mnt/render-voculinux.sh:ro" \
|
||||
-v "$WORK/inputs:/mnt/inputs:ro" \
|
||||
-v "$WORK/packages:/mnt/packages" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "TAG=$TAG" \
|
||||
-e "XBPS_VERSION=$XBPS_VERSION" \
|
||||
-e "SDIST_NAME=$SDIST_NAME" \
|
||||
-e "SDIST_SHA256=$SDIST_SHA256" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh build
|
||||
}
|
||||
|
||||
phase_sign() {
|
||||
mkdir -p "$OUT/xbps"
|
||||
podman run --rm --network none \
|
||||
-v "$WORK/packages:/mnt/packages:ro" \
|
||||
-v "$OUT/xbps:/mnt/xbps" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
|
||||
-e "SIGNEDBY=$SIGNEDBY" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh sign
|
||||
mkdir -p "$OUT/xbps/keys"
|
||||
cp "$WORK/client.plist" "$OUT/xbps/keys/$FINGERPRINT.plist"
|
||||
printf 'signed-by: %s\nfingerprint: %s\n' "$SIGNEDBY" "$FINGERPRINT" >"$OUT/xbps/keys/FINGERPRINT"
|
||||
}
|
||||
|
||||
phase_install() {
|
||||
podman run --rm \
|
||||
-v "$OUT/xbps:/mnt/xbps:ro" \
|
||||
-v "$WORK/packages:/mnt/packages:ro" \
|
||||
-v "$HERE/container-release.sh:/mnt/container-release.sh:ro" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "XBPS_VERSION=$XBPS_VERSION" \
|
||||
-e "VERSION=$VERSION" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-release.sh install
|
||||
}
|
||||
|
||||
# verify_set: the staged set is complete and holds nothing that may not be
|
||||
# published.
|
||||
verify_set() {
|
||||
cd "$OUT"
|
||||
for pattern in 'sdist/*.tar.gz' 'xbps/repo/*.x86_64.xbps' 'xbps/repo/*.sig2' \
|
||||
'xbps/repo/*-repodata*' 'xbps/keys/FINGERPRINT' 'xbps/keys/*.plist'; do
|
||||
present "$pattern" || fail "missing from the signed set: $pattern"
|
||||
done
|
||||
[ "$(ls sdist | wc -l)" -eq 1 ] && [ -f "sdist/$SDIST_NAME" ] ||
|
||||
fail "the sdist directory must hold exactly sdist/$SDIST_NAME"
|
||||
[ "$(sha256sum "sdist/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
|
||||
fail "the staged sdist does not match its digest"
|
||||
for f in xbps/repo/*; do
|
||||
base=${f##*/}
|
||||
case "$base" in
|
||||
*musl*) fail "musl data in the repository: $base" ;;
|
||||
*.x86_64.xbps | *.x86_64.xbps.sig2 | x86_64-repodata | x86_64-repodata.*) ;;
|
||||
*) fail "unexpected file (only glibc x86_64 data is published): xbps/repo/$base" ;;
|
||||
esac
|
||||
done
|
||||
for f in xbps/keys/*; do
|
||||
base=${f##*/}
|
||||
case "$base" in
|
||||
FINGERPRINT | *.plist) ;;
|
||||
*) fail "unexpected file among the client keys: xbps/keys/$base" ;;
|
||||
esac
|
||||
done
|
||||
for pkg in xbps/repo/*.x86_64.xbps; do
|
||||
[ -s "$pkg.sig2" ] || fail "package has no signature: $pkg"
|
||||
done
|
||||
for sig in xbps/repo/*.sig2; do
|
||||
[ -f "${sig%.sig2}" ] || fail "signature without its package: $sig"
|
||||
done
|
||||
for name in $PKGS; do
|
||||
if [ "$name" = voculinux ]; then
|
||||
[ -f "xbps/repo/voculinux-${XBPS_VERSION}_1.x86_64.xbps" ] ||
|
||||
fail "the repository lacks voculinux-${XBPS_VERSION}_1"
|
||||
else
|
||||
present "xbps/repo/${name}-[0-9]*.x86_64.xbps" ||
|
||||
fail "package missing from the repository: $name"
|
||||
fi
|
||||
done
|
||||
FP=$(sed -n 's/^fingerprint: //p' xbps/keys/FINGERPRINT)
|
||||
[ "$FP" = "$FINGERPRINT" ] || fail "FINGERPRINT says '$FP', the signing key is '$FINGERPRINT'"
|
||||
[ -s "xbps/keys/$FINGERPRINT.plist" ] || fail "client key xbps/keys/$FINGERPRINT.plist is missing"
|
||||
if [ "$MODE" = release ]; then
|
||||
cmp -s "$PLIST" "xbps/keys/$FINGERPRINT.plist" ||
|
||||
fail "the staged client key is not the committed repository-key.plist"
|
||||
fi
|
||||
cd "$ROOT"
|
||||
echo "OK: complete signed glibc x86_64 set for $TAG, key $FINGERPRINT"
|
||||
}
|
||||
|
||||
run_pipeline() {
|
||||
derive "$1"
|
||||
check_template
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/voculinux-release.XXXXXX")
|
||||
cleanup() {
|
||||
status=$?
|
||||
rm -f "$WORK/throwaway.pem"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
rm -rf "$WORK"
|
||||
else
|
||||
echo "build-release.sh: failed; diagnostics retained in $WORK" >&2
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
if [ "$MODE" = smoke ]; then
|
||||
openssl genrsa -out "$WORK/throwaway.pem" 4096 2>/dev/null
|
||||
KEY_FILE="$WORK/throwaway.pem"
|
||||
OUT="$WORK/out"
|
||||
fi
|
||||
if [ -e "$OUT" ]; then
|
||||
[ -d "$OUT" ] && [ -z "$(ls -A "$OUT")" ] ||
|
||||
fail "$OUT exists and is not empty; choose a new output directory"
|
||||
fi
|
||||
check_key "$KEY_FILE"
|
||||
build_sdist
|
||||
phase_build
|
||||
# The committed templates must be untouched: the build tree is the only
|
||||
# place a release pins version, _tag and checksum.
|
||||
if git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
git -C "$ROOT" diff --quiet -- packaging/void/srcpkgs ||
|
||||
fail "the build changed the committed templates"
|
||||
fi
|
||||
mkdir -p "$OUT/sdist"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME"
|
||||
phase_sign
|
||||
phase_install
|
||||
verify_set
|
||||
printf '%s\n' "$SDIST_SHA256" >"$OUT/SDIST_SHA256"
|
||||
echo "template bump for the post-release pull request: checksum=$SDIST_SHA256"
|
||||
}
|
||||
|
||||
cmd=${1:-}
|
||||
[ -n "$cmd" ] || usage
|
||||
shift
|
||||
|
||||
case "$cmd" in
|
||||
names)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
derive "$1"
|
||||
printf 'TAG=%s\nVERSION=%s\nXBPS_VERSION=%s\nSDIST_NAME=%s\n' \
|
||||
"$TAG" "$VERSION" "$XBPS_VERSION" "$SDIST_NAME"
|
||||
;;
|
||||
check-tag)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
derive "$1"
|
||||
check_template
|
||||
echo "tag gate passed: $TAG (xbps $XBPS_VERSION)"
|
||||
;;
|
||||
release)
|
||||
[ "$#" -ge 1 ] || usage
|
||||
TAG=$1
|
||||
shift
|
||||
KEY_FILE=""
|
||||
OUT=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--key-file) KEY_FILE=${2:-}; shift 2 ;;
|
||||
--out-dir) OUT=${2:-}; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$OUT" ] || usage
|
||||
MODE=release
|
||||
run_pipeline "$TAG"
|
||||
echo "RELEASE BUILT: $TAG staged in $OUT (not published by this script)"
|
||||
;;
|
||||
smoke)
|
||||
[ "$#" -eq 0 ] || usage
|
||||
MODE=smoke
|
||||
run_pipeline "v$(source_version)"
|
||||
echo "SMOKE PASSED: $TAG built, signed with a throwaway key, installed and smoked; nothing published"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Host-side harness for packaging/void: runs the container flow (see
|
||||
# container-main.sh and container-build.sh) in the pinned Void glibc
|
||||
# container via podman, never Docker.
|
||||
#
|
||||
# Usage: packaging/void/build.sh <pkgname> [-- <xbps-src args...>]
|
||||
#
|
||||
# packaging/void/build.sh python3-pywhispercpp
|
||||
# packaging/void/build.sh python3-pywhispercpp -- -o ~vulkan pkg
|
||||
#
|
||||
# Anything after -- is passed to `xbps-src` before the package name (use it
|
||||
# for -o ~vulkan CPU-only rebuilds). The package build itself fetches
|
||||
# nothing from the network beyond the template's declared distfiles:
|
||||
# xbps-src verifies distfile checksums before unpacking.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
PKGNAME=${1:?usage: build.sh "<pkgname>" [-- "<xbps-src args>"]}
|
||||
shift
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
XBPS_ARGS=${*:-pkg}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
|
||||
echo "build.sh: pins missing from tool_checksums.txt" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$XBPS_ARGS" in
|
||||
*~vulkan*) EXPECT_VULKAN=0 ;;
|
||||
*) EXPECT_VULKAN=1 ;;
|
||||
esac
|
||||
|
||||
echo "Pulling pinned base image: $BASE_IMAGE"
|
||||
podman pull "$BASE_IMAGE"
|
||||
|
||||
podman run --rm \
|
||||
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-main.sh:/mnt/container-main.sh:ro" \
|
||||
-v "$HERE/container-build.sh:/mnt/container-build.sh:ro" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGNAME=$PKGNAME" \
|
||||
-e "XBPS_ARGS=$XBPS_ARGS" \
|
||||
-e "EXPECT_VULKAN=$EXPECT_VULKAN" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-main.sh
|
||||
echo "OK: $PKGNAME built and imported cleanly"
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Builder-side half of packaging/void/build.sh. Runs inside the pinned Void
|
||||
# container as the unprivileged builder user (xbps-src refuses root).
|
||||
#
|
||||
# Not executed directly: build.sh mounts the repo's srcpkgs next to this
|
||||
# file, copies it into the container, and runs it after cloning
|
||||
# void-packages at the pinned commit. PKGNAME and XBPS_ARGS arrive via the
|
||||
# environment. Keeping this a real file (instead of an inline `sh -c`
|
||||
# string) means normal quoting, heredocs, and shellcheck apply.
|
||||
set -eu
|
||||
|
||||
export HOME=/home/builder
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
cd /void-packages
|
||||
|
||||
echo "== xlint $PKGNAME"
|
||||
xlint "srcpkgs/$PKGNAME/template"
|
||||
|
||||
echo "== show-options $PKGNAME"
|
||||
./xbps-src show-options "$PKGNAME" || true
|
||||
|
||||
echo "== xbps-src $XBPS_ARGS $PKGNAME"
|
||||
# shellcheck disable=SC2086
|
||||
./xbps-src $XBPS_ARGS "$PKGNAME"
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Root-side half of the packaging/void container harness. Runs inside the
|
||||
# pinned Void container as root: system setup, the void-packages checkout,
|
||||
# then the builder half (container-build.sh) as the builder user, then the
|
||||
# install plus import smoke as root again.
|
||||
#
|
||||
# Not executed directly: build.sh mounts this file and runs it. PKGNAME,
|
||||
# XBPS_ARGS, EXPECT_VULKAN and VOID_COMMIT arrive via the environment.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
cp /mnt/container-build.sh /run-build.sh
|
||||
chown builder:builder /void-packages /run-build.sh
|
||||
chmod +x /run-build.sh
|
||||
su -s /bin/sh builder -c /run-build.sh
|
||||
|
||||
echo "== install $PKGNAME from the local repository"
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKGNAME"-*.x86_64.xbps /host/binpkgs/"$PKGNAME"-*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || {
|
||||
echo "built package not found" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "built: $PKGFILE"
|
||||
xbps-install -Sy >/dev/null
|
||||
xbps-install -y --repository="$(dirname "$PKGFILE")" "$PKGNAME" >/dev/null
|
||||
|
||||
case "$PKGNAME" in
|
||||
voculinux)
|
||||
echo "== payload $PKGNAME: desktop entry, icons, packaged marker"
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/applications/voculinux.desktop" || {
|
||||
echo "renamed desktop entry missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" || {
|
||||
echo "renamed icons missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
tar -tf "$PKGFILE" 2>/dev/null | grep -q "usr/share/voculinux/packaged" || {
|
||||
echo "packaged-install marker missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "== payload $PKGNAME: no speech models, no vendored wheels"
|
||||
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then
|
||||
echo "package payload carries models or wheels" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no models or wheels in the shipped package"
|
||||
echo "== payload $PKGNAME: no pre-rename leftovers"
|
||||
if tar -tf "$PKGFILE" 2>/dev/null | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then
|
||||
echo "pre-rename file names in the OS-visible payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no pre-rename leftovers in the shipped package"
|
||||
echo "== version and import smoke $PKGNAME"
|
||||
voculinux --version
|
||||
cat > /voculinux-smoke.py <<'PYEOF'
|
||||
# The importable package keeps its retained internal name (vocalinux); the
|
||||
# distribution metadata is the fork's own name (voculinux).
|
||||
import importlib
|
||||
|
||||
MODULES = (
|
||||
"vocalinux.main",
|
||||
"vocalinux.ui.tray_indicator",
|
||||
"vocalinux.speech_recognition.recognition_manager",
|
||||
"vocalinux.speech_recognition.command_processor",
|
||||
"vocalinux.text_injection.text_injector",
|
||||
"vocalinux.text_injection.ibus_engine",
|
||||
"vocalinux.ui.keyboard_backends",
|
||||
"pywhispercpp.model",
|
||||
)
|
||||
for module in MODULES:
|
||||
importlib.import_module(module)
|
||||
print("import-smoke OK:", ", ".join(MODULES))
|
||||
|
||||
from importlib.metadata import version
|
||||
print("voculinux", version("voculinux"))
|
||||
PYEOF
|
||||
python3 /voculinux-smoke.py
|
||||
;;
|
||||
python3-pywhispercpp)
|
||||
echo "== compiler flag scan of the shipped ggml library"
|
||||
LIBMEMBER=$(tar -tf "$PKGFILE" 2>/dev/null | grep -E "libggml-cpu\.so" | head -n1)
|
||||
[ -n "$LIBMEMBER" ] || {
|
||||
echo "libggml-cpu missing from the built package" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "member: $LIBMEMBER"
|
||||
mkdir -p /flagscan
|
||||
tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER"
|
||||
LIBSCAN="/flagscan/${LIBMEMBER#./}"
|
||||
echo "-- .GCC.command.line:"
|
||||
readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null || echo "(section absent)"
|
||||
echo "-- .comment:"
|
||||
readelf -p .comment "$LIBSCAN" 2>/dev/null || echo "(section absent)"
|
||||
if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then
|
||||
echo "host-native CPU tuning in the shipped library" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "no host-native tuning in the shipped library"
|
||||
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "from pywhispercpp.model import Model; print('model submodule ok')"
|
||||
cat > /smoke.py <<'PYEOF'
|
||||
import glob
|
||||
import os
|
||||
import pywhispercpp
|
||||
pkgdir = os.path.dirname(pywhispercpp.__file__)
|
||||
sitedir = os.path.dirname(pkgdir)
|
||||
libs = sorted(os.path.basename(p) for p in glob.glob(sitedir + "/lib*.so*"))
|
||||
print("shipped libraries:", libs)
|
||||
assert any(n.startswith("libwhisper") for n in libs), "libwhisper missing"
|
||||
exts = [n for n in os.listdir(sitedir) if n.startswith("_pywhispercpp") and n.endswith(".so")]
|
||||
assert exts, "compiled extension missing"
|
||||
print("extension:", exts)
|
||||
has_vulkan = any("vulkan" in n for n in libs)
|
||||
want_vulkan = bool(int(os.environ["EXPECT_VULKAN"]))
|
||||
assert has_vulkan == want_vulkan, "vulkan backend present=%s, expected=%s" % (has_vulkan, want_vulkan)
|
||||
print("smoke ok")
|
||||
PYEOF
|
||||
EXPECT_VULKAN="$EXPECT_VULKAN" python3 /smoke.py
|
||||
;;
|
||||
python3-PyAudio)
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "import pyaudio; print('pyaudio', pyaudio.__version__)"
|
||||
;;
|
||||
python3-pynput)
|
||||
# pynput selects a display backend at import and raises without one;
|
||||
# the container has no display, so presence plus the installed
|
||||
# version (the same proof the native smoke accepts) is the smoke.
|
||||
echo "== import smoke $PKGNAME"
|
||||
python3 -c "import importlib.metadata, importlib.util; assert importlib.util.find_spec('pynput'), 'pynput not importable'; print('pynput', importlib.metadata.version('pynput'))"
|
||||
;;
|
||||
*)
|
||||
echo "no smoke recipe for package '$PKGNAME' — add one alongside the template" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/bin/sh
|
||||
# Container half of packaging/void/build-release.sh (tickets #28 and #29).
|
||||
# Runs inside the pinned Void glibc image, once per phase. Each phase gets only
|
||||
# the privileges it needs:
|
||||
#
|
||||
# build root, network on, NO signing key. Clones void-packages at the
|
||||
# pinned commit, renders the voculinux build tree from the tag, and
|
||||
# builds every template in smoke-gate order as the unprivileged
|
||||
# builder user (xbps-src refuses root). Build scripts run here, so
|
||||
# the key is never mounted in this phase.
|
||||
# sign root, NO network, the RSA4096 key mounted read-only. Runs no
|
||||
# build code: it indexes and signs the packages the build produced.
|
||||
# install root, network on (the official repository supplies dependencies),
|
||||
# NO key. In a clean image, installs voculinux from the staged signed
|
||||
# repository and checks what a user sees: the version, the payload,
|
||||
# the imports, and that the repository is refused until its key is
|
||||
# trusted.
|
||||
#
|
||||
# Not executed directly: build-release.sh mounts this file and runs it with one
|
||||
# phase argument. Inputs arrive as environment variables set there.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
KEY=/run/xbps-sign-key.pem
|
||||
PKG_STAGE=/mnt/packages
|
||||
XBPS_STAGE=/mnt/xbps
|
||||
OFFICIAL=https://repo-default.voidlinux.org/current
|
||||
|
||||
fail() {
|
||||
echo "container-release.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
phase_build() {
|
||||
: "${VOID_COMMIT:?}" "${PKGS:?}" "${XBPS_VERSION:?}" "${SDIST_NAME:?}"
|
||||
export VOID_COMMIT PKGS XBPS_VERSION SDIST_NAME
|
||||
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
|
||||
# Build tree only: the committed template in the repository is never changed.
|
||||
for PKG in $PKGS; do
|
||||
if [ "$PKG" = voculinux ]; then
|
||||
VOID_PACKAGES=/void-packages SDIST_DIR=/mnt/inputs sh /mnt/render-voculinux.sh
|
||||
fi
|
||||
done
|
||||
|
||||
chown -R builder:builder /void-packages
|
||||
su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src binary-bootstrap'
|
||||
for PKG in $PKGS; do
|
||||
echo "== xlint $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
|
||||
echo "== xbps-src pkg $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
|
||||
done
|
||||
|
||||
# The distfile the template names must be the sdist staged for it.
|
||||
su -s /bin/sh builder -c 'cd /void-packages && ./xbps-src show voculinux' >/tmp/show.txt 2>&1 ||
|
||||
fail "xbps-src show voculinux failed"
|
||||
grep -qF "/$SDIST_NAME" /tmp/show.txt || fail "xbps-src does not resolve distfile $SDIST_NAME"
|
||||
|
||||
mkdir -p "$PKG_STAGE"
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-[0-9]*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || fail "built package missing from hostdir/binpkgs: $PKG"
|
||||
echo "collected: $(basename "$PKGFILE")"
|
||||
cp "$PKGFILE" "$PKG_STAGE/"
|
||||
done
|
||||
}
|
||||
|
||||
phase_sign() {
|
||||
: "${SIGNEDBY:?}"
|
||||
[ -r "$KEY" ] && [ -s "$KEY" ] || fail "signing key is not mounted at $KEY"
|
||||
mkdir -p "$XBPS_STAGE/repo"
|
||||
cp "$PKG_STAGE"/*.x86_64.xbps "$XBPS_STAGE/repo/"
|
||||
|
||||
echo "== index the full repository"
|
||||
xbps-rindex -a "$XBPS_STAGE"/repo/*.x86_64.xbps
|
||||
echo "== sign every binary package"
|
||||
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE"/repo/*.x86_64.xbps
|
||||
echo "== sign the repository metadata"
|
||||
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$XBPS_STAGE/repo"
|
||||
echo "signed repository staged: $(ls "$XBPS_STAGE/repo" | wc -l) files"
|
||||
}
|
||||
|
||||
# The payload checks the package must pass before anyone installs it.
|
||||
payload_voculinux() {
|
||||
PKGFILE=$1
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/applications/voculinux.desktop" ||
|
||||
fail "renamed desktop entry missing from the package"
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/icons/hicolor/scalable/apps/voculinux.svg" ||
|
||||
fail "renamed icon missing from the package"
|
||||
tar -tf "$PKGFILE" | grep -q "usr/share/voculinux/packaged" ||
|
||||
fail "packaged-install marker missing from the package"
|
||||
if tar -tf "$PKGFILE" | grep -E "models/|\.ggml$|\.bin$|\.pt$|\.whl$"; then
|
||||
fail "package payload carries models or wheels"
|
||||
fi
|
||||
if tar -tf "$PKGFILE" | grep -E "usr/share/applications/vocalinux|usr/share/icons/.*/vocalinux"; then
|
||||
fail "pre-rename file names in the OS-visible payload"
|
||||
fi
|
||||
echo "payload ok: desktop entry, icon and marker present; no models, wheels or pre-rename names"
|
||||
}
|
||||
|
||||
# whisper.cpp must ship its Vulkan backend and no host-native CPU tuning.
|
||||
payload_pywhispercpp() {
|
||||
PKGFILE=$1
|
||||
tar -tf "$PKGFILE" | grep -q "libggml-vulkan" || fail "the Vulkan backend is missing from $PKGFILE"
|
||||
LIBMEMBER=$(tar -tf "$PKGFILE" | grep -E "libggml-cpu\.so" | head -n1)
|
||||
[ -n "$LIBMEMBER" ] || fail "libggml-cpu is missing from $PKGFILE"
|
||||
mkdir -p /flagscan
|
||||
tar -xf "$PKGFILE" -C /flagscan "$LIBMEMBER"
|
||||
LIBSCAN="/flagscan/${LIBMEMBER#./}"
|
||||
if readelf -p .GCC.command.line "$LIBSCAN" 2>/dev/null | grep -E "march=native|mcpu=native|mtune=native"; then
|
||||
fail "host-native CPU tuning in the shipped libggml-cpu"
|
||||
fi
|
||||
echo "payload ok: Vulkan backend present; no host-native tuning in libggml-cpu"
|
||||
}
|
||||
|
||||
phase_install() {
|
||||
: "${PKGS:?}" "${XBPS_VERSION:?}" "${VERSION:?}"
|
||||
[ -d "$XBPS_STAGE/repo" ] && [ -d "$XBPS_STAGE/keys" ] || fail "staged repository or keys are missing"
|
||||
xbps-install -Sy xbps binutils zstd >/dev/null
|
||||
|
||||
echo "== the signed repository is refused while its key is not trusted"
|
||||
# No -y: the documented first sync must not accept an unknown key.
|
||||
set +e
|
||||
REFUSAL=$(xbps-install -S --repository="$XBPS_STAGE/repo" voculinux </dev/null 2>&1)
|
||||
REFUSAL_RC=$?
|
||||
set -e
|
||||
printf '%s\n' "$REFUSAL" | grep -i "fingerprint" || true
|
||||
[ "$REFUSAL_RC" -ne 0 ] || fail "the repository installed without its key being trusted"
|
||||
xbps-query voculinux >/dev/null 2>&1 && fail "voculinux was installed by the untrusted attempt"
|
||||
echo "refused without the key, as required (rc=$REFUSAL_RC)"
|
||||
|
||||
echo "== trust the staged key, then install voculinux from the signed repository"
|
||||
cp "$XBPS_STAGE"/keys/*.plist /var/db/xbps/keys/
|
||||
xbps-install -y --repository="$XBPS_STAGE/repo" --repository="$OFFICIAL" -S voculinux >/dev/null
|
||||
xbps-query voculinux | grep -qx "pkgver: voculinux-${XBPS_VERSION}_1" ||
|
||||
fail "installed package is not voculinux-${XBPS_VERSION}_1"
|
||||
echo "installed: voculinux-${XBPS_VERSION}_1"
|
||||
|
||||
echo "== the renamed binary reports its version"
|
||||
VERSION_OUT=$(voculinux --version 2>&1) || fail "voculinux --version exited non-zero"
|
||||
printf '%s\n' "$VERSION_OUT"
|
||||
printf '%s' "$VERSION_OUT" | grep -qF "$VERSION" || fail "voculinux --version does not show $VERSION"
|
||||
|
||||
echo "== payload of the built packages; every package needs a smoke recipe"
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls "$PKG_STAGE"/"$PKG"-[0-9]*.x86_64.xbps | head -n1)
|
||||
case "$PKG" in
|
||||
voculinux) payload_voculinux "$PKGFILE" ;;
|
||||
python3-pywhispercpp) payload_pywhispercpp "$PKGFILE" ;;
|
||||
python3-pynput | python3-PyAudio) ;; # checked by import below
|
||||
*) fail "no smoke recipe for package $PKG; add one alongside its template" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "== imports, without a display or hardware"
|
||||
# pynput: the dummy backend proves the package and its loader. The X backend
|
||||
# needs an X server and is covered by the manual hardware checklist.
|
||||
PYNPUT_BACKEND=dummy python3 -c "import pynput.keyboard, pynput.mouse; print('pynput dummy backend ok')"
|
||||
python3 -c "import evdev; print('evdev ok')"
|
||||
python3 -c "import gi; gi.require_version('Gtk', '3.0'); from gi.repository import Gtk; print('gi ok: Gtk', Gtk.get_major_version(), Gtk.get_minor_version())"
|
||||
python3 -c "from pywhispercpp.model import Model; print('pywhispercpp.model ok')"
|
||||
python3 -c "import pyaudio; print('pyaudio ok')"
|
||||
python3 -c "import vocalinux.main, vocalinux.speech_recognition.recognition_manager; print('vocalinux imports ok')"
|
||||
echo "INSTALL SMOKE OK: voculinux $VERSION installed from the signed repository"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
build) phase_build ;;
|
||||
sign) phase_sign ;;
|
||||
install) phase_install ;;
|
||||
*) fail "usage: container-release.sh build|sign|install" ;;
|
||||
esac
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the
|
||||
# pinned Void container as root: system setup, the void-packages checkout
|
||||
# at the pinned commit, then every template built in smoke-gate order as
|
||||
# the unprivileged builder user (xbps-src refuses root), then index, sign,
|
||||
# verify and stage as root again.
|
||||
#
|
||||
# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT,
|
||||
# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is
|
||||
# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before
|
||||
# any container ran).
|
||||
#
|
||||
# Keeping this a real file (instead of an inline `sh -c` string) means
|
||||
# normal quoting, heredocs, and shellcheck apply.
|
||||
set -eu
|
||||
|
||||
export PATH=/usr/bin:/bin:/usr/sbin:/sbin
|
||||
KEY=/run/xbps-sign-key.pem
|
||||
OUT=/mnt/out
|
||||
REPO="$OUT/repo"
|
||||
KEYS="$OUT/keys"
|
||||
|
||||
[ -r "$KEY" ] || {
|
||||
echo "container-repo.sh: signing key not mounted at $KEY" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
xbps-install -Suy xbps >/dev/null
|
||||
xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null
|
||||
git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages
|
||||
git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT"
|
||||
git -C /void-packages checkout --quiet "$VOID_COMMIT"
|
||||
cp -r /overlay/. /void-packages/srcpkgs/
|
||||
id builder >/dev/null 2>&1 || useradd -m builder
|
||||
chown builder:builder /void-packages
|
||||
|
||||
echo "== build every template in order: $PKGS"
|
||||
cd /void-packages
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
echo "== xlint $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'"
|
||||
echo "== xbps-src pkg $PKG"
|
||||
su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'"
|
||||
done
|
||||
|
||||
echo "== collect built packages"
|
||||
mkdir -p "$REPO" "$KEYS"
|
||||
# shellcheck disable=SC2086
|
||||
for PKG in $PKGS; do
|
||||
PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1)
|
||||
[ -n "$PKGFILE" ] || {
|
||||
echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "collected: $(basename "$PKGFILE")"
|
||||
cp "$PKGFILE" "$REPO/"
|
||||
done
|
||||
|
||||
echo "== index the full repository"
|
||||
# shellcheck disable=SC2086
|
||||
xbps-rindex -a "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign every binary package"
|
||||
xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps
|
||||
|
||||
echo "== sign the repository metadata"
|
||||
xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"
|
||||
|
||||
echo "== prove an unsigned install is refused"
|
||||
set +e
|
||||
PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux </dev/null 2>&1)
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then
|
||||
echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2
|
||||
printf '%s\n' "$PROBE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "unsigned install refused as required"
|
||||
|
||||
FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1)
|
||||
[ -n "$FP" ] || {
|
||||
echo "FAIL: could not read the key fingerprint from the refusal" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "key fingerprint: $FP"
|
||||
|
||||
echo "== stage the client key"
|
||||
openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null
|
||||
PUB64=$(base64 -w0 /tmp/repo-pub.pem)
|
||||
# XML-escape the signer identity: xbps writes </> itself on import
|
||||
# and rejects a plist carrying raw angle brackets.
|
||||
ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g')
|
||||
{
|
||||
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
|
||||
printf '%s\n' '<plist version="1.0">' '<dict>' ' <key>public-key</key>'
|
||||
printf ' <data>%s</data>\n' "$PUB64"
|
||||
printf '%s\n' ' <key>public-key-size</key>' ' <integer>4096</integer>' ' <key>signature-by</key>'
|
||||
printf ' <string>%s</string>\n' "$ESCAPED_SIGNEDBY"
|
||||
printf '%s\n' '</dict>' '</plist>'
|
||||
} > "$KEYS/$FP.plist"
|
||||
{
|
||||
printf 'signed-by: %s\n' "$SIGNEDBY"
|
||||
printf 'fingerprint: %s\n' "$FP"
|
||||
} > "$KEYS/FINGERPRINT"
|
||||
echo "staged: keys/$FP.plist"
|
||||
|
||||
echo "== verify: install the app from the signed repo in a clean rootdir"
|
||||
VERIFY=/tmp/verify-install
|
||||
mkdir -p "$VERIFY/var/db/xbps/keys"
|
||||
# The app's full dependency tree resolves from the official remote, so the
|
||||
# verify rootdir trusts the official keys too; our repo stays enforced
|
||||
# because its packages verify only against the staged key below.
|
||||
cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/"
|
||||
cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/"
|
||||
xbps-install -y -r "$VERIFY" \
|
||||
--repository="$REPO" \
|
||||
--repository=https://repo-default.voidlinux.org/current \
|
||||
-Sy voculinux
|
||||
xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || {
|
||||
echo "FAIL: voculinux not installed in the verify rootdir" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "verified: voculinux installs from the signed repository"
|
||||
|
||||
ls "$REPO"
|
||||
echo "SIGNED REPO OK: fingerprint $FP"
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
# Renders the voculinux template for one release inside a void-packages build
|
||||
# tree (ticket #27, spec #16 "Release pipeline and upstream intake").
|
||||
#
|
||||
# The committed template is never edited by a build. This script runs against
|
||||
# the container's copy of srcpkgs and does three things, each refused unless it
|
||||
# lands exactly once:
|
||||
#
|
||||
# 1. pins version, _tag and checksum to the release being built;
|
||||
# 2. checks the sdist's digest is the checksum it just pinned;
|
||||
# 3. places the sdist where xbps-src looks for a distfile of this package
|
||||
# (hostdir/sources/<pkgname>-<version>/<basename>), so xbps-src verifies
|
||||
# the local file and never downloads it.
|
||||
#
|
||||
# Run as root, by container-release.sh (release) and container-main.sh (smoke).
|
||||
# Inputs arrive as environment variables from build-release.sh:
|
||||
# VOID_PACKAGES void-packages checkout (default /void-packages)
|
||||
# SDIST_DIR directory holding the sdist (read only)
|
||||
# XBPS_VERSION xbps spelling of the release, e.g. 0.18.5 or 0.19.0beta
|
||||
# TAG the release tag, e.g. v0.19.0-beta
|
||||
# SDIST_NAME distfile basename, voculinux-<XBPS_VERSION>.tar.gz
|
||||
# SDIST_SHA256 sha256 of $SDIST_DIR/$SDIST_NAME
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
echo "render-voculinux.sh: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
VP=${VOID_PACKAGES:-/void-packages}
|
||||
TEMPLATE="$VP/srcpkgs/voculinux/template"
|
||||
|
||||
for name in SDIST_DIR XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
|
||||
eval "value=\${$name:-}"
|
||||
[ -n "$value" ] || fail "$name is not set"
|
||||
done
|
||||
for name in XBPS_VERSION TAG SDIST_NAME SDIST_SHA256; do
|
||||
eval "value=\${$name}"
|
||||
case "$value" in
|
||||
*[!A-Za-z0-9._-]*) fail "$name has characters outside [A-Za-z0-9._-]: $value" ;;
|
||||
esac
|
||||
done
|
||||
[ -f "$TEMPLATE" ] || fail "no voculinux template at $TEMPLATE"
|
||||
[ -s "$SDIST_DIR/$SDIST_NAME" ] || fail "sdist is missing: $SDIST_DIR/$SDIST_NAME"
|
||||
|
||||
digest=$(sha256sum "$SDIST_DIR/$SDIST_NAME" | cut -d' ' -f1)
|
||||
[ "$digest" = "$SDIST_SHA256" ] || fail "sdist digest $digest is not the expected $SDIST_SHA256"
|
||||
|
||||
# set_line KEY VALUE: the template must hold exactly one KEY= line, and the
|
||||
# rewritten line must read back exactly as asked.
|
||||
set_line() {
|
||||
key=$1
|
||||
value=$2
|
||||
count=$(grep -c "^$key=" "$TEMPLATE" || true)
|
||||
[ "$count" = 1 ] || fail "template must hold exactly one $key= line, found $count"
|
||||
sed -i "s|^$key=.*|$key=$value|" "$TEMPLATE"
|
||||
grep -qxF "$key=$value" "$TEMPLATE" || fail "could not pin $key=$value in the build tree"
|
||||
}
|
||||
|
||||
set_line version "$XBPS_VERSION"
|
||||
set_line _tag "$TAG"
|
||||
set_line checksum "$SDIST_SHA256"
|
||||
|
||||
DEST="$VP/hostdir/sources/voculinux-$XBPS_VERSION"
|
||||
mkdir -p "$DEST"
|
||||
cp "$SDIST_DIR/$SDIST_NAME" "$DEST/$SDIST_NAME"
|
||||
[ "$(sha256sum "$DEST/$SDIST_NAME" | cut -d' ' -f1)" = "$SDIST_SHA256" ] ||
|
||||
fail "the copied distfile does not match $SDIST_SHA256"
|
||||
|
||||
echo "render-voculinux.sh: pinned version=$XBPS_VERSION _tag=$TAG checksum=$SDIST_SHA256"
|
||||
echo "render-voculinux.sh: distfile staged at $DEST/$SDIST_NAME"
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Signed xbps repository pipeline (ticket #29): build every template in
|
||||
# smoke-gate order, index the full repository, and sign every binary
|
||||
# package plus the repository metadata with the RSA4096 repository key.
|
||||
# Runs in the pinned Void container via podman, never Docker; no host
|
||||
# Void install needed.
|
||||
#
|
||||
# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh
|
||||
# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo
|
||||
#
|
||||
# Options (all optional):
|
||||
# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE)
|
||||
# --out-dir DIR stage the signed repo here (default: publish/xbps)
|
||||
# --signed-by "N <m>" signer identity (default: Voculinux <xbps@voculinux.com>)
|
||||
#
|
||||
# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY
|
||||
# secret to disk with newlines intact:
|
||||
#
|
||||
# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem
|
||||
# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh
|
||||
#
|
||||
# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before
|
||||
# any container runs, so an unsigned repository can never publish.
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
REPO_ROOT=$(cd -- "$HERE/../.." && pwd)
|
||||
|
||||
KEY_FILE=${XBPS_SIGNING_KEY_FILE:-}
|
||||
OUT_DIR="$REPO_ROOT/publish/xbps"
|
||||
SIGNEDBY="Voculinux <xbps@voculinux.com>"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;;
|
||||
--out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;;
|
||||
--signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name <mail>"} ; shift 2 ;;
|
||||
-*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;;
|
||||
*) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Fail closed before any container runs: no key, no repo, no publish.
|
||||
[ -n "$KEY_FILE" ] || {
|
||||
echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2
|
||||
echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2
|
||||
echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || {
|
||||
echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || {
|
||||
echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2
|
||||
echo "repo.sh: refusing to publish an unsigned repository" >&2
|
||||
exit 1
|
||||
}
|
||||
openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || {
|
||||
echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2
|
||||
echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt")
|
||||
VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt")
|
||||
[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || {
|
||||
echo "repo.sh: pins missing from tool_checksums.txt" >&2
|
||||
exit 1
|
||||
}
|
||||
# One dependency order for the whole track: the signed repo builds the
|
||||
# same set in the same order as the smoke gate (the app last), so a
|
||||
# renamed or added template cannot silently drop out of either.
|
||||
PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh")
|
||||
[ -n "$PKGS" ] || {
|
||||
echo "repo.sh: could not read the package order from smoke.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
echo "Pulling pinned base image: $BASE_IMAGE"
|
||||
podman pull "$BASE_IMAGE"
|
||||
|
||||
podman run --rm \
|
||||
-v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \
|
||||
-v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \
|
||||
-v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \
|
||||
-v "$OUT_DIR:/mnt/out" \
|
||||
-e "VOID_COMMIT=$VOID_COMMIT" \
|
||||
-e "PKGS=$PKGS" \
|
||||
-e "SIGNEDBY=$SIGNEDBY" \
|
||||
"$BASE_IMAGE" /bin/sh /mnt/container-repo.sh
|
||||
echo "OK: signed repository staged in $OUT_DIR"
|
||||
+12
-22
@@ -1,35 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Smoke gate for all Void templates (ticket #28): lint, build, install
|
||||
# and version/import smoke for every template, one command, from a clean
|
||||
# checkout. Runs under podman, never Docker; no host Void install needed.
|
||||
# Smoke gate for all Void templates (ticket #28): one command builds every
|
||||
# template in dependency order, signs the result with a throwaway key, installs
|
||||
# voculinux from that repository in a clean Void image and smokes it. The gate
|
||||
# runs the same code path as a release (packaging/void/build-release.sh), so a
|
||||
# gate that passes is a release that would build.
|
||||
#
|
||||
# Usage: packaging/void/smoke.sh
|
||||
# or: just void-smoke
|
||||
#
|
||||
# Builds every template under packaging/void/srcpkgs in dependency order
|
||||
# (leaves first, the app last — it resolves the others from the local
|
||||
# repository at install time) by delegating each package to build.sh,
|
||||
# which runs xlint, xbps-src, install and the payload/version/import
|
||||
# smoke in the pinned Void container. Fails fast naming the package; the
|
||||
# harness echoes each stage, so the output shows which package broke at
|
||||
# which stage.
|
||||
# Podman only, never Docker. Nothing is published, and the release key is not
|
||||
# used. Needs the same tools as a release: podman, openssl, git, python3.11,
|
||||
# and uv 0.12.x or 0.13.x (see packaging/void/README.md).
|
||||
set -eu
|
||||
|
||||
CDPATH=""
|
||||
HERE=$(dirname -- "$0")
|
||||
HERE=$(cd -- "$HERE" && pwd)
|
||||
|
||||
# Dependency order: the app last. tests/test_void_smoke_gate.py fails if
|
||||
# this list drifts from packaging/void/srcpkgs or the app moves earlier.
|
||||
# Dependency order: the app last. tests/test_void_smoke_gate.py fails if this
|
||||
# list drifts from packaging/void/srcpkgs.
|
||||
# shellcheck disable=SC2034 # read as data by build-release.sh, which parses this line
|
||||
PKGS="python3-PyAudio python3-pynput python3-pywhispercpp voculinux"
|
||||
|
||||
for PKG in $PKGS; do
|
||||
echo "== smoke $PKG"
|
||||
"$HERE/build.sh" "$PKG" || {
|
||||
echo "FAIL: $PKG (see the stage above: xlint, xbps-src, install, payload/version/import smoke)" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "OK: $PKG linted, built, installed and smoked"
|
||||
done
|
||||
|
||||
echo "SMOKE GATE PASSED: $PKGS"
|
||||
exec "$HERE/build-release.sh" smoke
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
pkgname=voculinux
|
||||
version=0.18.5
|
||||
revision=1
|
||||
# The release tag this template names: v + __version__, with the pre-release
|
||||
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
|
||||
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
|
||||
_tag=v0.18.5
|
||||
# The release sdist ships no setup.py (setuptools pyproject-only), so the
|
||||
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
|
||||
build_style=python3-pep517
|
||||
@@ -16,12 +20,12 @@ short_desc="Free, offline voice dictation for Linux"
|
||||
maintainer="Xavier Karma <xavier@bongbetic.com>"
|
||||
license="AGPL-3.0-only"
|
||||
homepage="https://git.bongbetic.com/xavierk/voculinux"
|
||||
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/v${version}/voculinux-${version}.tar.gz"
|
||||
# Pending: set to the sha256 of voculinux-${version}.tar.gz once the fork's
|
||||
# v${version} release exists (see packaging/void/README.md, "Checksum").
|
||||
# Until then xbps-src fails closed on this value; it never falls back to the
|
||||
# upstream archive.
|
||||
checksum=PENDING_FORK_RELEASE_SHA256
|
||||
distfiles="https://git.bongbetic.com/xavierk/voculinux/releases/download/${_tag}/voculinux-${version}.tar.gz"
|
||||
# sha256 of that release sdist. Recorded here by the post-release pull request
|
||||
# after each release. Until the first fork release it is a placeholder that is
|
||||
# never a real digest: every release build replaces it, in its build tree only,
|
||||
# with the digest of the sdist it builds (packaging/void/render-voculinux.sh).
|
||||
checksum=0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
post_install() {
|
||||
# The fork's sdist already names the desktop entry, icons and launchers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Pinned build inputs for packaging/void/build.sh.
|
||||
# Pinned build inputs for packaging/void/build-release.sh (and smoke.sh, which calls it).
|
||||
#
|
||||
# Same contract as packaging/appimage/tool_checksums.txt and
|
||||
# packaging/native/tool_checksums.txt: everything the package build
|
||||
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""xbps repository-key helper for packaging/void/build-release.sh.
|
||||
|
||||
xbps-fingerprint.py fingerprint PEM
|
||||
print the xbps fingerprint of an RSA public key (PEM): the MD5 of the
|
||||
ssh-rsa blob of the key, the identifier libxbps calls xbps_pubkey2fp.
|
||||
|
||||
xbps-fingerprint.py plist PEM SIGNER OUT
|
||||
write the client key plist xbps expects for that public key. Used only
|
||||
for a throwaway smoke key; the release publishes the committed
|
||||
repository-key.plist byte for byte.
|
||||
|
||||
Only the public key is ever read here. Run with -I: the interpreter must not
|
||||
pick up modules from the directory a downloaded file sits in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _rsa_numbers(pem: Path) -> tuple[int, int]:
|
||||
"""Modulus and exponent of a PEM public key, read through openssl."""
|
||||
text = subprocess.run(
|
||||
["openssl", "rsa", "-pubin", "-in", str(pem), "-noout", "-text"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
modulus_hex: list[str] = []
|
||||
exponent = None
|
||||
in_modulus = False
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("Modulus"):
|
||||
in_modulus = True
|
||||
continue
|
||||
if stripped.startswith("Exponent"):
|
||||
in_modulus = False
|
||||
exponent = int(stripped.split()[1])
|
||||
continue
|
||||
if in_modulus and re.fullmatch(r"[0-9a-f:]+", stripped):
|
||||
modulus_hex.append(stripped.replace(":", ""))
|
||||
if exponent is None or not modulus_hex:
|
||||
raise SystemExit(f"xbps-fingerprint.py: not an RSA public key: {pem}")
|
||||
return int("".join(modulus_hex), 16), exponent
|
||||
|
||||
|
||||
def _mpint(value: int) -> bytes:
|
||||
"""An SSH mpint: big-endian, with a leading zero when the top bit is set."""
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
if raw[0] & 0x80:
|
||||
raw = b"\x00" + raw
|
||||
return struct.pack(">I", len(raw)) + raw
|
||||
|
||||
|
||||
def fingerprint(pem: Path) -> str:
|
||||
"""Colon-separated lowercase MD5 of the ssh-rsa blob of the key."""
|
||||
modulus, exponent = _rsa_numbers(pem)
|
||||
blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
|
||||
digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
|
||||
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
|
||||
|
||||
|
||||
def write_plist(pem: Path, signer: str, out: Path) -> None:
|
||||
"""Write the client key plist xbps reads from /var/db/xbps/keys."""
|
||||
modulus, _ = _rsa_numbers(pem)
|
||||
entry = {
|
||||
"public-key": pem.read_bytes(),
|
||||
"public-key-size": modulus.bit_length(),
|
||||
"signature-by": signer,
|
||||
}
|
||||
with out.open("wb") as handle:
|
||||
plistlib.dump(entry, handle, fmt=plistlib.FMT_XML)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) == 2 and argv[0] == "fingerprint":
|
||||
print(fingerprint(Path(argv[1])))
|
||||
return 0
|
||||
if len(argv) == 4 and argv[0] == "plist":
|
||||
write_plist(Path(argv[1]), argv[2], Path(argv[3]))
|
||||
return 0
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -179,7 +179,7 @@ def _allowed(path: str, line: str) -> bool:
|
||||
return True
|
||||
if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line):
|
||||
return True
|
||||
if path == "packaging/void/container-main.sh" and _PRE_RENAME_GUARD.search(line):
|
||||
if path == "packaging/void/container-release.sh" and _PRE_RENAME_GUARD.search(line):
|
||||
return True
|
||||
if path == "src/vocalinux/utils/update_checker.py" and (
|
||||
'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line
|
||||
@@ -253,7 +253,7 @@ def test_allowlist_keeps_the_retained_surfaces() -> None:
|
||||
("src/vocalinux/ui/tray_indicator.py", "from vocalinux.utils import paths"),
|
||||
("src/vocalinux/app_identity.py", 'LEGACY_BUS_NAME: str = "com.vocalinux.Vocalinux"'),
|
||||
("tests/test_app_identity.py", "from vocalinux import app_identity"),
|
||||
("packaging/void/container-main.sh", 'grep -E "usr/share/applications/vocalinux"'),
|
||||
("packaging/void/container-release.sh", 'grep -E "usr/share/applications/vocalinux"'),
|
||||
("install.sh", 'local PKG_NAME="vocalinux"'),
|
||||
]
|
||||
for path, line in kept:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Spec #16 ships Voculinux itself as a Void package: an in-repo xbps
|
||||
template in void-packages layout under ``packaging/void`` builds the app
|
||||
from the canonical, checksum-verified release source distribution,
|
||||
from the canonical, checksum-verified release source distribution, which the
|
||||
release path builds from the tag and stages for xbps-src (see
|
||||
``tests/test_void_release_build.py``),
|
||||
depends on Void's own interpreter, GTK, desktop bindings and packaged
|
||||
Python libraries plus the dependency templates from tickets #25 and #26,
|
||||
and installs the renamed desktop entry and icons. Speech models are not
|
||||
@@ -12,9 +14,9 @@ install time.
|
||||
Independent sources of truth: ``src/vocalinux/version.py`` owns the
|
||||
version, ``pyproject.toml`` owns the dependency ranges, ``MANIFEST.in``
|
||||
plus the renamed ``voculinux.desktop`` own the install paths, and the
|
||||
GitHub release publish path (``releases/download``) plus ``xbps-src``
|
||||
fork's release path (``releases/download/<tag>``) plus ``xbps-src``
|
||||
conventions own the fetch shape. The template must agree with all of
|
||||
them; the container smoke gate (ticket #28) builds on top.
|
||||
them; the container smoke gate (ticket #28) runs the same release path.
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -27,8 +29,8 @@ SRC = VOID / "srcpkgs"
|
||||
TEMPLATE = SRC / "voculinux" / "template"
|
||||
INSTALL_MSG = SRC / "voculinux" / "INSTALL.msg"
|
||||
PINS = VOID / "tool_checksums.txt"
|
||||
BUILD_SH = VOID / "build.sh"
|
||||
CONTAINER_MAIN = VOID / "container-main.sh"
|
||||
RELEASE_PATH = VOID / "build-release.sh"
|
||||
CONTAINER_RELEASE = VOID / "container-release.sh"
|
||||
VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py"
|
||||
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
||||
DISTRO_MAP = REPO_ROOT / "scripts" / "distro-package-map.yaml"
|
||||
@@ -75,9 +77,10 @@ RUNTIME_HELPER_PACKAGES = {
|
||||
"pulseaudio-utils": "pactl source listing",
|
||||
}
|
||||
|
||||
#: The checksum the template may carry before the fork's release exists. It is
|
||||
#: explicit and fails closed in xbps-src; it is never the upstream digest.
|
||||
PENDING_CHECKSUM = "PENDING_FORK_RELEASE_SHA256"
|
||||
#: The placeholder the committed template carries until the first fork release
|
||||
#: has a recorded digest. Every release build replaces it in its build tree; it
|
||||
#: is never a real digest, and it never reaches a published package.
|
||||
PLACEHOLDER_CHECKSUM = "0" * 64
|
||||
|
||||
#: sha256 of the upstream 0.18.5 archive. The template must never pin it: the
|
||||
#: package is the fork's own release, not a copy of upstream's.
|
||||
@@ -245,33 +248,29 @@ def test_template_version_matches_version_py() -> None:
|
||||
|
||||
|
||||
def test_distfiles_sources_the_canonical_release_by_checksum() -> None:
|
||||
"""One fork release-asset distfile, checksum-verified; never upstream or git."""
|
||||
"""One fork release-asset distfile, checksum-bound; never upstream or git."""
|
||||
variables = _template_vars()
|
||||
distfiles = variables["distfiles"]
|
||||
version = variables["version"]
|
||||
assert distfiles == (
|
||||
f"{FORK_RELEASE_HOST}/releases/download/v${{version}}/"
|
||||
f"{FORK_RELEASE_HOST}/releases/download/${{_tag}}/"
|
||||
f"{variables['pkgname']}-${{version}}.tar.gz"
|
||||
), "distfiles must be the fork's own release sdist for this version"
|
||||
), "distfiles must name the fork's release asset by its tag and xbps version"
|
||||
assert "github.com" not in distfiles and "VocaHQ" not in distfiles, "no upstream archive"
|
||||
for moving in ("/master", "/main", "/archive/", "git+"):
|
||||
assert moving not in distfiles, f"distfiles must not track a moving ref: {moving}"
|
||||
assert distfiles.rstrip().endswith(".tar.gz"), "the release payload is the sdist tarball"
|
||||
# The sdist's top-level directory is pkgname-version, so xbps-src needs no
|
||||
# wrksrc override (deprecated; xlint rejects it).
|
||||
root = distfiles.rsplit("/", 1)[1].replace("${version}", version)
|
||||
assert root == f"{variables['pkgname']}-{version}.tar.gz", "archive name must match pkgname"
|
||||
assert variables["_tag"] == f"v{_app_version()}", "_tag is the tag, hyphen and all"
|
||||
assert "v${version}" not in distfiles, "a pre-release tag keeps its hyphen in the URL"
|
||||
assert "wrksrc" not in variables, "deprecated wrksrc override: xlint fails the template"
|
||||
|
||||
|
||||
def test_checksum_is_pinned_to_the_fork_release_or_explicitly_pending() -> None:
|
||||
"""A 64-hex digest once the fork release exists; until then, the explicit
|
||||
pending marker. Never the upstream digest, never empty."""
|
||||
def test_checksum_is_a_recorded_digest_or_the_build_placeholder() -> None:
|
||||
"""A 64-hex digest once recorded; until then the all-zero placeholder that
|
||||
every release build replaces. Never the upstream digest, never empty."""
|
||||
checksum = _template_vars()["checksum"]
|
||||
assert checksum != UPSTREAM_0_18_5_SHA256, "the template must not pin the upstream archive"
|
||||
assert (
|
||||
re.fullmatch(r"[0-9a-f]{64}", checksum) or checksum == PENDING_CHECKSUM
|
||||
), f"checksum must be a sha256 or the explicit pending marker, not {checksum!r}"
|
||||
assert re.fullmatch(r"[0-9a-f]{64}", checksum), f"not a sha256: {checksum!r}"
|
||||
assert "PENDING" not in _template_text(), "no pending marker: the build pins the digest"
|
||||
|
||||
|
||||
def test_every_project_dependency_is_covered() -> None:
|
||||
@@ -407,24 +406,21 @@ def test_install_msg_line_length() -> None:
|
||||
assert raw.endswith("\n"), "INSTALL.msg must end with a newline"
|
||||
|
||||
|
||||
def test_container_harness_covers_the_app_package() -> None:
|
||||
"""build.sh stays podman-only; container-main smokes the installed app."""
|
||||
build = BUILD_SH.read_text(encoding="utf-8")
|
||||
assert "podman" in build, "the build harness must use podman"
|
||||
assert "docker" not in build.lower().replace("never docker", "")
|
||||
harness = CONTAINER_MAIN.read_text(encoding="utf-8")
|
||||
def test_release_path_covers_the_app_package() -> None:
|
||||
"""The install smoke runs the renamed binary and the app's payload checks."""
|
||||
harness = CONTAINER_RELEASE.read_text(encoding="utf-8")
|
||||
assert "voculinux --version" in harness, "the app smoke checks the entry point"
|
||||
assert "vocalinux.ui.tray_indicator" in harness, "the smoke imports the desktop modules"
|
||||
assert (
|
||||
"vocalinux.speech_recognition.recognition_manager" in harness
|
||||
), "the smoke imports the speech modules"
|
||||
assert "usr/share/applications/voculinux.desktop" in harness
|
||||
assert "usr/share/icons/hicolor/scalable/apps/voculinux.svg" in harness
|
||||
assert "usr/share/voculinux/packaged" in harness
|
||||
assert (
|
||||
"no pre-rename leftovers" in harness
|
||||
"no pre-rename" in harness or "pre-rename file names" in harness
|
||||
), "the payload check must reject pre-rename file names in the package"
|
||||
assert "python3-pywhispercpp" in harness or "PKGNAME" in harness
|
||||
assert "models or wheels" in harness, "the payload check rejects models and wheels"
|
||||
assert "import vocalinux.main" in harness, "the install smoke imports the app"
|
||||
assert "pywhispercpp.model" in harness, "the install smoke imports the speech binding"
|
||||
assert "PYNPUT_BACKEND=dummy" in harness and "import evdev" in harness
|
||||
assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_void_distro_inventory_matches_void_packages_reality() -> None:
|
||||
|
||||
@@ -20,7 +20,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VOID_DIR = REPO_ROOT / "packaging" / "void"
|
||||
TEMPLATE = VOID_DIR / "srcpkgs" / "python3-pywhispercpp" / "template"
|
||||
PINS = VOID_DIR / "tool_checksums.txt"
|
||||
BUILD_SH = VOID_DIR / "build.sh"
|
||||
BUILD_SH = VOID_DIR / "build-release.sh"
|
||||
INSTALL_SH = REPO_ROOT / "install.sh"
|
||||
UV_LOCK = REPO_ROOT / "uv.lock"
|
||||
|
||||
@@ -235,9 +235,8 @@ def test_build_script_uses_podman_and_the_pinned_image() -> None:
|
||||
scrubbed = text.lower().replace("never docker", "")
|
||||
assert "docker" not in scrubbed, "never Docker"
|
||||
assert "tool_checksums.txt" in text, "the image digest comes from the pins file"
|
||||
# The container flow lives in mounted files, not an inline sh -c
|
||||
# string: single quotes inside sh -c silently regroup the command.
|
||||
for name in ("container-main.sh", "container-build.sh"):
|
||||
path = VOID_DIR / name
|
||||
assert path.is_file(), f"missing container script: {path}"
|
||||
assert name in text, f"{BUILD_SH.name} must mount {name}"
|
||||
# The container flow lives in a mounted file, not an inline sh -c string:
|
||||
# single quotes inside sh -c silently regroup the command.
|
||||
name = "container-release.sh"
|
||||
assert (VOID_DIR / name).is_file(), f"missing container script: {name}"
|
||||
assert name in text, f"{BUILD_SH.name} must mount {name}"
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Offline guards for the one Void release path (spec #16; tickets #28 and #29).
|
||||
|
||||
``packaging/void/build-release.sh`` is the release path. The Gitea tag workflow,
|
||||
the local dry run and the smoke gate (``packaging/void/smoke.sh``) all call it,
|
||||
so a passing dry run exercises the code a tag runs. These tests pin its seams:
|
||||
|
||||
- the xbps spelling a tag maps to, and the tag gate (exact ``v`` plus
|
||||
``__version__``, and the committed template must already name that release);
|
||||
- the signing-key checks, which must stop before any container starts (a fake
|
||||
``podman`` on PATH records any call and must never be reached);
|
||||
- ``render-voculinux.sh``, run against a scratch void-packages tree, which pins
|
||||
the build tree and never the repository's template;
|
||||
- the podman wiring: which phase may see the key, and which phase has no network;
|
||||
- the xbps version ordering the mapping depends on (``xbps-uhelper cmpver``,
|
||||
skipped where xbps is not installed).
|
||||
|
||||
The container runs themselves are the proof; these are the guards around them.
|
||||
Expected values come from the documented rules, not from the code under test.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VOID = REPO_ROOT / "packaging" / "void"
|
||||
BUILD_RELEASE = VOID / "build-release.sh"
|
||||
CONTAINER_RELEASE = VOID / "container-release.sh"
|
||||
RENDER = VOID / "render-voculinux.sh"
|
||||
FINGERPRINT_PY = VOID / "xbps-fingerprint.py"
|
||||
SMOKE_SH = VOID / "smoke.sh"
|
||||
PINS = VOID / "tool_checksums.txt"
|
||||
PLIST = VOID / "repository-key.plist"
|
||||
TEMPLATE = VOID / "srcpkgs" / "voculinux" / "template"
|
||||
VERSION_PY = REPO_ROOT / "src" / "vocalinux" / "version.py"
|
||||
|
||||
PINNED_FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
|
||||
SDIST = "voculinux-0.19.0beta.tar.gz"
|
||||
XBPS_UHELPER = shutil.which("xbps-uhelper")
|
||||
OPENSSL = shutil.which("openssl")
|
||||
|
||||
#: Files the release path reads from its own directory, copied into fixtures.
|
||||
FIXTURE_FILES = (BUILD_RELEASE, CONTAINER_RELEASE, RENDER, FINGERPRINT_PY, SMOKE_SH, PINS, PLIST)
|
||||
|
||||
|
||||
def _body(text: str, name: str) -> str:
|
||||
"""The body of a top-level shell function, from its opening line to the closing brace."""
|
||||
match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL)
|
||||
assert match, f"{name}() must be a top-level function"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _code(path: Path) -> str:
|
||||
"""A shell or Python script with comment-only lines removed."""
|
||||
return "\n".join(
|
||||
line
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
|
||||
|
||||
def make_tree(
|
||||
root: Path,
|
||||
version: str,
|
||||
template_version: str,
|
||||
template_tag: str,
|
||||
version_py: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""A scratch copy of the release tree, with the given version and committed template."""
|
||||
pkg = root / "packaging" / "void"
|
||||
pkg.mkdir(parents=True)
|
||||
for source in FIXTURE_FILES:
|
||||
shutil.copy2(source, pkg / source.name)
|
||||
(pkg / "srcpkgs" / "voculinux").mkdir(parents=True)
|
||||
text = TEMPLATE.read_text(encoding="utf-8")
|
||||
text = re.sub(r"^version=.*$", f"version={template_version}", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^_tag=.*$", f"_tag={template_tag}", text, flags=re.MULTILINE)
|
||||
(pkg / "srcpkgs" / "voculinux" / "template").write_text(text, encoding="utf-8")
|
||||
src = root / "src" / "vocalinux"
|
||||
src.mkdir(parents=True)
|
||||
(src / "version.py").write_text(
|
||||
version_py if version_py is not None else f'"""Version."""\n\n__version__ = "{version}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def run_build(tree: Path, *args: str, path: Optional[str] = None, tmp: Optional[Path] = None):
|
||||
"""Run the tree's build-release.sh with a minimal, explicit environment."""
|
||||
env = {"PATH": path or os.environ["PATH"], "HOME": os.environ.get("HOME", "/")}
|
||||
if tmp is not None:
|
||||
env["TMPDIR"] = str(tmp)
|
||||
return subprocess.run(
|
||||
["sh", str(tree / "packaging" / "void" / "build-release.sh"), *args],
|
||||
cwd=tree,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Names: a tag maps to one xbps spelling, and only release forms map at all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "xbps"),
|
||||
[
|
||||
("0.18.5", "0.18.5"),
|
||||
("1.0.0", "1.0.0"),
|
||||
("0.19.0-alpha", "0.19.0alpha"),
|
||||
("0.19.0-beta", "0.19.0beta"),
|
||||
("0.19.0-rc1", "0.19.0rc1"),
|
||||
("0.19.0-rc12", "0.19.0rc12"),
|
||||
],
|
||||
)
|
||||
def test_a_tag_maps_to_its_xbps_spelling(tmp_path: Path, version: str, xbps: str) -> None:
|
||||
"""xbps versions have no '-': the hyphen goes, and the rest is unchanged."""
|
||||
tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}")
|
||||
result = run_build(tree, "names", f"v{version}")
|
||||
assert result.returncode == 0, result.stderr
|
||||
names = dict(line.split("=", 1) for line in result.stdout.splitlines())
|
||||
assert names == {
|
||||
"TAG": f"v{version}",
|
||||
"VERSION": version,
|
||||
"XBPS_VERSION": xbps,
|
||||
"SDIST_NAME": f"voculinux-{xbps}.tar.gz",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"version",
|
||||
[
|
||||
"0.19.0-beta.2",
|
||||
"0.19.0+local",
|
||||
"0.19.0_beta",
|
||||
"0.19",
|
||||
"0.19.0-dev",
|
||||
"0.19.0-post1",
|
||||
"0.19.0-beta-2",
|
||||
"v0.19.0",
|
||||
],
|
||||
)
|
||||
def test_a_version_outside_the_release_forms_is_refused(tmp_path: Path, version: str) -> None:
|
||||
"""Only X.Y.Z and X.Y.Z-alpha|beta|rcN name a release; anything else stops."""
|
||||
tree = make_tree(tmp_path, version, template_version="0.19.0", template_tag=f"v{version}")
|
||||
result = run_build(tree, "names", f"v{version}")
|
||||
assert result.returncode == 1, result.stdout
|
||||
assert "not a release form" in result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The tag gate: exact match, and the committed template must already agree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tag", "version", "ok"),
|
||||
[
|
||||
("v0.18.5", "0.18.5", True),
|
||||
("v0.19.0-beta", "0.19.0-beta", True),
|
||||
("0.18.5", "0.18.5", False), # no v prefix
|
||||
("v0.18.50", "0.18.5", False), # the tag contains the version
|
||||
("v0.18", "0.18.5", False), # the version contains the tag
|
||||
("v0.18.5-beta", "0.18.5", False),
|
||||
("v0.18.5", "0.18.5-beta", False),
|
||||
("v0.18.4", "0.18.5", False),
|
||||
("vv0.18.5", "0.18.5", False),
|
||||
],
|
||||
)
|
||||
def test_tag_gate_is_an_exact_match_with_version_py(
|
||||
tmp_path: Path, tag: str, version: str, ok: bool
|
||||
) -> None:
|
||||
xbps = version.replace("-", "")
|
||||
tree = make_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}")
|
||||
result = run_build(tree, "check-tag", tag)
|
||||
if ok:
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "tag gate passed" in result.stdout
|
||||
else:
|
||||
assert result.returncode == 1, f"{tag} against {version} must stop: {result.stdout}"
|
||||
assert "refusing to publish" in result.stderr
|
||||
|
||||
|
||||
def test_the_committed_template_must_name_this_release(tmp_path: Path) -> None:
|
||||
"""A release whose template still names the previous version never publishes."""
|
||||
tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5")
|
||||
result = run_build(tree, "check-tag", "v0.19.0-beta")
|
||||
assert result.returncode == 1
|
||||
assert "committed template version is '0.18.5', not '0.19.0beta'" in result.stderr
|
||||
|
||||
|
||||
def test_the_committed_template_tag_must_match_the_tag(tmp_path: Path) -> None:
|
||||
tree = make_tree(tmp_path, "0.19.0-beta", template_version="0.19.0beta", template_tag="v0.19.0")
|
||||
result = run_build(tree, "check-tag", "v0.19.0-beta")
|
||||
assert result.returncode == 1
|
||||
assert "_tag is 'v0.19.0'" in result.stderr
|
||||
|
||||
|
||||
def test_version_py_must_define_the_version_exactly_once(tmp_path: Path) -> None:
|
||||
body = '__version__ = "0.18.5"\n__version__ = "0.18.6"\n'
|
||||
tree = make_tree(tmp_path, "0.18.5", "0.18.5", "v0.18.5", version_py=body)
|
||||
result = run_build(tree, "check-tag", "v0.18.5")
|
||||
assert result.returncode == 1
|
||||
assert "exactly once" in result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version ordering: the pre-release spellings sort below the release
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(XBPS_UHELPER is None, reason="xbps-uhelper is not installed")
|
||||
@pytest.mark.parametrize(
|
||||
("lower", "higher"),
|
||||
[
|
||||
("0.18.5beta", "0.18.5"),
|
||||
("0.18.5rc1", "0.18.5"),
|
||||
("0.18.5alpha", "0.18.5beta"),
|
||||
("0.18.5beta", "0.18.5rc1"),
|
||||
("0.18.5rc1", "0.18.5rc2"),
|
||||
("0.18.5rc2", "0.18.5rc10"),
|
||||
("0.18.5", "0.19.0beta"),
|
||||
("0.19.0beta", "0.19.0"),
|
||||
],
|
||||
)
|
||||
def test_xbps_sorts_pre_release_spellings_below_the_release(lower: str, higher: str) -> None:
|
||||
"""A stable user is never offered a pre-release of the same version as newer."""
|
||||
result = subprocess.run(
|
||||
[XBPS_UHELPER, "cmpver", f"voculinux-{lower}_1", f"voculinux-{higher}_1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
# xbps-uhelper exits -1 (255) when the first package is older than the second.
|
||||
assert result.returncode == 255, f"{lower} must sort below {higher}: rc={result.returncode}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signing key: every refusal happens before any container starts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_podman(tmp_path: Path) -> tuple:
|
||||
"""A podman that records its calls and fails. Any call means a container would start."""
|
||||
bindir = tmp_path / "fakebin"
|
||||
bindir.mkdir()
|
||||
log = tmp_path / "podman-calls.log"
|
||||
script = bindir / "podman"
|
||||
script.write_text(f'#!/bin/sh\necho "$@" >> "{log}"\nexit 97\n', encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
return f"{bindir}{os.pathsep}{os.environ['PATH']}", log
|
||||
|
||||
|
||||
def _key(path: Path, bits: int) -> Path:
|
||||
subprocess.run(
|
||||
[OPENSSL, "genrsa", "-out", str(path), str(bits)],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed")
|
||||
@pytest.mark.parametrize(
|
||||
("case", "message"),
|
||||
[
|
||||
("missing-arg", "no signing key"),
|
||||
("empty", "missing or empty"),
|
||||
("garbage", "not a valid RSA private key"),
|
||||
("rsa2048", "not RSA 4096"),
|
||||
("rsa4096-other", "does not match packaging/void/repository-key.plist"),
|
||||
],
|
||||
)
|
||||
def test_a_bad_key_stops_the_release_before_any_container(
|
||||
tmp_path: Path, fake_podman: tuple, case: str, message: str
|
||||
) -> None:
|
||||
path, log = fake_podman
|
||||
tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5")
|
||||
out = tmp_path / "out"
|
||||
args = ["release", "v0.18.5", "--out-dir", str(out)]
|
||||
if case == "empty":
|
||||
key = tmp_path / "empty.pem"
|
||||
key.write_bytes(b"")
|
||||
elif case == "garbage":
|
||||
key = tmp_path / "garbage.pem"
|
||||
key.write_text("not a key\n", encoding="utf-8")
|
||||
elif case == "rsa2048":
|
||||
key = _key(tmp_path / "rsa2048.pem", 2048)
|
||||
elif case == "rsa4096-other":
|
||||
key = _key(tmp_path / "other.pem", 4096)
|
||||
if case != "missing-arg":
|
||||
args += ["--key-file", str(key)]
|
||||
tmp = tmp_path / "work"
|
||||
tmp.mkdir()
|
||||
result = run_build(tree, *args, path=path, tmp=tmp)
|
||||
assert result.returncode == 1, result.stdout + result.stderr
|
||||
assert message in result.stderr
|
||||
assert not log.exists(), "a container started with a refused key"
|
||||
assert not out.exists(), "a refused release must not create its output directory"
|
||||
for work in tmp.glob("voculinux-release.*"):
|
||||
assert not (work / "dist").exists(), "the sdist must not be built for a refused key"
|
||||
|
||||
|
||||
@pytest.mark.skipif(OPENSSL is None, reason="openssl is not installed")
|
||||
def test_the_key_file_is_never_printed(tmp_path: Path, fake_podman: tuple) -> None:
|
||||
"""Refusals name the problem, never the key material."""
|
||||
path, _ = fake_podman
|
||||
tree = make_tree(tmp_path / "tree", "0.18.5", template_version="0.18.5", template_tag="v0.18.5")
|
||||
key = _key(tmp_path / "other.pem", 4096)
|
||||
secret_line = key.read_text(encoding="ascii").splitlines()[1]
|
||||
tmp = tmp_path / "work"
|
||||
tmp.mkdir()
|
||||
result = run_build(
|
||||
tree,
|
||||
"release",
|
||||
"v0.18.5",
|
||||
"--key-file",
|
||||
str(key),
|
||||
"--out-dir",
|
||||
str(tmp_path / "o"),
|
||||
path=path,
|
||||
tmp=tmp,
|
||||
)
|
||||
assert secret_line not in result.stdout + result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render-voculinux.sh: the build tree is pinned, the repository never is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _scratch_void_packages(tmp_path: Path) -> Path:
|
||||
vp = tmp_path / "void-packages"
|
||||
(vp / "srcpkgs" / "voculinux").mkdir(parents=True)
|
||||
shutil.copy2(TEMPLATE, vp / "srcpkgs" / "voculinux" / "template")
|
||||
return vp
|
||||
|
||||
|
||||
def _render(tmp_path: Path, vp: Path, payload: bytes, **overrides: str):
|
||||
inputs = tmp_path / "inputs"
|
||||
inputs.mkdir(exist_ok=True)
|
||||
(inputs / SDIST).write_bytes(payload)
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"VOID_PACKAGES": str(vp),
|
||||
"SDIST_DIR": str(inputs),
|
||||
"XBPS_VERSION": "0.19.0beta",
|
||||
"TAG": "v0.19.0-beta",
|
||||
"SDIST_NAME": SDIST,
|
||||
"SDIST_SHA256": hashlib.sha256(payload).hexdigest(),
|
||||
}
|
||||
env.update(overrides)
|
||||
return subprocess.run(
|
||||
["sh", str(RENDER)], env=env, capture_output=True, text=True, timeout=30, check=False
|
||||
)
|
||||
|
||||
|
||||
def test_render_pins_version_tag_and_checksum_in_the_build_tree(tmp_path: Path) -> None:
|
||||
payload = b"canonical sdist bytes for the release"
|
||||
vp = _scratch_void_packages(tmp_path)
|
||||
repo_before = TEMPLATE.read_bytes()
|
||||
result = _render(tmp_path, vp, payload)
|
||||
assert result.returncode == 0, result.stderr
|
||||
text = (vp / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8")
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
assert re.search(r"^version=0\.19\.0beta$", text, re.MULTILINE)
|
||||
assert re.search(r"^_tag=v0\.19\.0-beta$", text, re.MULTILINE)
|
||||
assert re.search(rf"^checksum={digest}$", text, re.MULTILINE)
|
||||
assert "releases/download/${_tag}/voculinux-${version}.tar.gz" in text, "distfiles unchanged"
|
||||
staged = vp / "hostdir" / "sources" / "voculinux-0.19.0beta" / SDIST
|
||||
assert staged.read_bytes() == payload, "xbps-src must find the sdist where it looks"
|
||||
assert TEMPLATE.read_bytes() == repo_before, "the repository template must never change"
|
||||
|
||||
|
||||
def test_render_refuses_a_payload_that_does_not_match_its_digest(tmp_path: Path) -> None:
|
||||
vp = _scratch_void_packages(tmp_path)
|
||||
before = (vp / "srcpkgs" / "voculinux" / "template").read_bytes()
|
||||
result = _render(tmp_path, vp, b"payload", SDIST_SHA256="0" * 64)
|
||||
assert result.returncode == 1
|
||||
assert "is not the expected" in result.stderr
|
||||
assert (vp / "srcpkgs" / "voculinux" / "template").read_bytes() == before
|
||||
|
||||
|
||||
def test_render_refuses_a_template_without_exactly_one_checksum_line(tmp_path: Path) -> None:
|
||||
vp = _scratch_void_packages(tmp_path)
|
||||
template = vp / "srcpkgs" / "voculinux" / "template"
|
||||
template.write_text(template.read_text(encoding="utf-8") + "checksum=abc\n", encoding="utf-8")
|
||||
result = _render(tmp_path, vp, b"payload")
|
||||
assert result.returncode == 1
|
||||
assert "exactly one checksum= line" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_tag", ["v0.19.0-beta;rm", "v0.19.0 beta", 'v0.19.0"beta'])
|
||||
def test_render_refuses_values_outside_the_safe_set(tmp_path: Path, bad_tag: str) -> None:
|
||||
vp = _scratch_void_packages(tmp_path)
|
||||
result = _render(tmp_path, vp, b"payload", TAG=bad_tag)
|
||||
assert result.returncode == 1
|
||||
assert "outside [A-Za-z0-9._-]" in result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Podman wiring: who may see the key, who may use the network
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_only_the_sign_phase_mounts_the_key() -> None:
|
||||
"""Build scripts run in the build phase; they must never see the release key."""
|
||||
text = _code(BUILD_RELEASE)
|
||||
build = _body(text, "phase_build")
|
||||
sign = _body(text, "phase_sign")
|
||||
install = _body(text, "phase_install")
|
||||
for name, body in (("build", build), ("install", install)):
|
||||
assert "KEY_FILE" not in body, f"the {name} phase must not mount or name the key"
|
||||
assert "/run/xbps-sign-key.pem" not in body
|
||||
assert '-v "$KEY_FILE:/run/xbps-sign-key.pem:ro"' in sign, "the key is mounted read-only"
|
||||
|
||||
|
||||
def test_the_sign_phase_has_no_network() -> None:
|
||||
sign = _body(_code(BUILD_RELEASE), "phase_sign")
|
||||
assert "--network none" in sign
|
||||
|
||||
|
||||
def test_the_build_phase_is_the_only_one_that_runs_build_scripts() -> None:
|
||||
text = _code(CONTAINER_RELEASE)
|
||||
assert "xbps-src pkg" in _body_of_container(text, "phase_build")
|
||||
for name in ("phase_sign", "phase_install"):
|
||||
assert "xbps-src" not in _body_of_container(text, name), f"{name} must not build anything"
|
||||
|
||||
|
||||
def _body_of_container(text: str, name: str) -> str:
|
||||
match = re.search(rf"^{name}\(\) \{{\n(.*?)^\}}$", text, re.MULTILINE | re.DOTALL)
|
||||
assert match, f"{name}() must be a function in container-release.sh"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_every_podman_run_uses_the_pinned_base_image() -> None:
|
||||
text = _code(BUILD_RELEASE)
|
||||
runs = text.count("podman run")
|
||||
assert runs == 3, "build, sign and install: exactly three containers"
|
||||
assert text.count('"$BASE_IMAGE" /bin/sh /mnt/container-release.sh') == 3
|
||||
assert "ghcr.io" not in text, "the base image is pinned in tool_checksums.txt only"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [BUILD_RELEASE, CONTAINER_RELEASE, RENDER, SMOKE_SH])
|
||||
def test_no_docker_in_the_release_path(path: Path) -> None:
|
||||
assert not re.search(r"\bdocker\b", _code(path), re.IGNORECASE), f"{path.name} names Docker"
|
||||
|
||||
|
||||
def test_the_checks_run_before_the_sdist_and_before_any_container() -> None:
|
||||
text = _code(BUILD_RELEASE)
|
||||
pipeline = _body(text, "run_pipeline")
|
||||
order = [
|
||||
pipeline.index("check_template"),
|
||||
pipeline.index("check_key"),
|
||||
pipeline.index("build_sdist"),
|
||||
pipeline.index("phase_build"),
|
||||
]
|
||||
assert order == sorted(order), order
|
||||
|
||||
|
||||
def test_the_key_is_never_echoed_or_copied() -> None:
|
||||
text = _code(BUILD_RELEASE)
|
||||
assert "set -x" not in text
|
||||
assert not re.search(r'cat\s+"?\$KEY_FILE', text)
|
||||
assert not re.search(r'cp\s+"?\$KEY_FILE', text), "the key is mounted, never copied"
|
||||
|
||||
|
||||
def test_the_build_tree_is_pinned_before_xbps_src_runs() -> None:
|
||||
build = _body_of_container(_code(CONTAINER_RELEASE), "phase_build")
|
||||
assert build.index("render-voculinux.sh") < build.index("xbps-src binary-bootstrap")
|
||||
assert build.index("chown -R builder") < build.index("xbps-src binary-bootstrap")
|
||||
|
||||
|
||||
def test_render_writes_only_inside_the_build_tree() -> None:
|
||||
text = _code(RENDER)
|
||||
assert 'TEMPLATE="$VP/srcpkgs/voculinux/template"' in text
|
||||
assert 'DEST="$VP/hostdir/sources' in text
|
||||
assert "$ROOT" not in text and "REPO" not in text, "the repository is never a target"
|
||||
|
||||
|
||||
def test_the_install_phase_proves_the_key_is_needed() -> None:
|
||||
install = _body_of_container(_code(CONTAINER_RELEASE), "phase_install")
|
||||
assert "xbps-install -S --repository" in install, "the unsigned attempt runs without -y"
|
||||
assert "refused without the key" in install
|
||||
assert "/var/db/xbps/keys" in install
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The fingerprint helper: the pinned value is derived, not typed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_committed_key_has_the_pinned_fingerprint(tmp_path: Path) -> None:
|
||||
pem = tmp_path / "pub.pem"
|
||||
pem.write_bytes(plistlib.loads(PLIST.read_bytes())["public-key"])
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", str(FINGERPRINT_PY), "fingerprint", str(pem)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=True,
|
||||
)
|
||||
assert result.stdout.strip() == PINNED_FINGERPRINT
|
||||
|
||||
|
||||
def test_the_plist_writer_round_trips_the_public_key(tmp_path: Path) -> None:
|
||||
if OPENSSL is None:
|
||||
pytest.skip("openssl is not installed")
|
||||
key = _key(tmp_path / "key.pem", 2048)
|
||||
pem = tmp_path / "pub.pem"
|
||||
subprocess.run(
|
||||
[OPENSSL, "rsa", "-in", str(key), "-pubout", "-out", str(pem)],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
out = tmp_path / "client.plist"
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-I",
|
||||
str(FINGERPRINT_PY),
|
||||
"plist",
|
||||
str(pem),
|
||||
"Test <t@example.com>",
|
||||
str(out),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
entry = plistlib.loads(out.read_bytes())
|
||||
assert entry["public-key"] == pem.read_bytes()
|
||||
assert entry["public-key-size"] == 2048
|
||||
assert entry["signature-by"] == "Test <t@example.com>"
|
||||
|
||||
|
||||
def test_smoke_is_the_same_path_with_a_throwaway_key() -> None:
|
||||
"""The smoke gate delegates to the release path; it never names the release key."""
|
||||
text = _code(SMOKE_SH)
|
||||
assert 'exec "$HERE/build-release.sh" smoke' in text
|
||||
assert "XBPS_SIGNING_KEY" not in text
|
||||
release = _code(BUILD_RELEASE)
|
||||
assert "throwaway.pem" in release and "genrsa" in release
|
||||
|
||||
|
||||
def test_the_sdist_is_staged_for_publication_before_the_completeness_check() -> None:
|
||||
"""The publish set is DIR/sdist plus DIR/xbps; the sdist must be copied there first."""
|
||||
pipeline = _body(_code(BUILD_RELEASE), "run_pipeline")
|
||||
assert 'mkdir -p "$OUT/sdist"' in pipeline
|
||||
assert 'cp "$WORK/inputs/$SDIST_NAME" "$OUT/sdist/$SDIST_NAME"' in pipeline
|
||||
assert pipeline.index('cp "$WORK/inputs/') < pipeline.index("verify_set"), "stage, then verify"
|
||||
+26
-166
@@ -1,37 +1,26 @@
|
||||
"""Offline policy guards for the signed Void xbps repository (ticket #29).
|
||||
|
||||
Spec #16 serves the Void templates from a signed personal xbps
|
||||
repository: every template builds in dependency order in the pinned Void
|
||||
container via podman, the repository index carries full repo data, and
|
||||
packages plus metadata are signed with the RSA4096 repository key. The
|
||||
pipeline fails closed — a missing, unreadable, or non-RSA4096 key aborts
|
||||
before any container runs, so an unsigned repository can never publish.
|
||||
Spec #16 serves the Void templates from a signed personal xbps repository.
|
||||
The release path itself (build, sign, install smoke) is guarded in
|
||||
``tests/test_void_release_build.py``; this module guards what must stay true
|
||||
around it: the committed public key and its pinned fingerprint, the README and
|
||||
root README registration text users copy, the release workflow's publish target,
|
||||
the template bump documented as a pull request, and the rule that no private key
|
||||
is ever committed.
|
||||
|
||||
Seams under test (public interfaces, not internals):
|
||||
|
||||
- ``packaging/void/repo.sh`` — the host entry: key handling plus the
|
||||
podman delegation. Exit status and stderr on a missing key are the
|
||||
fail-closed contract.
|
||||
- ``packaging/void/container-repo.sh`` — the container half: build-all,
|
||||
index, sign, verify, stage. Its xbps-rindex invocations are the
|
||||
signing contract.
|
||||
- The ``void-repo`` just recipe — how release work and CI call the
|
||||
pipeline, same shape as the other packaging gates.
|
||||
- ``packaging/void/README.md`` — the documented maintainer and client
|
||||
flow, including the CI secret name.
|
||||
- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of
|
||||
the repository key, whose fingerprint the READMEs and the Gitea release
|
||||
workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow.
|
||||
- ``packaging/void/repository-key.plist``: the committed PUBLIC half of the
|
||||
repository key. Its fingerprint is what the READMEs pin.
|
||||
- ``README.md`` and ``packaging/void/README.md``: the client registration text.
|
||||
- ``.gitea/workflows/xbps-release.yml``: the tag trigger, the Gitea API it
|
||||
publishes to, and the release path it calls.
|
||||
- ``docs/RELEASE_PROCESS.md``: the template bump is a pull request.
|
||||
|
||||
Independent sources of truth: the xbps fingerprint definition (the MD5 of
|
||||
the SSH-format ``ssh-rsa`` blob of the public key, per libxbps
|
||||
``xbps_pubkey2fp``), ``packaging/void/srcpkgs/*/template``
|
||||
owns the template set (a renamed or added template must not silently
|
||||
drop out), ``packaging/void/tool_checksums.txt`` owns the pins,
|
||||
``packaging/void/smoke.sh`` owns the dependency order the signed repo
|
||||
must agree with, and ``openssl`` plus ``xbps-rindex`` own the RSA4096
|
||||
signing mechanics the container half must invoke. The container run
|
||||
itself is the proof; these tests are the wiring guard.
|
||||
Independent sources of truth: the xbps fingerprint definition (the MD5 of the
|
||||
ssh-rsa blob of the public key, per libxbps ``xbps_pubkey2fp``), computed here
|
||||
with the standard library only, and ``packaging/void/smoke.sh`` owns the package
|
||||
order the signed repository must agree with.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -52,8 +41,8 @@ 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"
|
||||
CONTAINER_REPO = VOID / "container-repo.sh"
|
||||
BUILD_RELEASE = VOID / "build-release.sh"
|
||||
CONTAINER_RELEASE = VOID / "container-release.sh"
|
||||
SMOKE_SH = VOID / "smoke.sh"
|
||||
PINS = VOID / "tool_checksums.txt"
|
||||
JUSTFILE = REPO_ROOT / "justfile"
|
||||
@@ -72,18 +61,6 @@ def _srcpkg_templates() -> list:
|
||||
return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file())
|
||||
|
||||
|
||||
def _repo_text() -> str:
|
||||
assert REPO_SH.is_file(), f"missing signed-repo pipeline: {REPO_SH}"
|
||||
return REPO_SH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _container_text() -> str:
|
||||
assert (
|
||||
CONTAINER_REPO.is_file()
|
||||
), f"missing container half of the signed-repo pipeline: {CONTAINER_REPO}"
|
||||
return CONTAINER_REPO.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _smoke_packages() -> list:
|
||||
"""The ordered package list the smoke gate builds, from smoke.sh."""
|
||||
match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
|
||||
@@ -91,124 +68,11 @@ def _smoke_packages() -> list:
|
||||
return match.group(1).split()
|
||||
|
||||
|
||||
def test_pipeline_builds_every_template_and_nothing_else() -> None:
|
||||
"""A renamed or added template cannot silently drop out of the repo."""
|
||||
text = _repo_text()
|
||||
assert "smoke.sh" in text or "PKGS" in text, (
|
||||
"repo.sh must take its package set from the smoke gate order, "
|
||||
"not a second hand-maintained list"
|
||||
)
|
||||
assert set(_smoke_packages()) == set(_srcpkg_templates()), (
|
||||
f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}"
|
||||
)
|
||||
|
||||
|
||||
def test_pipeline_builds_the_app_last() -> None:
|
||||
"""voculinux resolves the other templates from the signed repo."""
|
||||
assert (
|
||||
_smoke_packages()[-1] == "voculinux"
|
||||
), f"the app must build last, order is {_smoke_packages()}"
|
||||
|
||||
|
||||
def test_pipeline_stays_podman_only_with_pinned_inputs() -> None:
|
||||
"""Podman, never Docker; pins live in tool_checksums.txt, not the flow."""
|
||||
for path, text in (
|
||||
(REPO_SH, _repo_text()),
|
||||
(CONTAINER_REPO, _container_text()),
|
||||
):
|
||||
assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman"
|
||||
assert "docker" not in text.lower().replace(
|
||||
"never docker", ""
|
||||
), f"{path.name} must never invoke Docker"
|
||||
assert "ghcr.io" not in _repo_text(), "the base image pin lives in tool_checksums.txt"
|
||||
assert PINS.is_file(), f"missing pins file: {PINS}"
|
||||
|
||||
|
||||
def test_pipeline_fails_closed_without_a_key() -> None:
|
||||
"""No key file means no container, no repo, no publish — never unsigned."""
|
||||
text = _repo_text()
|
||||
assert KEY_FILE_VAR in text, f"repo.sh must read the key path from {KEY_FILE_VAR}"
|
||||
assert (
|
||||
SIGNING_SECRET in text or "SIGNING_KEY" in text
|
||||
), "repo.sh must name the CI secret carrying the private key"
|
||||
for marker in ("exit 1",):
|
||||
assert marker in text, "the missing-key path must abort non-zero"
|
||||
assert re.search(
|
||||
r"[Nn]o .*key|missing .*key|key .*missing", text
|
||||
), "the missing-key abort must say the key is missing"
|
||||
|
||||
|
||||
def test_pipeline_requires_rsa4096_before_any_container_runs() -> None:
|
||||
"""The contract is RSA4096: any other key aborts before podman starts."""
|
||||
text = _repo_text()
|
||||
assert "4096" in text, "repo.sh must pin the 4096-bit key size"
|
||||
assert "openssl" in text, "repo.sh must validate the key with openssl"
|
||||
key_check = text.index("openssl")
|
||||
first_run = min(i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0)
|
||||
assert key_check < first_run, "key validation must run before the first podman invocation"
|
||||
|
||||
|
||||
def test_container_signs_packages_and_metadata_with_the_key() -> None:
|
||||
"""xbps-rindex signs every binary package and the repository metadata."""
|
||||
text = _container_text()
|
||||
assert "xbps-rindex -a" in text, "the repo index (full repodata) is missing"
|
||||
assert "-S" in text or "--sign-pkg" in text, "per-package signing (--sign-pkg) is missing"
|
||||
assert (
|
||||
re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text
|
||||
), "repository metadata signing (-s/--sign) is missing"
|
||||
assert "--privkey" in text, "signing must use the provisioned private key"
|
||||
assert "--signedby" in text, "signing must carry the signer identity"
|
||||
|
||||
|
||||
def test_container_lints_every_template_before_building() -> None:
|
||||
"""One bad template fails the repo, same as the per-package harness."""
|
||||
assert (
|
||||
"xlint" in _container_text()
|
||||
), "container-repo.sh must xlint each template before building it"
|
||||
|
||||
|
||||
def test_container_names_the_key_fingerprint_for_clients() -> None:
|
||||
"""Clients pin the key by fingerprint; the pipeline must report it."""
|
||||
assert (
|
||||
"ingerprint" in _container_text()
|
||||
), "container-repo.sh must capture the key fingerprint for release notes"
|
||||
|
||||
|
||||
def test_container_ships_an_importable_client_key() -> None:
|
||||
"""The staged plist must parse: xbps rejects raw <> in signature-by."""
|
||||
text = _container_text()
|
||||
assert ".plist" in text, "container-repo.sh must stage the client .plist"
|
||||
assert "<" in text and ">" in text, (
|
||||
"the plist writer must XML-escape the signer identity "
|
||||
"(xbps shows Fingerprint and refuses the import otherwise)"
|
||||
)
|
||||
|
||||
|
||||
def test_container_proves_signatures_with_a_clean_rootdir_install() -> None:
|
||||
"""The pipeline verifies what it signed: a fresh rootdir install."""
|
||||
text = _container_text()
|
||||
assert (
|
||||
"xbps-install" in text and "-r " in text
|
||||
), "container-repo.sh must prove the signed repo with a rootdir install"
|
||||
assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo"
|
||||
|
||||
|
||||
def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None:
|
||||
"""`just void-repo` runs the pipeline script with no shared-output step."""
|
||||
recipe = re.search(
|
||||
r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)",
|
||||
JUSTFILE.read_text(encoding="utf-8"),
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert recipe, "justfile must define a void-repo recipe"
|
||||
assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite"
|
||||
assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh"
|
||||
|
||||
|
||||
def test_readme_documents_the_signed_repo_flow() -> None:
|
||||
"""Maintainer key setup, CI secret, fail-closed rule, and client setup."""
|
||||
text = README.read_text(encoding="utf-8")
|
||||
assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command"
|
||||
assert "build-release.sh" in text, "README must document the release command"
|
||||
assert "Runner requirements" in text, "README must document the runner requirements"
|
||||
assert "4096" in text, "README must document RSA4096 key generation"
|
||||
assert SIGNING_SECRET in text, "README must name the CI secret"
|
||||
assert (
|
||||
@@ -228,17 +92,19 @@ def _gitea_workflow_text() -> str:
|
||||
|
||||
|
||||
def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None:
|
||||
"""One signing path: the tag-triggered Gitea workflow runs repo.sh."""
|
||||
"""One signing path: the tag-triggered Gitea workflow runs build-release.sh."""
|
||||
github = _release_workflow_text()
|
||||
for marker in (
|
||||
"build-xbps",
|
||||
"xbps-repo",
|
||||
"XBPS_SIGNING_KEY",
|
||||
"packaging/void/repo.sh",
|
||||
"packaging/void/build-release.sh",
|
||||
):
|
||||
assert marker not in github, f"release.yml must not carry the xbps job ({marker})"
|
||||
gitea = _gitea_workflow_text()
|
||||
assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline"
|
||||
assert (
|
||||
"packaging/void/build-release.sh" in gitea
|
||||
), "the Gitea workflow must run the release path"
|
||||
assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret"
|
||||
assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman"
|
||||
workflow = yaml.safe_load(gitea)
|
||||
@@ -415,12 +281,6 @@ def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None:
|
||||
assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}"
|
||||
|
||||
|
||||
def test_workflow_publishes_only_under_the_pinned_fingerprint() -> None:
|
||||
"""A secret holding another key fails the verify step before publishing."""
|
||||
workflow = yaml.safe_load(_gitea_workflow_text())
|
||||
assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_fingerprint()
|
||||
|
||||
|
||||
def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None:
|
||||
text = README.read_text(encoding="utf-8")
|
||||
assert "repository-key.plist" in text, "README must name the committed public key"
|
||||
|
||||
@@ -7,8 +7,9 @@ using podman and the pinned container, with no host Void install.
|
||||
|
||||
Seams under test (public interfaces, not internals):
|
||||
|
||||
- ``packaging/void/smoke.sh`` — the gate command itself: exit status
|
||||
plus package/stage identification on stdout/stderr.
|
||||
- ``packaging/void/smoke.sh`` — the gate command. It runs the same release
|
||||
path as a tag (``build-release.sh smoke``), so a passing gate is a release
|
||||
that would build.
|
||||
- The ``void-smoke`` just recipe — how release work and CI call the
|
||||
gate, same shape as the other packaging gates.
|
||||
- ``packaging/void/README.md`` — the documented one-command entry.
|
||||
@@ -16,8 +17,8 @@ Seams under test (public interfaces, not internals):
|
||||
Independent sources of truth: ``packaging/void/srcpkgs/*/template``
|
||||
owns the template set (a renamed or added template must not silently
|
||||
drop out), ``packaging/void/tool_checksums.txt`` owns the pins, and
|
||||
``packaging/void/build.sh`` owns the per-package container flow the
|
||||
gate orchestrates. The gate must agree with all of them; the container
|
||||
``packaging/void/container-release.sh`` owns the install smoke each
|
||||
package must pass. The gate must agree with all of them; the container
|
||||
run itself is the proof, these tests are the wiring guard.
|
||||
"""
|
||||
|
||||
@@ -28,8 +29,8 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
VOID = REPO_ROOT / "packaging" / "void"
|
||||
SRC = VOID / "srcpkgs"
|
||||
SMOKE_SH = VOID / "smoke.sh"
|
||||
BUILD_SH = VOID / "build.sh"
|
||||
CONTAINER_MAIN = VOID / "container-main.sh"
|
||||
RELEASE_PATH = VOID / "build-release.sh"
|
||||
CONTAINER_RELEASE = VOID / "container-release.sh"
|
||||
PINS = VOID / "tool_checksums.txt"
|
||||
JUSTFILE = REPO_ROOT / "justfile"
|
||||
README = VOID / "README.md"
|
||||
@@ -66,9 +67,11 @@ def test_gate_builds_the_app_last() -> None:
|
||||
), f"the app must build last, gate order is {_gate_packages()}"
|
||||
|
||||
|
||||
def test_gate_delegates_to_the_per_package_harness() -> None:
|
||||
"""One container flow implementation; the gate only orchestrates it."""
|
||||
assert "build.sh" in _smoke_text(), "smoke.sh must call build.sh per package"
|
||||
def test_gate_delegates_to_the_release_path() -> None:
|
||||
"""One release path; the gate only selects the smoke mode, and its exit status is the path's."""
|
||||
text = _smoke_text()
|
||||
assert 'exec "$HERE/build-release.sh" smoke' in text, "smoke.sh must exec the release path"
|
||||
assert "build-release.sh" in RELEASE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_gate_stays_podman_only_with_pinned_inputs() -> None:
|
||||
@@ -80,41 +83,35 @@ def test_gate_stays_podman_only_with_pinned_inputs() -> None:
|
||||
assert PINS.is_file(), f"missing pins file: {PINS}"
|
||||
|
||||
|
||||
def test_gate_names_the_failing_package() -> None:
|
||||
"""Output identifies which package failed, alongside the stage echoes."""
|
||||
text = _smoke_text()
|
||||
assert "FAIL" in text, "the gate must report failures as FAIL"
|
||||
assert re.search(
|
||||
r"FAIL.*\$PKG|FAIL.*\$\{PKG", text
|
||||
), "the FAIL report must name the failing package"
|
||||
|
||||
|
||||
def _case_branch(label: str) -> str:
|
||||
"""Shell lines of one case branch in container-main.sh."""
|
||||
text = CONTAINER_MAIN.read_text(encoding="utf-8")
|
||||
start = text.index(f"\n{label})")
|
||||
"""Shell lines of one case branch in the install phase of container-release.sh."""
|
||||
text = CONTAINER_RELEASE.read_text(encoding="utf-8")
|
||||
start = text.index(f"\n\t\t{label})")
|
||||
end = text.index(";;", start)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
def test_harness_scopes_the_ggml_scan_to_the_binding() -> None:
|
||||
"""Only the whisper binding ships libggml; leaves must not need it."""
|
||||
assert "libggml-cpu" in _case_branch("python3-pywhispercpp")
|
||||
assert "libggml-cpu" not in _case_branch("*")
|
||||
def test_the_ggml_scan_belongs_to_the_whisper_binding_only() -> None:
|
||||
"""Only the whisper binding ships libggml; the payload check is its own."""
|
||||
assert "payload_pywhispercpp" in _case_branch("python3-pywhispercpp")
|
||||
assert "payload_voculinux" not in _case_branch("python3-pywhispercpp")
|
||||
text = CONTAINER_RELEASE.read_text(encoding="utf-8")
|
||||
assert "libggml-cpu" in text and "libggml-vulkan" in text
|
||||
|
||||
|
||||
def test_harness_smokes_the_leaf_packages() -> None:
|
||||
"""PyAudio imports headless; pynput needs a display, so prove presence."""
|
||||
assert "import pyaudio" in _case_branch("python3-PyAudio")
|
||||
branch = _case_branch("python3-pynput")
|
||||
assert "pynput" in branch
|
||||
assert "find_spec" in branch or "version(" in branch
|
||||
def test_the_leaf_packages_are_imported_headless() -> None:
|
||||
"""PyAudio and pynput are proven by import: pynput's dummy backend needs no display."""
|
||||
text = CONTAINER_RELEASE.read_text(encoding="utf-8")
|
||||
assert "import pyaudio" in text
|
||||
assert "PYNPUT_BACKEND=dummy" in text and "import pynput.keyboard" in text
|
||||
|
||||
|
||||
def test_harness_rejects_packages_without_a_smoke_recipe() -> None:
|
||||
def test_a_package_without_a_smoke_recipe_fails_the_release() -> None:
|
||||
"""A future template falls into *) and must fail loudly, never pass mute."""
|
||||
branch = _case_branch("*")
|
||||
assert "exit 1" in branch
|
||||
text = CONTAINER_RELEASE.read_text(encoding="utf-8")
|
||||
assert "no smoke recipe for package" in _case_branch("*") or (
|
||||
"no smoke recipe for package" in text
|
||||
)
|
||||
|
||||
|
||||
def test_just_recipe_calls_the_gate_like_other_packaging_gates() -> None:
|
||||
|
||||
+237
-224
@@ -1,29 +1,25 @@
|
||||
"""Offline guards for the signed xbps release workflow (ticket #29).
|
||||
"""Offline guards for the signed xbps release workflow (tickets #28 and #29).
|
||||
|
||||
Releasing the Void repository is a tag push on the fork's Gitea host. The
|
||||
workflow ``.gitea/workflows/xbps-release.yml`` gates the tag against
|
||||
``src/vocalinux/version.py``, requires the ``XBPS_SIGNING_KEY`` secret, builds
|
||||
and signs only through ``packaging/void/repo.sh`` (podman, pinned Void glibc
|
||||
container), verifies the staged repository, and publishes it to the tag's
|
||||
release and to the rolling ``latest`` release the client registration URL
|
||||
serves.
|
||||
workflow ``.gitea/workflows/xbps-release.yml`` gates the tag, stages the
|
||||
signing key on tmpfs, runs ``packaging/void/build-release.sh`` (the one release
|
||||
path), and publishes exactly what that script staged to the tag's release and
|
||||
to the rolling ``latest`` release the client registration URL serves.
|
||||
|
||||
These tests parse the workflow and then *run* its gate, verify, build and
|
||||
publish steps in a scratch directory, so they assert behaviour rather than
|
||||
spelling:
|
||||
These tests parse the workflow and then *run* its gate, key, tools, and publish
|
||||
steps in a scratch directory, so they assert behaviour rather than spelling:
|
||||
|
||||
- the shell of each step is executed with the environment the workflow
|
||||
itself declares (``${{ ... }}`` expressions are rendered from an explicit
|
||||
context, so a step wired to the wrong secret or token fails loudly);
|
||||
- ``curl`` is replaced by a small stateful stand-in for the Gitea release API
|
||||
on ``PATH``, so the publish step's requests, their order, and its refusal to
|
||||
continue past a non-2xx response are observable.
|
||||
- each step's shell runs with the environment the workflow itself declares
|
||||
(``${{ ... }}`` is rendered from an explicit context, so an unexpected
|
||||
expression fails loudly);
|
||||
- ``curl`` is replaced by a stateful stand-in for the Gitea release API on
|
||||
PATH, so the publish step's requests, their order and its refusal to continue
|
||||
past a non-2xx response are observable.
|
||||
|
||||
Independent sources of truth: the tag/version pair comes from the test (not the
|
||||
workflow), the repository layout comes from what ``xbps-rindex`` writes
|
||||
(``<pkg>.x86_64.xbps`` plus ``<pkg>.x86_64.xbps.sig2`` and ``x86_64-repodata``),
|
||||
and the Gitea REST contract (release create, asset upload and delete, tag
|
||||
lookup) comes from the Gitea API documentation.
|
||||
workflow), the staged layout is what ``build-release.sh`` writes, and the Gitea
|
||||
REST contract (release create, asset upload and delete, tag lookup) comes from
|
||||
the Gitea API documentation.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -33,7 +29,7 @@ import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import unquote
|
||||
@@ -43,21 +39,24 @@ import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
|
||||
VOID = REPO_ROOT / "packaging" / "void"
|
||||
SMOKE_SH = VOID / "smoke.sh"
|
||||
JOB = "xbps-release"
|
||||
|
||||
GATE_TAG = "Require the tag to equal the version in version.py"
|
||||
GATE_KEY = "Require the repository signing key"
|
||||
INSTALL_TOOLS = "Install the tools repo.sh and the publish step need"
|
||||
BUILD = "Build and sign the repository"
|
||||
VERIFY = "Verify the signed repository is complete"
|
||||
TOOLS = "Check the runner has the release tools"
|
||||
BUILD = "Build, sign and stage the repository"
|
||||
PUBLISH = "Publish to the tag release and the latest release"
|
||||
CLEANUP = "Remove the signing key"
|
||||
STEP_ORDER = (GATE_TAG, GATE_KEY, INSTALL_TOOLS, BUILD, VERIFY, PUBLISH, CLEANUP)
|
||||
STEP_ORDER = (GATE_TAG, GATE_KEY, TOOLS, BUILD, PUBLISH, CLEANUP)
|
||||
|
||||
API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux"
|
||||
TOKEN = "test-token-not-a-secret"
|
||||
SHA = "0123456789abcdef0123456789abcdef01234567"
|
||||
FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
|
||||
FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n"
|
||||
SDIST = "voculinux-0.18.5.tar.gz"
|
||||
|
||||
|
||||
def _text() -> str:
|
||||
@@ -104,27 +103,29 @@ def _run_step(
|
||||
context: Optional[Dict[str, str]] = None,
|
||||
extra_env: Optional[Dict[str, str]] = None,
|
||||
path_prefix: Optional[Path] = None,
|
||||
path: Optional[str] = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Execute one step's ``run`` script with the env the workflow declares."""
|
||||
ctx = {"gitea.run_id": "42", "gitea.token": TOKEN, "gitea.sha": SHA}
|
||||
ctx.update(context or {})
|
||||
step = _step(name)
|
||||
env = {k: os.environ[k] for k in ("HOME", "LANG") if k in os.environ}
|
||||
path = os.environ.get("PATH", "/usr/bin:/bin")
|
||||
env["PATH"] = f"{path_prefix}{os.pathsep}{path}" if path_prefix else path
|
||||
base = path if path is not None else os.environ.get("PATH", "/usr/bin:/bin")
|
||||
env["PATH"] = f"{path_prefix}{os.pathsep}{base}" if path_prefix else base
|
||||
for source in (_job().get("env", {}), step.get("env", {})):
|
||||
for key, value in source.items():
|
||||
env[key] = _render(str(value), ctx)
|
||||
env.update(extra_env or {})
|
||||
script = cwd / f"{re.sub(r'[^a-z]+', '-', name.lower())}.sh"
|
||||
script.write_text(step["run"], encoding="utf-8")
|
||||
bash = shutil.which("bash", path=os.environ.get("PATH", "/usr/bin:/bin")) or "/bin/bash"
|
||||
return subprocess.run(
|
||||
["bash", str(script)],
|
||||
[bash, str(script)],
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -152,7 +153,7 @@ def test_runs_on_the_project_runner_with_no_third_party_actions() -> None:
|
||||
|
||||
|
||||
def test_steps_run_in_the_gated_order() -> None:
|
||||
"""Both gates precede any container; verify precedes publish; key cleanup is last."""
|
||||
"""Both gates precede any container; the cleanup of the key is last."""
|
||||
names = [s.get("name") for s in _job()["steps"]]
|
||||
assert names[0] == "Checkout"
|
||||
assert tuple(names[1:]) == STEP_ORDER, names
|
||||
@@ -163,7 +164,12 @@ def test_signing_key_is_removed_by_an_always_step() -> None:
|
||||
assert last["name"] == CLEANUP
|
||||
assert last["if"] == "always()"
|
||||
assert "rm -f" in last["run"] and "$XBPS_KEY_FILE" in last["run"]
|
||||
assert _job()["env"]["XBPS_KEY_FILE"].startswith("/tmp/xbps-sign-key-")
|
||||
|
||||
|
||||
def test_the_key_file_lives_on_tmpfs_under_a_per_run_name() -> None:
|
||||
path = _job()["env"]["XBPS_KEY_FILE"]
|
||||
assert path.startswith("/dev/shm/xbps-sign-key-"), path
|
||||
assert "${{ gitea.run_id }}" in path
|
||||
|
||||
|
||||
def test_signing_secret_is_read_by_exactly_one_step() -> None:
|
||||
@@ -181,20 +187,33 @@ def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None:
|
||||
for step in _job()["steps"]:
|
||||
assert "${{" not in step.get("run", ""), step.get("name")
|
||||
assert _step(GATE_TAG)["env"]["TAG"] == "${{ gitea.ref_name }}"
|
||||
assert _step(BUILD)["env"]["TAG"] == "${{ gitea.ref_name }}"
|
||||
|
||||
|
||||
def test_repo_sh_is_the_only_build_and_sign_path() -> None:
|
||||
"""No step builds, indexes or signs by hand; only repo.sh does, once."""
|
||||
def test_the_build_step_is_the_release_path_with_the_key_file_only() -> None:
|
||||
"""One path: build-release.sh release, handed the key file and nothing else."""
|
||||
code = _without_comments(_text())
|
||||
assert code.count("packaging/void/repo.sh") == 1
|
||||
for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg "):
|
||||
assert forbidden not in code, f"{forbidden!r} belongs inside repo.sh, not the workflow"
|
||||
assert "repo.sh" in _run_text(BUILD)
|
||||
assert code.count("packaging/void/build-release.sh release") == 1
|
||||
run = _run_text(BUILD)
|
||||
assert '--key-file "$XBPS_KEY_FILE"' in run
|
||||
assert "--out-dir publish" in run
|
||||
for forbidden in ("xbps-src", "xbps-rindex", "openssl rsa", "xlint", "gpg ", "repo.sh"):
|
||||
assert forbidden not in code, f"{forbidden!r} belongs inside build-release.sh"
|
||||
|
||||
|
||||
def test_no_step_installs_packages_and_the_runner_must_bring_its_tools() -> None:
|
||||
"""The job checks its tools fail-fast; it does not install them with apt."""
|
||||
code = _without_comments(_text())
|
||||
assert "apt-get" not in code
|
||||
check = _run_text(TOOLS)
|
||||
for tool in ("podman", "openssl", "git", "sha256sum", "python3.11", "uv"):
|
||||
assert tool in check, f"the tool check must name {tool}"
|
||||
assert "0.12.*" in check and "0.13.*" in check, "uv must be checked against the pinned range"
|
||||
|
||||
|
||||
def test_pipeline_uses_podman_and_never_docker() -> None:
|
||||
code = _without_comments(_text()).lower()
|
||||
assert "podman" in _run_text(INSTALL_TOOLS)
|
||||
assert "podman --version" in code
|
||||
assert "docker" not in code
|
||||
|
||||
|
||||
@@ -218,18 +237,31 @@ def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate 1: the tag equals the version source of truth
|
||||
# Gate 1: the tag equals the version source of truth, and the template agrees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _version_file(root: Path, body: str) -> None:
|
||||
target = root / "src" / "vocalinux" / "version.py"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(body, encoding="utf-8")
|
||||
def _gate_tree(root: Path, version: str, template_version: str, template_tag: str) -> Path:
|
||||
"""A scratch repository with the real release script and a chosen version."""
|
||||
pkg = root / "packaging" / "void"
|
||||
pkg.mkdir(parents=True)
|
||||
for name in ("build-release.sh", "smoke.sh", "tool_checksums.txt", "repository-key.plist"):
|
||||
shutil.copy2(VOID / name, pkg / name)
|
||||
template = (VOID / "srcpkgs" / "voculinux" / "template").read_text(encoding="utf-8")
|
||||
template = re.sub(r"^version=.*$", f"version={template_version}", template, flags=re.M)
|
||||
template = re.sub(r"^_tag=.*$", f"_tag={template_tag}", template, flags=re.M)
|
||||
(pkg / "srcpkgs" / "voculinux").mkdir(parents=True)
|
||||
(pkg / "srcpkgs" / "voculinux" / "template").write_text(template, encoding="utf-8")
|
||||
(root / "src" / "vocalinux").mkdir(parents=True)
|
||||
(root / "src" / "vocalinux" / "version.py").write_text(
|
||||
f'"""Version."""\n\n__version__ = "{version}"\n', encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess:
|
||||
_version_file(tmp_path, version_py)
|
||||
def _gate_tag(tmp_path: Path, tag: str, version: str) -> subprocess.CompletedProcess:
|
||||
xbps = version.replace("-", "")
|
||||
_gate_tree(tmp_path, version, template_version=xbps, template_tag=f"v{version}")
|
||||
return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag})
|
||||
|
||||
|
||||
@@ -250,7 +282,7 @@ def _gate_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.Completed
|
||||
def test_tag_gate_requires_exact_v_plus_version(
|
||||
tmp_path: Path, tag: str, version: str, ok: bool
|
||||
) -> None:
|
||||
result = _gate_tag(tmp_path, tag, f'"""Version."""\n\n__version__ = "{version}"\n')
|
||||
result = _gate_tag(tmp_path, tag, version)
|
||||
if ok:
|
||||
assert result.returncode == 0, result.stderr
|
||||
else:
|
||||
@@ -258,17 +290,42 @@ def test_tag_gate_requires_exact_v_plus_version(
|
||||
assert "refusing to publish" in result.stderr
|
||||
|
||||
|
||||
def test_tag_gate_refuses_a_committed_template_for_another_release(tmp_path: Path) -> None:
|
||||
"""The tag may match version.py, but the template the build tree starts from must too."""
|
||||
_gate_tree(tmp_path, "0.19.0-beta", template_version="0.18.5", template_tag="v0.18.5")
|
||||
result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.19.0-beta"})
|
||||
assert result.returncode == 1
|
||||
assert "committed Void template" in result.stderr
|
||||
|
||||
|
||||
def test_tag_gate_fails_when_the_version_cannot_be_read(tmp_path: Path) -> None:
|
||||
result = _gate_tag(tmp_path, "v0.18.5", '"""Version."""\n__version_info__ = (0, 18, 5)\n')
|
||||
_gate_tree(tmp_path, "0.18.5", template_version="0.18.5", template_tag="v0.18.5")
|
||||
(tmp_path / "src" / "vocalinux" / "version.py").write_text(
|
||||
'"""Version."""\n__version_info__ = (0, 18, 5)\n', encoding="utf-8"
|
||||
)
|
||||
result = _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": "v0.18.5"})
|
||||
assert result.returncode == 1
|
||||
assert "__version__" in result.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate 2: the signing key
|
||||
# Gate 2: the signing key is staged on tmpfs, 0600, never echoed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tmpfs_dir() -> Optional[Path]:
|
||||
"""A fresh directory on /dev/shm when that is tmpfs, or None on a host without one."""
|
||||
shm = Path("/dev/shm")
|
||||
if not shm.is_dir():
|
||||
return None
|
||||
fstype = subprocess.run(
|
||||
["stat", "-f", "-c", "%T", str(shm)], capture_output=True, text=True, check=False
|
||||
).stdout.strip()
|
||||
if fstype != "tmpfs":
|
||||
return None
|
||||
return Path(tempfile.mkdtemp(prefix="xbps-key-test-", dir=str(shm)))
|
||||
|
||||
|
||||
def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None:
|
||||
key_file = tmp_path / "key.pem"
|
||||
result = _run_step(
|
||||
@@ -283,7 +340,15 @@ def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None:
|
||||
assert not key_file.exists(), "no key material may be written when the secret is empty"
|
||||
|
||||
|
||||
def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None:
|
||||
def test_a_key_file_outside_tmpfs_is_refused(tmp_path: Path) -> None:
|
||||
"""A key that would land on disk stops the job before the secret is written."""
|
||||
if (
|
||||
subprocess.run(
|
||||
["stat", "-f", "-c", "%T", str(tmp_path)], capture_output=True, text=True, check=False
|
||||
).stdout.strip()
|
||||
== "tmpfs"
|
||||
):
|
||||
pytest.skip("the test directory is itself tmpfs")
|
||||
key_file = tmp_path / "key.pem"
|
||||
result = _run_step(
|
||||
GATE_KEY,
|
||||
@@ -291,10 +356,49 @@ def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None
|
||||
{"secrets.XBPS_SIGNING_KEY": FAKE_KEY},
|
||||
{"XBPS_KEY_FILE": str(key_file)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert key_file.read_text(encoding="utf-8") == FAKE_KEY
|
||||
assert stat.S_IMODE(key_file.stat().st_mode) == 0o600
|
||||
assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed"
|
||||
assert result.returncode == 1
|
||||
assert "not tmpfs" in result.stderr
|
||||
assert not key_file.exists()
|
||||
|
||||
|
||||
def test_signing_key_is_staged_0600_with_newlines_intact(tmp_path: Path) -> None:
|
||||
shm = _tmpfs_dir()
|
||||
if shm is None:
|
||||
pytest.skip("no tmpfs at /dev/shm on this host")
|
||||
try:
|
||||
key_file = shm / "key.pem"
|
||||
result = _run_step(
|
||||
GATE_KEY,
|
||||
tmp_path,
|
||||
{"secrets.XBPS_SIGNING_KEY": FAKE_KEY},
|
||||
{"XBPS_KEY_FILE": str(key_file)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert key_file.read_text(encoding="utf-8") == FAKE_KEY
|
||||
assert stat.S_IMODE(key_file.stat().st_mode) == 0o600
|
||||
assert FAKE_KEY.splitlines()[1] not in result.stdout + result.stderr, "key was echoed"
|
||||
finally:
|
||||
shutil.rmtree(shm, ignore_errors=True)
|
||||
|
||||
|
||||
def test_an_existing_key_file_is_never_reused(tmp_path: Path) -> None:
|
||||
"""Noclobber: a file already at the key path fails the job rather than being overwritten."""
|
||||
shm = _tmpfs_dir()
|
||||
if shm is None:
|
||||
pytest.skip("no tmpfs at /dev/shm on this host")
|
||||
try:
|
||||
key_file = shm / "key.pem"
|
||||
key_file.write_text("someone else's file\n", encoding="utf-8")
|
||||
result = _run_step(
|
||||
GATE_KEY,
|
||||
tmp_path,
|
||||
{"secrets.XBPS_SIGNING_KEY": FAKE_KEY},
|
||||
{"XBPS_KEY_FILE": str(key_file)},
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert key_file.read_text(encoding="utf-8") == "someone else's file\n"
|
||||
finally:
|
||||
shutil.rmtree(shm, ignore_errors=True)
|
||||
|
||||
|
||||
def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None:
|
||||
@@ -306,155 +410,17 @@ def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build: repo.sh is handed the key file, nothing else
|
||||
# Tools: a runner without the release tools stops before any work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_step_runs_repo_sh_with_the_key_file_only(tmp_path: Path) -> None:
|
||||
"""repo.sh gets XBPS_SIGNING_KEY_FILE and never the secret itself."""
|
||||
script = tmp_path / "packaging" / "void" / "repo.sh"
|
||||
script.parent.mkdir(parents=True)
|
||||
script.write_text(
|
||||
textwrap.dedent("""\
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
[ -s "$XBPS_SIGNING_KEY_FILE" ] || exit 7
|
||||
printf '%s' "$XBPS_SIGNING_KEY_FILE" > called-with
|
||||
printf '%s' "${XBPS_SIGNING_KEY:-unset}" > secret-seen
|
||||
mkdir -p publish/xbps/repo publish/xbps/keys
|
||||
"""),
|
||||
encoding="utf-8",
|
||||
)
|
||||
key_file = tmp_path / "key.pem"
|
||||
key_file.write_text(FAKE_KEY, encoding="utf-8")
|
||||
result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)})
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (tmp_path / "called-with").read_text(encoding="utf-8") == str(key_file)
|
||||
assert (tmp_path / "secret-seen").read_text(encoding="utf-8") == "unset"
|
||||
|
||||
|
||||
def test_build_step_fails_when_repo_sh_fails(tmp_path: Path) -> None:
|
||||
"""A failing repo.sh (missing or non-RSA4096 key, build error) ends the job."""
|
||||
script = tmp_path / "packaging" / "void" / "repo.sh"
|
||||
script.parent.mkdir(parents=True)
|
||||
script.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
|
||||
result = _run_step(BUILD, tmp_path, extra_env={"XBPS_KEY_FILE": str(tmp_path / "k")})
|
||||
assert result.returncode != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify: the full signed glibc x86_64 repository, nothing else
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
|
||||
SMOKE_SH = REPO_ROOT / "packaging" / "void" / "smoke.sh"
|
||||
|
||||
|
||||
def _smoke_packages() -> List[str]:
|
||||
"""The templates the smoke gate builds, in order: the repository's package set."""
|
||||
match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
|
||||
assert match, "smoke.sh must declare its ordered PKGS list"
|
||||
return match.group(1).split()
|
||||
|
||||
|
||||
#: One built package per template, versioned the way xbps-src names them.
|
||||
PACKAGES = [f"{name}-1.0_1" for name in _smoke_packages()]
|
||||
|
||||
|
||||
def _stage(root: Path, fingerprint: str = FINGERPRINT) -> Path:
|
||||
"""Lay out publish/xbps the way repo.sh stages it, plus the smoke gate's list."""
|
||||
smoke = root / "packaging" / "void" / "smoke.sh"
|
||||
smoke.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(SMOKE_SH, smoke)
|
||||
out = root / "publish" / "xbps"
|
||||
repo, keys = out / "repo", out / "keys"
|
||||
repo.mkdir(parents=True)
|
||||
keys.mkdir()
|
||||
for pkg in PACKAGES:
|
||||
(repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode())
|
||||
(repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode())
|
||||
(repo / "x86_64-repodata").write_bytes(b"signed index")
|
||||
(keys / "FINGERPRINT").write_text(
|
||||
f"signed-by: Voculinux <xbps@voculinux.com>\nfingerprint: {fingerprint}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(keys / f"{fingerprint}.plist").write_text("<plist></plist>\n", encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
def test_the_pinned_fingerprint_is_what_the_verify_step_checks() -> None:
|
||||
assert _job()["env"]["XBPS_KEY_FINGERPRINT"] == FINGERPRINT
|
||||
|
||||
|
||||
def test_verify_accepts_the_complete_signed_repository(tmp_path: Path) -> None:
|
||||
_stage(tmp_path)
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "complete signed glibc x86_64 repository" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing",
|
||||
[
|
||||
f"repo/{PACKAGES[0]}.x86_64.xbps",
|
||||
f"repo/{PACKAGES[-1]}.x86_64.xbps",
|
||||
f"repo/{PACKAGES[0]}.x86_64.xbps.sig2",
|
||||
"repo/x86_64-repodata",
|
||||
"keys/FINGERPRINT",
|
||||
f"keys/{FINGERPRINT}.plist",
|
||||
],
|
||||
)
|
||||
def test_verify_rejects_a_repository_missing_any_class(tmp_path: Path, missing: str) -> None:
|
||||
out = _stage(tmp_path)
|
||||
(out / missing).unlink()
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
assert result.returncode == 1, f"removing {missing} must fail verification"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[
|
||||
"repo/voculinux-1.0_1.x86_64-musl.xbps",
|
||||
"repo/voculinux-1.0_1.aarch64.xbps",
|
||||
"repo/voculinux-1.0_1.noarch.xbps",
|
||||
"repo/x86_64-musl-repodata",
|
||||
"repo/aarch64-repodata",
|
||||
"repo/notes.txt",
|
||||
"keys/private.pem",
|
||||
],
|
||||
)
|
||||
def test_verify_rejects_anything_but_glibc_x86_64_data(tmp_path: Path, extra: str) -> None:
|
||||
out = _stage(tmp_path)
|
||||
(out / extra).write_bytes(b"x")
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
assert result.returncode == 1, f"{extra} must not be publishable"
|
||||
|
||||
|
||||
def test_verify_rejects_an_unsigned_package(tmp_path: Path) -> None:
|
||||
"""One signature present does not excuse a package without its own."""
|
||||
out = _stage(tmp_path)
|
||||
(out / "repo" / f"{PACKAGES[-1]}.x86_64.xbps.sig2").write_bytes(b"")
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
def test_a_runner_missing_the_release_tools_fails_fast(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "empty-bin"
|
||||
empty.mkdir()
|
||||
result = _run_step(TOOLS, tmp_path, path=str(empty))
|
||||
assert result.returncode == 1
|
||||
assert "no signature" in result.stderr
|
||||
|
||||
|
||||
def test_verify_rejects_a_signature_whose_package_is_gone(tmp_path: Path) -> None:
|
||||
out = _stage(tmp_path)
|
||||
(out / "repo" / f"{PACKAGES[0]}.x86_64.xbps.sig2").write_bytes(b"sig")
|
||||
(out / "repo" / "ghost-1.0_1.x86_64.xbps.sig2").write_bytes(b"sig")
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "without its package" in result.stderr
|
||||
|
||||
|
||||
def test_verify_rejects_a_repository_signed_by_another_key(tmp_path: Path) -> None:
|
||||
"""A throwaway or wrong key must never publish under the pinned fingerprint."""
|
||||
other = "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff"
|
||||
_stage(tmp_path, fingerprint=other)
|
||||
result = _run_step(VERIFY, tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert FINGERPRINT in result.stderr and other in result.stderr
|
||||
assert "lacks" in result.stderr and "podman" in result.stderr
|
||||
assert "packaging/void/README.md" in result.stderr, "the message names the runner requirements"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -647,8 +613,40 @@ class FakeGitea:
|
||||
)
|
||||
|
||||
|
||||
def _smoke_packages() -> List[str]:
|
||||
"""The templates the release builds, in order, from the smoke gate's list."""
|
||||
match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
|
||||
assert match, "smoke.sh must declare its ordered PKGS list"
|
||||
return match.group(1).split()
|
||||
|
||||
|
||||
def _stage(root: Path) -> Path:
|
||||
"""Lay out publish/ the way build-release.sh stages a release."""
|
||||
out = root / "publish"
|
||||
sdist = out / "sdist"
|
||||
repo = out / "xbps" / "repo"
|
||||
keys = out / "xbps" / "keys"
|
||||
for directory in (sdist, repo, keys):
|
||||
directory.mkdir(parents=True)
|
||||
(sdist / SDIST).write_bytes(b"canonical sdist bytes")
|
||||
(out / "SDIST_SHA256").write_text("a" * 64 + "\n", encoding="utf-8")
|
||||
for name in _smoke_packages():
|
||||
pkg = f"{name}-0.18.5_1"
|
||||
(repo / f"{pkg}.x86_64.xbps").write_bytes(b"package bytes " + pkg.encode())
|
||||
(repo / f"{pkg}.x86_64.xbps.sig2").write_bytes(b"signature " + pkg.encode())
|
||||
(repo / "x86_64-repodata").write_bytes(b"signed index")
|
||||
(keys / "FINGERPRINT").write_text(
|
||||
f"signed-by: Voculinux <xbps@voculinux.com>\nfingerprint: {FINGERPRINT}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(keys / f"{FINGERPRINT}.plist").write_text("<plist></plist>\n", encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
def _staged_names(out: Path) -> List[str]:
|
||||
return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir())
|
||||
return sorted(
|
||||
p.name for sub in ("sdist", "xbps/repo", "xbps/keys") for p in (out / sub).iterdir()
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -659,11 +657,12 @@ def staged(tmp_path: Path) -> Path:
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) -> None:
|
||||
def test_publish_attaches_the_sdist_and_the_repository_to_both_releases(staged: Path) -> None:
|
||||
gitea = FakeGitea(staged)
|
||||
result = gitea.run_publish(staged)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
expected = _staged_names(staged / "publish" / "xbps")
|
||||
expected = _staged_names(staged / "publish")
|
||||
assert SDIST in expected
|
||||
for tag in ("v0.18.5", "latest"):
|
||||
assert sorted(gitea.asset_names(tag)) == expected, tag
|
||||
uploaded = gitea.uploads(tag)
|
||||
@@ -672,6 +671,12 @@ def test_publish_attaches_the_full_repository_to_both_releases(staged: Path) ->
|
||||
assert FINGERPRINT in gitea.release("v0.18.5")["body"]
|
||||
|
||||
|
||||
def test_publish_states_the_sdist_digest_in_the_notes(staged: Path) -> None:
|
||||
gitea = FakeGitea(staged)
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
assert "sdist sha256: " + "a" * 64 in gitea.release("latest")["body"]
|
||||
|
||||
|
||||
def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None:
|
||||
gitea = FakeGitea(staged)
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
@@ -692,37 +697,42 @@ def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None:
|
||||
for entry in log:
|
||||
assert entry["url"].startswith(API + "/"), entry["url"]
|
||||
assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry
|
||||
# The tag release is created and filled before latest is touched.
|
||||
posts = [e["data"]["tag_name"] for e in log if e["url"] == API + "/releases"]
|
||||
assert posts == ["v0.18.5", "latest"]
|
||||
assert posts == ["v0.18.5", "latest"], "the tag release is filled before latest is touched"
|
||||
|
||||
|
||||
def test_publish_replaces_every_asset_on_an_existing_latest_release(staged: Path) -> None:
|
||||
gitea = FakeGitea(staged)
|
||||
gitea.seed_release("latest", ["voculinux-0.18.4_1.x86_64.xbps", "x86_64-repodata", "old.txt"])
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish" / "xbps")
|
||||
assert sorted(gitea.asset_names("latest")) == _staged_names(staged / "publish")
|
||||
assert len([r for r in gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1
|
||||
assert "v0.18.5" in gitea.release("latest")["body"]
|
||||
|
||||
|
||||
def test_publish_rerun_keeps_foreign_assets_on_the_tag_release(staged: Path) -> None:
|
||||
"""Rerunning a tag replaces only its own files; hand-attached assets survive."""
|
||||
def test_publish_replaces_a_same_named_asset_on_a_rerun(staged: Path) -> None:
|
||||
"""A rerun of a tag replaces its own files, the sdist included: nothing stale survives."""
|
||||
gitea = FakeGitea(staged)
|
||||
gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"])
|
||||
gitea.seed_release("v0.18.5", [SDIST, "x86_64-repodata"])
|
||||
before = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]}
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
names = gitea.asset_names("v0.18.5")
|
||||
assert "voculinux-0.18.5.tar.gz" in names
|
||||
assert names.count("x86_64-repodata") == 1
|
||||
assert set(_staged_names(staged / "publish" / "xbps")) <= set(names)
|
||||
after = {a["name"]: a["id"] for a in gitea.release("v0.18.5")["assets"]}
|
||||
assert after[SDIST] != before[SDIST], "the sdist on the tag release must be the staged one"
|
||||
assert after["x86_64-repodata"] != before["x86_64-repodata"]
|
||||
assert sorted(after) == _staged_names(staged / "publish")
|
||||
|
||||
|
||||
def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None:
|
||||
def test_publish_uploads_only_what_the_release_path_staged(staged: Path) -> None:
|
||||
"""Nothing is built or uploaded by hand: every upload lives under publish/."""
|
||||
gitea = FakeGitea(staged)
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]]
|
||||
assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls
|
||||
assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest")
|
||||
staged_names = set(_staged_names(staged / "publish"))
|
||||
for tag in ("v0.18.5", "latest"):
|
||||
assert set(gitea.uploads(tag)) == staged_names
|
||||
for entry in gitea.state["log"]:
|
||||
for form in entry["forms"]:
|
||||
path = re.match(r"attachment=@(.*?);", form).group(1)
|
||||
assert path.startswith(("sdist/", "xbps/")), f"not from the staged set: {path}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -751,14 +761,17 @@ def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path)
|
||||
assert not any(r["tag_name"] == "latest" for r in gitea.state["releases"].values())
|
||||
|
||||
|
||||
def test_publish_uploads_only_staged_files(staged: Path) -> None:
|
||||
"""Nothing is built or created by hand: uploads are exactly the staged set."""
|
||||
def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None:
|
||||
gitea = FakeGitea(staged)
|
||||
assert gitea.run_publish(staged).returncode == 0
|
||||
staged_names = set(_staged_names(staged / "publish" / "xbps"))
|
||||
for tag in ("v0.18.5", "latest"):
|
||||
assert set(gitea.uploads(tag)) == staged_names
|
||||
for entry in gitea.state["log"]:
|
||||
for form in entry["forms"]:
|
||||
path = re.match(r"attachment=@(.*?);", form).group(1)
|
||||
assert path.startswith(("repo/", "keys/")), path
|
||||
plist_urls = [e["url"] for e in gitea.state["log"] if ".plist" in e["url"]]
|
||||
assert plist_urls and all(":" not in u.split("name=")[1] for u in plist_urls), plist_urls
|
||||
assert f"{FINGERPRINT}.plist" in gitea.asset_names("latest")
|
||||
|
||||
|
||||
def test_publish_refuses_to_run_without_a_staged_sdist_digest(staged: Path) -> None:
|
||||
(staged / "publish" / "SDIST_SHA256").unlink()
|
||||
gitea = FakeGitea(staged)
|
||||
result = gitea.run_publish(staged)
|
||||
assert result.returncode != 0
|
||||
assert not gitea.state["log"], "nothing may be uploaded without the staged digest"
|
||||
|
||||
Reference in New Issue
Block a user