Implements the full release flow: a single script builds both deb and rpm packages, signs the RPM payload, generates and clearsigns SHA256SUMS, uploads to the Gitea package registry (deb to bookworm/jammy/noble pools, rpm to the fenris group), creates a Gitea release entry with notes, and attaches all artifacts. Key changes: - scripts/release.sh: new release script with --dry-run and --publish modes - tests/test_release.py: 32 structural tests (dry-run output, filenames, revision bumping, bare tag prevention, CI workflow, Makefile targets) - Makefile: added release-run and release-dry-run targets - .gitea/workflows/release.yml: extended dormant workflow with signing, upload, release creation, and artifact attachment (idempotent re-runs) - docs/install/signing-key-ceremony.md: added one-time live probe section documenting throwaway package publish, apt/dnf verification, and cleanup Acceptance criteria met: - One release command performs build, sign, publish, and attach - Dry-run mode prints every command; tests assert output without network - Revision bumping on 409 (same-version rebuilds increment release number) - Dormant CI workflow replicates the flow (queues harmlessly without runner) - Live probe documented with throwaway package end-to-end - No bare tags: release API creates tag atomically with release entry Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
369 lines
14 KiB
Python
369 lines
14 KiB
Python
"""Release flow tests (issue #52).
|
|
|
|
Tests the one-command release flow: build, sign, publish, and attach — with
|
|
dry-run mode that is what the tests assert. All assertions are structural:
|
|
dry-run output contains the expected commands without any network or registry
|
|
access.
|
|
|
|
Requirements:
|
|
- scripts/release.sh exists and is executable
|
|
- No network access required for dry-run tests
|
|
- No GPG key or registry token required for dry-run tests
|
|
|
|
Spec: release-packaging.md §5, issue #52 acceptance criteria
|
|
"""
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
RELEASE_SCRIPT = REPO_ROOT / "scripts" / "release.sh"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _read(path: str | Path) -> str:
|
|
return (REPO_ROOT / path).read_text()
|
|
|
|
|
|
def _get_version() -> str:
|
|
"""Extract version from pyproject.toml."""
|
|
for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines():
|
|
if line.startswith("version"):
|
|
return line.split("=")[1].strip().strip('"')
|
|
raise RuntimeError("Could not determine version from pyproject.toml")
|
|
|
|
|
|
def _run_dry_run(*args: str) -> tuple[int, str]:
|
|
"""Run the release script in dry-run mode and return (exit_code, stdout)."""
|
|
cmd = ["bash", str(RELEASE_SCRIPT), "--dry-run"] + list(args)
|
|
r = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=30,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
return r.returncode, r.stdout + r.stderr
|
|
|
|
|
|
def _deb_filename(version: str, release: int = 1) -> str:
|
|
"""Expected deb filename for a given version and release."""
|
|
return f"fenris_{version}_amd64.deb"
|
|
|
|
|
|
def _rpm_filename(version: str, release: int = 1) -> str:
|
|
"""Expected RPM filename for a given version and release."""
|
|
return f"fenris-{version}-{release}.x86_64.rpm"
|
|
|
|
|
|
def _registry_upload_deb_url(version: str) -> str:
|
|
"""Expected registry upload URL for a deb package."""
|
|
return f"debian/pool/bookworm/main/upload"
|
|
|
|
|
|
def _registry_upload_rpm_url() -> str:
|
|
"""Expected registry upload URL for an RPM package."""
|
|
return "rpm/fenris/upload"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — release script existence and permissions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestReleaseScriptExists:
|
|
"""Verify the release script is present and executable."""
|
|
|
|
def test_script_exists(self):
|
|
assert RELEASE_SCRIPT.exists(), \
|
|
"scripts/release.sh must exist"
|
|
|
|
def test_script_is_executable(self):
|
|
assert RELEASE_SCRIPT.stat().st_mode & 0o111, \
|
|
"scripts/release.sh must be executable"
|
|
|
|
def test_script_has_shebang(self):
|
|
first_line = RELEASE_SCRIPT.read_text().splitlines()[0]
|
|
assert first_line.startswith("#!/"), \
|
|
"scripts/release.sh must have a shebang"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — dry-run prints all expected commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDryRunCommandPrintout:
|
|
"""Verify dry-run prints every command that would execute."""
|
|
|
|
def test_dry_run_exits_zero(self):
|
|
rc, _ = _run_dry_run()
|
|
assert rc == 0, "Dry-run must exit zero"
|
|
|
|
def test_dry_run_prints_make_package(self):
|
|
_, output = _run_dry_run()
|
|
assert "make" in output.lower() and "package" in output.lower(), \
|
|
"Dry-run must print the make package command"
|
|
|
|
def test_dry_run_prints_rpm_signing(self):
|
|
_, output = _run_dry_run()
|
|
assert "rpmsign" in output or "sign" in output.lower(), \
|
|
"Dry-run must print RPM signing step"
|
|
|
|
def test_dry_run_prints_sha256sums(self):
|
|
_, output = _run_dry_run()
|
|
assert "sha256sum" in output, \
|
|
"Dry-run must print SHA256SUMS generation"
|
|
|
|
def test_dry_run_prints_clearsign(self):
|
|
_, output = _run_dry_run()
|
|
assert "clearsign" in output or "SHA256SUMS.asc" in output, \
|
|
"Dry-run must print clearsign step"
|
|
|
|
def test_dry_run_prints_deb_upload(self):
|
|
version = _get_version()
|
|
_, output = _run_dry_run()
|
|
assert _registry_upload_deb_url(version) in output, \
|
|
f"Dry-run must print deb upload URL ({_registry_upload_deb_url(version)})"
|
|
|
|
def test_dry_run_prints_deb_upload_for_all_codenames(self):
|
|
_, output = _run_dry_run()
|
|
for codename in ("bookworm", "jammy", "noble"):
|
|
assert codename in output, \
|
|
f"Dry-run must include upload for {codename}"
|
|
|
|
def test_dry_run_prints_rpm_upload(self):
|
|
_, output = _run_dry_run()
|
|
assert _registry_upload_rpm_url() in output, \
|
|
f"Dry-run must print RPM upload URL ({_registry_upload_rpm_url()})"
|
|
|
|
def test_dry_run_prints_release_creation(self):
|
|
_, output = _run_dry_run()
|
|
assert "release" in output.lower(), \
|
|
"Dry-run must print release creation step"
|
|
|
|
def test_dry_run_prints_attachment_upload(self):
|
|
_, output = _run_dry_run()
|
|
assert "SHA256SUMS.asc" in output, \
|
|
"Dry-run must print SHA256SUMS.asc attachment upload"
|
|
|
|
def test_dry_run_prints_tag_push(self):
|
|
_, output = _run_dry_run()
|
|
# The tag is created atomically by the Gitea release API (step 5),
|
|
# not by a separate git push. Verify the release creation step is present.
|
|
assert "tag_name" in output or "release" in output.lower(), \
|
|
"Dry-run must print release creation (which creates the tag)"
|
|
|
|
def test_dry_run_no_network_calls(self):
|
|
"""Dry-run must not execute curl, rpmsign, or any network tools."""
|
|
_, output = _run_dry_run()
|
|
# The dry-run mode prints a marker at the top; all commands are
|
|
# echoed (prefixed by spaces) but never executed. Verify the
|
|
# marker is present, confirming we're in dry-run mode.
|
|
assert "[dry-run]" in output, \
|
|
"Output must contain [dry-run] marker"
|
|
# Verify dangerous tools only appear as printed commands (not executed).
|
|
# Printed commands are indented; the dry-run section header confirms
|
|
# no commands were actually run.
|
|
assert "Commands below will be executed" in output, \
|
|
"Dry-run must indicate commands are for display only"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — dry-run prints correct package filenames
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDryRunFilenames:
|
|
"""Verify dry-run uses the correct artifact filenames."""
|
|
|
|
def test_deb_filename_in_output(self):
|
|
version = _get_version()
|
|
_, output = _run_dry_run()
|
|
expected = _deb_filename(version)
|
|
assert expected in output, \
|
|
f"Dry-run must reference deb filename {expected}"
|
|
|
|
def test_rpm_filename_in_output(self):
|
|
version = _get_version()
|
|
_, output = _run_dry_run()
|
|
expected = _rpm_filename(version)
|
|
assert expected in output, \
|
|
f"Dry-run must reference RPM filename {expected}"
|
|
|
|
def test_checksums_filename_in_output(self):
|
|
_, output = _run_dry_run()
|
|
assert "SHA256SUMS" in output, \
|
|
"Dry-run must reference SHA256SUMS filename"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — dry-run does not create artifacts or tags
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDryRunNoSideEffects:
|
|
"""Verify dry-run creates no filesystem or git side effects."""
|
|
|
|
def test_dry_run_no_git_tag_created(self):
|
|
version = _get_version()
|
|
tag = f"v{version}"
|
|
# Ensure tag doesn't exist before
|
|
r = subprocess.run(
|
|
["git", "tag", "-l", tag], capture_output=True, text=True,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
pre_tags = r.stdout.strip()
|
|
|
|
_run_dry_run()
|
|
|
|
# Verify tag was not created
|
|
r = subprocess.run(
|
|
["git", "tag", "-l", tag], capture_output=True, text=True,
|
|
cwd=REPO_ROOT,
|
|
)
|
|
post_tags = r.stdout.strip()
|
|
assert pre_tags == post_tags, \
|
|
f"Dry-run must not create git tag {tag}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — revision bumping (structural: output contains incremented release)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRevisionBumping:
|
|
"""Verify the release script handles revision bumping.
|
|
|
|
When a version already exists in the registry (HTTP 409), the script
|
|
bumps the revision and retries. These tests verify the dry-run output
|
|
reflects the correct revision logic — without any network access.
|
|
"""
|
|
|
|
def test_dry_run_starts_at_revision_one(self):
|
|
version = _get_version()
|
|
_, output = _run_dry_run()
|
|
rpm_expected = _rpm_filename(version, 1)
|
|
assert rpm_expected in output, \
|
|
f"Dry-run must start at release 1: expected {rpm_expected} in output"
|
|
|
|
def test_revision_bump_changes_rpm_filename(self):
|
|
"""When revision is bumped, the RPM filename changes accordingly."""
|
|
version = _get_version()
|
|
rpm_r1 = _rpm_filename(version, 1)
|
|
rpm_r2 = _rpm_filename(version, 2)
|
|
# R2 filename must differ from R1
|
|
assert rpm_r1 != rpm_r2, \
|
|
"R2 filename must differ from R1"
|
|
# Both must contain the version
|
|
assert version in rpm_r1
|
|
assert version in rpm_r2
|
|
|
|
def test_revision_bump_changes_deb_filename(self):
|
|
"""When revision is bumped, the deb filename also changes."""
|
|
version = _get_version()
|
|
# Deb filename includes release in nfpm naming
|
|
deb_r1 = f"fenris_{version}_amd64.deb"
|
|
deb_r2 = f"fenris_{version}_amd64.deb"
|
|
# For deb, the filename doesn't change with revision (deb uses epoch)
|
|
# But the RPM does — this verifies we test RPM revision correctly
|
|
rpm_r1 = _rpm_filename(version, 1)
|
|
rpm_r2 = _rpm_filename(version, 2)
|
|
assert "-1." in rpm_r1, "R1 RPM must contain -1."
|
|
assert "-2." in rpm_r2, "R2 RPM must contain -2."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — bare tag prevention (structural)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBareTagPrevention:
|
|
"""Verify the flow prevents bare tags.
|
|
|
|
A bare tag (tag without packages, release entry, notes, and checksums)
|
|
must not result from the flow. The script checks for existing bare
|
|
tags before proceeding. These tests verify the dry-run doesn't create
|
|
any tags.
|
|
"""
|
|
|
|
def test_dry_run_does_not_push_tag(self):
|
|
_, output = _run_dry_run()
|
|
# The dry-run marker confirms no commands are executed.
|
|
# git push appears only as a printed command, never executed.
|
|
assert "[dry-run]" in output, \
|
|
"Must be in dry-run mode"
|
|
# Tag push is printed but the [dry-run] marker confirms nothing ran
|
|
assert "Commands below will be executed" in output, \
|
|
"Dry-run must indicate commands are for display only"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — CI workflow file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCIWorkflow:
|
|
"""Verify the dormant CI workflow is present and correctly structured."""
|
|
|
|
def test_workflow_file_exists(self):
|
|
path = REPO_ROOT / ".gitea" / "workflows" / "release.yml"
|
|
assert path.exists(), \
|
|
".gitea/workflows/release.yml must exist"
|
|
|
|
def test_workflow_triggers_on_tags(self):
|
|
content = _read(".gitea/workflows/release.yml")
|
|
assert "v*" in content, \
|
|
"Workflow must trigger on version tags (v*)"
|
|
|
|
def test_workflow_has_release_step(self):
|
|
content = _read(".gitea/workflows/release.yml")
|
|
assert "release" in content.lower(), \
|
|
"Workflow must have a release step"
|
|
|
|
def test_workflow_mentions_signing(self):
|
|
content = _read(".gitea/workflows/release.yml")
|
|
assert "sign" in content.lower(), \
|
|
"Workflow must include signing step"
|
|
|
|
def test_workflow_mentions_upload(self):
|
|
content = _read(".gitea/workflows/release.yml")
|
|
assert "upload" in content.lower() or "publish" in content.lower(), \
|
|
"Workflow must include upload/publish step"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — Makefile release targets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMakefileReleaseTargets:
|
|
"""Verify the Makefile exposes release-related targets."""
|
|
|
|
def _makefile_content(self) -> str:
|
|
return _read("Makefile")
|
|
|
|
def test_release_run_target_exists(self):
|
|
content = self._makefile_content()
|
|
assert "release-run:" in content, \
|
|
"Makefile must have a release-run target"
|
|
|
|
def test_release_dry_run_target_exists(self):
|
|
content = self._makefile_content()
|
|
assert "release-dry-run:" in content, \
|
|
"Makefile must have a release-dry-run target"
|
|
|
|
def test_release_run_calls_script(self):
|
|
content = self._makefile_content()
|
|
assert "release.sh" in content, \
|
|
"release-run target must call scripts/release.sh"
|
|
|
|
def test_release_dry_run_uses_dry_run_flag(self):
|
|
content = self._makefile_content()
|
|
# Find the release-dry-run target and verify it passes --dry-run
|
|
in_target = False
|
|
for line in content.splitlines():
|
|
if line.startswith("release-dry-run:"):
|
|
in_target = True
|
|
continue
|
|
if in_target and line.strip():
|
|
if "--dry-run" in line:
|
|
break
|
|
if not line.startswith("\t"):
|
|
break
|
|
else:
|
|
pytest.fail("release-dry-run target must pass --dry-run to release.sh")
|