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
308 changes: 133 additions & 175 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
name: Release to App Store
name: iOS Release Pipeline

on:
push:
branches:
- main
paths:
- 'V2er/Info.plist'
- 'V2er.xcodeproj/project.pbxproj'
workflow_dispatch:
inputs:
release_type:
description: 'Release type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
testflight_only:
description: 'TestFlight only (no App Store release)'
force_release:
description: 'Force release even if version unchanged'
required: false
default: false
type: boolean
Expand All @@ -22,197 +19,158 @@ env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer

jobs:
release:
name: Build and Release
runs-on: macos-latest

version-check:
name: Check Version and Create Tag
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
new_tag: ${{ steps.check.outputs.new_tag }}
version: ${{ steps.check.outputs.version }}
build: ${{ steps.check.outputs.build }}

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Check version and create tag if needed
id: check
run: |
# Get current version from Info.plist using plutil for robust XML parsing
CURRENT_VERSION=$(/usr/bin/plutil -extract CFBundleShortVersionString xml1 -o - V2er/Info.plist | grep '<string>' | sed 's/.*<string>\(.*\)<\/string>.*/\1/' | xargs)
CURRENT_BUILD=$(/usr/bin/plutil -extract CFBundleVersion xml1 -o - V2er/Info.plist | grep '<string>' | sed 's/.*<string>\(.*\)<\/string>.*/\1/' | xargs)

echo "Current version: $CURRENT_VERSION (build $CURRENT_BUILD)"

# Check if tag already exists
TAG_NAME="v$CURRENT_VERSION"

if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
if [[ "${{ github.event.inputs.force_release }}" == "true" ]]; then
echo "Tag $TAG_NAME exists but force_release is true"
# Delete existing tag for force release
git push origin --delete "$TAG_NAME" 2>/dev/null || true
echo "should_release=true" >> $GITHUB_OUTPUT
else
echo "Tag $TAG_NAME already exists, skipping release"
echo "should_release=false" >> $GITHUB_OUTPUT
fi
else
echo "Tag $TAG_NAME does not exist, will create it"
echo "should_release=true" >> $GITHUB_OUTPUT
fi

echo "new_tag=$TAG_NAME" >> $GITHUB_OUTPUT
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "build=$CURRENT_BUILD" >> $GITHUB_OUTPUT

- name: Create and push tag
if: steps.check.outputs.should_release == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"

TAG_NAME="${{ steps.check.outputs.new_tag }}"
VERSION="${{ steps.check.outputs.version }}"
BUILD="${{ steps.check.outputs.build }}"

# Create annotated tag
git tag -a "$TAG_NAME" -m "Release version $VERSION (build $BUILD)"

# Push tag
git push origin "$TAG_NAME"

echo "✅ Successfully created tag: $TAG_NAME"

build-and-release:
name: Build and Release to TestFlight
needs: version-check
if: needs.version-check.outputs.should_release == 'true'
runs-on: macos-latest

steps:
- name: Checkout repository at tag
uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0

ref: ${{ needs.version-check.outputs.new_tag }}

- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.0.app/Contents/Developer

- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
bundler-cache: true
bundler-cache: false

- name: Install Fastlane
run: |
gem install fastlane
gem install xcpretty

- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"

- name: Import certificates
env:
CERTIFICATES_P12: ${{ secrets.CERTIFICATES_P12 }}
CERTIFICATES_PASSWORD: ${{ secrets.CERTIFICATES_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

# Import certificate from secrets
echo -n "$CERTIFICATES_P12" | base64 --decode -o $CERTIFICATE_PATH

# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

# Import certificate to keychain
security import $CERTIFICATE_PATH -P "$CERTIFICATES_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH

- name: Download provisioning profiles

- name: Setup SSH for Match repository
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}

- name: Create App Store Connect API Key
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
run: |
# Create the provisioning profiles directory
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles

# Decode and save provisioning profile
echo -n "$PROVISIONING_PROFILE_BASE64" | base64 --decode -o ~/Library/MobileDevice/Provisioning\ Profiles/V2er.mobileprovision

- name: Bump version
id: version
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }}
run: |
# Get current version
CURRENT_VERSION=$(xcodebuild -project V2er.xcodeproj -showBuildSettings | grep MARKETING_VERSION | tr -d 'MARKETING_VERSION = ')
echo "Current version: $CURRENT_VERSION"

# Calculate new version based on input
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR=${VERSION_PARTS[0]}
MINOR=${VERSION_PARTS[1]}
PATCH=${VERSION_PARTS[2]}

case "${{ github.event.inputs.release_type }}" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="$MAJOR.$((MINOR + 1)).0"
;;
patch)
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
;;
esac

echo "New version: $NEW_VERSION"
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT

# Update version in project
xcrun agvtool new-marketing-version $NEW_VERSION

# Get and increment build number
BUILD_NUMBER=$(xcodebuild -project V2er.xcodeproj -showBuildSettings | grep CURRENT_PROJECT_VERSION | tr -d 'CURRENT_PROJECT_VERSION = ')
NEW_BUILD_NUMBER=$((BUILD_NUMBER + 1))
xcrun agvtool new-version -all $NEW_BUILD_NUMBER

- name: Archive app
mkdir -p ~/.appstoreconnect/private_keys
echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API key file is created with default permissions which may be too permissive. Consider setting restrictive file permissions (e.g., 600) on the private key file to improve security.

Suggested change
echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8
echo "$APP_STORE_CONNECT_API_KEY_BASE64" | base64 --decode > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8
chmod 600 ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8

Copilot uses AI. Check for mistakes.
chmod 600 ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8

# Set environment variables for Fastlane
echo "APP_STORE_CONNECT_API_KEY_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> $GITHUB_ENV
echo "APP_STORE_CONNECT_API_KEY_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> $GITHUB_ENV
echo "APP_STORE_CONNECT_API_KEY_KEY=~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" >> $GITHUB_ENV

- name: Run Fastlane Match
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
TEAM_ID: ${{ secrets.TEAM_ID }}
run: |
xcodebuild archive \
-project V2er.xcodeproj \
-scheme V2er \
-sdk iphoneos \
-configuration Release \
-archivePath $PWD/build/V2er.xcarchive \
DEVELOPMENT_TEAM=$TEAM_ID \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="iPhone Distribution" \
PROVISIONING_PROFILE_SPECIFIER="V2er AppStore" | xcpretty

- name: Export IPA
fastlane match appstore --readonly

- name: Build and Upload to TestFlight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
TEAM_ID: ${{ secrets.TEAM_ID }}
run: |
# Create export options plist
cat > ExportOptions.plist <<EOF
<?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</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>compileBitcode</key>
<false/>
<key>provisioningProfiles</key>
<dict>
<key>com.v2er.app</key>
<string>V2er AppStore</string>
</dict>
</dict>
</plist>
EOF

xcodebuild -exportArchive \
-archivePath $PWD/build/V2er.xcarchive \
-exportOptionsPlist ExportOptions.plist \
-exportPath $PWD/build \
-allowProvisioningUpdates | xcpretty

- name: Upload to TestFlight
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
run: |
# Create API key file
mkdir -p ~/.appstoreconnect/private_keys
echo -n "$APP_STORE_CONNECT_API_KEY" > ~/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8

xcrun altool --upload-app \
--type ios \
--file build/V2er.ipa \
--apiKey $APP_STORE_CONNECT_API_KEY_ID \
--apiIssuer $APP_STORE_CONNECT_API_KEY_ISSUER_ID

- name: Create release tag
run: |
git add -A
git commit -m "Release version ${{ steps.version.outputs.version }}"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release version ${{ steps.version.outputs.version }}"
git push origin main --tags

fastlane beta

- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
tag_name: ${{ needs.version-check.outputs.new_tag }}
name: Release ${{ needs.version-check.outputs.version }}
body: |
## What's New

This release includes bug fixes and performance improvements.

### Changes
- Version bump to ${{ steps.version.outputs.version }}

## 🚀 Version ${{ needs.version-check.outputs.version }}
Build: ${{ needs.version-check.outputs.build }}

### TestFlight
This version has been submitted to TestFlight for testing.

${{ github.event.inputs.testflight_only == 'true' && '### Note\nThis is a TestFlight-only release.' || '### App Store\nThis version will be submitted to the App Store after TestFlight testing.' }}
This version has been automatically submitted to TestFlight for beta testing.

### What's New
- See [commit history](https://github.com/${{ github.repository }}/commits/${{ needs.version-check.outputs.new_tag }}) for changes

---
*This release was automatically created by GitHub Actions*
draft: false
prerelease: ${{ github.event.inputs.testflight_only == 'true' }}
- name: Clean up
if: always()
prerelease: false

- name: Post release notification
if: success()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
rm -f ~/Library/MobileDevice/Provisioning\ Profiles/V2er.mobileprovision || true
echo "✅ Successfully released version ${{ needs.version-check.outputs.version }} to TestFlight!"
echo "🏷️ Tag: ${{ needs.version-check.outputs.new_tag }}"
echo "🔢 Build: ${{ needs.version-check.outputs.build }}"
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,15 @@ xcuserdata/
*.ipa
*.dSYM.zip
*.dSYM

## Fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
fastlane/.env*
!fastlane/.env.example

## Match
fastlane/certificates/
fastlane/profiles/
22 changes: 22 additions & 0 deletions fastlane/Appfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Appfile - App specific configuration

# App Bundle ID
app_identifier("v2er.app")

# Apple ID (optional if using API key)
# apple_id("your@email.com")

# Team ID from Apple Developer Portal
team_id(ENV["TEAM_ID"])

# iTunes Connect Team ID (if different from Developer Portal team)
itc_team_id(ENV["ITC_TEAM_ID"] || ENV["TEAM_ID"])

# You can set different app identifiers per lane
for_lane :beta do
app_identifier("v2er.app")
end

for_lane :release do
app_identifier("v2er.app")
end
Loading
Loading