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/.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 4e210276..0aae650e 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 @@ -29,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.find.outputs.build_tag }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Resolve tag and version id: resolve-tag @@ -49,20 +53,80 @@ jobs: echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Clear runner AWS profile overrides + - name: Resolve release source commit + id: resolve-source + shell: bash + env: + TAG: ${{ steps.resolve-tag.outputs.tag }} run: | - echo "AWS_PROFILE=" >> "$GITHUB_ENV" - echo "AWS_DEFAULT_PROFILE=" >> "$GITHUB_ENV" + set -euo pipefail + source_sha="$(git rev-list -n 1 "$TAG")" + echo "source_sha=$source_sha" >> "$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: Resolve TestFlight build tag candidates + id: resolve-build-tag + shell: bash + env: + SOURCE_SHA: ${{ steps.resolve-source.outputs.source_sha }} + 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-*" \ + | while read -r tag; do + build_number="${tag#"$tag_prefix"}" + printf '%s %s\n' "$build_number" "$tag" + done \ + | sort -n \ + | awk '{print $2}' + ) - - name: Fetch App Store Connect credentials - run: scripts/runner/fetch_asc_secret.sh + if [ "${#build_tags[@]}" -eq 0 ]; then + echo "::error::No testflight/${VERSION}/build-* tag points at release source ${SOURCE_SHA}." + exit 1 + fi + + { + printf 'build_numbers=' + for tag in "${build_tags[@]}"; do + printf '%s\n' "${tag#"$tag_prefix"}" + done | paste -sd, - + } >> "$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" + + 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: Install Python dependencies run: | @@ -75,6 +139,7 @@ jobs: shell: bash env: VERSION: ${{ steps.resolve-tag.outputs.version }} + BUILD_NUMBERS: ${{ steps.resolve-build-tag.outputs.build_numbers }} run: | set +e .venv-release/bin/python scripts/release/find_qualifying_build.py \ @@ -84,6 +149,7 @@ jobs: --app-id "${{ vars.APPLE_APP_ID }}" \ --latest \ --version "$VERSION" \ + --candidates "$BUILD_NUMBERS" \ --output-format github-output 2> /tmp/find-build.err status=$? set -e @@ -94,6 +160,8 @@ jobs: exit "$status" fi + 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 needs: find-build @@ -108,6 +176,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" @@ -122,20 +192,39 @@ 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" + 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: 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 + 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: Set up Ruby and Bundler if: ${{ !contains(fromJSON(vars.RUNNER_LABELS_LINUX || '["ubuntu-latest"]'), 'self-hosted') }} @@ -154,18 +243,50 @@ 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" \ + --metadata-path "${{ vars.IOS_WORKDIR || 'ios' }}/fastlane/metadata" + - 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 + 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.APPLE_TEAM_ID }} \ + app_version:"${{ needs.find-build.outputs.version }}" \ + skip_binary_upload:true \ + skip_screenshots:true \ + skip_metadata:true \ + 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: | diff --git a/.github/workflows/testflight-build.yml b/.github/workflows/testflight-build.yml index 8252beba..898cd17c 100644 --- a/.github/workflows/testflight-build.yml +++ b/.github/workflows/testflight-build.yml @@ -20,12 +20,11 @@ on: type: string permissions: - contents: read - id-token: write + contents: 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" @@ -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 }} @@ -131,32 +158,44 @@ 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 || '' }}" - # ------------------------------------------------------------------ - # 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 + - name: Tag TestFlight build provenance if: ${{ !inputs.dry_run }} + shell: bash 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 + 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/${VERSION}/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 }} 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é. diff --git a/scripts/release/ensure_app_store_version.py b/scripts/release/ensure_app_store_version.py new file mode 100644 index 00000000..db1ae9b0 --- /dev/null +++ b/scripts/release/ensure_app_store_version.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Ensure an App Store version train exists and attach a TestFlight build.""" + +import argparse +import os +import time +from pathlib import Path + +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[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 + 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 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) + 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) + parser.add_argument("--metadata-path", default="") + 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 args.metadata_path: + update_whats_new( + version["id"], + release_notes_by_locale(Path(args.metadata_path)), + token, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/release/find_qualifying_build.py b/scripts/release/find_qualifying_build.py index 3e0f074d..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")) @@ -85,6 +94,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, @@ -94,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 "") ) @@ -106,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", @@ -122,11 +134,20 @@ 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") 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") 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()