ci(release): split canary/stable tracks + unified Gitea Releases
ci / rust (push) Failing after 37s
apple / swift (push) Successful in 56s
ci / web (push) Successful in 42s
ci / docs-site (push) Failing after 27m33s
android / android (push) Failing after 28m53s
windows-host / package (push) Failing after 28m55s
deb / build-publish (push) Successful in 2m28s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
ci / bench (push) Successful in 4m34s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 46s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Failing after 4m4s
flatpak / build-publish (push) Successful in 4m19s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m38s
release / apple (push) Successful in 4m36s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s

A push to main publishes canary builds to canary channels (fast iteration,
unchanged); a single vX.Y.Z tag releases every platform at one version to the
stable channels and attaches all artifacts (.deb/.rpm/.msix/.apk/.aab/.dmg +
flatpak/decky/host-installer) to one Gitea Release. Collapses the
host-v*/win-v*/host-win-v* tag namespaces into v* — the channel split makes the
version-shadow bug structurally impossible (canary and stable are separate repos,
never a shared version line).

- scripts/ci/gitea-release.{sh,ps1}: one idempotent release helper
  (create-or-fetch + delete-before-upload), replacing 3 copy-pasted inline blocks
  and fixing their latent 409-on-reupload bug; prerelease flag auto-derived from
  the tag (an -rc tag won't shadow "Latest")
- channels: apt canary/stable distributions; rpm *-canary/base groups; flatpak
  canary/stable OSTree branches + a 2nd .Canary.flatpakref; generic-registry
  canary/ vs latest/ aliases; Play internal/alpha; Apple TestFlight vs notarized DMG
- android versionName threaded through gradle (versionCode stays run_number);
  Apple canary = TestFlight-only (no DMG/tvOS); canary base bumped to 0.3.0
- docs: new docs-site channels.md (subscribe table + cut-a-release runbook +
  box migration), refreshed ci.md workflow table + packaging READMEs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 16:32:55 +00:00
parent 3e6c9f6060
commit 0205c7b8d6
23 changed files with 631 additions and 183 deletions
+77
View File
@@ -0,0 +1,77 @@
# Shared Gitea Release helpers for the punktfunk Windows CI workflows (pwsh / PowerShell 7).
#
# Dot-source it, then call Ensure-GiteaRelease / Upsert-GiteaAsset:
# . scripts/ci/gitea-release.ps1
# Mirrors scripts/ci/gitea-release.sh; parses JSON with ConvertFrom-Json (the Windows runner
# has no python). Same idempotent semantics: Upsert-GiteaAsset deletes an existing asset of
# the same name before uploading, so re-runs / rolling canary uploads don't 409.
#
# Env (Gitea Actions sets the first two automatically):
# 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
# (must carry write:repository, not only write:package)
$ErrorActionPreference = 'Stop'
function _GiteaApi {
if (-not $env:GITHUB_SERVER_URL) { throw 'GITHUB_SERVER_URL unset' }
if (-not $env:GITHUB_REPOSITORY) { throw 'GITHUB_REPOSITORY unset' }
"$($env:GITHUB_SERVER_URL)/api/v1/repos/$($env:GITHUB_REPOSITORY)"
}
function _GiteaHeaders {
if (-not $env:GITEA_TOKEN) { throw 'GITEA_TOKEN unset' }
@{ Authorization = "token $($env:GITEA_TOKEN)" }
}
# Ensure-GiteaRelease TAG NAME PRERELEASE [TARGETCOMMITISH] -> release id
# Idempotently create or fetch the release for TAG. 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 "Latest"). TargetCommitish (optional) creates the tag if missing.
function Ensure-GiteaRelease {
param(
[Parameter(Mandatory)] [string]$Tag,
[Parameter(Mandatory)] [string]$Name,
[Parameter(Mandatory)] [string]$Prerelease,
[string]$TargetCommitish
)
$pre = if ($Prerelease -eq 'auto') { [bool]($Tag -match '-') } else { [System.Convert]::ToBoolean($Prerelease) }
$api = _GiteaApi; $h = _GiteaHeaders
$payload = @{ tag_name = $Tag; name = $Name; prerelease = $pre }
if ($TargetCommitish) { $payload.target_commitish = $TargetCommitish }
try {
$r = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $h `
-ContentType 'application/json' -Body ($payload | ConvertTo-Json -Compress)
return $r.id
} catch {
# Almost always: the release already exists. Fetch it by tag; error if that fails too.
$r = Invoke-RestMethod -Method Get -Uri "$api/releases/tags/$Tag" -Headers $h
if (-not $r.id) { throw "gitea-release: could not create or find a release for tag '$Tag'" }
return $r.id
}
}
# Upsert-GiteaAsset RELEASEID FILE [NAME]
# Attach FILE, replacing any existing asset of the same name first (idempotent).
function Upsert-GiteaAsset {
param(
[Parameter(Mandatory)] [string]$ReleaseId,
[Parameter(Mandatory)] [string]$File,
[string]$Name
)
if (-not (Test-Path $File)) { throw "gitea-release: asset file not found: $File" }
if (-not $Name) { $Name = Split-Path $File -Leaf }
$api = _GiteaApi; $h = _GiteaHeaders
$assets = Invoke-RestMethod -Method Get -Uri "$api/releases/$ReleaseId/assets" -Headers $h
$existing = $assets | Where-Object { $_.name -eq $Name } | Select-Object -First 1
if ($existing) {
Invoke-RestMethod -Method Delete -Uri "$api/releases/$ReleaseId/assets/$($existing.id)" -Headers $h | Out-Null
}
$enc = [uri]::EscapeDataString($Name)
# curl.exe for the multipart upload — matches the rest of the windows workflows.
curl.exe -fsS -H "Authorization: token $($env:GITEA_TOKEN)" -o NUL `
-X POST "$api/releases/$ReleaseId/assets?name=$enc" -F "attachment=@$File"
if ($LASTEXITCODE -ne 0) { throw "gitea-release: asset upload failed ($LASTEXITCODE): $Name" }
Write-Output "gitea-release: uploaded '$Name' -> release $ReleaseId"
}
+95
View File
@@ -0,0 +1,95 @@
# 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 release.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.
#
# 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"; }
# 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)"
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
# 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"
}
# upsert_asset RELEASE_ID FILE [NAME]
# Attach FILE to the release, replacing any existing asset of the same name first so that
# re-runs and rolling canary re-uploads are idempotent (a plain POST 409s on a dup name).
upsert_asset() {
local rid="${1:?release id}" file="${2:?file}" name="${3:-}"
local api existing
[ -n "$name" ] || name="$(basename "$file")"
[ -f "$file" ] || { echo "gitea-release: asset file not found: $file" >&2; return 1; }
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"
}