Files
punktfunk/scripts/ci/gitea-release.sh
T
enricobuehler 0bfc7fe913
apple / swift (pull_request) Successful in 1m43s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-drivers / probe-and-proto (pull_request) Successful in 30s
windows-drivers / driver-build (pull_request) Successful in 1m49s
ci / docs-site (pull_request) Successful in 1m21s
ci / bun-nix (pull_request) Successful in 31s
ci / web (pull_request) Successful in 3m50s
ci / rust-arm64 (pull_request) Successful in 8m32s
android / android (pull_request) Successful in 7m22s
ci / rust (pull_request) Successful in 14m7s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m6s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m7s
ci: fold release.yml into apple.yml and the two Windows client workflows into one
Two merges, both of which exist to express an ordering Gitea cannot express across
files, and both of which delete a duplicated build.

release.yml -> apple.yml (as the `distribute` job)
  The name described neither what it did (Apple only — every other platform's release
  is its own packaging workflow attaching to the same Gitea release on a v* tag, with
  announce.yml as the manual "go") nor anything a reader would guess. The name was the
  smaller problem. Gitea has no cross-workflow `needs`, so nothing sequenced it against
  apple.yml's tests: a canary main push uploaded iOS, macOS and tvOS builds to
  TestFlight even when `swift test` had just failed on that same commit. It is now
  `needs: swift`, which is only expressible in one file.

  The two files' paths: filters had also drifted — apple.yml watched crates/**,
  release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one,
  because that is the correct one: everything on this runner is built from
  punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is
  its own vendored fec-rs. That is checkable in one command, and the header says so, and
  says to widen it if that ever stops being true. Net effect on the shared mac mini:
  pushes that touch host-side crates no longer build or upload anything Apple.

windows.yml + windows-msix.yml -> windows-client.yml
  The pair built the same three crates FOUR times per client push on ONE runner: debug
  x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already
  records why a second (debug) dep tree on this machine is a liability rather than a
  cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips
  the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So
  there is one release build per arch now and clippy/fmt/test run against it, exactly as
  windows-host.yml does. The paths list went from three copies to one; PRs get the
  build/lint/test signal and stop before packaging.

The rename is safe, and this is worth recording because the GitHub instinct is wrong
here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of
DIFFERENT workflows get consecutive numbers (verified against the API: android 13226,
apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version <minor>.<run>.0
and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub
the same rename would reset both to 1, sorting every new canary below the published ones
and getting the TestFlight uploads rejected outright.

25 workflows, down from 27, and every `name:` now matches its filename. Cross-references
in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the
provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated.
2026-08-13 12:10:36 +02:00

193 lines
10 KiB
Bash

# shellcheck shell=bash
# Shared Gitea Release helpers for the punktfunk CI workflows (Linux + macOS runners).
#
# Source this file, then call ensure_release / upsert_asset. It replaces the three
# copy-pasted inline blocks that used to live in apple.yml / flatpak.yml / decky.yml,
# and fixes a latent bug those had: the bare asset POST returns 409 if an asset with the
# same name already exists, so re-running a workflow — or reusing the rolling `canary`
# release with stable filenames — would fail. upsert_asset deletes the old asset first.
#
# upsert_asset also attaches a `<asset>.sha256` sidecar for every asset, so a download off a
# release page is verifiable (`sha256sum -c punktfunk-1.2.3.dmg.sha256`) — see its comment for
# why sidecars rather than one shared SHA256SUMS.
#
# Callers run under Gitea Actions' default `bash -eo pipefail`, so a non-zero return from
# these functions aborts the step (the desired behaviour on a real failure).
#
# Env (Gitea Actions sets the first two automatically in every step):
# GITHUB_SERVER_URL e.g. https://git.unom.io
# GITHUB_REPOSITORY e.g. unom/punktfunk
# GITEA_TOKEN a PAT with repository (release) write scope — set from secrets.REGISTRY_TOKEN
# (the same PAT the package uploads use; it must carry `write:repository`,
# not only `write:package`, or the release-asset POST 403s)
#
# Requires: curl + python3 (python3 is already a proven dependency on every runner that
# attaches releases today — macOS, the fedora flatpak container, the node:bookworm decky
# image; the .deb runner installs it alongside its other apt deps).
_gitea_api() { printf '%s/api/v1/repos/%s' "${GITHUB_SERVER_URL:?}" "${GITHUB_REPOSITORY:?}"; }
# Tiny JSON / URL helpers. python3 reads the TOP-LEVEL "id" only, so there is no ambiguity
# with the nested author.id / assets[].id fields a string-grep would trip over.
_json_id() { python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null; }
_json_asset_id() {
python3 -c 'import json,sys
want=sys.argv[1]
for a in json.load(sys.stdin):
if a.get("name")==want:
print(a.get("id",""));break' "$1" 2>/dev/null
}
_urlencode() { python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"; }
# ⚠ Do NOT add helpers here for a workflow step that runs against a CHECKED-OUT RELEASE TAG
# (arch.yml's release-rebuild dispatch). Callers source this file from the working tree, so such a
# step gets the version of this file that shipped in that tag — never the one you just wrote. That
# logic belongs in the workflow, which is always read from the dispatched ref. Cost this once
# already: `prune_release_assets: command not found`, after the packages published fine.
# _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.
# canary/rc tags have no such file, which is intended (they get no curated body).
#
# Resolved relative to CWD: every caller sources this as `. scripts/ci/gitea-release.sh` from the
# repo root, so the notes are always docs/releases/<tag>.md from here. Do NOT use ${BASH_SOURCE[0]}
# — the deb + decky attach steps run under POSIX sh (dash), where an array subscript is a fatal
# "Bad substitution" (it silently broke the v0.19.0 deb/decky release-attach).
_release_notes_path() {
local notes="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
# `-` pre-release suffix (e.g. v0.2.0-rc1), so an rc never becomes the repo's "Latest" release
# (Gitea's /releases/latest surfaces the newest non-prerelease). TARGET_COMMITISH (optional)
# creates the git tag if it does not exist yet — for a `vX.Y.Z` release the tag already exists
# (it is the trigger), so TARGET is omitted and create-vs-fetch hinges on the release object.
ensure_release() {
local tag="${1:?tag}" name="${2:?name}" prerelease="${3:?prerelease}" target="${4:-}"
local api body id
if [ "$prerelease" = auto ]; then
case "$tag" in *-*) prerelease=true ;; *) prerelease=false ;; esac
fi
api="$(_gitea_api)"
# 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.
id=$(curl -fsS -X POST "$api/releases" \
-H "Authorization: token ${GITEA_TOKEN:?}" -H 'Content-Type: application/json' \
-d "$body" 2>/dev/null | _json_id || true)
if [ -z "$id" ]; then
id=$(curl -fsS "$api/releases/tags/$tag" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null | _json_id || true)
fi
if [ -z "$id" ]; then
echo "gitea-release: could not create or find a release for tag '$tag'" >&2
return 1
fi
printf '%s' "$id"
}
# _put_asset RELEASE_ID FILE NAME
# The raw attach: delete any existing asset of the same name, then POST FILE under NAME, so
# re-runs and rolling canary re-uploads are idempotent (a plain POST 409s on a dup name).
_put_asset() {
local rid="$1" file="$2" name="$3"
local api existing
api="$(_gitea_api)"
existing=$(curl -fsS "$api/releases/$rid/assets" \
-H "Authorization: token ${GITEA_TOKEN:?}" 2>/dev/null \
| _json_asset_id "$name" || true)
if [ -n "$existing" ]; then
curl -fsS -o /dev/null -X DELETE "$api/releases/$rid/assets/$existing" \
-H "Authorization: token ${GITEA_TOKEN:?}" || true
fi
curl -fsS -o /dev/null -X POST "$api/releases/$rid/assets?name=$(_urlencode "$name")" \
-H "Authorization: token ${GITEA_TOKEN:?}" \
-F "attachment=@$file"
echo "gitea-release: uploaded '$name' -> release $rid"
}
# _sha256 FILE -> lowercase hex digest
# python3 rather than sha256sum/shasum: python3 is already a hard dependency of this file, and
# the two coreutils spellings differ across our runners (Linux has sha256sum, macOS only shasum).
_sha256() {
python3 - "$1" <<'PY'
import hashlib, sys
h = hashlib.sha256()
with open(sys.argv[1], "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
print(h.hexdigest())
PY
}
# upsert_asset RELEASE_ID FILE [NAME]
# Attach FILE to the release AND a `<NAME>.sha256` checksum sidecar next to it, so every
# download off a release page can be verified: sha256sum -c punktfunk-1.2.3.dmg.sha256
#
# Sidecars, not one shared SHA256SUMS, ON PURPOSE: half a dozen workflows (release, windows-*,
# android, decky, deb, rpm, arch, flatpak) attach to the SAME release object concurrently, and a
# single manifest would be a read-modify-write race that silently loses whichever leg lost. One
# sidecar per asset has no shared mutable state — each leg only ever writes names it owns.
#
# Living here rather than in the callers means a new packaging workflow inherits checksums for
# free; the only way to attach an unchecksummed asset is to bypass this helper entirely.
upsert_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}"
local sums
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
_put_asset "$rid" "$file" "$name"
# A sidecar gets no sidecar of its own (and neither does a caller attaching one by hand).
case "$name" in *.sha256) return 0 ;; esac
# `sha256sum -c` wants "<digest> <filename>" with the filename as downloaded — i.e. the ASSET
# name, which is not necessarily the local basename (callers rename, e.g. Punktfunk-$VERSION.dmg).
sums="$(mktemp)"
printf '%s %s\n' "$(_sha256 "$file")" "$name" > "$sums"
if _put_asset "$rid" "$sums" "$name.sha256"; then rm -f "$sums"; else rm -f "$sums"; return 1; fi
}
# 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"
}