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
2 changes: 1 addition & 1 deletion .github/workflows/release-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
--arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \
'{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json

- run: bundle exec fastlane release_beta_ci
- run: bundle exec fastlane beta_ci
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
Expand Down
232 changes: 224 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,53 @@ name: Release to App Store

on:
workflow_dispatch:
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to release from. Defaults to main."
required: false
default: main
type: string

# Static concurrency group since we always release from main, regardless of trigger branch
# Static concurrency group: only one release may run at a time, regardless of ref,
# to avoid App Store Connect contention.
concurrency:
group: ${{ github.workflow }}-main
group: ${{ github.workflow }}
cancel-in-progress: true

permissions:
contents: write

jobs:
release:
name: Release to App Store
# ============================================================================
# Phase 0: Resolve the dispatch ref to an exact SHA once so every downstream
# job checks out the same commit. Prevents divergence if the ref advances
# mid-release. Defaults to main; override via the `ref` dispatch input.
# ============================================================================
resolve-ref:
name: Resolve Release SHA
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
sha: ${{ steps.resolve.outputs.sha }}
steps:
- name: Resolve ref to SHA
id: resolve
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REF: ${{ inputs.ref }}
run: |
SHA=$(gh api "repos/${{ github.repository }}/commits/$RELEASE_REF" --jq .sha)
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Releasing from $RELEASE_REF @ $SHA"

# ============================================================================
# Phase 1: Build IPA and screenshot test bundle in parallel
# ============================================================================
release-build:
name: Build Release
runs-on: macos-26
timeout-minutes: 120
timeout-minutes: 60
needs: resolve-ref
steps:
- name: Generate GitHub App Token
id: github_app_token
Expand All @@ -27,7 +60,7 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: true
token: ${{ steps.github_app_token.outputs.token }}

Expand All @@ -51,8 +84,191 @@ jobs:
--arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \
'{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json

- name: Release to App Store
run: bundle exec fastlane release_ci
- name: Build, Validate, and Setup Sentry
run: bundle exec fastlane publish_ci_build
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_PRIVATE_KEY: ${{ secrets.MATCH_GIT_PRIVATE_KEY }}
LICENSE_PLIST_GITHUB_TOKEN: ${{ steps.github_app_token.outputs.token }}
RELEASE_BOT_TOKEN: ${{ steps.github_app_token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}

- name: Upload Release Artifacts
uses: actions/upload-artifact@v7
with:
name: release-build
path: |
Flinky.ipa
Flinky.app.dSYM.zip
Flinky.xcarchive
version.txt
build_number.txt
Flinky.xcodeproj/project.pbxproj
Targets/App/Sources/Resources/Settings.bundle/Root.plist
retention-days: 1

- name: Run CI Diagnostics
if: failure()
run: ./Scripts/ci-diagnostics.sh

build-screenshots:
name: Build Screenshots
runs-on: macos-26
timeout-minutes: 30
needs: resolve-ref
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: true

- name: Install Dependencies
run: brew bundle --file Brewfile-ci

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Build Screenshot Test Bundle
run: bundle exec fastlane build_screenshots derived_data_path:/tmp/screenshot_build
env:
LICENSE_PLIST_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload Test Bundle
uses: actions/upload-artifact@v7
with:
name: screenshot-test-bundle
path: /tmp/screenshot_build/Build/Products/
retention-days: 1

- name: Run CI Diagnostics
if: failure()
run: ./Scripts/ci-diagnostics.sh

# ============================================================================
# Phase 2: Run screenshots on each device in parallel
# ============================================================================

screenshot:
name: Screenshot ${{ matrix.device }}
runs-on: macos-26
timeout-minutes: 30
needs: [resolve-ref, build-screenshots]
strategy:
fail-fast: false
matrix:
device:
- "iPhone 17 Pro Max"
- "iPhone 17 Pro"
- "iPad Pro 13-inch (M5)"
- "iPad Pro 11-inch (M5)"
steps:
- name: Checkout Code
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: true

- name: Install Dependencies
run: brew bundle --file Brewfile-ci

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Download Test Bundle
uses: actions/download-artifact@v7
with:
name: screenshot-test-bundle
path: /tmp/screenshot_build/Build/Products/

- name: Run Screenshots
run: bundle exec fastlane run_screenshot_on_device "device:${{ matrix.device }}" derived_data_path:/tmp/screenshot_build

- name: Upload Device Screenshots
uses: actions/upload-artifact@v7
if: always()
with:
name: screenshots-${{ matrix.device }}
Comment thread
sentry[bot] marked this conversation as resolved.
path: ~/Library/Caches/tools.fastlane/screenshots/*.png
retention-days: 1

- name: Run CI Diagnostics
if: failure()
run: ./Scripts/ci-diagnostics.sh

# ============================================================================
# Phase 3: Collect screenshots and upload to App Store
# ============================================================================

release-upload:
name: Upload to App Store
runs-on: macos-26
timeout-minutes: 60
needs: [resolve-ref, release-build, screenshot]
steps:
- name: Generate GitHub App Token
id: github_app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.TECHPRIMATE_RELEASE_BOT_APP_ID }}
private-key: ${{ secrets.TECHPRIMATE_RELEASE_BOT_PRIVATE_KEY }}

- name: Checkout Code
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve-ref.outputs.sha }}
submodules: true
token: ${{ steps.github_app_token.outputs.token }}

- name: Install Dependencies
run: brew bundle --file Brewfile-ci

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Create App Store Connect API Key
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ vars.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ vars.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY }}
run: |
jq -n \
--arg key_id "$APP_STORE_CONNECT_API_KEY_ID" \
--arg issuer_id "$APP_STORE_CONNECT_API_ISSUER_ID" \
--arg key "$APP_STORE_CONNECT_API_PRIVATE_KEY" \
'{key_id: $key_id, issuer_id: $issuer_id, key: $key}' > fastlane/api-key.json

- name: Download Release Build
uses: actions/download-artifact@v7
with:
name: release-build
path: .

- name: Download All Screenshots
uses: actions/download-artifact@v7
with:
pattern: screenshots-*
path: ~/Library/Caches/tools.fastlane/screenshots/
merge-multiple: true

- name: Collect Screenshots
run: bundle exec fastlane collect_screenshots

- name: Read Version Info
id: version
run: |
echo "version=$(cat version.txt)" >> "$GITHUB_OUTPUT"
echo "build=$(cat build_number.txt)" >> "$GITHUB_OUTPUT"

- name: Upload to App Store and Submit for Review
run: bundle exec fastlane publish_ci_upload version:${{ steps.version.outputs.version }} build:${{ steps.version.outputs.build }} ref:${{ inputs.ref }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
Expand Down
4 changes: 3 additions & 1 deletion fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
# ├── Fastfile # This file - imports and platform definition
# ├── lanes/
# │ ├── build.rb # build_ci lane
# │ ├── release.rb # beta, release_beta_ci, publish
# │ ├── release.rb # beta, beta_ci, publish, publish_ci_build, publish_ci_upload
# │ ├── utilities.rb # generate_*, upload_metadata, setup_code_signing, bump_*
# │ ├── screenshots.rb # Parallel screenshot lanes (build_screenshots, run_screenshot_on_device, etc.)
# │ ├── helpers.rb # Private helper lanes (_bump_version, _build_app_for_store, etc.)
# │ ├── sentry.rb # Sentry integration lanes
# │ ├── version.rb # Version management lanes
Expand All @@ -38,4 +39,5 @@ platform :ios do
import "lanes/build.rb"
import "lanes/release.rb"
import "lanes/utilities.rb"
import "lanes/screenshots.rb"
end
57 changes: 45 additions & 12 deletions fastlane/lanes/release.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
Single CI lane used by release-beta.yml (scheduled/manual). No PR.
Creates a signed, verified commit via the GitHub API linked to the GitHub App.
DESC
lane :release_beta_ci do
lane :beta_ci do
setup_ci if is_ci

# Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight
Expand Down Expand Up @@ -134,12 +134,12 @@
end

desc <<~DESC
Release to App Store: build, upload with metadata/screenshots, submit for review
Single CI lane used by release.yml (manual trigger). No PR.
Creates a signed, verified commit via the GitHub API linked to the GitHub App.
Generates screenshots, uploads metadata and binary, and submits for App Store review.
Release CI: Build phase
Prepares version, builds IPA, validates, and sets up Sentry release.
Outputs version and build number for downstream jobs.
Used by the parallel release workflow (release.yml).
DESC
lane :release_ci do
lane :publish_ci_build do
setup_ci if is_ci

# Prepare: check App Store Connect, bump patch if needed, get next build from TestFlight
Expand All @@ -154,17 +154,43 @@
_validate_app
_setup_sentry_release(version: version_number, build: build_number)

# Generate screenshots
UI.message "Generating screenshots for App Store..."
generate_screenshots
# Write version info for downstream jobs
Dir.chdir("..") do
File.write("version.txt", version_number)
File.write("build_number.txt", build_number)
end

UI.success "✅ Release build complete: #{version_number} (#{build_number})"
end

desc <<~DESC
Release CI: Upload phase
Uploads IPA and screenshots to App Store Connect, submits for review,
finalizes Sentry release, and commits/tags the version.
Expects IPA at project root and screenshots in fastlane/screenshots/.
Options:
version: version number (required)
build: build number (required)
ref: git ref the release was dispatched from (optional, default "main").
Commit+tag on main is skipped when ref != "main" to avoid creating
version tags whose tree doesn't match what was uploaded.
DESC
lane :publish_ci_upload do |options|
setup_ci if is_ci

version_number = options[:version]
build_number = options[:build]
release_ref = options[:ref] || "main"

UI.user_error!("version is required") unless version_number
UI.user_error!("build is required") unless build_number

# Upload to App Store Connect with metadata, screenshots, and submit for review
upload_to_app_store(
api_key_path: File.expand_path("./api-key.json"),
ipa: File.expand_path("../Flinky.ipa"), # Explicit path to avoid relying on SharedValues

app_version: version_number,
build_number: build_number,

skip_binary_upload: false,
overwrite_screenshots: true,
Expand All @@ -187,6 +213,13 @@

_finalize_sentry_release(version: version_number, build: build_number)

# Commit and tag on main via GitHub API (creates a signed, verified commit)
_commit_and_tag_version_signed(version: version_number, build: build_number)
# Commit and tag on main via GitHub API (creates a signed, verified commit).
# Skipped for non-main dispatches: _commit_and_tag_version_signed overlays
# VERSION_BUMP_FILES onto main's tree, which would not match the IPA that
# was actually uploaded from a feature branch.
if release_ref == "main"
_commit_and_tag_version_signed(version: version_number, build: build_number)
else
UI.important "Skipping commit+tag: dispatched from ref '#{release_ref}', not main"
end
end
Loading
Loading