The audit found the new machinery could do more harm than the bugs it prevents. - apply.sh reused the 'nothing left to do' exit -- which touches the PERMANENT kill switch, cleared only by install.sh, never by update.sh -- for the incompatible case. On TrueNAS 26 (providers ok, nested opt-out) both modules go quiet, so the switch would fire and the release that fixed 26 could never re-enable itself. Retirement and incompatibility now take different exits. - A network blip, a re-export, or a conditional def all read as BROKEN. Each is now 'unknown', which changes nothing, rather than evidence strong enough to disable a module. - 'native' outranked BROKEN everywhere but apply.sh, so a TrueNAS that reworded the guard AND reshaped the functions rendered as good news. - compat.py --tree read B2_BLOCK's own 'restic = True' as native support, so the documented way to check a live box lied on every patched machine. - The signature check was a name-subset test. It passed reorders, kw-only conversions, and added required params -- and it had already passed a real bug: restic_backup takes 4 args on 24.10/25.04, and the wrapper forwarded 5. Nested backups have been raising TypeError on those releases the whole time. The wrapper now forwards *args/**kwargs. - release.sh --promote was unreachable: it died if the tag existed, the gate died if it did not. The tests hid it by always tagging first.
191 lines
7.6 KiB
YAML
191 lines
7.6 KiB
YAML
name: Release
|
|
|
|
# Push a tag, get a release. The body always comes from CHANGELOG.md, so there is
|
|
# no second place to write release notes and therefore no second place for them to
|
|
# go stale.
|
|
#
|
|
# git tag -a v0.4.0 -m "v0.4.0" && git push origin v0.4.0
|
|
#
|
|
# workflow_dispatch re-cuts (or updates) the release for a tag that already
|
|
# exists, since re-pushing an existing tag triggers nothing.
|
|
#
|
|
# It checks out the TAG, because the tagged code is what people install and it has
|
|
# to pass its own tests. That means it only works for tags that actually contain
|
|
# this tooling (>= v0.3.0). Tags older than that were backfilled by hand.
|
|
|
|
on:
|
|
push:
|
|
tags: ["v*"]
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: "Existing tag to create a release for (e.g. v0.2.1)"
|
|
required: true
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
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 [ "$EVENT" = "workflow_dispatch" ]; then
|
|
tag="$INPUT_TAG"
|
|
else
|
|
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 }}
|
|
fetch-depth: 0
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.13"
|
|
|
|
# Never publish a release for code that does not pass its own tests. A
|
|
# tagged commit is what people install; it has to be at least as good as
|
|
# main.
|
|
- name: install dev deps
|
|
run: python -m pip install --upgrade pip pytest ruff
|
|
|
|
- name: ruff
|
|
run: ruff check patch tests tools
|
|
|
|
- name: pytest
|
|
run: pytest tests -q
|
|
|
|
- name: shell syntax
|
|
run: |
|
|
fail=0
|
|
while IFS= read -r f; do
|
|
bash -n "$f" || { echo "::error file=$f::bash syntax error"; fail=1; }
|
|
done < <(find . -name '*.sh' -not -path './.git/*')
|
|
exit $fail
|
|
|
|
# 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"
|
|
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
|
|
# source both take the newest plain vX.Y.Z), so debugging happens across
|
|
# rc1/rc2/rc3 at no cost to anyone -- instead of across v0.5.0/v0.5.1/v0.5.2,
|
|
# which alerts every installed box every time.
|
|
#
|
|
# Same code release.sh runs locally, so this should never be the first place
|
|
# 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"
|
|
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.
|
|
#
|
|
# It would have to query the forge's run history, which is the one thing that
|
|
# differs between GitHub and Gitea -- and it adds nothing: the steps above
|
|
# re-run ruff, pytest and the shell checks against the TAGGED COMMIT, and
|
|
# release_gate.py has already proved a candidate points at that same commit.
|
|
# If the code passes now, it passed then; they are the same code.
|
|
#
|
|
# What a candidate really buys is the thing no CI can check: that a human
|
|
# installed it on a real box and exercised it. The barrier makes room for
|
|
# that; it cannot verify it.
|
|
|
|
- name: extract release notes from CHANGELOG
|
|
env:
|
|
TAG: ${{ steps.tag.outputs.tag }}
|
|
run: |
|
|
python3 tools/release_notes.py notes "$TAG" > /tmp/notes.md
|
|
echo "--- release body ---"
|
|
cat /tmp/notes.md
|
|
|
|
# This repo is canonically hosted on Gitea (git.onetick.ninja) and mirrored to
|
|
# GitHub, and BOTH run this workflow -- Gitea reads .github/workflows too. So
|
|
# the publish step has to work on whichever forge it lands on. Everything
|
|
# above is forge-agnostic; only the "create a release" API differs.
|
|
|
|
- name: publish the release (GitHub)
|
|
if: ${{ contains(github.server_url, 'github.com') }}
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ steps.tag.outputs.tag }}
|
|
run: |
|
|
# Lowercased: release_gate/release_notes match the suffix case-INsensitively
|
|
# (`is_prerelease` uses re.I), so a `v0.6.0-RC1` skipped the barrier as a
|
|
# candidate and then landed here as a case-sensitive MISS -- published as the
|
|
# forge's "Latest release" on a commit that was never a candidate.
|
|
prerelease=""
|
|
case "$(printf '%s' "$TAG" | tr '[:upper:]' '[:lower:]')" in
|
|
*-rc*|*-beta*|*-alpha*) prerelease="--prerelease" ;;
|
|
esac
|
|
|
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
echo "Release $TAG exists — updating notes."
|
|
gh release edit "$TAG" --notes-file /tmp/notes.md
|
|
else
|
|
# shellcheck disable=SC2086
|
|
gh release create "$TAG" --title "$TAG" --notes-file /tmp/notes.md $prerelease
|
|
fi
|
|
|
|
- name: publish the release (Gitea)
|
|
if: ${{ !contains(github.server_url, 'github.com') }}
|
|
env:
|
|
TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
|
TAG: ${{ steps.tag.outputs.tag }}
|
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
|
run: |
|
|
prerelease=false
|
|
case "$(printf '%s' "$TAG" | tr '[:upper:]' '[:lower:]')" in
|
|
*-rc*|*-beta*|*-alpha*) prerelease=true ;;
|
|
esac
|
|
|
|
# jq -Rs so the notes are JSON-encoded properly: the changelog is full of
|
|
# quotes, backticks and newlines, and hand-built JSON would mangle them.
|
|
body="$(jq -Rs . < /tmp/notes.md)"
|
|
payload="$(printf '{"tag_name":%s,"name":%s,"body":%s,"prerelease":%s}' \
|
|
"$(printf '%s' "$TAG" | jq -Rs .)" \
|
|
"$(printf '%s' "$TAG" | jq -Rs .)" \
|
|
"$body" "$prerelease")"
|
|
|
|
existing="$(curl -sf -H "Authorization: token $TOKEN" \
|
|
"$API/releases/tags/$TAG" 2>/dev/null || true)"
|
|
|
|
if [ -n "$existing" ]; then
|
|
id="$(printf '%s' "$existing" | jq -r .id)"
|
|
echo "Release $TAG exists (id=$id) — updating notes."
|
|
curl -sS -X PATCH "$API/releases/$id" \
|
|
-H "Authorization: token $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$payload" -o /dev/null -w 'PATCH -> %{http_code}\n'
|
|
else
|
|
curl -sS -X POST "$API/releases" \
|
|
-H "Authorization: token $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$payload" -o /dev/null -w 'POST -> %{http_code}\n'
|
|
fi
|