compat/release: stop interpolating ${{ }} into run: bodies
CI / shell (shellcheck + syntax) (push) Successful in 8s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 15s
CI / python 3.13 (push) Successful in 15s
TrueNAS compatibility / compat (push) Successful in 9s

The report body is full of backticks, so 'echo "${{ steps.report.outputs.body }}"'
pasted it into the shell text and bash executed create-snapshot, def and async as
commands. The report is built from iX's middleware source, so that was an injection
vector as well as a bug. inputs.tag on workflow_dispatch had the same shape.

Data goes through files, scalars through env:. Tests enforce it across every
workflow.
This commit is contained in:
2026-07-13 17:30:50 +00:00
parent 9236aa0034
commit cd39489c7f
3 changed files with 171 additions and 50 deletions
+37 -44
View File
@@ -56,6 +56,17 @@ jobs:
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: |
@@ -76,14 +87,18 @@ jobs:
if any(not m["ok"] and not m["native"] for m in r["modules"].values())
]
native = [
r for r in rows
if any(m["native"] for m in r["modules"].values())
(r["ref"], mod)
for r in rows
for mod, m in sorted(r["modules"].items()) if m["native"]
]
print(f"broken={'1' if broken else '0'}")
print(f"refs={','.join(r['ref'] for r in broken)}")
lines = []
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("")
@@ -96,21 +111,19 @@ jobs:
lines.append(f"- `{p['id']}`: {p['detail']}")
lines.append(f" - why it matters: {p['why']}")
lines.append("")
if native:
lines.append("### Native support detected")
lines.append("")
for r in native:
for mod, m in sorted(r["modules"].items()):
if m["native"]:
lines.append(
f"- `{r['ref']}`: **{mod}** appears to be native now — "
f"retire the module rather than fixing it."
)
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`._"]
# GITHUB_OUTPUT is line-based; a multi-line value needs a heredoc marker.
print("body<<__EOF__")
print("\n".join(lines))
print("__EOF__")
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
@@ -132,25 +145,15 @@ jobs:
# 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 --label compat --search "$TITLE" \
--json number,title,state \
--jq ".[] | select(.title == \"$TITLE\") | .number" | head -1)"
{
echo "\`tools/compat.py\` found that the patch's assumptions no longer hold."
echo
cat /tmp/matrix.md
echo
echo "${{ steps.report.outputs.body }}"
echo
echo "_Filed automatically by \`.github/workflows/compat.yml\`._"
} > /tmp/issue.md
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 --label compat
gh issue create --title "$TITLE" --body-file /tmp/issue.md
fi
- name: file a bug report (Gitea)
@@ -160,16 +163,6 @@ jobs:
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TITLE: "Incompatible with upcoming TrueNAS: ${{ steps.report.outputs.refs }}"
run: |
{
echo "\`tools/compat.py\` found that the patch's assumptions no longer hold."
echo
cat /tmp/matrix.md
echo
echo "${{ steps.report.outputs.body }}"
echo
echo "_Filed automatically by \`.github/workflows/compat.yml\`._"
} > /tmp/issue.md
body="$(jq -Rs . < /tmp/issue.md)"
title="$(printf '%s' "$TITLE" | jq -Rs .)"
+27 -6
View File
@@ -30,15 +30,30 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
# Via `env:`, never spliced into the script. `inputs.tag` is attacker-chosen on
# a workflow_dispatch, and a ${{ }} in a `run:` body is pasted into the shell
# TEXT -- a tag of `$(...)` would simply execute. env: is safe: the runner sets
# the variable instead of rewriting the script.
- name: Resolve tag
id: tag
env:
EVENT: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
if [ "$EVENT" = "workflow_dispatch" ]; then
tag="$INPUT_TAG"
else
echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
tag="${GITHUB_REF#refs/tags/}"
fi
# Whatever it came from, it has to look like a tag we cut.
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::refusing to release a tag that is not vX.Y.Z[-rcN]: $tag"; exit 1 ;;
esac
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
@@ -71,7 +86,9 @@ jobs:
# Catches the failure mode this repo actually had: VERSION= drifted to
# three different values across the scripts, and nothing noticed.
- name: "gate: version matches tag, CHANGELOG complete, nothing stranded"
run: python3 tools/release_notes.py check "${{ steps.tag.outputs.tag }}"
env:
TAG: ${{ steps.tag.outputs.tag }}
run: python3 tools/release_notes.py check "$TAG"
# THE BARRIER. A stable release must have been a release candidate on this
# exact commit. Candidates are invisible to users (update.sh and the alert
@@ -83,7 +100,9 @@ jobs:
# you find out. It is here because this is the only place that cannot be
# bypassed: it holds the token that publishes.
- name: "gate: this commit was a release candidate"
run: python3 tools/release_gate.py "${{ steps.tag.outputs.tag }}" -C .
env:
TAG: ${{ steps.tag.outputs.tag }}
run: python3 tools/release_gate.py "$TAG" -C .
# There is deliberately NO "did the candidate's CI run pass?" gate here.
#
@@ -98,8 +117,10 @@ jobs:
# that; it cannot verify it.
- name: extract release notes from CHANGELOG
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
python3 tools/release_notes.py notes "${{ steps.tag.outputs.tag }}" > /tmp/notes.md
python3 tools/release_notes.py notes "$TAG" > /tmp/notes.md
echo "--- release body ---"
cat /tmp/notes.md