From 0205c7b8d635373f2212ec08d0e90a60d84a80aa Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 21 Jun 2026 16:32:55 +0000 Subject: [PATCH] ci(release): split canary/stable tracks + unified Gitea Releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/android.yml | 62 +++++++++++--- .gitea/workflows/deb.yml | 46 +++++++---- .gitea/workflows/decky.yml | 47 +++++------ .gitea/workflows/docker.yml | 5 ++ .gitea/workflows/flatpak.yml | 85 ++++++++++--------- .gitea/workflows/release.yml | 57 ++++++++----- .gitea/workflows/rpm.yml | 45 +++++++--- .gitea/workflows/windows-host.yml | 46 +++++++---- .gitea/workflows/windows-msix.yml | 58 ++++++++++--- clients/android/app/build.gradle.kts | 4 +- clients/windows/packaging/README.md | 9 +- docs-site/content/docs/channels.md | 100 +++++++++++++++++++++++ docs-site/content/docs/install-client.md | 4 + docs-site/content/docs/install.md | 5 ++ docs-site/content/docs/meta.json | 1 + docs/ci.md | 26 ++++-- packaging/debian/README.md | 8 +- packaging/rpm/README.md | 12 +-- packaging/rpm/build-rpm.sh | 2 +- packaging/rpm/punktfunk.spec | 12 +-- packaging/windows/README.md | 8 +- scripts/ci/gitea-release.ps1 | 77 +++++++++++++++++ scripts/ci/gitea-release.sh | 95 +++++++++++++++++++++ 23 files changed, 631 insertions(+), 183 deletions(-) create mode 100644 docs-site/content/docs/channels.md create mode 100644 scripts/ci/gitea-release.ps1 create mode 100644 scripts/ci/gitea-release.sh diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 7aab49e..ee189d7 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -12,6 +12,10 @@ name: android on: push: branches: [main] + # Single project version: a `vX.Y.Z` tag is THE release (uploads to Play's `alpha` closed + # track for manual promotion + attaches the .aab/.apk to the unified Gitea Release). A main + # push is canary (Play `internal`). + tags: ['v*'] pull_request: workflow_dispatch: @@ -69,11 +73,24 @@ jobs: VERSION_CODE: ${{ github.run_number }} run: ./gradlew :app:assembleDebug --stacktrace + # Single source of the version name + the Play track for the release steps below. versionCode + # stays github.run_number (monotonic across both tracks; Play rejects a regressed code). + - name: Version + channel + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) + run: | + case "$GITHUB_REF" in + refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="alpha" ;; # alpha = built-in closed testing + *) VN="0.3.0-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; + esac + echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" + echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" + echo "android version $VN -> Play track '$TRACK'" + - name: Build Release (signed AAB + universal APK) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) working-directory: clients/android env: - VERSION_CODE: ${{ github.run_number }} + VERSION_CODE: ${{ github.run_number }} # VERSION_NAME comes from the Version+channel step (GITHUB_ENV) RELEASE_KEYSTORE_FILE: "../release.jks" RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }} RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} @@ -85,33 +102,52 @@ jobs: # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. # Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). - - name: Publish AAB + APK to Gitea generic registry - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached + # to the unified Gitea Release. + - name: Publish to generic registry + attach to Gitea release + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) env: REGISTRY: git.unom.io OWNER: unom PKG: punktfunk-android VERSION: ${{ github.run_number }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | AAB=clients/android/app/build/outputs/bundle/release/app-release.aab APK=clients/android/app/build/outputs/apk/release/app-release.apk - base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG/$VERSION" - curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/punktfunk-android-r$VERSION.aab" - curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/punktfunk-android-r$VERSION.apk" - echo "Published artifacts (versionCode=$VERSION):" - echo " $base/punktfunk-android-r$VERSION.aab" - echo " $base/punktfunk-android-r$VERSION.apk" + base="https://$REGISTRY/api/packages/$OWNER/generic/$PKG" + # 1) immutable, run-number-versioned store (sideload + provenance) + curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$AAB" "$base/$VERSION/punktfunk-android-r$VERSION.aab" + curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$VERSION/punktfunk-android-r$VERSION.apk" + echo "published store version $VERSION (versionCode)" + # 2) channel alias for a predictable sideload URL: stable -> latest/, canary -> canary/ + case "$GITHUB_REF" in refs/tags/v*) ALIAS=latest ;; *) ALIAS=canary ;; esac + curl -fsS -o /dev/null --user "enricobuehler:$REGISTRY_TOKEN" -X DELETE "$base/$ALIAS/punktfunk-android.apk" || true + curl -fsS --user "enricobuehler:$REGISTRY_TOKEN" --upload-file "$APK" "$base/$ALIAS/punktfunk-android.apk" + echo "sideload alias: $base/$ALIAS/punktfunk-android.apk" + # 3) on a real release, attach the .aab + .apk to the unified Gitea Release (X.Y.Z names) + case "$GITHUB_REF" in + refs/tags/v*) + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$AAB" "punktfunk-${VERSION_NAME}.aab" + upsert_asset "$RID" "$APK" "punktfunk-${VERSION_NAME}.apk" + ;; + esac # Direct Publishing-API upload instead of r0adkll/upload-google-play — that action hides the # real API error behind "Unknown error occurred."; this prints it. stdlib + openssl only (no # pip), reuses SERVICE_ACCOUNT_JSON (raw JSON or base64), auto-handles changesNotSentForReview. - - name: Upload to Google Play (Internal Testing) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Track: canary main -> `internal`; a vX.Y.Z release -> `alpha` (closed testing) for manual + # promotion to production in the Play console. + - name: Upload to Google Play + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) env: SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} run: | + echo "uploading to Play track '$PLAY_TRACK'" python3 clients/android/ci/play-upload.py \ --package io.unom.punktfunk \ --aab clients/android/app/build/outputs/bundle/release/app-release.aab \ - --track internal --status completed + --track "$PLAY_TRACK" --status completed diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index 73cdc22..9a2f58b 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -13,16 +13,16 @@ name: deb on: push: branches: [main] - # HOST-scoped tags only. The Apple client uses `v*` (release.yml); those must NOT trigger a - # host publish — a `v0.1.1` client tag previously shipped a host package versioned 0.1.1 that - # outranked every rolling build (the version-shadow). Host releases use `host-v*`. - tags: ['host-v*'] + # Single project version: a `vX.Y.Z` tag is THE release for every platform (see + # docs-site channels.md). The old version-shadow (a client tag shipping a host package + # that outranked rolling builds) is now structurally impossible — main publishes to the + # `canary` apt distribution, tags to `stable`, so the two never share a version line. + tags: ['v*'] workflow_dispatch: env: REGISTRY: git.unom.io OWNER: unom - DISTRIBUTION: stable COMPONENT: main jobs: @@ -34,19 +34,22 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Version - # host-vX.Y.Z tag -> X.Y.Z (a real host release). A main push -> 0.2.0~ciN.g: the '~' - # sorts it BELOW the eventual 0.2.0 tag, it climbs monotonically by run number, AND it sits - # ABOVE the stray 0.1.1, so `apt upgrade` truly moves boxes forward. Computed BEFORE the - # build so it's stamped into the binary (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version). + - name: Version + channel + # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release). + # A main push -> 0.3.0~ciN.g, published to the `canary` distribution: the '~' sorts + # below the eventual 0.3.0 tag, it climbs monotonically by run number, and the canary base + # stays one minor AHEAD of the latest stable so a stable->canary box re-point still moves + # forward (see channels.md). Computed BEFORE the build so it's stamped into the binary + # (PUNKTFUNK_BUILD_VERSION -> build.rs -> --version). run: | SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) case "$GITHUB_REF" in - refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}" ;; - *) V="0.2.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; DIST=stable ;; + *) V="0.3.0~ci${GITHUB_RUN_NUMBER}.g${SHORT}"; DIST=canary ;; esac echo "VERSION=$V" >> "$GITHUB_ENV" - echo "package version $V" + echo "DISTRIBUTION=$DIST" >> "$GITHUB_ENV" + echo "package version $V -> apt distribution '$DIST'" # dpkg-shlibdeps (Depends resolution) + dpkg-deb live in dpkg-dev. The client's link # deps are also baked into the rust-ci image, but this job runs against the image @@ -55,7 +58,8 @@ jobs: - name: dpkg-dev + client link deps run: | apt-get update - apt-get install -y --no-install-recommends dpkg-dev \ + # python3 is used by scripts/ci/gitea-release.sh for the stable-tag release attach. + apt-get install -y --no-install-recommends dpkg-dev python3 \ libgtk-4-dev libadwaita-1-dev libsdl3-dev # Share ci.yml's cache keys so the release build reuses its registry + target artifacts. @@ -124,3 +128,17 @@ jobs: "https://$REGISTRY/api/packages/$OWNER/debian/pool/$DISTRIBUTION/$COMPONENT/upload" done echo "published to $OWNER/debian $DISTRIBUTION/$COMPONENT" + + # On a real release, also attach the .debs to the unified Gitea Release so they're on the + # downloads page next to every other platform's artifact (canary builds live in the apt + # `canary` distribution above — no release page for those). + - name: Attach .debs to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + for DEB in dist/*.deb; do + upsert_asset "$RID" "$DEB" + done diff --git a/.gitea/workflows/decky.yml b/.gitea/workflows/decky.yml index 0dc60f4..aaeb74c 100644 --- a/.gitea/workflows/decky.yml +++ b/.gitea/workflows/decky.yml @@ -56,19 +56,20 @@ jobs: pnpm install --frozen-lockfile pnpm run build # rollup -> clients/decky/dist/index.js - - name: Version - # Tag v1.2.3 -> 1.2.3; main push -> 0.0.1-ciN.g. Used only for the registry - # version path + the zip name (the plugin.json version is the source of truth Decky - # reads after install). + - name: Version + channel + # Tag vX.Y.Z -> X.Y.Z (stable `latest/` alias + Gitea Release); main push -> 0.3.0-ciN.g + # (`canary/` alias). Used for the registry version path + the zip name (the plugin.json + # version is the source of truth Decky reads after install — bump it in the release commit). working-directory: ${{ gitea.workspace }} run: | SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) case "$GITHUB_REF" in - refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; - *) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; ALIAS=latest ;; + *) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; ALIAS=canary ;; esac echo "VERSION=$V" >> "$GITHUB_ENV" - echo "decky version $V" + echo "ALIAS=$ALIAS" >> "$GITHUB_ENV" + echo "decky version $V -> alias '$ALIAS'" - name: Assemble store-layout zip working-directory: ${{ gitea.workspace }} @@ -102,29 +103,21 @@ jobs: curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ "$BASE/$VERSION/punktfunk.zip" echo "published $BASE/$VERSION/punktfunk.zip" - # 2) Stable `latest/punktfunk.zip` — this is the link to paste into Decky's - # "install from URL". The generic registry rejects re-uploading an existing - # version/file (409), so delete the prior `latest` first (ignore 404 on run #1). + # 2) Channel alias (stable release -> latest/, canary main build -> canary/) — the link + # to paste into Decky's "install from URL". The generic registry rejects re-uploading + # an existing version/file (409), so delete the prior alias first (ignore 404 on run #1). curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ - "$BASE/latest/punktfunk.zip" || true + "$BASE/$ALIAS/punktfunk.zip" || true curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$RUNNER_TEMP/punktfunk.zip" \ - "$BASE/latest/punktfunk.zip" - echo "install-from-URL link: $BASE/latest/punktfunk.zip" + "$BASE/$ALIAS/punktfunk.zip" + echo "install-from-URL link: $BASE/$ALIAS/punktfunk.zip" - - name: Attach zip to the Gitea release (tags only) - if: startsWith(gitea.ref, 'refs/tags/') + - name: Attach zip to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') working-directory: ${{ gitea.workspace }} env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | - API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" - ID=$(curl -sf -X POST "$API/releases" \ - -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \ - || curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') - curl -sf -X POST "$API/releases/$ID/assets?name=punktfunk-${VERSION}.zip" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@$RUNNER_TEMP/punktfunk.zip" >/dev/null - echo "attached punktfunk-${VERSION}.zip to release $GITHUB_REF_NAME" + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$RUNNER_TEMP/punktfunk.zip" "punktfunk-${VERSION}.zip" diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 5bf0be3..13fce1d 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -58,16 +58,21 @@ jobs: - name: Build run: | + # On a release tag, also tag the image vX.Y.Z so a release pins reproducible web/docs images. + EXTRA="" + case "$GITHUB_REF" in refs/tags/v*) EXTRA="-t $REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac docker build --pull ${{ matrix.buildargs }} \ -f "${{ matrix.dockerfile }}" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:latest" \ -t "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" \ + $EXTRA \ "${{ matrix.context }}" - name: Push run: | docker push "$REGISTRY/$OWNER/${{ matrix.image }}:sha-${GITHUB_SHA::8}" docker push "$REGISTRY/$OWNER/${{ matrix.image }}:latest" + case "$GITHUB_REF" in refs/tags/v*) docker push "$REGISTRY/$OWNER/${{ matrix.image }}:${GITHUB_REF_NAME}" ;; esac # Deploy the docs site to unom-1, the DMZ services VM website/cms also deploy to # (docs.punktfunk.unom.io via Caddy on home-reverse-proxy-1 -> :3220). Same secret set diff --git a/.gitea/workflows/flatpak.yml b/.gitea/workflows/flatpak.yml index 590eae4..c470ee1 100644 --- a/.gitea/workflows/flatpak.yml +++ b/.gitea/workflows/flatpak.yml @@ -71,19 +71,23 @@ jobs: https://dl.flathub.org/repo/flathub.flatpakrepo git config --global --add safe.directory "$PWD" - - name: Version - # Tag v1.2.3 -> 1.2.3; a main push -> 0.0.1-ciN.g (sorts before a real release, - # increases by run number — newest main build always wins). The generic registry - # version string allows letters/dots/hyphens. + - name: Version + channel + # Tag vX.Y.Z -> X.Y.Z on the OSTree `stable` branch (a real release); a main push -> + # 0.3.0-ciN.g on the `canary` branch. The two branches live side-by-side in one repo + # (rsync runs without --delete), each tracked by its own .flatpakref, so `flatpak update` + # on a stable box never jumps to a canary build. The generic-registry version string allows + # letters/dots/hyphens. run: | SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) case "$GITHUB_REF" in - refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; - *) V="0.0.1-ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; BRANCH=stable; ALIAS=latest ;; + *) V="0.3.0-ci${GITHUB_RUN_NUMBER}.g${SHORT}"; BRANCH=canary; ALIAS=canary ;; esac echo "VERSION=$V" >> "$GITHUB_ENV" echo "BUNDLE=punktfunk-client-${V}.flatpak" >> "$GITHUB_ENV" - echo "flatpak version $V" + echo "FLATPAK_BRANCH=$BRANCH" >> "$GITHUB_ENV" + echo "ALIAS=$ALIAS" >> "$GITHUB_ENV" + echo "flatpak version $V -> branch '$BRANCH' alias '$ALIAS'" - name: Generate offline cargo sources # flatpak builds with no network; vendor every crate from Cargo.lock into @@ -108,19 +112,20 @@ jobs: # runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions, plus # the runtime's auto codecs-extra (HEVC libavcodec). --disable-rofiles-fuse is the # container-safe path (no FUSE). - # --default-branch=stable pins the ref to app/io.unom.Punktfunk/x86_64/stable so the - # hosted .flatpakref (Branch=stable) matches deterministically (manifest sets no branch). + # --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/ + # (canary or stable) so the matching hosted .flatpakref resolves deterministically + # (manifest sets no branch). flatpak-builder --user --force-clean --disable-rofiles-fuse \ - --default-branch=stable \ + --default-branch="$FLATPAK_BRANCH" \ --install-deps-from=flathub \ --repo="$PWD/repo" \ "$PWD/build-dir" "$MANIFEST" - name: Export single-file bundle run: | - # Branch must be passed explicitly now that the repo ref is `stable` (--default-branch - # above); build-bundle otherwise defaults to `master` and errors "Refspec … not found". - flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" stable + # Branch must be passed explicitly (matches --default-branch above); build-bundle + # otherwise defaults to `master` and errors "Refspec … not found". + flatpak build-bundle "$PWD/repo" "$BUNDLE" "$APP_ID" "$FLATPAK_BRANCH" ls -lh "$BUNDLE" - name: Publish to the Gitea generic registry @@ -132,14 +137,14 @@ jobs: curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ "$BASE/$VERSION/$BUNDLE" echo "published $BASE/$VERSION/$BUNDLE" - # 2) Stable `latest/punktfunk-client.flatpak` alias for the Decky fallback + scripts. - # The generic registry rejects re-uploading an existing version/file (409), so - # delete the prior `latest` file first (ignore 404 on the first ever run). + # 2) Channel alias (stable release -> latest/, canary main build -> canary/) for the + # Decky fallback + scripts. The generic registry rejects re-uploading an existing + # version/file (409), so delete the prior alias file first (ignore 404 on run #1). curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \ - "$BASE/latest/punktfunk-client.flatpak" || true + "$BASE/$ALIAS/punktfunk-client.flatpak" || true curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$BUNDLE" \ - "$BASE/latest/punktfunk-client.flatpak" - echo "published $BASE/latest/punktfunk-client.flatpak" + "$BASE/$ALIAS/punktfunk-client.flatpak" + echo "published $BASE/$ALIAS/punktfunk-client.flatpak" # Sign the OSTree repo flatpak-builder already produced and publish it to flatpak.unom.io on # unom-1, so users get `flatpak update` (the single-file bundle above has no remote). Mirrors @@ -165,7 +170,7 @@ jobs: # build-sign signs the COMMIT objects; build-update-repo signs the SUMMARY. Both are # required — clients with gpg-verify=true verify the commit, so summary-only signing # fails the pull with "GPG verification enabled, but no signatures found". - flatpak build-sign "$PWD/repo" "$APP_ID" stable \ + flatpak build-sign "$PWD/repo" "$APP_ID" "$FLATPAK_BRANCH" \ --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" flatpak build-update-repo --generate-static-deltas \ --gpg-sign="$KEYID" --gpg-homedir="$GNUPGHOME" "$PWD/repo" @@ -180,23 +185,33 @@ jobs: Comment=unom Flatpak applications GPGKey=$GPGKEY EOF - cat > "site/${APP_ID}.flatpakref" < + cat > "site/$1" <<EOF [Flatpak Ref] Name=$APP_ID - Branch=stable + Branch=$2 Url=$REPO_URL/repo/ - Title=Punktfunk + Title=$3 Homepage=https://punktfunk.unom.io IsRuntime=false GPGKey=$GPGKEY RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo EOF + } + write_ref "${APP_ID}.flatpakref" stable "Punktfunk" + write_ref "${APP_ID}.Canary.flatpakref" canary "Punktfunk (Canary)" cat > site/index.html <<EOF <!doctype html><meta charset=utf-8><title>unom flatpak repo

unom Flatpak repository

-

Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates):

+

Install the Punktfunk Linux client (auto-adds Flathub for the GNOME runtime, then tracks updates).

+

Stable (recommended — only moves on releases):

flatpak install --user $REPO_URL/${APP_ID}.flatpakref
           flatpak run $APP_ID
+

Canary (latest main build, unstable):

+
flatpak install --user $REPO_URL/${APP_ID}.Canary.flatpakref

Or add the whole remote: flatpak remote-add --user --if-not-exists unom $REPO_URL/unom.flatpakrepo

EOF # 3) Ship to unom-1 and (re)start the static server. rsync WITHOUT --delete keeps old @@ -207,24 +222,16 @@ jobs: DEST="${DEPLOY_USER}@${DEPLOY_HOST}" $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/repo" rsync -az --info=stats1 -e "$SSH" repo/ "$DEST:$DEPLOY_DIR/site/repo/" - rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/" + rsync -az -e "$SSH" site/unom.flatpakrepo "site/${APP_ID}.flatpakref" "site/${APP_ID}.Canary.flatpakref" site/index.html "$DEST:$DEPLOY_DIR/site/" rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/" $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d" echo "deployed → $REPO_URL/${APP_ID}.flatpakref" - - name: Attach bundle to the Gitea release (tags only) - if: startsWith(gitea.ref, 'refs/tags/') + - name: Attach bundle to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | - API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" - ID=$(curl -sf -X POST "$API/releases" \ - -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \ - || curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') - curl -sf -X POST "$API/releases/$ID/assets?name=$BUNDLE" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@$BUNDLE" >/dev/null - echo "attached $BUNDLE to release $GITHUB_REF_NAME" + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$BUNDLE" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1c84049..a407437 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -46,6 +46,19 @@ name: release on: push: + # Canary: a relevant main push uploads the iOS + macOS builds to TestFlight (Apple's own + # canary channel) — no notarized DMG, no tvOS (those are stable-only; see the per-step gates). + # Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are + # continue-on-error until the App Store Connect record exists, so this no-ops until then. + branches: [main] + paths: + - 'clients/apple/**' + - 'crates/punktfunk-core/**' + - 'scripts/build-xcframework.sh' + - 'Cargo.lock' + - '.gitea/workflows/release.yml' + # Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release + # + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. tags: ['v*'] workflow_dispatch: inputs: @@ -87,8 +100,8 @@ jobs: - name: Version from tag run: | case "$GITHUB_REF" in - refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; - *) V="0.0.${GITHUB_RUN_NUMBER}" ;; + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) + *) V="0.3.0" ;; # canary marketing version; the build number disambiguates esac echo "VERSION=$V" >> "$GITHUB_ENV" echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" @@ -105,8 +118,16 @@ jobs: "$RUSTUP" toolchain install nightly --profile minimal "$RUSTUP" component add rust-src --toolchain nightly - - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) - run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh + - name: Build PunktfunkCore.xcframework (mac + iOS; + tvOS on stable tags) + # tvOS uses nightly -Zbuild-std (slow) — build it only for a real release, not on every + # canary main push. + run: | + TV="" + case "$GITHUB_REF" in refs/tags/v*) TV="BUILD_TVOS=1" ;; esac + # `env` (not a bare prefix): a $TV-expanded `NAME=val` word is NOT re-promoted to a shell + # assignment, so `BUILD_IOS=1 $TV bash …` would try to RUN `BUILD_TVOS=1` (exit 127). env + # treats its leading NAME=val args as assignments post-expansion; empty $TV is a no-op. + env BUILD_IOS=1 $TV bash scripts/build-xcframework.sh - name: Stage App Store Connect API key env: @@ -116,6 +137,9 @@ jobs: chmod 600 "$RUNNER_TEMP/asc.p8" - name: macOS — archive, codesign Developer ID, notarize, DMG + # Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not + # relevant to TestFlight testers (the canary channel). Skipped on canary main pushes. + if: startsWith(gitea.ref, 'refs/tags/v') run: | # Archive UNSIGNED, then codesign with the Developer ID Application identity from the # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups @@ -154,23 +178,14 @@ jobs: DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG" echo "DMG=$DMG" >> "$GITHUB_ENV" - - name: Attach DMG to Gitea release - if: startsWith(gitea.ref, 'refs/tags/') + - name: Attach DMG to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | - API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" - # Create the release (409 -> already exists, fetch it instead). - ID=$(curl -sf -X POST "$API/releases" \ - -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ - -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \ - || curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \ - | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') - curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \ - -H "Authorization: token $TOKEN" \ - -F "attachment=@$DMG" >/dev/null - echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME" + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg" - name: macOS App Store — archive + upload to TestFlight if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' @@ -278,7 +293,9 @@ jobs: -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" - name: tvOS — archive + upload to TestFlight - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + # Stable only — the tvOS xcframework slice is built just for releases (above), and the + # App Store Connect record + runner platform are tvOS prerequisites. + if: startsWith(gitea.ref, 'refs/tags/v') && (gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true') # Needs tvOS added to the App Store Connect app record + the tvOS platform installed # on the runner (xcodebuild -downloadPlatform tvOS). continue-on-error: true diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 8768f18..f9ee608 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -13,9 +13,10 @@ name: rpm on: push: branches: [main] - # HOST-scoped tags only — the Apple client's `v*` tags (release.yml) must NOT publish a host - # RPM (a `v0.1.1` client tag previously shipped a host 0.1.1 that shadowed every rolling build). - tags: ['host-v*'] + # Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm + # groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old + # version-shadow (a release outranking rolling builds in one group) is structurally gone. + tags: ['v*'] workflow_dispatch: env: @@ -66,20 +67,22 @@ jobs: key: cargo-home-${{ hashFiles('Cargo.lock') }} restore-keys: cargo-home- - - name: Version - # host-vX.Y.Z tag -> X.Y.Z-1 (a real host release); main push -> 0.2.0-0.ciN.g, whose - # "0." release sorts BELOW the eventual 0.2.0-1 yet climbs by run number AND outranks the - # stray 0.1.1, so `rpm-ostree upgrade` truly moves to the newest build. The spec %build - # stamps PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance). + - name: Version + channel + # vX.Y.Z tag -> X.Y.Z-1 in the base group (a real release); main push -> 0.3.0-0.ciN.g + # in the `-canary` group, whose "0." release sorts below the eventual 0.3.0-1 yet + # climbs by run number. The canary base stays one minor ahead of the latest stable so a + # stable->canary box re-point still moves forward. The spec %build stamps + # PUNKTFUNK_BUILD_VERSION from these macros into the binary (--version provenance). run: | SHORT=$(echo "$GITHUB_SHA" | cut -c1-8) case "$GITHUB_REF" in - refs/tags/host-v*) V="${GITHUB_REF_NAME#host-v}"; R="1" ;; - *) V="0.2.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}" ;; + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; GROUP="${{ matrix.group }}" ;; + *) V="0.3.0"; R="0.ci${GITHUB_RUN_NUMBER}.g${SHORT}"; GROUP="${{ matrix.group }}-canary" ;; esac echo "PF_VERSION=$V" >> "$GITHUB_ENV" echo "PF_RELEASE=$R" >> "$GITHUB_ENV" - echo "rpm $V-$R" + echo "GROUP=$GROUP" >> "$GITHUB_ENV" + echo "rpm $V-$R -> group '$GROUP'" - name: Build RPM # PF_WITH_WEB=1 → also build the noarch punktfunk-web subpackage (the publish loop below @@ -101,6 +104,22 @@ jobs: case "$rpm" in *debuginfo*|*debugsource*) echo "skip $rpm"; continue;; esac echo "uploading $rpm" curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$rpm" \ - "https://$REGISTRY/api/packages/$OWNER/rpm/${{ matrix.group }}/upload" + "https://$REGISTRY/api/packages/$OWNER/rpm/$GROUP/upload" + done + echo "published to $OWNER/rpm/$GROUP" + + # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases + # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep + # both on the release; canary builds live in the `*-canary` rpm groups (no release page). + - name: Attach .rpms to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + for rpm in dist/*.rpm; do + case "$rpm" in *debuginfo*|*debugsource*) continue;; esac + base="$(basename "$rpm" .rpm)" + upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm" done - echo "published to $OWNER/rpm/${{ matrix.group }}" diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index ce2fdef..cb635c2 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -11,10 +11,10 @@ # # Registry (public reads, unom org): https://git.unom.io/unom/-/packages (generic group) # -# Versioning (free-form; not MSIX's 4-part rule): -# host-win-vX.Y.Z tag -> X.Y.Z (a real host release; own tag namespace, off host-v*/win-v*/v* -# to avoid the version-shadow bug class — see deb.yml). -# main push / dispatch -> 0.2. (rolling; climbs monotonically by run number). +# Versioning (free-form; not MSIX's 4-part rule) — single project version: +# vX.Y.Z tag -> X.Y.Z (THE release; published + stable `latest/` alias + attached to the +# unified Gitea Release). +# main push / dispatch -> 0.3. (canary; `canary/` alias; climbs by run number). # # Signing reuses the client's MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD secrets (CN=unom). Without them # an ephemeral self-signed cert is generated and its public .cer published next to the installer @@ -36,7 +36,7 @@ on: - 'Cargo.lock' - 'Cargo.toml' - '.gitea/workflows/windows-host.yml' - tags: ['host-win-v*'] + tags: ['v*'] workflow_dispatch: env: @@ -59,10 +59,10 @@ jobs: # (pwsh Out-File utf8 = no BOM, unlike Windows PowerShell 5.1 — keeps the first line clean). "CARGO_TARGET_DIR=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "CARGO_WORKSPACE_DIR=$env:GITHUB_WORKSPACE" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - $v = if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { - $env:GITHUB_REF_NAME -replace '^host-win-v', '' + $v = if ($env:GITHUB_REF -like 'refs/tags/v*') { + $env:GITHUB_REF_NAME -replace '^v', '' } else { - "0.2.$($env:GITHUB_RUN_NUMBER)" + "0.3.$($env:GITHUB_RUN_NUMBER)" } "HOST_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "PUNKTFUNK_BUILD_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 @@ -116,13 +116,25 @@ jobs: if (-not $files) { throw "pack produced no artifacts to publish" } $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" foreach ($f in $files) { Publish-File $f "$base/$($env:HOST_VERSION)/$(Split-Path $f -Leaf)" } - # On a tagged release, also refresh the stable `latest/` alias (delete-then-reupload, like - # flatpak.yml/decky.yml) so there's a predictable download URL. - if ($env:GITHUB_REF -like 'refs/tags/host-win-v*') { - $aliases = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' } - foreach ($f in $files) { - $alias = $aliases[$f]; if (-not $alias) { continue } - curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/latest/$alias" 2>$null - Publish-File $f "$base/latest/$alias" - } + # Refresh the channel alias (delete-then-reupload, like flatpak.yml/decky.yml) for a + # predictable download URL: stable release -> `latest/`, canary main build -> `canary/`. + $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } + $aliasNames = @{ $env:HOST_SETUP_PATH = 'punktfunk-host-setup.exe'; $env:HOST_CER_PATH = 'punktfunk-host-windows.cer' } + foreach ($f in $files) { + $an = $aliasNames[$f]; if (-not $an) { continue } + curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null + Publish-File $f "$base/$alias/$an" + } + + # On a real release, also attach the signed installer (+ its .cer) to the unified Gitea Release. + - name: Attach host installer to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + shell: pwsh + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.ps1 + $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' + foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) { + if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } } diff --git a/.gitea/workflows/windows-msix.yml b/.gitea/workflows/windows-msix.yml index f99f889..853d5f0 100644 --- a/.gitea/workflows/windows-msix.yml +++ b/.gitea/workflows/windows-msix.yml @@ -11,11 +11,12 @@ # Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) # Packaging internals: clients/windows/packaging/README.md. # -# Versioning — MSIX requires a strictly 4-part numeric version (no ~/- suffixes), so: -# win-vX.Y.Z tag -> X.Y.Z.0 (a real Windows-client release; `win-v*` is its own tag namespace, -# kept off the host's `host-v*` and the Apple `v*` to avoid the -# version-shadow class of bug — see deb.yml). -# main push / dispatch -> 0.2..0 (rolling; climbs monotonically by run number). +# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so: +# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). +# Published to the generic registry + the stable `latest/` alias + attached to the +# unified Gitea Release alongside every other platform's artifact. +# main push / dispatch -> 0.3..0 (canary; climbs monotonically by run number). +# Published to the generic registry + the `canary/` alias. # Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). # # Signing (packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD Actions secrets @@ -34,7 +35,7 @@ on: - 'Cargo.lock' - 'Cargo.toml' - '.gitea/workflows/windows-msix.yml' - tags: ['win-v*'] + tags: ['v*'] workflow_dispatch: env: @@ -72,10 +73,11 @@ jobs: "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "FFMPEG_DIR=${{ matrix.ffmpeg }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 rustup target add ${{ matrix.target }} - $parts = if ($env:GITHUB_REF -like 'refs/tags/win-v*') { - ($env:GITHUB_REF_NAME -replace '^win-v', '').Split('.') + $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { + # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. + (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') } else { - @('0', '2', $env:GITHUB_RUN_NUMBER) + @('0', '3', $env:GITHUB_RUN_NUMBER) } while ($parts.Count -lt 4) { $parts += '0' } $v = ($parts[0..3] -join '.') @@ -101,11 +103,43 @@ jobs: env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | + $PSNativeCommandUseErrorActionPreference = $false + $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" + # stable release -> `latest/` alias; canary main build -> `canary/` alias. + $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } + # version-less, arch-suffixed alias names so each channel keeps one predictable URL. + $aliasNames = @{ + "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" + "$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer" + } $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } if (-not $files) { throw "pack produced no artifacts to publish" } + function Put($f, $url) { + curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" + if ($LASTEXITCODE -ne 0) { throw "upload failed ($LASTEXITCODE): $url" } + Write-Output "published $url" + } foreach ($f in $files) { $name = Split-Path $f -Leaf - $url = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)/$($env:MSIX_VERSION)/$name" - curl.exe -fsS --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url" - Write-Output "published $name -> $url" + # 1) immutable, versioned path + Put $f "$base/$($env:MSIX_VERSION)/$name" + # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file) + $an = $aliasNames["$f"] + curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null + Put $f "$base/$alias/$an" + } + + # On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both + # arch legs attach to the same release concurrently — the helper's create-or-fetch handles + # the race, and x64/arm64 filenames differ so the assets don't collide. + - name: Attach MSIX to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + shell: pwsh + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.ps1 + $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' + foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) { + if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } } diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 17880a2..3d48a2c 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -26,7 +26,9 @@ android { targetSdk = 36 val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE")) versionCode = vCode?.toInt() ?: 1 - versionName = "0.0.2" // bumped for first Play Store release + // versionName is the single project version, threaded from CI (a vX.Y.Z release or a + // canary string). versionCode stays the monotonic run number (Play rejects regressions). + versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2" ndk { abiFilters += listOf("arm64-v8a", "x86_64") } } diff --git a/clients/windows/packaging/README.md b/clients/windows/packaging/README.md index 6e4cdad..5bc0a4b 100644 --- a/clients/windows/packaging/README.md +++ b/clients/windows/packaging/README.md @@ -4,7 +4,8 @@ The Windows client ships as **signed MSIX** packages so Windows boxes get a real tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from [`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's **generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that -touches the client and on `win-v*` release tags. +touches the client (canary) and on `vX.Y.Z` release tags (stable) — see +[Release Channels](https://punktfunk.unom.io/docs/channels). **Two architectures, one x64 runner.** Both `x64` and `arm64` packages are produced off the single x64 Windows runner — `x86_64-pc-windows-msvc` builds natively, `aarch64-pc-windows-msvc` is @@ -39,9 +40,9 @@ because it owns raw D3D11, Win32 low-level input hooks, WASAPI and SDL3. ## Versioning MSIX requires a strictly 4-part numeric version. The workflow computes: -- `win-vX.Y.Z` tag → `X.Y.Z.0` (a real client release; `win-v*` is its own tag namespace, kept off - the host's `host-v*` and Apple's `v*` to avoid the version-shadow bug). -- `main` push / `workflow_dispatch` → `0.2..0` (rolling, climbs by run number). +- `vX.Y.Z` tag → `X.Y.Z.0` (THE release; any `-rc`/`+meta` suffix is dropped for MSIX). Published to + the stable `latest/` alias and attached to the unified Gitea Release. +- `main` push / `workflow_dispatch` → `0.3..0` (canary, climbs by run number; `canary/` alias). ## Signing & install diff --git a/docs-site/content/docs/channels.md b/docs-site/content/docs/channels.md new file mode 100644 index 0000000..45430a9 --- /dev/null +++ b/docs-site/content/docs/channels.md @@ -0,0 +1,100 @@ +--- +title: Release Channels +description: How punktfunk ships — the canary (every main push) and stable (vX.Y.Z) tracks, how to subscribe to each, and how to cut a release. +--- + +punktfunk ships on **two tracks**. Every push to `main` publishes a **canary** build to the +canary channels (fast iteration, possibly broken). A `vX.Y.Z` git tag cuts a **stable** release: +every platform is built at that one version, published to the stable channels, and all the +artifacts (`.deb`, `.rpm`, `.msix`, host installer, `.apk`/`.aab`, `.dmg`, flatpak, Decky zip) +are attached to a single [Gitea Release](https://git.unom.io/unom/punktfunk/releases). + +The two tracks are **separate repos / tracks per platform**, never a shared version line — so a +stable box never gets pulled onto a canary build, and a canary box always moves forward. Pick the +track per machine; switching is a one-line change. + +## Which track should I be on? + +- **Canary** — dev boxes, your own test fleet, "I want the latest main build." Updates land minutes + after a merge. +- **Stable** — anything you don't want to babysit. Only moves when a `vX.Y.Z` tag is cut. + +## Subscribe — per platform + +| Platform | Canary | Stable | +|---|---|---| +| **apt** (host/client) | `deb [signed-by=…] https://git.unom.io/api/packages/unom/debian canary main` | `… debian stable main` | +| **rpm** (host) | baseurl `…/rpm/bazzite-canary` (or `fedora-44-canary`) | `…/rpm/bazzite` (or `fedora-44`) | +| **Flatpak** (client) | `flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref` | `…/io.unom.Punktfunk.flatpakref` | +| **Decky** (Steam Deck) | install-from-URL `…/generic/punktfunk-decky/canary/punktfunk.zip` | `…/punktfunk-decky/latest/punktfunk.zip` | +| **Windows client** (MSIX) | `…/generic/punktfunk-client-windows/canary/punktfunk-client-windows_x64.msix` | `…/latest/…` + the release page | +| **Windows host** (installer) | `…/generic/punktfunk-host-windows/canary/punktfunk-host-setup.exe` | `…/latest/…` + the release page | +| **Android** | Play **Internal testing** + sideload `…/generic/punktfunk-android/canary/punktfunk-android.apk` | Play **closed (alpha)** track + the release page | +| **Apple** (mac/iOS/tvOS) | **TestFlight** | TestFlight + a notarized `.dmg` on the release page | + +The apt distribution and the rpm group are just path segments in the URL — switching tracks is a +one-line edit of `/etc/apt/sources.list.d/punktfunk.list` (`stable` ↔ `canary`) or +`/etc/yum.repos.d/punktfunk.repo` (`…/rpm/bazzite` ↔ `…/rpm/bazzite-canary`), then +`apt update` / `rpm-ostree upgrade`. + +> The OS-package channels (apt/rpm) are how Linux hosts get canary builds — they are **not** +> attached to a canary release page. The Gitea Releases page is stable-only. + +## Cut a stable release (maintainer) + +1. Make sure `main` is green. +2. (Optional) bump any user-facing version that isn't derived from the tag — the Android + `versionName` fallback (`clients/android/app/build.gradle.kts`) and the Decky `plugin.json` + `version` are cosmetic self-reported strings; everything else (binaries via + `PUNKTFUNK_BUILD_VERSION`, MSIX, apt/rpm, the `.dmg`) derives from the tag automatically. +3. Tag and push — **one** tag releases every platform: + ```sh + git tag v0.2.0 + git push origin v0.2.0 + ``` +4. Every platform workflow fans out, builds at `0.2.0`, publishes to its **stable** channel, and + attaches its artifact to the `v0.2.0` Gitea Release. Concurrent attaches are safe — the shared + `scripts/ci/gitea-release.{sh,ps1}` helper creates the release once and the rest reuse it. +5. **Promote the app stores manually** (CI only uploads to testing tracks — see below). +6. After a release reaches the current canary base, bump the canary base one minor ahead in + `deb.yml` / `rpm.yml` (and the `0.3.` strings in the other workflows) so a stable→canary + re-point still moves forward. Rule: **canary base = one minor ahead of the latest stable.** + +Pre-release tags work too: `v0.2.0-rc1` builds a real release (the `-rc1` suffix is dropped where a +strictly-numeric version is required — MSIX, the App Store marketing version). + +### App-store promotion (manual, after the tag) + +CI uploads stable to **testing** tracks only — it never auto-publishes to the public stores: + +- **Apple** — the build lands in **TestFlight**. Promote to the App Store from App Store Connect + (submit for review). The notarized `.dmg` on the release page is the direct-download path. +- **Android** — the build lands in Play's **closed (alpha)** track. Promote alpha → production in + the Play Console when ready. + +## Why two tracks (the version-shadow trap) + +apt/rpm/registries serve the **highest** version to every subscriber. If a stable release landed in +the same channel as rolling main builds, every box would jump to it and get **stuck** — the rolling +`0.3.0~ciN` build never climbs above a `0.3.0` release. Separate canary/stable channels remove the +trap by construction, which is why a single `vX.Y.Z` tag can safely release the whole project at +once (the old `host-v*` / `win-v*` / `host-win-v*` tag namespaces are retired — `v*` is the only +release tag now). + +## Migrating an existing box to canary + +Boxes added before this split point at the current stable channels, which now only move on releases. +Point your dev fleet at **canary**: + +```sh +# apt +sudo sed -i 's/ stable main/ canary main/' /etc/apt/sources.list.d/punktfunk.list +sudo apt update && sudo apt upgrade + +# rpm-ostree (Bazzite / Fedora) +sudo sed -i 's#/rpm/bazzite#/rpm/bazzite-canary#' /etc/yum.repos.d/punktfunk.repo # or fedora-44 → fedora-44-canary +rpm-ostree upgrade + +# Flatpak (Steam Deck client) +flatpak install --user https://flatpak.unom.io/io.unom.Punktfunk.Canary.flatpakref +``` diff --git a/docs-site/content/docs/install-client.md b/docs-site/content/docs/install-client.md index 726a7c7..4f629b5 100644 --- a/docs-site/content/docs/install-client.md +++ b/docs-site/content/docs/install-client.md @@ -7,6 +7,10 @@ This page is the **install path for each client device**. For what each client * pick, see [Clients](/docs/clients); to install the **host**, see [Install the Host](/docs/install). Whichever client you install, the first connection needs a one-time [pairing](/docs/pairing). +> The links below are the **stable** channel (moves on `vX.Y.Z` releases). For the latest `main` +> build, use the **canary** channel — TestFlight / Play Internal, the `…Canary.flatpakref`, or the +> `canary/` download URLs. See [Release Channels](/docs/channels). + ## Pick your device | Device | Install | diff --git a/docs-site/content/docs/install.md b/docs-site/content/docs/install.md index b164601..18352d0 100644 --- a/docs-site/content/docs/install.md +++ b/docs-site/content/docs/install.md @@ -21,6 +21,11 @@ Each registry is public — no auth, you just trust the repo's signing key. Addi one-time step covered in the linked guide; after that, normal `apt upgrade` / `rpm-ostree upgrade` tracks new builds automatically. +> **Stable vs canary.** The repos in the per-distro guides are the **stable** channel — it only +> moves when a `vX.Y.Z` release is cut. For the latest `main` build (fast, possibly broken), point +> at the **canary** channel instead (`canary` apt distribution / `*-canary` rpm group). See +> [Release Channels](/docs/channels). + ## Windows (NVIDIA) punktfunk also runs as a native host on **Windows 10/11 (x64) with an NVIDIA GPU**, shipped as a diff --git a/docs-site/content/docs/meta.json b/docs-site/content/docs/meta.json index 2d573f7..4e37c19 100644 --- a/docs-site/content/docs/meta.json +++ b/docs-site/content/docs/meta.json @@ -25,6 +25,7 @@ "troubleshooting", "---Project---", "roadmap", + "channels", "---Reference---", "[API Reference](/api)" ] diff --git a/docs/ci.md b/docs/ci.md index 68ceefe..04c34a5 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -7,15 +7,31 @@ CI runs on **Gitea Actions** (`git.unom.io`, org `unom`). The workflows live in `.gitea/workflows/`; they run across Linux and macOS runners and push a few images to the Gitea container registry. +## Release model + +Two tracks (full guide: [Release Channels](https://punktfunk.unom.io/docs/channels)). A push to +`main` publishes **canary** builds to the canary channels; a single **`vX.Y.Z` tag** is THE release +for every platform — built at that version, published to the **stable** channels, and every artifact +attached to one Gitea Release via the shared `scripts/ci/gitea-release.{sh,ps1}` helper (idempotent +create-or-fetch + delete-before-upload, so concurrent cross-runner attaches don't collide). The old +`host-v*` / `win-v*` / `host-win-v*` tag namespaces are retired — `v*` is the only release tag. + ## Workflows | Workflow | Trigger | Runner | What it does | |---|---|---|---| -| `ci.yml` | push to `main`, PRs | Linux | Rust workspace (fmt · clippy `-D warnings` · build · test · C-ABI harness · generated-header drift) inside the `punktfunk-rust-ci` image; `web/` and `docs-site/` build + typecheck in `oven/bun:1` | -| `docker.yml` | push to `main`, `v*` tags, manual | Linux | Builds + pushes the images below (`latest` + `sha-` tags) | -| `apple.yml` | push to `main`, PRs, manual | macOS | Rust core → `PunktfunkCore.xcframework` → `swift build` + `swift test` in `clients/apple` | -| `release.yml` | `v*` tags, manual | macOS | Production Apple builds: sandboxed macOS `.dmg` (Developer ID, notarized, stapled) attached to the Gitea release + macOS/iOS/tvOS archives uploaded to TestFlight | -| `windows-msix.yml` | push to `main`, `v*` tags, manual | Windows | Builds the Windows client for `x86_64`/`aarch64` and packages signed MSIX artifacts | +| `ci.yml` | push `main`, PRs | Linux | Rust workspace (fmt · clippy `-D warnings` · build · test · C-ABI harness · header drift) in `punktfunk-rust-ci`; `web/` + `docs-site/` build + typecheck in `oven/bun:1` | +| `apple.yml` | push `main`, PRs, manual | macOS | Rust core → `PunktfunkCore.xcframework` → `swift build`/`swift test` (CI gate, no publish) | +| `windows.yml` | push `main` (paths), PRs, manual | Windows | client build · clippy · fmt · test for `x86_64`/`aarch64` (CI gate, no publish) | +| `deb.yml` | push `main` → canary, `v*` → stable, manual | Linux | host/client/web `.deb` → apt (`canary`/`stable` distribution); `v*` attaches to the release | +| `rpm.yml` | push `main` → canary, `v*` → stable, manual | Linux | host `.rpm` (bazzite + fedora-44 bases) → rpm (`*-canary`/base groups); `v*` attaches | +| `windows-msix.yml` | push `main` (paths) → canary, `v*` → stable, manual | Windows | client MSIX `x64`+`arm64` → generic registry (`canary/`/`latest/`); `v*` attaches | +| `windows-host.yml` | push `main` (paths) → canary, `v*` → stable, manual | Windows | host Inno installer → generic registry (`canary/`/`latest/`); `v*` attaches | +| `android.yml` | push `main` → Play internal, `v*` → Play alpha, PRs, manual | Linux | signed AAB+APK → Play + generic registry; `v*` attaches | +| `release.yml` | push `main` (paths) → TestFlight, `v*` → DMG + TestFlight, manual | macOS | Apple mac/iOS(/tvOS on stable); `v*` notarized `.dmg` attaches | +| `flatpak.yml` | push `main` (paths) → canary branch, `v*` → stable, manual | Linux | client flatpak (OSTree repo + bundle, branch per channel); `v*` attaches | +| `decky.yml` | push `main` → canary, `v*` → stable, manual | Linux | Decky plugin zip → generic registry (`canary/`/`latest/`); `v*` attaches | +| `docker.yml` | push `main`, `v*`, manual | Linux | web/docs/CI images (`latest` + `sha-`; `v*` adds a `vX.Y.Z` tag) | ## Dockerized pieces diff --git a/packaging/debian/README.md b/packaging/debian/README.md index 05468cc..d2f76c3 100644 --- a/packaging/debian/README.md +++ b/packaging/debian/README.md @@ -2,9 +2,11 @@ `punktfunk-host` is published as a `.deb` to **Gitea's Debian package registry** in the public `unom` org, so the Ubuntu hosts update with plain `apt`. CI (`.gitea/workflows/deb.yml`) builds -and publishes on every push to `main` (a rolling `0.2.0~ciN.g` build) and on `host-v*` tags -(a clean `X.Y.Z`) — the rolling builds outrank the stray `0.1.1`, so plain `apt upgrade` always -gets the latest (no version pin needed). +and publishes on every push to `main` (a rolling `0.3.0~ciN.g` build to the **`canary`** apt +distribution) and on `vX.Y.Z` tags (a clean `X.Y.Z` to the **`stable`** distribution, plus attached +to the unified Gitea Release). The two are separate apt distributions, so a stable box never jumps +to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/channels). The repo line +below subscribes to `stable`; swap `stable` → `canary` for the latest main builds. The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing + status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends** diff --git a/packaging/rpm/README.md b/packaging/rpm/README.md index 4653397..63c4451 100644 --- a/packaging/rpm/README.md +++ b/packaging/rpm/README.md @@ -1,11 +1,13 @@ # punktfunk-host — RPM (Bazzite / Fedora Atomic) via the Gitea registry `punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom` -org (group `bazzite`), so Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. -CI (`.gitea/workflows/rpm.yml`) builds and publishes on every push to `main` (a rolling -`0.2.0-0.ciN.` build, which outranks the stray `0.1.1` so `rpm-ostree upgrade` always gets the -latest — no version pin needed) and on **host-scoped** `host-v*` tags (a clean `X.Y.Z-1`; the Apple -client's `v*` tags deliberately do **not** publish a host RPM). The RPM is built in the +org (stable groups `bazzite`/`fedora-44`, canary groups `bazzite-canary`/`fedora-44-canary`), so +Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`) +builds and publishes on every push to `main` (a rolling `0.3.0-0.ciN.` build to the `*-canary` +groups) and on `vX.Y.Z` tags (a clean `X.Y.Z-1` to the base groups, plus attached to the unified +Gitea Release) — separate repos, so a stable box never jumps to a canary build (see +[Release Channels](https://punktfunk.unom.io/docs/channels)). The `baseurl` below subscribes to the +`bazzite` stable group; use `bazzite-canary` for the latest main builds. The RPM is built in the Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires (`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends). diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index 9fb4996..83052cd 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -9,7 +9,7 @@ # Output: dist/punktfunk--..rpm (+ the -debuginfo/-debugsource subpkgs) set -euo pipefail -PF_VERSION="${PF_VERSION:-0.2.0}" +PF_VERSION="${PF_VERSION:-0.3.0}" # canary base; keep one minor ahead of the latest stable release PF_RELEASE="${PF_RELEASE:-1}" # PF_WITH_WEB=1 builds the punktfunk-web subpackage too (needs `bun` on PATH — present in the CI # builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works. diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 072a5e6..9206307 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -17,12 +17,12 @@ ################################################################################ Name: punktfunk -# Version/Release are overridable so CI can stamp a rolling snapshot: a main build passes -# --define "pf_version 0.2.0" --define "pf_release 0.ci42.gdeadbee" -# (Release starting "0." sorts BEFORE the eventual "1" release; base 0.2.0 sits ABOVE the stray -# 0.1.1), a host-v* tag passes the clean version with "pf_release 1". A plain `rpmbuild` (or COPR) -# with no defines builds 0.2.0-1. -Version: %{?pf_version}%{!?pf_version:0.2.0} +# Version/Release are overridable so CI can stamp a rolling snapshot: a canary main build passes +# --define "pf_version 0.3.0" --define "pf_release 0.ci42.gdeadbee" +# (Release starting "0." sorts BEFORE the eventual "1" release; the canary base stays one minor +# ahead of the latest stable), a vX.Y.Z release tag passes the clean version with "pf_release 1". +# A plain `rpmbuild` (or COPR) with no defines builds 0.3.0-1. +Version: %{?pf_version}%{!?pf_version:0.3.0} Release: %{?pf_release}%{!?pf_release:1}%{?dist} Summary: Low-latency desktop/game streaming host (Moonlight-compatible + punktfunk/1) diff --git a/packaging/windows/README.md b/packaging/windows/README.md index f8695f0..fce703f 100644 --- a/packaging/windows/README.md +++ b/packaging/windows/README.md @@ -83,6 +83,8 @@ pwsh -File packaging\windows\pack-host-installer.ps1 -Version 0.0.0-dev -TargetD ## Release -Push a `host-win-vX.Y.Z` tag — the workflow builds, signs, and publishes -`punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, and refreshes the `latest/` alias. Main pushes -publish rolling `0.2.` builds (no `latest/` update). +Push a `vX.Y.Z` tag — one tag releases every platform (see +[Release Channels](https://punktfunk.unom.io/docs/channels)). The workflow builds, signs, and +publishes `punktfunk-host-setup-X.Y.Z.exe` + the public `.cer`, refreshes the stable `latest/` +alias, and attaches the installer to the unified Gitea Release. Main pushes publish rolling +`0.3.` **canary** builds to the `canary/` alias. diff --git a/scripts/ci/gitea-release.ps1 b/scripts/ci/gitea-release.ps1 new file mode 100644 index 0000000..77497c4 --- /dev/null +++ b/scripts/ci/gitea-release.ps1 @@ -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" +} diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh new file mode 100644 index 0000000..0cb9ca2 --- /dev/null +++ b/scripts/ci/gitea-release.sh @@ -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" +}