From ed935ed31c011ebfa482de81c7fd6fa6bbabe7d7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 21:39:17 +0200 Subject: [PATCH 1/2] ci(android): canary ships to Play open testing (beta), not internal Main-push canaries now land on the open-testing track: public opt-in link, no tester-list cap. Trade-off documented in the workflow header: open testing goes through Google review (hours/days), where internal was review-free (minutes). android-promote's from_track default follows the canary to beta. --- .gitea/workflows/android-promote.yml | 4 ++-- .gitea/workflows/android.yml | 17 +++++++++++------ clients/android/README.md | 3 ++- docs/releases/README.md | 4 ++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/android-promote.yml b/.gitea/workflows/android-promote.yml index efedb712..693b4b01 100644 --- a/.gitea/workflows/android-promote.yml +++ b/.gitea/workflows/android-promote.yml @@ -6,7 +6,7 @@ # android.yml would mean an `if:` on all ten of its build steps. # # What it is for: -# * promote a tested build up a track (alpha -> production) +# * promote a tested build up a track (beta -> production) # * roll production back by re-pointing it at an older versionCode (to_track=production, # version_code=, from_track blank) # * halt a rollout (status=halted) @@ -36,7 +36,7 @@ on: from_track: description: 'track to verify it is on, then clear (blank = touch nothing else)' required: false - default: 'alpha' + default: 'beta' notes_tag: description: "tag whose docs/releases/whatsnew/.txt to attach, e.g. v0.23.0 (blank = none)" required: false diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 3b4b5b76..c1a1596b 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -36,8 +36,11 @@ on: - '.gitea/workflows/android.yml' # Single project version: a `vX.Y.Z` tag is THE release (publishes to Play `production` at # 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary - # (Play `internal`). Production access was granted 2026-08-01; before that a tag could only - # reach `alpha` and someone had to promote it by hand in the Console. + # (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous + # `internal` target, every canary now passes Google review before testers see it, so a + # canary lands in hours/days, not minutes). Production access was granted 2026-08-01; + # before that a tag could only reach `alpha` and someone had to promote it by hand in + # the Console. tags: ['v*'] pull_request: paths: @@ -94,8 +97,9 @@ jobs: # store listing. Failing here also means a missing file cannot leave a half-published # release: nothing is built, nothing is attached to the Gitea release, nothing reaches Play. # - # Canary is exempt on purpose: it has no curated notes, and Play reusing text for internal - # testers costs nothing. + # Canary is exempt on purpose: it has no curated notes. Open-testing users therefore see + # the previous release's text on a canary — cosmetic, and cheaper than gating every main + # push on a notes file. - name: Play release notes gate (tags only) if: startsWith(github.ref, 'refs/tags/v') run: | @@ -227,7 +231,7 @@ jobs: eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag) case "$GITHUB_REF" in refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;; - *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="internal" ;; + *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta" ;; esac echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" @@ -312,7 +316,8 @@ jobs: # 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. - # Track: canary main -> `internal`; a vX.Y.Z release -> `production` at 100% (`completed`). + # Track: canary main -> `beta` (open testing); a vX.Y.Z release -> `production` at 100% + # (`completed`). # # A tag therefore ships to real users with no further click. Two things keep that honest: # the tag is only pushed once every platform is green, and Play reviews each production diff --git a/clients/android/README.md b/clients/android/README.md index 1d06356e..97dfcba1 100644 --- a/clients/android/README.md +++ b/clients/android/README.md @@ -22,7 +22,8 @@ Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "n ## Get it -Published to **Google Play (Internal Testing)** — join the beta via the +Published to **Google Play (Open Testing)** — join via the +[public opt-in link](https://play.google.com/apps/testing/io.unom.punktfunk) or the [Discord](https://discord.gg/kaPNvzMuGU). Per-device setup and pairing: **[docs.punktfunk.unom.io/docs/install-client](https://docs.punktfunk.unom.io/docs/install-client)**. diff --git a/docs/releases/README.md b/docs/releases/README.md index 4004d86f..4f7878cc 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -54,8 +54,8 @@ no build, no assets on the Gitea release, nothing on Play. Two more checks sit d `play-upload.py` refuses text over the 500-char cap (printing the real count) before it uploads, because the API only rejects oversized notes at commit, by which point the AAB is already on Play. -Canary is exempt: it has no curated notes, and Play reusing text for internal testers costs -nothing. +Canary is exempt: it has no curated notes; open-testing users see the previous release's text on +a canary, which is cosmetic and cheaper than gating every main push on a notes file. Same freeze rule as the notes: once the tag exists, this file is the record of what that versionCode shipped. -- 2.54.0 From a4210024dc817d81b75a502e78377c65c5117bbd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 21:45:34 +0200 Subject: [PATCH 2/2] ci(android): canary also feeds Play closed testing (alpha) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed testing went dry on 2026-08-01 when tags started publishing straight to production instead of alpha — its testers have been pinned to the last pre-access build since. Canaries now assign the same versionCode to beta (open) AND alpha (closed) via play-upload.py's new repeatable --also-track flag: both PUTs share one Play edit, so one commit and one review cover both tracks and they can never disagree about which canary is current. Tags still go to production only. --- .gitea/workflows/android.yml | 22 +++++++++++++--------- clients/android/ci/play-upload.py | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index c1a1596b..ea1f7726 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -38,9 +38,11 @@ on: # 100% + attaches the .aab/.apk to the unified Gitea Release). A main push is canary # (Play `beta` = open testing: public opt-in, no tester list — but unlike the previous # `internal` target, every canary now passes Google review before testers see it, so a - # canary lands in hours/days, not minutes). Production access was granted 2026-08-01; - # before that a tag could only reach `alpha` and someone had to promote it by hand in - # the Console. + # canary lands in hours/days, not minutes). The same canary versionCode is also assigned + # to `alpha` (closed testing) in the same Play edit, so the pre-production-access closed + # testers keep receiving builds without re-opting-in. Production access was granted + # 2026-08-01; before that a tag could only reach `alpha` and someone had to promote it + # by hand in the Console. tags: ['v*'] pull_request: paths: @@ -230,11 +232,12 @@ jobs: run: | eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of the latest stable tag) case "$GITHUB_REF" in - refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production" ;; - *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta" ;; + refs/tags/v*) VN="${GITHUB_REF_NAME#v}"; TRACK="production"; ALSO="" ;; + *) VN="${PF_BASE}-ci${GITHUB_RUN_NUMBER}"; TRACK="beta"; ALSO="alpha" ;; esac echo "VERSION_NAME=$VN" >> "$GITHUB_ENV" echo "PLAY_TRACK=$TRACK" >> "$GITHUB_ENV" + echo "PLAY_ALSO_TRACK=$ALSO" >> "$GITHUB_ENV" # Play's own "What's new" (500-char cap, its own file — the vX.Y.Z.md body is ~34 KB). # On a tag the gate step above already proved this exists, so the else branch is only # ever the canary path. See docs/releases/README.md. @@ -244,7 +247,7 @@ jobs: else echo "no Play release notes at $NOTES (canary — Play keeps the previous text)" fi - echo "android version $VN -> Play track '$TRACK'" + echo "android version $VN -> Play track '$TRACK'${ALSO:+ (+ '$ALSO')}" - name: Build Release (signed AAB + universal APK) if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) @@ -316,8 +319,8 @@ jobs: # 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. - # Track: canary main -> `beta` (open testing); a vX.Y.Z release -> `production` at 100% - # (`completed`). + # Track: canary main -> `beta` (open testing) + the same versionCode on `alpha` (closed + # testing) in the same Play edit; a vX.Y.Z release -> `production` at 100% (`completed`). # # A tag therefore ships to real users with no further click. Two things keep that honest: # the tag is only pushed once every platform is green, and Play reviews each production @@ -329,9 +332,10 @@ jobs: env: SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} run: | - echo "uploading to Play track '$PLAY_TRACK'" + echo "uploading to Play track '$PLAY_TRACK'${PLAY_ALSO_TRACK:+ (+ '$PLAY_ALSO_TRACK')}" set -- --package io.unom.punktfunk \ --aab clients/android/app/build/outputs/bundle/release/app-release.aab \ --track "$PLAY_TRACK" --status completed + if [ -n "${PLAY_ALSO_TRACK:-}" ]; then set -- "$@" --also-track "$PLAY_ALSO_TRACK"; fi if [ -n "${PLAY_NOTES:-}" ]; then set -- "$@" --release-notes-file "$PLAY_NOTES"; fi python3 clients/android/ci/play-upload.py "$@" diff --git a/clients/android/ci/play-upload.py b/clients/android/ci/play-upload.py index b557707d..777f3e20 100644 --- a/clients/android/ci/play-upload.py +++ b/clients/android/ci/play-upload.py @@ -9,7 +9,8 @@ tolerates it being raw JSON *or* base64-encoded JSON. Usage (upload a new build): SERVICE_ACCOUNT_JSON='' \ python3 play-upload.py --package io.unom.punktfunk \ - --aab path/to/app-release.aab --track internal --status completed [--no-commit] + --aab path/to/app-release.aab --track beta --also-track alpha \ + --status completed [--no-commit] Usage (promote a build that is already on Play, no rebuild): python3 play-upload.py --package io.unom.punktfunk \ @@ -164,6 +165,9 @@ def main(): ap.add_argument("--promote-from", metavar="TRACK", help="with --promote: assert the code is on TRACK, then clear TRACK") ap.add_argument("--track", default="internal") + ap.add_argument("--also-track", action="append", default=[], metavar="TRACK", + help="assign the same versionCode to this track too, in the same edit " + "(repeatable). Canary uses it to feed open + closed testing at once.") ap.add_argument("--status", default="completed") ap.add_argument("--user-fraction", type=float, help="staged rollout fraction, 0` would assign and clear the same track in one edit; + # whichever PUT lands second silently wins. Refuse the ambiguity instead. + if t in (a.track, a.promote_from): + sys.exit(f"ERROR: --also-track {t} duplicates --track/--promote-from") notes = load_release_notes(a.release_notes_file, a.release_notes_language) \ if a.release_notes_file else None @@ -209,6 +218,11 @@ def main(): put_track(app, edit, tok, a.track, [vc], a.status, a.user_fraction, notes) print(f"assigned versionCode={vc} -> track={a.track} status={a.status}" + (f" userFraction={a.user_fraction}" if a.user_fraction is not None else "")) + # Same edit, so one commit (and one Play review) covers every track the code lands on — + # the tracks can never disagree about which canary is current. + for t in a.also_track: + put_track(app, edit, tok, t, [vc], a.status, a.user_fraction, notes) + print(f"assigned versionCode={vc} -> track={t} status={a.status}") # Same edit as the assignment above, so the code is never active on both tracks at once. if a.promote_from: put_track(app, edit, tok, a.promote_from, [], a.status) -- 2.54.0