Files
voculinux/tests/test_void_publish_release.py
xavierk 731b919865
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
chore(release): prepare v0.19.0 (#53)
Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
2026-11-06 20:23:14 +00:00

577 lines
23 KiB
Python

"""Offline guards for publishing a built Void release (spec #16; packaging/void/publish-release.sh).
``publish-release.sh`` publishes an out-dir that ``build-release.sh release``
already built and verified. These tests run the real script in a scratch git
checkout whose ``origin`` is a local bare repository, so the tag gate and the
``ls-remote`` comparison are exercised for real. The Gitea API is the stateful
``curl`` stand-in from ``test_xbps_release_workflow.py`` (``FakeGitea``): every
request, its order and its authentication are recorded, and nothing leaves the
machine. The release itself is never run.
The expected values come from the documented rules (the tag must be on origin
at the built commit, the repodata goes last, the token never leaves the header
file), not from the script.
"""
import hashlib
import os
import re
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
import pytest
from test_xbps_release_workflow import FakeGitea
REPO_ROOT = Path(__file__).resolve().parents[1]
VOID = REPO_ROOT / "packaging" / "void"
BUILD_RELEASE = VOID / "build-release.sh"
PUBLISH_RELEASE = VOID / "publish-release.sh"
SMOKE_SH = VOID / "smoke.sh"
PINS = VOID / "tool_checksums.txt"
PLIST = VOID / "repository-key.plist"
TEMPLATE = VOID / "srcpkgs" / "voculinux" / "template"
TAG = "v0.19.0"
XBPS = "0.19.0"
SDIST = "voculinux-0.19.0.tar.gz"
HOST = "https://git.bongbetic.com"
API = f"{HOST}/api/v1/repos/xavierk/voculinux"
TOKEN = "gitea-token-from-the-environment"
TEA_TOKEN = "tea-token-from-the-config-file"
FINGERPRINT = "fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25"
GIT_ENV = {
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_AUTHOR_NAME": "fixture",
"GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "fixture",
"GIT_COMMITTER_EMAIL": "fixture@example.invalid",
"GIT_TERMINAL_PROMPT": "0",
}
def _git(cwd: Path, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=cwd,
env={**os.environ, **GIT_ENV},
capture_output=True,
text=True,
check=True,
timeout=60,
)
return result.stdout.strip()
def _smoke_packages() -> List[str]:
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()
class Release:
"""A scratch checkout with an origin, a built out-dir, and a fake Gitea on PATH."""
def __init__(self, root: Path) -> None:
self.root = root
self.tree = root / "checkout"
self.origin = root / "origin.git"
self.out = root / "publish"
self.home = root / "home"
self.tmp = root / "tmp"
for directory in (self.home, self.tmp):
directory.mkdir()
self._write_tree()
_git(root, "init", "-q", "--bare", str(self.origin))
_git(self.tree, "remote", "add", "origin", str(self.origin))
self.gitea = FakeGitea(root)
def _write_tree(self) -> None:
pkg = self.tree / "packaging" / "void"
pkg.mkdir(parents=True)
for source in (BUILD_RELEASE, PUBLISH_RELEASE, SMOKE_SH, PINS, PLIST):
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={XBPS}", text, flags=re.M)
text = re.sub(r"^_tag=.*$", f"_tag={TAG}", text, flags=re.M)
(pkg / "srcpkgs" / "voculinux" / "template").write_text(text, encoding="utf-8")
src = self.tree / "src" / "vocalinux"
src.mkdir(parents=True)
(src / "version.py").write_text('__version__ = "0.19.0"\n', encoding="utf-8")
_git(self.tree, "init", "-q")
_git(self.tree, "add", "-A")
_git(self.tree, "commit", "-q", "-m", "release fixture")
def commit(self, message: str) -> str:
(self.tree / "CHANGELOG.fixture").write_text(message + "\n", encoding="utf-8")
_git(self.tree, "add", "-A")
_git(self.tree, "commit", "-q", "-m", message)
return _git(self.tree, "rev-parse", "HEAD")
def head(self) -> str:
return _git(self.tree, "rev-parse", "HEAD")
def push_tag(self, tag: str = TAG, annotated: bool = True, at: Optional[str] = None) -> None:
target = at or "HEAD"
if annotated:
_git(self.tree, "tag", "-a", tag, target, "-m", f"Voculinux {tag}")
else:
_git(self.tree, "tag", tag, target)
_git(self.tree, "push", "-q", "origin", f"refs/tags/{tag}")
def stage(self, built_commit: str, tag: str = TAG) -> None:
"""Lay out publish/ the way build-release.sh writes it, for ``built_commit``."""
sdist = self.out / "sdist"
repo = self.out / "xbps" / "repo"
keys = self.out / "xbps" / "keys"
for directory in (sdist, repo, keys):
directory.mkdir(parents=True)
payload = b"canonical sdist bytes"
(sdist / SDIST).write_bytes(payload)
(self.out / "SDIST_SHA256").write_text(hashlib.sha256(payload).hexdigest() + "\n")
for name in _smoke_packages():
pkg = f"{name}-{XBPS}_1"
(repo / f"{pkg}.x86_64.xbps").write_bytes(b"package " + 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",
)
shutil.copy2(PLIST, keys / f"{FINGERPRINT}.plist")
(self.out / "BUILT_FROM").write_text(f"tag={tag}\ncommit={built_commit}\n")
def staged_names(self) -> List[str]:
return sorted(
p.name
for sub in ("sdist", "xbps/repo", "xbps/keys")
for p in (self.out / sub).iterdir()
)
def run(
self,
*args: str,
tag: str = TAG,
token: Optional[str] = TOKEN,
tea_config: Optional[Path] = None,
fail: str = "",
) -> subprocess.CompletedProcess:
env = {
"PATH": f"{self.gitea.bin}{os.pathsep}{os.environ['PATH']}",
"HOME": str(self.home),
"TMPDIR": str(self.tmp),
"GITEA_HOST": HOST,
"GITEA_API": API,
"TEA_CONFIG": str(tea_config or self.home / "no-tea-config.yml"),
"FAKE_GITEA_STATE": str(self.gitea.state_file),
"FAKE_GITEA_BASE": API,
"FAKE_GITEA_FAIL": fail,
**GIT_ENV,
}
if token is not None:
env["GITEA_TOKEN"] = token
return subprocess.run(
["bash", str(self.tree / "packaging" / "void" / "publish-release.sh"), tag, *args],
cwd=self.tree,
env=env,
capture_output=True,
text=True,
timeout=60,
check=False,
)
def publish(self, *extra: str, **kwargs) -> subprocess.CompletedProcess:
return self.run("--out-dir", str(self.out), *extra, **kwargs)
@pytest.fixture()
def release(tmp_path: Path) -> Release:
if shutil.which("git") is None or shutil.which("bash") is None:
pytest.skip("git and bash are required")
return Release(tmp_path)
def _published(release: Release) -> Release:
"""A tree whose origin holds v0.19.0 at HEAD, and a staged out-dir for HEAD."""
release.push_tag()
release.stage(release.head())
return release
def _uploads(gitea: FakeGitea, tag: str) -> List[str]:
return gitea.uploads(tag)
# ---------------------------------------------------------------------------
# The gates: nothing is published unless origin's tag is the built commit
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("annotated", [True, False], ids=["annotated", "lightweight"])
def test_a_tag_at_the_built_commit_publishes_both_releases(
release: Release, annotated: bool
) -> None:
release.push_tag(annotated=annotated)
release.stage(release.head())
result = release.publish()
assert result.returncode == 0, result.stdout + result.stderr
assert "gates passed" in result.stdout
expected = release.staged_names()
assert SDIST in expected
for tag in (TAG, "latest"):
assert sorted(release.gitea.asset_names(tag)) == expected, tag
uploaded = _uploads(release.gitea, tag)
assert sorted(uploaded) == expected, f"{tag}: {uploaded}"
assert uploaded[-1] == "x86_64-repodata", "the index goes last, after its packages"
def test_a_tag_that_is_not_on_origin_is_refused(release: Release) -> None:
release.stage(release.head())
result = release.publish()
assert result.returncode != 0
assert f"tag {TAG} is not on origin" in result.stderr
assert not release.gitea.state["log"], "no request may be made for an unpublished tag"
def test_a_tag_that_only_shares_a_prefix_with_the_release_is_refused(release: Release) -> None:
"""origin holds v0.19.0x; the release is v0.19.0. A prefix match must not pass."""
_git(release.tree, "tag", "-a", "v0.19.0x", "-m", "other", "HEAD")
_git(release.tree, "push", "-q", "origin", "refs/tags/v0.19.0x")
release.stage(release.head())
result = release.publish()
assert result.returncode != 0
assert f"tag {TAG} is not on origin" in result.stderr
assert not release.gitea.state["log"]
def test_a_tag_on_another_commit_is_refused(release: Release) -> None:
built = release.head()
release.stage(built)
later = release.commit("a commit after the build")
release.push_tag(at=later)
result = release.publish()
assert result.returncode != 0
assert f"points at {later}, but" in result.stderr
assert f"built from {built}" in result.stderr
assert not release.gitea.state["log"]
def test_an_out_dir_without_a_build_record_is_refused(release: Release) -> None:
_published(release)
(release.out / "BUILT_FROM").unlink()
result = release.publish()
assert result.returncode != 0
assert "no BUILT_FROM record" in result.stderr
assert not release.gitea.state["log"]
def test_an_out_dir_built_for_another_tag_is_refused(release: Release) -> None:
release.push_tag()
release.stage(release.head(), tag="v0.18.5")
result = release.publish()
assert result.returncode != 0
assert "was built for 'v0.18.5'" in result.stderr
assert not release.gitea.state["log"]
def test_a_sdist_that_does_not_match_its_digest_is_refused(release: Release) -> None:
_published(release)
(release.out / "sdist" / SDIST).write_bytes(b"tampered after the build")
result = release.publish()
assert result.returncode != 0
assert "does not match SDIST_SHA256" in result.stderr
assert not release.gitea.state["log"]
def test_a_client_key_other_than_the_committed_plist_is_refused(release: Release) -> None:
_published(release)
(release.out / "xbps" / "keys" / f"{FINGERPRINT}.plist").write_text("<plist/>\n")
result = release.publish()
assert result.returncode != 0
assert "not the committed packaging/void/repository-key.plist" in result.stderr
assert not release.gitea.state["log"]
def test_a_repository_without_its_index_is_refused(release: Release) -> None:
_published(release)
(release.out / "xbps" / "repo" / "x86_64-repodata").unlink()
result = release.publish()
assert result.returncode != 0
assert "has no repository index" in result.stderr
assert not release.gitea.state["log"]
def test_a_tag_that_fails_the_tag_gate_is_refused(release: Release) -> None:
"""v0.19.0-beta is not the release: the tag gate refuses it before origin is read."""
_published(release)
result = release.publish(tag="v0.19.0-beta")
assert result.returncode != 0
assert "fails the tag gate" in result.stderr
assert not release.gitea.state["log"]
def test_usage_errors_exit_2(release: Release) -> None:
"""No --out-dir is a usage error, and so is a call with no tag at all."""
no_out_dir = release.run()
assert no_out_dir.returncode == 2, no_out_dir.stderr
assert "usage: publish-release.sh" in no_out_dir.stderr
bare = subprocess.run(
["bash", str(release.tree / "packaging" / "void" / "publish-release.sh")],
capture_output=True,
text=True,
check=False,
)
assert bare.returncode == 2, bare.stderr
# ---------------------------------------------------------------------------
# Dry run: every gate, the planned calls, and no request at all
# ---------------------------------------------------------------------------
def test_a_dry_run_plans_every_call_and_makes_none(release: Release) -> None:
_published(release)
result = release.publish("--dry-run")
assert result.returncode == 0, result.stdout + result.stderr
assert "dry run" in result.stdout
assert release.gitea.state["log"] == [], "a dry run must not reach the API"
lines = result.stdout.splitlines()
tag_section = lines.index(f"tag release {TAG} (commit {release.head()}):")
latest_section = lines.index("latest release (rolling, the registration URL serves it):")
assert tag_section < latest_section
for start, end in ((tag_section, latest_section), (latest_section, len(lines))):
uploads = [line for line in lines[start:end] if "/assets?name=" in line]
assert uploads, "a section plans its uploads"
assert uploads[-1].endswith("attachment=xbps/repo/x86_64-repodata"), uploads[-1]
assert f"POST {API}/releases only when {TAG} has no release" in result.stdout
def test_a_dry_run_never_prints_or_reads_a_token(release: Release) -> None:
_published(release)
result = release.publish("--dry-run", token=TOKEN)
assert result.returncode == 0, result.stdout + result.stderr
assert TOKEN not in result.stdout + result.stderr
assert "Authorization: token <redacted>" in result.stdout
def test_a_dry_run_needs_no_token_at_all(release: Release) -> None:
_published(release)
result = release.publish("--dry-run", token=None)
assert result.returncode == 0, result.stdout + result.stderr
def test_a_dry_run_still_refuses_a_tag_that_is_not_on_origin(release: Release) -> None:
release.stage(release.head())
result = release.publish("--dry-run")
assert result.returncode != 0
assert "is not on origin" in result.stderr
assert not release.gitea.state["log"]
# ---------------------------------------------------------------------------
# The publish itself, against the stateful stand-in
# ---------------------------------------------------------------------------
def test_the_latest_release_is_created_at_the_built_commit(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
built = release.head()
latest = release.gitea.release("latest")
assert latest["target_commitish"] == built
tag_release = release.gitea.release(TAG)
assert tag_release["target_commitish"] == built
assert not tag_release["draft"] and not latest["draft"]
assert TAG in latest["body"], "latest says which tag it was built from"
assert built in latest["body"]
def test_every_request_is_to_the_fork_api_and_carries_the_token(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
log = release.gitea.state["log"]
assert log
for entry in log:
assert entry["url"].startswith(API + "/"), entry["url"]
assert entry["auth"] == [f"Authorization: token {TOKEN}"], entry
posts = [e["data"]["tag_name"] for e in log if e["url"] == f"{API}/releases"]
assert posts == [TAG, "latest"], "the tag release is filled before latest is touched"
def test_the_notes_state_the_sdist_digest_exactly(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
digest = (release.out / "SDIST_SHA256").read_text(encoding="utf-8").strip()
assert f"sdist sha256: {digest}" in release.gitea.release("latest")["body"]
assert FINGERPRINT in release.gitea.release(TAG)["body"]
def test_a_rerun_replaces_same_named_assets_on_the_tag_release(release: Release) -> None:
_published(release)
release.gitea.seed_release(TAG, [SDIST, "x86_64-repodata"])
before = {a["name"]: a["id"] for a in release.gitea.release(TAG)["assets"]}
assert release.publish().returncode == 0
after = {a["name"]: a["id"] for a in release.gitea.release(TAG)["assets"]}
assert after[SDIST] != before[SDIST]
assert after["x86_64-repodata"] != before["x86_64-repodata"]
assert sorted(after) == release.staged_names()
def test_latest_replaces_every_asset_it_already_held(release: Release) -> None:
_published(release)
release.gitea.seed_release("latest", ["voculinux-0.18.5_1.x86_64.xbps", "stale.txt"])
assert release.publish().returncode == 0
assert sorted(release.gitea.asset_names("latest")) == release.staged_names()
assert (
len([r for r in release.gitea.state["releases"].values() if r["tag_name"] == "latest"]) == 1
)
def test_uploads_come_only_from_the_staged_set(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
staged = set(release.staged_names())
for tag in (TAG, "latest"):
assert set(_uploads(release.gitea, tag)) == staged
for entry in release.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}"
def test_the_colon_in_the_fingerprint_name_is_percent_encoded(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
plist_urls = [e["url"] for e in release.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 release.gitea.asset_names("latest")
@pytest.mark.parametrize(
"fail",
[
"POST|/releases|403", # the token may not create releases
"POST|/assets?|500", # an upload fails midway
"DELETE|/assets/|500",
"GET|/releases/tags/|500",
"PATCH|/releases/|500",
],
)
def test_any_non_2xx_response_fails_the_publish(release: Release, fail: str) -> None:
_published(release)
release.gitea.seed_release("latest", ["stale"])
release.gitea.seed_release(TAG, ["x86_64-repodata"])
result = release.publish(fail=fail)
assert result.returncode != 0, f"{fail} must fail the publish"
assert "HTTP" in result.stderr or "failed" in result.stderr
def test_a_failed_tag_release_never_touches_latest(release: Release) -> None:
_published(release)
result = release.publish(fail="POST|/assets?|500")
assert result.returncode != 0
assert not any(r["tag_name"] == "latest" for r in release.gitea.state["releases"].values())
# ---------------------------------------------------------------------------
# The token: from GITEA_TOKEN or the tea login, never printed, never left behind
# ---------------------------------------------------------------------------
def _tea_config(path: Path, token: str) -> Path:
path.write_text(
"logins:\n"
" - name: other\n"
" url: https://example.invalid\n"
" token: token-for-another-host\n"
" default: true\n"
" - name: xavierk\n"
f" url: {HOST}\n"
f" token: {token}\n"
" default: false\n"
" user: xavierk\n"
"preferences:\n"
" editor: false\n",
encoding="utf-8",
)
return path
def test_the_token_comes_from_the_tea_login_for_this_host_and_is_never_printed(
release: Release,
) -> None:
_published(release)
config = _tea_config(release.root / "tea.yml", TEA_TOKEN)
result = release.publish(token=None, tea_config=config)
assert result.returncode == 0, result.stdout + result.stderr
assert TEA_TOKEN not in result.stdout + result.stderr
for entry in release.gitea.state["log"]:
assert entry["auth"] == [f"Authorization: token {TEA_TOKEN}"], entry
def test_no_token_and_no_tea_login_refuses_before_any_request(release: Release) -> None:
_published(release)
config = _tea_config(release.root / "tea.yml", TEA_TOKEN)
other = release.root / "other-host.yml"
other.write_text(config.read_text(encoding="utf-8").replace(HOST, "https://elsewhere.invalid"))
result = release.publish(token=None, tea_config=other)
assert result.returncode != 0
assert f"no tea login for {HOST}" in result.stderr
assert not release.gitea.state["log"]
def test_the_header_file_is_removed_when_the_publish_ends(release: Release) -> None:
_published(release)
assert release.publish().returncode == 0
assert list(release.tmp.glob("publish-release.*")) == []
def test_a_failed_publish_also_removes_the_header_file(release: Release) -> None:
_published(release)
assert release.publish(fail="POST|/releases|403").returncode != 0
assert list(release.tmp.glob("publish-release.*")) == []
def test_the_token_never_appears_in_the_output_of_a_failed_request(release: Release) -> None:
_published(release)
result = release.publish(fail="POST|/releases|403")
assert TOKEN not in result.stdout + result.stderr
# ---------------------------------------------------------------------------
# The script itself
# ---------------------------------------------------------------------------
def test_the_script_is_executable_bash_that_fails_closed() -> None:
text = PUBLISH_RELEASE.read_text(encoding="utf-8")
assert text.startswith("#!/usr/bin/env bash\n")
assert "set -euo pipefail" in text
assert os.access(PUBLISH_RELEASE, os.X_OK)
def test_the_script_never_enables_tracing_or_echoes_the_header() -> None:
code = "\n".join(
line
for line in PUBLISH_RELEASE.read_text(encoding="utf-8").splitlines()
if not line.lstrip().startswith("#")
)
assert "set -x" not in code
assert 'cat "$AUTH"' not in code
assert 'echo "$GITEA_TOKEN"' not in code and "printf '%s' \"$GITEA_TOKEN\"" not in code
def test_the_workflow_publish_step_and_this_script_name_the_same_api() -> None:
"""Two copies of the publish logic: they must publish to the same place."""
workflow = (REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml").read_text(encoding="utf-8")
assert "GITEA_API: https://git.bongbetic.com/api/v1/repos/xavierk/voculinux" in workflow
assert "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux" in PUBLISH_RELEASE.read_text(
encoding="utf-8"
)
script = PUBLISH_RELEASE.read_text(encoding="utf-8")
assert "xbps/repo/*-repodata*" in workflow and "xbps/repo/*-repodata*" in script