mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-13 05:37:28 -04:00
ci(release): prevent Play submission churn and review-clock resets (#6517)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
094cbadbab
commit
dc9f8b9514
3 files changed
+143
-2
No files matched your search
@@ -20,6 +20,11 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
no_review_in_flight:
|
||||
description: 'Promotions only: I checked Publishing overview > Submission activity and no submission is In review. Every promotion creates a new Play submission, which CANCELS and RESTARTS any review in flight.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -44,6 +49,15 @@ jobs:
|
||||
final_tag: ${{ steps.calculate_tags.outputs.final_tag }}
|
||||
from_channel: ${{ steps.calculate_tags.outputs.from_channel }}
|
||||
steps:
|
||||
# Internal releases are exempt: Play internal testing skips full review,
|
||||
# so only promotions (closed/open/production) can clobber an in-flight
|
||||
# review. Dry runs never reach Play.
|
||||
- name: Require review-in-flight confirmation for promotions
|
||||
if: ${{ !inputs.dry_run && inputs.channel != 'internal' && !inputs.no_review_in_flight }}
|
||||
run: |
|
||||
echo "::error::Promotion blocked: confirm no Play review is in flight. Check Play Console > Publishing overview > Submission activity — if a submission shows 'In review', WAIT (a new promotion cancels it and restarts the clock). If clear, re-dispatch with 'no_review_in_flight' checked."
|
||||
exit 1
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
|
||||
@@ -55,9 +55,13 @@ on:
|
||||
HOMEBREW_TAP_TOKEN:
|
||||
required: false
|
||||
|
||||
# Never cancel a promotion mid-flight: being killed between the Play edit
|
||||
# commit and the GitHub release/tag update leaves the two disagreeing. The
|
||||
# caller's queue-only concurrency serializes dispatches; this group (keyed by
|
||||
# channel + tag) is a second line of defense that queues rather than cancels.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.tag_name }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ inputs.channel }}-${{ inputs.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -128,7 +132,42 @@ jobs:
|
||||
- name: Decode Play Store credentials
|
||||
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
|
||||
|
||||
# A re-dispatched promotion whose versionCode is already live on the
|
||||
# target track must no-op: every redundant `supply` commit creates a new
|
||||
# Play submission, and each submission cancels + restarts any review in
|
||||
# flight (this reset the v2.8.0 review clock repeatedly, Jul 2026).
|
||||
# Fail-open — a preflight error proceeds to supply, which uses the same
|
||||
# credentials and will surface any real failure.
|
||||
# The main checkout above is at the release tag, which predates this
|
||||
# script — fetch it from the caller's commit instead, so script and
|
||||
# workflow always move in lockstep. This is a local reusable-workflow
|
||||
# call (`uses: ./.github/workflows/promote.yml`), so promote.yml is
|
||||
# loaded from that same commit.
|
||||
#
|
||||
# Must NOT be a release input: any tag cut before this landed has no
|
||||
# scripts/ entry, and a missing script exits 127 at the step level,
|
||||
# bypassing the script's own fail-open path and hard-failing the
|
||||
# promotion. workflow_sha is no better — the github context in a called
|
||||
# reusable workflow is caller-associated, so it resolves to the caller's
|
||||
# workflow too. A cross-repo caller would need explicit repo+ref inputs.
|
||||
- name: Checkout preflight script from caller commit
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: .workflow-ref
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Preflight — is this versionCode already on the target track?
|
||||
id: preflight
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
|
||||
run: |
|
||||
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
|
||||
bash .workflow-ref/scripts/play-track-preflight.sh \
|
||||
fastlane/play-store-credentials.json "$PKG" "$TO_TRACK" "$VERSION_CODE"
|
||||
|
||||
- name: Promote to next channel
|
||||
if: ${{ steps.preflight.outputs.already_on_track != 'true' }}
|
||||
run: |
|
||||
bundle exec fastlane supply \
|
||||
--track "$FROM_TRACK" \
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Preflight for promote.yml: check whether VERSION_CODE is already live on the
|
||||
# target Play track, so a re-dispatched promotion can no-op instead of creating
|
||||
# a redundant Play submission (each submission cancels and restarts any review
|
||||
# already in flight — see the Jul 2026 v2.8.0 submission-churn incident).
|
||||
#
|
||||
# Usage: play-track-preflight.sh <service-account.json> <package> <track> <version_code>
|
||||
# Writes already_on_track=true|false to $GITHUB_OUTPUT (or stdout when unset).
|
||||
#
|
||||
# Fail-open by design: on any API/auth error it reports already_on_track=false
|
||||
# and exits 0, so a preflight hiccup never blocks a legitimate promotion —
|
||||
# `fastlane supply` uses the same credentials and will surface real failures.
|
||||
|
||||
set -u
|
||||
|
||||
KEY="${1:?usage: play-track-preflight.sh <service-account.json> <package> <track> <version_code>}"
|
||||
PKG="${2:?missing package}"
|
||||
TRACK="${3:?missing track}"
|
||||
VERSION_CODE="${4:?missing version_code}"
|
||||
SCOPE="https://www.googleapis.com/auth/androidpublisher"
|
||||
OUT="${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
|
||||
emit() {
|
||||
echo "already_on_track=$1" >> "$OUT"
|
||||
exit 0
|
||||
}
|
||||
|
||||
fail_open() {
|
||||
echo "::warning::Play preflight failed ($1) — proceeding with promotion (fail-open)."
|
||||
emit false
|
||||
}
|
||||
|
||||
TMP="$(mktemp -d)" || fail_open "mktemp"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
|
||||
|
||||
CLIENT_EMAIL=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["client_email"])' "$KEY" 2>/dev/null) \
|
||||
|| fail_open "unreadable service-account key"
|
||||
python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["private_key"])' "$KEY" > "$TMP/key.pem" 2>/dev/null \
|
||||
|| fail_open "key missing private_key"
|
||||
|
||||
NOW=$(date +%s)
|
||||
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
|
||||
CLAIM=$(printf '{"iss":"%s","scope":"%s","aud":"https://oauth2.googleapis.com/token","iat":%s,"exp":%s}' \
|
||||
"$CLIENT_EMAIL" "$SCOPE" "$NOW" "$((NOW + 600))" | b64url)
|
||||
SIG=$(printf '%s.%s' "$HEADER" "$CLAIM" | openssl dgst -sha256 -sign "$TMP/key.pem" 2>/dev/null | b64url) \
|
||||
|| fail_open "JWT signing"
|
||||
|
||||
TOKEN=$(curl -sf -X POST https://oauth2.googleapis.com/token \
|
||||
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
|
||||
--data-urlencode "assertion=${HEADER}.${CLAIM}.${SIG}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null)
|
||||
[ -n "$TOKEN" ] || fail_open "token exchange"
|
||||
|
||||
API="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$PKG"
|
||||
EDIT=$(curl -sf -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Length: 0' "$API/edits" 2>/dev/null \
|
||||
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
[ -n "$EDIT" ] || fail_open "edit insert"
|
||||
|
||||
# Read-only: GET the track, then discard the edit without committing.
|
||||
TRACK_JSON=$(curl -sf -H "Authorization: Bearer $TOKEN" \
|
||||
"$API/edits/$EDIT/tracks/$TRACK" 2>/dev/null)
|
||||
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" "$API/edits/$EDIT" >/dev/null 2>&1
|
||||
|
||||
[ -n "$TRACK_JSON" ] || fail_open "track read"
|
||||
|
||||
RESULT=$(printf '%s' "$TRACK_JSON" | python3 -c '
|
||||
import json, sys
|
||||
target = int(sys.argv[1])
|
||||
track = json.load(sys.stdin)
|
||||
for r in track.get("releases", []):
|
||||
# Only live release states count — a draft or halted release still needs
|
||||
# the promotion to run.
|
||||
if r.get("status") in ("completed", "inProgress") \
|
||||
and target in [int(c) for c in r.get("versionCodes", [])]:
|
||||
print("true")
|
||||
break
|
||||
else:
|
||||
print("false")
|
||||
' "$VERSION_CODE" 2>/dev/null) || fail_open "track parse"
|
||||
|
||||
if [ "$RESULT" = "true" ]; then
|
||||
echo "versionCode $VERSION_CODE is already live on track '$TRACK' — skipping promotion (no new Play submission)."
|
||||
else
|
||||
echo "versionCode $VERSION_CODE not live on track '$TRACK' — promotion will proceed."
|
||||
fi
|
||||
emit "$RESULT"
|
||||
Reference in new issue
Block a user