From dc9f8b9514f5c4cd464eb69ff8bb2ac0b536e97d Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:34:07 -0500 Subject: [PATCH] ci(release): prevent Play submission churn and review-clock resets (#6517) Co-authored-by: Claude Sonnet 5 --- .../workflows/create-or-promote-release.yml | 14 +++ .github/workflows/promote.yml | 43 ++++++++- scripts/play-track-preflight.sh | 88 +++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 scripts/play-track-preflight.sh diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml index 777f28c7e4..45a8129be7 100644 --- a/.github/workflows/create-or-promote-release.yml +++ b/.github/workflows/create-or-promote-release.yml @@ -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: diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index ddd2614ebc..954f3ffb8d 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -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" \ diff --git a/scripts/play-track-preflight.sh b/scripts/play-track-preflight.sh new file mode 100644 index 0000000000..c393250f79 --- /dev/null +++ b/scripts/play-track-preflight.sh @@ -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 +# 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 }" +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"