It commented on every run that found a break. In one day it left ELEVEN identical 3,000-character comments on the same issue. That is not a warning system, it is a mute button with extra steps -- and the next real finding would have been scrolled past, which defeats the entire reason for building it. Now: the issue BODY is the current truth, edited in place. COMMENTS are a changelog of changes. A fingerprint of the findings (broken ref/module/problem triples only) is embedded in the body; a run whose findings match it says nothing at all. It closes the issue when everything is fixed. The fingerprint deliberately ignores anything that moves on its own -- healthy rows, the hardware-verified column, TrueNAS point releases -- so TS-25.10.4 becoming TS-25.10.5 is not news and does not wake anybody up. Also: - The two near-identical per-forge shell steps are gone, replaced by one tested implementation (tools/compat_publish.py). Two copies of 'find the issue, decide whether to comment' is two chances to drift, and the Gitea one duplicated an issue for real. - The README matrix refresh now opens a PULL REQUEST instead of pushing straight to main from CI. An unattended push to main is exactly what the release barrier exists to prevent: a bot that can move main can move it somewhere nobody looked.
199 lines
8.3 KiB
YAML
199 lines
8.3 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"
|
|
- ".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
|