Two issues with the same title already existed -- the old title embedded the list of broken refs, so the issue's identity changed whenever that set changed. With an order-dependent pick the bot would alternate between them, reopening one and commenting on the other. Lowest number is stable regardless of how the API sorts.
238 lines
10 KiB
YAML
238 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"
|
|
- ".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: "TrueNAS compatibility: the patch's assumptions no longer hold"
|
|
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.
|
|
# Lowest-numbered match, for the same reason as the Gitea step below.
|
|
existing="$(gh issue list --state all --search "$TITLE" \
|
|
--json number,title \
|
|
--jq '[.[] | select(.title == env.TITLE) | .number] | min // empty')"
|
|
|
|
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: "TrueNAS compatibility: the patch's assumptions no longer hold"
|
|
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.
|
|
# LOWEST-numbered match, not "whichever the API returns first". Two issues
|
|
# with the same title already existed once (the old title embedded the ref
|
|
# list, so the identity changed when that set changed), and an
|
|
# order-dependent pick would have alternated between them, reopening one and
|
|
# commenting on the other. Lowest number is stable no matter what the API
|
|
# sorts by.
|
|
issues = call(f"{api}/issues?state=all&type=issues", "GET")
|
|
matches = sorted((i for i in issues if i["title"] == title),
|
|
key=lambda i: i["number"])
|
|
match = matches[0] if matches else 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
|