From 56d3638eaee4b91f648f06ae4abad921a4fbbbb2 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:05:13 -0400 Subject: [PATCH 01/14] fix App Store submit Fastlane options --- .github/workflows/submit-app-store.yml | 28 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 4e210276..ed7b6580 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -157,15 +157,25 @@ jobs: - name: Submit build for App Store review working-directory: ${{ vars.IOS_WORKDIR || 'ios' }} run: | - bundle exec fastlane deliver submit_build \ - --api_key_path /tmp/asc_api_key.json \ - --app_identifier ${{ vars.BUNDLE_ID }} \ - --build_number "${{ needs.find-build.outputs.build_number }}" \ - --run_precheck_before_submit false \ - --precheck_include_in_app_purchases false \ - --force true \ - --phased_release true \ - --automatic_release false + tmp_fastlane_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_fastlane_dir"' EXIT + + bundle exec fastlane run deliver \ + api_key_path:/tmp/asc_api_key.json \ + app_identifier:${{ vars.BUNDLE_ID }} \ + skip_binary_upload:true \ + skip_screenshots:true \ + skip_metadata:true \ + metadata_path:"$tmp_fastlane_dir/metadata" \ + screenshots_path:"$tmp_fastlane_dir/screenshots" \ + app_previews_path:"$tmp_fastlane_dir/app-previews" \ + submit_for_review:true \ + run_precheck_before_submit:false \ + precheck_include_in_app_purchases:false \ + force:true \ + phased_release:true \ + automatic_release:false \ + build_number:"${{ needs.find-build.outputs.build_number }}" - name: Summary run: | From 20e186525496dd25b5a173e6554e805805d31921 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:10:59 -0400 Subject: [PATCH 02/14] hide Deliverfile during App Store submit --- .github/workflows/submit-app-store.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index ed7b6580..e50e9553 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -157,18 +157,18 @@ jobs: - name: Submit build for App Store review working-directory: ${{ vars.IOS_WORKDIR || 'ios' }} run: | - tmp_fastlane_dir="$(mktemp -d)" - trap 'rm -rf "$tmp_fastlane_dir"' EXIT + if [ -f fastlane/Deliverfile ]; then + mv -f fastlane/Deliverfile fastlane/Deliverfile.release + trap 'mv -f fastlane/Deliverfile.release fastlane/Deliverfile' EXIT + fi bundle exec fastlane run deliver \ api_key_path:/tmp/asc_api_key.json \ app_identifier:${{ vars.BUNDLE_ID }} \ + team_id:${{ vars.TEAM_ID }} \ skip_binary_upload:true \ skip_screenshots:true \ skip_metadata:true \ - metadata_path:"$tmp_fastlane_dir/metadata" \ - screenshots_path:"$tmp_fastlane_dir/screenshots" \ - app_previews_path:"$tmp_fastlane_dir/app-previews" \ submit_for_review:true \ run_precheck_before_submit:false \ precheck_include_in_app_purchases:false \ From e634fd41b3ed3195fec743479d97a509aea363ed Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:12:26 -0400 Subject: [PATCH 03/14] use GitHub secrets for App Store credentials --- .github/workflows/submit-app-store.yml | 79 +++++++++++++++++--------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index e50e9553..7f761cf2 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -15,7 +15,6 @@ on: permissions: contents: read - id-token: write concurrency: group: app-store-review-submission @@ -49,20 +48,39 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Clear runner AWS profile overrides + - name: Write App Store Connect credentials + shell: bash + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} run: | - echo "AWS_PROFILE=" >> "$GITHUB_ENV" - echo "AWS_DEFAULT_PROFILE=" >> "$GITHUB_ENV" - - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_RELEASE_ROLE_ARN }} - aws-region: us-east-1 - unset-current-credentials: true + set -euo pipefail + : "${ASC_KEY_ID:?ASC_KEY_ID secret is required}" + : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" + : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" + + mkdir -p "$HOME/.appstoreconnect/private_keys" + p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path" + chmod 600 "$p8_path" + + jq -n \ + --arg key_id "$ASC_KEY_ID" \ + --arg issuer_id "$ASC_ISSUER_ID" \ + --arg key "$ASC_PRIVATE_KEY" \ + '{ + key_id: $key_id, + issuer_id: $issuer_id, + key: $key, + in_house: false + }' > /tmp/asc_api_key.json + chmod 600 /tmp/asc_api_key.json - - name: Fetch App Store Connect credentials - run: scripts/runner/fetch_asc_secret.sh + { + echo "ASC_KEY_ID=$ASC_KEY_ID" + echo "ASC_ISSUER_ID=$ASC_ISSUER_ID" + } >> "$GITHUB_ENV" - name: Install Python dependencies run: | @@ -122,20 +140,29 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Clear runner AWS profile overrides + - name: Write App Store Connect credentials + shell: bash + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} run: | - echo "AWS_PROFILE=" >> "$GITHUB_ENV" - echo "AWS_DEFAULT_PROFILE=" >> "$GITHUB_ENV" - - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_RELEASE_ROLE_ARN }} - aws-region: us-east-1 - unset-current-credentials: true - - - name: Fetch App Store Connect credentials - run: scripts/runner/fetch_asc_secret.sh + set -euo pipefail + : "${ASC_KEY_ID:?ASC_KEY_ID secret is required}" + : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" + : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" + + jq -n \ + --arg key_id "$ASC_KEY_ID" \ + --arg issuer_id "$ASC_ISSUER_ID" \ + --arg key "$ASC_PRIVATE_KEY" \ + '{ + key_id: $key_id, + issuer_id: $issuer_id, + key: $key, + in_house: false + }' > /tmp/asc_api_key.json + chmod 600 /tmp/asc_api_key.json - name: Set up Ruby and Bundler if: ${{ !contains(fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]'), 'self-hosted') }} From 96851169a457ccdad42dba1a3aaea27a68d6db5d Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:36:12 -0400 Subject: [PATCH 04/14] resolve App Store submit build from TestFlight tags --- .github/workflows/submit-app-store.yml | 74 +++++++++++++ .github/workflows/testflight-build.yml | 36 ++++++- scripts/release/ensure_app_store_version.py | 112 ++++++++++++++++++++ scripts/release/find_qualifying_build.py | 2 + 4 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 scripts/release/ensure_app_store_version.py diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 7f761cf2..d0f32cfb 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -28,13 +28,18 @@ jobs: name: Find matching TestFlight build runs-on: ${{ fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]') }} outputs: + build_id: ${{ steps.find.outputs.build_id }} build_number: ${{ steps.find.outputs.build_number }} build_version: ${{ steps.find.outputs.build_version }} upload_time: ${{ steps.find.outputs.upload_time }} tag: ${{ steps.resolve-tag.outputs.tag }} version: ${{ steps.resolve-tag.outputs.version }} + source_sha: ${{ steps.resolve-source.outputs.source_sha }} + build_tag: ${{ steps.resolve-build-tag.outputs.build_tag }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Resolve tag and version id: resolve-tag @@ -48,6 +53,40 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Resolve release source commit + id: resolve-source + shell: bash + env: + TAG: ${{ steps.resolve-tag.outputs.tag }} + run: | + set -euo pipefail + source_sha="$(git rev-list -n 1 "$TAG")" + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + + - name: Resolve TestFlight build tag + id: resolve-build-tag + shell: bash + env: + SOURCE_SHA: ${{ steps.resolve-source.outputs.source_sha }} + run: | + set -euo pipefail + mapfile -t build_tags < <( + git tag --points-at "$SOURCE_SHA" 'testflight/build-*' \ + | sed -E 's#^testflight/build-([0-9]+)$#\1 & #' \ + | sort -n \ + | awk '{print $2}' + ) + + if [ "${#build_tags[@]}" -eq 0 ]; then + echo "::error::No testflight/build-* tag points at release source ${SOURCE_SHA}." + exit 1 + fi + + build_tag="${build_tags[-1]}" + build_number="${build_tag#testflight/build-}" + echo "build_tag=$build_tag" >> "$GITHUB_OUTPUT" + echo "build_number=$build_number" >> "$GITHUB_OUTPUT" + - name: Write App Store Connect credentials shell: bash env: @@ -93,6 +132,7 @@ jobs: shell: bash env: VERSION: ${{ steps.resolve-tag.outputs.version }} + BUILD_NUMBER: ${{ steps.resolve-build-tag.outputs.build_number }} run: | set +e .venv-release/bin/python scripts/release/find_qualifying_build.py \ @@ -102,6 +142,7 @@ jobs: --app-id "${{ vars.APPLE_APP_ID }}" \ --latest \ --version "$VERSION" \ + --override "$BUILD_NUMBER" \ --output-format github-output 2> /tmp/find-build.err status=$? set -e @@ -126,6 +167,8 @@ jobs: echo "- Version: ${{ needs.find-build.outputs.build_version }}" echo "- Build: #${{ needs.find-build.outputs.build_number }}" echo "- Tag: ${{ needs.find-build.outputs.tag }}" + echo "- Source commit: ${{ needs.find-build.outputs.source_sha }}" + echo "- Build tag: ${{ needs.find-build.outputs.build_tag }}" echo "- Uploaded: ${{ needs.find-build.outputs.upload_time }}" echo "- Dry run: ${{ inputs.dry_run }}" } >> "$GITHUB_STEP_SUMMARY" @@ -152,6 +195,11 @@ jobs: : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" + mkdir -p "$HOME/.appstoreconnect/private_keys" + p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path" + chmod 600 "$p8_path" + jq -n \ --arg key_id "$ASC_KEY_ID" \ --arg issuer_id "$ASC_ISSUER_ID" \ @@ -164,6 +212,11 @@ jobs: }' > /tmp/asc_api_key.json chmod 600 /tmp/asc_api_key.json + { + echo "ASC_KEY_ID=$ASC_KEY_ID" + echo "ASC_ISSUER_ID=$ASC_ISSUER_ID" + } >> "$GITHUB_ENV" + - name: Set up Ruby and Bundler if: ${{ !contains(fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]'), 'self-hosted') }} uses: ruby/setup-ruby@v1 @@ -181,6 +234,26 @@ jobs: bundle config set path vendor/bundle bundle install --jobs 4 --retry 3 + - name: Install Python dependencies + run: | + python3 -m venv .venv-release + .venv-release/bin/python -m pip install --upgrade pip --quiet + .venv-release/bin/python -m pip install PyJWT requests cryptography --quiet + + - name: Ensure App Store version and attach build + shell: bash + env: + VERSION: ${{ needs.find-build.outputs.version }} + BUILD_ID: ${{ needs.find-build.outputs.build_id }} + run: | + .venv-release/bin/python scripts/release/ensure_app_store_version.py \ + --key-id "$ASC_KEY_ID" \ + --issuer-id "$ASC_ISSUER_ID" \ + --private-key-path "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ + --app-id "${{ vars.APPLE_APP_ID }}" \ + --version "$VERSION" \ + --build-id "$BUILD_ID" + - name: Submit build for App Store review working-directory: ${{ vars.IOS_WORKDIR || 'ios' }} run: | @@ -193,6 +266,7 @@ jobs: api_key_path:/tmp/asc_api_key.json \ app_identifier:${{ vars.BUNDLE_ID }} \ team_id:${{ vars.TEAM_ID }} \ + app_version:"${{ needs.find-build.outputs.version }}" \ skip_binary_upload:true \ skip_screenshots:true \ skip_metadata:true \ diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index 8252beba..3b2acd2f 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -20,12 +20,12 @@ on: type: string permissions: - contents: read + contents: write id-token: write concurrency: - group: testflight-build - cancel-in-progress: false + group: ${{ github.event_name == 'push' && 'testflight-main-latest' || format('testflight-manual-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'push' }} env: FASTLANE_SKIP_UPDATE_CHECK: "1" @@ -158,6 +158,36 @@ jobs: S3_BUCKET_PREFIX: s3://riddimsoftware-factory-transcripts/release-manifests run: bash .workflow-source/scripts/ci/emit_release_manifest.sh + - name: Tag TestFlight build provenance + if: ${{ !inputs.dry_run }} + shell: bash + env: + BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }} + run: | + set -euo pipefail + tag="testflight/build-${BUILD_NUMBER}" + + if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then + existing_sha="$(git rev-list -n 1 "${tag}")" + if [ "$existing_sha" != "$SOURCE_SHA" ]; then + echo "::error::${tag} already points at ${existing_sha}, not ${SOURCE_SHA}." + exit 1 + fi + echo "${tag} already points at ${SOURCE_SHA}." + exit 0 + fi + + git config user.name "riddim-developer-bot" + git config user.email "developer-bot@riddimsoftware.com" + git tag -a "${tag}" "${SOURCE_SHA}" \ + -m "TestFlight build ${BUILD_NUMBER}" \ + -m "version=${VERSION}" \ + -m "workflow_run_id=${GITHUB_RUN_ID}" \ + -m "source_sha=${SOURCE_SHA}" + git push origin "refs/tags/${tag}" + - name: Delete ephemeral keychain if: ${{ always() && !inputs.dry_run }} run: scripts/runner/ephemeral_keychain.sh cleanup diff --git a/scripts/release/ensure_app_store_version.py b/scripts/release/ensure_app_store_version.py new file mode 100644 index 00000000..5bda3179 --- /dev/null +++ b/scripts/release/ensure_app_store_version.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Ensure an App Store version train exists and attach a TestFlight build.""" + +import argparse +import os +import time + +import jwt +import requests + + +BASE_URL = "https://api.appstoreconnect.apple.com/v1" + + +def get_asc_token(key_id: str, issuer_id: str, private_key_path: str) -> str: + with open(os.path.expanduser(private_key_path)) as f: + private_key = f.read() + now = int(time.time()) + payload = { + "iss": issuer_id, + "iat": now, + "exp": now + 1200, + "aud": "appstoreconnect-v1", + } + return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id}) + + +def request(method: str, path: str, token: str, **kwargs) -> requests.Response: + headers = kwargs.pop("headers", {}) + headers["Authorization"] = f"Bearer {token}" + if method != "GET": + headers["Content-Type"] = "application/json" + + resp = requests.request(method, f"{BASE_URL}{path}", headers=headers, timeout=30, **kwargs) + if not resp.ok: + raise SystemExit(f"{method} {path} failed ({resp.status_code}): {resp.text}") + return resp + + +def find_app_store_version(app_id: str, version: str, token: str) -> dict | None: + params = { + "filter[app]": app_id, + "filter[platform]": "IOS", + "limit": 50, + "fields[appStoreVersions]": "versionString,platform,appStoreState", + } + resp = request("GET", "/appStoreVersions", token, params=params) + for item in resp.json().get("data", []): + attrs = item.get("attributes", {}) + if attrs.get("versionString") == version and attrs.get("platform") == "IOS": + return item + return None + + +def create_app_store_version(app_id: str, version: str, token: str) -> dict: + payload = { + "data": { + "type": "appStoreVersions", + "attributes": { + "platform": "IOS", + "versionString": version, + }, + "relationships": { + "app": { + "data": { + "type": "apps", + "id": app_id, + } + } + }, + } + } + return request("POST", "/appStoreVersions", token, json=payload).json()["data"] + + +def attach_build(version_id: str, build_id: str, token: str) -> None: + payload = { + "data": { + "type": "builds", + "id": build_id, + } + } + request("PATCH", f"/appStoreVersions/{version_id}/relationships/build", token, json=payload) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--key-id", required=True) + parser.add_argument("--issuer-id", required=True) + parser.add_argument("--private-key-path", required=True) + parser.add_argument("--app-id", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--build-id", required=True) + args = parser.parse_args() + + token = get_asc_token(args.key_id, args.issuer_id, args.private_key_path) + version = find_app_store_version(args.app_id, args.version, token) + if version: + print( + "Found App Store version " + f"{args.version} ({version['id']}, state={version['attributes'].get('appStoreState')})" + ) + else: + version = create_app_store_version(args.app_id, args.version, token) + print(f"Created App Store version {args.version} ({version['id']})") + + attach_build(version["id"], args.build_id, token) + print(f"Attached build {args.build_id} to App Store version {args.version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/release/find_qualifying_build.py b/scripts/release/find_qualifying_build.py index 3e0f074d..1e4f7731 100644 --- a/scripts/release/find_qualifying_build.py +++ b/scripts/release/find_qualifying_build.py @@ -85,6 +85,7 @@ def find_qualifying_build(app_id: str, cutoff_dt: datetime | None, token: str, o continue return { + "build_id": build["id"], "build_number": build_number, "build_version": build_version, "upload_time": uploaded_raw, @@ -127,6 +128,7 @@ def main() -> None: if args.output_format == "github-output": output_file = os.environ.get("GITHUB_OUTPUT", "/dev/stdout") with open(output_file, "a") as f: + f.write(f"build_id={result['build_id']}\n") f.write(f"build_number={result['build_number']}\n") f.write(f"build_version={result['build_version']}\n") f.write(f"upload_time={result['upload_time']}\n") From 31ad6b499f55418438f58d7591cd9a9970a55da3 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:41:34 -0400 Subject: [PATCH 05/14] remove AWS OIDC from TestFlight build --- .github/workflows/testflight-build.yml | 61 +++++++++++++++++++------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index 3b2acd2f..c17522ff 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -21,7 +21,6 @@ on: permissions: contents: write - id-token: write concurrency: group: ${{ github.event_name == 'push' && 'testflight-main-latest' || format('testflight-manual-{0}', github.run_id) }} @@ -71,23 +70,40 @@ jobs: bundler-cache: true working-directory: ${{ vars.IOS_WORKDIR || 'ios' }} - - name: Clear runner AWS profile selectors + - name: Write App Store Connect credentials if: ${{ !inputs.dry_run }} + shell: bash + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} run: | - echo "AWS_PROFILE=" >> "$GITHUB_ENV" - echo "AWS_DEFAULT_PROFILE=" >> "$GITHUB_ENV" - - - name: Configure AWS credentials via OIDC - if: ${{ !inputs.dry_run }} - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_RELEASE_ROLE_ARN }} - aws-region: us-east-1 - unset-current-credentials: true + set -euo pipefail + : "${ASC_KEY_ID:?ASC_KEY_ID secret is required}" + : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" + : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" + + mkdir -p "$HOME/.appstoreconnect/private_keys" + p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path" + chmod 600 "$p8_path" + + jq -n \ + --arg key_id "$ASC_KEY_ID" \ + --arg issuer_id "$ASC_ISSUER_ID" \ + --arg key "$ASC_PRIVATE_KEY" \ + '{ + key_id: $key_id, + issuer_id: $issuer_id, + key: $key, + in_house: false + }' > /tmp/asc_api_key.json + chmod 600 /tmp/asc_api_key.json - - name: Fetch App Store Connect credentials - if: ${{ !inputs.dry_run }} - run: scripts/runner/fetch_asc_secret.sh + { + echo "ASC_KEY_ID=$ASC_KEY_ID" + echo "ASC_ISSUER_ID=$ASC_ISSUER_ID" + } >> "$GITHUB_ENV" - name: Install release helper dependencies if: ${{ !inputs.dry_run && (github.event_name != 'workflow_dispatch' || inputs.version_override == '') }} @@ -109,9 +125,20 @@ jobs: --prefer-existing-train \ --output-format github-output - - name: Fetch distribution certificate + - name: Write distribution certificate if: ${{ !inputs.dry_run }} - run: scripts/runner/fetch_distribution_cert.sh + shell: bash + env: + DISTRIBUTION_CERT_P12: ${{ secrets.DISTRIBUTION_CERT_P12 }} + DISTRIBUTION_CERT_PASSWORD: ${{ secrets.DISTRIBUTION_CERT_PASSWORD }} + run: | + set -euo pipefail + : "${DISTRIBUTION_CERT_P12:?DISTRIBUTION_CERT_P12 secret is required}" + : "${DISTRIBUTION_CERT_PASSWORD:?DISTRIBUTION_CERT_PASSWORD secret is required}" + + printf '%s' "$DISTRIBUTION_CERT_P12" | base64 --decode > /tmp/dist_cert.p12 + chmod 600 /tmp/dist_cert.p12 + echo "DIST_CERT_PASSWORD=$DISTRIBUTION_CERT_PASSWORD" >> "$GITHUB_ENV" - name: Create ephemeral keychain if: ${{ !inputs.dry_run }} From a8063ebf61bd7cdeb5f1d0cb53a8fd7614f58fbb Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:44:59 -0400 Subject: [PATCH 06/14] select TestFlight build by marketing version --- .github/workflows/submit-app-store.yml | 20 ++++++++++++-------- scripts/release/find_qualifying_build.py | 23 +++++++++++++++++++++-- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index d0f32cfb..2ce09e91 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -35,7 +35,7 @@ jobs: tag: ${{ steps.resolve-tag.outputs.tag }} version: ${{ steps.resolve-tag.outputs.version }} source_sha: ${{ steps.resolve-source.outputs.source_sha }} - build_tag: ${{ steps.resolve-build-tag.outputs.build_tag }} + build_tag: ${{ steps.find.outputs.build_tag }} steps: - uses: actions/checkout@v4 with: @@ -63,7 +63,7 @@ jobs: source_sha="$(git rev-list -n 1 "$TAG")" echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" - - name: Resolve TestFlight build tag + - name: Resolve TestFlight build tag candidates id: resolve-build-tag shell: bash env: @@ -82,10 +82,12 @@ jobs: exit 1 fi - build_tag="${build_tags[-1]}" - build_number="${build_tag#testflight/build-}" - echo "build_tag=$build_tag" >> "$GITHUB_OUTPUT" - echo "build_number=$build_number" >> "$GITHUB_OUTPUT" + { + printf 'build_numbers=' + printf '%s\n' "${build_tags[@]}" \ + | sed -E 's#^testflight/build-##' \ + | paste -sd, - + } >> "$GITHUB_OUTPUT" - name: Write App Store Connect credentials shell: bash @@ -132,7 +134,7 @@ jobs: shell: bash env: VERSION: ${{ steps.resolve-tag.outputs.version }} - BUILD_NUMBER: ${{ steps.resolve-build-tag.outputs.build_number }} + BUILD_NUMBERS: ${{ steps.resolve-build-tag.outputs.build_numbers }} run: | set +e .venv-release/bin/python scripts/release/find_qualifying_build.py \ @@ -142,7 +144,7 @@ jobs: --app-id "${{ vars.APPLE_APP_ID }}" \ --latest \ --version "$VERSION" \ - --override "$BUILD_NUMBER" \ + --candidates "$BUILD_NUMBERS" \ --output-format github-output 2> /tmp/find-build.err status=$? set -e @@ -153,6 +155,8 @@ jobs: exit "$status" fi + echo "build_tag=testflight/build-$(grep '^build_number=' "$GITHUB_OUTPUT" | tail -n 1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" + approve-release: name: Approve App Store submission needs: find-build diff --git a/scripts/release/find_qualifying_build.py b/scripts/release/find_qualifying_build.py index 1e4f7731..1144c585 100644 --- a/scripts/release/find_qualifying_build.py +++ b/scripts/release/find_qualifying_build.py @@ -41,7 +41,14 @@ def get_asc_token(key_id: str, issuer_id: str, private_key_path: str) -> str: return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id}) -def find_qualifying_build(app_id: str, cutoff_dt: datetime | None, token: str, override: str | None, version: str | None = None) -> dict: +def find_qualifying_build( + app_id: str, + cutoff_dt: datetime | None, + token: str, + override: str | None, + version: str | None = None, + candidates: list[str] | None = None, +) -> dict: headers = {"Authorization": f"Bearer {token}"} url = "https://api.appstoreconnect.apple.com/v1/builds" params = { @@ -69,6 +76,8 @@ def find_qualifying_build(app_id: str, cutoff_dt: datetime | None, token: str, o if override and build_number != str(override): continue + if candidates and build_number not in candidates: + continue uploaded_raw = attrs["uploadedDate"] uploaded = datetime.fromisoformat(uploaded_raw.replace("Z", "+00:00")) @@ -95,6 +104,7 @@ def find_qualifying_build(app_id: str, cutoff_dt: datetime | None, token: str, o raise SystemExit( f"No qualifying build found{cutoff_msg}" + (f" (override={override})" if override else "") + + (f" (candidates={','.join(candidates)})" if candidates else "") ) @@ -107,6 +117,7 @@ def main() -> None: parser.add_argument("--latest", action="store_true", help="Return the most recent valid build (no cutoff)") parser.add_argument("--cutoff-hour", type=int, default=14, help="Hour (24h) in Eastern Time; ignored when --latest is set") parser.add_argument("--override", default="", help="Force a specific build number") + parser.add_argument("--candidates", default="", help="Comma-separated candidate build numbers") parser.add_argument("--version", default="", help="Filter by marketing version string (CFBundleShortVersionString)") parser.add_argument( "--output-format", @@ -123,7 +134,15 @@ def main() -> None: cutoff_dt = datetime(today.year, today.month, today.day, args.cutoff_hour, 0, 0, tzinfo=et).astimezone(timezone.utc) token = get_asc_token(args.key_id, args.issuer_id, args.private_key_path) - result = find_qualifying_build(args.app_id, cutoff_dt, token, args.override or None, args.version or None) + candidates = [item for item in args.candidates.split(",") if item] + result = find_qualifying_build( + args.app_id, + cutoff_dt, + token, + args.override or None, + args.version or None, + candidates or None, + ) if args.output_format == "github-output": output_file = os.environ.get("GITHUB_OUTPUT", "/dev/stdout") From ff66e843a7b18dc5ba9896cce49ad5e4becd6c50 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:48:01 -0400 Subject: [PATCH 07/14] use Apple team org variable in release workflows --- .github/workflows/submit-app-store.yml | 2 +- .github/workflows/testflight-build.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 2ce09e91..e5cacc01 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -269,7 +269,7 @@ jobs: bundle exec fastlane run deliver \ api_key_path:/tmp/asc_api_key.json \ app_identifier:${{ vars.BUNDLE_ID }} \ - team_id:${{ vars.TEAM_ID }} \ + team_id:${{ vars.APPLE_TEAM_ID }} \ app_version:"${{ needs.find-build.outputs.version }}" \ skip_binary_upload:true \ skip_screenshots:true \ diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index c17522ff..b2812fd6 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -158,11 +158,12 @@ jobs: env: NEW_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} run: | bundle exec fastlane deploy \ scheme:${{ vars.SCHEME }} \ bundle_id:${{ vars.BUNDLE_ID }} \ - team_id:${{ vars.TEAM_ID }} \ + team_id:${{ vars.APPLE_TEAM_ID }} \ xcodeproj:${{ vars.XCODEPROJ_PATH }} \ extra_bundle_ids:"${{ vars.EXTRA_BUNDLE_IDS || '' }}" From 1df4568f4189f30065b56b98bfb6704b758c014b Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 12:54:14 -0400 Subject: [PATCH 08/14] remove S3 release manifest from TestFlight build --- .github/workflows/testflight-build.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index b2812fd6..be765a0d 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -167,25 +167,6 @@ jobs: xcodeproj:${{ vars.XCODEPROJ_PATH }} \ extra_bundle_ids:"${{ vars.EXTRA_BUNDLE_IDS || '' }}" - # ------------------------------------------------------------------ - # Emit release-manifest.json for chain-of-custody receipt generation. - # - # Consumed by software-factory's chain-of-custody receipt generation; - # do not remove without coordinating with FAC team. - # - # Produces a deterministic build_number ↔ git_sha ↔ pr_numbers mapping - # that the factory receipt generator reads to populate - # release.built_from_sha. See EPAC-1929. - # ------------------------------------------------------------------ - - name: Emit release manifest - if: ${{ !inputs.dry_run }} - env: - BUILD_NUMBER: ${{ steps.build-number.outputs.build_number }} - GIT_SHA: ${{ steps.source.outputs.sha }} - WORKFLOW_RUN_ID: ${{ github.run_id }} - S3_BUCKET_PREFIX: s3://riddimsoftware-factory-transcripts/release-manifests - run: bash .workflow-source/scripts/ci/emit_release_manifest.sh - - name: Tag TestFlight build provenance if: ${{ !inputs.dry_run }} shell: bash From 54f2873289964dd4000358ba58a180e342f79b01 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:14:26 -0400 Subject: [PATCH 09/14] use app-scoped App Store version lookup --- scripts/release/ensure_app_store_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release/ensure_app_store_version.py b/scripts/release/ensure_app_store_version.py index 5bda3179..d9484da5 100644 --- a/scripts/release/ensure_app_store_version.py +++ b/scripts/release/ensure_app_store_version.py @@ -44,7 +44,7 @@ def find_app_store_version(app_id: str, version: str, token: str) -> dict | None "limit": 50, "fields[appStoreVersions]": "versionString,platform,appStoreState", } - resp = request("GET", "/appStoreVersions", token, params=params) + resp = request("GET", f"/apps/{app_id}/appStoreVersions", token, params=params) for item in resp.json().get("data", []): attrs = item.get("attributes", {}) if attrs.get("versionString") == version and attrs.get("platform") == "IOS": From 3002b2f5c6bdedb8219bb8ec149d505a830b513e Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:18:31 -0400 Subject: [PATCH 10/14] populate release notes before App Store submit --- .github/workflows/submit-app-store.yml | 3 +- scripts/release/ensure_app_store_version.py | 65 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index e5cacc01..8611b193 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -256,7 +256,8 @@ jobs: --private-key-path "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ --app-id "${{ vars.APPLE_APP_ID }}" \ --version "$VERSION" \ - --build-id "$BUILD_ID" + --build-id "$BUILD_ID" \ + --metadata-path "${{ vars.IOS_WORKDIR || 'ios' }}/fastlane/metadata" - name: Submit build for App Store review working-directory: ${{ vars.IOS_WORKDIR || 'ios' }} diff --git a/scripts/release/ensure_app_store_version.py b/scripts/release/ensure_app_store_version.py index d9484da5..0b0749f4 100644 --- a/scripts/release/ensure_app_store_version.py +++ b/scripts/release/ensure_app_store_version.py @@ -4,6 +4,7 @@ import argparse import os import time +from pathlib import Path import jwt import requests @@ -83,6 +84,62 @@ def attach_build(version_id: str, build_id: str, token: str) -> None: request("PATCH", f"/appStoreVersions/{version_id}/relationships/build", token, json=payload) +def release_notes_by_locale(metadata_path: Path) -> dict[str, str]: + notes: dict[str, str] = {} + if not metadata_path.exists(): + return notes + + for path in metadata_path.glob("*/release_notes.txt"): + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8").strip() + if text: + notes[path.parent.name] = text + return notes + + +def update_whats_new(version_id: str, notes: dict[str, str], token: str) -> None: + if not notes: + print("No release notes found; skipping whatsNew update") + return + + params = { + "limit": 200, + "fields[appStoreVersionLocalizations]": "locale,whatsNew", + } + resp = request( + "GET", + f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", + token, + params=params, + ) + updated = 0 + for item in resp.json().get("data", []): + locale = item.get("attributes", {}).get("locale") + whats_new = notes.get(locale or "") + if not whats_new: + continue + + payload = { + "data": { + "type": "appStoreVersionLocalizations", + "id": item["id"], + "attributes": { + "whatsNew": whats_new, + }, + } + } + request("PATCH", f"/appStoreVersionLocalizations/{item['id']}", token, json=payload) + updated += 1 + print(f"Updated whatsNew for {locale}") + + if updated == 0: + raise SystemExit( + "No App Store version localizations matched release_notes.txt locales: " + + ", ".join(sorted(notes)) + ) + + def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--key-id", required=True) @@ -91,6 +148,7 @@ def main() -> None: parser.add_argument("--app-id", required=True) parser.add_argument("--version", required=True) parser.add_argument("--build-id", required=True) + parser.add_argument("--metadata-path", default="") args = parser.parse_args() token = get_asc_token(args.key_id, args.issuer_id, args.private_key_path) @@ -107,6 +165,13 @@ def main() -> None: attach_build(version["id"], args.build_id, token) print(f"Attached build {args.build_id} to App Store version {args.version}") + if args.metadata_path: + update_whats_new( + version["id"], + release_notes_by_locale(Path(args.metadata_path)), + token, + ) + if __name__ == "__main__": main() From 9ccbe9678ecd209f429ecc89d23c5d6f7d443002 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:24:33 -0400 Subject: [PATCH 11/14] update metadata delivery workflow and release notes --- .github/workflows/deliver-metadata.yml | 60 +++++++++++++++---- ios/fastlane/metadata/en-CA/release_notes.txt | 9 +-- ios/fastlane/metadata/fr-CA/release_notes.txt | 9 +-- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deliver-metadata.yml b/.github/workflows/deliver-metadata.yml index 405a9c91..22cf0b57 100644 --- a/.github/workflows/deliver-metadata.yml +++ b/.github/workflows/deliver-metadata.yml @@ -7,10 +7,14 @@ on: - 'ios/fastlane/metadata/**' - 'ios/fastlane/Deliverfile' workflow_dispatch: + inputs: + source_ref: + description: Git ref/SHA containing the metadata to deliver instead of the workflow ref + required: false + type: string permissions: contents: read - id-token: write concurrency: group: deliver-app-store-metadata @@ -25,21 +29,51 @@ jobs: runs-on: ${{ fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]') }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.ref }} + fetch-depth: 0 - - name: Clear runner AWS profile overrides + - name: Resolve checked-out metadata source + id: source + shell: bash run: | - echo "AWS_PROFILE=" >> "$GITHUB_ENV" - echo "AWS_DEFAULT_PROFILE=" >> "$GITHUB_ENV" + set -euo pipefail + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "ref=${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.ref }}" >> "$GITHUB_OUTPUT" - - name: Configure AWS credentials via OIDC - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.AWS_RELEASE_ROLE_ARN }} - aws-region: us-east-1 - unset-current-credentials: true + - name: Write App Store Connect credentials + shell: bash + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} + run: | + set -euo pipefail + : "${ASC_KEY_ID:?ASC_KEY_ID secret is required}" + : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" + : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" - - name: Fetch App Store Connect credentials - run: scripts/runner/fetch_asc_secret.sh + mkdir -p "$HOME/.appstoreconnect/private_keys" + p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path" + chmod 600 "$p8_path" + + jq -n \ + --arg key_id "$ASC_KEY_ID" \ + --arg issuer_id "$ASC_ISSUER_ID" \ + --arg key "$ASC_PRIVATE_KEY" \ + '{ + key_id: $key_id, + issuer_id: $issuer_id, + key: $key, + in_house: false + }' > /tmp/asc_api_key.json + chmod 600 /tmp/asc_api_key.json + + { + echo "ASC_KEY_ID=$ASC_KEY_ID" + echo "ASC_ISSUER_ID=$ASC_ISSUER_ID" + } >> "$GITHUB_ENV" - name: Set up Ruby and Bundler if: ${{ !contains(fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]'), 'self-hosted') }} @@ -67,6 +101,8 @@ jobs: { echo "## Metadata delivered to App Store Connect" echo "" + echo "- Metadata source ref: ${{ steps.source.outputs.ref }}" + echo "- Metadata source commit: ${{ steps.source.outputs.sha }}" echo "Updated App Store metadata and screenshots only." echo "Binary upload and App Review submission were not touched." } >> "$GITHUB_STEP_SUMMARY" diff --git a/ios/fastlane/metadata/en-CA/release_notes.txt b/ios/fastlane/metadata/en-CA/release_notes.txt index e44e81ec..19e433bd 100644 --- a/ios/fastlane/metadata/en-CA/release_notes.txt +++ b/ios/fastlane/metadata/en-CA/release_notes.txt @@ -1,10 +1,7 @@ What's new in epac: -• Home feed — your MP's activity, followed bills, topics, and consultations in one place -• Topic notifications — get notified when a topic you follow is debated in the House -• Bill tracker — see new bills introduced since your last visit, with a dot badge on arrival -• City council coverage — Toronto and Vancouver council votes alongside federal Parliament -• Offline resilience — debates and votes load gracefully on slow or interrupted connections -• Apple Watch complication — Parliament sitting status on your wrist +• Reliability improvements for parliamentary activity, bills, votes, and notifications +• Maintenance updates to keep official government data loading smoothly +• Small fixes and polish across the app epac only shows verified data from official government sources. Nothing is generated or editorialized. diff --git a/ios/fastlane/metadata/fr-CA/release_notes.txt b/ios/fastlane/metadata/fr-CA/release_notes.txt index 0db0cd13..b2474f3b 100644 --- a/ios/fastlane/metadata/fr-CA/release_notes.txt +++ b/ios/fastlane/metadata/fr-CA/release_notes.txt @@ -1,10 +1,7 @@ Nouveautés dans epac : -• Fil d'actualité — l'activité de votre député, les projets de loi et sujets suivis, rassemblés en un seul endroit -• Notifications par sujet — recevez une alerte quand un sujet que vous suivez est débattu à la Chambre -• Suivi des projets de loi — voyez les nouveaux projets de loi déposés depuis votre dernière visite, signalés par un point rouge -• Conseils municipaux — votes du conseil municipal de Toronto et de Vancouver, aux côtés du Parlement fédéral -• Résilience hors connexion — débats et votes s'affichent même avec une connexion lente ou interrompue -• Complication Apple Watch — statut de séance du Parlement sur votre poignet +• Améliorations de fiabilité pour l'activité parlementaire, les projets de loi, les votes et les notifications +• Mises à jour de maintenance pour garder le chargement des données gouvernementales officielles fluide +• Petites corrections et améliorations dans l'application epac n'affiche que des données vérifiées provenant de sources gouvernementales officielles. Rien n'est généré ni éditorialisé. From e01f196fce63242717773e7100abe5d6f5ea4fa0 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:29:44 -0400 Subject: [PATCH 12/14] add manual App Store release workflow --- .github/workflows/release-app-store.yml | 100 +++++++++++++++ .github/workflows/submit-app-store.yml | 10 +- .github/workflows/testflight-build.yml | 2 +- scripts/release/ensure_app_store_version.py | 1 - scripts/release/release_app_store_version.py | 128 +++++++++++++++++++ 5 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release-app-store.yml create mode 100644 scripts/release/release_app_store_version.py diff --git a/.github/workflows/release-app-store.yml b/.github/workflows/release-app-store.yml new file mode 100644 index 00000000..d3792393 --- /dev/null +++ b/.github/workflows/release-app-store.yml @@ -0,0 +1,100 @@ +name: Release App Store Version + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to publish + required: true + type: string + dry_run: + description: Validate release readiness without publishing or deleting tags + required: true + type: boolean + default: true + +permissions: + contents: write + +concurrency: + group: app-store-version-release + cancel-in-progress: false + +jobs: + release: + name: Release approved App Store version + runs-on: ${{ fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]') }} + environment: ${{ vars.APPROVAL_ENVIRONMENT || 'app-store-release' }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve tag and version + id: resolve-tag + shell: bash + run: | + set -euo pipefail + TAG="${{ inputs.tag }}" + VERSION="${TAG#app/v}" + VERSION="${VERSION#v}" + VERSION="${VERSION%%-rc.*}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Write App Store Connect credentials + shell: bash + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }} + run: | + set -euo pipefail + : "${ASC_KEY_ID:?ASC_KEY_ID secret is required}" + : "${ASC_ISSUER_ID:?ASC_ISSUER_ID secret is required}" + : "${ASC_PRIVATE_KEY:?ASC_PRIVATE_KEY secret is required}" + + mkdir -p "$HOME/.appstoreconnect/private_keys" + p8_path="$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" + printf '%s' "$ASC_PRIVATE_KEY" > "$p8_path" + chmod 600 "$p8_path" + + { + echo "ASC_KEY_ID=$ASC_KEY_ID" + echo "ASC_ISSUER_ID=$ASC_ISSUER_ID" + } >> "$GITHUB_ENV" + + - name: Install Python dependencies + run: | + python3 -m venv .venv-release + .venv-release/bin/python -m pip install --upgrade pip --quiet + .venv-release/bin/python -m pip install PyJWT requests cryptography --quiet + + - name: Release App Store version + shell: bash + env: + VERSION: ${{ steps.resolve-tag.outputs.version }} + run: | + args=() + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=(--dry-run) + fi + + .venv-release/bin/python scripts/release/release_app_store_version.py \ + --key-id "$ASC_KEY_ID" \ + --issuer-id "$ASC_ISSUER_ID" \ + --private-key-path "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8" \ + --app-id "${{ vars.APPLE_APP_ID }}" \ + --version "$VERSION" \ + "${args[@]}" + + - name: Summary + run: | + { + echo "## App Store release workflow complete" + echo "" + echo "- Tag: ${{ steps.resolve-tag.outputs.tag }}" + echo "- Version: ${{ steps.resolve-tag.outputs.version }}" + echo "- Dry run: ${{ inputs.dry_run }}" + echo "- Cleanup scope: testflight/${{ steps.resolve-tag.outputs.version }}/build-*" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 8611b193..434840bc 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -71,21 +71,21 @@ jobs: run: | set -euo pipefail mapfile -t build_tags < <( - git tag --points-at "$SOURCE_SHA" 'testflight/build-*' \ - | sed -E 's#^testflight/build-([0-9]+)$#\1 & #' \ + git tag --points-at "$SOURCE_SHA" "testflight/${VERSION}/build-*" \ + | sed -E "s#^testflight/${VERSION}/build-([0-9]+)$#\1 & #" \ | sort -n \ | awk '{print $2}' ) if [ "${#build_tags[@]}" -eq 0 ]; then - echo "::error::No testflight/build-* tag points at release source ${SOURCE_SHA}." + echo "::error::No testflight/${VERSION}/build-* tag points at release source ${SOURCE_SHA}." exit 1 fi { printf 'build_numbers=' printf '%s\n' "${build_tags[@]}" \ - | sed -E 's#^testflight/build-##' \ + | sed -E "s#^testflight/${VERSION}/build-##" \ | paste -sd, - } >> "$GITHUB_OUTPUT" @@ -155,7 +155,7 @@ jobs: exit "$status" fi - echo "build_tag=testflight/build-$(grep '^build_number=' "$GITHUB_OUTPUT" | tail -n 1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" + echo "build_tag=testflight/${VERSION}/build-$(grep '^build_number=' "$GITHUB_OUTPUT" | tail -n 1 | cut -d= -f2)" >> "$GITHUB_OUTPUT" approve-release: name: Approve App Store submission diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index be765a0d..898cd17c 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -176,7 +176,7 @@ jobs: VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version_override || steps.version.outputs.next_version }} run: | set -euo pipefail - tag="testflight/build-${BUILD_NUMBER}" + tag="testflight/${VERSION}/build-${BUILD_NUMBER}" if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then existing_sha="$(git rev-list -n 1 "${tag}")" diff --git a/scripts/release/ensure_app_store_version.py b/scripts/release/ensure_app_store_version.py index 0b0749f4..db1ae9b0 100644 --- a/scripts/release/ensure_app_store_version.py +++ b/scripts/release/ensure_app_store_version.py @@ -40,7 +40,6 @@ def request(method: str, path: str, token: str, **kwargs) -> requests.Response: def find_app_store_version(app_id: str, version: str, token: str) -> dict | None: params = { - "filter[app]": app_id, "filter[platform]": "IOS", "limit": 50, "fields[appStoreVersions]": "versionString,platform,appStoreState", diff --git a/scripts/release/release_app_store_version.py b/scripts/release/release_app_store_version.py new file mode 100644 index 00000000..3ae5a423 --- /dev/null +++ b/scripts/release/release_app_store_version.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Release a manually held App Store version and clean up TestFlight tags.""" + +import argparse +import os +import subprocess +import time + +import jwt +import requests + + +BASE_URL = "https://api.appstoreconnect.apple.com/v1" +RELEASABLE_STATES = {"PENDING_DEVELOPER_RELEASE"} +ALREADY_RELEASED_STATES = {"READY_FOR_SALE", "READY_FOR_DISTRIBUTION"} + + +def get_asc_token(key_id: str, issuer_id: str, private_key_path: str) -> str: + with open(os.path.expanduser(private_key_path)) as f: + private_key = f.read() + now = int(time.time()) + payload = { + "iss": issuer_id, + "iat": now, + "exp": now + 1200, + "aud": "appstoreconnect-v1", + } + return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id}) + + +def request(method: str, path: str, token: str, **kwargs) -> requests.Response: + headers = kwargs.pop("headers", {}) + headers["Authorization"] = f"Bearer {token}" + if method != "GET": + headers["Content-Type"] = "application/json" + + resp = requests.request(method, f"{BASE_URL}{path}", headers=headers, timeout=30, **kwargs) + if not resp.ok: + raise SystemExit(f"{method} {path} failed ({resp.status_code}): {resp.text}") + return resp + + +def find_app_store_version(app_id: str, version: str, token: str) -> dict: + params = { + "filter[platform]": "IOS", + "limit": 50, + "fields[appStoreVersions]": "versionString,platform,appStoreState", + } + resp = request("GET", f"/apps/{app_id}/appStoreVersions", token, params=params) + for item in resp.json().get("data", []): + attrs = item.get("attributes", {}) + if attrs.get("versionString") == version and attrs.get("platform") == "IOS": + return item + raise SystemExit(f"App Store version {version} for IOS was not found") + + +def release_version(version_id: str, token: str) -> str: + payload = { + "data": { + "type": "appStoreVersionReleaseRequests", + "relationships": { + "appStoreVersion": { + "data": { + "type": "appStoreVersions", + "id": version_id, + } + } + }, + } + } + return request("POST", "/appStoreVersionReleaseRequests", token, json=payload).json()["data"]["id"] + + +def delete_testflight_tags(version: str) -> list[str]: + pattern = f"testflight/{version}/build-*" + result = subprocess.run( + ["git", "tag", "--list", pattern], + check=True, + capture_output=True, + text=True, + ) + tags = [line.strip() for line in result.stdout.splitlines() if line.strip()] + for tag in tags: + subprocess.run(["git", "push", "origin", "--delete", tag], check=True) + return tags + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--key-id", required=True) + parser.add_argument("--issuer-id", required=True) + parser.add_argument("--private-key-path", required=True) + parser.add_argument("--app-id", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + token = get_asc_token(args.key_id, args.issuer_id, args.private_key_path) + version = find_app_store_version(args.app_id, args.version, token) + state = version["attributes"].get("appStoreState", "") + version_id = version["id"] + print(f"Found App Store version {args.version} ({version_id}, state={state})") + + if state in ALREADY_RELEASED_STATES: + print(f"Version {args.version} is already released; deleting scoped TestFlight tags.") + if not args.dry_run: + deleted = delete_testflight_tags(args.version) + print("Deleted tags: " + (", ".join(deleted) if deleted else "none")) + return + + if state not in RELEASABLE_STATES: + raise SystemExit( + f"Version {args.version} is not releasable yet. " + f"Expected one of {sorted(RELEASABLE_STATES)}, got {state}." + ) + + if args.dry_run: + print(f"Dry run: would release version {args.version} and delete testflight/{args.version}/build-* tags.") + return + + request_id = release_version(version_id, token) + print(f"Created App Store version release request {request_id}") + deleted = delete_testflight_tags(args.version) + print("Deleted tags: " + (", ".join(deleted) if deleted else "none")) + + +if __name__ == "__main__": + main() From 736313c6feedbc534db23e5d4d1ad757ea255488 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:46:12 -0400 Subject: [PATCH 13/14] pass version to TestFlight tag lookup --- .github/workflows/submit-app-store.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 434840bc..8e34d86b 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -68,6 +68,7 @@ jobs: shell: bash env: SOURCE_SHA: ${{ steps.resolve-source.outputs.source_sha }} + VERSION: ${{ steps.resolve-tag.outputs.version }} run: | set -euo pipefail mapfile -t build_tags < <( From 141586272b35aae1406acd361fbe4f91c7aa8de9 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Tue, 19 May 2026 13:48:23 -0400 Subject: [PATCH 14/14] fix version-scoped TestFlight tag parsing --- .github/workflows/submit-app-store.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/submit-app-store.yml b/.github/workflows/submit-app-store.yml index 8e34d86b..0aae650e 100644 --- a/.github/workflows/submit-app-store.yml +++ b/.github/workflows/submit-app-store.yml @@ -71,9 +71,13 @@ jobs: VERSION: ${{ steps.resolve-tag.outputs.version }} run: | set -euo pipefail + tag_prefix="testflight/${VERSION}/build-" mapfile -t build_tags < <( git tag --points-at "$SOURCE_SHA" "testflight/${VERSION}/build-*" \ - | sed -E "s#^testflight/${VERSION}/build-([0-9]+)$#\1 & #" \ + | while read -r tag; do + build_number="${tag#"$tag_prefix"}" + printf '%s %s\n' "$build_number" "$tag" + done \ | sort -n \ | awk '{print $2}' ) @@ -85,9 +89,9 @@ jobs: { printf 'build_numbers=' - printf '%s\n' "${build_tags[@]}" \ - | sed -E "s#^testflight/${VERSION}/build-##" \ - | paste -sd, - + for tag in "${build_tags[@]}"; do + printf '%s\n' "${tag#"$tag_prefix"}" + done | paste -sd, - } >> "$GITHUB_OUTPUT" - name: Write App Store Connect credentials