Make Gitea canonical; derive the changelog URL from the remote instead of hard-coding GitHub

This commit is contained in:
2026-07-13 17:21:30 +00:00
parent 8a82bde531
commit 4ced730d65
12 changed files with 1489 additions and 19 deletions
+40 -1
View File
@@ -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:-<no run found>}"
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
+42
View File
@@ -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
+2 -2
View File
@@ -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/<pool>/truenas-truecloud-patch
# cd /mnt/<pool>/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/<pool>/truenas-truecloud-patch" >&2
echo " cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh" >&2
exit 1
+37 -9
View File
@@ -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:
+77 -1
View File
@@ -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."
+3 -3
View File
@@ -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
+284
View File
@@ -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/tty
case "$reply" in [yY]*) return 0 ;; *) die "aborted" ;; esac
}
# ── check ────────────────────────────────────────────────────────────────────
if [ "$mode" = "check" ]; then
echo
python3 - "$target" <<'PY'
import sys, os
sys.path.insert(0, os.path.join(os.getcwd(), "tools"))
from release_notes import unreleased_body
with open("CHANGELOG.md", encoding="utf-8") as fh:
body = unreleased_body(fh.read())
if body:
print("Unreleased, and would ship as v%s:\n" % sys.argv[1])
print("\n".join(" " + line for line in body.splitlines()))
else:
print("Nothing under `## Unreleased`. There is nothing to release.")
PY
echo
next_rc="$(python3 tools/release_gate.py "$target" --next-rc -C .)"
echo "Next candidate would be: $next_rc"
exit 0
fi
# ── stage 1: release candidate ───────────────────────────────────────────────
if [ "$mode" = "rc" ]; then
tag="$(python3 tools/release_gate.py "$target" --next-rc -C .)"
# The first candidate promotes `## Unreleased` and stamps the version into every
# script. Later candidates (rc2+) are re-cuts of an already-stamped version, so
# they only tag -- the CHANGELOG section for this version already exists, and
# fixes found during rc go into it.
if git rev-parse -q --verify "refs/tags/v$target-rc1" >/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
+231
View File
@@ -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) == []
+570
View File
@@ -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))
+144
View File
@@ -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<version> 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))
+58 -2
View File
@@ -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
+1 -1
View File
@@ -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