feat(ci): add the signed xbps release workflow on Gitea

A version tag push builds every Void template through packaging/void/repo.sh
(podman, pinned Void glibc container), signs the repository, and publishes
the complete glibc x86_64 set to the tag's release and to the rolling latest
release that the client registration URL serves.

Gates, each failing the job before anything publishes: the tag must equal
v plus __version__ exactly, XBPS_SIGNING_KEY must be set, the staged
repository must hold every class of file and nothing but glibc x86_64
data, every package must carry its signature, and the signing key must have
the pinned fingerprint. The key lives in a 0600 temp file that an
always-run step removes. Nothing commits, pushes, or bumps templates.

The offline tests run the workflow's own gate, verify, build and publish
scripts against a stateful stand-in for the Gitea release API.
This commit is contained in:
2026-11-06 18:23:03 +05:30
parent df34293f27
commit 6609cfa6d9
2 changed files with 1090 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
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:
#
# - 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
#
# 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.
on:
push:
tags:
- "v*"
permissions:
contents: write
concurrency:
group: xbps-release
cancel-in-progress: false
jobs:
xbps-release:
name: Build, sign and publish the xbps repository
runs-on: bongbetic-ci
timeout-minutes: 180
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
steps:
- name: Checkout
env:
GITEA_TOKEN: ${{ gitea.token }}
SERVER_URL: ${{ gitea.server_url }}
REPOSITORY: ${{ gitea.repository }}
COMMIT_SHA: ${{ gitea.sha }}
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
- 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
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.
- name: Require the repository signing key
shell: bash
env:
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${XBPS_SIGNING_KEY:-}" ]; then
echo "::error::XBPS_SIGNING_KEY is unset or empty; refusing to publish an unsigned repository (packaging/void/README.md)" >&2
exit 1
fi
umask 077
rm -f "$XBPS_KEY_FILE"
printf '%s' "$XBPS_SIGNING_KEY" > "$XBPS_KEY_FILE"
chmod 600 "$XBPS_KEY_FILE"
- name: Install the tools repo.sh and the publish step need
shell: bash
run: |
set -euo pipefail
missing=""
for tool in podman openssl curl python3; 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
fi
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
shell: bash
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}"
# 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
# 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
shell: bash
env:
GITEA_TOKEN: ${{ gitea.token }}
TAG: ${{ gitea.ref_name }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -euo pipefail
cd publish/xbps
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
fail() { echo "::error::$*" >&2; exit 1; }
urlencode() {
local s="$1" out="" c i
for ((i = 0; i < ${#s}; i++)); do
c="${s:i:1}"
case "$c" in
[a-zA-Z0-9._~-]) out+="$c" ;;
*) out+=$(printf '%%%02X' "'$c") ;;
esac
done
printf '%s' "$out"
}
# call METHOD URL [curl args...]: response body lands in $BODY.
call() {
local method="$1" url="$2" code
shift 2
code=$(curl -sS -o "$BODY" -w '%{http_code}' -X "$method" \
-H "Authorization: token ${GITEA_TOKEN}" "$@" "$url") \
|| fail "request failed: ${method} ${url}"
case "$code" in
2[0-9][0-9]) ;;
*) cat "$BODY" >&2; echo >&2; fail "${method} ${url} returned HTTP ${code}" ;;
esac
}
json_field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$BODY" "$1"; }
release_payload() {
python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": sys.argv[3], "body": sys.argv[4], "draft": False, "prerelease": False}))' "$@"
}
# release_id TAG: sets REL_ID to the release id, or to "" when the
# tag has no release. These helpers set a variable instead of
# printing, because bash clears `set -e` inside $(...): a failure in
# a nested substitution would otherwise be swallowed.
release_id() {
local code
REL_ID=""
code=$(curl -sS -o "$BODY" -w '%{http_code}' \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/releases/tags/$(urlencode "$1")") \
|| fail "request failed: GET releases/tags/$1"
case "$code" in
200) REL_ID=$(json_field id) ;;
404) ;;
*) cat "$BODY" >&2; echo >&2; fail "GET releases/tags/$1 returned HTTP ${code}" ;;
esac
}
# ensure_release TAG NAME NOTES: sets REL_ID, creating the release
# when the tag has none.
ensure_release() {
local payload
release_id "$1"
if [ -z "$REL_ID" ]; then
payload=$(release_payload "$1" "$COMMIT_SHA" "$2" "$3")
call POST "${GITEA_API}/releases" -H 'Content-Type: application/json' -d "$payload"
REL_ID=$(json_field id)
fi
}
# delete_assets ID MODE: MODE is "all", or "same" to remove only the
# assets whose names this run uploads again (a rerun on a tag release).
delete_assets() {
local listing aid aname
call GET "${GITEA_API}/releases/$1/assets"
listing=$(python3 -c 'import json,sys; [print(a["id"], a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY")
[ -n "$listing" ] || return 0
while read -r aid aname; do
if [ "$2" = "all" ] || printf '%s\n' "$NAMES" | grep -qxF -- "$aname"; then
call DELETE "${GITEA_API}/releases/$1/assets/${aid}"
echo "deleted: ${aname}"
fi
done <<< "$listing"
}
upload_assets() {
local f name
for f in "${FILES[@]}"; do
name=$(basename "$f")
call POST "${GITEA_API}/releases/$1/assets?name=$(urlencode "$name")" \
-F "attachment=@${f};filename=${name}"
echo "uploaded: ${name}"
done
}
# verify_assets ID: every file we uploaded is on the release.
verify_assets() {
local have
call GET "${GITEA_API}/releases/$1/assets"
have=$(python3 -c 'import json,sys; [print(a["name"]) for a in json.load(open(sys.argv[1]))]' "$BODY" | sort)
[ "$(printf '%s\n' "$NAMES" | sort | comm -23 - <(printf '%s\n' "$have") | wc -l)" -eq 0 ] \
|| 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]++')
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)")
echo "== tag release ${TAG}"
ensure_release "$TAG" "$TAG" "$NOTES"
TAG_ID=$REL_ID
delete_assets "$TAG_ID" same
upload_assets "$TAG_ID"
verify_assets "$TAG_ID"
# The rolling release the registration URL points at. The tag
# created with it stays where it was first made; the assets are the
# source of truth for what clients download.
echo "== latest release"
LATEST_NOTES=$(printf '%s\n\n%s\n' \
"Rolling signed xbps repository, currently built from ${TAG} (${COMMIT_SHA})." \
"$NOTES")
ensure_release latest latest "$LATEST_NOTES"
LATEST_ID=$REL_ID
PATCH_PAYLOAD=$(python3 -c 'import json,sys; print(json.dumps({"name": sys.argv[1], "body": sys.argv[2]}))' latest "$LATEST_NOTES")
call PATCH "${GITEA_API}/releases/${LATEST_ID}" -H 'Content-Type: application/json' -d "$PATCH_PAYLOAD"
delete_assets "$LATEST_ID" all
upload_assets "$LATEST_ID"
verify_assets "$LATEST_ID"
echo "OK: signed repository published to ${TAG} and latest"
- name: Remove the signing key
if: always()
shell: bash
run: rm -f "$XBPS_KEY_FILE"
+764
View File
@@ -0,0 +1,764 @@
"""Offline guards for the signed xbps release workflow (ticket #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.
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:
- 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.
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.
"""
import json
import os
import re
import shutil
import stat
import subprocess
import sys
import textwrap
from pathlib import Path
from typing import Dict, List, Optional
from urllib.parse import unquote
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
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"
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)
API = "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux"
TOKEN = "test-token-not-a-secret"
SHA = "0123456789abcdef0123456789abcdef01234567"
FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nline-one\nline-two\n-----END RSA PRIVATE KEY-----\n"
def _text() -> str:
assert WORKFLOW.is_file(), f"missing signed xbps release workflow: {WORKFLOW}"
return WORKFLOW.read_text(encoding="utf-8")
def _workflow() -> dict:
return yaml.safe_load(_text())
def _job() -> dict:
return _workflow()["jobs"][JOB]
def _step(name: str) -> dict:
matches = [s for s in _job()["steps"] if s.get("name") == name]
assert len(matches) == 1, f"workflow must have exactly one step named {name!r}"
return matches[0]
def _run_text(name: str) -> str:
return _step(name)["run"]
def _without_comments(text: str) -> str:
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _render(value: str, context: Dict[str, str]) -> str:
"""Render ``${{ expr }}`` from an explicit context; an unknown one is a bug."""
def substitute(match: "re.Match[str]") -> str:
expression = match.group(1)
assert expression in context, f"workflow uses an unexpected expression: {expression}"
return context[expression]
return re.sub(r"\$\{\{\s*([\w.]+)\s*\}\}", substitute, value)
def _run_step(
name: str,
cwd: Path,
context: Optional[Dict[str, str]] = None,
extra_env: Optional[Dict[str, str]] = None,
path_prefix: Optional[Path] = 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
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")
return subprocess.run(
["bash", str(script)],
cwd=cwd,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)
# ---------------------------------------------------------------------------
# Workflow shape
# ---------------------------------------------------------------------------
def test_triggers_only_on_version_tag_pushes() -> None:
"""A tag push is a human act: no schedule, no dispatch, no PR trigger."""
triggers = _workflow()[True] # YAML 1.1 reads the bare key `on` as True
assert triggers == {"push": {"tags": ["v*"]}}, triggers
def test_grants_only_contents_write() -> None:
assert _workflow()["permissions"] == {"contents": "write"}
def test_runs_on_the_project_runner_with_no_third_party_actions() -> None:
"""Checkout is the git fetch the repo's own CI uses; nothing floats on a tag."""
assert _job()["runs-on"] == "bongbetic-ci"
uses = [s["uses"] for s in _job()["steps"] if "uses" in s]
assert not uses, f"the signing job must not run third-party actions: {uses}"
def test_steps_run_in_the_gated_order() -> None:
"""Both gates precede any container; verify precedes publish; key cleanup is last."""
names = [s.get("name") for s in _job()["steps"]]
assert names[0] == "Checkout"
assert tuple(names[1:]) == STEP_ORDER, names
def test_signing_key_is_removed_by_an_always_step() -> None:
last = _job()["steps"][-1]
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_signing_secret_is_read_by_exactly_one_step() -> None:
"""The secret is exposed to the gate that stages it, never to the build."""
text = _text()
assert text.count("secrets.XBPS_SIGNING_KEY") == 1
assert (
_step(GATE_KEY)["env"]["XBPS_SIGNING_KEY"] == "${{ secrets.XBPS_SIGNING_KEY }}"
), "the key gate must read the XBPS_SIGNING_KEY secret"
assert "XBPS_SIGNING_KEY" not in _step(BUILD).get("env", {})
def test_the_tag_reaches_scripts_through_env_never_interpolated() -> None:
"""`${{ gitea.ref_name }}` inside a run script is shell injection by tag name."""
for step in _job()["steps"]:
assert "${{" not in step.get("run", ""), step.get("name")
assert _step(GATE_TAG)["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."""
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)
def test_pipeline_uses_podman_and_never_docker() -> None:
code = _without_comments(_text()).lower()
assert "podman" in _run_text(INSTALL_TOOLS)
assert "docker" not in code
def test_workflow_never_commits_pushes_or_bumps_templates() -> None:
"""Template bumps are a normal pull request, never a bot commit."""
code = _without_comments(_text())
for forbidden in ("git commit", "git push", "git tag", "git add", "srcpkgs", "gh pr"):
assert forbidden not in code, f"the workflow must not run {forbidden!r}"
def test_workflow_text_names_no_assistant() -> None:
assert not re.search(r"claude|anthropic", _text(), re.IGNORECASE)
def test_publish_goes_to_the_fork_gitea_api_with_the_job_token() -> None:
assert _job()["env"]["GITEA_API"] == API
env = _step(PUBLISH)["env"]
assert env["GITEA_TOKEN"] == "${{ gitea.token }}"
assert "Authorization: token ${GITEA_TOKEN}" in _run_text(PUBLISH)
assert "gh release" not in _text(), "GitHub CLI cannot reach the fork's Gitea releases"
# ---------------------------------------------------------------------------
# Gate 1: the tag equals the version source of truth
# ---------------------------------------------------------------------------
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_tag(tmp_path: Path, tag: str, version_py: str) -> subprocess.CompletedProcess:
_version_file(tmp_path, version_py)
return _run_step(GATE_TAG, tmp_path, {"gitea.ref_name": tag})
@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), # tag contains the version: loose match would pass
("v0.18", "0.18.5", False), # version contains the tag: loose match would pass
("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_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')
if ok:
assert result.returncode == 0, result.stderr
else:
assert result.returncode == 1, f"{tag} vs {version} must exit 1: {result.stdout}"
assert "refusing to publish" 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')
assert result.returncode == 1
assert "__version__" in result.stderr
# ---------------------------------------------------------------------------
# Gate 2: the signing key
# ---------------------------------------------------------------------------
def test_unset_signing_key_exits_1_and_writes_nothing(tmp_path: Path) -> None:
key_file = tmp_path / "key.pem"
result = _run_step(
GATE_KEY,
tmp_path,
{"secrets.XBPS_SIGNING_KEY": ""},
{"XBPS_KEY_FILE": str(key_file)},
)
assert result.returncode == 1
assert "XBPS_SIGNING_KEY" in result.stderr
assert "unsigned" in result.stderr
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:
key_file = tmp_path / "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"
def test_cleanup_step_removes_the_key_file(tmp_path: Path) -> None:
key_file = tmp_path / "key.pem"
key_file.write_text(FAKE_KEY, encoding="utf-8")
result = _run_step(CLEANUP, tmp_path, extra_env={"XBPS_KEY_FILE": str(key_file)})
assert result.returncode == 0, result.stderr
assert not key_file.exists()
# ---------------------------------------------------------------------------
# Build: repo.sh is handed the key file, nothing else
# ---------------------------------------------------------------------------
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)
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
# ---------------------------------------------------------------------------
# Publish: tag release plus rolling latest release, through a fake Gitea API
# ---------------------------------------------------------------------------
FAKE_CURL = '''#!{python}
"""Stateful stand-in for curl against the Gitea release API."""
import json
import os
import re
import sys
from urllib.parse import unquote, urlsplit
STATE = os.environ["FAKE_GITEA_STATE"]
BASE = os.environ["FAKE_GITEA_BASE"]
def load():
with open(STATE, encoding="utf-8") as handle:
return json.load(handle)
def save(state):
with open(STATE, "w", encoding="utf-8") as handle:
json.dump(state, handle)
def main(argv):
out = method = data = url = None
headers, forms = [], []
i = 0
while i < len(argv):
arg = argv[i]
if arg == "-o":
out, i = argv[i + 1], i + 2
elif arg == "-w":
i += 2
elif arg == "-X":
method, i = argv[i + 1], i + 2
elif arg == "-H":
headers.append(argv[i + 1])
i += 2
elif arg == "-d":
data, i = argv[i + 1], i + 2
elif arg == "-F":
forms.append(argv[i + 1])
i += 2
elif arg.startswith("-"):
i += 1
else:
url, i = arg, i + 1
method = method or "GET"
state = load()
entry = {{
"method": method,
"url": url,
"auth": [h for h in headers if h.lower().startswith("authorization:")],
"data": json.loads(data) if data else None,
"forms": forms,
}}
state["log"].append(entry)
status, body = route(state, method, url, data, forms)
fail = os.environ.get("FAKE_GITEA_FAIL", "")
if fail:
fmethod, fneedle, fcode = fail.split("|")
if method == fmethod and fneedle in url:
status, body = int(fcode), {{"message": "injected failure"}}
save(state)
if out:
with open(out, "w", encoding="utf-8") as handle:
handle.write(json.dumps(body) if body is not None else "")
sys.stdout.write(str(status))
return 0
def route(state, method, url, data, forms):
if not url.startswith(BASE):
return 599, {{"message": "unexpected host " + url}}
path = urlsplit(url[len(BASE):]).path
query = urlsplit(url).query
releases = state["releases"]
m = re.fullmatch(r"/releases/tags/(.+)", path)
if m and method == "GET":
tag = unquote(m.group(1))
for rel in releases.values():
if rel["tag_name"] == tag:
return 200, rel
return 404, {{"message": "not found"}}
if path == "/releases" and method == "POST":
payload = json.loads(data)
rid = state["next_id"]
state["next_id"] += 1
rel = dict(payload, id=rid, assets=[])
releases[str(rid)] = rel
return 201, rel
m = re.fullmatch(r"/releases/(\\d+)", path)
if m and method == "PATCH":
rel = releases[m.group(1)]
rel.update(json.loads(data))
return 200, rel
m = re.fullmatch(r"/releases/(\\d+)/assets", path)
if m and method == "GET":
return 200, releases[m.group(1)]["assets"]
if m and method == "POST":
rel = releases[m.group(1)]
name = unquote(re.search(r"(?:^|&)name=([^&]*)", query).group(1))
if any(a["name"] == name for a in rel["assets"]):
return 409, {{"message": "asset already exists"}}
source = re.match(r"attachment=@(.*?);filename=(.*)$", forms[0])
size = os.path.getsize(source.group(1))
asset = {{"id": state["next_id"], "name": name, "size": size}}
state["next_id"] += 1
rel["assets"].append(asset)
return 201, asset
m = re.fullmatch(r"/releases/(\\d+)/assets/(\\d+)", path)
if m and method == "DELETE":
rel = releases[m.group(1)]
rel["assets"] = [a for a in rel["assets"] if str(a["id"]) != m.group(2)]
return 204, None
return 404, {{"message": "no such route " + method + " " + path}}
sys.exit(main(sys.argv[1:]))
'''
class FakeGitea:
"""A stateful curl stand-in on PATH plus helpers to read what it recorded."""
def __init__(self, root: Path) -> None:
self.bin = root / "fakebin"
self.bin.mkdir()
self.state_file = root / "gitea-state.json"
curl = self.bin / "curl"
curl.write_text(FAKE_CURL.format(python=sys.executable), encoding="utf-8")
curl.chmod(0o755)
self.state_file.write_text(
json.dumps({"next_id": 100, "releases": {}, "log": []}), encoding="utf-8"
)
@property
def state(self) -> dict:
return json.loads(self.state_file.read_text(encoding="utf-8"))
def seed_release(self, tag: str, assets: List[str]) -> None:
state = self.state
rid = state["next_id"]
state["next_id"] += 1
state["releases"][str(rid)] = {
"id": rid,
"tag_name": tag,
"name": tag,
"body": "old",
"assets": [{"id": rid * 1000 + n, "name": a, "size": 1} for n, a in enumerate(assets)],
}
self.state_file.write_text(json.dumps(state), encoding="utf-8")
def release(self, tag: str) -> dict:
for rel in self.state["releases"].values():
if rel["tag_name"] == tag:
return rel
raise AssertionError(f"no release for tag {tag!r}")
def asset_names(self, tag: str) -> List[str]:
return [a["name"] for a in self.release(tag)["assets"]]
def uploads(self, tag: str) -> List[str]:
"""Asset names POSTed to this tag's release, in upload order."""
prefix = f"{API}/releases/{self.release(tag)['id']}/assets?name="
return [
unquote(e["url"][len(prefix) :])
for e in self.state["log"]
if e["method"] == "POST" and e["url"].startswith(prefix)
]
def run_publish(
self, root: Path, tag: str = "v0.18.5", fail: str = ""
) -> subprocess.CompletedProcess:
return _run_step(
PUBLISH,
root,
{"gitea.ref_name": tag},
{
"FAKE_GITEA_STATE": str(self.state_file),
"FAKE_GITEA_BASE": API,
"FAKE_GITEA_FAIL": fail,
},
path_prefix=self.bin,
)
def _staged_names(out: Path) -> List[str]:
return sorted(p.name for sub in ("repo", "keys") for p in (out / sub).iterdir())
@pytest.fixture()
def staged(tmp_path: Path) -> Path:
if shutil.which("python3") is None:
pytest.skip("python3 is required by the publish step")
_stage(tmp_path)
return tmp_path
def test_publish_attaches_the_full_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")
for tag in ("v0.18.5", "latest"):
assert sorted(gitea.asset_names(tag)) == expected, tag
uploaded = gitea.uploads(tag)
assert sorted(uploaded) == expected, f"{tag}: uploaded {uploaded}"
assert uploaded[-1] == "x86_64-repodata", "the index goes last, after its packages"
assert FINGERPRINT in gitea.release("v0.18.5")["body"]
def test_publish_creates_latest_at_the_workflow_commit(staged: Path) -> None:
gitea = FakeGitea(staged)
assert gitea.run_publish(staged).returncode == 0
latest = gitea.release("latest")
assert latest["target_commitish"] == SHA
assert latest["tag_name"] == "latest"
tag_release = gitea.release("v0.18.5")
assert tag_release["target_commitish"] == SHA
assert not tag_release["draft"] and not latest["draft"]
assert "v0.18.5" in latest["body"], "latest says which tag it was built from"
def test_publish_talks_only_to_the_fork_api_authenticated(staged: Path) -> None:
gitea = FakeGitea(staged)
assert gitea.run_publish(staged).returncode == 0
log = gitea.state["log"]
assert log, "the publish step made no API calls"
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"]
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 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."""
gitea = FakeGitea(staged)
gitea.seed_release("v0.18.5", ["x86_64-repodata", "voculinux-0.18.5.tar.gz"])
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)
def test_publish_percent_encodes_the_colon_fingerprint_asset_name(staged: Path) -> None:
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")
@pytest.mark.parametrize(
"fail",
[
"POST|/releases|403", # token may not create releases
"POST|/assets?|500", # an upload fails midway
"DELETE|/assets/|500",
"GET|/releases/tags/|500",
"PATCH|/releases/|500",
],
)
def test_publish_fails_the_job_on_any_non_2xx_response(staged: Path, fail: str) -> None:
gitea = FakeGitea(staged)
gitea.seed_release("latest", ["stale"])
gitea.seed_release("v0.18.5", ["x86_64-repodata"])
result = gitea.run_publish(staged, fail=fail)
assert result.returncode != 0, f"{fail} must fail the job"
assert "HTTP" in result.stderr or "failed" in result.stderr
def test_publish_does_not_touch_latest_when_the_tag_release_fails(staged: Path) -> None:
gitea = FakeGitea(staged)
result = gitea.run_publish(staged, fail="POST|/assets?|500")
assert result.returncode != 0
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."""
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