diff --git a/.github/actions/prepare-versioned-release/action.yml b/.github/actions/prepare-versioned-release/action.yml
new file mode 100644
index 00000000..e342fb0e
--- /dev/null
+++ b/.github/actions/prepare-versioned-release/action.yml
@@ -0,0 +1,70 @@
+name: Prepare versioned release
+description: Create or verify the immutable version tag 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
+
+ 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/scripts/app_store_connect_release.rb b/.github/scripts/app_store_connect_release.rb
new file mode 100644
index 00000000..b2989b57
--- /dev/null
+++ b/.github/scripts/app_store_connect_release.rb
@@ -0,0 +1,540 @@
+#!/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
+ loop do
+ attempts += 1
+ 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
+
+ if response.code.to_i.between?(200, 299)
+ return {} if response.body.nil? || response.body.empty?
+
+ 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
+
+ delay = [response["retry-after"].to_i, 5 * attempts].max
+ warn "Apple API returned HTTP #{response.code}; retrying in #{delay} seconds"
+ 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
+
+ sleep delay
+ 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 = 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
+
+ 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 start_xcode_cloud_run
+ source_reference = wait_for_source_reference
+ puts "Starting Xcode Cloud workflow #{@options[:workflow_id]} at #{@options[:tag]}"
+ @client.post(
+ "/v1/ciBuildRuns",
+ data: {
+ type: "ciBuildRuns",
+ attributes: {},
+ relationships: {
+ workflow: { data: { type: "ciWorkflows", id: @options[:workflow_id] } },
+ sourceBranchOrTag: { data: { type: "scmGitReferences", id: source_reference.fetch("id") } }
+ }
+ }
+ ).fetch("data")
+ end
+
+ def wait_for_source_reference
+ canonical_name = "refs/tags/#{@options[:tag]}"
+ loop do
+ repository = @client.get("/v1/ciWorkflows/#{@options[:workflow_id]}/repository").fetch("data")
+ response = @client.get(
+ "/v1/scmRepositories/#{repository.fetch('id')}/gitReferences",
+ "fields[scmGitReferences]" => "name,canonicalName,isDeleted,kind",
+ "limit" => 200
+ )
+ reference = Array(response["data"]).find do |candidate|
+ candidate.dig("attributes", "canonicalName") == canonical_name &&
+ !candidate.dig("attributes", "isDeleted")
+ end
+ return reference if reference
+
+ puts "Xcode Cloud has not indexed #{@options[:tag]} yet"
+ wait_or_timeout!
+ end
+ end
+
+ def wait_for_xcode_cloud_run(build_run_id)
+ puts "Waiting for Xcode Cloud build #{build_run_id}"
+ loop do
+ run = @client.get("/v1/ciBuildRuns/#{build_run_id}").fetch("data")
+ commit_sha = run.dig("attributes", "sourceCommit", "commitSha").to_s
+ if !commit_sha.empty? && commit_sha != @options[:git_sha]
+ raise AppStoreConnectError,
+ "Xcode Cloud build source commit #{commit_sha} does not match release commit #{@options[:git_sha]}"
+ end
+
+ progress = run.dig("attributes", "executionProgress")
+ if progress == "COMPLETE"
+ status = run.dig("attributes", "completionStatus")
+ raise AppStoreConnectError, "Xcode Cloud build finished with #{status}" unless status == "SUCCEEDED"
+
+ puts "Xcode Cloud build ##{run.dig('attributes', 'number')} succeeded"
+ return run
+ end
+
+ puts "Xcode Cloud build ##{run.dig('attributes', 'number')} is #{progress}"
+ wait_or_timeout!
+ end
+ end
+
+ def wait_for_processed_build(build_run_id)
+ puts "Waiting for App Store Connect to process the Xcode Cloud archive"
+ 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 = 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|
+ 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 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
+
+ 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
+
+ []
+ 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..181f0980 100644
--- a/.github/workflows/android_build_and_deploy.yaml
+++ b/.github/workflows/android_build_and_deploy.yaml
@@ -1,20 +1,79 @@
name: Android Build and Deploy to Play Store
on:
+ workflow_call:
+ inputs:
+ ref:
+ description: "Branch or tag to deploy"
+ required: true
+ type: string
+ release_type:
+ description: "Google Play track"
+ required: true
+ type: string
+ publish_github_release:
+ description: "Create the version tag and GitHub Release"
+ required: false
+ default: true
+ type: boolean
+ create_version_bump_pr:
+ description: "Open the post-release version bump PR"
+ required: false
+ default: true
+ type: boolean
+ version_bump_base_ref:
+ description: "Branch to target with the post-release version bump PR"
+ required: false
+ default: ""
+ type: string
+ outputs:
+ released_sha:
+ value: ${{ jobs.deploy_for_android.outputs.released_sha }}
+ released_version:
+ value: ${{ jobs.deploy_for_android.outputs.released_version }}
+ public_version:
+ value: ${{ jobs.deploy_for_android.outputs.public_version }}
+ release_tag:
+ value: ${{ jobs.deploy_for_android.outputs.release_tag }}
+ next_version:
+ value: ${{ jobs.deploy_for_android.outputs.next_version }}
+ secrets:
+ OPEN_WEARABLE_APP_ANDROID_DEPLOYMENT_KEY_PATH__FILE:
+ required: true
+ OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD:
+ required: true
+ OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PATH__FILE:
+ required: true
workflow_dispatch:
inputs:
- 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
+ version_bump_base_ref:
+ description: "Branch to target with the post-release version bump PR"
+ required: false
+ default: ""
+ type: string
permissions:
contents: read
@@ -25,20 +84,24 @@ 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 }}
+ release_notes_path: ${{ steps.version.outputs.release_notes_path }}
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 +115,36 @@ 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
+ version_notes_path="open_wearable/release_notes/${public_version}/default.txt"
+ fallback_notes_path="open_wearable/release_notes/default.txt"
+ notes_path="$version_notes_path"
+ if [ ! -s "$notes_path" ]; then
+ notes_path="$fallback_notes_path"
+ echo "Using generic release notes because $version_notes_path is missing."
+ fi
+ if [ ! -s "$notes_path" ]; then
+ echo "::error::Missing version-specific release notes at $version_notes_path and fallback release notes at $fallback_notes_path."
+ exit 1
+ fi
+ else
+ notes_path=""
+ fi
+
echo "released_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "released_version=$version" >> "$GITHUB_OUTPUT"
+ echo "public_version=$public_version" >> "$GITHUB_OUTPUT"
+ echo "release_tag=v${public_version}" >> "$GITHUB_OUTPUT"
echo "next_version=$next_version" >> "$GITHUB_OUTPUT"
+ echo "release_notes_path=$notes_path" >> "$GITHUB_OUTPUT"
- - name: Set up ruby 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 +153,112 @@ 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 "${{ steps.version.outputs.release_notes_path }}" "$changelog_dir/default.txt"
+
+ - name: Set up Fastlane
+ working-directory: open_wearable/android
+ run: bundle install
+
+ - name: Build and deploy Android internal release
+ if: ${{ inputs.release_type == 'internal' }}
+ working-directory: open_wearable/android
+ run: bundle exec fastlane internal_deploy
env:
OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.OPEN_WEARABLE_APP_ANDROID_KEYSTORE_PASSWORD }}
- - name: Build & 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 workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout released source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.deploy_for_android.outputs.released_sha }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Download signed APK
+ uses: actions/download-artifact@v7
+ with:
+ name: android-release-${{ needs.deploy_for_android.outputs.public_version }}
+ path: release-apk
+
+ - name: Create or verify immutable version tag
+ id: release
+ uses: ./workflow-source/.github/actions/prepare-versioned-release
+ with:
+ source-directory: release-source
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Create or update GitHub Release
+ uses: ./workflow-source/.github/actions/publish-versioned-release
+ with:
+ source-directory: release-source
+ release-tag: ${{ steps.release.outputs.release_tag }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Upload signed APK to GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ steps.release.outputs.release_tag }}
+ PUBLIC_VERSION: ${{ needs.deploy_for_android.outputs.public_version }}
+ run: |
+ gh release upload "$RELEASE_TAG" \
+ "release-apk/app-release.apk#OpenWearable-${PUBLIC_VERSION}.apk" \
+ --repo "$GITHUB_REPOSITORY" \
+ --clobber
create_version_bump_pr:
name: Create version bump PR for review
needs: deploy_for_android
- if: ${{ 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 +273,13 @@ jobs:
shell: bash
env:
VERSION_FILE: open_wearable/pubspec.yaml
- BASE_BRANCH: ${{ github.event.inputs.branch }}
+ BASE_BRANCH: ${{ inputs.version_bump_base_ref || inputs.ref }}
RELEASED_VERSION: ${{ needs.deploy_for_android.outputs.released_version }}
NEXT_VERSION: ${{ needs.deploy_for_android.outputs.next_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ set -euo pipefail
+
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -140,7 +293,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 +316,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/app_store_build_and_deploy.yaml b/.github/workflows/app_store_build_and_deploy.yaml
new file mode 100644
index 00000000..f5df2c14
--- /dev/null
+++ b/.github/workflows/app_store_build_and_deploy.yaml
@@ -0,0 +1,154 @@
+name: Apple App Store Build and Submit
+
+on:
+ workflow_call:
+ inputs:
+ release_tag:
+ description: "Existing version tag to release"
+ required: true
+ type: string
+ ref:
+ description: "Optional branch or tag from which to create the release tag"
+ required: false
+ type: string
+ release_mode:
+ description: "When to publish after App Review"
+ required: false
+ default: "after_approval"
+ type: string
+ android_artifact_name:
+ description: "Optional signed Android APK artifact to attach to the GitHub Release"
+ required: false
+ default: ""
+ type: string
+ secrets:
+ APP_STORE_CONNECT_APP_ID:
+ required: true
+ APP_STORE_CONNECT_ISSUER_ID:
+ required: true
+ APP_STORE_CONNECT_KEY_ID:
+ required: true
+ APP_STORE_CONNECT_PRIVATE_KEY:
+ required: true
+ XCODE_CLOUD_IOS_WORKFLOW_ID:
+ required: true
+ XCODE_CLOUD_MACOS_WORKFLOW_ID:
+ required: true
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Branch or existing release tag to release"
+ required: true
+ default: "main"
+ type: string
+ release_mode:
+ description: "When to publish after App Review"
+ required: true
+ default: "after_approval"
+ type: choice
+ options:
+ - after_approval
+ - manual
+permissions:
+ contents: write
+
+jobs:
+ prepare_release:
+ name: Create or verify release tag
+ if: ${{ inputs.ref != '' }}
+ runs-on: ubuntu-latest
+ outputs:
+ release_tag: ${{ steps.release.outputs.release_tag }}
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or verify immutable version tag
+ id: release
+ uses: ./workflow-source/.github/actions/prepare-versioned-release
+ with:
+ source-directory: release-source
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ submit_ios:
+ name: Build and submit iOS
+ needs: prepare_release
+ if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }}
+ uses: ./.github/workflows/ios_build_and_deploy.yaml
+ with:
+ release_tag: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }}
+ release_mode: ${{ inputs.release_mode }}
+ secrets: inherit
+
+ submit_macos:
+ name: Build and submit macOS
+ needs: prepare_release
+ if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }}
+ uses: ./.github/workflows/macos_build_and_deploy.yaml
+ with:
+ release_tag: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }}
+ release_mode: ${{ inputs.release_mode }}
+ secrets: inherit
+
+ publish_github_release:
+ name: Publish GitHub Release
+ needs:
+ - prepare_release
+ - submit_ios
+ - submit_macos
+ if: >-
+ ${{ always() && needs.submit_ios.result == 'success' && needs.submit_macos.result == 'success' &&
+ (inputs.ref == '' || needs.prepare_release.result == 'success') }}
+ runs-on: ubuntu-latest
+ env:
+ RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }}
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or update GitHub Release
+ uses: ./workflow-source/.github/actions/publish-versioned-release
+ with:
+ source-directory: release-source
+ release-tag: ${{ env.RELEASE_TAG }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Download signed Android APK
+ if: ${{ inputs.android_artifact_name != '' }}
+ uses: actions/download-artifact@v7
+ with:
+ name: ${{ inputs.android_artifact_name }}
+ path: release-apk
+
+ - name: Upload signed Android APK to GitHub Release
+ if: ${{ inputs.android_artifact_name != '' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ env.RELEASE_TAG }}
+ run: |
+ gh release upload "$RELEASE_TAG" \
+ "release-apk/app-release.apk#OpenWearable-${RELEASE_TAG#v}.apk" \
+ --repo "$GITHUB_REPOSITORY" \
+ --clobber
diff --git a/.github/workflows/ios_build_and_deploy.yaml b/.github/workflows/ios_build_and_deploy.yaml
new file mode 100644
index 00000000..d279a1dd
--- /dev/null
+++ b/.github/workflows/ios_build_and_deploy.yaml
@@ -0,0 +1,193 @@
+name: iOS Build and Submit to App Store
+
+on:
+ workflow_call:
+ inputs:
+ release_tag:
+ description: "Version tag to release"
+ required: true
+ type: string
+ ref:
+ description: "Optional branch or tag from which to create the release tag"
+ required: false
+ type: string
+ release_mode:
+ description: "Release automatically after App Review or wait for manual release"
+ required: false
+ default: "after_approval"
+ type: string
+ secrets:
+ APP_STORE_CONNECT_APP_ID:
+ required: true
+ APP_STORE_CONNECT_ISSUER_ID:
+ required: true
+ APP_STORE_CONNECT_KEY_ID:
+ required: true
+ APP_STORE_CONNECT_PRIVATE_KEY:
+ required: true
+ XCODE_CLOUD_IOS_WORKFLOW_ID:
+ required: true
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Branch or existing release tag to release"
+ required: true
+ default: "main"
+ type: string
+ release_mode:
+ description: "When to publish after App Review"
+ required: true
+ default: "after_approval"
+ type: choice
+ options:
+ - after_approval
+ - manual
+
+permissions:
+ contents: write
+
+concurrency:
+ group: ios-app-store-${{ inputs.release_tag || inputs.ref || github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ prepare_release:
+ name: Create or verify release tag
+ if: ${{ inputs.ref != '' }}
+ runs-on: ubuntu-latest
+ outputs:
+ release_tag: ${{ steps.release.outputs.release_tag }}
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or verify immutable version tag
+ id: release
+ uses: ./workflow-source/.github/actions/prepare-versioned-release
+ with:
+ source-directory: release-source
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ submit_ios:
+ name: Build in Xcode Cloud and submit iOS
+ needs: prepare_release
+ if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 130
+ env:
+ RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }}
+ RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }}
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ fetch-depth: 0
+
+ - name: Validate release inputs
+ id: release
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml)
+ version=${version//\"/}
+ version=${version//\'/}
+ public_version=${version%%+*}
+ expected_tag="v${public_version}"
+
+ if [ "$RELEASE_TAG" != "$expected_tag" ]; then
+ echo "::error::Tag '$RELEASE_TAG' does not match pubspec version '$public_version'."
+ exit 1
+ fi
+
+ version_notes_dir="open_wearable/release_notes/${public_version}"
+ notes_dir="$version_notes_dir"
+ if [ ! -s "$notes_dir/default.txt" ]; then
+ notes_dir="open_wearable/release_notes"
+ echo "Using generic release notes because $version_notes_dir/default.txt is missing."
+ fi
+ if [ ! -s "$notes_dir/default.txt" ]; then
+ echo "::error::Missing version-specific release notes at $version_notes_dir/default.txt and fallback release notes at $notes_dir/default.txt."
+ exit 1
+ fi
+
+ case "$RELEASE_MODE" in
+ after_approval) release_type=AFTER_APPROVAL ;;
+ manual) release_type=MANUAL ;;
+ *)
+ echo "::error::Unsupported release mode '$RELEASE_MODE'."
+ exit 1
+ ;;
+ esac
+
+ echo "version=$public_version" >> "$GITHUB_OUTPUT"
+ echo "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ echo "notes_dir=$notes_dir" >> "$GITHUB_OUTPUT"
+ echo "release_type=$release_type" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.2.3
+
+ - name: Start Xcode Cloud and submit to App Review
+ env:
+ APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
+ XCODE_CLOUD_WORKFLOW_ID: ${{ secrets.XCODE_CLOUD_IOS_WORKFLOW_ID }}
+ run: |
+ ruby .github/scripts/app_store_connect_release.rb \
+ --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \
+ --app-id "$APP_STORE_CONNECT_APP_ID" \
+ --platform IOS \
+ --version "${{ steps.release.outputs.version }}" \
+ --tag "$RELEASE_TAG" \
+ --git-sha "${{ steps.release.outputs.git_sha }}" \
+ --release-notes-dir "${{ steps.release.outputs.notes_dir }}" \
+ --release-type "${{ steps.release.outputs.release_type }}"
+
+ - name: Summarize submission
+ run: |
+ printf 'Submitted iOS **%s** to App Review with release mode **%s**.\n' \
+ "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY"
+
+ publish_github_release:
+ name: Publish GitHub Release
+ needs: submit_ios
+ if: ${{ inputs.ref != '' && needs.submit_ios.result == 'success' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.prepare_release.outputs.release_tag }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or update GitHub Release
+ uses: ./workflow-source/.github/actions/publish-versioned-release
+ with:
+ source-directory: release-source
+ release-tag: ${{ needs.prepare_release.outputs.release_tag }}
+ env:
+ GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/macos_build_and_deploy.yaml b/.github/workflows/macos_build_and_deploy.yaml
new file mode 100644
index 00000000..16e44c60
--- /dev/null
+++ b/.github/workflows/macos_build_and_deploy.yaml
@@ -0,0 +1,193 @@
+name: macOS Build and Submit to App Store
+
+on:
+ workflow_call:
+ inputs:
+ release_tag:
+ description: "Version tag to release"
+ required: true
+ type: string
+ ref:
+ description: "Optional branch or tag from which to create the release tag"
+ required: false
+ type: string
+ release_mode:
+ description: "Release automatically after App Review or wait for manual release"
+ required: false
+ default: "after_approval"
+ type: string
+ secrets:
+ APP_STORE_CONNECT_APP_ID:
+ required: true
+ APP_STORE_CONNECT_ISSUER_ID:
+ required: true
+ APP_STORE_CONNECT_KEY_ID:
+ required: true
+ APP_STORE_CONNECT_PRIVATE_KEY:
+ required: true
+ XCODE_CLOUD_MACOS_WORKFLOW_ID:
+ required: true
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: "Branch or existing release tag to release"
+ required: true
+ default: "main"
+ type: string
+ release_mode:
+ description: "When to publish after App Review"
+ required: true
+ default: "after_approval"
+ type: choice
+ options:
+ - after_approval
+ - manual
+
+permissions:
+ contents: write
+
+concurrency:
+ group: macos-app-store-${{ inputs.release_tag || inputs.ref || github.ref_name }}
+ cancel-in-progress: false
+
+jobs:
+ prepare_release:
+ name: Create or verify release tag
+ if: ${{ inputs.ref != '' }}
+ runs-on: ubuntu-latest
+ outputs:
+ release_tag: ${{ steps.release.outputs.release_tag }}
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or verify immutable version tag
+ id: release
+ uses: ./workflow-source/.github/actions/prepare-versioned-release
+ with:
+ source-directory: release-source
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ submit_macos:
+ name: Build in Xcode Cloud and submit macOS
+ needs: prepare_release
+ if: ${{ always() && (inputs.ref == '' || needs.prepare_release.result == 'success') }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 130
+ env:
+ RELEASE_TAG: ${{ inputs.ref != '' && needs.prepare_release.outputs.release_tag || inputs.release_tag }}
+ RELEASE_MODE: ${{ inputs.release_mode || 'after_approval' }}
+ steps:
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ fetch-depth: 0
+
+ - name: Validate release inputs
+ id: release
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ version=$(awk '$1 == "version:" { print $2; exit }' open_wearable/pubspec.yaml)
+ version=${version//\"/}
+ version=${version//\'/}
+ public_version=${version%%+*}
+ expected_tag="v${public_version}"
+
+ if [ "$RELEASE_TAG" != "$expected_tag" ]; then
+ echo "::error::Tag '$RELEASE_TAG' does not match pubspec version '$public_version'."
+ exit 1
+ fi
+
+ version_notes_dir="open_wearable/release_notes/${public_version}"
+ notes_dir="$version_notes_dir"
+ if [ ! -s "$notes_dir/default.txt" ]; then
+ notes_dir="open_wearable/release_notes"
+ echo "Using generic release notes because $version_notes_dir/default.txt is missing."
+ fi
+ if [ ! -s "$notes_dir/default.txt" ]; then
+ echo "::error::Missing version-specific release notes at $version_notes_dir/default.txt and fallback release notes at $notes_dir/default.txt."
+ exit 1
+ fi
+
+ case "$RELEASE_MODE" in
+ after_approval) release_type=AFTER_APPROVAL ;;
+ manual) release_type=MANUAL ;;
+ *)
+ echo "::error::Unsupported release mode '$RELEASE_MODE'."
+ exit 1
+ ;;
+ esac
+
+ echo "version=$public_version" >> "$GITHUB_OUTPUT"
+ echo "git_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+ echo "notes_dir=$notes_dir" >> "$GITHUB_OUTPUT"
+ echo "release_type=$release_type" >> "$GITHUB_OUTPUT"
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: 3.2.3
+
+ - name: Start Xcode Cloud and submit to App Review
+ env:
+ APP_STORE_CONNECT_APP_ID: ${{ secrets.APP_STORE_CONNECT_APP_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
+ XCODE_CLOUD_WORKFLOW_ID: ${{ secrets.XCODE_CLOUD_MACOS_WORKFLOW_ID }}
+ run: |
+ ruby .github/scripts/app_store_connect_release.rb \
+ --workflow-id "$XCODE_CLOUD_WORKFLOW_ID" \
+ --app-id "$APP_STORE_CONNECT_APP_ID" \
+ --platform MAC_OS \
+ --version "${{ steps.release.outputs.version }}" \
+ --tag "$RELEASE_TAG" \
+ --git-sha "${{ steps.release.outputs.git_sha }}" \
+ --release-notes-dir "${{ steps.release.outputs.notes_dir }}" \
+ --release-type "${{ steps.release.outputs.release_type }}"
+
+ - name: Summarize submission
+ run: |
+ printf 'Submitted macOS **%s** to App Review with release mode **%s**.\n' \
+ "${{ steps.release.outputs.version }}" "$RELEASE_MODE" >> "$GITHUB_STEP_SUMMARY"
+
+ publish_github_release:
+ name: Publish GitHub Release
+ needs: submit_macos
+ if: ${{ inputs.ref != '' && needs.submit_macos.result == 'success' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout workflow source
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-source
+
+ - name: Checkout release tag
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.prepare_release.outputs.release_tag }}
+ path: release-source
+ fetch-depth: 0
+
+ - name: Create or update GitHub Release
+ uses: ./workflow-source/.github/actions/publish-versioned-release
+ with:
+ source-directory: release-source
+ release-tag: ${{ needs.prepare_release.outputs.release_tag }}
+ env:
+ GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/release_all_platforms.yaml b/.github/workflows/release_all_platforms.yaml
new file mode 100644
index 00000000..1b596b80
--- /dev/null
+++ b/.github/workflows/release_all_platforms.yaml
@@ -0,0 +1,96 @@
+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_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 }}
+ 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 }}
+
+ release_android:
+ name: Release Android
+ needs: prepare_release
+ if: >-
+ ${{ always() && (inputs.platforms == 'all' || inputs.platforms == 'android') &&
+ (inputs.platforms == 'android' || needs.prepare_release.result == 'success') }}
+ uses: ./.github/workflows/android_build_and_deploy.yaml
+ with:
+ ref: ${{ inputs.platforms == 'all' && needs.prepare_release.outputs.release_tag || inputs.ref }}
+ release_type: production
+ publish_github_release: ${{ inputs.platforms == 'android' }}
+ create_version_bump_pr: ${{ inputs.create_version_bump_pr }}
+ version_bump_base_ref: ${{ inputs.ref }}
+ secrets: inherit
+
+ 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') &&
+ needs.prepare_release.result == 'success' }}
+ needs:
+ - prepare_release
+ - release_android
+ uses: ./.github/workflows/app_store_build_and_deploy.yaml
+ with:
+ release_tag: ${{ needs.prepare_release.outputs.release_tag }}
+ release_mode: ${{ inputs.release_mode }}
+ android_artifact_name: ${{ inputs.platforms == 'all' && format('android-release-{0}', needs.release_android.outputs.public_version) || '' }}
+ secrets: inherit
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/.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/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..646b6a0f 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,
@@ -58,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,
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 677c9991..7074c17b 100644
--- a/open_wearable/ios/Runner/Info.plist
+++ b/open_wearable/ios/Runner/Info.plist
@@ -39,16 +39,18 @@
This app uses Bluetooth to connect to wearable devices.
NSBluetoothPeripheralUsageDescription
This app requires Bluetooth access to communicate with wearable devices.
+ 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.
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.
+ 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.
- 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.
+ 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/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/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/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/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..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:
@@ -484,10 +524,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 +548,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:
@@ -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:
@@ -1033,10 +1073,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 +1201,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:
@@ -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
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.
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.