From 0a197b0b2e0bdfdca22585459cdb8f442de6cd6e Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 09:40:07 +0200 Subject: [PATCH 1/8] ci(apple): build and upload on GitHub macOS runners --- .github/actions/apple-build-upload/action.yml | 60 ++++++++++++ .github/scripts/app_store_connect_release.rb | 91 ++++-------------- .github/scripts/apple_archive.sh | 65 +++++++++++++ .../test/app_store_connect_release_test.rb | 93 +++++++++++++++++++ .../workflows/app_store_build_and_deploy.yaml | 4 - .github/workflows/ios_build_and_deploy.yaml | 67 +++++++++---- .github/workflows/macos_build_and_deploy.yaml | 67 +++++++++---- .github/workflows/pr_build_all_platforms.yml | 45 +++++++++ open_wearable/docs/apple-builds.md | 55 +++++++++++ 9 files changed, 433 insertions(+), 114 deletions(-) create mode 100644 .github/actions/apple-build-upload/action.yml create mode 100644 .github/scripts/apple_archive.sh create mode 100644 .github/scripts/test/app_store_connect_release_test.rb create mode 100644 open_wearable/docs/apple-builds.md diff --git a/.github/actions/apple-build-upload/action.yml b/.github/actions/apple-build-upload/action.yml new file mode 100644 index 000000000..639fcaa45 --- /dev/null +++ b/.github/actions/apple-build-upload/action.yml @@ -0,0 +1,60 @@ +name: Apple build and upload +description: Archive on a GitHub macOS runner and upload to App Store Connect. +inputs: + platform: + description: ios or macos + required: true + app-dir: + description: Release source Flutter application directory + required: true +outputs: + build_number: + description: Exact CFBundleVersion uploaded to Apple + value: ${{ steps.archive.outputs.build_number }} +runs: + using: composite + steps: + - name: Select Xcode + shell: bash + run: | + sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + xcodebuild -version + + - name: Read Flutter version + id: flutter + shell: bash + env: + APP_DIR: ${{ inputs.app-dir }} + run: echo "version=$(cat "$APP_DIR/.flutter_version")" >> "$GITHUB_OUTPUT" + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: ${{ steps.flutter.outputs.version }} + cache: true + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.app-dir }} + run: flutter pub get + + - name: Archive, sign and upload + id: archive + shell: bash + env: + APPLE_PLATFORM: ${{ inputs.platform }} + APP_DIR: ${{ inputs.app-dir }} + ACTION_PATH: ${{ github.action_path }} + run: bash "$ACTION_PATH/../../scripts/apple_archive.sh" "$APPLE_PLATFORM" "$APP_DIR" "$RUNNER_TEMP/apple-build" + + - name: Preserve build diagnostics + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v6 + with: + name: apple-${{ inputs.platform }}-diagnostics + path: | + ${{ runner.temp }}/apple-build/*.log + ${{ runner.temp }}/apple-build/*.xcresult + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb index b2989b57d..9d8a5c244 100644 --- a/.github/scripts/app_store_connect_release.rb +++ b/.github/scripts/app_store_connect_release.rb @@ -10,6 +10,8 @@ require "timeout" require "uri" +$stdout.sync = true + class AppStoreConnectError < StandardError; end class AppStoreConnectClient @@ -151,9 +153,8 @@ def run validate_release_notes! return puts("Release inputs are valid") if @options[:validate_only] - build_run = start_xcode_cloud_run - wait_for_xcode_cloud_run(build_run.fetch("id")) - build = wait_for_processed_build(build_run.fetch("id")) + build = wait_for_processed_build + return puts("Uploaded build is ready; App Review submission was not requested") if @options[:process_only] version = find_or_create_version if submitted?(version) @@ -187,71 +188,12 @@ def validate_note!(path) raise AppStoreConnectError, "Release notes exceed Apple's 4000-character limit: #{path}" end - def start_xcode_cloud_run - source_reference = wait_for_source_reference - puts "Starting Xcode Cloud workflow #{@options[:workflow_id]} at #{@options[:tag]}" - @client.post( - "/v1/ciBuildRuns", - data: { - type: "ciBuildRuns", - attributes: {}, - relationships: { - workflow: { data: { type: "ciWorkflows", id: @options[:workflow_id] } }, - sourceBranchOrTag: { data: { type: "scmGitReferences", id: source_reference.fetch("id") } } - } - } - ).fetch("data") - end - - def wait_for_source_reference - canonical_name = "refs/tags/#{@options[:tag]}" - loop do - repository = @client.get("/v1/ciWorkflows/#{@options[:workflow_id]}/repository").fetch("data") - response = @client.get( - "/v1/scmRepositories/#{repository.fetch('id')}/gitReferences", - "fields[scmGitReferences]" => "name,canonicalName,isDeleted,kind", - "limit" => 200 - ) - reference = Array(response["data"]).find do |candidate| - candidate.dig("attributes", "canonicalName") == canonical_name && - !candidate.dig("attributes", "isDeleted") - end - return reference if reference - - puts "Xcode Cloud has not indexed #{@options[:tag]} yet" - wait_or_timeout! - end - end - - def wait_for_xcode_cloud_run(build_run_id) - puts "Waiting for Xcode Cloud build #{build_run_id}" - loop do - run = @client.get("/v1/ciBuildRuns/#{build_run_id}").fetch("data") - commit_sha = run.dig("attributes", "sourceCommit", "commitSha").to_s - if !commit_sha.empty? && commit_sha != @options[:git_sha] - raise AppStoreConnectError, - "Xcode Cloud build source commit #{commit_sha} does not match release commit #{@options[:git_sha]}" - end - - progress = run.dig("attributes", "executionProgress") - if progress == "COMPLETE" - status = run.dig("attributes", "completionStatus") - raise AppStoreConnectError, "Xcode Cloud build finished with #{status}" unless status == "SUCCEEDED" - - puts "Xcode Cloud build ##{run.dig('attributes', 'number')} succeeded" - return run - end - - puts "Xcode Cloud build ##{run.dig('attributes', 'number')} is #{progress}" - wait_or_timeout! - end - end - - def wait_for_processed_build(build_run_id) - puts "Waiting for App Store Connect to process the Xcode Cloud archive" + def wait_for_processed_build + puts "Waiting for App Store Connect to process #{@options[:platform]} #{@options[:version]} (#{@options[:build_number]})" loop do response = @client.get( - "/v1/ciBuildRuns/#{build_run_id}/builds", + "/v1/builds", + "filter[version]" => @options[:build_number], "filter[app]" => @options[:app_id], "filter[preReleaseVersion.version]" => @options[:version], "filter[preReleaseVersion.platform]" => @options[:platform], @@ -271,7 +213,7 @@ def wait_for_processed_build(build_run_id) audience = build.dig("attributes", "buildAudienceType") unless audience == "APP_STORE_ELIGIBLE" raise AppStoreConnectError, - "Build #{build.dig('attributes', 'version')} is #{audience}; configure the Xcode Cloud " \ + "Build #{build.dig('attributes', 'version')} is #{audience}; export the " \ "archive for TestFlight and App Store distribution" end @@ -279,7 +221,7 @@ def wait_for_processed_build(build_run_id) return build end - puts builds.empty? ? "No uploaded build is associated with the run yet" : "The build is still processing" + puts builds.empty? ? "The uploaded build is not visible yet" : "The build is still processing" wait_or_timeout! end end @@ -491,29 +433,28 @@ def wait_or_timeout! end end -begin +def run_release_cli options = { - timeout: 7200, + timeout: 2700, release_type: "AFTER_APPROVAL", validate_only: false } OptionParser.new do |parser| parser.banner = "Usage: app_store_connect_release.rb [options]" - parser.on("--workflow-id ID") { |value| options[:workflow_id] = value } + parser.on("--build-number NUMBER") { |value| options[:build_number] = value } parser.on("--app-id ID") { |value| options[:app_id] = value } parser.on("--platform PLATFORM") { |value| options[:platform] = value } parser.on("--version VERSION") { |value| options[:version] = value } - parser.on("--tag TAG") { |value| options[:tag] = value } - parser.on("--git-sha SHA") { |value| options[:git_sha] = value } parser.on("--release-notes-dir PATH") { |value| options[:release_notes_dir] = value } parser.on("--release-type TYPE") { |value| options[:release_type] = value } parser.on("--timeout SECONDS", Integer) { |value| options[:timeout] = value } parser.on("--validate-only") { options[:validate_only] = true } + parser.on("--process-only") { options[:process_only] = true } end.parse! required = %i[platform version release_notes_dir] - required += %i[workflow_id app_id tag git_sha] unless options[:validate_only] + required += %i[app_id build_number] unless options[:validate_only] missing = required.select { |key| options[key].nil? || options[key].empty? } raise AppStoreConnectError, "Missing options: #{missing.join(', ')}" unless missing.empty? unless %w[IOS MAC_OS].include?(options[:platform]) @@ -538,3 +479,5 @@ def wait_or_timeout! warn "::error::#{error.message}" exit 1 end + +run_release_cli if $PROGRAM_NAME == __FILE__ diff --git a/.github/scripts/apple_archive.sh b/.github/scripts/apple_archive.sh new file mode 100644 index 000000000..da75e5a55 --- /dev/null +++ b/.github/scripts/apple_archive.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Archive on GitHub, then let Xcode sign and upload using the existing Apple API key. +set -euo pipefail + +platform="$1" +app_dir="$2" +output_dir="$3" +case "$platform" in + ios) destination='generic/platform=iOS' ;; + macos) destination='generic/platform=macOS' ;; + *) echo "Unsupported Apple platform: $platform" >&2; exit 1 ;; +esac + +mkdir -p "$output_dir" +output_dir=$(cd "$output_dir" && pwd) +cd "$app_dir" +# Three numeric components, each within Apple's digit limits. Reruns get a new +# increasing build number, including when a different workflow releases the tag. +build_number=$(date -u '+%y%m.%d%H.%M%S' | awk -F. '{printf "%d.%d.%d\n", $1, $2, $3}') +printf 'build_number=%s\n' "$build_number" >> "$GITHUB_OUTPUT" +printf 'Building %s from %s as %s\n' "$platform" "$(git rev-parse HEAD)" "$build_number" + +config_args=("$platform" --release --config-only --build-number="$build_number") +if [[ "$platform" == ios ]]; then config_args+=(--no-codesign); fi +flutter build "${config_args[@]}" +(cd "$platform" && pod install) + +xcodebuild archive -hideShellScriptEnvironment \ + -workspace "$platform/Runner.xcworkspace" -scheme Runner \ + -configuration Release -destination "$destination" \ + -archivePath "$output_dir/Runner.xcarchive" \ + -resultBundlePath "$output_dir/archive.xcresult" \ + CODE_SIGNING_ALLOWED=NO \ + | tee "$output_dir/archive.log" + +# Only the ephemeral runner sees this file. It is never included in artifacts. +key_dir=$(mktemp -d "$RUNNER_TEMP/apple-api.XXXXXX") +trap 'rm -rf "$key_dir"' EXIT +export APPLE_API_KEY_PATH="$key_dir/AuthKey.p8" +ruby -rbase64 -e ' + key = ENV.fetch("APP_STORE_CONNECT_PRIVATE_KEY").gsub("\\n", "\n").strip + key = Base64.strict_decode64(key) unless key.include?("BEGIN") + File.write(ENV.fetch("APPLE_API_KEY_PATH"), key, perm: 0600) +' +cat > "$output_dir/ExportOptions.plist" <<'PLIST' + + + + methodapp-store-connect + destinationupload + signingStyleautomatic + teamID6DCQ69GP5G + manageAppVersionAndBuildNumber + uploadSymbols + +PLIST +xcodebuild -exportArchive \ + -archivePath "$output_dir/Runner.xcarchive" \ + -exportOptionsPlist "$output_dir/ExportOptions.plist" \ + -exportPath "$output_dir/export" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$APPLE_API_KEY_PATH" \ + -authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \ + -authenticationKeyIssuerID "$APP_STORE_CONNECT_ISSUER_ID" \ + | tee "$output_dir/upload.log" diff --git a/.github/scripts/test/app_store_connect_release_test.rb b/.github/scripts/test/app_store_connect_release_test.rb new file mode 100644 index 000000000..dbd2954b3 --- /dev/null +++ b/.github/scripts/test/app_store_connect_release_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true +require 'minitest/autorun' +require 'tmpdir' +require 'fileutils' +require_relative '../app_store_connect_release' + +class AppStoreConnectReleaseTest < Minitest::Test + def setup + @notes_dir = Dir.mktmpdir + File.write(File.join(@notes_dir, "default.txt"), "Release improvements.") + end + + def teardown + FileUtils.remove_entry(@notes_dir) + end + + class Client + attr_reader :requests + def initialize(builds) + @builds = builds + @requests = [] + end + def get(path, query = {}) + @requests << [path, query] + raise "Unexpected API request: #{path}" unless path == '/v1/builds' + { 'data' => @builds } + end + end + + def build(state = 'VALID', audience = 'APP_STORE_ELIGIBLE') + { 'id' => 'uploaded-build', 'attributes' => { + 'version' => '2610.0710.1234', 'processingState' => state, + 'buildAudienceType' => audience + } } + end + + def release(client, **overrides) + AppStoreRelease.new(client: client, options: { + app_id: 'app-id', platform: 'IOS', version: '1.6.0', + build_number: '2610.0710.1234', timeout: 0, + release_notes_dir: @notes_dir, + process_only: true + }.merge(overrides)) + end + + def test_processing_only_selects_exact_app_platform_version_and_build_without_submission + client = Client.new([build]) + capture_io { release(client).run } + assert_equal 1, client.requests.length + path, query = client.requests.first + assert_equal '/v1/builds', path + assert_equal 'app-id', query['filter[app]'] + assert_equal 'IOS', query['filter[preReleaseVersion.platform]'] + assert_equal '1.6.0', query['filter[preReleaseVersion.version]'] + assert_equal '2610.0710.1234', query['filter[version]'] + end + + def test_macos_uses_its_own_platform_filter + client = Client.new([build]) + capture_io { release(client, platform: 'MAC_OS').run } + assert_equal 'MAC_OS', client.requests.first.last['filter[preReleaseVersion.platform]'] + end + + def test_rejected_build_cannot_be_submitted + %w[FAILED INVALID].each do |state| + error = assert_raises(AppStoreConnectError) do + capture_io { release(Client.new([build(state)])).run } + end + assert_match(/Apple rejected build/, error.message) + end + end + + def test_internal_only_build_cannot_be_submitted + assert_raises(AppStoreConnectError) do + capture_io { release(Client.new([build('VALID', 'INTERNAL_ONLY')])).run } + end + end + + def test_absent_and_processing_builds_time_out + [[], [build('PROCESSING')]].each do |builds| + error = assert_raises(AppStoreConnectError) do + capture_io { release(Client.new(builds)).run } + end + assert_match(/Timed out/, error.message) + end + end + + def test_validate_only_makes_no_api_requests + client = Client.new([]) + capture_io { release(client, validate_only: true).run } + assert_empty client.requests + end +end diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index f5df2c146..937962a6b 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -30,10 +30,6 @@ on: required: true APP_STORE_CONNECT_PRIVATE_KEY: required: true - XCODE_CLOUD_IOS_WORKFLOW_ID: - required: true - XCODE_CLOUD_MACOS_WORKFLOW_ID: - required: true workflow_dispatch: inputs: ref: diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml index d279a1ddd..0056cedc2 100644 --- a/.github/workflows/ios_build_and_deploy.yaml +++ b/.github/workflows/ios_build_and_deploy.yaml @@ -16,6 +16,11 @@ on: required: false default: "after_approval" type: string + submit_for_review: + description: "Submit after upload; disable to validate a build without publishing" + required: false + default: true + type: boolean secrets: APP_STORE_CONNECT_APP_ID: required: true @@ -25,8 +30,6 @@ on: required: true APP_STORE_CONNECT_PRIVATE_KEY: required: true - XCODE_CLOUD_IOS_WORKFLOW_ID: - required: true workflow_dispatch: inputs: ref: @@ -34,6 +37,11 @@ on: required: true default: "main" type: string + submit_for_review: + description: "Submit the uploaded build to App Review" + required: true + default: true + type: boolean release_mode: description: "When to publish after App Review" required: true @@ -80,22 +88,30 @@ jobs: GH_TOKEN: ${{ github.token }} submit_ios: - name: Build in Xcode Cloud and submit iOS + name: Build on GitHub and submit iOS needs: prepare_release if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: ubuntu-latest - timeout-minutes: 130 + runs-on: macos-15 + timeout-minutes: 120 env: RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + path: workflow-source + - name: Checkout release tag uses: actions/checkout@v6 with: ref: ${{ env.RELEASE_TAG }} + path: release-source fetch-depth: 0 - name: Validate release inputs + working-directory: release-source id: release shell: bash run: | @@ -133,8 +149,7 @@ jobs: esac echo "version=$public_version" >> "$GITHUB_OUTPUT" - echo "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "notes_dir=$notes_dir" >> "$GITHUB_OUTPUT" + echo "notes_dir=release-source/$notes_dir" >> "$GITHUB_OUTPUT" echo "release_type=$release_type" >> "$GITHUB_OUTPUT" - name: Set up Ruby @@ -142,33 +157,49 @@ jobs: with: ruby-version: 3.2.3 - - name: Start Xcode Cloud and submit to App Review + - name: Build and upload iOS + id: upload + uses: ./workflow-source/.github/actions/apple-build-upload + with: + platform: ios + app-dir: release-source/open_wearable + env: + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + + - name: Wait for processing and submit to App Review env: APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - XCODE_CLOUD_WORKFLOW_ID: ${{ secrets.XCODE_CLOUD_IOS_WORKFLOW_ID }} + SUBMIT_FOR_REVIEW: ${{ inputs.submit_for_review }} + BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} + PUBLIC_VERSION: ${{ steps.release.outputs.version }} + NOTES_DIR: ${{ steps.release.outputs.notes_dir }} + RELEASE_TYPE: ${{ steps.release.outputs.release_type }} run: | - ruby .github/scripts/app_store_connect_release.rb \ - --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \ + args=() + if [ "$SUBMIT_FOR_REVIEW" != true ]; then args+=(--process-only); fi + ruby workflow-source/.github/scripts/app_store_connect_release.rb \ --app-id "$APP_STORE_CONNECT_APP_ID" \ --platform IOS \ - --version "${{ steps.release.outputs.version }}" \ - --tag "$RELEASE_TAG" \ - --git-sha "${{ steps.release.outputs.git_sha }}" \ - --release-notes-dir "${{ steps.release.outputs.notes_dir }}" \ - --release-type "${{ steps.release.outputs.release_type }}" + --version "$PUBLIC_VERSION" \ + --build-number "$BUILD_NUMBER" \ + --release-notes-dir "$NOTES_DIR" \ + --release-type "$RELEASE_TYPE" "${args[@]}" - name: Summarize submission + if: ${{ inputs.submit_for_review }} run: | printf 'Submitted iOS **%s** to App Review with release mode **%s**.\n' \ "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY" publish_github_release: name: Publish GitHub Release - needs: submit_ios - if: ${{ inputs.ref != '' && needs.submit_ios.result == 'success' }} + needs: [prepare_release, submit_ios] + if: ${{ inputs.ref != '' && inputs.submit_for_review && needs.submit_ios.result == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout workflow source diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml index 16e44c604..2f1887995 100644 --- a/.github/workflows/macos_build_and_deploy.yaml +++ b/.github/workflows/macos_build_and_deploy.yaml @@ -16,6 +16,11 @@ on: required: false default: "after_approval" type: string + submit_for_review: + description: "Submit after upload; disable to validate a build without publishing" + required: false + default: true + type: boolean secrets: APP_STORE_CONNECT_APP_ID: required: true @@ -25,8 +30,6 @@ on: required: true APP_STORE_CONNECT_PRIVATE_KEY: required: true - XCODE_CLOUD_MACOS_WORKFLOW_ID: - required: true workflow_dispatch: inputs: ref: @@ -34,6 +37,11 @@ on: required: true default: "main" type: string + submit_for_review: + description: "Submit the uploaded build to App Review" + required: true + default: true + type: boolean release_mode: description: "When to publish after App Review" required: true @@ -80,22 +88,30 @@ jobs: GH_TOKEN: ${{ github.token }} submit_macos: - name: Build in Xcode Cloud and submit macOS + name: Build on GitHub and submit macOS needs: prepare_release if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: ubuntu-latest - timeout-minutes: 130 + runs-on: macos-15 + timeout-minutes: 120 env: RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + path: workflow-source + - name: Checkout release tag uses: actions/checkout@v6 with: ref: ${{ env.RELEASE_TAG }} + path: release-source fetch-depth: 0 - name: Validate release inputs + working-directory: release-source id: release shell: bash run: | @@ -133,8 +149,7 @@ jobs: esac echo "version=$public_version" >> "$GITHUB_OUTPUT" - echo "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "notes_dir=$notes_dir" >> "$GITHUB_OUTPUT" + echo "notes_dir=release-source/$notes_dir" >> "$GITHUB_OUTPUT" echo "release_type=$release_type" >> "$GITHUB_OUTPUT" - name: Set up Ruby @@ -142,33 +157,49 @@ jobs: with: ruby-version: 3.2.3 - - name: Start Xcode Cloud and submit to App Review + - name: Build and upload macOS + id: upload + uses: ./workflow-source/.github/actions/apple-build-upload + with: + platform: macos + app-dir: release-source/open_wearable + env: + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + + - name: Wait for processing and submit to App Review env: APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - XCODE_CLOUD_WORKFLOW_ID: ${{ secrets.XCODE_CLOUD_MACOS_WORKFLOW_ID }} + SUBMIT_FOR_REVIEW: ${{ inputs.submit_for_review }} + BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} + PUBLIC_VERSION: ${{ steps.release.outputs.version }} + NOTES_DIR: ${{ steps.release.outputs.notes_dir }} + RELEASE_TYPE: ${{ steps.release.outputs.release_type }} run: | - ruby .github/scripts/app_store_connect_release.rb \ - --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \ + args=() + if [ "$SUBMIT_FOR_REVIEW" != true ]; then args+=(--process-only); fi + ruby workflow-source/.github/scripts/app_store_connect_release.rb \ --app-id "$APP_STORE_CONNECT_APP_ID" \ --platform MAC_OS \ - --version "${{ steps.release.outputs.version }}" \ - --tag "$RELEASE_TAG" \ - --git-sha "${{ steps.release.outputs.git_sha }}" \ - --release-notes-dir "${{ steps.release.outputs.notes_dir }}" \ - --release-type "${{ steps.release.outputs.release_type }}" + --version "$PUBLIC_VERSION" \ + --build-number "$BUILD_NUMBER" \ + --release-notes-dir "$NOTES_DIR" \ + --release-type "$RELEASE_TYPE" "${args[@]}" - name: Summarize submission + if: ${{ inputs.submit_for_review }} run: | printf 'Submitted macOS **%s** to App Review with release mode **%s**.\n' \ "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY" publish_github_release: name: Publish GitHub Release - needs: submit_macos - if: ${{ inputs.ref != '' && needs.submit_macos.result == 'success' }} + needs: [prepare_release, submit_macos] + if: ${{ inputs.ref != '' && inputs.submit_for_review && needs.submit_macos.result == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout workflow source diff --git a/.github/workflows/pr_build_all_platforms.yml b/.github/workflows/pr_build_all_platforms.yml index 22dc3d47f..9a924a532 100644 --- a/.github/workflows/pr_build_all_platforms.yml +++ b/.github/workflows/pr_build_all_platforms.yml @@ -21,9 +21,21 @@ env: APP_DIR: open_wearable jobs: + validate_release_scripts: + name: Validate Apple release scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.3 + - run: ruby .github/scripts/test/app_store_connect_release_test.rb + - run: bash -n .github/scripts/apple_archive.sh + build: name: Build ${{ matrix.name }} runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -35,6 +47,24 @@ jobs: artifact_name: android-apk artifact_path: open_wearable/build/app/outputs/flutter-apk/app-release.apk android: true + - target: ios + name: iOS + os: macos-15 + build_command: flutter build ios --release --no-codesign + artifact_name: ios-unsigned + artifact_path: open_wearable/build/ios/iphoneos + apple: true + - target: macos + name: macOS + os: macos-15 + build_command: >- + flutter build macos --release --config-only && + xcodebuild -workspace macos/Runner.xcworkspace -scheme Runner + -configuration Release -destination 'generic/platform=macOS' + -derivedDataPath build/macos-pr CODE_SIGNING_ALLOWED=NO build + artifact_name: macos-unsigned + artifact_path: open_wearable/build/macos-pr/Build/Products/Release/*.app + apple: true - target: linux name: Linux os: ubuntu-latest @@ -59,6 +89,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Select Xcode + if: ${{ matrix.apple == true }} + run: | + sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + xcodebuild -version + - name: Install Linux build dependencies if: ${{ matrix.linux_dependencies == true }} run: | @@ -85,6 +121,13 @@ jobs: with: app-dir: ${{ env.APP_DIR }} + - name: Install macOS pods + if: ${{ matrix.target == 'macos' }} + working-directory: open_wearable + run: | + flutter build macos --release --config-only + cd macos && pod install + - name: Build ${{ matrix.name }} artifact working-directory: ${{ env.APP_DIR }} run: ${{ matrix.build_command }} @@ -115,6 +158,8 @@ jobs: const repoUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}`; const artifactLabels = new Map([ ['android-apk', 'Android APK'], + ['ios-unsigned', 'iOS unsigned build'], + ['macos-unsigned', 'macOS unsigned build'], ['linux-bundle', 'Linux Bundle'], ['windows-runner', 'Windows Runner'], ['web-bundle', 'Web Bundle'], diff --git a/open_wearable/docs/apple-builds.md b/open_wearable/docs/apple-builds.md new file mode 100644 index 000000000..4a30a2ed4 --- /dev/null +++ b/open_wearable/docs/apple-builds.md @@ -0,0 +1,55 @@ +# Apple builds and releases + +GitHub Actions builds iOS and macOS on standard `macos-15` runners with Xcode +26.2 and the Flutter version pinned in `.flutter_version`. Android, Linux, +Windows, and web continue using their existing GitHub runners. + +## Pull requests + +**Build PR Artifacts** compiles both Apple platforms without signing credentials. +These artifacts validate compilation; the unsigned iOS app cannot be installed +on a physical iPhone. Fork PRs do not receive Apple secrets. + +## App Store releases + +Use **Release All Platforms**, **Apple App Store Build and Submit**, or either +platform's individual release workflow. The workflows keep the existing +immutable version tag and release-note validation. Build scripts come from the +workflow revision, while app source comes from the release tag, so a CI fix can +release an existing tag without changing its contents. + +The GitHub Mac archives the app, then Xcode signs and uploads it directly to App +Store Connect. Automatic signing uses the existing API key and Apple's managed +distribution certificates. This signing service does not consume Xcode Cloud +build hours. The key must have access to signing resources and app submission. + +The existing repository secrets are sufficient when that access is granted: + +- `APP_STORE_CONNECT_APP_ID` +- `APP_STORE_CONNECT_ISSUER_ID` +- `APP_STORE_CONNECT_KEY_ID` +- `APP_STORE_CONNECT_PRIVATE_KEY` (PEM or base64-encoded PEM) + +Each upload receives an increasing UTC build number in `YYMM.DDHH.MMSS` form +(with leading zeroes removed). Retries therefore do not reuse a previously +uploaded build number. Processing selects the exact app, platform, public +version, and build number, and rejects failed or internal-only builds before +submission. Processing has a 45-minute deadline with live progress output. + +By default releases are submitted to App Review and publish after approval. +`release_mode: manual` preserves manual publication. To validate signing and +upload without submitting, run the individual iOS/macOS workflow with +`submit_for_review: false`. This still uploads a build and waits for Apple to +process it, but does not change the App Store version or publish a GitHub release. + +Build logs and Xcode result bundles are retained in GitHub for seven days. API +key files stay outside artifacts and are removed after upload. + +## Migration from Xcode Cloud + +Disable the app's old PR and release workflows in App Store Connect once the +replacement GitHub builds are verified. Otherwise Apple's separately configured +PR triggers will continue creating duplicate builds. The old +`XCODE_CLOUD_IOS_WORKFLOW_ID` and `XCODE_CLOUD_MACOS_WORKFLOW_ID` secrets are no +longer used. Apple processing and App Review remain required after upload; +Xcode Cloud build capacity is no longer involved. From c11aecaccc4d0ba7b21691a83ab482308879ef73 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 09:49:02 +0200 Subject: [PATCH 2/8] ci(apple): migrate main-branch TestFlight distribution --- open_wearable/ios/ci_scripts/ci_post_clone.sh | 47 ------------------- .../macos/ci_scripts/ci_post_clone.sh | 45 ------------------ 2 files changed, 92 deletions(-) delete mode 100755 open_wearable/ios/ci_scripts/ci_post_clone.sh delete mode 100755 open_wearable/macos/ci_scripts/ci_post_clone.sh diff --git a/open_wearable/ios/ci_scripts/ci_post_clone.sh b/open_wearable/ios/ci_scripts/ci_post_clone.sh deleted file mode 100755 index 2b6fb96b9..000000000 --- a/open_wearable/ios/ci_scripts/ci_post_clone.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash - -# Fail this script if any subcommand fails. -set -e - -FLUTTER_VERSION=$(cat "$CI_WORKSPACE_PATH/repository/open_wearable/.flutter_version") -FLUTTER_URL="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_${FLUTTER_VERSION}-stable.zip" - -echo "🟩 Install Flutter $FLUTTER_VERSION" -cd $HOME -time curl -L $FLUTTER_URL -o flutter.zip -unzip flutter.zip -export PATH="$PATH:$HOME/flutter/bin" - -echo "🟩 Verify Flutter Installation" -flutter --version - -echo "🟩 Flutter Precache" -time flutter precache --ios - -# by default, the execution directory of this script is the ci_scripts directory -# CI_WORKSPACE is the directory of your cloned repo -echo "🟩 Navigate from ($PWD) to ($CI_WORKSPACE_PATH)" -cd $CI_WORKSPACE_PATH - -echo "🟩 Install Flutter Dependencies" -cd repository/open_wearable -time flutter clean -time flutter pub get - -echo "🟩 Install CocoaPods via Homebrew" -time HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods - -echo "🟩 Install CocoaPods dependencies..." -time cd ios && pod install -cd ../ - -echo "🟩 Prepare iOS Flutter/Xcode project" -# Generate Flutter ephemeral files and Xcode config. -# The actual signed archive/build is performed later by Xcode Cloud. -build_args=(ios --release --config-only) -if [[ -n "${CI_BUILD_NUMBER:-}" ]]; then - build_args+=(--build-number="$CI_BUILD_NUMBER") -fi -time flutter build "${build_args[@]}" - -exit 0 diff --git a/open_wearable/macos/ci_scripts/ci_post_clone.sh b/open_wearable/macos/ci_scripts/ci_post_clone.sh deleted file mode 100755 index 2eb046db9..000000000 --- a/open_wearable/macos/ci_scripts/ci_post_clone.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash - - -set -e - -FLUTTER_VERSION=$(cat "$CI_WORKSPACE_PATH/repository/open_wearable/.flutter_version") -FLUTTER_URL="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_${FLUTTER_VERSION}-stable.zip" - -echo "🟩 Install Flutter $FLUTTER_VERSION" -cd $HOME -time curl -L $FLUTTER_URL -o flutter.zip -unzip flutter.zip -export PATH="$PATH:$HOME/flutter/bin" - -echo "🟩 Verify Flutter Installation" -flutter --version - -echo "🟩 Flutter Precache" -time flutter precache --macos - -echo "🟩 Navigate from ($PWD) to ($CI_WORKSPACE_PATH)" -cd $CI_WORKSPACE_PATH - -echo "🟩 Install Flutter Dependencies" -cd repository/open_wearable -time flutter clean -time flutter pub get - -echo "🟩 Install CocoaPods via Homebrew" -time HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods - -echo "🟩 Install CocoaPods dependencies..." -time cd macos && pod install -cd ../ - -echo "🟩 Prepare macOS Flutter/Xcode project" -# Generate Flutter ephemeral files and Xcode config. -# The actual signed archive/build is performed later by Xcode Cloud. -build_args=(macos --release --config-only) -if [[ -n "${CI_BUILD_NUMBER:-}" ]]; then - build_args+=(--build-number="$CI_BUILD_NUMBER") -fi -time flutter build "${build_args[@]}" - -exit 0 From f017e49933853a407b4bc79cddb835154f3fee00 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 09:49:22 +0200 Subject: [PATCH 3/8] ci(apple): preserve automatic TestFlight uploads --- .github/scripts/app_store_connect_release.rb | 53 +++++++++- .../test/app_store_connect_release_test.rb | 60 +++++++++++ .github/workflows/apple_testflight.yml | 99 +++++++++++++++++++ .github/workflows/pr_build_all_platforms.yml | 7 ++ open_wearable/docs/apple-builds.md | 12 +++ 5 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/apple_testflight.yml diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb index 9d8a5c244..6663e2ed9 100644 --- a/.github/scripts/app_store_connect_release.rb +++ b/.github/scripts/app_store_connect_release.rb @@ -155,6 +155,8 @@ def run build = wait_for_processed_build return puts("Uploaded build is ready; App Review submission was not requested") if @options[:process_only] + return distribute_testflight(build) unless Array(@options[:testflight_groups]).empty? + version = find_or_create_version if submitted?(version) @@ -226,6 +228,53 @@ def wait_for_processed_build end end + # Keep the same explicit tester groups as the retired main-branch workflows. + # Beta App Review is separate from public App Store review. + def distribute_testflight(build) + groups = @client.get("/v1/apps/#{@options[:app_id]}/betaGroups", "limit" => 200).fetch("data") + selected = @options[:testflight_groups].map do |name| + matches = groups.select { |group| group.dig("attributes", "name") == name } + raise AppStoreConnectError, "Expected exactly one TestFlight group named #{name}" unless matches.length == 1 + + matches.first + end + build_id = build.fetch("id") + notes = File.read(File.join(@options[:release_notes_dir], "default.txt"), encoding: "UTF-8").strip + localizations = @client.get("/v1/builds/#{build_id}/betaBuildLocalizations").fetch("data") + if localizations.empty? + @client.post("/v1/betaBuildLocalizations", data: { + type: "betaBuildLocalizations", attributes: { locale: "en-US", whatsNew: notes }, + relationships: { build: { data: { type: "builds", id: build_id } } } + }) + else + localizations.each do |localization| + @client.patch("/v1/betaBuildLocalizations/#{localization.fetch('id')}", data: { + type: "betaBuildLocalizations", id: localization.fetch("id"), attributes: { whatsNew: notes } + }) + end + end + + if selected.any? { |group| !group.dig("attributes", "isInternalGroup") } + detail = @client.get("/v1/builds/#{build_id}/buildBetaDetail").fetch("data") + @client.patch("/v1/buildBetaDetails/#{detail.fetch('id')}", data: { + type: "buildBetaDetails", id: detail.fetch("id"), attributes: { autoNotifyEnabled: true } + }) + state = detail.dig("attributes", "externalBuildState") + if state == "READY_FOR_BETA_SUBMISSION" + @client.post("/v1/betaAppReviewSubmissions", data: { + type: "betaAppReviewSubmissions", + relationships: { build: { data: { type: "builds", id: build_id } } } + }) + elsif !%w[WAITING_FOR_BETA_REVIEW IN_BETA_REVIEW BETA_APPROVED READY_FOR_BETA_TESTING IN_BETA_TESTING].include?(state) + raise AppStoreConnectError, "TestFlight build cannot be distributed: #{state}" + end + end + @client.post("/v1/builds/#{build_id}/relationships/betaGroups", data: selected.map do |group| + { type: "betaGroups", id: group.fetch("id") } + end) + puts "Assigned #{@options[:platform]} build #{@options[:build_number]} to #{@options[:testflight_groups].join(', ')}" + end + def find_or_create_version response = @client.get( "/v1/apps/#{@options[:app_id]}/appStoreVersions", @@ -437,7 +486,8 @@ def run_release_cli options = { timeout: 2700, release_type: "AFTER_APPROVAL", - validate_only: false + validate_only: false, + testflight_groups: [] } OptionParser.new do |parser| @@ -451,6 +501,7 @@ def run_release_cli parser.on("--timeout SECONDS", Integer) { |value| options[:timeout] = value } parser.on("--validate-only") { options[:validate_only] = true } parser.on("--process-only") { options[:process_only] = true } + parser.on("--testflight-group NAME") { |value| options[:testflight_groups] << value } end.parse! required = %i[platform version release_notes_dir] diff --git a/.github/scripts/test/app_store_connect_release_test.rb b/.github/scripts/test/app_store_connect_release_test.rb index dbd2954b3..1b9da4b2a 100644 --- a/.github/scripts/test/app_store_connect_release_test.rb +++ b/.github/scripts/test/app_store_connect_release_test.rb @@ -90,4 +90,64 @@ def test_validate_only_makes_no_api_requests capture_io { release(client, validate_only: true).run } assert_empty client.requests end + class BetaClient < Client + attr_reader :mutations + def initialize(builds, groups:, state: 'READY_FOR_BETA_SUBMISSION') + super(builds) + @groups = groups + @state = state + @mutations = [] + end + def get(path, query = {}) + case path + when '/v1/builds' then super + when '/v1/apps/app-id/betaGroups' then { 'data' => @groups } + when '/v1/builds/uploaded-build/betaBuildLocalizations' then { 'data' => [] } + when '/v1/builds/uploaded-build/buildBetaDetail' + { 'data' => { 'id' => 'beta-detail', 'attributes' => { 'externalBuildState' => @state } } } + else raise "Unexpected API request: #{path}" + end + end + def post(path, body) + @mutations << [:post, path, body] + {} + end + def patch(path, body) + @mutations << [:patch, path, body] + {} + end + end + + def beta_groups + [ + { 'id' => 'internal', 'attributes' => { 'name' => 'Internal Testing', 'isInternalGroup' => true } }, + { 'id' => 'external', 'attributes' => { 'name' => 'External Testing', 'isInternalGroup' => false } } + ] + end + + def test_testflight_uses_only_existing_groups_and_beta_review + client = BetaClient.new([build], groups: beta_groups) + capture_io do + release(client, process_only: false, testflight_groups: ['Internal Testing', 'External Testing']).run + end + assert client.mutations.any? { |_, path, _| path == '/v1/betaAppReviewSubmissions' } + assert_equal '/v1/builds/uploaded-build/relationships/betaGroups', client.mutations.last[1] + assert_equal %w[internal external], client.mutations.last[2][:data].map { |group| group[:id] } + refute client.mutations.any? { |_, path, _| path.include?('appStoreVersions') || path.include?('reviewSubmissions') } + end + + def test_missing_testflight_group_fails_without_changing_apple + client = BetaClient.new([build], groups: beta_groups) + assert_raises(AppStoreConnectError) do + capture_io { release(client, process_only: false, testflight_groups: ['Wrong group']).run } + end + assert_empty client.mutations + end + + def test_approved_beta_is_not_resubmitted + client = BetaClient.new([build], groups: beta_groups, state: 'IN_BETA_TESTING') + capture_io { release(client, process_only: false, testflight_groups: ['External Testing']).run } + refute client.mutations.any? { |_, path, _| path == '/v1/betaAppReviewSubmissions' } + end + end diff --git a/.github/workflows/apple_testflight.yml b/.github/workflows/apple_testflight.yml new file mode 100644 index 000000000..583989a9b --- /dev/null +++ b/.github/workflows/apple_testflight.yml @@ -0,0 +1,99 @@ +name: Apple TestFlight + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + ref: + description: Branch, tag or commit to upload + type: string + required: true + default: main + distribute: + description: Distribute to the existing Internal Testing and External Testing groups + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: apple-testflight-${{ github.ref }} + cancel-in-progress: true + +jobs: + testflight: + name: TestFlight ${{ matrix.platform }} + runs-on: macos-15 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - platform: ios + api_platform: IOS + - platform: macos + api_platform: MAC_OS + steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + path: workflow-source + + - name: Checkout app source + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + path: release-source + + - name: Read app version and release notes + id: release + working-directory: release-source + run: | + set -euo pipefail + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) + version=${version//\"/} + version=${version//\'/} + version=${version%%+*} + notes="open_wearable/release_notes/$version" + if [ ! -s "$notes/default.txt" ]; then notes=open_wearable/release_notes; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "notes_dir=release-source/$notes" >> "$GITHUB_OUTPUT" + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.3 + + - name: Build and upload + id: upload + uses: ./workflow-source/.github/actions/apple-build-upload + with: + platform: ${{ matrix.platform }} + app-dir: release-source/open_wearable + env: + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + + - name: Process and distribute TestFlight build + env: + APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + DISTRIBUTE: ${{ github.event_name == 'push' || inputs.distribute }} + PLATFORM: ${{ matrix.api_platform }} + PUBLIC_VERSION: ${{ steps.release.outputs.version }} + BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} + NOTES_DIR: ${{ steps.release.outputs.notes_dir }} + run: | + args=(--process-only) + if [ "$DISTRIBUTE" = true ]; then + args=(--testflight-group 'Internal Testing' --testflight-group 'External Testing') + fi + ruby workflow-source/.github/scripts/app_store_connect_release.rb \ + --app-id "$APP_STORE_CONNECT_APP_ID" --platform "$PLATFORM" \ + --version "$PUBLIC_VERSION" --build-number "$BUILD_NUMBER" \ + --release-notes-dir "$NOTES_DIR" "${args[@]}" diff --git a/.github/workflows/pr_build_all_platforms.yml b/.github/workflows/pr_build_all_platforms.yml index 9a924a532..b696b7980 100644 --- a/.github/workflows/pr_build_all_platforms.yml +++ b/.github/workflows/pr_build_all_platforms.yml @@ -121,6 +121,13 @@ jobs: with: app-dir: ${{ env.APP_DIR }} + - name: Analyze and test Flutter + if: ${{ matrix.target == 'ios' }} + working-directory: open_wearable + run: | + flutter analyze + flutter test + - name: Install macOS pods if: ${{ matrix.target == 'macos' }} working-directory: open_wearable diff --git a/open_wearable/docs/apple-builds.md b/open_wearable/docs/apple-builds.md index 4a30a2ed4..8a3edeb3a 100644 --- a/open_wearable/docs/apple-builds.md +++ b/open_wearable/docs/apple-builds.md @@ -10,6 +10,18 @@ Windows, and web continue using their existing GitHub runners. These artifacts validate compilation; the unsigned iOS app cannot be installed on a physical iPhone. Fork PRs do not receive Apple secrets. +## TestFlight + +**Apple TestFlight** archives both platforms on each push to `main`, then submits +for Beta App Review when required and assigns the builds to the existing +**Internal Testing** and **External Testing** groups. This preserves the old +Xcode Cloud main-branch distribution settings, including automatic notification +when Apple makes an external build available. It does not submit a public App +Store release. + +The workflow can also be run manually against a chosen ref. Manual runs default +to upload-only validation; enable `distribute` to send the build to those groups. + ## App Store releases Use **Release All Platforms**, **Apple App Store Build and Submit**, or either From 18c27b8f5da39b244592142052d2e2055fb76c33 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 09:56:27 +0200 Subject: [PATCH 4/8] ci(apple): document signing access and preserve upload errors --- .github/scripts/apple_archive.sh | 4 ++-- open_wearable/docs/apple-builds.md | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/scripts/apple_archive.sh b/.github/scripts/apple_archive.sh index da75e5a55..f94616d96 100644 --- a/.github/scripts/apple_archive.sh +++ b/.github/scripts/apple_archive.sh @@ -31,7 +31,7 @@ xcodebuild archive -hideShellScriptEnvironment \ -archivePath "$output_dir/Runner.xcarchive" \ -resultBundlePath "$output_dir/archive.xcresult" \ CODE_SIGNING_ALLOWED=NO \ - | tee "$output_dir/archive.log" + 2>&1 | tee "$output_dir/archive.log" # Only the ephemeral runner sees this file. It is never included in artifacts. key_dir=$(mktemp -d "$RUNNER_TEMP/apple-api.XXXXXX") @@ -62,4 +62,4 @@ xcodebuild -exportArchive \ -authenticationKeyPath "$APPLE_API_KEY_PATH" \ -authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \ -authenticationKeyIssuerID "$APP_STORE_CONNECT_ISSUER_ID" \ - | tee "$output_dir/upload.log" + 2>&1 | tee "$output_dir/upload.log" diff --git a/open_wearable/docs/apple-builds.md b/open_wearable/docs/apple-builds.md index 8a3edeb3a..72a0d6e92 100644 --- a/open_wearable/docs/apple-builds.md +++ b/open_wearable/docs/apple-builds.md @@ -35,7 +35,10 @@ Store Connect. Automatic signing uses the existing API key and Apple's managed distribution certificates. This signing service does not consume Xcode Cloud build hours. The key must have access to signing resources and app submission. -The existing repository secrets are sufficient when that access is granted: +Use an Admin App Store Connect team API key for managed signing. The previous +App Manager release key can submit builds but Apple rejects its cloud-signing +requests. Update the key ID and private key together in the existing secrets; +the issuer and app ID remain unchanged: - `APP_STORE_CONNECT_APP_ID` - `APP_STORE_CONNECT_ISSUER_ID` @@ -59,9 +62,9 @@ key files stay outside artifacts and are removed after upload. ## Migration from Xcode Cloud -Disable the app's old PR and release workflows in App Store Connect once the -replacement GitHub builds are verified. Otherwise Apple's separately configured -PR triggers will continue creating duplicate builds. The old +The six old PR, TestFlight, and release workflows were disabled in App Store +Connect during migration. Their definitions and history remain available for +reference; keep them disabled to avoid duplicate builds. The old `XCODE_CLOUD_IOS_WORKFLOW_ID` and `XCODE_CLOUD_MACOS_WORKFLOW_ID` secrets are no longer used. Apple processing and App Review remain required after upload; Xcode Cloud build capacity is no longer involved. From 30f11f173779a1c8e62271bd58a0d191cc38c046 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 10:19:56 +0200 Subject: [PATCH 5/8] ci: consolidate app workflows into four entry points --- .../workflows/android_build_and_deploy.yaml | 319 ------------ .../workflows/app_store_build_and_deploy.yaml | 150 ------ .github/workflows/apple_testflight.yml | 99 ---- .github/workflows/deploy_web.yml | 16 +- .github/workflows/firebase-hosting-merge.yml | 25 - .../firebase-hosting-pull-request.yml | 51 -- .github/workflows/ios_build_and_deploy.yaml | 224 --------- .github/workflows/macos_build_and_deploy.yaml | 224 --------- .github/workflows/pr_build_all_platforms.yml | 32 +- .github/workflows/release_all_platforms.yaml | 465 +++++++++++++++--- .github/workflows/release_apk.yml | 121 ++++- open_wearable/docs/apple-builds.md | 37 +- 12 files changed, 589 insertions(+), 1174 deletions(-) delete mode 100644 .github/workflows/android_build_and_deploy.yaml delete mode 100644 .github/workflows/app_store_build_and_deploy.yaml delete mode 100644 .github/workflows/apple_testflight.yml delete mode 100644 .github/workflows/firebase-hosting-merge.yml delete mode 100644 .github/workflows/firebase-hosting-pull-request.yml delete mode 100644 .github/workflows/ios_build_and_deploy.yaml delete mode 100644 .github/workflows/macos_build_and_deploy.yaml diff --git a/.github/workflows/android_build_and_deploy.yaml b/.github/workflows/android_build_and_deploy.yaml deleted file mode 100644 index 181f09807..000000000 --- a/.github/workflows/android_build_and_deploy.yaml +++ /dev/null @@ -1,319 +0,0 @@ -name: Android Build and Deploy to Play Store - -on: - workflow_call: - inputs: - ref: - description: "Branch or tag to deploy" - required: true - type: string - release_type: - description: "Google Play track" - required: true - type: string - publish_github_release: - description: "Create the version tag and GitHub Release" - required: false - default: true - type: boolean - create_version_bump_pr: - description: "Open the post-release version bump PR" - required: false - default: true - type: boolean - version_bump_base_ref: - description: "Branch to target with the post-release version bump PR" - required: false - default: "" - type: string - outputs: - released_sha: - value: ${{ jobs.deploy_for_android.outputs.released_sha }} - released_version: - value: ${{ jobs.deploy_for_android.outputs.released_version }} - public_version: - value: ${{ jobs.deploy_for_android.outputs.public_version }} - release_tag: - value: ${{ jobs.deploy_for_android.outputs.release_tag }} - next_version: - value: ${{ jobs.deploy_for_android.outputs.next_version }} - secrets: - OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH__FILE: - required: true - OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: - required: true - OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH__FILE: - required: true - workflow_dispatch: - inputs: - ref: - description: "Branch or tag to deploy" - required: true - default: "main" - type: string - release_type: - description: "Google Play track" - required: true - default: "internal" - type: choice - options: - - internal - - production - publish_github_release: - description: "Create the version tag and GitHub Release for production" - required: true - default: true - type: boolean - create_version_bump_pr: - description: "Open a version bump PR after production" - required: true - default: true - type: boolean - version_bump_base_ref: - description: "Branch to target with the post-release version bump PR" - required: false - default: "" - type: string - -permissions: - contents: read - -jobs: - deploy_for_android: - runs-on: ubuntu-latest - outputs: - released_sha: ${{ steps.version.outputs.released_sha }} - released_version: ${{ steps.version.outputs.released_version }} - public_version: ${{ steps.version.outputs.public_version }} - release_tag: ${{ steps.version.outputs.release_tag }} - next_version: ${{ steps.version.outputs.next_version }} - release_notes_path: ${{ steps.version.outputs.release_notes_path }} - env: - RELEASE_TYPE: ${{ inputs.release_type }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - - - name: Validate release version - id: version - shell: bash - run: | - set -euo pipefail - - version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) - version=${version//\"/} - version=${version//\'/} - version_pattern='^([0-9]+)\.([0-9]+)\.([0-9]+)(\+[0-9]+)?$' - if ! [[ "$version" =~ $version_pattern ]]; then - echo "::error::Version '$version' must use major.minor.patch with an optional numeric +build suffix." - exit 1 - fi - - major=${BASH_REMATCH[1]} - minor=${BASH_REMATCH[2]} - patch=${BASH_REMATCH[3]} - build_suffix=${BASH_REMATCH[4]} - public_version=${version%%+*} - next_version="${major}.${minor}.$((10#$patch + 1))${build_suffix}" - - if [ "$RELEASE_TYPE" = "production" ]; then - version_notes_path="open_wearable/release_notes/${public_version}/default.txt" - fallback_notes_path="open_wearable/release_notes/default.txt" - notes_path="$version_notes_path" - if [ ! -s "$notes_path" ]; then - notes_path="$fallback_notes_path" - echo "Using generic release notes because $version_notes_path is missing." - fi - if [ ! -s "$notes_path" ]; then - echo "::error::Missing version-specific release notes at $version_notes_path and fallback release notes at $fallback_notes_path." - exit 1 - fi - else - notes_path="" - fi - - echo "released_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "released_version=$version" >> "$GITHUB_OUTPUT" - echo "public_version=$public_version" >> "$GITHUB_OUTPUT" - echo "release_tag=v${public_version}" >> "$GITHUB_OUTPUT" - echo "next_version=$next_version" >> "$GITHUB_OUTPUT" - echo "release_notes_path=$notes_path" >> "$GITHUB_OUTPUT" - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2.3 - - - name: Set up Android build - uses: ./.github/actions/android-build-prepare - with: - app-dir: open_wearable - clean: "true" - analyze: "true" - - - name: Decode Android signing key - run: | - echo "${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH__FILE }}" | base64 --decode > android_keystore_temp.keystore - echo "OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH=$(realpath android_keystore_temp.keystore)" >> "$GITHUB_ENV" - - - name: Decode Google Play service account - run: | - echo "${{ secrets.OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH__FILE }}" | base64 --decode > android_deployment_key.json - echo "OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH=$(realpath android_deployment_key.json)" >> "$GITHUB_ENV" - - - name: Prepare Google Play release notes - if: ${{ inputs.release_type == 'production' }} - run: | - changelog_dir="open_wearable/android/fastlane/metadata/android/en-US/changelogs" - mkdir -p "$changelog_dir" - cp "${{ steps.version.outputs.release_notes_path }}" "$changelog_dir/default.txt" - - - name: Set up Fastlane - working-directory: open_wearable/android - run: bundle install - - - name: Build and deploy Android internal release - if: ${{ inputs.release_type == 'internal' }} - working-directory: open_wearable/android - run: bundle exec fastlane internal_deploy - env: - OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD }} - - - name: Build and deploy Android production release - if: ${{ inputs.release_type == 'production' }} - working-directory: open_wearable/android - run: bundle exec fastlane production_deploy - env: - OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD }} - - - name: Upload signed release APK - if: ${{ inputs.release_type == 'production' }} - uses: actions/upload-artifact@v6 - with: - name: android-release-${{ steps.version.outputs.public_version }} - path: open_wearable/build/app/outputs/apk/release/app-release.apk - if-no-files-found: error - - - name: Summarize successful Play Store upload - run: | - printf 'Successfully uploaded Android **%s** to the **%s** track on Google Play.\n' \ - "${{ steps.version.outputs.public_version }}" "$RELEASE_TYPE" >> "$GITHUB_STEP_SUMMARY" - - publish_github_release: - name: Tag version and publish GitHub Release - needs: deploy_for_android - if: ${{ inputs.release_type == 'production' && inputs.publish_github_release }} - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout released source - uses: actions/checkout@v6 - with: - ref: ${{ needs.deploy_for_android.outputs.released_sha }} - path: release-source - fetch-depth: 0 - - - name: Download signed APK - uses: actions/download-artifact@v7 - with: - name: android-release-${{ needs.deploy_for_android.outputs.public_version }} - path: release-apk - - - name: Create or verify immutable version tag - id: release - uses: ./workflow-source/.github/actions/prepare-versioned-release - with: - source-directory: release-source - env: - GH_TOKEN: ${{ github.token }} - - - name: Create or update GitHub Release - uses: ./workflow-source/.github/actions/publish-versioned-release - with: - source-directory: release-source - release-tag: ${{ steps.release.outputs.release_tag }} - env: - GH_TOKEN: ${{ github.token }} - - - name: Upload signed APK to GitHub Release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ steps.release.outputs.release_tag }} - PUBLIC_VERSION: ${{ needs.deploy_for_android.outputs.public_version }} - run: | - gh release upload "$RELEASE_TAG" \ - "release-apk/app-release.apk#OpenWearable-${PUBLIC_VERSION}.apk" \ - --repo "$GITHUB_REPOSITORY" \ - --clobber - - create_version_bump_pr: - name: Create version bump PR for review - needs: deploy_for_android - if: ${{ inputs.release_type == 'production' && inputs.create_version_bump_pr }} - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout released commit - uses: actions/checkout@v6 - with: - ref: ${{ needs.deploy_for_android.outputs.released_sha }} - - - name: Open version bump PR - shell: bash - env: - VERSION_FILE: open_wearable/pubspec.yaml - BASE_BRANCH: ${{ inputs.version_bump_base_ref || inputs.ref }} - RELEASED_VERSION: ${{ needs.deploy_for_android.outputs.released_version }} - NEXT_VERSION: ${{ needs.deploy_for_android.outputs.next_version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - bump_branch="automation/bump-version-${NEXT_VERSION}-${GITHUB_RUN_ID}" - commit_message="chore(version): bump version to ${NEXT_VERSION}" - pr_url=$(gh pr list \ - --base "$BASE_BRANCH" \ - --head "$bump_branch" \ - --state all \ - --json url \ - --jq '.[0].url // empty') - - if [ -z "$pr_url" ]; then - remote_branch=$(git ls-remote --heads origin "refs/heads/$bump_branch") - if [ -z "$remote_branch" ]; then - git switch -c "$bump_branch" - sed -i "s|^version:.*|version: $NEXT_VERSION|" "$VERSION_FILE" - git add "$VERSION_FILE" - git commit -m "$commit_message" - git push --set-upstream origin "$bump_branch" - fi - - body_file=$(mktemp) - printf '%s\n' \ - "The production Play Store upload of version $RELEASED_VERSION succeeded." \ - "" \ - "Bumps the app version to $NEXT_VERSION for the next release." \ - "Please review and merge this PR using the repository's normal approval process." \ - > "$body_file" - pr_url=$(gh pr create \ - --base "$BASE_BRANCH" \ - --head "$bump_branch" \ - --title "$commit_message" \ - --body-file "$body_file") - fi - - printf 'Version bump PR: %s\n' "$pr_url" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml deleted file mode 100644 index 937962a6b..000000000 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ /dev/null @@ -1,150 +0,0 @@ -name: Apple App Store Build and Submit - -on: - workflow_call: - inputs: - release_tag: - description: "Existing version tag to release" - required: true - type: string - ref: - description: "Optional branch or tag from which to create the release tag" - required: false - type: string - release_mode: - description: "When to publish after App Review" - required: false - default: "after_approval" - type: string - android_artifact_name: - description: "Optional signed Android APK artifact to attach to the GitHub Release" - required: false - default: "" - type: string - secrets: - APP_STORE_CONNECT_APP_ID: - required: true - APP_STORE_CONNECT_ISSUER_ID: - required: true - APP_STORE_CONNECT_KEY_ID: - required: true - APP_STORE_CONNECT_PRIVATE_KEY: - required: true - workflow_dispatch: - inputs: - ref: - description: "Branch or existing release tag to release" - required: true - default: "main" - type: string - release_mode: - description: "When to publish after App Review" - required: true - default: "after_approval" - type: choice - options: - - after_approval - - manual -permissions: - contents: write - -jobs: - prepare_release: - name: Create or verify release tag - if: ${{ inputs.ref != '' }} - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.release.outputs.release_tag }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release source - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - path: release-source - fetch-depth: 0 - - - name: Create or verify immutable version tag - id: release - uses: ./workflow-source/.github/actions/prepare-versioned-release - with: - source-directory: release-source - env: - GH_TOKEN: ${{ github.token }} - - submit_ios: - name: Build and submit iOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - uses: ./.github/workflows/ios_build_and_deploy.yaml - with: - release_tag: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - release_mode: ${{ inputs.release_mode }} - secrets: inherit - - submit_macos: - name: Build and submit macOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - uses: ./.github/workflows/macos_build_and_deploy.yaml - with: - release_tag: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - release_mode: ${{ inputs.release_mode }} - secrets: inherit - - publish_github_release: - name: Publish GitHub Release - needs: - - prepare_release - - submit_ios - - submit_macos - if: >- - ${{ always() && needs.submit_ios.result == 'success' && needs.submit_macos.result == 'success' && - (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: ubuntu-latest - env: - RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - path: release-source - fetch-depth: 0 - - - name: Create or update GitHub Release - uses: ./workflow-source/.github/actions/publish-versioned-release - with: - source-directory: release-source - release-tag: ${{ env.RELEASE_TAG }} - env: - GH_TOKEN: ${{ github.token }} - - - name: Download signed Android APK - if: ${{ inputs.android_artifact_name != '' }} - uses: actions/download-artifact@v7 - with: - name: ${{ inputs.android_artifact_name }} - path: release-apk - - - name: Upload signed Android APK to GitHub Release - if: ${{ inputs.android_artifact_name != '' }} - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ env.RELEASE_TAG }} - run: | - gh release upload "$RELEASE_TAG" \ - "release-apk/app-release.apk#OpenWearable-${RELEASE_TAG#v}.apk" \ - --repo "$GITHUB_REPOSITORY" \ - --clobber diff --git a/.github/workflows/apple_testflight.yml b/.github/workflows/apple_testflight.yml deleted file mode 100644 index 583989a9b..000000000 --- a/.github/workflows/apple_testflight.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: Apple TestFlight - -on: - push: - branches: [main] - workflow_dispatch: - inputs: - ref: - description: Branch, tag or commit to upload - type: string - required: true - default: main - distribute: - description: Distribute to the existing Internal Testing and External Testing groups - type: boolean - default: false - -permissions: - contents: read - -concurrency: - group: apple-testflight-${{ github.ref }} - cancel-in-progress: true - -jobs: - testflight: - name: TestFlight ${{ matrix.platform }} - runs-on: macos-15 - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - platform: ios - api_platform: IOS - - platform: macos - api_platform: MAC_OS - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - path: workflow-source - - - name: Checkout app source - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - path: release-source - - - name: Read app version and release notes - id: release - working-directory: release-source - run: | - set -euo pipefail - version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) - version=${version//\"/} - version=${version//\'/} - version=${version%%+*} - notes="open_wearable/release_notes/$version" - if [ ! -s "$notes/default.txt" ]; then notes=open_wearable/release_notes; fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "notes_dir=release-source/$notes" >> "$GITHUB_OUTPUT" - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2.3 - - - name: Build and upload - id: upload - uses: ./workflow-source/.github/actions/apple-build-upload - with: - platform: ${{ matrix.platform }} - app-dir: release-source/open_wearable - env: - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - - - name: Process and distribute TestFlight build - env: - APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - DISTRIBUTE: ${{ github.event_name == 'push' || inputs.distribute }} - PLATFORM: ${{ matrix.api_platform }} - PUBLIC_VERSION: ${{ steps.release.outputs.version }} - BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} - NOTES_DIR: ${{ steps.release.outputs.notes_dir }} - run: | - args=(--process-only) - if [ "$DISTRIBUTE" = true ]; then - args=(--testflight-group 'Internal Testing' --testflight-group 'External Testing') - fi - ruby workflow-source/.github/scripts/app_store_connect_release.rb \ - --app-id "$APP_STORE_CONNECT_APP_ID" --platform "$PLATFORM" \ - --version "$PUBLIC_VERSION" --build-number "$BUILD_NUMBER" \ - --release-notes-dir "$NOTES_DIR" "${args[@]}" diff --git a/.github/workflows/deploy_web.yml b/.github/workflows/deploy_web.yml index 3950f9456..9784f1045 100644 --- a/.github/workflows/deploy_web.yml +++ b/.github/workflows/deploy_web.yml @@ -1,16 +1,14 @@ -name: Deploy Flutter web +name: Deploy Web on: push: branches: - main - paths: - - ".github/workflows/deploy_web.yml" - - "open_wearable/**" workflow_dispatch: permissions: contents: read + checks: write concurrency: group: app-web-deployment @@ -38,6 +36,16 @@ jobs: app-dir: ${{ env.FLUTTER_PROJECT }} base-href: ${{ env.BASE_HREF }} pwa-strategy: none + build-metadata: "true" + + - name: Publish the same bundle to Firebase + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ github.token }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_OPEN_EARABLE_WEB }} + channelId: live + projectId: open-earable-web + entryPoint: open_wearable/ - name: Prepare GitHub Pages files working-directory: ${{ env.FLUTTER_PROJECT }} diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml deleted file mode 100644 index 323c06648..000000000 --- a/.github/workflows/firebase-hosting-merge.yml +++ /dev/null @@ -1,25 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Analyze and deploy to Firebase Hosting on merge -on: - push: - branches: - - main -jobs: - build_and_deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Build web bundle - uses: ./.github/actions/flutter-web-build - with: - app-dir: open_wearable - build-metadata: "true" - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_open_earable_WEB }} - channelId: live - projectId: open-earable-web - entryPoint: open_wearable/ diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml deleted file mode 100644 index 6ae3c661f..000000000 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ /dev/null @@ -1,51 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Analyze and deploy to Firebase Hosting on PR -on: pull_request -permissions: - actions: read - checks: write - contents: read - pull-requests: write -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Build web bundle - uses: ./.github/actions/flutter-web-build - with: - app-dir: open_wearable - build-metadata: "true" - - - name: Upload a Build Artifact - uses: actions/upload-artifact@v6 - with: - name: web-build - path: open_wearable/build/web - if-no-files-found: error - overwrite: true - - deploy_preview: - needs: build - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Download Web build artifact - uses: actions/download-artifact@v7 - with: - name: web-build - path: open_wearable/build/web - - - uses: FirebaseExtended/action-hosting-deploy@v0 - if: ${{ github.event_name == 'pull_request' }} - name: Deploy preview - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_open_earable_WEB }} - projectId: open-earable-web - expires: 28d - entryPoint: open_wearable/ diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml deleted file mode 100644 index 0056cedc2..000000000 --- a/.github/workflows/ios_build_and_deploy.yaml +++ /dev/null @@ -1,224 +0,0 @@ -name: iOS Build and Submit to App Store - -on: - workflow_call: - inputs: - release_tag: - description: "Version tag to release" - required: true - type: string - ref: - description: "Optional branch or tag from which to create the release tag" - required: false - type: string - release_mode: - description: "Release automatically after App Review or wait for manual release" - required: false - default: "after_approval" - type: string - submit_for_review: - description: "Submit after upload; disable to validate a build without publishing" - required: false - default: true - type: boolean - secrets: - APP_STORE_CONNECT_APP_ID: - required: true - APP_STORE_CONNECT_ISSUER_ID: - required: true - APP_STORE_CONNECT_KEY_ID: - required: true - APP_STORE_CONNECT_PRIVATE_KEY: - required: true - workflow_dispatch: - inputs: - ref: - description: "Branch or existing release tag to release" - required: true - default: "main" - type: string - submit_for_review: - description: "Submit the uploaded build to App Review" - required: true - default: true - type: boolean - release_mode: - description: "When to publish after App Review" - required: true - default: "after_approval" - type: choice - options: - - after_approval - - manual - -permissions: - contents: write - -concurrency: - group: ios-app-store-${{ inputs.release_tag || inputs.ref || github.ref_name }} - cancel-in-progress: false - -jobs: - prepare_release: - name: Create or verify release tag - if: ${{ inputs.ref != '' }} - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.release.outputs.release_tag }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release source - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - path: release-source - fetch-depth: 0 - - - name: Create or verify immutable version tag - id: release - uses: ./workflow-source/.github/actions/prepare-versioned-release - with: - source-directory: release-source - env: - GH_TOKEN: ${{ github.token }} - - submit_ios: - name: Build on GitHub and submit iOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: macos-15 - timeout-minutes: 120 - env: - RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - path: release-source - fetch-depth: 0 - - - name: Validate release inputs - working-directory: release-source - id: release - shell: bash - run: | - set -euo pipefail - - version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) - version=${version//\"/} - version=${version//\'/} - public_version=${version%%+*} - expected_tag="v${public_version}" - - if [ "$RELEASE_TAG" != "$expected_tag" ]; then - echo "::error::Tag '$RELEASE_TAG' does not match pubspec version '$public_version'." - exit 1 - fi - - version_notes_dir="open_wearable/release_notes/${public_version}" - notes_dir="$version_notes_dir" - if [ ! -s "$notes_dir/default.txt" ]; then - notes_dir="open_wearable/release_notes" - echo "Using generic release notes because $version_notes_dir/default.txt is missing." - fi - if [ ! -s "$notes_dir/default.txt" ]; then - echo "::error::Missing version-specific release notes at $version_notes_dir/default.txt and fallback release notes at $notes_dir/default.txt." - exit 1 - fi - - case "$RELEASE_MODE" in - after_approval) release_type=AFTER_APPROVAL ;; - manual) release_type=MANUAL ;; - *) - echo "::error::Unsupported release mode '$RELEASE_MODE'." - exit 1 - ;; - esac - - echo "version=$public_version" >> "$GITHUB_OUTPUT" - echo "notes_dir=release-source/$notes_dir" >> "$GITHUB_OUTPUT" - echo "release_type=$release_type" >> "$GITHUB_OUTPUT" - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2.3 - - - name: Build and upload iOS - id: upload - uses: ./workflow-source/.github/actions/apple-build-upload - with: - platform: ios - app-dir: release-source/open_wearable - env: - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - - - name: Wait for processing and submit to App Review - env: - APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - SUBMIT_FOR_REVIEW: ${{ inputs.submit_for_review }} - BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} - PUBLIC_VERSION: ${{ steps.release.outputs.version }} - NOTES_DIR: ${{ steps.release.outputs.notes_dir }} - RELEASE_TYPE: ${{ steps.release.outputs.release_type }} - run: | - args=() - if [ "$SUBMIT_FOR_REVIEW" != true ]; then args+=(--process-only); fi - ruby workflow-source/.github/scripts/app_store_connect_release.rb \ - --app-id "$APP_STORE_CONNECT_APP_ID" \ - --platform IOS \ - --version "$PUBLIC_VERSION" \ - --build-number "$BUILD_NUMBER" \ - --release-notes-dir "$NOTES_DIR" \ - --release-type "$RELEASE_TYPE" "${args[@]}" - - - name: Summarize submission - if: ${{ inputs.submit_for_review }} - run: | - printf 'Submitted iOS **%s** to App Review with release mode **%s**.\n' \ - "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY" - - publish_github_release: - name: Publish GitHub Release - needs: [prepare_release, submit_ios] - if: ${{ inputs.ref != '' && inputs.submit_for_review && needs.submit_ios.result == 'success' }} - runs-on: ubuntu-latest - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ needs.prepare_release.outputs.release_tag }} - path: release-source - fetch-depth: 0 - - - name: Create or update GitHub Release - uses: ./workflow-source/.github/actions/publish-versioned-release - with: - source-directory: release-source - release-tag: ${{ needs.prepare_release.outputs.release_tag }} - env: - GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml deleted file mode 100644 index 2f1887995..000000000 --- a/.github/workflows/macos_build_and_deploy.yaml +++ /dev/null @@ -1,224 +0,0 @@ -name: macOS Build and Submit to App Store - -on: - workflow_call: - inputs: - release_tag: - description: "Version tag to release" - required: true - type: string - ref: - description: "Optional branch or tag from which to create the release tag" - required: false - type: string - release_mode: - description: "Release automatically after App Review or wait for manual release" - required: false - default: "after_approval" - type: string - submit_for_review: - description: "Submit after upload; disable to validate a build without publishing" - required: false - default: true - type: boolean - secrets: - APP_STORE_CONNECT_APP_ID: - required: true - APP_STORE_CONNECT_ISSUER_ID: - required: true - APP_STORE_CONNECT_KEY_ID: - required: true - APP_STORE_CONNECT_PRIVATE_KEY: - required: true - workflow_dispatch: - inputs: - ref: - description: "Branch or existing release tag to release" - required: true - default: "main" - type: string - submit_for_review: - description: "Submit the uploaded build to App Review" - required: true - default: true - type: boolean - release_mode: - description: "When to publish after App Review" - required: true - default: "after_approval" - type: choice - options: - - after_approval - - manual - -permissions: - contents: write - -concurrency: - group: macos-app-store-${{ inputs.release_tag || inputs.ref || github.ref_name }} - cancel-in-progress: false - -jobs: - prepare_release: - name: Create or verify release tag - if: ${{ inputs.ref != '' }} - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.release.outputs.release_tag }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release source - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - path: release-source - fetch-depth: 0 - - - name: Create or verify immutable version tag - id: release - uses: ./workflow-source/.github/actions/prepare-versioned-release - with: - source-directory: release-source - env: - GH_TOKEN: ${{ github.token }} - - submit_macos: - name: Build on GitHub and submit macOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: macos-15 - timeout-minutes: 120 - env: - RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - path: release-source - fetch-depth: 0 - - - name: Validate release inputs - working-directory: release-source - id: release - shell: bash - run: | - set -euo pipefail - - version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) - version=${version//\"/} - version=${version//\'/} - public_version=${version%%+*} - expected_tag="v${public_version}" - - if [ "$RELEASE_TAG" != "$expected_tag" ]; then - echo "::error::Tag '$RELEASE_TAG' does not match pubspec version '$public_version'." - exit 1 - fi - - version_notes_dir="open_wearable/release_notes/${public_version}" - notes_dir="$version_notes_dir" - if [ ! -s "$notes_dir/default.txt" ]; then - notes_dir="open_wearable/release_notes" - echo "Using generic release notes because $version_notes_dir/default.txt is missing." - fi - if [ ! -s "$notes_dir/default.txt" ]; then - echo "::error::Missing version-specific release notes at $version_notes_dir/default.txt and fallback release notes at $notes_dir/default.txt." - exit 1 - fi - - case "$RELEASE_MODE" in - after_approval) release_type=AFTER_APPROVAL ;; - manual) release_type=MANUAL ;; - *) - echo "::error::Unsupported release mode '$RELEASE_MODE'." - exit 1 - ;; - esac - - echo "version=$public_version" >> "$GITHUB_OUTPUT" - echo "notes_dir=release-source/$notes_dir" >> "$GITHUB_OUTPUT" - echo "release_type=$release_type" >> "$GITHUB_OUTPUT" - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2.3 - - - name: Build and upload macOS - id: upload - uses: ./workflow-source/.github/actions/apple-build-upload - with: - platform: macos - app-dir: release-source/open_wearable - env: - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - - - name: Wait for processing and submit to App Review - env: - APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} - APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} - APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} - APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} - SUBMIT_FOR_REVIEW: ${{ inputs.submit_for_review }} - BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} - PUBLIC_VERSION: ${{ steps.release.outputs.version }} - NOTES_DIR: ${{ steps.release.outputs.notes_dir }} - RELEASE_TYPE: ${{ steps.release.outputs.release_type }} - run: | - args=() - if [ "$SUBMIT_FOR_REVIEW" != true ]; then args+=(--process-only); fi - ruby workflow-source/.github/scripts/app_store_connect_release.rb \ - --app-id "$APP_STORE_CONNECT_APP_ID" \ - --platform MAC_OS \ - --version "$PUBLIC_VERSION" \ - --build-number "$BUILD_NUMBER" \ - --release-notes-dir "$NOTES_DIR" \ - --release-type "$RELEASE_TYPE" "${args[@]}" - - - name: Summarize submission - if: ${{ inputs.submit_for_review }} - run: | - printf 'Submitted macOS **%s** to App Review with release mode **%s**.\n' \ - "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY" - - publish_github_release: - name: Publish GitHub Release - needs: [prepare_release, submit_macos] - if: ${{ inputs.ref != '' && inputs.submit_for_review && needs.submit_macos.result == 'success' }} - runs-on: ubuntu-latest - steps: - - name: Checkout workflow source - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ needs.prepare_release.outputs.release_tag }} - path: release-source - fetch-depth: 0 - - - name: Create or update GitHub Release - uses: ./workflow-source/.github/actions/publish-versioned-release - with: - source-directory: release-source - release-tag: ${{ needs.prepare_release.outputs.release_tag }} - env: - GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/pr_build_all_platforms.yml b/.github/workflows/pr_build_all_platforms.yml index b696b7980..d880f0c82 100644 --- a/.github/workflows/pr_build_all_platforms.yml +++ b/.github/workflows/pr_build_all_platforms.yml @@ -1,4 +1,4 @@ -name: Build PR Artifacts +name: PR Checks & Preview on: pull_request: @@ -81,7 +81,10 @@ jobs: - target: web name: Web os: ubuntu-latest - build_command: flutter build web --release + build_command: >- + flutter build web --release + --dart-define BUILD_COMMIT=$(git rev-parse --short HEAD) + --dart-define BUILD_BRANCH=$(git rev-parse --abbrev-ref HEAD) artifact_name: web-bundle artifact_path: open_wearable/build/web @@ -146,11 +149,34 @@ jobs: path: ${{ matrix.artifact_path }} if-no-files-found: error + deploy_preview: + name: Deploy web preview + needs: build + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + permissions: + contents: read + checks: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: web-bundle + path: open_wearable/build/web + - uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ github.token }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_OPEN_EARABLE_WEB }} + projectId: open-earable-web + expires: 28d + entryPoint: open_wearable/ + comment_artifact_links: name: Comment Artifact Links runs-on: ubuntu-latest needs: build - if: always() + if: ${{ !cancelled() && github.event.pull_request.head.repo.full_name == github.repository }} steps: - name: Post or update PR comment with artifact links diff --git a/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index 1b596b808..911732958 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -1,96 +1,451 @@ -name: Release All Platforms +name: Release App on: workflow_dispatch: inputs: ref: - description: "Branch or tag to release" - required: true - default: "main" + description: Branch or existing version tag to release type: string - platforms: - description: "Platforms to release" required: true - default: "all" + default: main + platforms: + description: Platforms to release + type: choice + default: all + options: [all, android, app_store, ios, macos] + release_type: + description: Google Play track (Android only) type: choice - options: - - all - - android - - app_store + default: production + options: [production, internal] release_mode: - description: "When to publish Apple releases after App Review" - required: true - default: "after_approval" + description: When to publish Apple releases after App Review type: choice - options: - - after_approval - - manual + default: after_approval + options: [after_approval, manual] + submit_for_review: + description: Submit Apple builds to App Review; disable for upload-only validation + type: boolean + default: true create_version_bump_pr: - description: "Open the post-release version bump PR" - required: true + description: Open the post-release version bump PR + type: boolean default: true + version_bump_base_ref: + description: Branch to target with the version bump PR + type: string + default: main + validate_only: + description: Validate inputs only, without building, tagging or uploading type: boolean + default: false permissions: - contents: write - pull-requests: write + contents: read + +concurrency: + group: release-app-${{ inputs.ref }} + cancel-in-progress: false jobs: prepare_release: - name: Create or verify release tag - if: ${{ inputs.platforms == 'all' || inputs.platforms == 'app_store' }} + name: Validate source and prepare release runs-on: ubuntu-latest + permissions: + contents: write outputs: - release_tag: ${{ steps.release.outputs.release_tag }} + released_sha: ${{ steps.version.outputs.released_sha }} + released_version: ${{ steps.version.outputs.released_version }} + public_version: ${{ steps.version.outputs.public_version }} + release_tag: ${{ steps.version.outputs.release_tag }} + next_version: ${{ steps.version.outputs.next_version }} + release_notes_path: ${{ steps.version.outputs.release_notes_path }} + apple_matrix: ${{ steps.platforms.outputs.apple_matrix }} + env: + RELEASE_TYPE: ${{ inputs.platforms == 'android' && inputs.release_type || 'production' }} steps: - name: Checkout workflow source uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - path: workflow-source - - name: Checkout release source uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} path: release-source fetch-depth: 0 + - name: Validate release version + id: version + working-directory: release-source + shell: bash + run: | + set -euo pipefail + + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) + version=${version//\"/} + version=${version//\'/} + version_pattern='^([0-9]+)\.([0-9]+)\.([0-9]+)(\+[0-9]+)?$' + if ! [[ "$version" =~ $version_pattern ]]; then + echo "::error::Version '$version' must use major.minor.patch with an optional numeric +build suffix." + exit 1 + fi + + major=${BASH_REMATCH[1]} + minor=${BASH_REMATCH[2]} + patch=${BASH_REMATCH[3]} + build_suffix=${BASH_REMATCH[4]} + public_version=${version%%+*} + next_version="${major}.${minor}.$((10#$patch + 1))${build_suffix}" + + if [ "$RELEASE_TYPE" = "production" ]; then + version_notes_path="open_wearable/release_notes/${public_version}/default.txt" + fallback_notes_path="open_wearable/release_notes/default.txt" + notes_path="$version_notes_path" + if [ ! -s "$notes_path" ]; then + notes_path="$fallback_notes_path" + echo "Using generic release notes because $version_notes_path is missing." + fi + if [ ! -s "$notes_path" ]; then + echo "::error::Missing version-specific release notes at $version_notes_path and fallback release notes at $fallback_notes_path." + exit 1 + fi + else + notes_path="" + fi + + echo "released_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "released_version=$version" >> "$GITHUB_OUTPUT" + echo "public_version=$public_version" >> "$GITHUB_OUTPUT" + echo "release_tag=v${public_version}" >> "$GITHUB_OUTPUT" + echo "next_version=$next_version" >> "$GITHUB_OUTPUT" + echo "release_notes_path=$notes_path" >> "$GITHUB_OUTPUT" + + - name: Select Apple platforms + id: platforms + env: + PLATFORMS: ${{ inputs.platforms }} + run: | + case "$PLATFORMS" in + all|app_store) matrix='{"include":[{"platform":"ios","api_platform":"IOS"},{"platform":"macos","api_platform":"MAC_OS"}]}' ;; + ios) matrix='{"include":[{"platform":"ios","api_platform":"IOS"}]}' ;; + macos) matrix='{"include":[{"platform":"macos","api_platform":"MAC_OS"}]}' ;; + android) matrix='{"include":[]}' ;; + *) echo "::error::Unsupported platform selection"; exit 1 ;; + esac + echo "apple_matrix=$matrix" >> "$GITHUB_OUTPUT" - name: Create or verify immutable version tag - id: release - uses: ./workflow-source/.github/actions/prepare-versioned-release + if: ${{ !inputs.validate_only && (inputs.platforms != 'android' || inputs.release_type == 'production') }} + uses: ./.github/actions/prepare-versioned-release with: source-directory: release-source env: GH_TOKEN: ${{ github.token }} release_android: - name: Release Android + name: Build and upload Android needs: prepare_release - if: >- - ${{ always() && (inputs.platforms == 'all' || inputs.platforms == 'android') && - (inputs.platforms == 'android' || needs.prepare_release.result == 'success') }} - uses: ./.github/workflows/android_build_and_deploy.yaml - with: - ref: ${{ inputs.platforms == 'all' && needs.prepare_release.outputs.release_tag || inputs.ref }} - release_type: production - publish_github_release: ${{ inputs.platforms == 'android' }} - create_version_bump_pr: ${{ inputs.create_version_bump_pr }} - version_bump_base_ref: ${{ inputs.ref }} - secrets: inherit + if: ${{ !inputs.validate_only && (inputs.platforms == 'all' || inputs.platforms == 'android') }} + runs-on: ubuntu-latest + env: + RELEASE_TYPE: ${{ inputs.release_type }} + steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare_release.outputs.released_sha }} + path: release-source + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.3 + + - name: Set up Android build + uses: ./.github/actions/android-build-prepare + with: + app-dir: release-source/open_wearable + clean: "true" + analyze: "true" + + - name: Decode Android signing key + env: + KEYSTORE: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH__FILE }} + run: | + umask 077 + printf '%s' "$KEYSTORE" | base64 --decode > "$RUNNER_TEMP/android.keystore" + echo "OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/android.keystore" >> "$GITHUB_ENV" + + - name: Decode Google Play service account + env: + SERVICE_ACCOUNT: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH__FILE }} + run: | + umask 077 + printf '%s' "$SERVICE_ACCOUNT" | base64 --decode > "$RUNNER_TEMP/android-deployment.json" + echo "OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH=$RUNNER_TEMP/android-deployment.json" >> "$GITHUB_ENV" + + - name: Prepare Google Play release notes + if: ${{ inputs.release_type == 'production' }} + run: | + changelog_dir="release-source/open_wearable/android/fastlane/metadata/android/en-US/changelogs" + mkdir -p "$changelog_dir" + cp "release-source/${{ needs.prepare_release.outputs.release_notes_path }}" "$changelog_dir/default.txt" + + - name: Set up Fastlane + working-directory: release-source/open_wearable/android + run: bundle install + + - name: Build and deploy Android internal release + if: ${{ inputs.release_type == 'internal' }} + working-directory: release-source/open_wearable/android + run: bundle exec fastlane internal_deploy + env: + OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD }} + + - name: Build and deploy Android production release + if: ${{ inputs.release_type == 'production' }} + working-directory: release-source/open_wearable/android + run: bundle exec fastlane production_deploy + env: + OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD }} + + - name: Upload signed release APK + if: ${{ inputs.release_type == 'production' }} + uses: actions/upload-artifact@v6 + with: + name: android-release-${{ needs.prepare_release.outputs.public_version }} + path: release-source/open_wearable/build/app/outputs/apk/release/app-release.apk + if-no-files-found: error + + - name: Summarize successful Play Store upload + run: | + printf 'Successfully uploaded Android **%s** to the **%s** track on Google Play.\n' \ + "${{ needs.prepare_release.outputs.public_version }}" "$RELEASE_TYPE" >> "$GITHUB_STEP_SUMMARY" release_apple: - name: Release iOS and macOS + name: Build and upload ${{ matrix.platform }} + needs: prepare_release + if: ${{ !inputs.validate_only && inputs.platforms != 'android' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.prepare_release.outputs.apple_matrix) }} + runs-on: macos-15 + timeout-minutes: 120 + env: + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} + steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + path: release-source + fetch-depth: 0 + + - name: Validate release inputs + working-directory: release-source + id: release + shell: bash + run: | + set -euo pipefail + + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) + version=${version//\"/} + version=${version//\'/} + public_version=${version%%+*} + expected_tag="v${public_version}" + + if [ "$RELEASE_TAG" != "$expected_tag" ]; then + echo "::error::Tag '$RELEASE_TAG' does not match pubspec version '$public_version'." + exit 1 + fi + + version_notes_dir="open_wearable/release_notes/${public_version}" + notes_dir="$version_notes_dir" + if [ ! -s "$notes_dir/default.txt" ]; then + notes_dir="open_wearable/release_notes" + echo "Using generic release notes because $version_notes_dir/default.txt is missing." + fi + if [ ! -s "$notes_dir/default.txt" ]; then + echo "::error::Missing version-specific release notes at $version_notes_dir/default.txt and fallback release notes at $notes_dir/default.txt." + exit 1 + fi + + case "$RELEASE_MODE" in + after_approval) release_type=AFTER_APPROVAL ;; + manual) release_type=MANUAL ;; + *) + echo "::error::Unsupported release mode '$RELEASE_MODE'." + exit 1 + ;; + esac + + echo "version=$public_version" >> "$GITHUB_OUTPUT" + echo "notes_dir=release-source/$notes_dir" >> "$GITHUB_OUTPUT" + echo "release_type=$release_type" >> "$GITHUB_OUTPUT" + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.3 + + - name: Build and upload ${{ matrix.platform }} + id: upload + uses: ./.github/actions/apple-build-upload + with: + platform: ${{ matrix.platform }} + app-dir: release-source/open_wearable + env: + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + + - name: Wait for processing and submit to App Review + env: + APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + SUBMIT_FOR_REVIEW: ${{ inputs.submit_for_review }} + PLATFORM: ${{ matrix.api_platform }} + BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} + PUBLIC_VERSION: ${{ steps.release.outputs.version }} + NOTES_DIR: ${{ steps.release.outputs.notes_dir }} + RELEASE_TYPE: ${{ steps.release.outputs.release_type }} + run: | + args=() + if [ "$SUBMIT_FOR_REVIEW" != true ]; then args+=(--process-only); fi + ruby .github/scripts/app_store_connect_release.rb \ + --app-id "$APP_STORE_CONNECT_APP_ID" \ + --platform "$PLATFORM" \ + --version "$PUBLIC_VERSION" \ + --build-number "$BUILD_NUMBER" \ + --release-notes-dir "$NOTES_DIR" \ + --release-type "$RELEASE_TYPE" "${args[@]}" + + - name: Summarize submission + if: ${{ inputs.submit_for_review }} + run: | + printf 'Submitted **%s** version **%s** to App Review with release mode **%s**.\n' \ + "${{ matrix.platform }}" "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY" + + publish_github_release: + name: Publish GitHub Release + needs: [prepare_release, release_android, release_apple] if: >- - ${{ always() && (inputs.platforms == 'all' || inputs.platforms == 'app_store') && - (inputs.platforms == 'app_store' || needs.release_android.result == 'success') && - needs.prepare_release.result == 'success' }} - needs: - - prepare_release - - release_android - uses: ./.github/workflows/app_store_build_and_deploy.yaml - with: - release_tag: ${{ needs.prepare_release.outputs.release_tag }} - release_mode: ${{ inputs.release_mode }} - android_artifact_name: ${{ inputs.platforms == 'all' && format('android-release-{0}', needs.release_android.outputs.public_version) || '' }} - secrets: inherit + ${{ !cancelled() && !inputs.validate_only && inputs.release_type == 'production' && + (inputs.platforms == 'android' || inputs.submit_for_review) && + needs.prepare_release.result == 'success' && + (needs.release_android.result == 'success' || needs.release_android.result == 'skipped') && + (needs.release_apple.result == 'success' || needs.release_apple.result == 'skipped') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + path: workflow-source + + - name: Checkout released source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare_release.outputs.released_sha }} + path: release-source + fetch-depth: 0 + + - name: Download signed APK + if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }} + uses: actions/download-artifact@v7 + with: + name: android-release-${{ needs.prepare_release.outputs.public_version }} + path: release-apk + + - name: Create or update GitHub Release + uses: ./workflow-source/.github/actions/publish-versioned-release + with: + source-directory: release-source + release-tag: ${{ needs.prepare_release.outputs.release_tag }} + env: + GH_TOKEN: ${{ github.token }} + + - name: Upload signed APK to GitHub Release + if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare_release.outputs.release_tag }} + PUBLIC_VERSION: ${{ needs.prepare_release.outputs.public_version }} + run: | + gh release upload "$RELEASE_TAG" \ + "release-apk/app-release.apk#OpenWearable-${PUBLIC_VERSION}.apk" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + + create_version_bump_pr: + name: Create version bump PR for review + needs: [prepare_release, publish_github_release] + if: ${{ inputs.create_version_bump_pr }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout released commit + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare_release.outputs.released_sha }} + + - name: Open version bump PR + shell: bash + env: + VERSION_FILE: open_wearable/pubspec.yaml + BASE_BRANCH: ${{ inputs.version_bump_base_ref }} + RELEASED_VERSION: ${{ needs.prepare_release.outputs.released_version }} + NEXT_VERSION: ${{ needs.prepare_release.outputs.next_version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + bump_branch="automation/bump-version-${NEXT_VERSION}-${GITHUB_RUN_ID}" + commit_message="chore(version): bump version to ${NEXT_VERSION}" + pr_url=$(gh pr list \ + --base "$BASE_BRANCH" \ + --head "$bump_branch" \ + --state all \ + --json url \ + --jq '.[0].url // empty') + + if [ -z "$pr_url" ]; then + remote_branch=$(git ls-remote --heads origin "refs/heads/$bump_branch") + if [ -z "$remote_branch" ]; then + git switch -c "$bump_branch" + sed -i "s|^version:.*|version: $NEXT_VERSION|" "$VERSION_FILE" + git add "$VERSION_FILE" + git commit -m "$commit_message" + git push --set-upstream origin "$bump_branch" + fi + + body_file=$(mktemp) + printf '%s\n' \ + "The selected production releases of version $RELEASED_VERSION succeeded." \ + "" \ + "Bumps the app version to $NEXT_VERSION for the next release." \ + "Please review and merge this PR using the repository's normal approval process." \ + > "$body_file" + pr_url=$(gh pr create \ + --base "$BASE_BRANCH" \ + --head "$bump_branch" \ + --title "$commit_message" \ + --body-file "$body_file") + fi + + printf 'Version bump PR: %s\n' "$pr_url" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release_apk.yml b/.github/workflows/release_apk.yml index e18783d8b..155e8361e 100644 --- a/.github/workflows/release_apk.yml +++ b/.github/workflows/release_apk.yml @@ -1,25 +1,122 @@ -name: Android APK Build +name: Beta Builds on: push: - branches: - - main + branches: [main] + workflow_dispatch: + inputs: + ref: + description: Branch, tag or commit to upload + type: string + required: true + default: main + distribute: + description: Distribute to the existing Internal Testing and External Testing groups + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: beta-builds-${{ github.ref }} + cancel-in-progress: true jobs: - build: + android: + name: Android APK runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Set up Android build - uses: ./.github/actions/android-build-prepare + - name: Checkout workflow source + uses: actions/checkout@v6 + - name: Checkout app source + uses: actions/checkout@v6 with: - app-dir: open_wearable + ref: ${{ inputs.ref || github.sha }} + path: release-source + - uses: ./.github/actions/android-build-prepare + with: + app-dir: release-source/open_wearable analyze: "true" - - - working-directory: open_wearable + - working-directory: release-source/open_wearable run: flutter build apk --release - uses: actions/upload-artifact@v6 with: - name: main-release-apk-${{ github.sha }} - path: open_wearable/build/app/outputs/flutter-apk/app-release.apk + name: android-beta-apk-${{ github.run_id }} + path: release-source/open_wearable/build/app/outputs/flutter-apk/app-release.apk if-no-files-found: error + + testflight: + name: TestFlight ${{ matrix.platform }} + runs-on: macos-15 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - platform: ios + api_platform: IOS + - platform: macos + api_platform: MAC_OS + steps: + - name: Checkout workflow source + uses: actions/checkout@v6 + with: + path: workflow-source + + - name: Checkout app source + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.sha }} + path: release-source + + - name: Read app version and release notes + id: release + working-directory: release-source + run: | + set -euo pipefail + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) + version=${version//\"/} + version=${version//\'/} + version=${version%%+*} + notes="open_wearable/release_notes/$version" + if [ ! -s "$notes/default.txt" ]; then notes=open_wearable/release_notes; fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "notes_dir=release-source/$notes" >> "$GITHUB_OUTPUT" + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2.3 + + - name: Build and upload + id: upload + uses: ./workflow-source/.github/actions/apple-build-upload + with: + platform: ${{ matrix.platform }} + app-dir: release-source/open_wearable + env: + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + + - name: Process and distribute TestFlight build + env: + APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + DISTRIBUTE: ${{ github.event_name == 'push' || inputs.distribute }} + PLATFORM: ${{ matrix.api_platform }} + PUBLIC_VERSION: ${{ steps.release.outputs.version }} + BUILD_NUMBER: ${{ steps.upload.outputs.build_number }} + NOTES_DIR: ${{ steps.release.outputs.notes_dir }} + run: | + args=(--process-only) + if [ "$DISTRIBUTE" = true ]; then + args=(--testflight-group 'Internal Testing' --testflight-group 'External Testing') + fi + ruby workflow-source/.github/scripts/app_store_connect_release.rb \ + --app-id "$APP_STORE_CONNECT_APP_ID" --platform "$PLATFORM" \ + --version "$PUBLIC_VERSION" --build-number "$BUILD_NUMBER" \ + --release-notes-dir "$NOTES_DIR" "${args[@]}" diff --git a/open_wearable/docs/apple-builds.md b/open_wearable/docs/apple-builds.md index 72a0d6e92..ede387cea 100644 --- a/open_wearable/docs/apple-builds.md +++ b/open_wearable/docs/apple-builds.md @@ -1,4 +1,17 @@ -# Apple builds and releases +# Builds and releases + +There are four GitHub Actions workflows: + +| Workflow | Trigger | Purpose | +| --- | --- | --- | +| PR Checks & Preview | Pull requests | Analyze, test, compile all six platforms, publish artifacts and a Firebase web preview. | +| Beta Builds | Push to `main`, or manual | Android APK plus iOS/macOS TestFlight builds. | +| Deploy Web | Push to `main`, or manual | Build once and deploy to both `app.openwearables.com` and the existing Firebase site. | +| Release App | Manual | Release all platforms or select Android, iOS, macOS, or both Apple platforms. | + +Copilot review/agent workflows are GitHub-managed and independent of app builds. +Old workflow runs remain in Actions history; there are no duplicate Firebase or +platform-specific release workflow files. GitHub Actions builds iOS and macOS on standard `macos-15` runners with Xcode 26.2 and the Flutter version pinned in `.flutter_version`. Android, Linux, @@ -6,13 +19,14 @@ Windows, and web continue using their existing GitHub runners. ## Pull requests -**Build PR Artifacts** compiles both Apple platforms without signing credentials. +**PR Checks & Preview** compiles both Apple platforms without signing credentials. These artifacts validate compilation; the unsigned iOS app cannot be installed -on a physical iPhone. Fork PRs do not receive Apple secrets. +on a physical iPhone. Fork PRs do not receive Apple secrets or deploy previews. +Firebase previews reuse the web build artifact instead of rebuilding the app. ## TestFlight -**Apple TestFlight** archives both platforms on each push to `main`, then submits +**Beta Builds** archives both Apple platforms on each push to `main`, then submits for Beta App Review when required and assigns the builds to the existing **Internal Testing** and **External Testing** groups. This preserves the old Xcode Cloud main-branch distribution settings, including automatic notification @@ -24,14 +38,14 @@ to upload-only validation; enable `distribute` to send the build to those groups ## App Store releases -Use **Release All Platforms**, **Apple App Store Build and Submit**, or either -platform's individual release workflow. The workflows keep the existing +Use **Release App**, with `platforms` set to `all`, `android`, `ios`, `macos`, or +`app_store` (both Apple platforms). The workflow keeps the existing immutable version tag and release-note validation. Build scripts come from the workflow revision, while app source comes from the release tag, so a CI fix can release an existing tag without changing its contents. The GitHub Mac archives the app, then Xcode signs and uploads it directly to App -Store Connect. Automatic signing uses the existing API key and Apple's managed +Store Connect. Automatic signing uses the configured API key and Apple's managed distribution certificates. This signing service does not consume Xcode Cloud build hours. The key must have access to signing resources and app submission. @@ -53,10 +67,17 @@ submission. Processing has a 45-minute deadline with live progress output. By default releases are submitted to App Review and publish after approval. `release_mode: manual` preserves manual publication. To validate signing and -upload without submitting, run the individual iOS/macOS workflow with +upload without submitting, select `ios`, `macos`, or `app_store` with `submit_for_review: false`. This still uploads a build and waits for Apple to process it, but does not change the App Store version or publish a GitHub release. +Set `validate_only: true` to check the selected source version and platform +selection without tagging, building, or uploading. Android releases retain the +`internal`/`production` track choice. GitHub release publication and the optional +version-bump PR wait for every selected platform to succeed; internal and Apple +upload-only runs do not publish a release. The version-bump PR targets `main` +unless `version_bump_base_ref` specifies another branch. + Build logs and Xcode result bundles are retained in GitHub for seven days. API key files stay outside artifacts and are removed after upload. From e75bd06e6fdcd303e930ca15bd161827d48dcb11 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 10:22:26 +0200 Subject: [PATCH 6/8] ci(release): apply Play track only to Android publication --- .github/workflows/release_all_platforms.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index 911732958..516e3a299 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -338,7 +338,8 @@ jobs: name: Publish GitHub Release needs: [prepare_release, release_android, release_apple] if: >- - ${{ !cancelled() && !inputs.validate_only && inputs.release_type == 'production' && + ${{ !cancelled() && !inputs.validate_only && + ((inputs.platforms != 'all' && inputs.platforms != 'android') || inputs.release_type == 'production') && (inputs.platforms == 'android' || inputs.submit_for_review) && needs.prepare_release.result == 'success' && (needs.release_android.result == 'success' || needs.release_android.result == 'skipped') && From dfed469bf76a8a88074779470b6dfb163fb20182 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 10:25:15 +0200 Subject: [PATCH 7/8] fix(ci): preserve macOS sandbox entitlements during export --- .github/scripts/apple_archive.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/scripts/apple_archive.sh b/.github/scripts/apple_archive.sh index f94616d96..c442440cb 100644 --- a/.github/scripts/apple_archive.sh +++ b/.github/scripts/apple_archive.sh @@ -25,14 +25,40 @@ if [[ "$platform" == ios ]]; then config_args+=(--no-codesign); fi flutter build "${config_args[@]}" (cd "$platform" && pod install) +signing_args=(CODE_SIGNING_ALLOWED=NO) +if [[ "$platform" == macos ]]; then + # Xcode's export preserves the archive's signed entitlements. An unsigned Mac + # archive loses App Sandbox at export, so embed the project entitlements in + # an ad-hoc signature first; export replaces it with Apple's distribution one. + signing_args=(CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual) +fi xcodebuild archive -hideShellScriptEnvironment \ -workspace "$platform/Runner.xcworkspace" -scheme Runner \ -configuration Release -destination "$destination" \ -archivePath "$output_dir/Runner.xcarchive" \ -resultBundlePath "$output_dir/archive.xcresult" \ - CODE_SIGNING_ALLOWED=NO \ + "${signing_args[@]}" \ 2>&1 | tee "$output_dir/archive.log" +if [[ "$platform" == macos ]]; then + app_path=$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:ApplicationPath' "$output_dir/Runner.xcarchive/Info.plist") + codesign -d --entitlements - "$output_dir/Runner.xcarchive/Products/$app_path" \ + > "$output_dir/archive-entitlements.plist" + python3 - "$platform/Runner/Release.entitlements" "$output_dir/archive-entitlements.plist" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], 'rb') as source, open(sys.argv[2], 'rb') as archive: + expected, actual = plistlib.load(source), plistlib.load(archive) +if actual.get('com.apple.security.app-sandbox') is not True: + raise SystemExit('macOS archive is missing App Sandbox') +for key, value in expected.items(): + if actual.get(key) != value: + raise SystemExit(f'macOS archive entitlement was not preserved: {key}') +print('macOS release entitlements verified before upload') +PY +fi + # Only the ephemeral runner sees this file. It is never included in artifacts. key_dir=$(mktemp -d "$RUNNER_TEMP/apple-api.XXXXXX") trap 'rm -rf "$key_dir"' EXIT From a83a4b0d7903a308a6ec8b31ad331fb7f90ccd66 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 7 Oct 2026 10:34:00 +0200 Subject: [PATCH 8/8] fix(ci): request XML when checking signed entitlements --- .github/scripts/apple_archive.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/apple_archive.sh b/.github/scripts/apple_archive.sh index c442440cb..fc92b219a 100644 --- a/.github/scripts/apple_archive.sh +++ b/.github/scripts/apple_archive.sh @@ -42,7 +42,7 @@ xcodebuild archive -hideShellScriptEnvironment \ if [[ "$platform" == macos ]]; then app_path=$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:ApplicationPath' "$output_dir/Runner.xcarchive/Info.plist") - codesign -d --entitlements - "$output_dir/Runner.xcarchive/Products/$app_path" \ + codesign -d --entitlements - --xml "$output_dir/Runner.xcarchive/Products/$app_path" \ > "$output_dir/archive-entitlements.plist" python3 - "$platform/Runner/Release.entitlements" "$output_dir/archive-entitlements.plist" <<'PY' import plistlib