feat(ci/release): author release notes in-repo + announce stable releases to Discord
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m25s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m55s
apple / screenshots (push) Successful in 6m47s
deb / build-publish (push) Successful in 11m23s
android / android (push) Successful in 12m36s
arch / build-publish (push) Successful in 12m46s
docker / deploy-docs (push) Successful in 23s
deb / build-publish-host (push) Successful in 12m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m9s
ci / rust (push) Successful in 26m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m30s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m5s
apple / swift (push) Successful in 1m25s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m55s
apple / screenshots (push) Successful in 6m47s
deb / build-publish (push) Successful in 11m23s
android / android (push) Successful in 12m36s
arch / build-publish (push) Successful in 12m46s
docker / deploy-docs (push) Successful in 23s
deb / build-publish-host (push) Successful in 12m32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m9s
ci / rust (push) Successful in 26m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m30s
Release notes now live in the repo at docs/releases/vX.Y.Z.md, authored during the version bump (before the tag). ensure_release (gitea-release.sh + .ps1 twin) seeds the Gitea release body from that file in the create POST, so the release is born WITH its notes instead of being created empty and PATCHed afterward. canary/rc have no file -> empty body, unchanged. New manual `announce` workflow (workflow_dispatch, tag input) re-syncs the notes file over the live release and posts a violet embed (title, notes lead-in, release-page link) to the Discord #releases channel via the DISCORD_RELEASE_WEBHOOK secret. Stable-only: a -rc tag is refused unless allow_prerelease=true. Pressing "go" is the quality gate, so a half-built or failed release is never announced. docs/releases/README.md documents the ritual; TEMPLATE.md is the skeleton. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<tag>.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 }}"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,20 @@
|
||||
Wire-compatible with X.Y.x — existing pairings and clients keep working. <one sentence on how
|
||||
older/newer clients negotiate or fall back, so nobody fears updating.>
|
||||
|
||||
<Optional: one or two sentences naming the headline change of this release — this whole lead-in
|
||||
(everything above the first `##`) is what the Discord #releases embed shows, so make it read as a
|
||||
standalone summary.>
|
||||
|
||||
## Highlights
|
||||
|
||||
- **<Lead-in>.** <What changed and why it matters, concretely.>
|
||||
- **<Lead-in>.** <…>
|
||||
|
||||
## Fixes
|
||||
|
||||
- **<Lead-in>.** <…>
|
||||
|
||||
## Platform notes
|
||||
|
||||
- **<Platform>.** <Anything platform-specific: env vars, ABI/protocol versions, hardware that was
|
||||
verified on-glass.>
|
||||
Executable
+114
@@ -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/<tag>.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 <tag> # 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 <tag>}"
|
||||
: "${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/<tag>.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)"
|
||||
@@ -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/<Tag>.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)
|
||||
|
||||
@@ -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/<TAG>.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/<tag>.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/<TAG>.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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user