Compare commits

...
2 Commits
Author SHA1 Message Date
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
10 changed files with 114 additions and 51 deletions
+31 -19
View File
@@ -193,24 +193,36 @@ jobs:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}"
run: |
body="$(jq -Rs . < /tmp/issue.md)"
title="$(printf '%s' "$TITLE" | jq -Rs .)"
# python3, not jq: jq is not guaranteed on a self-hosted runner, and a bug
# 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.
number="$(curl -sf -H "Authorization: token $TOKEN" \
"$API/issues?state=all&type=issues" \
| jq -r --arg t "$TITLE" '.[] | select(.title == $t) | .number' | head -1)"
api, token, title = os.environ["API"], os.environ["TOKEN"], os.environ["TITLE"]
with open("/tmp/issue.md", encoding="utf-8") as fh:
body = fh.read()
headers = {"Authorization": f"token {token}",
"Content-Type": "application/json"}
if [ -n "$number" ]; then
curl -sS -X POST "$API/issues/$number/comments" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$(printf '{"body":%s}' "$body")" -o /dev/null -w 'comment -> %{http_code}\n'
curl -sS -X PATCH "$API/issues/$number" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"state":"open"}' -o /dev/null -w 'reopen -> %{http_code}\n'
else
curl -sS -X POST "$API/issues" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$(printf '{"title":%s,"body":%s}' "$title" "$body")" \
-o /dev/null -w 'create -> %{http_code}\n'
fi
def call(url, method, data=None):
req = urllib.request.Request(
url, method=method, headers=headers,
data=json.dumps(data).encode() if data else None)
with urllib.request.urlopen(req) as r: # noqa: S310
return json.load(r) if r.length != 0 else {}
# Same title => same issue. Comment on it rather than filing a new one every
# morning: a bot that duplicates itself daily gets muted, and then it is not
# a warning system any more.
issues = call(f"{api}/issues?state=all&type=issues", "GET")
match = next((i for i in issues if i["title"] == title), None)
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 ;;
esac
# jq -Rs so the notes are JSON-encoded properly: the changelog is full of
# quotes, backticks and newlines, and hand-built JSON would mangle them.
body="$(jq -Rs . < /tmp/notes.md)"
payload="$(printf '{"tag_name":%s,"name":%s,"body":%s,"prerelease":%s}' \
"$(printf '%s' "$TAG" | jq -Rs .)" \
"$(printf '%s' "$TAG" | jq -Rs .)" \
"$body" "$prerelease")"
# python3, not jq. The changelog is full of quotes, backticks and newlines,
# so the body must be properly JSON-encoded -- but `jq` is not guaranteed on
# a self-hosted Gitea runner, and a publish step that dies on a missing tool
# leaves a tag with no release behind it. python3 is guaranteed: setup-python
# ran above.
python3 - "$TAG" "$API" "$TOKEN" "$prerelease" <<'PY'
import json, sys, urllib.error, urllib.request
existing="$(curl -sf -H "Authorization: token $TOKEN" \
"$API/releases/tags/$TAG" 2>/dev/null || true)"
tag, api, token, prerelease = sys.argv[1:5]
with open("/tmp/notes.md", encoding="utf-8") as fh:
body = fh.read()
if [ -n "$existing" ]; then
id="$(printf '%s' "$existing" | jq -r .id)"
echo "Release $TAG exists (id=$id) — updating notes."
curl -sS -X PATCH "$API/releases/$id" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" -o /dev/null -w 'PATCH -> %{http_code}\n'
else
curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" -o /dev/null -w 'POST -> %{http_code}\n'
fi
payload = {
"tag_name": tag, "name": tag, "body": body,
"prerelease": prerelease == "true",
}
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json",
}
def call(url, method, data=None):
req = urllib.request.Request(
url, method=method, headers=headers,
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
+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:
```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
cd /mnt/tank/truenas-truecloud-patch
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 |
|---|---|
| `[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. |
| `[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. |
+1 -1
View File
@@ -109,7 +109,7 @@ If a module shows `[FAIL]`:
risk.
4. **If the detail says the module doesn't exist**, a TrueNAS update renamed
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.
---
+2 -2
View File
@@ -4,7 +4,7 @@
# Prerequisites: run as root on TrueNAS SCALE with middlewared running.
# Clone this repository to a persistent ZFS pool first:
#
# git clone https://git.onetick.ninja/flan/truenas-truecloud-patch \
# git clone https://github.com/sudolulo/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://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 " cd /mnt/<pool>/truenas-truecloud-patch && bash install.sh" >&2
exit 1
+3 -3
View File
@@ -111,7 +111,7 @@ def main():
print(
"[truecloud-patch] WARNING: filterByProviders pattern not found in any JS bundle.\n"
"[truecloud-patch] The TrueNAS webui may have been restructured in this version.\n"
"[truecloud-patch] File an issue at https://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()}"
)
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://git.onetick.ninja/flan/truenas-truecloud-patch"
f"https://github.com/sudolulo/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://git.onetick.ninja/flan/truenas-truecloud-patch"
"https://github.com/sudolulo/truenas-truecloud-patch"
)
return
+28
View File
@@ -205,3 +205,31 @@ class TestSignificance:
assert version_tuple("0.4.2") > version_tuple("0.4.1")
# Pre-release suffixes are dropped, not ranked above the release.
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:
"""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
wrong body is worse than a failed release.
"""
want = normalise(version)
want = base_version(version)
lines = text.splitlines()
start = None
+1 -1
View File
@@ -123,7 +123,7 @@ cd "$PATCH_DIR"
if ! git rev-parse --git-dir >/dev/null 2>&1; then
echo "ERROR: $PATCH_DIR is not a git clone — nothing to update." >&2
echo " Re-clone from https://git.onetick.ninja/flan/truenas-truecloud-patch" >&2
echo " Re-clone from https://github.com/sudolulo/truenas-truecloud-patch" >&2
exit 1
fi