diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e52a909e..99c69765 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -233,14 +233,18 @@ jobs: name: Test runs-on: ubuntu-24.04 needs: [build-rust] - env: - SUPPLY_TRACK: production # used by fastlane to determine track to publish to steps: - uses: actions/checkout@v4 with: submodules: 'recursive' + # Cheap, no SDK: keeps the Play-track contract from regressing on every PR. + # The leftover job-level `SUPPLY_TRACK: production` here never reached + # fastlane (release-fastlane sets its own) and was a copy-paste footgun. + - name: Assert Play-track resolver + run: bash scripts/assert-play-track.sh --self-test + - name: Set up JDK uses: actions/setup-java@v4 with: @@ -412,15 +416,18 @@ jobs: # Set SUPPLY_TRACK based on whether this is a stable (x.y.z) or pre-release tag. # NOTE: nowsprinting/check-version-format-action classifies 0.x.x as not stable # (semver convention), but aw-android uses 0.x.x for production releases. - # Check for pre-release suffixes directly instead. + # Check for pre-release suffixes directly instead (scripts/assert-play-track.sh). TAG="${{ github.ref_name }}" - VERSION="${TAG#v}" - if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - SUPPLY_TRACK="production" - else - SUPPLY_TRACK="internal" - fi - echo "SUPPLY_TRACK=${SUPPLY_TRACK}" >> $GITHUB_ENV + SUPPLY_TRACK="$(bash scripts/assert-play-track.sh resolve "$TAG")" + echo "SUPPLY_TRACK=${SUPPLY_TRACK}" >> "$GITHUB_ENV" + { + echo "## Play Store publish" + echo "- tag: \`${TAG}\`" + echo "- SUPPLY_TRACK: \`${SUPPLY_TRACK}\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Assert Play track matches tag + run: bash scripts/assert-play-track.sh assert "${{ github.ref_name }}" "${SUPPLY_TRACK}" - uses: adnsio/setup-age-action@v1.2.0 - name: Load Android secrets @@ -438,8 +445,12 @@ jobs: # Newer fastlane versions fail hard if rubygems.org isn't in the system gem sources list. # This is idempotent (exits 0 if already present). gem sources --add https://rubygems.org + # Re-check at the publish boundary, then pass the asserted track on + # argv. supply's default track is production; SUPPLY_TRACK via env is + # the historical path, but an explicit --track is the fail-closed one. + bash scripts/assert-play-track.sh assert "${{ github.ref_name }}" "${SUPPLY_TRACK}" # bundle exec fastlane supply run --apk dist/*.apk - bundle exec fastlane supply run --aab dist/*.aab + bundle exec fastlane supply run --aab dist/*.aab --track "$SUPPLY_TRACK" release-gh: needs: [build-apk, test] diff --git a/scripts/assert-play-track.sh b/scripts/assert-play-track.sh new file mode 100755 index 00000000..24d7de00 --- /dev/null +++ b/scripts/assert-play-track.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Resolve or assert the Play Store supply track for an aw-android release tag. +# +# Contract: +# X.Y.Z (no suffix) → production +# anything else → internal (0.14.0b2, 0.14.0devYYYYMMDD, 0.14.0-rc1, …) +# +# Fail closed: a pre-release tag must never publish to production, even if +# SUPPLY_TRACK is later hardcoded or the resolver regresses. Stable tags may +# still be sent to internal (staged rollout); that is not this guard. +# +# Usage: +# scripts/assert-play-track.sh resolve +# scripts/assert-play-track.sh assert +# scripts/assert-play-track.sh --self-test + +set -euo pipefail + +usage() { + cat <<'EOF' >&2 +Usage: + scripts/assert-play-track.sh resolve + scripts/assert-play-track.sh assert + scripts/assert-play-track.sh --self-test +EOF + exit 2 +} + +is_stable_version() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +tag_to_version() { + local tag="$1" + if [[ -z "$tag" ]]; then + echo "error: empty tag" >&2 + return 1 + fi + echo "${tag#v}" +} + +resolve_track() { + local version + version="$(tag_to_version "$1")" + if is_stable_version "$version"; then + echo production + else + echo internal + fi +} + +assert_track() { + local tag="$1" + local track="$2" + local version + version="$(tag_to_version "$tag")" + + if [[ -z "$track" ]]; then + echo "error: SUPPLY_TRACK is empty; refusing to publish ${tag}" >&2 + return 1 + fi + if [[ "$track" != production && "$track" != internal ]]; then + echo "error: unknown SUPPLY_TRACK=${track} for ${tag}" >&2 + return 1 + fi + if [[ "$track" == production ]] && ! is_stable_version "$version"; then + echo "error: refusing to publish pre-release tag ${tag} to production (SUPPLY_TRACK=${track})" >&2 + return 1 + fi + echo "ok: tag=${tag} version=${version} SUPPLY_TRACK=${track}" +} + +self_test() { + local fail=0 + expect_resolve() { + local tag="$1" want="$2" got + got="$(resolve_track "$tag")" + if [[ "$got" != "$want" ]]; then + echo "FAIL resolve ${tag}: got ${got} want ${want}" >&2 + fail=1 + fi + } + expect_assert_ok() { + if ! assert_track "$1" "$2" >/dev/null; then + echo "FAIL assert should pass: tag=$1 track=$2" >&2 + fail=1 + fi + } + expect_assert_fail() { + if assert_track "$1" "$2" >/dev/null 2>&1; then + echo "FAIL assert should fail: tag=$1 track=$2" >&2 + fail=1 + fi + } + + expect_resolve v0.14.0 production + expect_resolve 0.14.0 production + expect_resolve v1.0.0 production + expect_resolve v0.14.0b2 internal + expect_resolve v0.14.0beta2 internal + expect_resolve v0.14.0dev20260723 internal + expect_resolve v0.14.0-rc1 internal + expect_resolve v0.14.0rc1 internal + expect_resolve v0.14 internal + + expect_assert_ok v0.14.0 production + expect_assert_ok v0.14.0 internal + expect_assert_ok v0.14.0b2 internal + expect_assert_fail v0.14.0b2 production + expect_assert_fail v0.14.0dev20260723 production + expect_assert_fail v0.14.0beta2 production + expect_assert_fail v0.14.0 "" + expect_assert_fail v0.14.0 alpha + expect_assert_fail "" production + + if [[ "$fail" -ne 0 ]]; then + echo "assert-play-track self-test FAILED" >&2 + return 1 + fi + echo "assert-play-track self-test passed" +} + +cmd="${1:-}" +case "$cmd" in + resolve) + [[ $# -eq 2 ]] || usage + resolve_track "$2" + ;; + assert) + [[ $# -eq 3 ]] || usage + assert_track "$2" "$3" + ;; + --self-test) + self_test + ;; + *) + usage + ;; +esac