From 4ced730d6519231171ca2a779db2128bc0ac55c3 Mon Sep 17 00:00:00 2001 From: sudolulo Date: Mon, 13 Jul 2026 17:21:30 +0000 Subject: [PATCH] Make Gitea canonical; derive the changelog URL from the remote instead of hard-coding GitHub --- .github/workflows/release.yml | 41 ++- CHANGELOG.md | 42 +++ install.sh | 4 +- patch/alert_source.py | 46 ++- patch/apply.sh | 78 ++++- patch/patch_ui.py | 6 +- release.sh | 284 +++++++++++++++++ tests/test_release_gate.py | 231 ++++++++++++++ tools/compat.py | 570 ++++++++++++++++++++++++++++++++++ tools/release_gate.py | 144 +++++++++ tools/release_notes.py | 60 +++- update.sh | 2 +- 12 files changed, 1489 insertions(+), 19 deletions(-) create mode 100644 release.sh create mode 100644 tests/test_release_gate.py create mode 100644 tools/compat.py create mode 100644 tools/release_gate.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db5012c..67323b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,9 +70,48 @@ jobs: # Catches the failure mode this repo actually had: VERSION= drifted to # three different values across the scripts, and nothing noticed. - - name: version matches tag and CHANGELOG has a section + - name: "gate: version matches tag, CHANGELOG complete, nothing stranded" run: python3 tools/release_notes.py check "${{ steps.tag.outputs.tag }}" + # THE BARRIER. A stable release must have been a release candidate on this + # exact commit. Candidates are invisible to users (update.sh and the alert + # source both take the newest plain vX.Y.Z), so debugging happens across + # rc1/rc2/rc3 at no cost to anyone -- instead of across v0.5.0/v0.5.1/v0.5.2, + # which alerts every installed box every time. + # + # Same code release.sh runs locally, so this should never be the first place + # you find out. It is here because this is the only place that cannot be + # bypassed: it holds the token that publishes. + - name: "gate: this commit was a release candidate" + run: python3 tools/release_gate.py "${{ steps.tag.outputs.tag }}" -C . + + # ...and the candidate has to have actually passed. Only CI can see this, so + # it cannot live in release_gate.py with the rest. + - name: "gate: that candidate's CI run passed" + if: ${{ !contains(steps.tag.outputs.tag, '-rc') && !contains(steps.tag.outputs.tag, '-beta') && !contains(steps.tag.outputs.tag, '-alpha') }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + sha="$(git rev-list -n 1 "$TAG")" + # Every rc tag on this exact commit -- release_gate.py already proved + # there is at least one. + rcs="$(git tag --points-at "$sha" | grep -E -- '-rc[0-9]+$' || true)" + + for rc in $rcs; do + concl="$(gh run list --workflow=release.yml --branch "$rc" \ + --json conclusion --jq '.[0].conclusion // ""' 2>/dev/null || true)" + echo "candidate $rc -> ${concl:-}" + if [ "$concl" = "success" ]; then + echo "::notice::$TAG is promoting $rc, whose release run passed." + exit 0 + fi + done + + echo "::error::No release candidate on $sha has a passing release run." + echo "::error::Candidates found: ${rcs:-none}. Wait for CI, or cut a new one." + exit 1 + - name: extract release notes from CHANGELOG run: | python3 tools/release_notes.py notes "${{ steps.tag.outputs.tag }}" > /tmp/notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6010119..b0e57af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +Work lands under **Unreleased** and stays there until a release promotes it. That +is deliberate: see [Releasing](README.md#releasing). Twelve releases were cut on +2026-07-13, several of them fixing the release before — and with the update alert +live, every one of those interrupts every user. An alert people learn to ignore is +worse than no alert, because one day it carries a security fix. + +## Unreleased + +### Added + +- **`release.sh` — a two-stage release process, and a barrier that enforces it.** + A stable `vX.Y.Z` tag is now only publishable if a `vX.Y.Z-rcN` tag points at the + **same commit** and that candidate's CI run passed. Candidates are invisible to + users — `update.sh` and the update alert both take the newest plain `vX.Y.Z` tag — + so debugging happens across rc1, rc2, rc3 at nobody's expense, instead of across + v0.5.0, v0.5.1, v0.5.2 at everybody's. + + bash release.sh 0.6.0 --rc # candidate. Invisible to users. + bash release.sh 0.6.0 --promote # stable. Refused unless an rc passed HERE. + + The rule is enforced in `tools/release_gate.py`, which `release.sh` runs locally + (so you fail in 200 ms) and `.github/workflows/release.yml` runs again where it + cannot be bypassed (so failing locally is not optional). "The candidate passed, + then I pushed one more little fix" is refused by name — that is precisely how + v0.5.1 happened. + +### Changed + +- **A stable release may not leave work stranded under `## Unreleased`.** Either it + is finished and belongs in the release, or the release is premature. Candidates + are exempt: an rc may legitimately have work queued behind it. + +- **`release.sh` refuses to run on an installed box.** The whole repo is cloned onto + every box, so this file is there too; `update.sh` pins the checkout to a tag in + detached HEAD, and `release.sh` now recognises that and says so, rather than + emitting a confusing branch error. + +### Internal + +- Static-analysis annotations in `patch/alert_source.py` (`# noqa` placement). No + runtime change. + ## v0.5.1 — 2026-07-13 ### Fixed diff --git a/install.sh b/install.sh index f15c581..c94d0ed 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # Prerequisites: run as root on TrueNAS SCALE with middlewared running. # Clone this repository to a persistent ZFS pool first: # -# git clone https://github.com/sudolulo/truenas-truecloud-patch \ +# git clone https://git.onetick.ninja/flan/truenas-truecloud-patch \ # /mnt//truenas-truecloud-patch # cd /mnt//truenas-truecloud-patch && bash install.sh # @@ -72,7 +72,7 @@ done if [ ! -f "$PATCH_DIR/patch/apply.sh" ]; then echo "ERROR: patch files not found at $PATCH_DIR/patch/" >&2 echo "Run install.sh from a clone of the repository on a persistent pool:" >&2 - echo " git clone https://github.com/sudolulo/truenas-truecloud-patch \\" >&2 + echo " git clone https://git.onetick.ninja/flan/truenas-truecloud-patch \\" >&2 echo " /mnt//truenas-truecloud-patch" >&2 echo " cd /mnt//truenas-truecloud-patch && bash install.sh" >&2 exit 1 diff --git a/patch/alert_source.py b/patch/alert_source.py index a429ec7..2c5c9ac 100644 --- a/patch/alert_source.py +++ b/patch/alert_source.py @@ -43,7 +43,16 @@ DISABLED_MARKER = os.path.join(PATCH_DIR, "update_alerts_disabled") _TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$") _VERSION_RE = re.compile(r'^VERSION="([^"]+)"', re.M) -_GITHUB_RE = re.compile(r"github\.com[:/]([^/]+)/([^/.]+)") + +#: owner/repo out of any of: +#: git@github.com:sudolulo/repo.git +#: https://github.com/sudolulo/repo.git +#: ssh://git@git.onetick.ninja:55214/flan/repo.git +#: https://git.onetick.ninja/flan/repo.git +#: The SSH port is deliberately not captured: it is not the web port. +_REMOTE_RE = re.compile( + r"^(?:\w+://)?(?:[^@/]+@)?([^:/]+)(?::\d+)?[:/]([^/]+)/([^/]+?)(?:\.git)?/?$" +) _TIMEOUT = 20 @@ -206,21 +215,40 @@ class TrueCloudPatchUpdateAlertSource(ThreadedAlertSource): detail = ", ".join(ordered) return level, versions, f" Changes: {detail}." if detail else "" - def _remote_changelog(self, tag): - """CHANGELOG.md at `tag`, over HTTPS. None if it cannot be read.""" + def _changelog_url(self, tag): + """Where to read CHANGELOG.md at `tag`, derived from the origin remote. + + Forge-agnostic on purpose. This project is canonically hosted on Gitea and + mirrored to GitHub, and hard-coding either one has a nastier failure than it + looks: when the changelog cannot be read, _classify() falls back to + "notable" and alerts ANYWAY, because the alternative is silently hiding a + security fix. So a stale URL does not disable the alert -- it makes the + alert fire on every release including documentation-only ones, which is + precisely the nagging this whole mechanism exists to prevent. + """ try: remote = self._git("remote", "get-url", "origin").strip() except Exception: return None - m = _GITHUB_RE.search(remote) + m = _REMOTE_RE.match(remote) if not m: - return None # not a GitHub remote; skip classification + return None - url = ( - f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/" - f"{tag}/CHANGELOG.md" - ) + host, owner, repo = m.group(1), m.group(2), m.group(3) + + if host.endswith("github.com"): + return f"https://raw.githubusercontent.com/{owner}/{repo}/{tag}/CHANGELOG.md" + + # Gitea and Forgejo both serve /{owner}/{repo}/raw/tag/{tag}/{path} over the + # web port, which is not the SSH port the remote may name. + return f"https://{host}/{owner}/{repo}/raw/tag/{tag}/CHANGELOG.md" + + def _remote_changelog(self, tag): + """CHANGELOG.md at `tag`, over HTTPS. None if it cannot be read.""" + url = self._changelog_url(tag) + if not url: + return None try: with urllib.request.urlopen(url, timeout=_TIMEOUT) as resp: # noqa: S310 if resp.status != 200: diff --git a/patch/apply.sh b/patch/apply.sh index fbd185c..a6f1df1 100755 --- a/patch/apply.sh +++ b/patch/apply.sh @@ -204,7 +204,67 @@ else _NESTED_ENABLED=0 fi -# Is either module still doing something useful? +# ── compatibility preflight ────────────────────────────────────────────────── +# +# The native probes above ask "has iX made this module unnecessary?". This asks the +# other question, the dangerous one: "has iX changed middleware so that this module +# no longer WORKS?" +# +# middlewared is internal API with no stability contract, and TrueNAS 26 rewrites +# the entire cloud_backup path from async to synchronous. Every block the nested +# module injects is an `async def` wrapping an `await`ed original; on 26 that hands +# sync.py a coroutine where it unpacks a tuple. The backup does not fail cleanly -- +# it fails at the point where you needed it. +# +# tools/compat.py records what each module assumes and checks it against the +# middlewared ACTUALLY INSTALLED HERE. A module whose assumptions no longer hold is +# not applied. Stock TrueNAS without a feature beats TrueNAS with a broken one. +# +# Fail direction, deliberately asymmetric: +# * a definite "assumption violated" -> disable that module. Strong evidence. +# * the checker cannot run at all -> change nothing. That is a tooling glitch, +# not evidence, and turning it into a disabled module would break working boxes. +_TC_COMPAT_JSON="$PATCH_DIR/incompatible.json" +rm -f "$_TC_COMPAT_JSON" + +_tc_compat=$("$PYTHON" - "$PATCH_DIR" "$_MW_DIR" "$_TC_COMPAT_JSON" <<'PYEOF' 2>/dev/null || printf 'unknown\nunknown\n') +import json, os, sys + +patch_dir, mw_dir, out_path = sys.argv[1], sys.argv[2], sys.argv[3] + +# APPEND, never insert(0) -- see the note by the mw_patch import below. Shadowing +# the stdlib for this interpreter is a much worse failure than not finding compat. +sys.path.append(os.path.join(patch_dir, 'tools')) +try: + import compat + result = compat.check_tree(mw_dir) +except Exception: + print('unknown') + print('unknown') + raise SystemExit(0) + +def verdict(r): + return 'broken' if (not r['ok'] and not r['native']) else 'ok' + +broken = {m: r for m, r in result.items() if verdict(r) == 'broken'} +if broken: + # The alert source reads this. Written before we print, so a box that is + # incompatible always has the evidence on disk even if apply.sh dies later. + try: + with open(out_path, 'w', encoding='utf-8') as fh: + json.dump(broken, fh, indent=2) + except OSError: + pass + +print(verdict(result['providers'])) +print(verdict(result['nested'])) +PYEOF +) + +_tc_compat_providers=$(printf '%s' "$_tc_compat" | sed -n '1p') +_tc_compat_nested=$(printf '%s' "$_tc_compat" | sed -n '2p') + +# Is either module still doing something useful -- and can it still be applied? _providers_needed=1 [ "$_tc_native_b2" = "yes" ] && _providers_needed=0 @@ -213,6 +273,22 @@ if [ "$_NESTED_ENABLED" = "1" ] && [ "$_tc_native_nested" != "yes" ]; then _nested_needed=1 fi +if [ "$_tc_compat_providers" = "broken" ]; then + echo "WARNING: truecloud-patch is NOT COMPATIBLE with this TrueNAS version." + echo "WARNING: The B2/S3 providers module will NOT be applied. TrueCloud is" + echo "WARNING: left stock, so B2/S3 tasks will not run until this is fixed." + echo "WARNING: Details: $_TC_COMPAT_JSON" + _providers_needed=0 +fi + +if [ "$_tc_compat_nested" = "broken" ] && [ "$_NESTED_ENABLED" = "1" ]; then + echo "WARNING: truecloud-patch's nested-snapshot module is NOT COMPATIBLE with" + echo "WARNING: this TrueNAS version and will NOT be applied. Backups still" + echo "WARNING: run; datasets nested under the target are not included." + echo "WARNING: Details: $_TC_COMPAT_JSON" + _nested_needed=0 +fi + if [ "$_providers_needed" = "0" ] && [ "$_nested_needed" = "0" ]; then echo "NOTICE: Nothing left for truecloud-patch to do:" [ "$_tc_native_b2" = "yes" ] && echo "NOTICE: - TrueNAS now provides native B2 restic support." diff --git a/patch/patch_ui.py b/patch/patch_ui.py index 78c8cce..2475481 100644 --- a/patch/patch_ui.py +++ b/patch/patch_ui.py @@ -111,7 +111,7 @@ def main(): print( "[truecloud-patch] WARNING: filterByProviders pattern not found in any JS bundle.\n" "[truecloud-patch] The TrueNAS webui may have been restructured in this version.\n" - "[truecloud-patch] File an issue at https://github.com/sudolulo/truenas-truecloud-patch\n" + "[truecloud-patch] File an issue at https://git.onetick.ninja/flan/truenas-truecloud-patch\n" f"[truecloud-patch] TrueNAS version info: {_tnversion()}" ) return @@ -136,7 +136,7 @@ def main(): f"[truecloud-patch] WARNING: {count} replacement(s) in {path}; " f"expected exactly 1 — skipping write to avoid corrupting the bundle.\n" f"[truecloud-patch] File an issue at " - f"https://github.com/sudolulo/truenas-truecloud-patch" + f"https://git.onetick.ninja/flan/truenas-truecloud-patch" ) return @@ -154,7 +154,7 @@ def main(): "[truecloud-patch] The UI is UNCHANGED and still works. This means the " "pattern no longer fits this TrueNAS build.\n" "[truecloud-patch] File an issue at " - "https://github.com/sudolulo/truenas-truecloud-patch" + "https://git.onetick.ninja/flan/truenas-truecloud-patch" ) return diff --git a/release.sh b/release.sh new file mode 100644 index 0000000..ecc2fa5 --- /dev/null +++ b/release.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# Cut a release. Two stages, and you cannot skip the first one. +# +# bash release.sh 0.6.0 --rc stage 1: candidate. Invisible to users. +# bash release.sh 0.6.0 --promote stage 2: stable. Only if an rc passed HERE. +# +# WHY IT WORKS THIS WAY +# +# This repo once cut twelve releases in a day, several of them fixing the release +# before. Every one of those raises an update alert on every user's box. An alert +# people learn to ignore is worse than no alert, because one day it will be +# carrying a security fix. +# +# So: debugging happens across rc1, rc2, rc3 -- which update.sh and the alert +# source both filter out, so no user ever sees them -- and a stable tag is only +# reachable from a candidate that already went green on the identical commit. +# tools/release_gate.py enforces that here, and .github/workflows/release.yml +# enforces it again where it cannot be bypassed. +# +# Day to day you do not touch this script. You write your changes under +# `## Unreleased` in CHANGELOG.md and push to main. Releasing is a separate, +# deliberate act. + +set -euo pipefail + +# This file IS on every user's box -- update.sh clones the whole repo -- so it +# carries no VERSION= not because it is "not shipped", but because nothing reads +# it. VERSION= exists so the running system can say which patch it is; this script +# never runs on a running system. (Anything that DOES carry a VERSION= must be in +# release_notes.VERSIONED_FILES or it silently rots: create_task.py sat three +# releases behind for exactly that reason.) +# +# Running it on a user's box is a no-op by construction, and that is checked below +# rather than left to luck: update.sh pins the checkout to a tag in detached HEAD, +# and this refuses to run anywhere but an up-to-date `main` with push access. + +cd "$(dirname "$(readlink -f "$0")")" + +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +note() { printf '\033[36m==>\033[0m %s\n' "$*"; } +ok() { printf '\033[32m ok\033[0m %s\n' "$*"; } + +usage() { + sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +# ── args ───────────────────────────────────────────────────────────────────── + +target="" +mode="" +assume_yes=0 + +while [ $# -gt 0 ]; do + case "$1" in + --rc) mode="rc" ;; + --promote) mode="promote" ;; + --check) mode="check" ;; + -y|--yes) assume_yes=1 ;; + -h|--help) usage 0 ;; + -*) die "unknown option: $1" ;; + *) + [ -n "$target" ] && die "give exactly one version" + target="${1#v}" + ;; + esac + shift +done + +[ -n "$target" ] || usage 2 +[ -n "$mode" ] || die "pick a stage: --rc (candidate) or --promote (stable)" + +printf '%s' "$target" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + || die "version must be plain X.Y.Z (the -rcN suffix is added for you)" + +# ── preflight ──────────────────────────────────────────────────────────────── + +[ -d .git ] || die "not a git checkout" + +git diff --quiet && git diff --cached --quiet \ + || die "working tree is dirty. Commit or stash first -- a release must be + reproducible from a commit, not from whatever happened to be on disk." + +branch="$(git rev-parse --abbrev-ref HEAD)" +if [ "$branch" = "HEAD" ]; then + # This is what an INSTALLED patch looks like: update.sh pins the checkout to a + # release tag in detached HEAD. Someone has found this script in their clone and + # run it. Say so plainly rather than emitting a confusing branch error. + die "this is an installed checkout (detached at $(git describe --tags --always)), + not a development one. release.sh is the maintainer tool that publishes new + versions of the patch; it is not how you install or update one. + + To update this box: bash update.sh" +fi +[ "$branch" = "main" ] || die "releases are cut from main, not '$branch'" + +note "fetching tags" +git fetch --tags --quiet origin + +if [ -n "$(git log --oneline "origin/$branch..$branch" 2>/dev/null)" ]; then + die "local main has commits that are not pushed. Push first: the tag must point + at a commit the world can actually fetch." +fi + +# ── the gates: identical to the ones CI will run ───────────────────────────── + +run_gates() { + local tag="$1" + note "gate: versions agree, CHANGELOG is complete" + python3 tools/release_notes.py check "$tag" \ + || die "content gate failed (see above)" + ok "content" + + note "gate: provenance" + python3 tools/release_gate.py "$tag" -C . \ + || die "provenance gate failed (see above)" + ok "provenance" +} + +# ── tests, because a tag that fails its own tests is not a release ─────────── + +run_tests() { + note "running the suite" + python3 -m pytest tests -q || die "tests fail. Fix them; do not release around them." + if command -v ruff >/dev/null 2>&1; then + ruff check patch tests tools || die "lint fails" + fi + local f + while IFS= read -r f; do + bash -n "$f" || die "bash syntax error in $f" + done < <(find . -name '*.sh' -not -path './.git/*') + ok "suite" +} + +confirm() { + [ "$assume_yes" -eq 1 ] && return 0 + printf '\n%s [y/N] ' "$1" + read -r reply /dev/null; then + note "v$target is already stamped; cutting a follow-up candidate" + else + note "promoting '## Unreleased' -> v$target and stamping the scripts" + python3 - "$target" <<'PY' +import datetime, os, re, sys +sys.path.insert(0, os.path.join(os.getcwd(), "tools")) +from release_notes import VERSIONED_FILES, promote + +version = sys.argv[1] +today = datetime.date.today().isoformat() + +with open("CHANGELOG.md", encoding="utf-8") as fh: + text = fh.read() +try: + out = promote(text, version, today) +except ValueError as e: + sys.exit(f"error: {e}") +with open("CHANGELOG.md", "w", encoding="utf-8") as fh: + fh.write(out) +print(f" CHANGELOG.md Unreleased -> v{version} - {today}") + +for rel in VERSIONED_FILES: + with open(rel, encoding="utf-8") as fh: + src = fh.read() + new, n = re.subn( + r'^(VERSION=|__version__\s*=\s*)"[^"]+"', + lambda m: f'{m.group(1)}"{version}"', + src, count=1, flags=re.M, + ) + if not n: + sys.exit(f"error: {rel} has no VERSION= line to stamp") + if new != src: + with open(rel, "w", encoding="utf-8") as fh: + fh.write(new) + print(f" {rel} -> {version}") +PY + git add -A + git commit -q -m "release v$target" + ok "stamped" + fi + + # Gated as the rc tag it is: content is checked against the base version, and the + # provenance gate is a no-op for candidates -- being one is the whole point. + run_gates "$tag" + run_tests + + echo + note "about to cut $tag" + echo " commit: $(git rev-parse --short HEAD) $(git log -1 --format=%s)" + echo + echo " A candidate is invisible to users: update.sh and the update alert both" + echo " ignore -rc tags. Install it on a real box, exercise it, and only then" + echo " run: bash release.sh $target --promote" + confirm "cut $tag?" + + git tag -a "$tag" -m "$tag" + git push --quiet origin main + git push --quiet origin "$tag" + ok "pushed $tag" + echo + echo "CI is now testing $tag and publishing it as a PRE-RELEASE." + echo "When you are satisfied: bash release.sh $target --promote" + exit 0 +fi + +# ── stage 2: promote to stable ─────────────────────────────────────────────── + +if [ "$mode" = "promote" ]; then + tag="v$target" + + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + next="$(echo "$target" | awk -F. '{printf "%d.%d.%d", $1, $2, $3+1}')" + die "$tag already exists. A published version is immutable -- if it is broken, + the fix ships as v$next, and it goes through a candidate like everything else." + fi + + # The barrier. Fails unless an rc points at THIS commit. + note "gate: was this exact commit a release candidate?" + python3 tools/release_gate.py "$tag" -C . || { + echo + die "not promotable (see above)" + } + ok "provenance" + + run_gates "$tag" + run_tests + + rcs="$(python3 - "$target" <<'PY' +import os, sys +sys.path.insert(0, os.path.join(os.getcwd(), "tools")) +from release_gate import rc_tags +print(", ".join(rc_tags(sys.argv[1])) or "none") +PY +)" + + echo + note "about to publish $tag to every user" + echo " commit: $(git rev-parse --short HEAD)" + echo " candidates: $rcs" + echo + echo " This raises an update alert on every installed box (unless the only" + echo " CHANGELOG section is Docs). Make sure it is worth interrupting people." + confirm "publish $tag?" + + git tag -a "$tag" -m "$tag" + git push --quiet origin "$tag" + ok "pushed $tag" + echo + echo "CI is publishing the release. Users will be alerted within 24h." + exit 0 +fi diff --git a/tests/test_release_gate.py b/tests/test_release_gate.py new file mode 100644 index 0000000..9a8a626 --- /dev/null +++ b/tests/test_release_gate.py @@ -0,0 +1,231 @@ +"""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 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_missing_tag_is_reported_not_crashed(self, repo): + problems = check_promotable("v9.9.9", cwd=str(repo)) + assert problems and "does not exist" in problems[0] + + +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) == [] diff --git a/tools/compat.py b/tools/compat.py new file mode 100644 index 0000000..226abc0 --- /dev/null +++ b/tools/compat.py @@ -0,0 +1,570 @@ +#!/usr/bin/env python3 +"""What this patch assumes about middlewared -- written down, and checkable. + +WHY THIS EXISTS +--------------- +This patch appends code to middlewared's own modules. middlewared has no stability +contract: it is internal API, and iX may reshape it in any release. When they do, +the patch does not politely decline -- it breaks a backup, possibly silently, which +is the worst thing a backup tool can do. + +It has already happened. TrueNAS 26 rewrites the whole cloud_backup path from +async to synchronous: + + 25.10: async def create_snapshot(...) / await create_snapshot(...) + 26.0: def create_snapshot(...) / create_snapshot(...) + +Every block the nested module injects is an `async def` wrapping an `await`ed +original. On 26 that unpacks a coroutine object instead of a tuple. Nobody would +have found out until a restore failed. + +So the assumptions are written down here, once, and checked in two places: + + * .github/workflows/compat.yml runs `--ref` against TrueNAS's *unreleased* + branches (master, the newest BETA/RC) on a schedule, and opens a bug report + the day iX breaks us -- while it is still a beta, not after it ships. + + * patch/apply.sh runs `--tree` against the middlewared *actually installed*, at + every boot, and REFUSES to patch a module whose assumptions no longer hold. + That is the guarantee: an unpatched module means stock TrueNAS (Storj only, + but working). A patched-anyway module means broken backups. Declining is + always the better failure. + +The two modules are checked independently, because they fail independently: on 26 +the providers module (B2/S3) only touches synchronous symbols and survives, while +the nested module does not. + + python3 tools/compat.py --tree /usr/lib/python3/dist-packages/middlewared + python3 tools/compat.py --ref release/26.0.0-BETA.3 + python3 tools/compat.py --ref master --json +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import sys +import urllib.request + +PROVIDERS = "providers" +NESTED = "nested" + +RAW = "https://raw.githubusercontent.com/truenas/middleware/{ref}/src/middlewared/middlewared/{path}" + +_TIMEOUT = 30 + + +class Assumption: + """One thing that must be true of middlewared, or a module cannot be applied. + + `is_async=None` means "do not care". Everywhere else it is stated explicitly, + because asyncness is exactly the axis TrueNAS 26 changed and a checker that + ignored it would have passed a build that breaks every backup. + """ + + def __init__(self, ident, module, path, symbol, *, kind="function", + is_async=None, params=None, why=""): + self.id = ident + self.module = module + self.path = path + self.symbol = symbol + self.kind = kind + self.is_async = is_async + self.params = params or [] + self.why = why + + +#: Everything patch/apply.sh's injected blocks depend on. Derived from the blocks +#: themselves -- if you add a block, add its assumptions here or the checker is +#: decoration. +ASSUMPTIONS = [ + # ── providers (B2/S3). Touches only synchronous symbols. ────────────────── + Assumption( + "b2-remote-class", PROVIDERS, "rclone/remote/b2.py", "B2RcloneRemote", + kind="class", + why="B2_BLOCK sets .get_restic_config and .restic on this class", + ), + Assumption( + "restic-config-fn", PROVIDERS, "plugins/cloud_backup/restic.py", + "get_restic_config", is_async=False, params=["cloud_backup"], + why="RESTIC_BLOCK wraps it to rewrite the repo URL; it calls the original " + "WITHOUT await, so it must stay synchronous", + ), + Assumption( + "restic-config-class", PROVIDERS, "plugins/cloud_backup/restic.py", + "ResticConfig", kind="class", + why="RESTIC_BLOCK does dataclasses.replace(result, cmd=...) on what " + "get_restic_config returns", + ), + + # ── nested snapshots. Every block here is an async wrapper. ─────────────── + Assumption( + "create-snapshot", NESTED, "plugins/cloud/snapshot.py", "create_snapshot", + is_async=True, params=["middleware", "path", "name"], + why="SNAPSHOT_BLOCK replaces it with `async def` that AWAITS the original " + "and returns (snapshot, staging_root). TrueNAS 26 made it synchronous: " + "the wrapper would return a coroutine that sync.py unpacks as a tuple", + ), + Assumption( + "crud-mixin-validate", NESTED, "plugins/cloud/crud.py", + "CloudTaskServiceMixin._validate", + kind="method", is_async=True, params=["self", "app", "verrors", "name", "data"], + why="CRUD_BLOCK replaces it with `async def` that AWAITS the original, to " + "drop the no-further-nesting error", + ), + Assumption( + "restic-backup", NESTED, "plugins/cloud_backup/sync.py", "restic_backup", + is_async=True, params=["middleware", "job", "cloud_backup"], + why="SYNC_BLOCK replaces it with `async def` that AWAITS the original, to " + "tear down bind mounts in a finally", + ), +] + + +#: Things that mean iX has done the job themselves and the module should RETIRE, +#: not break. Absence of the nesting guard = nested snapshots went native. +#: `restic = True` already on B2RcloneRemote = B2 restic support went native. +NATIVE_PROBES = { + NESTED: ( + "plugins/cloud/crud.py", + "no further nesting", + False, # native when the phrase is ABSENT + ), + PROVIDERS: ( + "rclone/remote/b2.py", + "restic = True", + True, # native when the phrase is PRESENT + ), +} + + +def _squash(text: str) -> str: + """Drop whitespace and quotes, so a phrase split across string literals matches. + + Stock middleware writes the guard as an implicitly-concatenated literal: + + verrors.add(f"{name}.snapshot", "This option is only available for " + "datasets that have no further nesting") + + A naive `"no further nesting" in source` is therefore FALSE on a version that + very much has the guard -- and this probe's False means "TrueNAS supports it + natively, retire the module". That is a silent, catastrophic misread: it would + disable nested snapshots on every box that currently depends on them. + + apply.sh already learned this the hard way and normalises the same way. Both + now call this one function, which is the only reason they cannot drift apart + again. + """ + return text.translate(str.maketrans("", "", " \t\n\r\"'")) + + +# ── AST lookups ────────────────────────────────────────────────────────────── + +def _find(tree, symbol): + """The node for `name` or `Class.method`, or None.""" + if "." in symbol: + cls_name, meth = symbol.split(".", 1) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == cls_name: + for sub in node.body: + if isinstance(sub, ast.FunctionDef | ast.AsyncFunctionDef) \ + and sub.name == meth: + return sub + return None + + for node in tree.body: + if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef) \ + and node.name == symbol: + return node + return None + + +def _params(node): + a = node.args + return [p.arg for p in (*a.posonlyargs, *a.args, *a.kwonlyargs)] + + +def check_source(a: Assumption, src: str | None) -> str | None: + """The reason assumption `a` no longer holds, or None if it does.""" + if src is None: + return f"{a.path} does not exist" + + try: + tree = ast.parse(src) + except SyntaxError as e: + return f"{a.path} does not parse: {e}" + + node = _find(tree, a.symbol) + if node is None: + return f"{a.path} no longer defines {a.symbol}" + + if a.kind == "class": + if not isinstance(node, ast.ClassDef): + return f"{a.symbol} is no longer a class" + return None + + if isinstance(node, ast.ClassDef): + return f"{a.symbol} is a class, expected a function" + + got_async = isinstance(node, ast.AsyncFunctionDef) + if a.is_async is not None and got_async != a.is_async: + want = "async def" if a.is_async else "def" + got = "async def" if got_async else "def" + return ( + f"{a.symbol} is now `{got}`, the patch requires `{want}` " + f"({a.path})" + ) + + have = _params(node) + missing = [p for p in a.params if p not in have] + if missing: + return ( + f"{a.symbol}{tuple(have)} no longer takes {', '.join(missing)}" + ) + + return None + + +# ── sources ────────────────────────────────────────────────────────────────── + +def _fetch(ref: str, path: str) -> str | None: + url = RAW.format(ref=ref, path=path) + try: + with urllib.request.urlopen(url, timeout=_TIMEOUT) as r: # noqa: S310 + if r.status != 200: + return None + return r.read().decode("utf-8", "replace") + except Exception: + return None + + +def _read(root: str, path: str) -> str | None: + try: + with open(os.path.join(root, *path.split("/")), encoding="utf-8") as fh: + return fh.read() + except OSError: + return None + + +def check(loader, modules=None) -> dict: + """Check every assumption. `loader(path) -> source|None`. + + Returns {module: {"ok": bool, "native": bool, "problems": [...]}}. + """ + modules = modules or [PROVIDERS, NESTED] + cache = {} + + def src(path): + if path not in cache: + cache[path] = loader(path) + return cache[path] + + out = {m: {"ok": True, "native": False, "problems": []} for m in modules} + + for a in ASSUMPTIONS: + if a.module not in out: + continue + problem = check_source(a, src(a.path)) + if problem: + out[a.module]["ok"] = False + out[a.module]["problems"].append({ + "id": a.id, "detail": problem, "why": a.why, + }) + + for module, (path, phrase, native_when_present) in NATIVE_PROBES.items(): + if module not in out: + continue + text = src(path) + if text is None: + continue + present = _squash(phrase) in _squash(text) + out[module]["native"] = (present == native_when_present) + + return out + + +def check_ref(ref: str, modules=None) -> dict: + return check(lambda p: _fetch(ref, p), modules) + + +def check_tree(root: str, modules=None) -> dict: + return check(lambda p: _read(root, p), modules) + + +# ── which TrueNAS versions to check ────────────────────────────────────────── + +REPO = "https://github.com/truenas/middleware" + +#: TrueCloud Backup -- the restic-based cloud_backup this patch extends -- was +#: introduced in 24.10. In 24.04 the modules simply do not exist (404), which the +#: checker would otherwise report as three separate "broken assumptions" for a +#: feature that was never there. +OLDEST = (24, 10) + +#: BETA < RC < shipped. Without this, "26.0.0-BETA.1" and "26.0.0-BETA.3" both +#: reduce to (26,0,0) and the matrix silently reports whichever was seen first -- +#: which is how it first showed BETA.1 while BETA.3 was the one to worry about. +_STAGE = {"BETA": 0, "RC": 1} +_SHIPPED = 2 + + +def _version_of(name: str): + """Sortable version of 'release/26.0.0-BETA.3' or 'TS-25.10.4'. None if junk. + + Returns ((major, minor, ...), stage_rank, stage_number). + """ + tail = name.split("/", 1)[1] if "/" in name else name + tail = tail.removeprefix("TS-") + + core, _, suffix = tail.partition("-") + try: + version = tuple(int(p) for p in core.split(".")) + except ValueError: + return None + if len(version) < 2: + return None + + if not suffix: + return version, _SHIPPED, 0 + + stage, _, num = suffix.partition(".") + rank = _STAGE.get(stage.upper()) + if rank is None: + return None # not a release line we understand + return version, rank, int(num) if num.isdigit() else 0 + + +def _newest_per_line(names): + """Newest name on each (major, minor) line.""" + best = {} + for name in names: + v = _version_of(name) + if not v or v[0][:2] < OLDEST: + continue + key = v[0][:2] + if key not in best or v > best[key][0]: + best[key] = (v, name) + return [n for _, n in sorted(best.values())] + + +def _ls_remote(remote, what): + import subprocess + + out = subprocess.run( + ["git", "ls-remote", what, "--refs", remote], + capture_output=True, text=True, check=True, timeout=60, + ).stdout + prefix = "refs/tags/" if what == "--tags" else "refs/heads/" + return [ + line.split(prefix, 1)[1].strip() + for line in out.splitlines() if prefix in line + ] + + +def discover_refs(remote: str = REPO) -> list[str]: + """What to check: every shipped TrueNAS line, everything unreleased, and master. + + Two sources, because they are authoritative for different things: + + * SHIPPED comes from the `TS-*` TAGS. Those are what iX actually released. + The `release/*` branches include mistakes -- `release/25.20.2.2` exists and + 25.20 is not a TrueNAS version -- and a typo branch in the matrix reads as + a real supported release that we are silently broken on. + + * UNRELEASED comes from the BRANCHES, because that is where a beta appears + first: `release/26.0.0-BETA.3` had no tag yet while it was the newest beta. + Catching breakage here, before it ships, is the whole point of this file. + """ + tags = _ls_remote(remote, "--tags") + heads = _ls_remote(remote, "--heads") + + shipped = _newest_per_line([ + t for t in tags if t.startswith("TS-") and "-BETA" not in t and "-RC" not in t + ]) + + # A prerelease of a line that has ALREADY shipped is history, not a warning: + # release/24.10-RC.2 still exists, and the nested module does not apply to it, + # but 24.10 shipped long ago and TS-24.10.2.4 is fine. Reporting it would be a + # standing red row in the matrix for a version nobody can install. + shipped_lines = {_version_of(t)[0][:2] for t in shipped} + upcoming = [ + h for h in _newest_per_line([ + h for h in heads + if h.startswith("release/") and ("-BETA" in h or "-RC" in h) + ]) + if _version_of(h)[0][:2] not in shipped_lines + ] + + return [*shipped, *upcoming, "master"] + + +def is_unreleased(ref: str) -> bool: + """master and any BETA/RC. Breakage here is early warning, not an outage.""" + return ref == "master" or "-BETA" in ref or "-RC" in ref + + +def matrix(refs=None, remote: str = REPO) -> list[dict]: + """Check every release line. Returns one row per ref.""" + rows = [] + for ref in (refs or discover_refs(remote)): + result = check(lambda p, r=ref: _fetch(r, p)) + rows.append({ + "ref": ref, + "unreleased": is_unreleased(ref), + "modules": result, + }) + return rows + + +def _verdict(r: dict) -> str: + if r["native"]: + return "native" + return "ok" if r["ok"] else "BROKEN" + + +#: Versions a human has actually run a backup on, with real data, on real hardware. +#: This is NOT automatable and must never be inferred: everything else in this file +#: is static analysis of iX's source, which proves the patch's assumptions hold -- +#: a strictly weaker claim than "a restore worked". Add a row only after doing it. +HARDWARE_VERIFIED = { + "25.10.4": "nested + providers; 252-snapshot recursive backup of /mnt/Tap, 18m", +} + +_LEGEND = """ +| verdict | meaning | +| --- | --- | +| **ok** | Every assumption the patch makes about middleware still holds. | +| **BROKEN** | middleware changed underneath the patch. `apply.sh` **refuses to apply that module** on this version and leaves TrueNAS stock, so backups keep working — without the module's feature. | +| **native** | TrueNAS does this itself now. The module retires; it is not a failure. | + +"ok" means *the patch's assumptions hold*, checked automatically against iX's +source. It does not mean a human ran a backup on it — that is the +**Hardware-verified** column, which is filled in by hand and only by doing it. +""" + + +def render_markdown(rows: list[dict]) -> str: + """The matrix, for COMPATIBILITY.md and the README.""" + out = [ + "| TrueNAS | B2/S3 providers | Nested snapshots | Hardware-verified |", + "| --- | --- | --- | --- |", + ] + for row in rows: + m = row["modules"] + ref = row["ref"] + label = ref.removeprefix("TS-").removeprefix("release/") + if row["unreleased"]: + label = f"{label} _(unreleased)_" + + cells = [] + for mod in (PROVIDERS, NESTED): + v = _verdict(m[mod]) + cells.append({ + "ok": "ok", + "BROKEN": "**BROKEN**", + "native": "native", + }[v]) + + version = ref.removeprefix("TS-") + hw = HARDWARE_VERIFIED.get(version) + out.append(f"| {label} | {cells[0]} | {cells[1]} | {hw or '—'} |") + + return "\n".join(out) + "\n" + _LEGEND + + +def render_matrix(rows: list[dict]) -> str: + """A support table. + + Says "assumptions hold", not "works" -- this is static analysis of iX's source, + which is a strictly weaker claim than having run a backup on the hardware. The + hardware-verified column lives in COMPATIBILITY.md and is maintained by hand, + because nothing else can honestly fill it in. + """ + w = max((len(r["ref"]) for r in rows), default=10) + lines = [ + f"{'TrueNAS'.ljust(w)} {'providers':<10} {'nested':<10}", + f"{'-' * w} {'-' * 10} {'-' * 10}", + ] + for row in rows: + m = row["modules"] + lines.append( + f"{row['ref'].ljust(w)} " + f"{_verdict(m[PROVIDERS]):<10} {_verdict(m[NESTED]):<10}" + ) + return "\n".join(lines) + + +# ── reporting ──────────────────────────────────────────────────────────────── + +def render(label: str, result: dict) -> str: + lines = [f"TrueNAS middleware @ {label}", ""] + for module, r in sorted(result.items()): + if r["native"]: + lines.append( + f" [NATIVE] {module}: TrueNAS appears to support this natively " + f"now — the module should be retired, not fixed." + ) + elif r["ok"]: + lines.append(f" [ok] {module}: all assumptions hold") + else: + lines.append(f" [BROKEN] {module}:") + for p in r["problems"]: + lines.append(f" - {p['detail']}") + lines.append(f" why it matters: {p['why']}") + return "\n".join(lines) + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--ref", help="a truenas/middleware git ref, e.g. master") + g.add_argument("--tree", help="path to an installed middlewared package") + g.add_argument("--matrix", action="store_true", + help="check every TrueNAS release line, newest of each") + ap.add_argument("--module", action="append", choices=[PROVIDERS, NESTED], + help="check only this module (repeatable)") + ap.add_argument("--json", action="store_true") + ap.add_argument("--markdown", action="store_true", + help="with --matrix: emit the table for COMPATIBILITY.md") + args = ap.parse_args(argv[1:]) + + if args.matrix: + rows = matrix() + if args.json: + print(json.dumps(rows, indent=2)) + elif args.markdown: + print(render_markdown(rows)) + else: + print(render_matrix(rows)) + # A broken UNRELEASED line (master, -BETA, -RC) is a warning, not a build + # failure -- it is exactly what we want to know early, and it is iX's tree + # to change. compat.yml turns it into a bug report. A broken SHIPPED line + # is a genuine failure: users are on it right now. + shipped_broken = [ + r["ref"] for r in rows + if not r["unreleased"] + and any(not m["ok"] and not m["native"] for m in r["modules"].values()) + ] + if shipped_broken: + print(f"\nBROKEN on shipped releases: {', '.join(shipped_broken)}", + file=sys.stderr) + return 1 + return 0 + + label = args.ref or args.tree + result = (check_ref(args.ref, args.module) if args.ref + else check_tree(args.tree, args.module)) + + if args.json: + print(json.dumps({"ref": label, "modules": result}, indent=2)) + else: + print(render(label, result)) + + # Exit 1 if any module is broken. "Native" is not broken -- it is good news. + return 1 if any(not r["ok"] and not r["native"] for r in result.values()) else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/release_gate.py b/tools/release_gate.py new file mode 100644 index 0000000..8970544 --- /dev/null +++ b/tools/release_gate.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""The barrier: a stable release must have been a release candidate first. + +CHECKS +------ +release_notes.py checks the *content* of the tree (versions agree, CHANGELOG has a +non-empty section, nothing stranded under Unreleased). This module checks the +*provenance* of the commit: was this exact code ever a release candidate, and did +that candidate pass CI? + +WHY +--- +This repo cut twelve releases in a single day, several of them "fix the thing the +last release broke". With an update alert live on every user's box, that is not +iteration, it is nagging -- and it teaches people to ignore the alert that will one +day carry a real security fix. + +The rule that makes the bad path impossible: + + A stable vX.Y.Z tag is only publishable if a vX.Y.Z-rcN tag points at the SAME + commit, and that candidate's CI run passed. + +Release candidates are invisible to users: update.sh and the alert source both take +the newest plain vX.Y.Z tag, so an rc is never offered as an update. Debugging +therefore happens across rc1, rc2, rc3 -- where it costs nobody anything -- instead +of across v0.5.0, v0.5.1, v0.5.2, where it costs everybody an alert. + +The commit must be *identical*, not merely an ancestor. "The rc passed, then I +pushed one more little fix" is exactly the habit this exists to break. + + python3 tools/release_gate.py v0.6.0 # exit 1 if not promotable + python3 tools/release_gate.py v0.6.0 --next-rc # -> the rc tag to cut next +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys + +from release_notes import base_version, is_prerelease, normalise + + +def _git(*args: str, cwd: str | None = None) -> str: + return subprocess.run( + ["git", *args], + cwd=cwd, capture_output=True, text=True, check=True, + ).stdout.strip() + + +def rc_tags(version: str, cwd: str | None = None) -> list[str]: + """Every rc tag for this version, oldest first (rc2 sorts after rc1).""" + want = normalise(base_version(version)) + out = _git("tag", "--list", f"v{want}-rc*", cwd=cwd) + tags = [t.strip() for t in out.splitlines() if t.strip()] + return sorted(tags, key=_rc_number) + + +def _rc_number(tag: str) -> int: + m = re.search(r"-rc(\d+)$", tag) + return int(m.group(1)) if m else 0 + + +def next_rc(version: str, cwd: str | None = None) -> str: + """The next rc tag to cut: v0.6.0-rc1, then -rc2, ...""" + existing = rc_tags(version, cwd=cwd) + n = max((_rc_number(t) for t in existing), default=0) + 1 + return f"v{normalise(base_version(version))}-rc{n}" + + +def commit_for(ref: str, cwd: str | None = None) -> str | None: + try: + return _git("rev-list", "-n", "1", ref, cwd=cwd) + except subprocess.CalledProcessError: + return None + + +def check_promotable(version: str, cwd: str | None = None) -> list[str]: + """Every reason v may not be cut as a stable release. + + Empty list means the barrier is satisfied. Does NOT talk to the network: CI + layers the "did that rc's run actually pass" check on top, because only CI can + see workflow results. + """ + if is_prerelease(version): + return [] # candidates are what the barrier exists to encourage + + want = normalise(version) + tag = f"v{want}" + + target = commit_for(tag, cwd=cwd) + if target is None: + return [f"{tag} does not exist"] + + candidates = rc_tags(want, cwd=cwd) + if not candidates: + return [ + f"{tag} was never a release candidate. Cut one first:\n" + f" bash release.sh {want} --rc\n" + f"Candidates are invisible to users -- debug there, not in a release." + ] + + matching = [c for c in candidates if commit_for(c, cwd=cwd) == target] + if not matching: + newest = candidates[-1] + return [ + f"{tag} points at {target[:12]}, but no release candidate does.\n" + f" Candidates: {', '.join(candidates)}\n" + f" Newest ({newest}) is at " + f"{(commit_for(newest, cwd=cwd) or '?')[:12]}.\n" + f"Code changed after the last candidate. That change is untested as a\n" + f"release: cut {next_rc(want, cwd=cwd)} and promote THAT commit." + ] + + return [] + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("version", help="e.g. v0.6.0") + ap.add_argument("--next-rc", action="store_true", + help="print the next rc tag to cut, and exit") + ap.add_argument("-C", dest="cwd", default=None, help="run git in this directory") + args = ap.parse_args(argv[1:]) + + if args.next_rc: + print(next_rc(args.version, cwd=args.cwd)) + return 0 + + problems = check_promotable(args.version, cwd=args.cwd) + for p in problems: + print(f"::error::{p}") + if problems: + return 1 + + print(f"v{normalise(args.version)} was a release candidate and may be promoted") + return 0 + + +if __name__ == "__main__": + # Running `python3 tools/release_gate.py` already puts tools/ on sys.path[0], + # which is what makes the `release_notes` import above resolve. + sys.exit(main(sys.argv)) diff --git a/tools/release_notes.py b/tools/release_notes.py index 2bc10af..f76391b 100644 --- a/tools/release_notes.py +++ b/tools/release_notes.py @@ -37,10 +37,53 @@ _VERSION_RE = re.compile(r'^(?:VERSION=|__version__\s*=\s*)"([^"]+)"', re.M) _HEADING_RE = re.compile(r"^##\s+v?(\d+\.\d+\.\d+[^\s]*)", re.M) +#: Work in progress lives here until a release promotes it. Batching through this +#: section is what stops "tag, find bug, tag again" from becoming twelve releases. +UNRELEASED = "Unreleased" + +_UNRELEASED_RE = re.compile(r"^##\s+Unreleased\s*$", re.M | re.I) +_RC_RE = re.compile(r"-(rc|beta|alpha)\d*$", re.I) + + def normalise(v: str) -> str: return v.strip().lstrip("v") +def is_prerelease(tag: str) -> bool: + """True for v1.2.3-rc1 / -beta / -alpha. Those never reach users.""" + return bool(_RC_RE.search(tag.strip())) + + +def base_version(tag: str) -> str: + """v1.2.3-rc2 -> 1.2.3""" + return _RC_RE.sub("", normalise(tag)) + + +def unreleased_body(text: str) -> str: + """Content under `## Unreleased`, or "" if the section is absent/empty.""" + m = _UNRELEASED_RE.search(text) + if not m: + return "" + rest = text[m.end():] + nxt = _HEADING_RE.search(rest) + return (rest[:nxt.start()] if nxt else rest).strip() + + +def promote(text: str, version: str, date: str) -> str: + """Rename `## Unreleased` to `## vX.Y.Z — date`. + + Refuses if the section is missing or empty: a release with nothing in it is a + release nobody needed, and cutting one only trains people to ignore alerts. + """ + if not unreleased_body(text): + raise ValueError( + "CHANGELOG.md has no `## Unreleased` content — nothing to release. " + "Add your changes there first." + ) + m = _UNRELEASED_RE.search(text) + return text[:m.start()] + f"## v{normalise(version)} — {date}" + text[m.end():] + + def script_versions(root: str = ROOT) -> dict[str, str]: """VERSION= as declared by each script.""" found = {} @@ -143,8 +186,12 @@ def significance(text: str, current: str, latest: str): def check(version: str, root: str = ROOT) -> list[str]: - """Every reason this version is not releasable. Empty list means it is.""" - want = normalise(version) + """Every reason this version is not releasable. Empty list means it is. + + `version` may be a release candidate (v1.2.3-rc2); the scripts and CHANGELOG + are checked against its BASE version, since an rc ships the same code. + """ + want = base_version(version) problems = [] versions = script_versions(root) @@ -170,6 +217,15 @@ def check(version: str, root: str = ROOT) -> list[str]: if not body: problems.append(f"CHANGELOG.md section for v{want} is empty") + # A stable release must not leave work stranded under `## Unreleased`. If it is + # finished enough to ship, it belongs in the release; if it is not, the release + # is premature. (An rc may legitimately have more work queued behind it.) + if not is_prerelease(version) and unreleased_body(text): + problems.append( + "CHANGELOG.md still has content under `## Unreleased` — either include " + "it in this release, or do not cut the release yet" + ) + return problems diff --git a/update.sh b/update.sh index 979d54b..4b30274 100644 --- a/update.sh +++ b/update.sh @@ -123,7 +123,7 @@ cd "$PATCH_DIR" if ! git rev-parse --git-dir >/dev/null 2>&1; then echo "ERROR: $PATCH_DIR is not a git clone — nothing to update." >&2 - echo " Re-clone from https://github.com/sudolulo/truenas-truecloud-patch" >&2 + echo " Re-clone from https://git.onetick.ninja/flan/truenas-truecloud-patch" >&2 exit 1 fi