diff --git a/.gitea/workflows/announce.yml b/.gitea/workflows/announce.yml new file mode 100644 index 00000000..b4500f81 --- /dev/null +++ b/.gitea/workflows/announce.yml @@ -0,0 +1,39 @@ +# Announce a stable release to the Discord #releases channel. +# +# This is the deliberate "go" step for a release. Release notes live in the repo at +# docs/releases/.md and are seeded into the Gitea release body at creation by the build +# workflows (scripts/ci/gitea-release.sh), so the release is never noteless. Once every +# platform's CI is green for a tag, dispatch this workflow with that tag: it re-asserts the notes +# file over the live release and posts a formatted embed to #releases. +# +# Manual on purpose — pressing "go" is the quality gate that says "all platforms built, notes are +# final, tell the community." It is NOT wired to the tag push, so a half-built or failed release +# is never announced. Stable-only: a -rc/pre-release tag is refused unless allow_prerelease=true. +# +# Requires the repo secret DISCORD_RELEASE_WEBHOOK (the #releases channel webhook URL); GITEA auth +# reuses REGISTRY_TOKEN like the other release workflows. +name: announce + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to announce (e.g. v0.18.0)" + required: true + allow_prerelease: + description: "Announce even if the tag is a pre-release (-rc)" + required: false + default: "false" + +jobs: + announce: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Post release announcement to Discord + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + ALLOW_PRERELEASE: ${{ inputs.allow_prerelease }} + run: bash scripts/ci/discord-announce.sh "${{ inputs.tag }}" diff --git a/docs/releases/README.md b/docs/releases/README.md new file mode 100644 index 00000000..1bec3be4 --- /dev/null +++ b/docs/releases/README.md @@ -0,0 +1,45 @@ +# Release notes + +One file per **stable** release: `docs/releases/vX.Y.Z.md`. Its contents become the Gitea release +body **verbatim** and are the source of the Discord `#releases` announcement. + +## Why this exists + +Releases used to be created by CI with an **empty body**; the notes were pasted in by hand +afterward. That left a window where the release — and anything announcing it — carried no notes. +Now the notes are authored **before the tag is pushed**, as part of the version bump, so the +release is born complete and the announcement always has something to say. + +## The flow + +1. **Write the notes.** Add `docs/releases/vX.Y.Z.md` in the same commit (or PR) as the version + bump. Copy `TEMPLATE.md` and fill it in. This file is the single source of truth for the body. +2. **Tag & push.** `git tag -a vX.Y.Z … && git push origin vX.Y.Z` fans out to the build + workflows. Whichever one wins the create race seeds the release body from this file + (`scripts/ci/gitea-release.sh` → `ensure_release`, and its PowerShell twin). The release page + shows the notes immediately. +3. **Wait for green.** Let every platform's CI finish and go green. +4. **Announce.** Dispatch the `announce` workflow (`.gitea/workflows/announce.yml`) with the tag. + It re-asserts this file over the live release (so any late edit wins) and posts an embed to + Discord `#releases`. Pressing "go" is the quality gate — a half-built release is never + announced. Stable-only; a `-rc` tag is refused unless `allow_prerelease=true`. + +Editing the notes after the tag is fine: update this file, then re-run step 4 (or PATCH the body +via the API) — the announce step always re-syncs from the file, so the file stays authoritative +even across a tag re-point. + +Canary / `-rc` builds have **no** file here on purpose: they get no curated body and are not +announced. + +## Format + +Match the house style (see any recent `vX.Y.Z.md`): + +- Open with a **wire-compatibility** line — *"Wire-compatible with X.Y.x — existing pairings and + clients keep working."* — plus a one-sentence fallback/negotiation note. This lead-in (all text + before the first `##` header) is what the Discord embed shows, so make it a real summary. +- Then `## Section` headers grouping the changes, with **bold lead-in** bullets. +- Be concrete: env vars, ABI/protocol versions, on-glass-verified hardware, platform scope. + +The short annotated-**tag** message stays separate and short (a headline + a paragraph); it is the +tag object's message, not this file. diff --git a/docs/releases/TEMPLATE.md b/docs/releases/TEMPLATE.md new file mode 100644 index 00000000..2e2f8cb7 --- /dev/null +++ b/docs/releases/TEMPLATE.md @@ -0,0 +1,20 @@ +Wire-compatible with X.Y.x — existing pairings and clients keep working. + + + +## Highlights + +- **.** +- **.** <…> + +## Fixes + +- **.** <…> + +## Platform notes + +- **.** diff --git a/scripts/ci/discord-announce.sh b/scripts/ci/discord-announce.sh new file mode 100755 index 00000000..158d1287 --- /dev/null +++ b/scripts/ci/discord-announce.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Announce a stable Punktfunk release to the Discord #releases channel. +# +# Model: release notes live in the repo at docs/releases/.md (authored during the version +# bump, BEFORE the tag is pushed — see docs/releases/README.md). CI seeds the Gitea release body +# from that file at creation, so the release is never noteless. This script is the deliberate +# "go" step (workflow_dispatch, .gitea/workflows/announce.yml): once every platform's CI is green +# it re-asserts the notes file over the live release, then posts a formatted embed to Discord. +# +# Usage: scripts/ci/discord-announce.sh # e.g. v0.18.0 +# Env: +# GITHUB_SERVER_URL https://git.unom.io (Gitea Actions sets it) +# GITHUB_REPOSITORY unom/punktfunk (Gitea Actions sets it) +# GITEA_TOKEN PAT with repo write scope (from secrets.REGISTRY_TOKEN) +# DISCORD_RELEASE_WEBHOOK the #releases channel webhook URL (from secrets) +# ALLOW_PRERELEASE "1" to permit announcing a -rc/pre-release tag (default: refuse) +# +# Requires: curl + python3 (both present on the ubuntu-24.04 runner). +set -euo pipefail + +TAG="${1:?usage: discord-announce.sh }" +: "${GITHUB_SERVER_URL:?}" "${GITHUB_REPOSITORY:?}" "${GITEA_TOKEN:?}" "${DISCORD_RELEASE_WEBHOOK:?}" + +# Stable-only guard: a `-` in the tag marks a pre-release (v0.2.0-rc1). Refuse unless explicitly +# allowed, so an rc dispatched by accident never pings the community. +case "$TAG" in + *-*) + case "$(printf '%s' "${ALLOW_PRERELEASE:-0}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes) : ;; + *) echo "discord-announce: '$TAG' looks like a pre-release; set ALLOW_PRERELEASE=1 to override" >&2 + exit 1 ;; + esac ;; +esac + +_here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +API="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY" + +# Resolve the release object created by the build workflows (id, html_url, published_at). +release_json="$(curl -fsS "$API/releases/tags/$TAG" -H "Authorization: token $GITEA_TOKEN")" +RID="$(printf '%s' "$release_json" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))')" +[ -n "$RID" ] || { echo "discord-announce: no release found for tag '$TAG' — has CI created it yet?" >&2; exit 1; } + +# Re-assert the notes file over the live release (source of truth), covering an edit after the +# release object was first created. No-op if docs/releases/.md is absent. +# shellcheck source=scripts/ci/gitea-release.sh +. "$_here/gitea-release.sh" +apply_release_notes "$RID" "$TAG" + +# Build and post the Discord embed. The description is the notes' lead-in (everything before the +# first `## ` section header), trimmed to a readable length, with a link to the full release page +# for the sections + downloads. Falls back to the release's own body if there is no notes file. +NOTES_PATH="$(_release_notes_path "$TAG" || true)" +payload_file="$(mktemp)" +trap 'rm -f "$payload_file"' EXIT + +RELEASE_JSON="$release_json" NOTES_PATH="$NOTES_PATH" TAG="$TAG" python3 - "$payload_file" <<'PY' +import json, os, sys + +rel = json.loads(os.environ["RELEASE_JSON"]) +tag = os.environ["TAG"] +version = tag[1:] if tag.startswith("v") else tag +url = rel.get("html_url") or "" +published = rel.get("published_at") or rel.get("created_at") or "" + +notes_path = os.environ.get("NOTES_PATH") or "" +body = "" +if notes_path and os.path.isfile(notes_path): + with open(notes_path, encoding="utf-8") as f: + body = f.read() +else: + body = rel.get("body") or "" + +# Lead-in = text before the first `## ` section header (the intro paragraphs); fall back to the +# whole body when the notes open straight into a section. +lead = body +idx = body.find("\n## ") +if idx != -1: + lead = body[:idx] +lead = lead.strip() +if not lead: + lead = body.strip() + +LIMIT = 1800 +if len(lead) > LIMIT: + cut = lead.rfind("\n\n", 0, LIMIT) + if cut < LIMIT // 2: + cut = lead.rfind(" ", 0, LIMIT) + if cut <= 0: + cut = LIMIT + lead = lead[:cut].rstrip() + " …" + +description = lead +if url: + description = (description + "\n\n" if description else "") + \ + f"**[⬇️ Download & full release notes →]({url})**" + +embed = { + "title": f"Punktfunk {version}", + "color": 0x6C5BF3, # --pf-brand deep violet + "description": description, + "footer": {"text": "Punktfunk • git.unom.io"}, +} +if url: + embed["url"] = url +if published: + embed["timestamp"] = published + +with open(sys.argv[1], "w", encoding="utf-8") as f: + json.dump({"embeds": [embed]}, f) +PY + +curl -fsS -o /dev/null -X POST "$DISCORD_RELEASE_WEBHOOK" \ + -H 'Content-Type: application/json' --data-binary @"$payload_file" +echo "discord-announce: posted Punktfunk $TAG to #releases (release $RID)" diff --git a/scripts/ci/gitea-release.ps1 b/scripts/ci/gitea-release.ps1 index 77497c43..ef1c12e1 100644 --- a/scripts/ci/gitea-release.ps1 +++ b/scripts/ci/gitea-release.ps1 @@ -40,6 +40,11 @@ function Ensure-GiteaRelease { $api = _GiteaApi; $h = _GiteaHeaders $payload = @{ tag_name = $Tag; name = $Name; prerelease = $pre } if ($TargetCommitish) { $payload.target_commitish = $TargetCommitish } + # Seed the body from docs/releases/.md (the source of truth) so a Windows job winning the + # create race is born WITH its notes, exactly like the bash twin. Missing file (canary/rc) -> no + # body key. $PSScriptRoot is scripts/ci; walk up two levels to the repo root. + $notes = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "docs/releases/$Tag.md" + if (Test-Path $notes) { $payload.body = Get-Content -Raw -Encoding utf8 $notes } try { $r = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $h ` -ContentType 'application/json' -Body ($payload | ConvertTo-Json -Compress) diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh index 0cb9ca22..446b4a9b 100644 --- a/scripts/ci/gitea-release.sh +++ b/scripts/ci/gitea-release.sh @@ -35,6 +35,20 @@ for a in json.load(sys.stdin): } _urlencode() { python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; } +# _release_notes_path TAG +# Print the path of the in-repo release notes for TAG (docs/releases/.md) IFF it exists, +# else print nothing. This file is the single source of truth for a stable release's body +# (authored as part of the version bump, before the tag is pushed — see docs/releases/README.md), +# so the Gitea release is born WITH its notes instead of being PATCHed noteless-then-late. +# Resolves relative to this script (scripts/ci/ -> repo root); canary/rc tags have no such file, +# which is intended (they get no curated body). +_release_notes_path() { + local root notes + root="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)" + notes="$root/docs/releases/$1.md" + [ -f "$notes" ] && printf '%s' "$notes" +} + # ensure_release TAG NAME PRERELEASE [TARGET_COMMITISH] # Idempotently create (or fetch) the release for TAG; prints its numeric id on stdout. # PRERELEASE is "true", "false", or "auto" — auto marks it a prerelease iff TAG carries a @@ -49,12 +63,25 @@ ensure_release() { case "$tag" in *-*) prerelease=true ;; *) prerelease=false ;; esac fi api="$(_gitea_api)" - if [ -n "$target" ]; then - body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s,"target_commitish":"%s"}' \ - "$tag" "$name" "$prerelease" "$target") - else - body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s}' "$tag" "$name" "$prerelease") - fi + # Build the create payload with python3 so the (multi-line, quote-bearing) release body from + # docs/releases/.md is JSON-escaped correctly. Whichever workflow wins the create race thus + # sets the body ATOMICALLY at creation; the losers 409 and fall through to the fetch-by-tag path + # below (which never touches the body). No notes file (canary/rc) -> no body key -> empty body. + local notes; notes="$(_release_notes_path "$tag")" + body=$(TAG="$tag" NAME="$name" PRERELEASE="$prerelease" TARGET="$target" NOTES_FILE="$notes" \ + python3 - <<'PY' +import json, os +d = {"tag_name": os.environ["TAG"], "name": os.environ["NAME"], + "prerelease": os.environ["PRERELEASE"] == "true"} +if os.environ.get("TARGET"): + d["target_commitish"] = os.environ["TARGET"] +nf = os.environ.get("NOTES_FILE") or "" +if nf: + with open(nf, encoding="utf-8") as f: + d["body"] = f.read() +print(json.dumps(d)) +PY +) # Try to create. On any failure (almost always "release already exists"), fall back to # fetching it by tag. Either path MUST yield an id, or we error loudly — so a 401/scope # problem can't masquerade as a successful no-op. @@ -93,3 +120,23 @@ upsert_asset() { -F "attachment=@$file" echo "gitea-release: uploaded '$name' -> release $rid" } + +# apply_release_notes RELEASE_ID TAG +# Force the release body to match docs/releases/.md (the source of truth), if that file +# exists — a no-op otherwise. PATCHes ONLY the body, so name/prerelease/assets are preserved +# (Gitea has no partial-update footgun here; sending just {"body":...} leaves everything else). +# ensure_release already seeds the body at creation; this exists so the announce step can +# re-assert the file over the live release right before publishing — covering the case where the +# notes file was edited after the release object was first created (e.g. a tag re-point). +apply_release_notes() { + local rid="${1:?release id}" tag="${2:?tag}" api notes payload + notes="$(_release_notes_path "$tag")" + [ -n "$notes" ] || { echo "gitea-release: no docs/releases/$tag.md — leaving body as-is"; return 0; } + api="$(_gitea_api)" + payload=$(NOTES_FILE="$notes" python3 -c \ + 'import json,os;print(json.dumps({"body":open(os.environ["NOTES_FILE"],encoding="utf-8").read()}))') + curl -fsS -o /dev/null -X PATCH "$api/releases/$rid" \ + -H "Authorization: token ${GITEA_TOKEN:?}" -H 'Content-Type: application/json' \ + -d "$payload" + echo "gitea-release: synced release $rid body from $notes" +}