The matrix is regenerated daily by CI rather than typed once and forgotten — a support table that quietly goes stale is a false promise to someone deciding whether to trust this with their backups.
216 lines
9.0 KiB
YAML
216 lines
9.0 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(not m["ok"] and not m["native"] 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"]
|
|
]
|
|
|
|
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 m["ok"] or m["native"]:
|
|
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: |
|
|
body="$(jq -Rs . < /tmp/issue.md)"
|
|
title="$(printf '%s' "$TITLE" | jq -Rs .)"
|
|
|
|
# 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)"
|
|
|
|
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
|