CI / shell (shellcheck + syntax) (push) Successful in 14s
CI / python 3.11 (push) Successful in 17s
CI / python 3.12 (push) Successful in 20s
CI / python 3.13 (push) Successful in 21s
TrueNAS compatibility / compat (push) Successful in 10s
Release / release (push) Successful in 14s
release_notes.py 'notes v0.6.0-rc1' looked for a CHANGELOG section literally named v0.6.0-rc1. check() already used base_version(); extract_notes() did not. So the release workflow cut the tag, passed every gate, and then died extracting the body -- the candidate existed but was never published. Caught in an rc, which is the entire point of having them. Also: the Gitea publish and issue steps used jq, which is not guaranteed on a self-hosted runner. A publish step that dies on a missing tool leaves a tag with no release behind it, and a bug report that dies on one is a warning system that does not warn. Both now use python3, which setup-python guarantees.
209 lines
8.3 KiB
YAML
209 lines
8.3 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
|
|
|
|
# python3, not jq. The changelog is full of quotes, backticks and newlines,
|
|
# so the body must be properly JSON-encoded -- but `jq` is not guaranteed on
|
|
# a self-hosted Gitea runner, and a publish step that dies on a missing tool
|
|
# leaves a tag with no release behind it. python3 is guaranteed: setup-python
|
|
# ran above.
|
|
python3 - "$TAG" "$API" "$TOKEN" "$prerelease" <<'PY'
|
|
import json, sys, urllib.error, urllib.request
|
|
|
|
tag, api, token, prerelease = sys.argv[1:5]
|
|
with open("/tmp/notes.md", encoding="utf-8") as fh:
|
|
body = fh.read()
|
|
|
|
payload = {
|
|
"tag_name": tag, "name": tag, "body": body,
|
|
"prerelease": prerelease == "true",
|
|
}
|
|
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 r.status, json.load(r) if r.length != 0 else {}
|
|
|
|
try:
|
|
_, existing = call(f"{api}/releases/tags/{tag}", "GET")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code != 404:
|
|
raise
|
|
existing = None
|
|
|
|
if existing:
|
|
status, _ = call(f"{api}/releases/{existing['id']}", "PATCH", payload)
|
|
print(f"updated release {tag} -> {status}")
|
|
else:
|
|
status, _ = call(f"{api}/releases", "POST", payload)
|
|
print(f"created release {tag} (prerelease={payload['prerelease']}) -> {status}")
|
|
PY
|