compat.yml's push paths listed tools/compat.py but not tools/compat_publish.py. So the commit that taught the bot to refresh a stale report body fired no run, and the report stayed stale until the next scheduled one -- caught by watching for the run that never came. A fix nobody runs is a fix nobody has.
242 lines
10 KiB
YAML
242 lines
10 KiB
YAML
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"
|
|
# ...and so did the thing that PUBLISHES the finding. This was missing, and it
|
|
# showed: the commit teaching the bot to refresh a stale report body touched only
|
|
# compat_publish.py, so no run fired, and the report stayed stale until the next
|
|
# scheduled one. A fix nobody runs is a fix nobody has.
|
|
- "tools/compat_publish.py"
|
|
- ".github/workflows/compat.yml"
|
|
|
|
permissions:
|
|
# write, because the matrix refresh pushes a branch and opens a PR. It does NOT get
|
|
# to move `main` -- that is the whole reason it is a PR. See the refresh step below.
|
|
contents: write
|
|
pull-requests: write
|
|
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 — as a PULL REQUEST, on the CANONICAL forge.
|
|
#
|
|
# Two things this gets right that the obvious version gets wrong:
|
|
#
|
|
# 1. It is a PR, not a push to main. This used to `git push origin HEAD:main`
|
|
# from CI. An unattended write to main is exactly what the release barrier
|
|
# exists to prevent — a bot that can move main can move it somewhere nobody
|
|
# looked. Nothing lands by itself.
|
|
#
|
|
# 2. It runs on GITEA, not GitHub. GitHub is a one-way MIRROR: a PR merged there
|
|
# would be silently clobbered by the next `fleet-repos mirror` push from Gitea.
|
|
# A bot opening PRs against a mirror is a bot doing nothing, slowly.
|
|
#
|
|
# 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 is refreshed daily; it
|
|
# just asks first.
|
|
- name: refresh the README matrix (PR on the canonical forge)
|
|
if: ${{ github.event_name == 'schedule' && !contains(github.server_url, 'github.com') }}
|
|
env:
|
|
TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
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 — nothing to propose"; 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, force-pushed in place — not a new one every morning.
|
|
# (A daily PR is the same mistake as a daily comment, wearing a hat.)
|
|
BRANCH="$BRANCH" python3 - <<'PY'
|
|
import json, os, urllib.error, urllib.request
|
|
|
|
api, token, branch = os.environ["API"], os.environ["TOKEN"], os.environ["BRANCH"]
|
|
h = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
|
|
|
def call(url, method="GET", data=None):
|
|
r = urllib.request.Request(
|
|
url, method=method, headers=h,
|
|
data=json.dumps(data).encode() if data else None)
|
|
with urllib.request.urlopen(r) as resp: # noqa: S310
|
|
return json.load(resp) if resp.length != 0 else {}
|
|
|
|
existing = [
|
|
p for p in call(f"{api}/pulls?state=open")
|
|
if p["head"]["ref"] == branch
|
|
]
|
|
if existing:
|
|
print(f"PR #{existing[0]['number']} already open; the force-push updated it")
|
|
else:
|
|
pr = call(f"{api}/pulls", "POST", {
|
|
"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 iXsystems' actual middleware.\n\n"
|
|
"This 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."
|
|
),
|
|
})
|
|
print(f"opened PR #{pr['number']}")
|
|
PY
|
|
|
|
# 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
|