Adds a reusable mobile release workflow call from the release pipeline Passes version, build number, and release tag into Android artifact publishing Tightens release workflow shell quoting and version handling
367 lines
15 KiB
YAML
367 lines
15 KiB
YAML
name: Mobile Release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version_name:
|
|
description: Version name / marketing version. Leave empty to use package.json version.
|
|
required: false
|
|
type: string
|
|
build_number:
|
|
description: Build number. Leave empty to use GitHub run number.
|
|
required: false
|
|
type: string
|
|
upload_github_release:
|
|
description: Upload Android artifacts to GitHub Release. Requires release_tag when called by the release workflow.
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
workflow_call:
|
|
inputs:
|
|
version_name:
|
|
description: Version name / marketing version. Leave empty to use package.json version.
|
|
required: false
|
|
type: string
|
|
build_number:
|
|
description: Build number. Leave empty to use GitHub run number.
|
|
required: false
|
|
type: string
|
|
release_tag:
|
|
description: Existing GitHub Release tag to attach Android artifacts to.
|
|
required: false
|
|
type: string
|
|
upload_github_release:
|
|
description: Upload Android artifacts to the matching GitHub Release.
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
|
|
concurrency:
|
|
group: mobile-release-${{ inputs.release_tag != '' && inputs.release_tag || github.run_id }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
MOBILE_PACKAGE_DIR: packages/mobile
|
|
IOS_PROJECT_DIR: packages/mobile/ios/App
|
|
ANDROID_PROJECT_DIR: packages/mobile/android
|
|
|
|
jobs:
|
|
resolve-version:
|
|
name: Resolve mobile version
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
version_name: ${{ steps.version.outputs.version_name }}
|
|
build_number: ${{ steps.version.outputs.build_number }}
|
|
release_tag: ${{ steps.version.outputs.release_tag }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Resolve version values
|
|
id: version
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
input_version='${{ inputs.version_name }}'
|
|
input_build='${{ inputs.build_number }}'
|
|
input_release_tag='${{ inputs.release_tag }}'
|
|
package_version="$(node -p "require('./package.json').version")"
|
|
|
|
version_name="${input_version:-$package_version}"
|
|
build_number="${input_build:-${{ github.run_number }}}"
|
|
release_tag="$input_release_tag"
|
|
|
|
{
|
|
echo "version_name=$version_name"
|
|
echo "build_number=$build_number"
|
|
echo "release_tag=$release_tag"
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
android-release:
|
|
name: Android signed release
|
|
runs-on: ubuntu-latest
|
|
needs: resolve-version
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: oven-sh/setup-bun@v2
|
|
with:
|
|
bun-version: 1.3.14
|
|
|
|
- uses: actions/setup-java@v4
|
|
with:
|
|
distribution: temurin
|
|
java-version: 21
|
|
|
|
- name: Install dependencies
|
|
run: bun install
|
|
|
|
- name: Prepare Android keystore
|
|
shell: bash
|
|
env:
|
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ -z "$ANDROID_KEYSTORE_BASE64" ]]; then
|
|
echo "ANDROID_KEYSTORE_BASE64 secret is required."
|
|
exit 1
|
|
fi
|
|
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/openchamber-release.keystore"
|
|
|
|
- name: Build signed Android release
|
|
env:
|
|
OPENCHAMBER_ANDROID_VERSION_CODE: ${{ needs.resolve-version.outputs.build_number }}
|
|
OPENCHAMBER_ANDROID_VERSION_NAME: ${{ needs.resolve-version.outputs.version_name }}
|
|
OPENCHAMBER_ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/openchamber-release.keystore
|
|
OPENCHAMBER_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
OPENCHAMBER_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
|
OPENCHAMBER_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
run: |
|
|
bun run mobile:sync
|
|
./packages/mobile/android/gradlew -p packages/mobile/android bundleRelease assembleRelease
|
|
|
|
- name: Upload Android artifacts
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: openchamber-android-${{ needs.resolve-version.outputs.version_name }}-${{ needs.resolve-version.outputs.build_number }}
|
|
path: |
|
|
packages/mobile/android/app/build/outputs/bundle/release/*.aab
|
|
packages/mobile/android/app/build/outputs/apk/release/*.apk
|
|
if-no-files-found: error
|
|
|
|
- name: Upload Android artifacts to GitHub Release
|
|
if: inputs.upload_github_release && needs.resolve-version.outputs.release_tag != ''
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
files=(
|
|
app/build/outputs/bundle/release/*.aab
|
|
app/build/outputs/apk/release/*.apk
|
|
)
|
|
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
|
working-directory: ${{ env.ANDROID_PROJECT_DIR }}
|
|
|
|
ios-testflight:
|
|
name: iOS TestFlight upload
|
|
runs-on: macos-26
|
|
needs: resolve-version
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: oven-sh/setup-bun@v2
|
|
with:
|
|
bun-version: 1.3.14
|
|
|
|
- name: Install dependencies
|
|
run: bun install
|
|
|
|
- name: Install Apple signing assets
|
|
shell: bash
|
|
env:
|
|
IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }}
|
|
IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }}
|
|
IOS_APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROFILE_BASE64 }}
|
|
IOS_WIDGET_PROFILE_BASE64: ${{ secrets.IOS_WIDGET_PROFILE_BASE64 }}
|
|
IOS_NSE_PROFILE_BASE64: ${{ secrets.IOS_NSE_PROFILE_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
for name in IOS_DISTRIBUTION_CERTIFICATE_BASE64 IOS_APP_PROFILE_BASE64 IOS_WIDGET_PROFILE_BASE64 IOS_NSE_PROFILE_BASE64; do
|
|
if [[ -z "${!name}" ]]; then
|
|
echo "$name secret is required."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
cert_path="$RUNNER_TEMP/ios_distribution.p12"
|
|
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
|
|
profiles_dir="$HOME/Library/MobileDevice/Provisioning Profiles"
|
|
mkdir -p "$profiles_dir"
|
|
|
|
printf '%s' "$IOS_DISTRIBUTION_CERTIFICATE_BASE64" | base64 -D > "$cert_path"
|
|
security create-keychain -p "$RUNNER_TEMP" "$keychain_path"
|
|
security set-keychain-settings -lut 21600 "$keychain_path"
|
|
security unlock-keychain -p "$RUNNER_TEMP" "$keychain_path"
|
|
security import "$cert_path" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain_path"
|
|
security list-keychain -d user -s "$keychain_path"
|
|
|
|
app_profile="$RUNNER_TEMP/openchamber-app.mobileprovision"
|
|
widget_profile="$RUNNER_TEMP/openchamber-widget.mobileprovision"
|
|
nse_profile="$RUNNER_TEMP/openchamber-notification-service.mobileprovision"
|
|
printf '%s' "$IOS_APP_PROFILE_BASE64" | base64 -D > "$app_profile"
|
|
printf '%s' "$IOS_WIDGET_PROFILE_BASE64" | base64 -D > "$widget_profile"
|
|
printf '%s' "$IOS_NSE_PROFILE_BASE64" | base64 -D > "$nse_profile"
|
|
|
|
profile_uuid() {
|
|
security cms -D -i "$1" > "$RUNNER_TEMP/profile.plist"
|
|
/usr/libexec/PlistBuddy -c 'Print :UUID' "$RUNNER_TEMP/profile.plist"
|
|
}
|
|
install_profile() {
|
|
local source_path="$1"
|
|
local env_name="$2"
|
|
local uuid
|
|
uuid="$(profile_uuid "$source_path")"
|
|
cp "$source_path" "$profiles_dir/$uuid.mobileprovision"
|
|
echo "$env_name=$uuid" >> "$GITHUB_ENV"
|
|
}
|
|
install_profile "$app_profile" IOS_APP_PROFILE_UUID
|
|
install_profile "$widget_profile" IOS_WIDGET_PROFILE_UUID
|
|
install_profile "$nse_profile" IOS_NSE_PROFILE_UUID
|
|
|
|
- name: Prepare mobile assets
|
|
run: bun run mobile:sync
|
|
|
|
- name: Set TestFlight entitlement and versions
|
|
shell: bash
|
|
env:
|
|
VERSION_NAME: ${{ needs.resolve-version.outputs.version_name }}
|
|
BUILD_NUMBER: ${{ needs.resolve-version.outputs.build_number }}
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
IOS_APP_PROFILE_NAME: ${{ secrets.IOS_APP_PROFILE_NAME }}
|
|
IOS_WIDGET_PROFILE_NAME: ${{ secrets.IOS_WIDGET_PROFILE_NAME }}
|
|
IOS_NSE_PROFILE_NAME: ${{ secrets.IOS_NSE_PROFILE_NAME }}
|
|
run: |
|
|
set -euo pipefail
|
|
/usr/libexec/PlistBuddy -c "Set :aps-environment production" App/App.entitlements
|
|
xcrun agvtool new-marketing-version "$VERSION_NAME"
|
|
xcrun agvtool new-version -all "$BUILD_NUMBER"
|
|
|
|
node --input-type=module <<'NODE'
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
|
const projectPath = 'App.xcodeproj/project.pbxproj';
|
|
let project = readFileSync(projectPath, 'utf8');
|
|
const releaseBlockPattern = /\n\t\t[^\n]+ \/\* Release \*\/ = \{\n\t\t\tisa = XCBuildConfiguration;[\s\S]*?\n\t\t\tname = Release;\n\t\t\};/g;
|
|
const replacements = [
|
|
{
|
|
bundle: 'com.openchamber.app',
|
|
profile: process.env.IOS_APP_PROFILE_NAME,
|
|
uuid: process.env.IOS_APP_PROFILE_UUID,
|
|
},
|
|
{
|
|
bundle: 'com.openchamber.app.OpenChamberWidget',
|
|
profile: process.env.IOS_WIDGET_PROFILE_NAME,
|
|
uuid: process.env.IOS_WIDGET_PROFILE_UUID,
|
|
},
|
|
{
|
|
bundle: 'com.openchamber.app.OpenChamberNotificationService',
|
|
profile: process.env.IOS_NSE_PROFILE_NAME,
|
|
uuid: process.env.IOS_NSE_PROFILE_UUID,
|
|
},
|
|
];
|
|
|
|
function setBuildSetting(block, key, value) {
|
|
const settingPattern = new RegExp(`\\n\\t\\t\\t\\t${key} = [^;]+;`);
|
|
const line = `\n\t\t\t\t${key} = ${value};`;
|
|
if (settingPattern.test(block)) return block.replace(settingPattern, line);
|
|
return block.replace('\n\t\t\t};', `${line}\n\t\t\t};`);
|
|
}
|
|
|
|
for (const { bundle, profile, uuid } of replacements) {
|
|
if (!profile) throw new Error(`Missing provisioning profile name for ${bundle}`);
|
|
if (!uuid) throw new Error(`Missing provisioning profile UUID for ${bundle}`);
|
|
const marker = `PRODUCT_BUNDLE_IDENTIFIER = ${bundle};`;
|
|
const match = [...project.matchAll(releaseBlockPattern)].find(([block]) => block.includes(marker));
|
|
if (!match) throw new Error(`Could not find ${bundle} Release build settings block`);
|
|
|
|
let block = match[0];
|
|
block = setBuildSetting(block, 'CODE_SIGN_IDENTITY', '"Apple Distribution"');
|
|
block = setBuildSetting(block, 'CODE_SIGN_STYLE', 'Manual');
|
|
block = setBuildSetting(block, 'DEVELOPMENT_TEAM', process.env.APPLE_TEAM_ID);
|
|
block = setBuildSetting(block, 'PROVISIONING_PROFILE', `"${uuid}"`);
|
|
block = setBuildSetting(block, 'PROVISIONING_PROFILE_SPECIFIER', `"${profile}"`);
|
|
|
|
project = project.replace(match[0], block);
|
|
}
|
|
|
|
writeFileSync(projectPath, project);
|
|
NODE
|
|
working-directory: ${{ env.IOS_PROJECT_DIR }}
|
|
|
|
- name: Archive iOS app
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
xcodebuild archive \
|
|
-workspace App.xcworkspace \
|
|
-scheme App \
|
|
-configuration Release \
|
|
-destination 'generic/platform=iOS' \
|
|
-archivePath "$RUNNER_TEMP/OpenChamber.xcarchive" \
|
|
"OTHER_CODE_SIGN_FLAGS=--keychain $RUNNER_TEMP/app-signing.keychain-db"
|
|
working-directory: ${{ env.IOS_PROJECT_DIR }}
|
|
|
|
- name: Export IPA
|
|
shell: bash
|
|
env:
|
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
IOS_APP_PROFILE_NAME: ${{ secrets.IOS_APP_PROFILE_NAME }}
|
|
IOS_WIDGET_PROFILE_NAME: ${{ secrets.IOS_WIDGET_PROFILE_NAME }}
|
|
IOS_NSE_PROFILE_NAME: ${{ secrets.IOS_NSE_PROFILE_NAME }}
|
|
run: |
|
|
set -euo pipefail
|
|
for name in IOS_APP_PROFILE_NAME IOS_WIDGET_PROFILE_NAME IOS_NSE_PROFILE_NAME; do
|
|
if [[ -z "${!name}" ]]; then
|
|
echo "$name secret is required."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
cat > "$RUNNER_TEMP/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</string>
|
|
<key>teamID</key>
|
|
<string>$APPLE_TEAM_ID</string>
|
|
<key>signingStyle</key>
|
|
<string>manual</string>
|
|
<key>provisioningProfiles</key>
|
|
<dict>
|
|
<key>com.openchamber.app</key>
|
|
<string>$IOS_APP_PROFILE_NAME</string>
|
|
<key>com.openchamber.app.OpenChamberWidget</key>
|
|
<string>$IOS_WIDGET_PROFILE_NAME</string>
|
|
<key>com.openchamber.app.OpenChamberNotificationService</key>
|
|
<string>$IOS_NSE_PROFILE_NAME</string>
|
|
</dict>
|
|
<key>uploadSymbols</key>
|
|
<true/>
|
|
</dict>
|
|
</plist>
|
|
PLIST
|
|
xcodebuild -exportArchive \
|
|
-archivePath "$RUNNER_TEMP/OpenChamber.xcarchive" \
|
|
-exportPath "$RUNNER_TEMP/OpenChamberExport" \
|
|
-exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist"
|
|
working-directory: ${{ env.IOS_PROJECT_DIR }}
|
|
|
|
- name: Upload IPA artifact
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: openchamber-ios-${{ needs.resolve-version.outputs.version_name }}-${{ needs.resolve-version.outputs.build_number }}
|
|
path: ${{ runner.temp }}/OpenChamberExport/*.ipa
|
|
if-no-files-found: error
|
|
|
|
- name: Upload to TestFlight
|
|
shell: bash
|
|
env:
|
|
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_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY_BASE64 }}
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p "$HOME/private_keys"
|
|
printf '%s' "$APP_STORE_CONNECT_PRIVATE_KEY_BASE64" | base64 -D > "$HOME/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
|
|
xcrun altool --upload-app \
|
|
--type ios \
|
|
--file "$RUNNER_TEMP/OpenChamberExport/App.ipa" \
|
|
--apiKey "$APP_STORE_CONNECT_KEY_ID" \
|
|
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
|