- 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>
211 lines
9 KiB
Python
Executable file
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()
|