Compare commits

...
3 Commits
Author SHA1 Message Date
flan 469a2e4651 Installing the patch permanently blocked updating it
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
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
flan 518a22d87e docs: user-facing URLs point at GitHub, the user-facing repo
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 14s
CI / python 3.13 (push) Successful in 15s
TrueNAS compatibility / compat (push) Failing after 6s
Release / release (push) Successful in 14s
Gitea is canonical for development; GitHub is where users clone from and where the
box's read-only checkout points. The install instructions, the re-clone hint and the
'file an issue' link are all read by users, so they name GitHub. docs/releasing.md
still names Gitea, because that is a contributor doc about where the code is pushed.
2026-07-13 18:42:44 +00:00
flan 8c1b4c45f5 release: rc notes resolve to the base version; publish without jq
CI / shell (shellcheck + syntax) (push) Successful in 14s
CI / python 3.11 (push) Successful in 17s
CI / python 3.12 (push) Successful in 20s
CI / python 3.13 (push) Successful in 21s
TrueNAS compatibility / compat (push) Successful in 10s
Release / release (push) Successful in 14s
release_notes.py 'notes v0.6.0-rc1' looked for a CHANGELOG section literally named
v0.6.0-rc1. check() already used base_version(); extract_notes() did not. So the
release workflow cut the tag, passed every gate, and then died extracting the body --
the candidate existed but was never published.

Caught in an rc, which is the entire point of having them.

Also: the Gitea publish and issue steps used jq, which is not guaranteed on a
self-hosted runner. A publish step that dies on a missing tool leaves a tag with no
release behind it, and a bug report that dies on one is a warning system that does
not warn. Both now use python3, which setup-python guarantees.
2026-07-13 18:38:02 +00:00
13 changed files with 177 additions and 53 deletions
+31 -19
View File
@@ -193,24 +193,36 @@ jobs:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}" TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}"
run: | run: |
body="$(jq -Rs . < /tmp/issue.md)" # python3, not jq: jq is not guaranteed on a self-hosted runner, and a bug
title="$(printf '%s' "$TITLE" | jq -Rs .)" # report that dies on a missing tool is a warning system that does not warn.
python3 - <<'PY'
import json, os, urllib.error, urllib.request
# Same title => same issue. Comment on it instead of filing a new one. api, token, title = os.environ["API"], os.environ["TOKEN"], os.environ["TITLE"]
number="$(curl -sf -H "Authorization: token $TOKEN" \ with open("/tmp/issue.md", encoding="utf-8") as fh:
"$API/issues?state=all&type=issues" \ body = fh.read()
| jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1)" headers = {"Authorization": f"token {token}",
"Content-Type": "application/json"}
if [ -n "$number" ]; then def call(url, method, data=None):
curl -sS -X POST "$API/issues/$number/comments" \ req = urllib.request.Request(
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ url, method=method, headers=headers,
-d "$(printf '{"body":%s}' "$body")" -o /dev/null -w 'comment -> %{http_code}\n' data=json.dumps(data).encode() if data else None)
curl -sS -X PATCH "$API/issues/$number" \ with urllib.request.urlopen(req) as r: # noqa: S310
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ return json.load(r) if r.length != 0 else {}
-d '{"state":"open"}' -o /dev/null -w 'reopen -> %{http_code}\n'
else # Same title => same issue. Comment on it rather than filing a new one every
curl -sS -X POST "$API/issues" \ # morning: a bot that duplicates itself daily gets muted, and then it is not
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ # a warning system any more.
-d "$(printf '{"title":%s,"body":%s}' "$title" "$body")" \ issues = call(f"{api}/issues?state=all&type=issues", "GET")
-o /dev/null -w 'create -> %{http_code}\n' match = next((i for i in issues if i["title"] == title), None)
fi
if match:
n = match["number"]
call(f"{api}/issues/{n}/comments", "POST", {"body": body})
call(f"{api}/issues/{n}", "PATCH", {"state": "open"})
print(f"commented on and reopened issue #{n}")
else:
made = call(f"{api}/issues", "POST", {"title": title, "body": body})
print(f"filed issue #{made['number']}")
PY
+40 -22
View File
@@ -164,27 +164,45 @@ jobs:
*-rc*|*-beta*|*-alpha*) prerelease=true ;; *-rc*|*-beta*|*-alpha*) prerelease=true ;;
esac esac
# jq -Rs so the notes are JSON-encoded properly: the changelog is full of # python3, not jq. The changelog is full of quotes, backticks and newlines,
# quotes, backticks and newlines, and hand-built JSON would mangle them. # so the body must be properly JSON-encoded -- but `jq` is not guaranteed on
body="$(jq -Rs . < /tmp/notes.md)" # a self-hosted Gitea runner, and a publish step that dies on a missing tool
payload="$(printf '{"tag_name":%s,"name":%s,"body":%s,"prerelease":%s}' \ # leaves a tag with no release behind it. python3 is guaranteed: setup-python
"$(printf '%s' "$TAG" | jq -Rs .)" \ # ran above.
"$(printf '%s' "$TAG" | jq -Rs .)" \ python3 - "$TAG" "$API" "$TOKEN" "$prerelease" <<'PY'
"$body" "$prerelease")" import json, sys, urllib.error, urllib.request
existing="$(curl -sf -H "Authorization: token $TOKEN" \ tag, api, token, prerelease = sys.argv[1:5]
"$API/releases/tags/$TAG" 2>/dev/null || true)" with open("/tmp/notes.md", encoding="utf-8") as fh:
body = fh.read()
if [ -n "$existing" ]; then payload = {
id="$(printf '%s' "$existing" | jq -r .id)" "tag_name": tag, "name": tag, "body": body,
echo "Release $TAG exists (id=$id) — updating notes." "prerelease": prerelease == "true",
curl -sS -X PATCH "$API/releases/$id" \ }
-H "Authorization: token $TOKEN" \ headers = {
-H "Content-Type: application/json" \ "Authorization": f"token {token}",
-d "$payload" -o /dev/null -w 'PATCH -> %{http_code}\n' "Content-Type": "application/json",
else }
curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \ def call(url, method, data=None):
-H "Content-Type: application/json" \ req = urllib.request.Request(
-d "$payload" -o /dev/null -w 'POST -> %{http_code}\n' url, method=method, headers=headers,
fi data=json.dumps(data).encode() if data else None)
with urllib.request.urlopen(req) as r: # noqa: S310
return r.status, json.load(r) if r.length != 0 else {}
try:
_, existing = call(f"{api}/releases/tags/{tag}", "GET")
except urllib.error.HTTPError as e:
if e.code != 404:
raise
existing = None
if existing:
status, _ = call(f"{api}/releases/{existing['id']}", "PATCH", payload)
print(f"updated release {tag} -> {status}")
else:
status, _ = call(f"{api}/releases", "POST", payload)
print(f"created release {tag} (prerelease={payload['prerelease']}) -> {status}")
PY
+12
View File
@@ -66,6 +66,18 @@ worse than no alert, because one day it carries a security fix.
### Fixed ### Fixed
- **Installing the patch permanently blocked updating it.** `install.sh` does
`chmod +x 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 run
`git checkout -- .`, which just undoes the exec bit so the next install can re-dirty
it. A real box sat on an old version for exactly this reason.
Fixed on both sides: the scripts `install.sh` chmods are now executable in git (so
the chmod is a no-op), and `update.sh`'s dirty check now looks at **content**, not
file 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.
- **Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all - **Nested snapshots were broken on TrueNAS 24.10 and 25.04, and had been all
along.** `SYNC_BLOCK`'s wrapper spelled out the stock signature and forwarded five along.** `SYNC_BLOCK`'s wrapper spelled out the stock signature and forwarded five
arguments — but those releases declare `restic_backup(middleware, job, arguments — but those releases declare `restic_backup(middleware, job,
+1 -1
View File
@@ -22,7 +22,7 @@ Clone it onto a **pool** (not the boot device — that is wiped on TrueNAS upgra
then run `install.sh` as root: then run `install.sh` as root:
```bash ```bash
git clone https://git.onetick.ninja/flan/truenas-truecloud-patch.git \ git clone https://github.com/sudolulo/truenas-truecloud-patch.git \
/mnt/tank/truenas-truecloud-patch # replace `tank` with your pool /mnt/tank/truenas-truecloud-patch # replace `tank` with your pool
cd /mnt/tank/truenas-truecloud-patch cd /mnt/tank/truenas-truecloud-patch
sudo bash install.sh sudo bash install.sh
+1 -1
View File
@@ -158,7 +158,7 @@ python3 /mnt/tank/truenas-truecloud-patch/patch/create_task.py verify
| What you see | What it means | | What you see | What it means |
|---|---| |---|---|
| `[OK] providers`, `[OK]`/`[SKIP] nested_snapshots` | Fine. Nothing to do. | | `[OK] providers`, `[OK]`/`[SKIP] nested_snapshots` | Fine. Nothing to do. |
| `WARNING: … pattern not found` (UI) | The Angular bundle changed. The UI dropdown reverts to Storj-only, but **backups keep working** — create tasks with `create_task.py` meanwhile, and [open an issue](https://git.onetick.ninja/flan/truenas-truecloud-patch/issues) with your TrueNAS version. | | `WARNING: … pattern not found` (UI) | The Angular bundle changed. The UI dropdown reverts to Storj-only, but **backups keep working** — create tasks with `create_task.py` meanwhile, and [open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues) with your TrueNAS version. |
| `WARNING: truecloud-patch is NOT COMPATIBLE with this TrueNAS version` | This TrueNAS changed middleware underneath the patch, and the named module was **deliberately not applied** — see `incompatible.json` for exactly which assumption broke. TrueNAS is left stock, so nothing is half-patched. Check [TrueNAS compatibility](../README.md#truenas-compatibility), then `bash update.sh` once a release supports your version; it re-applies itself on the next boot. This is **not** the kill switch and needs no manual reset. | | `WARNING: truecloud-patch is NOT COMPATIBLE with this TrueNAS version` | This TrueNAS changed middleware underneath the patch, and the named module was **deliberately not applied** — see `incompatible.json` for exactly which assumption broke. TrueNAS is left stock, so nothing is half-patched. Check [TrueNAS compatibility](../README.md#truenas-compatibility), then `bash update.sh` once a release supports your version; it re-applies itself on the next boot. This is **not** the kill switch and needs no manual reset. |
| `[FAIL] providers` | **Your B2/S3 backups will not run.** middlewared is fine, but the credential/URL handling is gone. Open an issue with your version. | | `[FAIL] providers` | **Your B2/S3 backups will not run.** middlewared is fine, but the credential/URL handling is gone. Open an issue with your version. |
| `[FAIL] nested_snapshots` | The stock guard is back, so tasks with `snapshot = true` on a nested dataset will fail validation. Turn the option off on those tasks until it's fixed. | | `[FAIL] nested_snapshots` | The stock guard is back, so tasks with `snapshot = true` on a nested dataset will fail validation. Turn the option off on those tasks until it's fixed. |
+1 -1
View File
@@ -109,7 +109,7 @@ If a module shows `[FAIL]`:
risk. risk.
4. **If the detail says the module doesn't exist**, a TrueNAS update renamed 4. **If the detail says the module doesn't exist**, a TrueNAS update renamed
or restructured the internal API. or restructured the internal API.
[Open an issue](https://git.onetick.ninja/flan/truenas-truecloud-patch/issues) [Open an issue](https://github.com/sudolulo/truenas-truecloud-patch/issues)
with your TrueNAS version number and the full verify output. with your TrueNAS version number and the full verify output.
--- ---
+2 -2
View File
@@ -4,7 +4,7 @@
# Prerequisites: run as root on TrueNAS SCALE with middlewared running. # Prerequisites: run as root on TrueNAS SCALE with middlewared running.
# Clone this repository to a persistent ZFS pool first: # Clone this repository to a persistent ZFS pool first:
# #
# git clone https://git.onetick.ninja/flan/truenas-truecloud-patch \ # git clone https://github.com/sudolulo/truenas-truecloud-patch \
# /mnt/<pool>/truenas-truecloud-patch # /mnt/<pool>/truenas-truecloud-patch
# cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh # cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh
# #
@@ -72,7 +72,7 @@ done
if [ ! -f "$PATCH_DIR/patch/apply.sh" ]; then if [ ! -f "$PATCH_DIR/patch/apply.sh" ]; then
echo "ERROR: patch files not found at $PATCH_DIR/patch/" >&2 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 "Run install.sh from a clone of the repository on a persistent pool:" >&2
echo " git clone https://git.onetick.ninja/flan/truenas-truecloud-patch \\" >&2 echo " git clone https://github.com/sudolulo/truenas-truecloud-patch \\" >&2
echo " /mnt/<pool>/truenas-truecloud-patch" >&2 echo " /mnt/<pool>/truenas-truecloud-patch" >&2
echo " cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh" >&2 echo " cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh" >&2
exit 1 exit 1
+3 -3
View File
@@ -111,7 +111,7 @@ def main():
print( print(
"[truecloud-patch] WARNING: filterByProviders pattern not found in any JS bundle.\n" "[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] The TrueNAS webui may have been restructured in this version.\n"
"[truecloud-patch] File an issue at https://git.onetick.ninja/flan/truenas-truecloud-patch\n" "[truecloud-patch] File an issue at https://github.com/sudolulo/truenas-truecloud-patch\n"
f"[truecloud-patch] TrueNAS version info: {_tnversion()}" f"[truecloud-patch] TrueNAS version info: {_tnversion()}"
) )
return return
@@ -136,7 +136,7 @@ def main():
f"[truecloud-patch] WARNING: {count} replacement(s) in {path}; " f"[truecloud-patch] WARNING: {count} replacement(s) in {path}; "
f"expected exactly 1 — skipping write to avoid corrupting the bundle.\n" f"expected exactly 1 — skipping write to avoid corrupting the bundle.\n"
f"[truecloud-patch] File an issue at " f"[truecloud-patch] File an issue at "
f"https://git.onetick.ninja/flan/truenas-truecloud-patch" f"https://github.com/sudolulo/truenas-truecloud-patch"
) )
return return
@@ -154,7 +154,7 @@ def main():
"[truecloud-patch] The UI is UNCHANGED and still works. This means the " "[truecloud-patch] The UI is UNCHANGED and still works. This means the "
"pattern no longer fits this TrueNAS build.\n" "pattern no longer fits this TrueNAS build.\n"
"[truecloud-patch] File an issue at " "[truecloud-patch] File an issue at "
"https://git.onetick.ninja/flan/truenas-truecloud-patch" "https://github.com/sudolulo/truenas-truecloud-patch"
) )
return return
Regular → Executable
View File
+37
View File
@@ -94,3 +94,40 @@ class TestTheReadmeStaysAReadme:
assert "24.10" in text[:text.index("## Install")], ( assert "24.10" in text[:text.index("## Install")], (
"the minimum TrueNAS version must be visible above the install steps" "the minimum TrueNAS version must be visible above the install steps"
) )
class TestInstallDoesNotDirtyTheCheckout:
"""install.sh chmod +x's scripts. If git records them as 100644, that chmod is a
TRACKED MODIFICATION -- and update.sh refuses to run over a dirty tree.
So installing once permanently blocked updating, for every user, with a message
telling them to `git checkout -- .` (which would just undo the exec bit and let
the next install re-dirty it). Found on a real box that had been stuck on an old
version for exactly this reason.
Every script install.sh makes executable must already be executable in git.
"""
def test_every_chmodded_script_is_already_executable_in_git(self):
import re
import subprocess
with open(os.path.join(ROOT, "install.sh"), encoding="utf-8") as fh:
m = re.search(r"^for _exe in (.+?); do", fh.read(), re.M)
assert m, "could not find install.sh's chmod loop"
scripts = m.group(1).split()
out = subprocess.run(
["git", "ls-files", "-s", *scripts],
cwd=ROOT, capture_output=True, text=True, check=True,
).stdout
not_exec = [
line.split("\t")[-1] for line in out.strip().splitlines()
if not line.startswith("100755")
]
assert not not_exec, (
"install.sh chmod +x's these, but git records them as non-executable — "
"so installing dirties the checkout and update.sh then refuses to run:\n "
+ "\n ".join(not_exec)
)
+28
View File
@@ -205,3 +205,31 @@ class TestSignificance:
assert version_tuple("0.4.2") > version_tuple("0.4.1") assert version_tuple("0.4.2") > version_tuple("0.4.1")
# Pre-release suffixes are dropped, not ranked above the release. # Pre-release suffixes are dropped, not ranked above the release.
assert version_tuple("v0.5.0-rc1") == version_tuple("v0.5.0") assert version_tuple("v0.5.0-rc1") == version_tuple("v0.5.0")
class TestCandidateNotesResolveToTheBaseVersion:
"""`notes v0.6.0-rc1` must return v0.6.0's section.
A candidate ships the same code as the release it is a candidate for, and the
CHANGELOG only ever has the one section. Without this, the release workflow cut
v0.6.0-rc1, passed every gate, and then died extracting the body -- so the tag
existed but nothing was ever published. Caught in an rc, which is the entire
point of having them.
"""
CHANGELOG = "# C\n\n## v0.6.0 — 2026-07-13\n\n### Added\n- the thing\n\n## v0.5.1 — 2026-07-13\n\n- older\n"
def test_an_rc_resolves_to_its_base_version(self):
body = extract_notes(self.CHANGELOG, "v0.6.0-rc1")
assert "the thing" in body
assert "older" not in body
def test_rc10_too(self):
assert "the thing" in extract_notes(self.CHANGELOG, "v0.6.0-rc10")
def test_the_plain_version_still_works(self):
assert "the thing" in extract_notes(self.CHANGELOG, "v0.6.0")
def test_a_genuinely_missing_section_still_raises(self):
with pytest.raises(KeyError):
extract_notes(self.CHANGELOG, "v9.9.9-rc1")
+6 -1
View File
@@ -107,10 +107,15 @@ def changelog_versions(text: str) -> list[str]:
def extract_notes(text: str, version: str) -> str: def extract_notes(text: str, version: str) -> str:
"""The body of one version's section, without its heading. """The body of one version's section, without its heading.
A release candidate resolves to its BASE version: v0.6.0-rc2 ships the same code
as v0.6.0 and therefore the same notes, and the CHANGELOG only ever has the one
section. Without this, the release workflow cut the tag, passed every gate, and
then died extracting the body -- so the candidate existed but was never published.
Raises KeyError if the version has no section -- a release with an empty or Raises KeyError if the version has no section -- a release with an empty or
wrong body is worse than a failed release. wrong body is worse than a failed release.
""" """
want = normalise(version) want = base_version(version)
lines = text.splitlines() lines = text.splitlines()
start = None start = None
Regular → Executable
+15 -3
View File
@@ -123,7 +123,7 @@ cd "$PATCH_DIR"
if ! git rev-parse --git-dir >/dev/null 2>&1; then 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 "ERROR: $PATCH_DIR is not a git clone — nothing to update." >&2
echo " Re-clone from https://git.onetick.ninja/flan/truenas-truecloud-patch" >&2 echo " Re-clone from https://github.com/sudolulo/truenas-truecloud-patch" >&2
exit 1 exit 1
fi fi
@@ -136,9 +136,21 @@ fi
# A dirty tree means someone edited or scp'd files in place; merging over that # A dirty tree means someone edited or scp'd files in place; merging over that
# silently loses their changes, or conflicts halfway through. # silently loses their changes, or conflicts halfway through.
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then #
# CONTENT changes only. A mode-only change (100644 -> 100755) is not somebody's work
# and must not block an update -- and it is not hypothetical: install.sh chmod +x's
# these very scripts, so on any version where git recorded one as 100644, INSTALLING
# dirtied the checkout and update.sh then refused to run. Install once, and updating
# was blocked forever, with an error telling the user to `git checkout -- .` (which
# merely undoes the exec bit so the next install can re-dirty it). A real box sat on
# an old version for exactly this reason.
#
# `git diff --numstat` reports "0 0 file" for a mode-only change, so anything with a
# nonzero insert or delete count is a genuine edit.
_dirty=$(git diff --numstat HEAD -- . | awk '$1 != 0 || $2 != 0 { print $3 }')
if [ -n "$_dirty" ]; then
echo "ERROR: the working tree has uncommitted changes:" >&2 echo "ERROR: the working tree has uncommitted changes:" >&2
git status --short --untracked-files=no >&2 printf ' M %s\n' $_dirty >&2
echo "" >&2 echo "" >&2
echo " Refusing to update over them. Commit, stash, or discard them first:" >&2 echo " Refusing to update over them. Commit, stash, or discard them first:" >&2
echo " git -C $PATCH_DIR checkout -- ." >&2 echo " git -C $PATCH_DIR checkout -- ." >&2