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..6663e2ed9 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,10 @@ 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] + return distribute_testflight(build) unless Array(@options[:testflight_groups]).empty? + version = find_or_create_version if submitted?(version) @@ -187,71 +190,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 +215,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,11 +223,58 @@ 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 + # 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", @@ -491,29 +482,30 @@ def wait_or_timeout! end end -begin +def run_release_cli options = { - timeout: 7200, + timeout: 2700, release_type: "AFTER_APPROVAL", - validate_only: false + validate_only: false, + testflight_groups: [] } 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 } + parser.on("--testflight-group NAME") { |value| options[:testflight_groups] << value } 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 +530,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..fc92b219a --- /dev/null +++ b/.github/scripts/apple_archive.sh @@ -0,0 +1,91 @@ +#!/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) + +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" \ + "${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 - --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 +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 +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" \ + 2>&1 | 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..1b9da4b2a --- /dev/null +++ b/.github/scripts/test/app_store_connect_release_test.rb @@ -0,0 +1,153 @@ +# 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 + 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/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 f5df2c146..000000000 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ /dev/null @@ -1,154 +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 - XCODE_CLOUD_IOS_WORKFLOW_ID: - required: true - XCODE_CLOUD_MACOS_WORKFLOW_ID: - 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/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 d279a1ddd..000000000 --- a/.github/workflows/ios_build_and_deploy.yaml +++ /dev/null @@ -1,193 +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 - 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 - XCODE_CLOUD_IOS_WORKFLOW_ID: - 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 - -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 in Xcode Cloud and submit iOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: ubuntu-latest - timeout-minutes: 130 - env: - RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} - steps: - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Validate release inputs - 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 "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "notes_dir=$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: Start Xcode Cloud 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 }} - run: | - ruby .github/scripts/app_store_connect_release.rb \ - --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \ - --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 }}" - - - name: Summarize submission - 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' }} - 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 16e44c604..000000000 --- a/.github/workflows/macos_build_and_deploy.yaml +++ /dev/null @@ -1,193 +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 - 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 - XCODE_CLOUD_MACOS_WORKFLOW_ID: - 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 - -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 in Xcode Cloud and submit macOS - needs: prepare_release - if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }} - runs-on: ubuntu-latest - timeout-minutes: 130 - env: - RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} - RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }} - steps: - - name: Checkout release tag - uses: actions/checkout@v6 - with: - ref: ${{ env.RELEASE_TAG }} - fetch-depth: 0 - - - name: Validate release inputs - 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 "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - echo "notes_dir=$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: Start Xcode Cloud 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 }} - run: | - ruby .github/scripts/app_store_connect_release.rb \ - --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \ - --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 }}" - - - name: Summarize submission - 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' }} - 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 22dc3d47f..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: @@ -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 @@ -51,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 @@ -59,6 +92,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 +124,20 @@ 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 + 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 }} @@ -96,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 @@ -115,6 +191,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/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index 1b596b808..516e3a299 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -1,96 +1,452 @@ -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.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') && + (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 new file mode 100644 index 000000000..ede387cea --- /dev/null +++ b/open_wearable/docs/apple-builds.md @@ -0,0 +1,91 @@ +# 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, +Windows, and web continue using their existing GitHub runners. + +## Pull requests + +**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 or deploy previews. +Firebase previews reuse the web build artifact instead of rebuilding the app. + +## TestFlight + +**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 +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 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 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. + +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` +- `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, 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. + +## Migration from Xcode Cloud + +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. 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