Make Gitea canonical; derive the changelog URL from the remote instead of hard-coding GitHub
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user