Files
herdr/scripts/test_release.py

206 lines
11 KiB
Python

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()