import json import os import subprocess import tempfile import unittest from pathlib import Path from unittest import mock from scripts import release class ReleaseTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="herdr-release-", dir="/var/tmp" if os.name != "nt" else None) self.addCleanup(self.temp.cleanup) self.previous_cwd = Path.cwd() os.chdir(self.temp.name) self.addCleanup(os.chdir, self.previous_cwd) self.git("init", "-q", "-b", "master") self.git("config", "user.name", "Release Test") self.git("config", "user.email", "release@example.invalid") self.put("Cargo.toml", '[package]\nname = "herdr"\nversion = "1.0.0"\n[dependencies]\nserde = "1"\n') self.put("Cargo.lock", 'version = 4\n[[package]]\nname = "herdr"\nversion = "1.0.0"\n[[package]]\nname = "serde"\nversion = "1.0.0"\n') self.put("src/main.rs", "fn main() {}\n") self.put("docs/next/CHANGELOG.md", "# Changelog\n") self.put("skills/herdr/SKILL.md", "Stable skill\n") self.put("docs/next/website/src/content/docs/obsolete.mdx", "Old page\n") self.put("distribution/latest.json", json.dumps({"version": "1.0.0"})) self.put("scripts/release.py", "# promotion tooling\n") self.put(".github/workflows/release.yml", "run: python3 scripts/release.py check-tag\n") self.put(".github/workflows/preview.yml", 'on:\n push:\n tags:\n - "preview-*"\n') self.commit("stable") self.git("tag", "v1.0.0") self.put("src/main.rs", "fn main() { println!(\"preview\"); }\n") self.preview = self.commit("preview") self.git("tag", "preview-test") self.git("update-ref", "refs/remotes/origin/master", "HEAD") def git(self, *args): return subprocess.check_output(["git", *args], text=True, stderr=subprocess.STDOUT).strip() def put(self, path, text): target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(text, encoding="utf-8") def commit(self, message): self.git("add", ".") self.git("commit", "-qm", message) return self.git("rev-parse", "HEAD") def prepare(self): for path in ("Cargo.toml", "Cargo.lock"): self.put(path, Path(path).read_text().replace('version = "1.0.0"', 'version = "1.0.1"', 1)) self.put("CHANGELOG.md", "# 1.0.1\nFixed the bug.\n") return self.commit("release preparation") def test_promotion_excludes_newer_master_and_records_explicit_previous_stable(self): self.put("src/new-feature.rs", "untested feature\n") master = self.commit("newer master") self.git("update-ref", "refs/remotes/origin/master", master) self.git("checkout", "-q", "-b", "release/1.0.1", self.preview) candidate = self.prepare() self.git("tag", "-a", "v1.0.1", "-m", "v1.0.1\n\nPreview: preview-test\nPrevious-Stable: v1.0.0") self.assertEqual(release.tag_metadata("v1.0.1"), ("preview-test", "v1.0.0")) with mock.patch.object(release, "published_preview", return_value=self.preview): self.assertEqual(release.validate_release("preview-test", candidate, "1.0.1", "v1.0.0", "test/repo"), self.preview) self.assertFalse(Path("src/new-feature.rs").exists()) self.git("checkout", "-q", "master") with self.assertRaisesRegex(ValueError, "unpreviewed change"): release.validate_diff(self.preview, master) def test_code_dependencies_schema_and_build_changes_are_rejected(self): cases = { "src/main.rs": "fn main() { panic!(); }\n", "Cargo.toml": '[package]\nname = "herdr"\nversion = "1.0.1"\n[dependencies]\nserde = "2"\n', "Cargo.lock": Path("Cargo.lock").read_text().replace('name = "serde"\nversion = "1.0.0"', 'name = "serde"\nversion = "2.0.0"'), "docs/next/api/herdr-api.schema.json": "{}\n", ".github/workflows/release.yml": "changed\n", } for path, content in cases.items(): with self.subTest(path=path): self.git("reset", "--hard", self.preview) self.put(path, content) candidate = self.commit("not release preparation") with self.assertRaises(ValueError): release.validate_diff(self.preview, candidate) def test_release_docs_and_skill_are_allowed_but_symlinks_are_not(self): for path in release.RELEASE_FILES | {"docs/next/website/src/content/docs/index.mdx"}: self.put(path, "release prose\n") candidate = self.prepare() release.validate_diff(self.preview, candidate, "1.0.1") if os.name != "nt": Path("CHANGELOG.md").unlink() Path("CHANGELOG.md").symlink_to("src/main.rs") with self.assertRaisesRegex(ValueError, "regular files"): release.validate_diff(self.preview, self.commit("symlink")) def test_required_release_files_cannot_be_deleted_but_website_pages_can(self): for path in ("docs/next/CHANGELOG.md", "skills/herdr/SKILL.md"): with self.subTest(path=path): self.git("reset", "--hard", self.preview) self.git("rm", path) candidate = self.commit("delete required release file") with self.assertRaisesRegex(ValueError, "regular files"): release.validate_diff(self.preview, candidate) self.git("reset", "--hard", self.preview) self.git("rm", "docs/next/website/src/content/docs/obsolete.mdx") release.validate_diff(self.preview, self.commit("remove obsolete website page")) def test_preview_source_accepts_master_history_and_rejects_unpublished_branch(self): self.assertEqual(release.select_preview(self.preview), self.preview) self.assertEqual(release.select_preview("v1.0.0"), self.git("rev-parse", "v1.0.0")) self.put("src/main.rs", "unpublished work\n") candidate = self.commit("unpublished") with self.assertRaisesRegex(ValueError, "preview source must"): release.select_preview(candidate) def test_preview_source_rejects_legacy_dispatch_even_on_master(self): self.put(".github/workflows/preview.yml", "on:\n workflow_dispatch:\n") legacy = self.commit("legacy preview workflow") self.git("update-ref", "refs/remotes/origin/master", legacy) with self.assertRaisesRegex(ValueError, "predates tag-triggered previews"): release.select_preview(legacy) def test_wrong_ancestry_and_mismatched_version_fail(self): candidate = self.prepare() with self.assertRaisesRegex(ValueError, "must descend"): release.validate_diff(candidate, self.preview) with self.assertRaisesRegex(ValueError, "version must match"): release.validate_diff(self.preview, candidate, "1.0.2") def test_published_preview_requires_immutable_complete_release_and_matching_remote_tag(self): payload = {"tag_name": "preview-test", "draft": False, "prerelease": True, "immutable": True, "assets": [{"name": name} for name in release.ASSETS]} real_git = release.git def git(*args): if args[0] == "ls-remote": return f"{self.preview}\trefs/tags/preview-test" return real_git(*args) real_output = subprocess.check_output def output(args, **kwargs): return json.dumps(payload) if args[0] == "gh" else real_output(args, **kwargs) with mock.patch.object(release, "git", side_effect=git), mock.patch.object(release.subprocess, "check_output", side_effect=output): self.assertEqual(release.published_preview("preview-test", "test/repo"), self.preview) for key, value in (("draft", True), ("prerelease", False), ("immutable", False), ("assets", [])): with self.subTest(key=key), mock.patch.dict(payload, {key: value}): with self.assertRaisesRegex(ValueError, "immutable published preview"): release.published_preview("preview-test", "test/repo") self.git("tag", "-f", "preview-test", "v1.0.0") with self.assertRaisesRegex(ValueError, "does not match"): release.published_preview("preview-test", "test/repo") def test_missing_provenance_or_stale_previous_release_fails(self): self.git("tag", "v1.0.1") with self.assertRaisesRegex(ValueError, "annotated"): release.tag_metadata("v1.0.1") self.git("tag", "-af", "v1.0.1", "-m", "v1.0.1") with self.assertRaisesRegex(ValueError, "Preview"): release.tag_metadata("v1.0.1") candidate = self.prepare() with mock.patch.object(release, "published_preview", return_value=self.preview): with self.assertRaisesRegex(ValueError, "currently published"): release.validate_release("preview-test", candidate, "1.0.1", "v0.9.0", "test/repo") with self.assertRaisesRegex(ValueError, "must increase"): release.validate_release("preview-test", candidate, "1.0.1", "v1.0.1", "test/repo") def test_hotfix_isolated_from_master_and_metadata_sync_preserves_master_code(self): self.put("src/feature.rs", "b and c\n") master = self.commit("features b and c") self.git("checkout", "-q", "-b", "release/hotfix", "v1.0.0") self.put("src/main.rs", "fn main() { /* fix d */ }\n") hotfix = self.commit("fix d") self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix) self.assertEqual(release.select_hotfix("release/hotfix", "v1.0.0"), hotfix) self.assertEqual(release.select_preview(hotfix), hotfix) self.git("rm", "scripts/release.py") legacy = self.commit("legacy release tooling") self.git("update-ref", "refs/remotes/origin/release/hotfix", legacy) with self.assertRaisesRegex(ValueError, "predates preview promotion"): release.select_hotfix("release/hotfix", "v1.0.0") self.git("reset", "--hard", hotfix) self.git("update-ref", "refs/remotes/origin/release/hotfix", hotfix) with self.assertRaisesRegex(ValueError, "release/\\*"): release.select_hotfix("feature/unreviewed", "v1.0.0") self.git("tag", "v2.0.0", master) with self.assertRaisesRegex(ValueError, "must descend"): release.select_hotfix("release/hotfix", "v2.0.0") candidate = self.prepare() release.validate_diff(hotfix, candidate, "1.0.1") patch = subprocess.check_output(["git", "diff", "--binary", hotfix, candidate]) self.git("checkout", "-q", "master") subprocess.run(["git", "apply", "--3way", "--index"], input=patch, check=True) self.assertEqual(Path("src/feature.rs").read_text(), "b and c\n") self.assertNotIn("fix d", Path("src/main.rs").read_text()) self.assertEqual(release.normalized_cargo(Path("Cargo.toml").read_text(), "Cargo.toml")[1], "1.0.1") if __name__ == "__main__": unittest.main()