Files
flan 469a2e4651
CI / shell (shellcheck + syntax) (push) Successful in 9s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 16s
CI / python 3.13 (push) Successful in 17s
TrueNAS compatibility / compat (push) Successful in 11s
Release / release (push) Successful in 14s
Installing the patch permanently blocked updating it
install.sh chmod +x's update.sh, and git recorded update.sh as 100644 -- so the chmod
was a TRACKED modification, and update.sh refuses to run over a dirty tree. Install
once and you could never update again. The error even told you to 'git checkout -- .',
which just undoes the exec bit so the next install can re-dirty it.

Found on the real box, which had been sitting on v0.4.1 for exactly this reason.

Fixed on both sides: the scripts install.sh chmods are executable in git (so the
chmod is a no-op), and update.sh's dirty check now looks at CONTENT, not mode --
git diff --numstat reports 0 0 for a mode-only change. A test asserts every script in
install.sh's chmod loop is already 100755 in git.
2026-07-13 18:58:46 +00:00

333 lines
13 KiB
Bash
Executable File

#!/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"
# UNTRACKED files count as dirty, because --rc runs `git add -A`: an untracked file
# lying around would be swept into the release commit and pushed. This checkout is
# routinely shared between sessions, so stray files are the normal state here, not
# an exotic one. (Anything genuinely ignorable belongs in .gitignore.)
if [ -n "$(git status --porcelain)" ]; then
echo " Working tree is not clean:" >&2
git status --short >&2
die "commit, stash, or ignore the above first -- a release must be reproducible
from a commit, not from whatever happened to be on disk. --rc runs
'git add -A', so an untracked file here ships inside the release."
fi
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
# BEHIND is just as bad as ahead, and less obvious. --rc commits the version stamp,
# creates the tag, and only THEN pushes -- so on a stale main the push is rejected
# (non-fast-forward) *after* the tag exists and `## Unreleased` has already been
# consumed. Re-running then sees rc1, cuts rc2, and silently skips the stamping
# step; the rc1 tag dangles locally forever.
if [ -n "$(git log --oneline "$branch..origin/$branch" 2>/dev/null)" ]; then
die "local main is BEHIND origin. Pull first: git pull --ff-only
Releasing from a stale main half-completes: the tag is cut locally, the push
is rejected, and '## Unreleased' has already been consumed."
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 ───────────
# The interpreter that actually has the dev deps. A bare `python3` is usually the
# system one with no pytest -- and "No module named pytest" would read as "tests
# fail", i.e. the gate blocking a release for a reason that is not true.
PY=python3
[ -x .venv/bin/python ] && PY=.venv/bin/python
RUFF=""
if [ -x .venv/bin/ruff ]; then RUFF=.venv/bin/ruff
elif command -v ruff >/dev/null 2>&1; then RUFF=ruff
fi
run_tests() {
note "running the suite"
"$PY" -c 'import pytest' 2>/dev/null || die "no pytest in $PY. Install the dev deps:
python3 -m venv .venv && .venv/bin/pip install pytest ruff"
"$PY" -m pytest tests -q || die "tests fail. Fix them; do not release around them."
[ -n "$RUFF" ] && { "$RUFF" check patch tests tools || die "lint fails"; }
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 .)"
# Tests BEFORE the stamping commit, deliberately.
#
# Stamping consumes `## Unreleased` and makes a "release vX.Y.Z" commit. If the
# suite then failed, that commit was already on main and a re-run died inside
# promote() with "no `## Unreleased` content" -- the release was wedged, and the
# only way out was to hand-unpick a commit. Failing first leaves the tree
# untouched.
run_tests
# 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.
#
# "Already stamped" is decided by the TREE, not by the rc1 tag: if a previous run
# stamped and committed but died before tagging (or before pushing), the tag is
# absent while the stamp is present, and re-stamping would try to promote an
# `## Unreleased` section that is no longer there.
if python3 tools/release_notes.py check "v$target-rc0" >/dev/null 2>&1; 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_tests already ran, above, before anything was committed.)
run_gates "$tag"
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?"
# Push the branch FIRST. If it is rejected, no tag has been created yet -- a tag
# pointing at a commit nobody else has is worse than no tag, because the next run
# sees it, counts it as a candidate, and cuts rc2 against a commit that was never
# published.
git push --quiet origin main
git tag -a "$tag" -m "$tag"
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