yams/tests/scripts/test_validate_release_candidate.py
trvon 2bfc5334eb
ci(release): decouple draft promotion and enforce non-squash merge policy
- Decouple draft-promotion.yml from automated Tests workflow_run triggers,
  making promotion draft creation manual via workflow_dispatch to prevent
  GitHub from auto-chaining active experimental feature PRs into a stack
  targeting main.
- Enforce and document the mandatory Promotion Merge Policy across
  reconcile_promotion_pr.py, validate_release_candidate.py, and draft-promotion.yml
  step summary: promotions must use a merge commit (--no-ff) or fast-forward,
  never squash-merge into main, preserving conventional commit history and
  unbroken git ancestry.
- Unhide refactor commits in release-please-config.json so architectural
  refactorings are tracked under Changed in CHANGELOG.md.
- Update unit tests in test_reconcile_promotion_pr.py and
  test_validate_release_candidate.py for the new policy invariants.

Signed-off-by: trvon <me@trevon.dev>
2026-09-22 07:58:31 -06:00

211 lines
9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Unit tests for the stable-release candidate preflight."""
from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MODULE_PATH = ROOT / "scripts" / "ci" / "validate_release_candidate.py"
SPEC = importlib.util.spec_from_file_location("validate_release_candidate", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
candidate_module = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = candidate_module
SPEC.loader.exec_module(candidate_module)
CandidateError = candidate_module.CandidateError
class ReleaseCandidateTests(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tempdir.cleanup)
self.repo = Path(self.tempdir.name)
self.git("init", "-q")
self.git("config", "user.name", "Release Test")
self.git("config", "user.email", "release-test@example.com")
self.git("config", "commit.gpgsign", "false")
self.git("config", "tag.gpgsign", "false")
self.git("config", "core.hooksPath", "/dev/null")
(self.repo / "README.md").write_text("fixture\n", encoding="utf-8")
self.git("add", "README.md")
self.git("commit", "-q", "-m", "chore: initialize fixture")
self.write_versions("0.19.0")
self.git("add", ".")
self.git("commit", "-q", "-m", "chore(main): release 0.19.0")
self.base = self.git("rev-parse", "HEAD")
self.git("tag", "v0.19.0")
(self.repo / "feature.txt").write_text("candidate\n", encoding="utf-8")
self.git("add", "feature.txt")
self.git("commit", "-q", "-m", "feat: candidate")
self.candidate = self.git("rev-parse", "HEAD")
def git(self, *args: str) -> str:
result = subprocess.run(
["git", *args],
cwd=self.repo,
# Hooks export GIT_DIR even across cwd changes; never mutate the caller.
env={
key: value
for key, value in os.environ.items()
if not key.startswith("GIT_")
},
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def write_versions(self, version: str) -> None:
(self.repo / ".release-please-manifest.json").write_text(
json.dumps({".": version}) + "\n", encoding="utf-8"
)
(self.repo / "meson.build").write_text(
f"project('yams', 'cpp', version: '{version}')\n", encoding="utf-8"
)
(self.repo / "conanfile.py").write_text(
f'class YamsConan:\n version = "{version}"\n', encoding="utf-8"
)
(self.repo / "CITATION.cff").write_text(
f'cff-version: 1.2.0\nversion: "{version}"\n', encoding="utf-8"
)
def test_accepts_descendant_with_aligned_unbumped_versions(self) -> None:
report = candidate_module.validate_candidate(
self.repo, self.base, self.candidate
)
self.assertEqual(report.current_version, "0.19.0")
self.assertEqual(report.latest_stable_tag, "v0.19.0")
self.assertEqual(report.commit_count, 1)
self.assertEqual(report.base_sha, self.base)
self.assertEqual(report.candidate_sha, self.candidate)
markdown = report.markdown()
self.assertIn("Promotion Merge Policy", markdown)
self.assertIn("Create a merge commit", markdown)
self.assertIn("NEVER Squash and Merge", markdown)
def test_rejects_divergent_candidate(self) -> None:
self.git("checkout", "-q", "--detach", self.base + "^")
(self.repo / "other.txt").write_text("divergent\n", encoding="utf-8")
self.git("add", "other.txt")
self.git("commit", "-q", "-m", "feat: divergent")
divergent = self.git("rev-parse", "HEAD")
with self.assertRaisesRegex(CandidateError, "must contain the complete base"):
candidate_module.validate_candidate(self.repo, self.base, divergent)
def test_behind_main_explains_manual_reconciliation_without_mutating(self) -> None:
self.git("checkout", "-q", "--detach", self.base)
(self.repo / "feature.txt").write_text("main changes\n", encoding="utf-8")
self.git("add", "feature.txt")
self.git("commit", "-q", "-m", "fix: main changes")
current_main = self.git("rev-parse", "HEAD")
before_status = self.git("status", "--porcelain")
with self.assertRaises(CandidateError) as raised:
candidate_module.validate_candidate(self.repo, current_main, self.candidate)
message = str(raised.exception)
self.assertIn("1 base commit", message)
self.assertIn(current_main, message)
self.assertIn("resolve conflicts", message)
self.assertIn("rerun", message)
self.assertIn("Never squash-merge", message)
self.assertEqual(self.git("rev-parse", "HEAD"), current_main)
self.assertEqual(self.git("status", "--porcelain"), before_status)
self.assertFalse((self.repo / ".git" / "MERGE_HEAD").exists())
def test_accepts_reconciled_candidate_with_both_histories(self) -> None:
self.git("checkout", "-q", "--detach", self.base)
(self.repo / "main-only.txt").write_text("main\n", encoding="utf-8")
self.git("add", "main-only.txt")
self.git("commit", "-q", "-m", "fix: main-only change")
current_main = self.git("rev-parse", "HEAD")
self.git("checkout", "-q", "--detach", self.candidate)
self.git("merge", "--no-ff", "-m", "Merge current main", current_main)
reconciled = self.git("rev-parse", "HEAD")
report = candidate_module.validate_candidate(
self.repo, current_main, reconciled
)
self.assertEqual(report.base_sha, current_main)
self.assertEqual(report.candidate_sha, reconciled)
self.assertEqual(report.current_version, "0.19.0")
self.assertEqual(self.git("show", f"{reconciled}:feature.txt"), "candidate")
self.assertEqual(self.git("show", f"{reconciled}:main-only.txt"), "main")
def test_rejects_candidate_version_prebump(self) -> None:
self.write_versions("0.20.0")
self.git("add", ".")
self.git("commit", "-q", "-m", "chore: pre-bump candidate")
with self.assertRaisesRegex(CandidateError, "must remain at 0.19.0"):
candidate_module.validate_candidate(
self.repo, self.base, self.git("rev-parse", "HEAD")
)
def test_rejects_misaligned_candidate_version_surfaces(self) -> None:
(self.repo / "conanfile.py").write_text(
'class YamsConan:\n version = "0.18.0"\n', encoding="utf-8"
)
self.git("add", "conanfile.py")
self.git("commit", "-q", "-m", "fix: drift conan version")
with self.assertRaisesRegex(CandidateError, "version surfaces disagree"):
candidate_module.validate_candidate(
self.repo, self.base, self.git("rev-parse", "HEAD")
)
def test_rejects_base_manifest_that_does_not_match_stable_tag(self) -> None:
self.git("checkout", "-q", "--detach", self.base)
self.write_versions("0.18.0")
self.git("add", ".")
self.git("commit", "-q", "-m", "fix: stale base version")
stale_base = self.git("rev-parse", "HEAD")
(self.repo / "next.txt").write_text("next\n", encoding="utf-8")
self.git("add", "next.txt")
self.git("commit", "-q", "-m", "feat: next")
with self.assertRaisesRegex(CandidateError, "does not match latest stable tag"):
candidate_module.validate_candidate(
self.repo, stale_base, self.git("rev-parse", "HEAD")
)
def test_workflow_uses_current_main_and_preserves_validation_failure(self) -> None:
workflow = (ROOT / ".github/workflows/release-candidate.yml").read_text()
self.assertNotIn("PR_BASE_SHA", workflow)
self.assertIn('base_sha="$(git rev-parse origin/main)"', workflow)
self.assertIn("if-no-files-found: warn", workflow)
self.assertNotIn("continue-on-error:", workflow)
def test_workflow_is_read_only_and_uses_pinned_release_please_dry_run(self) -> None:
workflow = (ROOT / ".github/workflows/release-candidate.yml").read_text(
encoding="utf-8"
)
self.assertIn("contents: read", workflow)
self.assertIn("pull-requests: read", workflow)
self.assertNotIn("contents: write", workflow)
self.assertNotIn("pull-requests: write", workflow)
self.assertIn("release-please@17.11.2", workflow)
self.assertIn("--dry-run", workflow)
self.assertIn("--target-branch=experimental", workflow)
self.assertIn("validate_release_candidate.py", workflow)
self.assertIn("github.head_ref == 'experimental'", workflow)
self.assertIn(
"github.event.pull_request.head.repo.full_name == github.repository",
workflow,
)
if __name__ == "__main__":
unittest.main()