From 7739cec6abf24f175a32ce3334d431cb34bb9cb5 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 2 Apr 2026 19:30:54 -0500 Subject: [PATCH] Add iOS TestFlight release workflow - Sync release versions across mobile packages - Update iOS marketing/build versions during release - Document TestFlight publishing in release notes --- .github/workflows/release-ios.yml | 229 ++++++++++++++++++ .github/workflows/release.yml | 6 +- apps/desktop/package.json | 2 +- .../ios/App/App.xcodeproj/project.pbxproj | 4 +- apps/mobile/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package.json | 3 + packages/contracts/package.json | 2 +- scripts/prepare-release.ts | 23 +- scripts/update-ios-version.ts | 120 +++++++++ scripts/update-release-package-versions.ts | 1 + 12 files changed, 381 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release-ios.yml create mode 100644 scripts/update-ios-version.ts diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml new file mode 100644 index 000000000..d9f6631bc --- /dev/null +++ b/.github/workflows/release-ios.yml @@ -0,0 +1,229 @@ +name: Release iOS + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: "Release version (for example 1.2.3 or v1.2.3)" + required: true + type: string + +permissions: + contents: read + +jobs: + preflight: + name: Preflight + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.release_meta.outputs.version }} + tag: ${{ steps.release_meta.outputs.tag }} + is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} + ref: ${{ github.sha }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - id: release_meta + name: Resolve release version + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + raw="${{ github.event.inputs.version }}" + else + raw="${GITHUB_REF_NAME}" + fi + + version="${raw#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release version: $raw" >&2 + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + fi + + build-ios: + name: Build & Upload to TestFlight + needs: [preflight] + runs-on: macos-14 + env: + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version-file: package.json + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Align package versions to release version + run: node scripts/update-release-package-versions.ts "$RELEASE_VERSION" + + - name: Update iOS version in Xcode project + run: node scripts/update-ios-version.ts "$RELEASE_VERSION" --build-number "$GITHUB_RUN_NUMBER" + + - name: Build mobile web bundle + run: bun run --cwd apps/mobile build + + - name: Sync Capacitor iOS + run: bunx cap sync ios --deployment + working-directory: apps/mobile + + - name: Install Apple certificate and provisioning profile + env: + IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }} + run: | + set -euo pipefail + + # Validate required secrets + for secret_name in IOS_CERTIFICATE_P12 IOS_CERTIFICATE_PASSWORD IOS_PROVISIONING_PROFILE; do + if [[ -z "${!secret_name}" ]]; then + echo "Missing required secret: $secret_name" >&2 + exit 1 + fi + done + + # Create temporary keychain + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 16)" + 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 distribution certificate + CERT_PATH="$RUNNER_TEMP/certificate.p12" + echo "$IOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" + security import "$CERT_PATH" \ + -P "$IOS_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" + + # Install provisioning profile + PROFILE_PATH="$RUNNER_TEMP/profile.mobileprovision" + echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > "$PROFILE_PATH" + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ + + - name: Build iOS archive + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + + xcodebuild archive \ + -project apps/mobile/ios/App/App.xcodeproj \ + -scheme App \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/App.xcarchive" \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_IDENTITY="iPhone Distribution" \ + PROVISIONING_PROFILE_SPECIFIER="${{ secrets.IOS_PROVISIONING_PROFILE_NAME }}" \ + -allowProvisioningUpdates \ + COMPILER_INDEX_STORE_ENABLE=NO + + - name: Generate ExportOptions.plist + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + cat > "$RUNNER_TEMP/ExportOptions.plist" < + + + + method + app-store-connect + destination + upload + teamID + ${APPLE_TEAM_ID} + uploadSymbols + + signingStyle + manual + provisioningProfiles + + com.openknots.okcode.mobile + ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }} + + + + PLIST + + - name: Export IPA + run: | + set -euo pipefail + + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/App.xcarchive" \ + -exportPath "$RUNNER_TEMP/export" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" + + - name: Write App Store Connect API key + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + run: | + set -euo pipefail + KEY_DIR="$HOME/private_keys" + mkdir -p "$KEY_DIR" + printf '%s' "$APPLE_API_KEY" > "$KEY_DIR/AuthKey_${APPLE_API_KEY_ID}.p8" + + - name: Upload to TestFlight + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + set -euo pipefail + + IPA_FILE=$(find "$RUNNER_TEMP/export" -name "*.ipa" -print -quit) + if [[ -z "$IPA_FILE" ]]; then + echo "No IPA file found in export directory" >&2 + ls -la "$RUNNER_TEMP/export/" + exit 1 + fi + + echo "Uploading $IPA_FILE to TestFlight..." + + xcrun altool --upload-app \ + -f "$IPA_FILE" \ + -t ios \ + --apiKey "$APPLE_API_KEY_ID" \ + --apiIssuer "$APPLE_API_ISSUER" + + echo "Upload to TestFlight complete!" + + - name: Cleanup keychain + if: always() + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" + if [[ -f "$KEYCHAIN_PATH" ]]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + rm -f "$HOME/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50b757241..d71c51b00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,7 +371,7 @@ jobs: - name: Format package.json files if: steps.update_versions.outputs.changed == 'true' - run: bunx oxfmt apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json + run: bunx oxfmt apps/server/package.json apps/desktop/package.json apps/web/package.json apps/mobile/package.json packages/contracts/package.json - name: Refresh lockfile if: steps.update_versions.outputs.changed == 'true' @@ -383,7 +383,7 @@ jobs: env: RELEASE_TAG: ${{ needs.preflight.outputs.tag }} run: | - if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock; then + if git diff --quiet -- apps/server/package.json apps/desktop/package.json apps/web/package.json apps/mobile/package.json packages/contracts/package.json bun.lock; then echo "No version changes to commit." exit 0 fi @@ -391,6 +391,6 @@ jobs: git config user.name "${{ steps.app_bot.outputs.name }}" git config user.email "${{ steps.app_bot.outputs.email }}" - git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json bun.lock + git add apps/server/package.json apps/desktop/package.json apps/web/package.json apps/mobile/package.json packages/contracts/package.json bun.lock git commit -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd42436b5..7df53d7c9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@okcode/desktop", - "version": "0.0.12", + "version": "0.10.0", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/mobile/ios/App/App.xcodeproj/project.pbxproj b/apps/mobile/ios/App/App.xcodeproj/project.pbxproj index 0965b8894..f5e2b34ee 100644 --- a/apps/mobile/ios/App/App.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/App/App.xcodeproj/project.pbxproj @@ -303,7 +303,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.10.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.openknots.okcode.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -325,7 +325,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.10.0; PRODUCT_BUNDLE_IDENTIFIER = com.openknots.okcode.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index e2ee8fb35..80e558773 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@okcode/mobile", - "version": "0.0.4", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/apps/server/package.json b/apps/server/package.json index c3ef3052c..adf50bcb4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "okcodes", - "version": "0.0.12", + "version": "0.10.0", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index ca3a097e6..16fdc7109 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@okcode/web", - "version": "0.0.12", + "version": "0.10.0", "private": true, "type": "module", "scripts": { diff --git a/package.json b/package.json index fbb7f675d..96c39bd7a 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", "release:prepare": "node scripts/prepare-release.ts", "release:smoke": "node scripts/release-smoke.ts", + "dist:mobile:build": "bun run --cwd apps/mobile build", + "dist:mobile:sync:ios": "bunx cap sync ios --deployment", + "dist:mobile:update-ios-version": "node scripts/update-ios-version.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", "regenerate:brand-assets": "python3 scripts/generate-brand-assets.py", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 4a2fd427c..e7e4ac3d9 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@okcode/contracts", - "version": "0.0.12", + "version": "0.10.0", "private": true, "files": [ "dist" diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index 5dab6863d..6c33c2f75 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -260,6 +260,7 @@ ${highlights || "- See changelog for detailed changes."} - **CLI:** \`npm install -g okcodes@${version}\` (after the package is published to npm manually). - **Desktop:** Download from [GitHub Releases](${REPO_URL}/releases/tag/v${version}). Filenames are listed in [assets.md](v${version}/assets.md). +- **iOS:** Available via TestFlight (uploaded automatically by the Release iOS workflow). ## Known limitations @@ -301,6 +302,16 @@ After the workflow completes, expect **installer and updater** artifacts similar | \`latest.yml\` | Windows update manifest | | \`*.blockmap\` | Differential download block maps | +## iOS (TestFlight) + +The iOS build is uploaded directly to App Store Connect / TestFlight by the [Release iOS workflow](../../.github/workflows/release-ios.yml). No IPA artifact is attached to the GitHub Release. + +| Detail | Value | +| ----------------- | ---------------------------------------- | +| Bundle ID | \`com.openknots.okcode.mobile\` | +| Marketing version | \`${version}\` | +| Build number | Set from \`GITHUB_RUN_NUMBER\` at build time | + ## Checksums SHA-256 checksums are not committed here; verify downloads via GitHub's release UI or \`gh release download\` if you use the GitHub CLI. @@ -713,11 +724,13 @@ async function main(): Promise { if (!skipCommit && !dryRun) { console.log(" Next steps:"); - console.log(` 1. Monitor the release workflow: ${REPO_URL}/actions`); - console.log(` 2. Verify the GitHub Release: ${REPO_URL}/releases/tag/${tag}`); - console.log(" 3. Test downloaded installers on each platform."); - console.log(" 4. Verify auto-update from the previous version."); - console.log(` 5. Confirm version bump commit on main: git log origin/main --oneline -5`); + console.log(` 1. Monitor the desktop release workflow: ${REPO_URL}/actions/workflows/release.yml`); + console.log(` 2. Monitor the iOS TestFlight workflow: ${REPO_URL}/actions/workflows/release-ios.yml`); + console.log(` 3. Verify the GitHub Release: ${REPO_URL}/releases/tag/${tag}`); + console.log(" 4. Test downloaded installers on each platform."); + console.log(" 5. Verify auto-update from the previous version."); + console.log(" 6. Verify TestFlight build in App Store Connect."); + console.log(` 7. Confirm version bump commit on main: git log origin/main --oneline -5`); console.log(""); } else if (skipCommit) { console.log(" Documentation generated. To finish the release manually:"); diff --git a/scripts/update-ios-version.ts b/scripts/update-ios-version.ts new file mode 100644 index 000000000..1b9d0d9f0 --- /dev/null +++ b/scripts/update-ios-version.ts @@ -0,0 +1,120 @@ +/** + * update-ios-version.ts — Update MARKETING_VERSION and CURRENT_PROJECT_VERSION + * in the Xcode project.pbxproj for iOS release builds. + * + * Usage: + * node scripts/update-ios-version.ts [--build-number ] + * + * If --build-number is omitted, falls back to $GITHUB_RUN_NUMBER or "1". + * + * Examples: + * node scripts/update-ios-version.ts 0.10.0 + * node scripts/update-ios-version.ts 0.10.0 --build-number 42 + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PBXPROJ_RELATIVE_PATH = "apps/mobile/ios/App/App.xcodeproj/project.pbxproj"; + +export interface UpdateIosVersionOptions { + readonly rootDir?: string; +} + +export function updateIosVersion( + version: string, + buildNumber: string, + options: UpdateIosVersionOptions = {}, +): { changed: boolean } { + const rootDir = resolve(options.rootDir ?? process.cwd()); + const pbxprojPath = resolve(rootDir, PBXPROJ_RELATIVE_PATH); + + let content = readFileSync(pbxprojPath, "utf8"); + const original = content; + + // Replace all MARKETING_VERSION = ; with the new version + content = content.replace( + /MARKETING_VERSION\s*=\s*[^;]+;/g, + `MARKETING_VERSION = ${version};`, + ); + + // Replace all CURRENT_PROJECT_VERSION = ; with the new build number + content = content.replace( + /CURRENT_PROJECT_VERSION\s*=\s*[^;]+;/g, + `CURRENT_PROJECT_VERSION = ${buildNumber};`, + ); + + if (content === original) { + return { changed: false }; + } + + writeFileSync(pbxprojPath, content); + return { changed: true }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function parseArgs(argv: ReadonlyArray): { + version: string; + buildNumber: string; + rootDir: string | undefined; +} { + let version: string | undefined; + let buildNumber: string | undefined; + let rootDir: string | undefined; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === undefined) continue; + + if (arg === "--build-number") { + buildNumber = argv[i + 1]; + if (!buildNumber) throw new Error("Missing value for --build-number."); + i += 1; + continue; + } + + if (arg === "--root") { + rootDir = argv[i + 1]; + if (!rootDir) throw new Error("Missing value for --root."); + i += 1; + continue; + } + + if (arg.startsWith("--")) { + throw new Error(`Unknown argument: ${arg}`); + } + + if (version !== undefined) { + throw new Error("Only one version argument is allowed."); + } + version = arg; + } + + if (!version) { + throw new Error( + "Usage: node scripts/update-ios-version.ts [--build-number ] [--root ]", + ); + } + + const resolvedBuildNumber = buildNumber ?? process.env.GITHUB_RUN_NUMBER ?? "1"; + + return { version, buildNumber: resolvedBuildNumber, rootDir }; +} + +const isMain = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const { version, buildNumber, rootDir } = parseArgs(process.argv.slice(2)); + const { changed } = updateIosVersion(version, buildNumber, rootDir === undefined ? {} : { rootDir }); + + if (changed) { + console.log(`Updated iOS version to ${version} (build ${buildNumber}).`); + } else { + console.log("iOS project version already matches."); + } +} diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts index b860b85e8..a351373a2 100644 --- a/scripts/update-release-package-versions.ts +++ b/scripts/update-release-package-versions.ts @@ -6,6 +6,7 @@ export const releasePackageFiles = [ "apps/server/package.json", "apps/desktop/package.json", "apps/web/package.json", + "apps/mobile/package.json", "packages/contracts/package.json", ] as const;