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:
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user