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:
James RichandClaude Sonnet 5 authored and GitHub committed 2026-07-29 16:34:07 -05:00
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:
+41 -2
View File
@@ -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" \
+88
View File
@@ -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"