Files
truenas-truecloud-patch/tests/test_release_gate.py
flan ea090c7f72
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 12s
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 13s
TrueNAS compatibility / compat (push) Successful in 38s
Audit fixes: a compat verdict must never be able to brick a working box
The audit found the new machinery could do more harm than the bugs it prevents.

- apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT
  kill switch, cleared only by install.sh, never by update.sh -- for the
  incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go
  quiet, so the switch would fire and the release that fixed 26 could never
  re-enable itself. Retirement and incompatibility now take different exits.
- A network blip, a re-export, or a conditional def all read as BROKEN. Each is
  now 'unknown', which changes nothing, rather than evidence strong enough to
  disable a module.
- 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded
  the guard AND reshaped the functions rendered as good news.
- compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the
  documented way to check a live box lied on every patched machine.
- The signature check was a name-subset test. It passed reorders, kw-only
  conversions, and added required params -- and it had already passed a real bug:
  restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested
  backups have been raising TypeError on those releases the whole time. The
  wrapper now forwards *args/**kwargs.
- release.sh --promote was unreachable: it died if the tag existed, the gate died
  if it did not. The tests hid it by always tagging first.
2026-07-13 17:58:15 +00:00

265 lines
10 KiB
Python

"""Tests for the barrier: a stable release must have been a release candidate.
This exists because the repo cut twelve releases in one day, several of them
fixing the release before -- and with the update alert live, every one of those
interrupts every user. The gate makes that path impossible rather than impolite.
The provenance gate is the one thing here that can wrongly PASS in a way nobody
notices (a wrongly-failing gate is loud; a wrongly-passing gate silently restores
the old behaviour), so it gets tested against real git repositories, not mocks.
"""
import os
import subprocess
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools"))
from release_gate import ( # noqa: E402
check_promotable,
next_rc,
rc_tags,
)
from release_notes import ( # noqa: E402
base_version,
is_prerelease,
promote,
unreleased_body,
)
# ── a real git repo, because the gate reads real tags ────────────────────────
class Repo:
"""A throwaway git repo. The gate reads real tags, so the tests build real ones."""
def __init__(self, path):
self.path = path
def __str__(self):
return str(self.path)
def git(self, *args):
return subprocess.run(
["git", *args], cwd=self.path, capture_output=True, text=True, check=True,
).stdout.strip()
def commit(self, msg):
(self.path / "f").write_text(msg)
self.git("add", "-A")
self.git("commit", "-q", "-m", msg)
return self.git("rev-parse", "HEAD")
@pytest.fixture
def repo(tmp_path):
d = tmp_path / "repo"
d.mkdir()
r = Repo(d)
r.git("init", "-q", "-b", "main")
r.git("config", "user.email", "t@example.com")
r.git("config", "user.name", "t")
r.commit("initial")
return r
class TestTheBarrierBeforeTheTagExists:
"""release.sh calls the gate BEFORE creating the stable tag.
Every other test here tags first, and that is what let a fatal bug ship green:
`check_promotable` began with `commit_for(vX.Y.Z)` and returned "does not exist",
while release.sh's own guard refuses to run at all IF the tag exists. The two
conditions were mutually exclusive, so `--promote` could never succeed -- the
only way to cut a stable release was to hand-tag, bypassing every gate.
A gate that can only be satisfied after the thing it gates is not a gate.
"""
def test_promote_is_allowed_when_head_was_a_candidate_and_the_tag_is_absent(self, repo):
repo.git("tag", "v1.0.0-rc1") # rc on HEAD, no stable tag yet
assert check_promotable("v1.0.0", cwd=str(repo)) == []
def test_promote_is_refused_when_head_was_never_a_candidate(self, repo):
assert check_promotable("v1.0.0", cwd=str(repo))
def test_promote_is_refused_when_head_moved_past_the_candidate(self, repo):
repo.git("tag", "v1.0.0-rc1")
repo.commit("one more little fix") # HEAD is no longer the candidate
problems = check_promotable("v1.0.0", cwd=str(repo))
assert problems
assert "no release candidate does" in problems[0]
class TestTheBarrier:
def test_a_tag_with_no_candidate_is_refused(self, repo):
repo.git("tag", "v1.0.0")
problems = check_promotable("v1.0.0", cwd=str(repo))
assert problems
assert "never a release candidate" in problems[0]
def test_a_tag_whose_candidate_is_on_the_same_commit_is_allowed(self, repo):
repo.git("tag", "v1.0.0-rc1")
repo.git("tag", "v1.0.0")
assert check_promotable("v1.0.0", cwd=str(repo)) == []
def test_one_more_little_fix_after_the_rc_is_refused(self, repo):
# THE case this whole mechanism exists for. The candidate passed, then a
# "trivial" commit landed, and the stable tag ships code no candidate ever
# tested. That is how v0.5.1 happened.
repo.git("tag", "v1.0.0-rc1")
repo.commit("just a tiny fix, surely fine")
repo.git("tag", "v1.0.0")
problems = check_promotable("v1.0.0", cwd=str(repo))
assert problems
assert "no release candidate does" in problems[0]
assert "v1.0.0-rc2" in problems[0], "must say how to fix it"
def test_an_rc_for_a_different_version_does_not_count(self, repo):
repo.git("tag", "v0.9.0-rc1")
repo.git("tag", "v1.0.0")
problems = check_promotable("v1.0.0", cwd=str(repo))
assert problems
assert "never a release candidate" in problems[0]
def test_candidates_themselves_are_never_gated(self, repo):
# Requiring an rc to have an rc would be a deadlock.
repo.git("tag", "v1.0.0-rc1")
assert check_promotable("v1.0.0-rc1", cwd=str(repo)) == []
def test_a_later_candidate_on_the_right_commit_rescues_it(self, repo):
repo.git("tag", "v1.0.0-rc1")
repo.commit("fix found during rc1")
repo.git("tag", "v1.0.0-rc2") # re-cut on the fixed commit
repo.git("tag", "v1.0.0")
assert check_promotable("v1.0.0", cwd=str(repo)) == []
def test_a_tag_the_numbering_does_not_understand_is_not_a_candidate(self, repo):
# `v1.0.0-rc*` also globs `v1.0.0-rc1-hotfix`, which _rc_number reads as 0.
# The barrier must be satisfied only by something that really was a candidate.
repo.git("tag", "v1.0.0-rc1-hotfix")
repo.git("tag", "v1.0.0")
problems = check_promotable("v1.0.0", cwd=str(repo))
assert problems
assert "never a release candidate" in problems[0]
assert rc_tags("1.0.0", cwd=str(repo)) == []
class TestRcNumbering:
def test_first_candidate_is_rc1(self, repo):
assert next_rc("1.0.0", cwd=str(repo)) == "v1.0.0-rc1"
def test_it_counts_up(self, repo):
repo.git("tag", "v1.0.0-rc1")
assert next_rc("1.0.0", cwd=str(repo)) == "v1.0.0-rc2"
repo.git("tag", "v1.0.0-rc2")
assert next_rc("1.0.0", cwd=str(repo)) == "v1.0.0-rc3"
def test_rc10_sorts_after_rc9_not_before(self, repo):
# Lexicographic sorting would rank rc10 before rc9 and hand out a duplicate.
for n in range(1, 11):
repo.git("tag", f"v1.0.0-rc{n}")
assert rc_tags("1.0.0", cwd=str(repo))[-1] == "v1.0.0-rc10"
assert next_rc("1.0.0", cwd=str(repo)) == "v1.0.0-rc11"
def test_other_versions_do_not_leak_in(self, repo):
repo.git("tag", "v0.9.0-rc7")
assert next_rc("1.0.0", cwd=str(repo)) == "v1.0.0-rc1"
# ── the content half of the gate ─────────────────────────────────────────────
class TestPrereleaseDetection:
@pytest.mark.parametrize("tag", ["v1.2.3-rc1", "v1.2.3-rc10", "1.2.3-beta",
"v1.2.3-alpha2", "V1.2.3-RC1"])
def test_prereleases(self, tag):
assert is_prerelease(tag)
@pytest.mark.parametrize("tag", ["v1.2.3", "1.2.3", "v0.0.1"])
def test_stable(self, tag):
assert not is_prerelease(tag)
def test_base_version_strips_the_suffix(self):
assert base_version("v1.2.3-rc4") == "1.2.3"
assert base_version("v1.2.3") == "1.2.3"
class TestUnreleasedSection:
def test_body_is_extracted(self):
text = "# C\n\n## Unreleased\n\n### Fixed\n- a thing\n\n## v1.0.0 — 2026-01-01\n\n- old\n"
assert "- a thing" in unreleased_body(text)
assert "old" not in unreleased_body(text)
def test_empty_section_reads_as_empty(self):
text = "# C\n\n## Unreleased\n\n## v1.0.0 — 2026-01-01\n\n- old\n"
assert unreleased_body(text) == ""
def test_absent_section_reads_as_empty(self):
assert unreleased_body("# C\n\n## v1.0.0 — 2026-01-01\n\n- old\n") == ""
def test_promote_renames_the_heading_and_keeps_the_body(self):
text = "# C\n\n## Unreleased\n\n### Fixed\n- a thing\n\n## v1.0.0 — 2026-01-01\n"
out = promote(text, "1.1.0", "2026-07-13")
assert "## v1.1.0 — 2026-07-13" in out
assert "## Unreleased" not in out
assert "- a thing" in out
assert "## v1.0.0 — 2026-01-01" in out, "older sections survive"
def test_promoting_nothing_is_refused(self):
# A release with no content is a release nobody needed -- and it still
# alerts every box.
with pytest.raises(ValueError, match="nothing to release"):
promote("# C\n\n## v1.0.0 — 2026-01-01\n", "1.1.0", "2026-07-13")
class TestStrandedWorkBlocksAStableRelease:
"""`check()` refuses a stable tag that leaves work under `## Unreleased`.
Either it is finished and belongs in the release, or the release is premature.
"""
def _tree(self, tmp_path, changelog):
from release_notes import VERSIONED_FILES
for rel in VERSIONED_FILES:
p = tmp_path / rel
p.parent.mkdir(parents=True, exist_ok=True)
marker = "__version__ = " if rel.endswith(".py") else "VERSION="
p.write_text(f'{marker}"1.0.0"\n')
(tmp_path / "CHANGELOG.md").write_text(changelog)
return str(tmp_path)
def test_stranded_work_is_refused_for_a_stable_tag(self, tmp_path):
from release_notes import check
root = self._tree(tmp_path, (
"# C\n\n## Unreleased\n\n### Fixed\n- not done yet\n\n"
"## v1.0.0 — 2026-07-13\n\n### Added\n- the thing\n"
))
problems = check("v1.0.0", root=root)
assert any("Unreleased" in p for p in problems)
def test_stranded_work_is_fine_for_a_candidate(self, tmp_path):
# An rc may legitimately have more work queued behind it.
from release_notes import check
root = self._tree(tmp_path, (
"# C\n\n## Unreleased\n\n### Fixed\n- later\n\n"
"## v1.0.0 — 2026-07-13\n\n### Added\n- the thing\n"
))
assert check("v1.0.0-rc1", root=root) == []
def test_a_clean_stable_release_passes(self, tmp_path):
from release_notes import check
root = self._tree(tmp_path, (
"# C\n\n## v1.0.0 — 2026-07-13\n\n### Added\n- the thing\n"
))
assert check("v1.0.0", root=root) == []
def test_a_candidate_checks_against_its_base_version(self, tmp_path):
# The scripts say 1.0.0; the tag says v1.0.0-rc3. That must agree, not clash.
from release_notes import check
root = self._tree(tmp_path, (
"# C\n\n## v1.0.0 — 2026-07-13\n\n### Added\n- the thing\n"
))
assert check("v1.0.0-rc3", root=root) == []