From 98d1d9700c20968760809609bf5f1502792405dd Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 24 Sep 2026 16:41:50 +0200 Subject: [PATCH 1/9] ci: add app store deployment workflow --- .github/scripts/app_store_connect_release.rb | 477 ++++++++++++++++++ .../workflows/android_build_and_deploy.yaml | 207 ++++++-- .github/workflows/ios_build_and_deploy.yaml | 129 +++++ .github/workflows/macos_build_and_deploy.yaml | 129 +++++ .github/workflows/release_apk.yml | 50 +- open_wearable/analysis_options.yaml | 9 + open_wearable/android/fastlane/Fastfile | 10 + open_wearable/ios/Runner/Info.plist | 4 +- open_wearable/ios/ci_scripts/ci_post_clone.sh | 6 +- .../macos/ci_scripts/ci_post_clone.sh | 6 +- open_wearable/pubspec.lock | 16 +- open_wearable/release_notes/1.5.2/default.txt | 1 + 12 files changed, 953 insertions(+), 91 deletions(-) create mode 100644 .github/scripts/app_store_connect_release.rb create mode 100644 .github/workflows/ios_build_and_deploy.yaml create mode 100644 .github/workflows/macos_build_and_deploy.yaml create mode 100644 open_wearable/release_notes/1.5.2/default.txt diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb new file mode 100644 index 00000000..a350a155 --- /dev/null +++ b/.github/scripts/app_store_connect_release.rb @@ -0,0 +1,477 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "base64" +require "json" +require "net/http" +require "openssl" +require "optparse" +require "time" +require "timeout" +require "uri" + +class AppStoreConnectError < StandardError; end + +class AppStoreConnectClient + BASE_URL = "https://api.appstoreconnect.apple.com" + + def initialize(key_id:, issuer_id:, private_key:) + @key_id = key_id + @issuer_id = issuer_id + @private_key = OpenSSL::PKey.read(normalize_private_key(private_key)) + end + + def get(path, query = {}) + request(Net::HTTP::Get, path, query: query) + end + + def post(path, body) + request(Net::HTTP::Post, path, body: body) + end + + def patch(path, body) + request(Net::HTTP::Patch, path, body: body) + end + + private + + def normalize_private_key(value) + key = value.gsub("\\n", "\n").strip + return key if key.include?("BEGIN PRIVATE KEY") || key.include?("BEGIN EC PRIVATE KEY") + + Base64.strict_decode64(key) + rescue ArgumentError + raise AppStoreConnectError, + "APP_STORE_CONNECT_PRIVATE_KEY must contain the .p8 contents or their base64 encoding" + end + + def token + issued_at = Time.now.to_i + header = { alg: "ES256", kid: @key_id, typ: "JWT" } + payload = { + iss: @issuer_id, + iat: issued_at, + exp: issued_at + (15 * 60), + aud: "appstoreconnect-v1" + } + signing_input = [header, payload].map { |part| base64url(JSON.generate(part)) }.join(".") + digest = OpenSSL::Digest::SHA256.digest(signing_input) + der_signature = @private_key.dsa_sign_asn1(digest) + sequence = OpenSSL::ASN1.decode(der_signature) + raw_signature = sequence.value.map { |integer| integer_bytes(integer.value) }.join + + "#{signing_input}.#{base64url(raw_signature)}" + end + + def integer_bytes(integer) + [format("%064x", integer)].pack("H*") + end + + def base64url(value) + Base64.urlsafe_encode64(value, padding: false) + end + + def request(request_class, path, query: {}, body: nil) + uri = URI("#{BASE_URL}#{path}") + uri.query = URI.encode_www_form(query) unless query.empty? + + attempts = 0 + begin + attempts += 1 + request = request_class.new(uri) + request["Authorization"] = "Bearer #{token}" + request["Accept"] = "application/json" + if body + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + end + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.open_timeout = 30 + http.read_timeout = 60 + http.request(request) + end + + if response.code.to_i.between?(200, 299) + return {} if response.body.nil? || response.body.empty? + + return JSON.parse(response.body) + end + + if (response.code.to_i == 429 || response.code.to_i >= 500) && attempts < 4 + delay = [response["retry-after"].to_i, 5 * attempts].max + warn "Apple API returned HTTP #{response.code}; retrying in #{delay} seconds" + sleep delay + retry + end + + raise AppStoreConnectError, api_error(response) + rescue IOError, SocketError, SystemCallError, Timeout::Error => error + raise if attempts >= 4 + + warn "Apple API request failed (#{error.message}); retrying" + sleep 5 * attempts + retry + end + end + + def api_error(response) + parsed = JSON.parse(response.body) + details = Array(parsed["errors"]).map do |error| + [error["code"], error["title"], error["detail"]].compact.join(": ") + end + message = details.empty? ? response.body : details.join(" | ") + "Apple API request failed with HTTP #{response.code}: #{message}" + rescue JSON::ParserError + "Apple API request failed with HTTP #{response.code}: #{response.body}" + end +end + +class AppStoreRelease + SUBMITTED_STATES = %w[ + ACCEPTED + IN_REVIEW + PENDING_APPLE_RELEASE + PENDING_DEVELOPER_RELEASE + PROCESSING_FOR_DISTRIBUTION + READY_FOR_DISTRIBUTION + READY_FOR_SALE + WAITING_FOR_EXPORT_COMPLIANCE + WAITING_FOR_REVIEW + ].freeze + + def initialize(client:, options:) + @client = client + @options = options + @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + options.fetch(:timeout) + end + + def run + validate_release_notes! + return puts("Release inputs are valid") if @options[:validate_only] + + build_run = wait_for_xcode_cloud_run + build = wait_for_processed_build(build_run.fetch("id")) + version = find_or_create_version + + if submitted?(version) + puts "App Store version #{@options[:version]} is already in #{version_state(version)}" + return + end + + update_release_type(version.fetch("id")) + update_release_notes(version.fetch("id")) + attach_build(version.fetch("id"), build.fetch("id")) + submit_for_review(version.fetch("id")) + + puts "Submitted #{@options[:platform]} version #{@options[:version]} for App Review" + end + + private + + def validate_release_notes! + default_path = File.join(@options[:release_notes_dir], "default.txt") + raise AppStoreConnectError, "Missing release notes: #{default_path}" unless File.file?(default_path) + + validate_note!(default_path) + Dir.glob(File.join(@options[:release_notes_dir], "*.txt")).each { |path| validate_note!(path) } + end + + def validate_note!(path) + note = File.read(path, encoding: "UTF-8").strip + raise AppStoreConnectError, "Release notes are empty: #{path}" if note.empty? + return if note.length <= 4000 + + raise AppStoreConnectError, "Release notes exceed Apple's 4000-character limit: #{path}" + end + + def wait_for_xcode_cloud_run + puts "Waiting for Xcode Cloud workflow #{@options[:workflow_id]} at #{@options[:tag]}" + loop do + response = @client.get( + "/v1/ciWorkflows/#{@options[:workflow_id]}/buildRuns", + "sort" => "-number", + "limit" => 25, + "include" => "sourceBranchOrTag" + ) + references = Array(response["included"]).each_with_object({}) do |resource, index| + index[resource["id"]] = resource if resource["type"] == "scmGitReferences" + end + matching_runs = Array(response["data"]).select do |run| + commit_sha = run.dig("attributes", "sourceCommit", "commitSha").to_s + reference_id = run.dig("relationships", "sourceBranchOrTag", "data", "id") + reference = references[reference_id] + names = [reference&.dig("attributes", "name"), reference&.dig("attributes", "canonicalName")] + same_commit = commit_sha == @options[:git_sha] || commit_sha.start_with?(@options[:git_sha]) || + @options[:git_sha].start_with?(commit_sha) + same_commit && names.compact.any? do |name| + name == @options[:tag] || name == "refs/tags/#{@options[:tag]}" + end + end + run = matching_runs.max_by { |candidate| candidate.dig("attributes", "number").to_i } + + if run + 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}" + else + puts "The tag-triggered Xcode Cloud build has not appeared yet" + end + + 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" + loop do + response = @client.get( + "/v1/ciBuildRuns/#{build_run_id}/builds", + "filter[app]" => @options[:app_id], + "filter[preReleaseVersion.version]" => @options[:version], + "filter[preReleaseVersion.platform]" => @options[:platform], + "sort" => "-uploadedDate", + "limit" => 10 + ) + builds = Array(response["data"]) + invalid = builds.find { |build| %w[FAILED INVALID].include?(build.dig("attributes", "processingState")) } + if invalid + raise AppStoreConnectError, + "Apple rejected build #{invalid.dig('attributes', 'version')} as " \ + "#{invalid.dig('attributes', 'processingState')}" + end + + build = builds.find { |candidate| candidate.dig("attributes", "processingState") == "VALID" } + if build + audience = build.dig("attributes", "buildAudienceType") + unless audience == "APP_STORE_ELIGIBLE" + raise AppStoreConnectError, + "Build #{build.dig('attributes', 'version')} is #{audience}; configure the Xcode Cloud " \ + "archive for TestFlight and App Store distribution" + end + + puts "Build #{build.dig('attributes', 'version')} is valid and App Store eligible" + return build + end + + puts builds.empty? ? "No uploaded build is associated with the run yet" : "The build is still processing" + wait_or_timeout! + end + end + + def find_or_create_version + response = @client.get( + "/v1/apps/#{@options[:app_id]}/appStoreVersions", + "filter[platform]" => @options[:platform], + "filter[versionString]" => @options[:version], + "limit" => 10 + ) + version = Array(response["data"]).first + return version if version + + puts "Creating #{@options[:platform]} App Store version #{@options[:version]}" + @client.post( + "/v1/appStoreVersions", + data: { + type: "appStoreVersions", + attributes: { + platform: @options[:platform], + versionString: @options[:version], + reviewType: "APP_STORE", + releaseType: @options[:release_type] + }, + relationships: { + app: { data: { type: "apps", id: @options[:app_id] } } + } + } + ).fetch("data") + end + + def submitted?(version) + SUBMITTED_STATES.include?(version_state(version)) + end + + def version_state(version) + version.dig("attributes", "appVersionState") || version.dig("attributes", "appStoreState") + end + + def update_release_type(version_id) + @client.patch( + "/v1/appStoreVersions/#{version_id}", + data: { + type: "appStoreVersions", + id: version_id, + attributes: { releaseType: @options[:release_type] } + } + ) + end + + def update_release_notes(version_id) + localizations = wait_for_localizations(version_id) + default_note = File.read(File.join(@options[:release_notes_dir], "default.txt"), encoding: "UTF-8").strip + + localizations.each do |localization| + locale = localization.dig("attributes", "locale") + localized_path = File.join(@options[:release_notes_dir], "#{locale}.txt") + note = File.file?(localized_path) ? File.read(localized_path, encoding: "UTF-8").strip : default_note + @client.patch( + "/v1/appStoreVersionLocalizations/#{localization.fetch('id')}", + data: { + type: "appStoreVersionLocalizations", + id: localization.fetch("id"), + attributes: { whatsNew: note } + } + ) + puts "Updated What's New for #{locale}" + end + end + + def wait_for_localizations(version_id) + 6.times do + response = @client.get( + "/v1/appStoreVersions/#{version_id}/appStoreVersionLocalizations", + "limit" => 200 + ) + localizations = Array(response["data"]) + return localizations unless localizations.empty? + + sleep 10 + end + + raise AppStoreConnectError, + "The new App Store version has no localizations. Configure its store metadata in App Store Connect first." + end + + def attach_build(version_id, build_id) + @client.patch( + "/v1/appStoreVersions/#{version_id}/relationships/build", + data: { type: "builds", id: build_id } + ) + puts "Attached build #{build_id} to App Store version #{version_id}" + end + + def submit_for_review(version_id) + existing = review_submission_for(version_id) + if existing + state = existing.dig("attributes", "state") + if state == "READY_FOR_REVIEW" + submit_review(existing.fetch("id")) + else + puts "Review submission is already #{state}" + end + return + end + + submission = @client.post( + "/v1/reviewSubmissions", + data: { + type: "reviewSubmissions", + attributes: { platform: @options[:platform] }, + relationships: { + app: { data: { type: "apps", id: @options[:app_id] } } + } + } + ).fetch("data") + submission_id = submission.fetch("id") + + @client.post( + "/v1/reviewSubmissionItems", + data: { + type: "reviewSubmissionItems", + relationships: { + reviewSubmission: { + data: { type: "reviewSubmissions", id: submission_id } + }, + appStoreVersion: { + data: { type: "appStoreVersions", id: version_id } + } + } + } + ) + submit_review(submission_id) + end + + def review_submission_for(version_id) + response = @client.get( + "/v1/apps/#{@options[:app_id]}/reviewSubmissions", + "filter[platform]" => @options[:platform], + "include" => "appStoreVersionForReview", + "limit" => 50 + ) + Array(response["data"]).find do |submission| + submission.dig("relationships", "appStoreVersionForReview", "data", "id") == version_id + end + end + + def submit_review(submission_id) + @client.patch( + "/v1/reviewSubmissions/#{submission_id}", + data: { + type: "reviewSubmissions", + id: submission_id, + attributes: { submitted: true } + } + ) + end + + def wait_or_timeout! + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + raise AppStoreConnectError, "Timed out waiting for Apple release processing" if now >= @deadline + + sleep [20, @deadline - now].min + end +end + +begin + options = { + timeout: 7200, + 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("--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 } + end.parse! + + required = %i[platform version release_notes_dir] + required += %i[workflow_id app_id tag git_sha] 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]) + raise AppStoreConnectError, "Unsupported platform: #{options[:platform]}" + end + unless %w[MANUAL AFTER_APPROVAL].include?(options[:release_type]) + raise AppStoreConnectError, "Release type must be MANUAL or AFTER_APPROVAL" + end + + client = if options[:validate_only] + nil + else + AppStoreConnectClient.new( + key_id: ENV.fetch("APP_STORE_CONNECT_KEY_ID"), + issuer_id: ENV.fetch("APP_STORE_CONNECT_ISSUER_ID"), + private_key: ENV.fetch("APP_STORE_CONNECT_PRIVATE_KEY") + ) + end + + AppStoreRelease.new(client: client, options: options).run +rescue AppStoreConnectError, KeyError, OpenSSL::PKey::PKeyError => error + warn "::error::#{error.message}" + exit 1 +end diff --git a/.github/workflows/android_build_and_deploy.yaml b/.github/workflows/android_build_and_deploy.yaml index 84fb3c1b..d966758b 100644 --- a/.github/workflows/android_build_and_deploy.yaml +++ b/.github/workflows/android_build_and_deploy.yaml @@ -1,20 +1,69 @@ 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 + 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: - branch: - description: "Branch to deploy" + ref: + description: "Branch or tag to deploy" required: true default: "main" + type: string release_type: - description: "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 permissions: contents: read @@ -25,20 +74,23 @@ jobs: 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 }} env: - RELEASE_TYPE: ${{ github.event.inputs.release_type }} + RELEASE_TYPE: ${{ inputs.release_type }} steps: - name: Checkout repository uses: actions/checkout@v6 with: - ref: ${{ github.event.inputs.branch }} + ref: ${{ inputs.ref }} - - name: Validate and prepare production version bump + - name: Validate release version id: version - if: ${{ github.event.inputs.release_type == 'production' }} shell: bash run: | + set -euo pipefail + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) version=${version//\"/} version=${version//\'/} @@ -52,16 +104,27 @@ jobs: 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 + notes_path="open_wearable/release_notes/${public_version}/default.txt" + if [ ! -s "$notes_path" ]; then + echo "::error::Missing release notes at $notes_path." + exit 1 + fi + 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" - - name: Set up ruby env + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: 3.2.3 - bundler-cache: true - name: Set up Android build uses: ./.github/actions/android-build-prepare @@ -70,44 +133,124 @@ jobs: clean: "true" analyze: "true" - - name: Decode and save OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH + - 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 + echo "OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH=$(realpath android_keystore_temp.keystore)" >> "$GITHUB_ENV" - - name: Decode and save OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH + - 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: Set up fastlane - run: (cd open_wearable/android && bundle install) + echo "OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH=$(realpath android_deployment_key.json)" >> "$GITHUB_ENV" - - name: Build & deploy Android (Internal) - if: ${{ github.event.inputs.release_type == 'internal' }} + - name: Prepare Google Play release notes + if: ${{ inputs.release_type == 'production' }} run: | - cd open_wearable/android - bundle exec fastlane internal_deploy + changelog_dir="open_wearable/android/fastlane/metadata/android/en-US/changelogs" + mkdir -p "$changelog_dir" + cp "open_wearable/release_notes/${{ steps.version.outputs.public_version }}/default.txt" \ + "$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 & deploy Android (Production) - if: ${{ github.event.inputs.release_type == 'production' }} - run: | - cd open_wearable/android - bundle exec fastlane production_deploy + - 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 the Android app to the **%s** track on Google Play.\n' \ - "$RELEASE_TYPE" >> "$GITHUB_STEP_SUMMARY" + 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 released commit + uses: actions/checkout@v6 + with: + ref: ${{ needs.deploy_for_android.outputs.released_sha }} + 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 immutable version tag and GitHub Release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASED_SHA: ${{ needs.deploy_for_android.outputs.released_sha }} + RELEASED_VERSION: ${{ needs.deploy_for_android.outputs.released_version }} + RELEASE_TAG: ${{ needs.deploy_for_android.outputs.release_tag }} + run: | + set -euo pipefail + + public_version=${RELEASED_VERSION%%+*} + notes_path="open_wearable/release_notes/${public_version}/default.txt" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then + git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + tagged_sha=$(git rev-list -n 1 "$RELEASE_TAG") + if [ "$tagged_sha" != "$RELEASED_SHA" ]; then + echo "::error::Tag $RELEASE_TAG already points to $tagged_sha, not $RELEASED_SHA." + exit 1 + fi + else + git tag -a "$RELEASE_TAG" "$RELEASED_SHA" -m "OpenWearable $public_version" + git push origin "$RELEASE_TAG" + fi + + if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + else + gh release create "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + fi + + 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: ${{ github.event.inputs.release_type == 'production' }} + if: ${{ inputs.release_type == 'production' && inputs.create_version_bump_pr }} runs-on: ubuntu-latest permissions: contents: write @@ -122,11 +265,13 @@ jobs: shell: bash env: VERSION_FILE: open_wearable/pubspec.yaml - BASE_BRANCH: ${{ github.event.inputs.branch }} + BASE_BRANCH: ${{ 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" @@ -140,7 +285,6 @@ jobs: --jq '.[0].url // empty') if [ -z "$pr_url" ]; then - # Reuse a branch left by a previous attempt if PR creation failed. remote_branch=$(git ls-remote --heads origin "refs/heads/$bump_branch") if [ -z "$remote_branch" ]; then git switch -c "$bump_branch" @@ -164,5 +308,4 @@ jobs: --body-file "$body_file") fi - printf 'Version bump PR: %s\n\nReview and merge the PR if it is still open. The production upload has already succeeded.\n' \ - "$pr_url" >> "$GITHUB_STEP_SUMMARY" + printf 'Version bump PR: %s\n' "$pr_url" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml new file mode 100644 index 00000000..d086bb9e --- /dev/null +++ b/.github/workflows/ios_build_and_deploy.yaml @@ -0,0 +1,129 @@ +name: iOS Build and Submit to App Store + +on: + push: + tags: + - "v*" + workflow_call: + inputs: + release_tag: + description: "Version tag to release" + required: true + 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: + release_tag: + description: "Version tag to release, for example v1.5.2" + required: true + type: string + release_mode: + description: "When to publish after App Review" + required: true + default: "after_approval" + type: choice + options: + - after_approval + - manual + +permissions: + contents: read + +concurrency: + group: ios-app-store-${{ inputs.release_tag || github.ref_name }} + cancel-in-progress: false + +jobs: + submit_ios: + name: Build in Xcode Cloud and submit iOS + runs-on: ubuntu-latest + timeout-minutes: 130 + env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + 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 + + notes_dir="open_wearable/release_notes/${public_version}" + if [ ! -s "$notes_dir/default.txt" ]; then + echo "::error::Missing 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: Wait for 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" diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml new file mode 100644 index 00000000..2908a9ed --- /dev/null +++ b/.github/workflows/macos_build_and_deploy.yaml @@ -0,0 +1,129 @@ +name: macOS Build and Submit to App Store + +on: + push: + tags: + - "v*" + workflow_call: + inputs: + release_tag: + description: "Version tag to release" + required: true + 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: + release_tag: + description: "Version tag to release, for example v1.5.2" + required: true + type: string + release_mode: + description: "When to publish after App Review" + required: true + default: "after_approval" + type: choice + options: + - after_approval + - manual + +permissions: + contents: read + +concurrency: + group: macos-app-store-${{ inputs.release_tag || github.ref_name }} + cancel-in-progress: false + +jobs: + submit_macos: + name: Build in Xcode Cloud and submit macOS + runs-on: ubuntu-latest + timeout-minutes: 130 + env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + 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 + + notes_dir="open_wearable/release_notes/${public_version}" + if [ ! -s "$notes_dir/default.txt" ]; then + echo "::error::Missing 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: Wait for 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" diff --git a/.github/workflows/release_apk.yml b/.github/workflows/release_apk.yml index 89a6ecd1..e18783d8 100644 --- a/.github/workflows/release_apk.yml +++ b/.github/workflows/release_apk.yml @@ -1,4 +1,4 @@ -name: release apk +name: Android APK Build on: push: @@ -20,52 +20,6 @@ jobs: run: flutter build apk --release - uses: actions/upload-artifact@v6 with: - name: release-apk + name: main-release-apk-${{ github.sha }} path: open_wearable/build/app/outputs/flutter-apk/app-release.apk if-no-files-found: error - - release: - permissions: - contents: write - runs-on: ubuntu-latest - needs: build - steps: - - name: Download APK Artifact - uses: actions/download-artifact@v7 - with: - name: release-apk - path: ${{ github.workspace }}/release-apk - - - name: Create Release and Upload APK - shell: bash - env: - GH_TOKEN: ${{ github.token }} - COMMIT_MESSAGE: ${{ github.event.head_commit.message }} - run: | - set -euo pipefail - - tag_name="${GITHUB_SHA::7}" - commit_subject="${COMMIT_MESSAGE%%$'\n'*}" - if [[ -z "$commit_subject" ]]; then - commit_subject="$tag_name" - fi - release_title="Release ${commit_subject:0:248}" - apk_path="$GITHUB_WORKSPACE/release-apk/app-release.apk" - - if gh release view "$tag_name" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$tag_name" \ - --repo "$GITHUB_REPOSITORY" \ - --title "$release_title" \ - --notes "$COMMIT_MESSAGE" - else - gh release create "$tag_name" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ - --title "$release_title" \ - --notes "$COMMIT_MESSAGE" - fi - - gh release upload "$tag_name" \ - "$apk_path#app-release.apk" \ - --repo "$GITHUB_REPOSITORY" \ - --clobber diff --git a/open_wearable/analysis_options.yaml b/open_wearable/analysis_options.yaml index d2f83007..b58a37db 100644 --- a/open_wearable/analysis_options.yaml +++ b/open_wearable/analysis_options.yaml @@ -1,3 +1,12 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/open_wearable/android/fastlane/Fastfile b/open_wearable/android/fastlane/Fastfile index 8caa2223..422ef316 100644 --- a/open_wearable/android/fastlane/Fastfile +++ b/open_wearable/android/fastlane/Fastfile @@ -33,6 +33,16 @@ platform :android do "android.injected.signing.key.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], } ) + gradle( + task: 'assemble', + build_type: 'Release', + properties: { + "android.injected.signing.store.file" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH"], + "android.injected.signing.store.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => "upload", + "android.injected.signing.key.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], + } + ) upload_to_play_store( track: 'internal', skip_upload_changelogs: false, diff --git a/open_wearable/ios/Runner/Info.plist b/open_wearable/ios/Runner/Info.plist index 677c9991..4be50e63 100644 --- a/open_wearable/ios/Runner/Info.plist +++ b/open_wearable/ios/Runner/Info.plist @@ -48,7 +48,9 @@ NSCameraUsageDescription Camera access may be required by an iOS file selection component used to import firmware files. The app does not use the camera as part of its normal workflow. NSLocationWhenInUseUsageDescription - Location access may be required by an iOS file selection component used to import firmware files. The app does not use location as part of its normal workflow. + Location access may be requested by Bluetooth discovery libraries so the app can find and connect to nearby wearable devices. The app does not use location for tracking. + NSLocationAlwaysAndWhenInUseUsageDescription + Location access may be requested by Bluetooth discovery libraries so the app can find and connect to nearby wearable devices. The app does not use location for tracking. NSPhotoLibraryUsageDescription Needed for optional file selection functionality. UIApplicationSupportsIndirectInputEvents diff --git a/open_wearable/ios/ci_scripts/ci_post_clone.sh b/open_wearable/ios/ci_scripts/ci_post_clone.sh index b9e117f0..2b6fb96b 100755 --- a/open_wearable/ios/ci_scripts/ci_post_clone.sh +++ b/open_wearable/ios/ci_scripts/ci_post_clone.sh @@ -38,6 +38,10 @@ 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. -time flutter build ios --release --config-only +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 index 06bce749..2eb046db 100755 --- a/open_wearable/macos/ci_scripts/ci_post_clone.sh +++ b/open_wearable/macos/ci_scripts/ci_post_clone.sh @@ -36,6 +36,10 @@ 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. -time flutter build macos --release --config-only +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 diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index bc1c03c2..03f9cb80 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -484,10 +484,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -508,10 +508,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -1033,10 +1033,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tuple: dependency: transitive description: @@ -1161,10 +1161,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: diff --git a/open_wearable/release_notes/1.5.2/default.txt b/open_wearable/release_notes/1.5.2/default.txt new file mode 100644 index 00000000..6315446b --- /dev/null +++ b/open_wearable/release_notes/1.5.2/default.txt @@ -0,0 +1 @@ +Improved the stability of live notch filtering and updated Apple platform compatibility. From bfb83244f70ad5734c24961ccfab1d932862e862 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 24 Sep 2026 16:35:59 +0200 Subject: [PATCH 2/9] ci(release): automate cross-platform releases with Xcode 27 support --- .github/scripts/app_store_connect_release.rb | 203 ++++++++++++------ .../workflows/android_build_and_deploy.yaml | 20 +- .../workflows/app_store_build_and_deploy.yaml | 61 ++++++ .github/workflows/ios_build_and_deploy.yaml | 14 +- .github/workflows/macos_build_and_deploy.yaml | 14 +- .github/workflows/release_all_platforms.yaml | 138 ++++++++++++ open_wearable/.flutter_version | 2 +- open_wearable/macos/Podfile | 2 +- open_wearable/macos/Podfile.lock | 6 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- open_wearable/release_notes/default.txt | 1 + 11 files changed, 372 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/app_store_build_and_deploy.yaml create mode 100644 .github/workflows/release_all_platforms.yaml create mode 100644 open_wearable/release_notes/default.txt diff --git a/.github/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb index a350a155..b2989b57 100644 --- a/.github/scripts/app_store_connect_release.rb +++ b/.github/scripts/app_store_connect_release.rb @@ -76,42 +76,43 @@ def request(request_class, path, query: {}, body: nil) uri.query = URI.encode_www_form(query) unless query.empty? attempts = 0 - begin + loop do attempts += 1 - request = request_class.new(uri) - request["Authorization"] = "Bearer #{token}" - request["Accept"] = "application/json" - if body - request["Content-Type"] = "application/json" - request.body = JSON.generate(body) - end + begin + request = request_class.new(uri) + request["Authorization"] = "Bearer #{token}" + request["Accept"] = "application/json" + if body + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + end - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| - http.open_timeout = 30 - http.read_timeout = 60 - http.request(request) - end + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| + http.open_timeout = 30 + http.read_timeout = 60 + http.request(request) + end - if response.code.to_i.between?(200, 299) - return {} if response.body.nil? || response.body.empty? + if response.code.to_i.between?(200, 299) + return {} if response.body.nil? || response.body.empty? - return JSON.parse(response.body) - end + return JSON.parse(response.body) + end + + unless (response.code.to_i == 429 || response.code.to_i >= 500) && attempts < 4 + raise AppStoreConnectError, api_error(response) + end - if (response.code.to_i == 429 || response.code.to_i >= 500) && attempts < 4 delay = [response["retry-after"].to_i, 5 * attempts].max warn "Apple API returned HTTP #{response.code}; retrying in #{delay} seconds" - sleep delay - retry - end + rescue IOError, SocketError, SystemCallError, Timeout::Error => error + raise if attempts >= 4 - raise AppStoreConnectError, api_error(response) - rescue IOError, SocketError, SystemCallError, Timeout::Error => error - raise if attempts >= 4 + delay = 5 * attempts + warn "Apple API request failed (#{error.message}); retrying in #{delay} seconds" + end - warn "Apple API request failed (#{error.message}); retrying" - sleep 5 * attempts - retry + sleep delay end end @@ -150,7 +151,8 @@ def run validate_release_notes! return puts("Release inputs are valid") if @options[:validate_only] - build_run = wait_for_xcode_cloud_run + build_run = start_xcode_cloud_run + wait_for_xcode_cloud_run(build_run.fetch("id")) build = wait_for_processed_build(build_run.fetch("id")) version = find_or_create_version @@ -185,45 +187,62 @@ def validate_note!(path) raise AppStoreConnectError, "Release notes exceed Apple's 4000-character limit: #{path}" end - def wait_for_xcode_cloud_run - puts "Waiting for Xcode Cloud workflow #{@options[:workflow_id]} at #{@options[:tag]}" + 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/ciWorkflows/#{@options[:workflow_id]}/buildRuns", - "sort" => "-number", - "limit" => 25, - "include" => "sourceBranchOrTag" + "/v1/scmRepositories/#{repository.fetch('id')}/gitReferences", + "fields[scmGitReferences]" => "name,canonicalName,isDeleted,kind", + "limit" => 200 ) - references = Array(response["included"]).each_with_object({}) do |resource, index| - index[resource["id"]] = resource if resource["type"] == "scmGitReferences" + reference = Array(response["data"]).find do |candidate| + candidate.dig("attributes", "canonicalName") == canonical_name && + !candidate.dig("attributes", "isDeleted") end - matching_runs = Array(response["data"]).select do |run| - commit_sha = run.dig("attributes", "sourceCommit", "commitSha").to_s - reference_id = run.dig("relationships", "sourceBranchOrTag", "data", "id") - reference = references[reference_id] - names = [reference&.dig("attributes", "name"), reference&.dig("attributes", "canonicalName")] - same_commit = commit_sha == @options[:git_sha] || commit_sha.start_with?(@options[:git_sha]) || - @options[:git_sha].start_with?(commit_sha) - same_commit && names.compact.any? do |name| - name == @options[:tag] || name == "refs/tags/#{@options[:tag]}" - 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 - run = matching_runs.max_by { |candidate| candidate.dig("attributes", "number").to_i } - if run - progress = run.dig("attributes", "executionProgress") - if progress == "COMPLETE" - status = run.dig("attributes", "completionStatus") - raise AppStoreConnectError, "Xcode Cloud build finished with #{status}" unless status == "SUCCEEDED" + 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}" - else - puts "The tag-triggered Xcode Cloud build has not appeared yet" + 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 @@ -313,7 +332,8 @@ def update_release_type(version_id) end def update_release_notes(version_id) - localizations = wait_for_localizations(version_id) + localizations = app_store_version_localizations(version_id) + localizations = copy_previous_localizations(version_id) if localizations.empty? default_note = File.read(File.join(@options[:release_notes_dir], "default.txt"), encoding: "UTF-8").strip localizations.each do |localization| @@ -332,20 +352,63 @@ def update_release_notes(version_id) end end - def wait_for_localizations(version_id) - 6.times do - response = @client.get( - "/v1/appStoreVersions/#{version_id}/appStoreVersionLocalizations", - "limit" => 200 - ) - localizations = Array(response["data"]) - return localizations unless localizations.empty? + def app_store_version_localizations(version_id) + response = @client.get( + "/v1/appStoreVersions/#{version_id}/appStoreVersionLocalizations", + "limit" => 200 + ) + Array(response["data"]) + end + + def copy_previous_localizations(version_id) + source_localizations = previous_version_localizations(version_id) + raise AppStoreConnectError, + "No prior App Store version localizations are available to seed version #{@options[:version]}" if source_localizations.empty? + + default_note = File.read(File.join(@options[:release_notes_dir], "default.txt"), encoding: "UTF-8").strip + source_localizations.map do |source| + locale = source.dig("attributes", "locale") + localized_path = File.join(@options[:release_notes_dir], "#{locale}.txt") + note = File.file?(localized_path) ? File.read(localized_path, encoding: "UTF-8").strip : default_note + attributes = source.fetch("attributes", {}).slice( + "description", + "keywords", + "marketingUrl", + "promotionalText", + "supportUrl" + ).compact + attributes.merge!("locale" => locale, "whatsNew" => note) + + localization = @client.post( + "/v1/appStoreVersionLocalizations", + data: { + type: "appStoreVersionLocalizations", + attributes: attributes, + relationships: { + appStoreVersion: { data: { type: "appStoreVersions", id: version_id } } + } + } + ).fetch("data") + puts "Created App Store localization for #{locale}" + localization + end + end - sleep 10 + def previous_version_localizations(version_id) + response = @client.get( + "/v1/apps/#{@options[:app_id]}/appStoreVersions", + "filter[platform]" => @options[:platform], + "sort" => "-versionString", + "limit" => 200 + ) + Array(response["data"]).each do |version| + next if version.fetch("id") == version_id + + localizations = app_store_version_localizations(version.fetch("id")) + return localizations unless localizations.empty? end - raise AppStoreConnectError, - "The new App Store version has no localizations. Configure its store metadata in App Store Connect first." + [] end def attach_build(version_id, build_id) diff --git a/.github/workflows/android_build_and_deploy.yaml b/.github/workflows/android_build_and_deploy.yaml index d966758b..419a6f31 100644 --- a/.github/workflows/android_build_and_deploy.yaml +++ b/.github/workflows/android_build_and_deploy.yaml @@ -77,6 +77,7 @@ jobs: 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: @@ -108,11 +109,19 @@ jobs: next_version="${major}.${minor}.$((10#$patch + 1))${build_suffix}" if [ "$RELEASE_TYPE" = "production" ]; then - notes_path="open_wearable/release_notes/${public_version}/default.txt" + 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 - echo "::error::Missing release notes at $notes_path." + 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" @@ -120,6 +129,7 @@ jobs: 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 @@ -148,8 +158,7 @@ jobs: run: | changelog_dir="open_wearable/android/fastlane/metadata/android/en-US/changelogs" mkdir -p "$changelog_dir" - cp "open_wearable/release_notes/${{ steps.version.outputs.public_version }}/default.txt" \ - "$changelog_dir/default.txt" + cp "${{ steps.version.outputs.release_notes_path }}" "$changelog_dir/default.txt" - name: Set up Fastlane working-directory: open_wearable/android @@ -209,11 +218,12 @@ jobs: RELEASED_SHA: ${{ needs.deploy_for_android.outputs.released_sha }} RELEASED_VERSION: ${{ needs.deploy_for_android.outputs.released_version }} RELEASE_TAG: ${{ needs.deploy_for_android.outputs.release_tag }} + NOTES_PATH: ${{ needs.deploy_for_android.outputs.release_notes_path }} run: | set -euo pipefail public_version=${RELEASED_VERSION%%+*} - notes_path="open_wearable/release_notes/${public_version}/default.txt" + notes_path="$NOTES_PATH" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml new file mode 100644 index 00000000..dc5a5e32 --- /dev/null +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -0,0 +1,61 @@ +name: Apple App Store Build and Submit + +on: + workflow_call: + inputs: + release_tag: + description: "Existing version tag to release" + required: true + type: string + release_mode: + description: "When to publish after App Review" + 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 + XCODE_CLOUD_MACOS_WORKFLOW_ID: + required: true + workflow_dispatch: + inputs: + release_tag: + description: "Existing version tag to release, for example v1.5.2" + required: true + type: string + release_mode: + description: "When to publish after App Review" + required: true + default: "after_approval" + type: choice + options: + - after_approval + - manual + +permissions: + contents: read + +jobs: + submit_ios: + name: Build and submit iOS + uses: ./.github/workflows/ios_build_and_deploy.yaml + with: + release_tag: ${{ inputs.release_tag }} + release_mode: ${{ inputs.release_mode }} + secrets: inherit + + submit_macos: + name: Build and submit macOS + uses: ./.github/workflows/macos_build_and_deploy.yaml + with: + release_tag: ${{ inputs.release_tag }} + release_mode: ${{ inputs.release_mode }} + secrets: inherit diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml index d086bb9e..76c240cc 100644 --- a/.github/workflows/ios_build_and_deploy.yaml +++ b/.github/workflows/ios_build_and_deploy.yaml @@ -1,9 +1,6 @@ name: iOS Build and Submit to App Store on: - push: - tags: - - "v*" workflow_call: inputs: release_tag: @@ -80,9 +77,14 @@ jobs: exit 1 fi - notes_dir="open_wearable/release_notes/${public_version}" + version_notes_dir="open_wearable/release_notes/${public_version}" + notes_dir="$version_notes_dir" if [ ! -s "$notes_dir/default.txt" ]; then - echo "::error::Missing release notes at $notes_dir/default.txt." + 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 @@ -105,7 +107,7 @@ jobs: with: ruby-version: 3.2.3 - - name: Wait for Xcode Cloud and submit to App Review + - 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 }} diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml index 2908a9ed..31a3910b 100644 --- a/.github/workflows/macos_build_and_deploy.yaml +++ b/.github/workflows/macos_build_and_deploy.yaml @@ -1,9 +1,6 @@ name: macOS Build and Submit to App Store on: - push: - tags: - - "v*" workflow_call: inputs: release_tag: @@ -80,9 +77,14 @@ jobs: exit 1 fi - notes_dir="open_wearable/release_notes/${public_version}" + version_notes_dir="open_wearable/release_notes/${public_version}" + notes_dir="$version_notes_dir" if [ ! -s "$notes_dir/default.txt" ]; then - echo "::error::Missing release notes at $notes_dir/default.txt." + 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 @@ -105,7 +107,7 @@ jobs: with: ruby-version: 3.2.3 - - name: Wait for Xcode Cloud and submit to App Review + - 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 }} diff --git a/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml new file mode 100644 index 00000000..40b3cf42 --- /dev/null +++ b/.github/workflows/release_all_platforms.yaml @@ -0,0 +1,138 @@ +name: Release All Platforms + +on: + workflow_dispatch: + inputs: + ref: + description: "Branch or tag to release" + required: true + default: "main" + type: string + platforms: + description: "Platforms to release" + required: true + default: "all" + type: choice + options: + - all + - android + - app_store + release_mode: + description: "When to publish Apple releases after App Review" + required: true + default: "after_approval" + type: choice + options: + - after_approval + - manual + create_version_bump_pr: + description: "Open the post-release version bump PR" + required: true + default: true + type: boolean + +permissions: + contents: write + pull-requests: write + +jobs: + prepare_apple_release: + name: Tag Apple-only release + if: ${{ inputs.platforms == 'app_store' }} + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release.outputs.release_tag }} + steps: + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Create or verify immutable version tag + id: release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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 + + public_version=${version%%+*} + release_tag="v${public_version}" + released_sha=$(git rev-parse HEAD) + 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 + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then + git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" + tagged_sha=$(git rev-list -n 1 "$release_tag") + if [ "$tagged_sha" != "$released_sha" ]; then + echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha." + exit 1 + fi + else + git tag -a "$release_tag" "$released_sha" -m "OpenWearable $public_version" + git push origin "$release_tag" + fi + + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + else + gh release create "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + + release_android: + name: Release Android + if: ${{ inputs.platforms == 'all' || inputs.platforms == 'android' }} + uses: ./.github/workflows/android_build_and_deploy.yaml + with: + ref: ${{ inputs.ref }} + release_type: production + publish_github_release: true + create_version_bump_pr: ${{ inputs.create_version_bump_pr }} + secrets: inherit + + release_apple: + name: Release iOS and macOS + if: >- + ${{ always() && (inputs.platforms == 'all' || inputs.platforms == 'app_store') && + (inputs.platforms == 'app_store' || needs.release_android.result == 'success') && + (inputs.platforms == 'all' || needs.prepare_apple_release.result == 'success') }} + needs: + - prepare_apple_release + - release_android + uses: ./.github/workflows/app_store_build_and_deploy.yaml + with: + release_tag: ${{ inputs.platforms == 'app_store' && needs.prepare_apple_release.outputs.release_tag || needs.release_android.outputs.release_tag }} + release_mode: ${{ inputs.release_mode }} + secrets: inherit diff --git a/open_wearable/.flutter_version b/open_wearable/.flutter_version index faf0dcbb..60d55fb0 100644 --- a/open_wearable/.flutter_version +++ b/open_wearable/.flutter_version @@ -1 +1 @@ -3.44.0 +3.47.4 diff --git a/open_wearable/macos/Podfile b/open_wearable/macos/Podfile index ccf76e3f..895d6cb0 100644 --- a/open_wearable/macos/Podfile +++ b/open_wearable/macos/Podfile @@ -1,4 +1,4 @@ -macos_deployment_target = '10.15' +macos_deployment_target = '12.0' platform :osx, macos_deployment_target # CocoaPods analytics sends network stats synchronously affecting flutter build latency. diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 303eb239..6eca944a 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -30,13 +30,13 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/mcumgr_flutter/darwin SPEC CHECKSUMS: - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e iOSMcuManagerLibrary: f72db849f6dd66b1f26cd7eea776050a22c8591c mcumgr_flutter: b9864b2ae8334b6ba2b33f9ab4fc0a67bbe75c7a SwiftCBOR: aa87349b4ccddd19f861aa544f7c27d43dee5772 SwiftProtobuf: 3fafd1b2fb97e6d95ad9c8adb2215da9afec7c83 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c -PODFILE CHECKSUM: 463a7f221fb14c9e3a027094353cdef1b5c69604 +PODFILE CHECKSUM: b8959a7c9c7d85e56c248baab1b0a5907867aa6f -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/open_wearable/macos/Runner.xcodeproj/project.pbxproj b/open_wearable/macos/Runner.xcodeproj/project.pbxproj index 12000fa5..d338bd96 100644 --- a/open_wearable/macos/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/macos/Runner.xcodeproj/project.pbxproj @@ -566,7 +566,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -649,7 +649,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -699,7 +699,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/open_wearable/release_notes/default.txt b/open_wearable/release_notes/default.txt new file mode 100644 index 00000000..6b2ef309 --- /dev/null +++ b/open_wearable/release_notes/default.txt @@ -0,0 +1 @@ +This release includes improvements and bug fixes. From c9bee5609f5795ccff1d1efc515b570e1c8688da Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 24 Sep 2026 16:36:05 +0200 Subject: [PATCH 3/9] fix(platform): modernize dependencies and project settings for Xcode 27 --- .../workflows/app_store_build_and_deploy.yaml | 1 - open_wearable/android/settings.gradle | 2 +- open_wearable/ios/Podfile.lock | 4 +- .../ios/Runner.xcodeproj/project.pbxproj | 13 ++- .../xcshareddata/swiftpm/Package.resolved | 54 --------- .../xcshareddata/xcodecloud/manifest.json | 9 ++ .../xcshareddata/swiftpm/Package.resolved | 54 --------- open_wearable/ios/Runner/Info.plist | 16 +-- .../fota/firmware_select/firmware_list.dart | 8 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- open_wearable/pubspec.lock | 104 +++++++++++++----- open_wearable/pubspec.yaml | 10 +- 12 files changed, 116 insertions(+), 161 deletions(-) create mode 100644 open_wearable/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index dc5a5e32..198083e3 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -39,7 +39,6 @@ on: options: - after_approval - manual - permissions: contents: read diff --git a/open_wearable/android/settings.gradle b/open_wearable/android/settings.gradle index 2fe3a94e..4c7c25c5 100644 --- a/open_wearable/android/settings.gradle +++ b/open_wearable/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.12.0" apply false + id "com.android.application" version "8.12.1" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false } diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 01644bee..6b286ff3 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -36,7 +36,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/mcumgr_flutter/darwin" SPEC CHECKSUMS: - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a iOSMcuManagerLibrary: f72db849f6dd66b1f26cd7eea776050a22c8591c mcumgr_flutter: b9864b2ae8334b6ba2b33f9ab4fc0a67bbe75c7a @@ -46,4 +46,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 922e9d2984ff13b7650f7be55ac226e7ee85ec0e -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/open_wearable/ios/Runner.xcodeproj/project.pbxproj b/open_wearable/ios/Runner.xcodeproj/project.pbxproj index 78dd0854..ff10b788 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -245,7 +245,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -347,10 +347,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -505,6 +509,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.kit.teco.openWearable.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -523,6 +528,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.kit.teco.openWearable.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -539,6 +545,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = "$(RECOMMENDED_IPHONEOS_DEPLOYMENT_TARGET)"; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.kit.teco.openWearable.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -743,7 +750,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c834a06b..51e02a31 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/open_wearable/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,59 +1,5 @@ { "pins" : [ - { - "identity" : "dkcamera", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKCamera", - "state" : { - "branch" : "master", - "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" - } - }, - { - "identity" : "dkimagepickercontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKImagePickerController", - "state" : { - "branch" : "4.3.9", - "revision" : "0bdfeacefa308545adde07bef86e349186335915" - } - }, - { - "identity" : "dkphotogallery", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKPhotoGallery", - "state" : { - "branch" : "master", - "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" - } - }, - { - "identity" : "sdwebimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage", - "state" : { - "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", - "version" : "5.21.7" - } - }, - { - "identity" : "swiftygif", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kirualex/SwiftyGif.git", - "state" : { - "revision" : "4430cbc148baa3907651d40562d96325426f409a", - "version" : "5.4.5" - } - }, - { - "identity" : "tocropviewcontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/TimOliver/TOCropViewController", - "state" : { - "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", - "version" : "2.8.0" - } - }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json b/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..bbbccb2a --- /dev/null +++ b/open_wearable/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "a0319be9-78d7-470a-8d38-eccf3355ae20", + "targets" : [ + { + "id" : "5D6E7D70-9D8F-425C-9727-AD60401DE764", + "name" : "open_wearable" + } + ] +} \ No newline at end of file diff --git a/open_wearable/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/open_wearable/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index c834a06b..51e02a31 100644 --- a/open_wearable/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/open_wearable/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,59 +1,5 @@ { "pins" : [ - { - "identity" : "dkcamera", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKCamera", - "state" : { - "branch" : "master", - "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" - } - }, - { - "identity" : "dkimagepickercontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKImagePickerController", - "state" : { - "branch" : "4.3.9", - "revision" : "0bdfeacefa308545adde07bef86e349186335915" - } - }, - { - "identity" : "dkphotogallery", - "kind" : "remoteSourceControl", - "location" : "https://github.com/zhangao0086/DKPhotoGallery", - "state" : { - "branch" : "master", - "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" - } - }, - { - "identity" : "sdwebimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SDWebImage/SDWebImage", - "state" : { - "revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0", - "version" : "5.21.7" - } - }, - { - "identity" : "swiftygif", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kirualex/SwiftyGif.git", - "state" : { - "revision" : "4430cbc148baa3907651d40562d96325426f409a", - "version" : "5.4.5" - } - }, - { - "identity" : "tocropviewcontroller", - "kind" : "remoteSourceControl", - "location" : "https://github.com/TimOliver/TOCropViewController", - "state" : { - "revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e", - "version" : "2.8.0" - } - }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/open_wearable/ios/Runner/Info.plist b/open_wearable/ios/Runner/Info.plist index 4be50e63..7074c17b 100644 --- a/open_wearable/ios/Runner/Info.plist +++ b/open_wearable/ios/Runner/Info.plist @@ -39,18 +39,18 @@ This app uses Bluetooth to connect to wearable devices. NSBluetoothPeripheralUsageDescription This app requires Bluetooth access to communicate with wearable devices. - NSLocalNetworkUsageDescription - This app uses the local network to host a webserver for tools integration. - NSMotionUsageDescription - This app requires access to device motion in order to provide sensor data. - NSMicrophoneUsageDescription - This app records microphone audio with local sensor recording sessions. NSCameraUsageDescription Camera access may be required by an iOS file selection component used to import firmware files. The app does not use the camera as part of its normal workflow. - NSLocationWhenInUseUsageDescription - Location access may be requested by Bluetooth discovery libraries so the app can find and connect to nearby wearable devices. The app does not use location for tracking. + NSLocalNetworkUsageDescription + This app uses the local network to host a webserver for tools integration. NSLocationAlwaysAndWhenInUseUsageDescription Location access may be requested by Bluetooth discovery libraries so the app can find and connect to nearby wearable devices. The app does not use location for tracking. + NSLocationWhenInUseUsageDescription + Location access may be requested by Bluetooth discovery libraries so the app can find and connect to nearby wearable devices. The app does not use location for tracking. + NSMicrophoneUsageDescription + This app records microphone audio with local sensor recording sessions. + NSMotionUsageDescription + This app requires access to device motion in order to provide sensor data. NSPhotoLibraryUsageDescription Needed for optional file selection functionality. UIApplicationSupportsIndirectInputEvents diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index fcb41681..65fa53a1 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -140,17 +140,17 @@ class _FirmwareListState extends State { if (confirmed != true || !mounted) return; - FilePickerResult? result = await FilePicker.pickFiles( + final files = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: ['zip', 'bin'], ); - if (result == null || !mounted) return; + if (files.isEmpty || !mounted) return; - final ext = result.files.first.extension; + final firstResult = files.first; + final ext = firstResult.extension; final fwType = ext == 'zip' ? FirmwareType.multiImage : FirmwareType.singleImage; - final firstResult = result.files.first; final path = firstResult.path; if (path == null || path.isEmpty) { return; diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index fe2eeb85..53279f2f 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,7 +7,7 @@ import Foundation import audioplayers_darwin import device_info_plus -import file_picker +import file_picker_darwin import flutter_archive import mcumgr_flutter import open_file_mac diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 03f9cb80..61a2877e 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" archive: dependency: transitive description: @@ -197,18 +205,18 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" url: "https://pub.dev" source: hosted - version: "12.4.0" + version: "13.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" url: "https://pub.dev" source: hosted - version: "7.0.3" + version: "8.1.0" equatable: dependency: transitive description: @@ -233,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -245,10 +261,42 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" url: "https://pub.dev" source: hosted - version: "11.0.2" + version: "13.1.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "51aef9f4c80449c736e7cc02198675fda73689f0bd45a84da642cfeab0250d46" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: e7db600f50672ce5ebbe422ac0906e0f3d2a476188cc93e7126afee12bf08063 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496 + url: "https://pub.dev" + source: hosted + version: "4.0.0" fixnum: dependency: transitive description: @@ -318,14 +366,6 @@ packages: url: "https://pub.dev" source: hosted version: "10.0.1" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_staggered_grid_view: dependency: "direct main" description: @@ -628,18 +668,18 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.dev" source: hosted - version: "9.0.1" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: "direct main" description: @@ -908,18 +948,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" url: "https://pub.dev" source: hosted - version: "12.0.2" + version: "13.3.0" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -1177,10 +1217,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + sha256: "22b3e7e937721de70e63c85e7139f4ac781dc22863b9262431a53ac030eb074b" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.8.0" wakelock_plus_platform_interface: dependency: transitive description: @@ -1201,18 +1241,26 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "3.0.3" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index db60444e..fa0dee12 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -47,18 +47,18 @@ dependencies: flutter_staggered_grid_view: ^0.7.0 flutter_bloc: ^9.1.1 fl_chart: ^1.2.0 - file_picker: ^11.0.2 + file_picker: ^13.1.0 mcumgr_flutter: ^0.9.1 path_provider: ^2.1.5 - share_plus: ^12.0.2 + share_plus: ^13.3.0 shared_preferences: ^2.5.5 url_launcher: ^6.3.2 go_router: ^17.2.3 audioplayers: ^6.7.0 wakelock_plus: ^1.5.2 - package_info_plus: ^9.0.1 + package_info_plus: ^10.2.1 sensors_plus: ^7.0.0 - device_info_plus: ^12.4.0 + device_info_plus: ^13.2.0 pub_semver: ^2.2.0 flutter_headset_detector: ^3.1.0 record: ^7.0.0 @@ -77,7 +77,7 @@ dev_dependencies: flutter_lints: ^6.0.0 dependency_overrides: - file_picker: 11.0.2 + file_picker: 13.1.0 mcumgr_flutter: 0.9.1 universal_ble: 2.0.2 From f09553f909a4c8ae96d31f86b49dfbc7280395cf Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Thu, 24 Sep 2026 16:45:49 +0200 Subject: [PATCH 4/9] ci(release): temporarily trigger App Store submissions from branch push --- .../workflows/app_store_build_and_deploy.yaml | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index 198083e3..e719ad11 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -39,22 +39,81 @@ on: options: - after_approval - manual + push: + branches: + - app-store-deploy-workflow + permissions: contents: read jobs: + prepare_push_release: + name: Tag branch build + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + release_tag: ${{ steps.release.outputs.release_tag }} + steps: + - name: Checkout pushed commit + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Create or verify version tag + id: release + 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 + + public_version=${version%%+*} + release_tag="v${public_version}" + released_sha=$(git rev-parse HEAD) + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then + git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" + tagged_sha=$(git rev-list -n 1 "$release_tag") + if [ "$tagged_sha" != "$released_sha" ]; then + echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha." + exit 1 + fi + else + git tag -a "$release_tag" "$released_sha" -m "OpenWearable $public_version" + git push origin "$release_tag" + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + submit_ios: name: Build and submit iOS + needs: prepare_push_release + if: ${{ always() && (github.event_name != 'push' || needs.prepare_push_release.result == 'success') }} uses: ./.github/workflows/ios_build_and_deploy.yaml with: - release_tag: ${{ inputs.release_tag }} - release_mode: ${{ inputs.release_mode }} + release_tag: ${{ github.event_name == 'push' && needs.prepare_push_release.outputs.release_tag || inputs.release_tag }} + release_mode: ${{ github.event_name == 'push' && 'after_approval' || inputs.release_mode }} secrets: inherit submit_macos: name: Build and submit macOS + needs: prepare_push_release + if: ${{ always() && (github.event_name != 'push' || needs.prepare_push_release.result == 'success') }} uses: ./.github/workflows/macos_build_and_deploy.yaml with: - release_tag: ${{ inputs.release_tag }} - release_mode: ${{ inputs.release_mode }} + release_tag: ${{ github.event_name == 'push' && needs.prepare_push_release.outputs.release_tag || inputs.release_tag }} + release_mode: ${{ github.event_name == 'push' && 'after_approval' || inputs.release_mode }} secrets: inherit From 387243ca9232ab0e16789189e6bb85852f4b25f0 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 25 Sep 2026 16:23:30 +0200 Subject: [PATCH 5/9] ci(release): remove automatic App Store release on push --- .../workflows/app_store_build_and_deploy.yaml | 67 ++----------------- 1 file changed, 4 insertions(+), 63 deletions(-) diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index e719ad11..198083e3 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -39,81 +39,22 @@ on: options: - after_approval - manual - push: - branches: - - app-store-deploy-workflow - permissions: contents: read jobs: - prepare_push_release: - name: Tag branch build - if: ${{ github.event_name == 'push' }} - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - release_tag: ${{ steps.release.outputs.release_tag }} - steps: - - name: Checkout pushed commit - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - - name: Create or verify version tag - id: release - 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 - - public_version=${version%%+*} - release_tag="v${public_version}" - released_sha=$(git rev-parse HEAD) - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then - git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" - tagged_sha=$(git rev-list -n 1 "$release_tag") - if [ "$tagged_sha" != "$released_sha" ]; then - echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha." - exit 1 - fi - else - git tag -a "$release_tag" "$released_sha" -m "OpenWearable $public_version" - git push origin "$release_tag" - fi - - echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" - submit_ios: name: Build and submit iOS - needs: prepare_push_release - if: ${{ always() && (github.event_name != 'push' || needs.prepare_push_release.result == 'success') }} uses: ./.github/workflows/ios_build_and_deploy.yaml with: - release_tag: ${{ github.event_name == 'push' && needs.prepare_push_release.outputs.release_tag || inputs.release_tag }} - release_mode: ${{ github.event_name == 'push' && 'after_approval' || inputs.release_mode }} + release_tag: ${{ inputs.release_tag }} + release_mode: ${{ inputs.release_mode }} secrets: inherit submit_macos: name: Build and submit macOS - needs: prepare_push_release - if: ${{ always() && (github.event_name != 'push' || needs.prepare_push_release.result == 'success') }} uses: ./.github/workflows/macos_build_and_deploy.yaml with: - release_tag: ${{ github.event_name == 'push' && needs.prepare_push_release.outputs.release_tag || inputs.release_tag }} - release_mode: ${{ github.event_name == 'push' && 'after_approval' || inputs.release_mode }} + release_tag: ${{ inputs.release_tag }} + release_mode: ${{ inputs.release_mode }} secrets: inherit From 832a940ec2452de49c7b9b42d8d85a5157bc344d Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 25 Sep 2026 16:41:25 +0200 Subject: [PATCH 6/9] fix(release): build signed APK for Android production releases --- open_wearable/android/fastlane/Fastfile | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/open_wearable/android/fastlane/Fastfile b/open_wearable/android/fastlane/Fastfile index 422ef316..646b6a0f 100644 --- a/open_wearable/android/fastlane/Fastfile +++ b/open_wearable/android/fastlane/Fastfile @@ -68,6 +68,16 @@ platform :android do "android.injected.signing.key.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], } ) + gradle( + task: 'assemble', + build_type: 'Release', + properties: { + "android.injected.signing.store.file" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH"], + "android.injected.signing.store.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], + "android.injected.signing.key.alias" => "upload", + "android.injected.signing.key.password" => ENV["OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD"], + } + ) upload_to_play_store( track: 'production', skip_upload_changelogs: false, From 8b081c95faac0395b25a2623fc103c53468b0d2f Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 25 Sep 2026 17:19:45 +0200 Subject: [PATCH 7/9] feat(release): unify version tag handling across release workflows --- .../prepare-versioned-release/action.yml | 88 +++++++++++++++++++ .../workflows/app_store_build_and_deploy.yaml | 47 ++++++++-- .github/workflows/ios_build_and_deploy.yaml | 45 ++++++++-- .github/workflows/macos_build_and_deploy.yaml | 45 ++++++++-- .github/workflows/release_all_platforms.yaml | 69 +++------------ 5 files changed, 221 insertions(+), 73 deletions(-) create mode 100644 .github/actions/prepare-versioned-release/action.yml diff --git a/.github/actions/prepare-versioned-release/action.yml b/.github/actions/prepare-versioned-release/action.yml new file mode 100644 index 00000000..de04784e --- /dev/null +++ b/.github/actions/prepare-versioned-release/action.yml @@ -0,0 +1,88 @@ +name: Prepare versioned release +description: Create or verify the immutable version tag and GitHub Release for the checked-out commit. + +inputs: + source-directory: + description: Repository directory that contains the release source. + required: false + default: . + +outputs: + release_tag: + description: The immutable tag for the version in pubspec.yaml. + value: ${{ steps.prepare.outputs.release_tag }} + +runs: + using: composite + steps: + - id: prepare + shell: bash + run: | + set -euo pipefail + cd "${{ inputs.source-directory }}" + + 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 + + public_version=${version%%+*} + release_tag="v${public_version}" + released_sha=$(git rev-parse HEAD) + 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 + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then + git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" + tagged_sha=$(git rev-list -n 1 "$release_tag") + if [ "$tagged_sha" != "$released_sha" ]; then + echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha. Select $release_tag as the release ref to use the existing version." + exit 1 + fi + else + git tag -a "$release_tag" "$released_sha" -m "OpenWearable $public_version" + if ! git push origin "$release_tag"; then + git fetch --force origin "refs/tags/$release_tag:refs/tags/$release_tag" + tagged_sha=$(git rev-list -n 1 "$release_tag") + if [ "$tagged_sha" != "$released_sha" ]; then + echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha. Select $release_tag as the release ref to use the existing version." + exit 1 + fi + fi + fi + + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + else + if ! gh release create "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path"; then + gh release edit "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + fi + fi + + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index 198083e3..27843429 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -7,6 +7,10 @@ on: 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 @@ -27,9 +31,10 @@ on: required: true workflow_dispatch: inputs: - release_tag: - description: "Existing version tag to release, for example v1.5.2" + ref: + description: "Branch or existing release tag to release" required: true + default: "main" type: string release_mode: description: "When to publish after App Review" @@ -40,21 +45,53 @@ on: - after_approval - manual permissions: - contents: read + 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.release_tag }} + 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.release_tag }} + release_tag: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }} release_mode: ${{ inputs.release_mode }} secrets: inherit diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml index 76c240cc..435e68a1 100644 --- a/.github/workflows/ios_build_and_deploy.yaml +++ b/.github/workflows/ios_build_and_deploy.yaml @@ -7,6 +7,10 @@ on: 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 @@ -25,9 +29,10 @@ on: required: true workflow_dispatch: inputs: - release_tag: - description: "Version tag to release, for example v1.5.2" + ref: + description: "Branch or existing release tag to release" required: true + default: "main" type: string release_mode: description: "When to publish after App Review" @@ -39,19 +44,49 @@ on: - manual permissions: - contents: read + contents: write concurrency: - group: ios-app-store-${{ inputs.release_tag || github.ref_name }} + 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.release_tag || github.ref_name }} + 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 diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml index 31a3910b..e5632d24 100644 --- a/.github/workflows/macos_build_and_deploy.yaml +++ b/.github/workflows/macos_build_and_deploy.yaml @@ -7,6 +7,10 @@ on: 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 @@ -25,9 +29,10 @@ on: required: true workflow_dispatch: inputs: - release_tag: - description: "Version tag to release, for example v1.5.2" + ref: + description: "Branch or existing release tag to release" required: true + default: "main" type: string release_mode: description: "When to publish after App Review" @@ -39,19 +44,49 @@ on: - manual permissions: - contents: read + contents: write concurrency: - group: macos-app-store-${{ inputs.release_tag || github.ref_name }} + 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.release_tag || github.ref_name }} + 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 diff --git a/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index 40b3cf42..e0e75996 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -43,73 +43,26 @@ jobs: 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 - shell: bash + uses: ./workflow-source/.github/actions/prepare-versioned-release + with: + source-directory: release-source env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - 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 - - public_version=${version%%+*} - release_tag="v${public_version}" - released_sha=$(git rev-parse HEAD) - 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 - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if git ls-remote --exit-code --tags origin "refs/tags/$release_tag" >/dev/null 2>&1; then - git fetch origin "refs/tags/$release_tag:refs/tags/$release_tag" - tagged_sha=$(git rev-list -n 1 "$release_tag") - if [ "$tagged_sha" != "$released_sha" ]; then - echo "::error::Tag $release_tag already points to $tagged_sha, not $released_sha." - exit 1 - fi - else - git tag -a "$release_tag" "$released_sha" -m "OpenWearable $public_version" - git push origin "$release_tag" - fi - - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - else - gh release create "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - fi - - echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + GH_TOKEN: ${{ github.token }} release_android: name: Release Android From 633b9582fe4af88ef0e0c1e17d6c842e4af739bb Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 25 Sep 2026 17:36:31 +0200 Subject: [PATCH 8/9] fix(ci): publish GitHub releases after successful Apple submission --- .../prepare-versioned-release/action.yml | 20 +----- .../publish-versioned-release/action.yml | 69 +++++++++++++++++++ .../workflows/android_build_and_deploy.yaml | 12 +++- .../workflows/app_store_build_and_deploy.yaml | 34 +++++++++ .github/workflows/ios_build_and_deploy.yaml | 27 ++++++++ .github/workflows/macos_build_and_deploy.yaml | 27 ++++++++ .github/workflows/release_all_platforms.yaml | 22 +++--- 7 files changed, 182 insertions(+), 29 deletions(-) create mode 100644 .github/actions/publish-versioned-release/action.yml diff --git a/.github/actions/prepare-versioned-release/action.yml b/.github/actions/prepare-versioned-release/action.yml index de04784e..e342fb0e 100644 --- a/.github/actions/prepare-versioned-release/action.yml +++ b/.github/actions/prepare-versioned-release/action.yml @@ -1,5 +1,5 @@ name: Prepare versioned release -description: Create or verify the immutable version tag and GitHub Release for the checked-out commit. +description: Create or verify the immutable version tag for the checked-out commit. inputs: source-directory: @@ -67,22 +67,4 @@ runs: fi fi - if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - else - if ! gh release create "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path"; then - gh release edit "$release_tag" \ - --repo "$GITHUB_REPOSITORY" \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - fi - fi - echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/publish-versioned-release/action.yml b/.github/actions/publish-versioned-release/action.yml new file mode 100644 index 00000000..a1c8460e --- /dev/null +++ b/.github/actions/publish-versioned-release/action.yml @@ -0,0 +1,69 @@ +name: Publish versioned release +description: Create or update the GitHub Release for a verified release tag. + +inputs: + source-directory: + description: Repository directory checked out at the release tag. + required: false + default: . + release-tag: + description: Immutable release tag to publish. + required: true + +runs: + using: composite + steps: + - shell: bash + run: | + set -euo pipefail + cd "${{ inputs.source-directory }}" + + version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml) + version=${version//\"/} + version=${version//\'/} + public_version=${version%%+*} + expected_tag="v${public_version}" + release_tag="${{ inputs.release-tag }}" + + if [ "$release_tag" != "$expected_tag" ]; then + echo "::error::Tag $release_tag does not match pubspec version $public_version." + exit 1 + fi + + tagged_sha=$(git rev-list -n 1 "$release_tag") + if [ "$tagged_sha" != "$(git rev-parse HEAD)" ]; then + echo "::error::Release source is not checked out at $release_tag." + exit 1 + fi + + 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 + + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release edit "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + else + if ! gh release create "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --verify-tag \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path"; then + # Another successful platform workflow may have published the + # release between the view and create calls. + gh release edit "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "OpenWearable $public_version" \ + --notes-file "$notes_path" + fi + fi diff --git a/.github/workflows/android_build_and_deploy.yaml b/.github/workflows/android_build_and_deploy.yaml index 419a6f31..ef36e36d 100644 --- a/.github/workflows/android_build_and_deploy.yaml +++ b/.github/workflows/android_build_and_deploy.yaml @@ -21,6 +21,11 @@ on: 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 }} @@ -64,6 +69,11 @@ on: 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 @@ -275,7 +285,7 @@ jobs: shell: bash env: VERSION_FILE: open_wearable/pubspec.yaml - BASE_BRANCH: ${{ inputs.ref }} + 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 }} diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index 27843429..56c12394 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -95,3 +95,37 @@ jobs: 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 }} diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml index 435e68a1..d279a1dd 100644 --- a/.github/workflows/ios_build_and_deploy.yaml +++ b/.github/workflows/ios_build_and_deploy.yaml @@ -164,3 +164,30 @@ jobs: 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 index e5632d24..16e44c60 100644 --- a/.github/workflows/macos_build_and_deploy.yaml +++ b/.github/workflows/macos_build_and_deploy.yaml @@ -164,3 +164,30 @@ jobs: 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/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index e0e75996..c610a9af 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -36,9 +36,9 @@ permissions: pull-requests: write jobs: - prepare_apple_release: - name: Tag Apple-only release - if: ${{ inputs.platforms == 'app_store' }} + prepare_release: + name: Create or verify release tag + if: ${{ inputs.platforms == 'all' || inputs.platforms == 'app_store' }} runs-on: ubuntu-latest outputs: release_tag: ${{ steps.release.outputs.release_tag }} @@ -66,13 +66,17 @@ jobs: release_android: name: Release Android - if: ${{ inputs.platforms == 'all' || inputs.platforms == '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.ref }} + ref: ${{ inputs.platforms == 'all' && needs.prepare_release.outputs.release_tag || inputs.ref }} release_type: production - publish_github_release: true + publish_github_release: ${{ inputs.platforms == 'android' }} create_version_bump_pr: ${{ inputs.create_version_bump_pr }} + version_bump_base_ref: ${{ inputs.ref }} secrets: inherit release_apple: @@ -80,12 +84,12 @@ jobs: if: >- ${{ always() && (inputs.platforms == 'all' || inputs.platforms == 'app_store') && (inputs.platforms == 'app_store' || needs.release_android.result == 'success') && - (inputs.platforms == 'all' || needs.prepare_apple_release.result == 'success') }} + needs.prepare_release.result == 'success' }} needs: - - prepare_apple_release + - prepare_release - release_android uses: ./.github/workflows/app_store_build_and_deploy.yaml with: - release_tag: ${{ inputs.platforms == 'app_store' && needs.prepare_apple_release.outputs.release_tag || needs.release_android.outputs.release_tag }} + release_tag: ${{ needs.prepare_release.outputs.release_tag }} release_mode: ${{ inputs.release_mode }} secrets: inherit From a050a272a80add75f007e15587be7aff217b90c3 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 25 Sep 2026 17:50:26 +0200 Subject: [PATCH 9/9] fix(release): attach Android APK to combined GitHub release --- .../workflows/android_build_and_deploy.yaml | 68 ++++++++----------- .../workflows/app_store_build_and_deploy.yaml | 23 +++++++ .github/workflows/release_all_platforms.yaml | 1 + 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/.github/workflows/android_build_and_deploy.yaml b/.github/workflows/android_build_and_deploy.yaml index ef36e36d..181f0980 100644 --- a/.github/workflows/android_build_and_deploy.yaml +++ b/.github/workflows/android_build_and_deploy.yaml @@ -209,10 +209,17 @@ jobs: permissions: contents: write steps: - - name: Checkout released commit + - 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 @@ -221,49 +228,30 @@ jobs: name: android-release-${{ needs.deploy_for_android.outputs.public_version }} path: release-apk - - name: Create immutable version tag and GitHub Release - shell: bash + - 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: ${{ secrets.GITHUB_TOKEN }} - RELEASED_SHA: ${{ needs.deploy_for_android.outputs.released_sha }} - RELEASED_VERSION: ${{ needs.deploy_for_android.outputs.released_version }} - RELEASE_TAG: ${{ needs.deploy_for_android.outputs.release_tag }} - NOTES_PATH: ${{ needs.deploy_for_android.outputs.release_notes_path }} - run: | - set -euo pipefail - - public_version=${RELEASED_VERSION%%+*} - notes_path="$NOTES_PATH" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then - git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" - tagged_sha=$(git rev-list -n 1 "$RELEASE_TAG") - if [ "$tagged_sha" != "$RELEASED_SHA" ]; then - echo "::error::Tag $RELEASE_TAG already points to $tagged_sha, not $RELEASED_SHA." - exit 1 - fi - else - git tag -a "$RELEASE_TAG" "$RELEASED_SHA" -m "OpenWearable $public_version" - git push origin "$RELEASE_TAG" - fi + GH_TOKEN: ${{ github.token }} - if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release edit "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - else - gh release create "$RELEASE_TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --title "OpenWearable $public_version" \ - --notes-file "$notes_path" - fi + - 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" \ + "release-apk/app-release.apk#OpenWearable-${PUBLIC_VERSION}.apk" \ --repo "$GITHUB_REPOSITORY" \ --clobber diff --git a/.github/workflows/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml index 56c12394..f5df2c14 100644 --- a/.github/workflows/app_store_build_and_deploy.yaml +++ b/.github/workflows/app_store_build_and_deploy.yaml @@ -16,6 +16,11 @@ on: 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 @@ -129,3 +134,21 @@ jobs: 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/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml index c610a9af..1b596b80 100644 --- a/.github/workflows/release_all_platforms.yaml +++ b/.github/workflows/release_all_platforms.yaml @@ -92,4 +92,5 @@ jobs: 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