"""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 \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("\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 " 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