The bug-report bot was spamming; make it say something only when there is something to say
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 14s
CI / python 3.12 (push) Successful in 17s
CI / python 3.13 (push) Successful in 17s
TrueNAS compatibility / compat (push) Successful in 11s

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.
This commit is contained in:
2026-07-13 20:20:35 +00:00
parent c6b252ac6b
commit 82084b6806
4 changed files with 328 additions and 91 deletions
+48 -87
View File
@@ -130,17 +130,19 @@ jobs:
- 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.
# Keep the README's table true — but as a PULL REQUEST, not a push to main.
#
# 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') }}
# 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
@@ -151,87 +153,46 @@ jobs:
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
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
# A broken SHIPPED release is an outage: users are on it right now.
# 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
- 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