Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/actions/apple-build-upload/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Apple build and upload
description: Archive on a GitHub macOS runner and upload to App Store Connect.
inputs:
platform:
description: ios or macos
required: true
app-dir:
description: Release source Flutter application directory
required: true
outputs:
build_number:
description: Exact CFBundleVersion uploaded to Apple
value: ${{ steps.archive.outputs.build_number }}
runs:
using: composite
steps:
- name: Select Xcode
shell: bash
run: |
sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
xcodebuild -version

- name: Read Flutter version
id: flutter
shell: bash
env:
APP_DIR: ${{ inputs.app-dir }}
run: echo "version=$(cat "$APP_DIR/.flutter_version")" >> "$GITHUB_OUTPUT"

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ steps.flutter.outputs.version }}
cache: true

- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-dir }}
run: flutter pub get

- name: Archive, sign and upload
id: archive
shell: bash
env:
APPLE_PLATFORM: ${{ inputs.platform }}
APP_DIR: ${{ inputs.app-dir }}
ACTION_PATH: ${{ github.action_path }}
run: bash "$ACTION_PATH/../../scripts/apple_archive.sh" "$APPLE_PLATFORM" "$APP_DIR" "$RUNNER_TEMP/apple-build"

- name: Preserve build diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: apple-${{ inputs.platform }}-diagnostics
path: |
${{ runner.temp }}/apple-build/*.log
${{ runner.temp }}/apple-build/*.xcresult
if-no-files-found: ignore
retention-days: 7
144 changes: 69 additions & 75 deletions .github/scripts/app_store_connect_release.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
require "timeout"
require "uri"

$stdout.sync = true

class AppStoreConnectError < StandardError; end

class AppStoreConnectClient
Expand Down Expand Up @@ -151,9 +153,10 @@ def run
validate_release_notes!
return puts("Release inputs are valid") if @options[:validate_only]

build_run = start_xcode_cloud_run
wait_for_xcode_cloud_run(build_run.fetch("id"))
build = wait_for_processed_build(build_run.fetch("id"))
build = wait_for_processed_build
return puts("Uploaded build is ready; App Review submission was not requested") if @options[:process_only]
return distribute_testflight(build) unless Array(@options[:testflight_groups]).empty?

version = find_or_create_version

if submitted?(version)
Expand Down Expand Up @@ -187,71 +190,12 @@ def validate_note!(path)
raise AppStoreConnectError, "Release notes exceed Apple's 4000-character limit: #{path}"
end

def start_xcode_cloud_run
source_reference = wait_for_source_reference
puts "Starting Xcode Cloud workflow #{@options[:workflow_id]} at #{@options[:tag]}"
@client.post(
"/v1/ciBuildRuns",
data: {
type: "ciBuildRuns",
attributes: {},
relationships: {
workflow: { data: { type: "ciWorkflows", id: @options[:workflow_id] } },
sourceBranchOrTag: { data: { type: "scmGitReferences", id: source_reference.fetch("id") } }
}
}
).fetch("data")
end

def wait_for_source_reference
canonical_name = "refs/tags/#{@options[:tag]}"
loop do
repository = @client.get("/v1/ciWorkflows/#{@options[:workflow_id]}/repository").fetch("data")
response = @client.get(
"/v1/scmRepositories/#{repository.fetch('id')}/gitReferences",
"fields[scmGitReferences]" => "name,canonicalName,isDeleted,kind",
"limit" => 200
)
reference = Array(response["data"]).find do |candidate|
candidate.dig("attributes", "canonicalName") == canonical_name &&
!candidate.dig("attributes", "isDeleted")
end
return reference if reference

puts "Xcode Cloud has not indexed #{@options[:tag]} yet"
wait_or_timeout!
end
end

def wait_for_xcode_cloud_run(build_run_id)
puts "Waiting for Xcode Cloud build #{build_run_id}"
loop do
run = @client.get("/v1/ciBuildRuns/#{build_run_id}").fetch("data")
commit_sha = run.dig("attributes", "sourceCommit", "commitSha").to_s
if !commit_sha.empty? && commit_sha != @options[:git_sha]
raise AppStoreConnectError,
"Xcode Cloud build source commit #{commit_sha} does not match release commit #{@options[:git_sha]}"
end

progress = run.dig("attributes", "executionProgress")
if progress == "COMPLETE"
status = run.dig("attributes", "completionStatus")
raise AppStoreConnectError, "Xcode Cloud build finished with #{status}" unless status == "SUCCEEDED"

puts "Xcode Cloud build ##{run.dig('attributes', 'number')} succeeded"
return run
end

puts "Xcode Cloud build ##{run.dig('attributes', 'number')} is #{progress}"
wait_or_timeout!
end
end

def wait_for_processed_build(build_run_id)
puts "Waiting for App Store Connect to process the Xcode Cloud archive"
def wait_for_processed_build
puts "Waiting for App Store Connect to process #{@options[:platform]} #{@options[:version]} (#{@options[:build_number]})"
loop do
response = @client.get(
"/v1/ciBuildRuns/#{build_run_id}/builds",
"/v1/builds",
"filter[version]" => @options[:build_number],
"filter[app]" => @options[:app_id],
"filter[preReleaseVersion.version]" => @options[:version],
"filter[preReleaseVersion.platform]" => @options[:platform],
Expand All @@ -271,19 +215,66 @@ def wait_for_processed_build(build_run_id)
audience = build.dig("attributes", "buildAudienceType")
unless audience == "APP_STORE_ELIGIBLE"
raise AppStoreConnectError,
"Build #{build.dig('attributes', 'version')} is #{audience}; configure the Xcode Cloud " \
"Build #{build.dig('attributes', 'version')} is #{audience}; export the " \
"archive for TestFlight and App Store distribution"
end

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"
puts builds.empty? ? "The uploaded build is not visible yet" : "The build is still processing"
wait_or_timeout!
end
end

# Keep the same explicit tester groups as the retired main-branch workflows.
# Beta App Review is separate from public App Store review.
def distribute_testflight(build)
groups = @client.get("/v1/apps/#{@options[:app_id]}/betaGroups", "limit" => 200).fetch("data")
selected = @options[:testflight_groups].map do |name|
matches = groups.select { |group| group.dig("attributes", "name") == name }
raise AppStoreConnectError, "Expected exactly one TestFlight group named #{name}" unless matches.length == 1

matches.first
end
build_id = build.fetch("id")
notes = File.read(File.join(@options[:release_notes_dir], "default.txt"), encoding: "UTF-8").strip
localizations = @client.get("/v1/builds/#{build_id}/betaBuildLocalizations").fetch("data")
if localizations.empty?
@client.post("/v1/betaBuildLocalizations", data: {
type: "betaBuildLocalizations", attributes: { locale: "en-US", whatsNew: notes },
relationships: { build: { data: { type: "builds", id: build_id } } }
})
else
localizations.each do |localization|
@client.patch("/v1/betaBuildLocalizations/#{localization.fetch('id')}", data: {
type: "betaBuildLocalizations", id: localization.fetch("id"), attributes: { whatsNew: notes }
})
end
end

if selected.any? { |group| !group.dig("attributes", "isInternalGroup") }
detail = @client.get("/v1/builds/#{build_id}/buildBetaDetail").fetch("data")
@client.patch("/v1/buildBetaDetails/#{detail.fetch('id')}", data: {
type: "buildBetaDetails", id: detail.fetch("id"), attributes: { autoNotifyEnabled: true }
})
state = detail.dig("attributes", "externalBuildState")
if state == "READY_FOR_BETA_SUBMISSION"
@client.post("/v1/betaAppReviewSubmissions", data: {
type: "betaAppReviewSubmissions",
relationships: { build: { data: { type: "builds", id: build_id } } }
})
elsif !%w[WAITING_FOR_BETA_REVIEW IN_BETA_REVIEW BETA_APPROVED READY_FOR_BETA_TESTING IN_BETA_TESTING].include?(state)
raise AppStoreConnectError, "TestFlight build cannot be distributed: #{state}"
end
end
@client.post("/v1/builds/#{build_id}/relationships/betaGroups", data: selected.map do |group|
{ type: "betaGroups", id: group.fetch("id") }
end)
puts "Assigned #{@options[:platform]} build #{@options[:build_number]} to #{@options[:testflight_groups].join(', ')}"
end

def find_or_create_version
response = @client.get(
"/v1/apps/#{@options[:app_id]}/appStoreVersions",
Expand Down Expand Up @@ -491,29 +482,30 @@ def wait_or_timeout!
end
end

begin
def run_release_cli
options = {
timeout: 7200,
timeout: 2700,
release_type: "AFTER_APPROVAL",
validate_only: false
validate_only: false,
testflight_groups: []
}

OptionParser.new do |parser|
parser.banner = "Usage: app_store_connect_release.rb [options]"
parser.on("--workflow-id ID") { |value| options[:workflow_id] = value }
parser.on("--build-number NUMBER") { |value| options[:build_number] = value }
parser.on("--app-id ID") { |value| options[:app_id] = value }
parser.on("--platform PLATFORM") { |value| options[:platform] = value }
parser.on("--version VERSION") { |value| options[:version] = value }
parser.on("--tag TAG") { |value| options[:tag] = value }
parser.on("--git-sha SHA") { |value| options[:git_sha] = value }
parser.on("--release-notes-dir PATH") { |value| options[:release_notes_dir] = value }
parser.on("--release-type TYPE") { |value| options[:release_type] = value }
parser.on("--timeout SECONDS", Integer) { |value| options[:timeout] = value }
parser.on("--validate-only") { options[:validate_only] = true }
parser.on("--process-only") { options[:process_only] = true }
parser.on("--testflight-group NAME") { |value| options[:testflight_groups] << value }
end.parse!

required = %i[platform version release_notes_dir]
required += %i[workflow_id app_id tag git_sha] unless options[:validate_only]
required += %i[app_id build_number] unless options[:validate_only]
missing = required.select { |key| options[key].nil? || options[key].empty? }
raise AppStoreConnectError, "Missing options: #{missing.join(', ')}" unless missing.empty?
unless %w[IOS MAC_OS].include?(options[:platform])
Expand All @@ -538,3 +530,5 @@ def wait_or_timeout!
warn "::error::#{error.message}"
exit 1
end

run_release_cli if $PROGRAM_NAME == __FILE__
91 changes: 91 additions & 0 deletions .github/scripts/apple_archive.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/bin/bash
# Archive on GitHub, then let Xcode sign and upload using the existing Apple API key.
set -euo pipefail

platform="$1"
app_dir="$2"
output_dir="$3"
case "$platform" in
ios) destination='generic/platform=iOS' ;;
macos) destination='generic/platform=macOS' ;;
*) echo "Unsupported Apple platform: $platform" >&2; exit 1 ;;
esac

mkdir -p "$output_dir"
output_dir=$(cd "$output_dir" && pwd)
cd "$app_dir"
# Three numeric components, each within Apple's digit limits. Reruns get a new
# increasing build number, including when a different workflow releases the tag.
build_number=$(date -u '+%y%m.%d%H.%M%S' | awk -F. '{printf "%d.%d.%d\n", $1, $2, $3}')
printf 'build_number=%s\n' "$build_number" >> "$GITHUB_OUTPUT"
printf 'Building %s from %s as %s\n' "$platform" "$(git rev-parse HEAD)" "$build_number"

config_args=("$platform" --release --config-only --build-number="$build_number")
if [[ "$platform" == ios ]]; then config_args+=(--no-codesign); fi
flutter build "${config_args[@]}"
(cd "$platform" && pod install)

signing_args=(CODE_SIGNING_ALLOWED=NO)
if [[ "$platform" == macos ]]; then
# Xcode's export preserves the archive's signed entitlements. An unsigned Mac
# archive loses App Sandbox at export, so embed the project entitlements in
# an ad-hoc signature first; export replaces it with Apple's distribution one.
signing_args=(CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual)
fi
xcodebuild archive -hideShellScriptEnvironment \
-workspace "$platform/Runner.xcworkspace" -scheme Runner \
-configuration Release -destination "$destination" \
-archivePath "$output_dir/Runner.xcarchive" \
-resultBundlePath "$output_dir/archive.xcresult" \
"${signing_args[@]}" \
2>&1 | tee "$output_dir/archive.log"

if [[ "$platform" == macos ]]; then
app_path=$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:ApplicationPath' "$output_dir/Runner.xcarchive/Info.plist")
codesign -d --entitlements - --xml "$output_dir/Runner.xcarchive/Products/$app_path" \
> "$output_dir/archive-entitlements.plist"
python3 - "$platform/Runner/Release.entitlements" "$output_dir/archive-entitlements.plist" <<'PY'
import plistlib
import sys

with open(sys.argv[1], 'rb') as source, open(sys.argv[2], 'rb') as archive:
expected, actual = plistlib.load(source), plistlib.load(archive)
if actual.get('com.apple.security.app-sandbox') is not True:
raise SystemExit('macOS archive is missing App Sandbox')
for key, value in expected.items():
if actual.get(key) != value:
raise SystemExit(f'macOS archive entitlement was not preserved: {key}')
print('macOS release entitlements verified before upload')
PY
fi

# Only the ephemeral runner sees this file. It is never included in artifacts.
key_dir=$(mktemp -d "$RUNNER_TEMP/apple-api.XXXXXX")
trap 'rm -rf "$key_dir"' EXIT
export APPLE_API_KEY_PATH="$key_dir/AuthKey.p8"
ruby -rbase64 -e '
key = ENV.fetch("APP_STORE_CONNECT_PRIVATE_KEY").gsub("\\n", "\n").strip
key = Base64.strict_decode64(key) unless key.include?("BEGIN")
File.write(ENV.fetch("APPLE_API_KEY_PATH"), key, perm: 0600)
'
cat > "$output_dir/ExportOptions.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>signingStyle</key><string>automatic</string>
<key>teamID</key><string>6DCQ69GP5G</string>
<key>manageAppVersionAndBuildNumber</key><false/>
<key>uploadSymbols</key><true/>
</dict></plist>
PLIST
xcodebuild -exportArchive \
-archivePath "$output_dir/Runner.xcarchive" \
-exportOptionsPlist "$output_dir/ExportOptions.plist" \
-exportPath "$output_dir/export" \
-allowProvisioningUpdates \
-authenticationKeyPath "$APPLE_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_ISSUER_ID" \
2>&1 | tee "$output_dir/upload.log"
Loading
Loading