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 — but as a PULL REQUEST, not a push to main. # # This used to `git push origin HEAD:main` from CI. Unattended writes to main are # exactly what the release barrier exists to prevent; a bot that can move main can # move it somewhere nobody looked. A PR is reviewable, and nothing lands by itself. # # A stale support matrix is not a stale doc — it is a false promise to somebody # deciding whether to trust this with their backups. So it still gets refreshed # daily; it just asks first. - name: refresh the README matrix (as a PR) if: ${{ github.event_name == 'schedule' && contains(github.server_url, 'github.com') }} env: GH_TOKEN: ${{ github.token }} 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 git diff --quiet -- README.md && { echo "matrix unchanged"; exit 0; } git config user.name "truecloud-patch bot" git config user.email "bot@onetick.ninja" branch="bot/compat-matrix" git checkout -B "$branch" git add README.md git commit -m "docs: refresh the TrueNAS compatibility matrix" git push -f origin "$branch" # One long-lived PR, updated in place — not a new one every morning. if ! gh pr view "$branch" --json number >/dev/null 2>&1; then gh pr create --head "$branch" --base main \ --title "docs: refresh the TrueNAS compatibility matrix" \ --body "The daily compatibility check found that the support matrix in the README no longer matches what iXsystems' middleware actually looks like. This PR only touches the block between the \`COMPAT MATRIX\` markers. It is regenerated by \`tools/compat.py --matrix --update-readme\` and force-pushed, so it always reflects the latest run." fi # ONE bug report, kept in sync. It is edited in place when the findings change and # says NOTHING when they do not. # # The first version commented on every run and left 11 identical 3,000-character # comments on one issue in a single day. A bot that repeats itself daily gets # muted, and then the next real finding is scrolled past — which defeats the whole # reason for building it. # # Runs on whichever forge it lands on; compat_publish.py handles both, so the two # cannot drift. - name: file / update / close the bug report env: TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} API: ${{ contains(github.server_url, 'github.com') && 'https://api.github.com' || format('{0}/api/v1', github.server_url) }}/repos/${{ github.repository }} run: python3 tools/compat_publish.py --api "$API" --token "$TOKEN" --matrix /tmp/matrix.json # A broken SHIPPED release is an outage: users are on it right now. Fails LAST, so # the report is filed before the job goes red. - 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