diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc976372..25271627 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,8 +96,8 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v9.0.0 - - name: Check every version field agrees - run: uv run --no-project python scripts/sync_version.py --check + - name: Check version fields + the CHANGELOG release contract + run: uv run --no-project python scripts/release.py check lint: name: lint (trunk) @@ -107,8 +107,11 @@ jobs: steps: - uses: actions/checkout@v7 with: - # Trunk needs full history for diff-aware checks. + # Trunk needs full history for diff-aware checks... fetch-depth: 0 + # ...but not a credential helper: it only reads local history. Matches + # every other checkout in this file (zizmor/artipacked). + persist-credentials: false - name: Trunk Check uses: trunk-io/trunk-action@v1 diff --git a/AGENTS.md b/AGENTS.md index 891e98b2..d9990fdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,9 @@ Two orthogonal seams: **which CLI** (adapter axis: `adapters/base.py` `CodingCLI ## Repo hygiene -- CHANGELOG entries: terse, scannable, imperative, under the Unreleased heading. +- CHANGELOG entries: terse, scannable, imperative, under the `## [Unreleased]` heading. That heading is the contract, not a staging area: + - Every entry lands under `## [Unreleased]`, and only under the six Keep a Changelog subsections — `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + - A release **promotes** that section: `## [Unreleased]` is renamed to `## [X.Y.Z] — ` and a fresh empty `## [Unreleased]` opens above it. The release never authors a new version section from the git log, and never leaves a populated `Unreleased` behind — `scripts/release.py prepare` refuses both, and `release.py check` (CI job `version-sync`) holds the reopened heading and its `compare/v...HEAD` link. - Never commit session notes, probe records, or run artifacts. Durable facts belong in docstrings; records in git history. - Review non-convergence is evidence about the approach, not just a defect queue — escalate rather than grind. diff --git a/docs/testing.md b/docs/testing.md index 18c930d7..10db4c81 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -129,7 +129,7 @@ A slice of the suite tests the **repo** rather than the product. The inventory: | Skill-drift guard | `tests/test_module_skills_sync.py` | The seeded forks in `.claude/skills/` and `.agents/skills/` are byte-identical to canonical `src/bmad_loop/data/skills/`. **Documented limitation: CI-inert** — both trees are gitignored and absent in CI, so every parametrization skips there; the guard bites on dev boxes only. (The canonical-existence assertion runs before the skip and is CI-live.) | | Schema-version parity | `tests/test_tui_app.py` | The TUI renderer's pinned validate schema version equals `documents.VALIDATE_SCHEMA_VERSION` — deliberate duplication, because an import would auto-follow a CLI bump and silently render a v2 document as v1 | | Installed-copy drift | `tests/test_hook_script.py`, `tests/test_probe_hook.py` | The hook relays' copies match their source: `test_hook_script.py` re-runs `install_into` and text-compares the project copy against the source; `test_probe_hook.py` compares the packaged resource — which only bites in a wheel-installed run, since an editable install resolves both sides to the same file | -| Version sync | CI only | `scripts/sync_version.py --check` runs as the `version-sync` job; `tests/test_release.py` covers the release helpers' pure logic, not the check | +| Version sync | `tests/test_release.py` + CI | `scripts/release.py check` runs as the `version-sync` job — `sync_version.check()` in-process, plus the CHANGELOG release contract (the canonical version's section exists; `## [Unreleased]` was reopened; its `compare/v...HEAD` link tracks the bump). `tests/test_release.py` covers the release helpers' pure logic **and** drives `cmd_check`/`cmd_prepare` over fixture changelogs; the version-field comparison itself is still CI-only | Rules for adding or touching a guard: diff --git a/scripts/release.py b/scripts/release.py index dcbc7326..d13100d4 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -8,9 +8,10 @@ The flow is two-phase: -* ``prepare X.Y.Z`` runs on a feature/release branch. It validates the CHANGELOG - section for the target version (the human curates it *before* calling this), - stamps the version everywhere via ``sync_version.py``, +* ``prepare X.Y.Z`` runs on a feature/release branch. It validates that the + CHANGELOG's ``## [Unreleased]`` section was *promoted* into ``## [X.Y.Z]`` — + populated version section, emptied Unreleased — which the human does *before* + calling this, stamps the version everywhere via ``sync_version.py``, regenerates screenshots + demo *only* when ``src/bmad_loop/tui`` changed since the last tag, and commits the result — leaving the branch ready for a PR. * ``publish`` runs on ``main`` after the PR merges (driven by @@ -25,7 +26,7 @@ python scripts/release.py commits # commits since last tag, grouped python scripts/release.py publish # tag + gh release (idempotent) python scripts/release.py publish --dry-run # show the tag + notes it would create - python scripts/release.py check # local mirror of the CI guards + python scripts/release.py check # version + CHANGELOG guards (also run by CI) """ from __future__ import annotations @@ -35,6 +36,7 @@ import shutil import subprocess import sys +from datetime import date from pathlib import Path # sync_version is the canonical owner of the version value + format. Import it @@ -139,20 +141,73 @@ def has_curated_section(text: str, version: str) -> bool: return bool(body) +# The shape a promoted heading must take: `## [X.Y.Z] — YYYY-MM-DD`. `section_re` +# accepts any suffix after `]`, so nothing else notices a dateless or garbled one. +# Every release heading in CHANGELOG.md matches this — the sole exception is the +# pre-dating `## [0.1.0]`, which no release re-prepares. +RELEASE_HEADING_RE = re.compile( + r"(?m)^##\s+\[(?P[^\]]+)\]\s+—\s+(?P\d{4}-\d{2}-\d{2})\s*$" +) +# Any `[Unreleased]:` link-reference line. Deliberately shape-blind: `ensure_link_ref` +# rewrites whatever is there in place, so a hand-mangled line is repaired rather than +# duplicated by an insert. +UNRELEASED_REF_RE = re.compile(r"(?m)^\[Unreleased\]:[^\n]*$") +# ...and the base version that line's compare link must name. `cmd_check` reads the +# group; matching only this shape is what makes a stale base detectable at all. +UNRELEASED_COMPARE_RE = re.compile( + r"(?m)^\[Unreleased\]:\s*(?P\S+)/compare/v(?P\S+)\.\.\.HEAD\s*$" +) + + +def is_promoted_heading(m: re.Match[str], version: str) -> bool: + """Whether a ``## [...] — `` match is *this* release's heading, dated. + + ``RELEASE_HEADING_RE`` pins digit widths only, so ``2026-02-31`` and ``2026-99-99`` + are correctly shaped. ``date.fromisoformat`` is what makes "ISO date" mean a real + calendar date rather than a digit pattern. + """ + if m.group("version") != version: + return False + try: + date.fromisoformat(m.group("date")) + except ValueError: + return False + return True + + def ensure_link_ref(text: str, version: str, repo_url: str) -> str: """Insert ``[version]: /releases/tag/vversion`` into the trailing - link-reference block if it is absent. Newest refs sit on top, matching the - existing descending order. Returns the (possibly unchanged) text.""" + link-reference block if it is absent, and re-point ``[Unreleased]:`` at + ``compare/vversion...HEAD``. Newest refs sit on top, matching the existing + descending order. Returns the (possibly unchanged) text. + + The two halves are independent on purpose: an already-present ``[version]:`` + ref must not short-circuit the ``[Unreleased]:`` rewrite, or a re-run of + ``prepare`` would leave the compare base pinned to the *previous* release — + the drift this function exists to stop. + """ ref_line = f"[{version}]: {repo_url}/releases/tag/v{version}" - if re.search(rf"(?m)^\[{re.escape(version)}\]:\s", text): - return text ref_pat = re.compile(r"(?m)^\[\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?\]:\s") + + if not re.search(rf"(?m)^\[{re.escape(version)}\]:\s", text): + m = ref_pat.search(text) + if m: + text = text[: m.start()] + ref_line + "\n" + text[m.start() :] + else: + # No link-ref block yet: append one. + sep = "" if text.endswith("\n") else "\n" + text = text + sep + "\n" + ref_line + "\n" + + # `[Unreleased]:` always compares the just-cut release against HEAD. A plain + # string replacement would interpret backslash escapes in the URL, so + # substitute through a callable. + unreleased_line = f"[Unreleased]: {repo_url}/compare/v{version}...HEAD" + if UNRELEASED_REF_RE.search(text): + return UNRELEASED_REF_RE.sub(lambda _m: unreleased_line, text, count=1) m = ref_pat.search(text) - if m: - return text[: m.start()] + ref_line + "\n" + text[m.start() :] - # No link-ref block yet: append one. - sep = "" if text.endswith("\n") else "\n" - return text + sep + "\n" + ref_line + "\n" + if m: # sits above the version refs, matching the block's descending order + return text[: m.start()] + unreleased_line + "\n" + text[m.start() :] + return text def group_commits(lines: list[str]) -> dict[str, list[str]]: @@ -316,6 +371,7 @@ def cmd_prepare(args: argparse.Namespace) -> int: last_tag = last_release_tag() # --- preconditions ----------------------------------------------------- # + changelog = CHANGELOG.read_text() problems: list[str] = [] if branch == "main": problems.append("on `main`; run prepare from a release/feature branch") @@ -323,11 +379,60 @@ def cmd_prepare(args: argparse.Namespace) -> int: problems.append(f"tag {tag} already exists") if not version_gt(version, canonical): problems.append(f"{version} is not greater than the current version {canonical}") - if not has_curated_section(CHANGELOG.read_text(), version): + if not has_curated_section(changelog, version): problems.append( f"CHANGELOG.md has no non-empty `## [{version}]` section — " "curate the release notes there first" ) + # Paired with the guard above, these prove a *promotion* happened: the notes left + # `## [Unreleased]` and arrived under `## [X.Y.Z]`, and an empty Unreleased was + # reopened above it. + # + # Missing and empty are separate failures, not one: `has_curated_section` reports + # False for both, and only `prepare` can tell them apart in time. `release.yml` + # fires on a push to `main`/`release/*` with no dependency on the CI workflow, so + # it can tag and publish while `version-sync` is still running — this precondition + # is the last gate before that irreversible step, not a duplicate of `check`. + # The release date is part of the promoted shape, and only this notices it missing. + if section_re(version).search(changelog) and not any( + is_promoted_heading(m, version) for m in RELEASE_HEADING_RE.finditer(changelog) + ): + problems.append( + f"CHANGELOG.md heading for {version} is not `## [{version}] — ` " + "with a real calendar date — the promotion stamps the release date on the " + "heading it renames" + ) + # Every heading, not just the first: `extract_section` searches, so a leftover + # populated Unreleased *below* a freshly-inserted empty one would hide behind it + # and its entries would never ship. + unreleased = list(section_re("Unreleased").finditer(changelog)) + if not unreleased: + problems.append( + "CHANGELOG.md has no `## [Unreleased]` heading — a release renames the old " + f"one to `## [{version}]`, so a fresh empty one must be reopened above it" + ) + elif len(unreleased) > 1: + problems.append( + f"CHANGELOG.md has {len(unreleased)} `## [Unreleased]` headings — the " + "promotion renames the existing one rather than inserting a second; the " + "leftover still holds entries that would never ship" + ) + elif unreleased[0].group("body").strip(): + problems.append( + "CHANGELOG.md `## [Unreleased]` still has content — a release *promotes* " + f"that section (rename its heading to `## [{version}] — `, then " + "reopen an empty `## [Unreleased]` above it), it does not author a new " + "section beside it" + ) + else: + # Reopened, but it has to sit above the section it was promoted into — Keep a + # Changelog is newest-first, and `publish` reads sections by heading, not order. + v = section_re(version).search(changelog) + if v and unreleased[0].start() > v.start(): + problems.append( + f"CHANGELOG.md `## [Unreleased]` sits below `## [{version}]` — reopen it " + "above the section it was promoted into" + ) # Only CHANGELOG.md + regenerated assets are expected to be dirty pre-prepare. expected_dirty = {"CHANGELOG.md"} unexpected = [ @@ -455,16 +560,69 @@ def cmd_publish(args: argparse.Namespace) -> int: def cmd_check(args: argparse.Namespace) -> int: + """Run the CI release guards locally — and, via the `version-sync` job, in CI. + + A strict superset of ``sync_version.py --check``: it calls that check in-process, + then holds the CHANGELOG release contract. Every problem is reported before + returning, so one run shows the whole picture. It installs nothing — that + in-process call rather than a ``uv run`` child is what lets CI invoke it under + ``--no-project``. It does read ``origin`` (via :func:`repo_url`), so it wants a + git checkout, which every context that runs it has; on a clone whose ``origin`` + is a fork the compare-link arm reports the fork, which is accurate rather than + spurious. ``actions/checkout`` sets ``origin`` to the *base* repo even for a + fork PR, so CI is unaffected. + The `version-sync` job name is load-bearing for branch protection — when wiring + this in, change the step's command, never the job. + + The CHANGELOG arms assert what *promote-and-reopen* leaves behind, not the + prepare-time precondition: between releases a populated `## [Unreleased]` is + the correct state, so its emptiness is checked only by ``prepare``. + """ rc = 0 print("version-sync:") - if _run(["uv", "run", "python", str(SYNC_VERSION), "--check"], check=False).returncode != 0: + if sync_version.check() != 0: rc = 1 version = sync_version.read_canonical() - if has_curated_section(CHANGELOG.read_text(), version): + text = CHANGELOG.read_text() + if has_curated_section(text, version): print(f"changelog: `## [{version}]` section present") else: print(f"changelog: MISSING `## [{version}]` section", file=sys.stderr) rc = 1 + if extract_section(text, "Unreleased") is None: + print( + "changelog: MISSING `## [Unreleased]` heading — a release promotes that " + "section and must reopen an empty one above it", + file=sys.stderr, + ) + rc = 1 + else: + print("changelog: `## [Unreleased]` heading present") + m = UNRELEASED_COMPARE_RE.search(text) + url = repo_url() + if m is None: + print( + "changelog: MISSING `[Unreleased]:` compare link ref " + f"(expected one naming `compare/v{version}...HEAD`)", + file=sys.stderr, + ) + rc = 1 + elif m.group("repo") != url: + print( + f"changelog: `[Unreleased]:` compares against {m.group('repo')} " + f"— expected this repository, {url}", + file=sys.stderr, + ) + rc = 1 + elif m.group("base") != version: + print( + f"changelog: STALE `[Unreleased]:` compare base v{m.group('base')} " + f"— expected v{version}", + file=sys.stderr, + ) + rc = 1 + else: + print(f"changelog: `[Unreleased]:` compares against v{version}") return rc @@ -493,7 +651,9 @@ def build_parser() -> argparse.ArgumentParser: pp.add_argument("--dry-run", action="store_true", help="show the tag + notes, create nothing") pp.set_defaults(func=cmd_publish) - cc = sub.add_parser("check", help="local mirror of the CI release guards") + cc = sub.add_parser( + "check", help="version + CHANGELOG release guards (the CI `version-sync` job runs this)" + ) cc.set_defaults(func=cmd_check) return p diff --git a/tests/test_release.py b/tests/test_release.py index c1f1acd2..e0d65135 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -102,6 +102,110 @@ def test_has_curated_section_false_when_empty(): assert release.has_curated_section(text, "0.6.0") is False +# --- promote-and-reopen fixtures ------------------------------------------- # +# The state a release leaves behind: the notes moved out of `## [Unreleased]` into +# the version section, an empty Unreleased was reopened above it, and the compare +# link tracks the release just cut. `check` must pass on exactly this. +PROMOTED = """# Changelog + +## [Unreleased] + +## [0.5.0] — 2026-07-01 + +### Fixed + +- **A thing.** It no longer breaks. + +[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.5.0 +[0.4.3]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.4.3 +""" + +# The drift the prepare guard exists to catch: a `## [0.5.0]` section authored +# *beside* a still-populated `## [Unreleased]` rather than promoted from it. Both +# sections are non-empty, so the Unreleased guard is the only precondition that can +# fire — ablate it and `prepare` sails through. +DRIFTED = """# Changelog + +## [Unreleased] + +### Added + +- **Something newer.** Filed after the section below was authored. + +## [0.5.0] — 2026-07-01 + +### Fixed + +- **A thing.** It no longer breaks. + +[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.4.3...HEAD +[0.4.3]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.4.3 +""" + +# Each derives from PROMOTED by breaking exactly one thing, so exactly one `check` +# arm reports — a shared "rc == 1" would pass for the wrong reason. +NO_UNRELEASED_HEADING = PROMOTED.replace("## [Unreleased]\n\n", "", 1) +NO_UNRELEASED_REF = PROMOTED.replace( + "[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.5.0...HEAD\n", "", 1 +) +STALE_UNRELEASED_REF = PROMOTED.replace("/compare/v0.5.0...HEAD", "/compare/v0.4.3...HEAD", 1) + +# Renamed but never reopened. `has_curated_section` reports False here for the same +# reason it does for a correctly emptied one, so `prepare` needs the missing/empty +# distinction that `extract_section`'s None makes. +# Renamed and emptied correctly, but the release date never got stamped on. `section_re` +# accepts any suffix after `]`, so every other guard reads this as a clean promotion. +UNDATED_RELEASE_HEADING = PROMOTED.replace("## [0.5.0] — 2026-07-01", "## [0.5.0]", 1) + +# A half-finished rename: the fresh empty heading went in, the old populated one was +# never renamed. Guards that `search` for the first Unreleased see only the empty one. +DUPLICATE_UNRELEASED = """# Changelog + +## [Unreleased] + +## [0.5.0] — 2026-07-01 + +### Fixed + +- **A thing.** It no longer breaks. + +## [Unreleased] + +### Added + +- **Never promoted.** Left behind by the half-finished rename. + +[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.5.0 +""" + +UNRELEASED_REOPENED_BELOW = """# Changelog + +## [0.5.0] — 2026-07-01 + +### Fixed + +- **A thing.** It no longer breaks. + +## [Unreleased] + +[Unreleased]: https://github.com/bmad-code-org/bmad-loop/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/bmad-code-org/bmad-loop/releases/tag/v0.5.0 +""" + + +# `section_re` already escapes its argument, so the promotion guards reuse it for the +# non-numeric "Unreleased" heading rather than adding a second section regex. +def test_extract_section_reads_an_empty_unreleased_heading(): + assert release.extract_section(PROMOTED, "Unreleased") == "" + + +def test_has_curated_section_distinguishes_empty_from_populated_unreleased(): + assert release.has_curated_section(PROMOTED, "Unreleased") is False + assert release.has_curated_section(DRIFTED, "Unreleased") is True + + # --- link-ref insertion ---------------------------------------------------- # def test_ensure_link_ref_inserts_on_top_of_block(): out = release.ensure_link_ref(SAMPLE, "0.5.0", REPO_URL) @@ -123,6 +227,43 @@ def test_ensure_link_ref_appends_when_no_block(): assert out.rstrip().endswith(f"[0.1.0]: {REPO_URL}/releases/tag/v0.1.0") +# --- the `[Unreleased]:` compare link -------------------------------------- # +# Its base has to advance with every bump, or it silently keeps comparing against a +# release two cuts back. +def test_ensure_link_ref_repoints_a_stale_unreleased_compare_link(): + out = release.ensure_link_ref(DRIFTED, "0.5.0", REPO_URL) + assert f"[Unreleased]: {REPO_URL}/compare/v0.5.0...HEAD" in out + + +def test_ensure_link_ref_repoints_unreleased_even_when_the_version_ref_exists(): + # STALE_UNRELEASED_REF already carries `[0.5.0]:`. The version-ref insert is + # therefore a no-op, and a shared early return would skip the rewrite below — + # which is exactly what a re-run of `prepare` looks like. + out = release.ensure_link_ref(STALE_UNRELEASED_REF, "0.5.0", REPO_URL) + assert f"[Unreleased]: {REPO_URL}/compare/v0.5.0...HEAD" in out + + +def test_ensure_link_ref_inserts_unreleased_above_the_version_refs(): + out = release.ensure_link_ref(SAMPLE, "0.5.0", REPO_URL) + assert f"[Unreleased]: {REPO_URL}/compare/v0.5.0...HEAD" in out + assert out.index("[Unreleased]:") < out.index("[0.5.0]:") + + +def test_ensure_link_ref_repairs_a_malformed_unreleased_ref_in_place(): + # Rewriting is shape-blind on purpose: matching only the well-formed + # `compare/vX...HEAD` shape would leave a mangled line behind *and* insert a + # second one. + text = PROMOTED.replace(f"{REPO_URL}/compare/v0.5.0...HEAD", f"{REPO_URL}/compare/HEAD", 1) + out = release.ensure_link_ref(text, "0.5.0", REPO_URL) + assert out.count("[Unreleased]:") == 1 + + +def test_ensure_link_ref_idempotent_with_an_unreleased_ref(): + once = release.ensure_link_ref(DRIFTED, "0.5.0", REPO_URL) + twice = release.ensure_link_ref(once, "0.5.0", REPO_URL) + assert once == twice + + # --- commit grouping ------------------------------------------------------- # def test_group_commits_by_type(): lines = [ @@ -212,6 +353,131 @@ def test_publish_still_dies_on_a_genuine_gh_failure(monkeypatch, capsys, tmp_pat assert "Bad credentials" in capsys.readouterr().err +# --- prepare refuses an unpromoted changelog -------------------------------- # +# `--dry-run` still runs every precondition before returning, so it drives the guard +# without mutating anything; `no_assets` + an absent `trunk` keep the whole path +# subprocess-free. +def _prepare_dry_run(monkeypatch, tmp_path, changelog_text, *, version="0.5.0"): + cl = tmp_path / "CHANGELOG.md" + cl.write_text(changelog_text) + monkeypatch.setattr(release, "CHANGELOG", cl) + monkeypatch.setattr(release.sync_version, "read_canonical", lambda: "0.4.3") + monkeypatch.setattr(release, "repo_url", lambda: REPO_URL) + monkeypatch.setattr(release, "current_branch", lambda: "release/0.5.0") + monkeypatch.setattr(release, "last_release_tag", lambda: "v0.4.3") + monkeypatch.setattr(release, "tag_exists", lambda tag: False) + monkeypatch.setattr(release, "dirty_paths", lambda: ["CHANGELOG.md"]) + monkeypatch.setattr(release, "tui_changed_since", lambda tag: False) + monkeypatch.setattr(release.shutil, "which", lambda name: None) + return release.cmd_prepare( + SimpleNamespace( + version=version, dry_run=True, force_assets=False, no_assets=True, allow_dirty=False + ) + ) + + +def test_prepare_refuses_a_still_populated_unreleased(monkeypatch, tmp_path): + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, DRIFTED) + assert "`## [Unreleased]` still has content" in str(exc.value) + + +def test_prepare_refuses_a_never_reopened_unreleased(monkeypatch, tmp_path): + # Renaming the heading without reopening one leaves `has_curated_section` False, + # exactly as a correct promotion does — and `release.yml` publishes on push without + # waiting for the CI check that would catch it, so `prepare` has to. + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, NO_UNRELEASED_HEADING) + assert "no `## [Unreleased]` heading" in str(exc.value) + + +def test_prepare_refuses_a_release_heading_without_a_date(monkeypatch, tmp_path): + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, UNDATED_RELEASE_HEADING) + assert "is not `## [0.5.0] — `" in str(exc.value) + + +@pytest.mark.parametrize("bad", ["2026-02-31", "2026-99-99", "2026-13-05"]) +def test_prepare_refuses_an_impossible_release_date(monkeypatch, tmp_path, bad): + text = PROMOTED.replace("2026-07-01", bad, 1) + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, text) + assert "real calendar date" in str(exc.value) + + +def test_prepare_refuses_a_leftover_second_unreleased(monkeypatch, tmp_path): + # The empty heading is first, so anything that `search`es rather than scanning + # every match reads it and passes while the real entries sit below, unshipped. + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, DUPLICATE_UNRELEASED) + assert "2 `## [Unreleased]` headings" in str(exc.value) + + +def test_prepare_refuses_an_unreleased_reopened_below_the_release(monkeypatch, tmp_path): + with pytest.raises(SystemExit) as exc: + _prepare_dry_run(monkeypatch, tmp_path, UNRELEASED_REOPENED_BELOW) + assert "sits below `## [0.5.0]`" in str(exc.value) + + +def test_prepare_accepts_a_promoted_changelog(monkeypatch, tmp_path): + # The positive control: without it the guard above passes for a version bump + # that `prepare` was refusing for some entirely different precondition. + assert _prepare_dry_run(monkeypatch, tmp_path, PROMOTED) == 0 + + +# --- check holds the promote-and-reopen result ------------------------------ # +def _check(monkeypatch, tmp_path, changelog_text, *, canonical="0.5.0", sync_rc=0): + cl = tmp_path / "CHANGELOG.md" + cl.write_text(changelog_text) + monkeypatch.setattr(release, "CHANGELOG", cl) + monkeypatch.setattr(release.sync_version, "read_canonical", lambda: canonical) + monkeypatch.setattr(release.sync_version, "check", lambda: sync_rc) + monkeypatch.setattr(release, "repo_url", lambda: REPO_URL) + return release.cmd_check(SimpleNamespace()) + + +def test_check_passes_on_a_promoted_changelog(monkeypatch, tmp_path): + assert _check(monkeypatch, tmp_path, PROMOTED) == 0 + + +def test_check_flags_a_consumed_unreleased_heading(monkeypatch, capsys, tmp_path): + rc = _check(monkeypatch, tmp_path, NO_UNRELEASED_HEADING) + assert rc == 1 + assert "MISSING `## [Unreleased]` heading" in capsys.readouterr().err + + +def test_check_flags_a_missing_unreleased_compare_ref(monkeypatch, capsys, tmp_path): + rc = _check(monkeypatch, tmp_path, NO_UNRELEASED_REF) + assert rc == 1 + assert "MISSING `[Unreleased]:` compare link ref" in capsys.readouterr().err + + +def test_check_flags_an_unreleased_compare_link_to_another_repo(monkeypatch, capsys, tmp_path): + # Correct version, wrong repository: the base alone cannot tell these apart. + text = PROMOTED.replace(f"{REPO_URL}/compare", "https://github.com/other/repo/compare", 1) + rc = _check(monkeypatch, tmp_path, text) + assert rc == 1 + assert "compares against https://github.com/other/repo" in capsys.readouterr().err + + +def test_check_flags_a_stale_unreleased_compare_base(monkeypatch, capsys, tmp_path): + rc = _check(monkeypatch, tmp_path, STALE_UNRELEASED_REF) + assert rc == 1 + assert "STALE `[Unreleased]:` compare base v0.4.3" in capsys.readouterr().err + + +def test_check_still_reports_a_version_field_mismatch(monkeypatch, tmp_path): + # `sync_version.check()` moved in-process so CI can run this under + # `--no-project`; it still has to gate the exit code. + assert _check(monkeypatch, tmp_path, PROMOTED, sync_rc=1) == 1 + + +def test_check_flags_a_missing_section_for_the_canonical_version(monkeypatch, capsys, tmp_path): + rc = _check(monkeypatch, tmp_path, PROMOTED, canonical="9.9.9") + assert rc == 1 + assert "MISSING `## [9.9.9]` section" in capsys.readouterr().err + + @pytest.mark.parametrize( "stderr,lost", [