4.1 KiB
Development and releasing
Part of truenas-truecloud-patch.
Development
Parts of this project were written with AI assistance (Claude). All of it is reviewed and tested before release; the test suite and CI exist in large part to make that review meaningful. Bugs are mine.
pip install pytest ruff
ruff check patch tests tools
pytest tests
CI runs shellcheck, bash -n, ruff, and pytest on Python 3.11–3.13. Two checks
are worth calling out, because nothing else would catch what they catch:
- The tests
compile()the*_BLOCKstrings inpatch/apply.sh. Those are Python source appended into livemiddlewaredmodules — a syntax error there breaks the box at boot, and they're string literals, so nothing else type-checks them. - CI asserts every script declares the same version, and that it matches the
newest CHANGELOG entry.
VERSION=had silently drifted to three different values across the scripts before anything checked.
The project is hosted on Gitea (git.arch.fyi/flan/truenas-truecloud-patch)
and mirrored to GitHub. Both run the same workflows — Gitea reads
.github/workflows/ too — so a change is checked twice, on two independent runners.
Releasing
Every release interrupts every user. An update alert fires on each installed box (see Update alerts), so a release that exists only to fix the last release teaches people to dismiss the alert — and one day that alert will be carrying a security fix. This project cut twelve releases in a single day once. Never again, and not by good intentions: by a gate.
The rule
A stable
vX.Y.Zmay only be published if avX.Y.Z-rcNtag points at the same commit.
Release candidates are invisible to users: update.sh and the update alert both
take the newest plain vX.Y.Z tag, so an -rc is never offered as an update. All
the debugging therefore happens across rc1, rc2, rc3 — at nobody's expense —
instead of across v0.5.0, v0.5.1, v0.5.2, at everybody's.
"The candidate passed, then I pushed one more little fix" is refused by name. That is not hypothetical; it is exactly how v0.5.1 happened.
Day to day
You don't touch the release machinery. Write your changes under ## Unreleased in
CHANGELOG.md and push to main. main is a work surface — it is allowed to be
mid-thought. Releasing is a separate, deliberate act.
Cutting a release
bash release.sh 0.6.0 --check # what would ship? what is the next rc?
bash release.sh 0.6.0 --rc # promotes `## Unreleased` -> v0.6.0, stamps every
# VERSION=, tags v0.6.0-rc1, pushes. Users see nothing.
# ... install it on a real box. Exercise it. Break it. ...
# Found a bug? Fix it on main, then `bash release.sh 0.6.0 --rc` again -> rc2.
bash release.sh 0.6.0 --promote # publishes v0.6.0. REFUSED unless an rc points here.
The gates, and where they live
The logic is Python so it can be unit-tested; CI is the enforcement boundary
because it is the only actor holding the token that publishes. release.sh runs the
same code locally so you fail in 200 ms instead of after a push.
| gate | enforces | where |
|---|---|---|
release_notes.py check |
every script's VERSION= matches the tag; the CHANGELOG section exists and is non-empty; nothing is stranded under ## Unreleased |
release.sh + CI |
release_gate.py |
an rc points at this exact commit | release.sh + CI |
| the full suite | ruff, pytest, shellcheck, bash -n — re-run against the tagged commit |
CI |
A release's body is its CHANGELOG.md section — there is no second place to
write release notes, and therefore no second place for them to go stale. Releases
are published on both forges.
Why the alert doesn't nag
A release whose CHANGELOG contains only a ### Docs section changed no code, and
raises no alert. Candidates raise no alert either. So the only thing that ever
interrupts a user is a real, complete change — which is the entire point.