diff --git a/client/capacitor/README.md b/client/capacitor/README.md index 971bc66528..a806b210b0 100644 --- a/client/capacitor/README.md +++ b/client/capacitor/README.md @@ -105,6 +105,16 @@ npm run action client/capacitor/build ios This runs the full build: the web bundle, `cap sync ios` (tun2socks + native sync), and an **unsigned** `xcodebuild` of `ios/App/App.xcodeproj` for a generic iOS device. It is what CI runs. Because it disables code signing, the resulting app cannot be installed on a device; to run the app, use the steps below instead. +### Build the release + +Releases are built and published by the scripts in [outline-release](https://github.com/OutlineFoundation/outline-release), which supply the Sentry DSN and the development team and call this action: + +```sh +npm run action client/capacitor/build ios -- --buildMode=release --versionName= +``` + +It reads `SENTRY_DSN` and `DEVELOPMENT_TEAM` from the environment, and needs that team's signing identity set up in Xcode. It runs `xcodebuild archive` with the given version and the build number (hours since the Unix epoch), and leaves the signed archive at `output/client/ios/Outline.xcarchive`. + ### Steps to build and start the app 1. **Build the web bundle** (`www/`), from the **repository root**: diff --git a/client/capacitor/build.action.mjs b/client/capacitor/build.action.mjs index d0af86bf63..b67ad6ea3b 100644 --- a/client/capacitor/build.action.mjs +++ b/client/capacitor/build.action.mjs @@ -17,6 +17,7 @@ import os from 'os'; import path from 'path'; import url from 'url'; +import {getRootDir} from '@outline/infrastructure/build/get_root_dir.mjs'; import {runAction} from '@outline/infrastructure/build/run_action.mjs'; import {spawnStream} from '@outline/infrastructure/build/spawn_stream.mjs'; @@ -43,14 +44,17 @@ export async function main(...parameters) { ); } - // TODO: Support an iOS release build once we're ready to migrate to Capacitor. - if (buildMode === 'release' && platform !== 'android') { - throw new TypeError( - `Capacitor ${platform} build supports only debug mode, got "${buildMode}".` - ); + // Check the release signing inputs before the (slow) web and Go builds. + if (platform === 'ios' && buildMode === 'release') { + // The app target has no development team of its own (see + // ios/App/App.xcodeproj), so a signed build needs one from the caller. + if (!process.env.DEVELOPMENT_TEAM) { + throw new ReferenceError( + 'DEVELOPMENT_TEAM must be defined in the environment to build an iOS Release!' + ); + } } - // Check the release signing inputs before the (slow) web and Go builds. if (platform === 'android' && buildMode === 'release') { if (!process.env.JAVA_HOME) { throw new ReferenceError( @@ -104,6 +108,13 @@ export async function main(...parameters) { ); case 'ios' + 'debug': return iosDebug(verbose); + case 'ios' + 'release': + return iosRelease( + process.env.DEVELOPMENT_TEAM, + versionName, + buildNumber, + verbose + ); } } @@ -196,25 +207,28 @@ async function androidRelease( } } +const IOS_XCODE_BUILD_ARGS = [ + '-project', + path.resolve(capacitorDir, 'ios', 'App', 'App.xcodeproj'), + '-scheme', + 'Outline', + '-destination', + 'generic/platform=iOS', +]; + async function iosDebug(verbose) { // `cap build` only produces signed release builds, so invoke xcodebuild // directly for an unsigned debug build, the same way the Cordova iOS build // does (see client/src/cordova/build.action.mjs). Signing is disabled so the // build needs no development team or provisioning profile, which CI does not // have. - // TODO: Migrate to an archive once we have a production build. console.warn( 'WARNING: building "ios" in [DEBUG] mode. Do not publish this build!!' ); await spawnStream( 'xcodebuild', 'clean', - '-project', - path.resolve(capacitorDir, 'ios', 'App', 'App.xcodeproj'), - '-scheme', - 'Outline', - '-destination', - 'generic/platform=iOS', + ...IOS_XCODE_BUILD_ARGS, 'build', '-configuration', 'Debug', @@ -224,6 +238,39 @@ async function iosDebug(verbose) { ); } +/** + * Archives the signed release app to output/client/ios/Outline.xcarchive, + * replacing any previous archive there. + */ +async function iosRelease(teamId, versionName, buildNumber, verbose) { + const archivePath = path.resolve( + getRootDir(), + 'output', + 'client', + 'ios', + 'Outline.xcarchive' + ); + await fs.rm(archivePath, {recursive: true, force: true}); + + await spawnStream( + 'xcodebuild', + 'clean', + ...IOS_XCODE_BUILD_ARGS, + 'archive', + '-archivePath', + archivePath, + '-configuration', + 'Release', + '-allowProvisioningUpdates', + ...(verbose ? [] : ['-quiet']), + // The Info.plist of the app and of the VpnExtension take their version + // from these build settings. + `MARKETING_VERSION=${versionName}`, + `CURRENT_PROJECT_VERSION=${buildNumber}`, + `DEVELOPMENT_TEAM=${teamId}` + ); +} + if (import.meta.url === url.pathToFileURL(process.argv[1]).href) { await main(...process.argv.slice(2)); }