"""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: from tests.conftest import read return read(path) def _get_version() -> str: """Extract version from pyproject.toml.""" from tests.conftest import get_version return get_version() 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" def test_workflow_validates_notes_before_publication(self): content = _read(".gitea/workflows/release.yml") assert "scripts/extract_changelog.py" in content, \ "Workflow must fail before publication if release notes cannot be extracted" assert "--footer packaging/release-footer.md" in content, \ "Workflow must assemble the body from the standing release footer" def test_workflow_resynchronizes_existing_release_bodies(self): content = _read(".gitea/workflows/release.yml") assert "scripts/release_request.py" in content, \ "Workflow must make the create-versus-update decision through the request seam" assert '"${METHOD}"' in content, \ "Workflow must execute the helper-selected create-or-update request" assert 'RELEASE_PATH="$(printf' in content and '\n PATH="$(printf' not in content, \ "Workflow must not overwrite the shell PATH while preparing the request URL" def test_readme_points_consumers_to_release_notes(): readme = _read("README.md") assert "Per-release notes live on the [releases page]" in readme assert "standing install and verification instructions" in readme # --------------------------------------------------------------------------- # 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")