name: TrueNAS compatibility # Find out that iX broke us BEFORE their release ships, not after a user's backup # fails. # # This patch appends code to middlewared's internal modules. There is no stability # contract: TrueNAS 26 rewrote the whole cloud_backup path from async to sync, and # every block the nested module injects is an `async def` wrapping an `await`ed # original. Nobody would have found out until a restore did not work. # # tools/compat.py records what the patch assumes and checks it against iX's actual # source at every release line -- including master and the current BETA/RC, which is # where a break shows up first. When an UNRELEASED line breaks, this opens a bug # report so there is time to fix it before that version reaches anyone. # # Runs on both forges: Gitea (canonical) and GitHub (mirror). Only the "file an # issue" call differs. on: schedule: - cron: "17 6 * * *" # daily, off the hour: everyone crons on the hour workflow_dispatch: push: paths: # The manifest itself changed -- re-check immediately rather than waiting a day. - "tools/compat.py" - ".github/workflows/compat.yml" permissions: contents: read issues: write jobs: compat: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" # ONE pass over the network. Every other step renders from this JSON -- calling # compat.py three times would re-fetch every file from every release line three # times, and could even disagree with itself if iX pushed mid-run. # # The exit code is CAPTURED, not allowed to abort the step: a nonzero exit means # "a shipped release is broken", which is a result to report, not a reason to # die before reporting it. (Actions runs `bash -e`, so `cmd > out` followed by # `echo $?` never reaches the echo.) It becomes a job failure at the end, after # the bug report has been filed. - name: check every TrueNAS release line id: check run: | rc=0 python3 tools/compat.py --matrix --json > /tmp/matrix.json || rc=$? echo "shipped_broken=$rc" >> "$GITHUB_OUTPUT" # The report body is written to a FILE, and never becomes a step output. # # An earlier version did `echo "${{ steps.report.outputs.body }}"`, which # splices the text into the shell script itself -- and the report is full of # backticks, so bash ran `create-snapshot`, `def` and `async` as commands. It # is also an injection vector: the report is built from iX's source, so # anything that lands in middleware would execute on the runner. # # The rule that avoids the whole class: never interpolate ${{ }} into a `run:` # body. Files for data, `env:` for scalars (the runner sets those, rather than # pasting them into the script). - name: build the report id: report run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import json, sys sys.path.insert(0, "tools") import compat with open("/tmp/matrix.json") as fh: rows = json.load(fh) with open("/tmp/matrix.md", "w") as fh: fh.write(compat.render_markdown(rows)) broken = [ r for r in rows if any(compat.is_broken(m) for m in r["modules"].values()) ] native = [ (r["ref"], mod) for r in rows for mod, m in sorted(r["modules"].items()) if m["native"] and not compat.is_broken(m) ] lines = [ "`tools/compat.py` found that the patch's assumptions about " "middlewared no longer hold.", "", compat.render_markdown(rows), "", ] for r in broken: lines.append(f"### {r['ref']}") lines.append("") for mod, m in sorted(r["modules"].items()): if not compat.is_broken(m): continue lines.append(f"**{mod}** — the patch will not apply:") lines.append("") for p in m["problems"]: lines.append(f"- `{p['id']}`: {p['detail']}") lines.append(f" - why it matters: {p['why']}") lines.append("") for ref, mod in native: lines.append( f"- `{ref}`: **{mod}** appears to be NATIVE now — retire the " f"module rather than fixing it." ) lines += ["", "_Filed automatically by `.github/workflows/compat.yml`._"] with open("/tmp/issue.md", "w") as fh: fh.write("\n".join(lines)) # Scalars only. The body stays in the file. print(f"broken={'1' if broken else '0'}") print(f"refs={','.join(r['ref'] for r in broken)}") PY - name: matrix run: cat /tmp/matrix.md # Keep the README's table true. A support matrix that quietly goes stale is not # a stale doc -- it is a false promise to somebody deciding whether to trust # this with their backups. # # Only ever touches the block between the COMPAT MATRIX markers, and only on # the canonical host (Gitea) so the two forges cannot race each other. The # `paths:` trigger above does not include README.md, so this cannot re-trigger # itself; and a README change is documentation-only, which by design raises no # update alert on anyone's box. - name: refresh the README matrix if: ${{ github.event_name == 'schedule' && !contains(github.server_url, 'github.com') }} run: | python3 - <<'PY' import json, sys sys.path.insert(0, "tools") import compat with open("/tmp/matrix.json") as fh: rows = json.load(fh) print("changed" if compat.update_readme(rows) else "unchanged") PY if ! git diff --quiet -- README.md; then git config user.name "truecloud-patch bot" git config user.email "bot@onetick.ninja" git add README.md git commit -m "docs: refresh the TrueNAS compatibility matrix" git push origin HEAD:main fi # A broken SHIPPED release is an outage: users are on it right now. - name: fail if a shipped release is broken if: ${{ steps.check.outputs.shipped_broken != '0' }} run: | echo "::error::The patch is broken on a SHIPPED TrueNAS release." exit 1 - name: file a bug report (GitHub) if: ${{ steps.report.outputs.broken == '1' && contains(github.server_url, 'github.com') }} env: GH_TOKEN: ${{ github.token }} TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}" run: | # One issue per set of broken refs, reopened/updated rather than duplicated # daily -- a bot that files the same issue every morning gets muted, and # then it is not a warning system any more. existing="$(gh issue list --state all --search "$TITLE" \ --json number,title \ --jq '.[] | select(.title == env.TITLE) | .number' | head -1)" if [ -n "$existing" ]; then gh issue comment "$existing" --body-file /tmp/issue.md gh issue reopen "$existing" 2>/dev/null || true else gh issue create --title "$TITLE" --body-file /tmp/issue.md fi - name: file a bug report (Gitea) if: ${{ steps.report.outputs.broken == '1' && !contains(github.server_url, 'github.com') }} env: TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}" run: | # 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 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"} 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