diff --git a/.github/workflows/release-ios.yml b/.github/workflows/release-ios.yml index 33d903cc..4db1d8bf 100644 --- a/.github/workflows/release-ios.yml +++ b/.github/workflows/release-ios.yml @@ -25,6 +25,9 @@ jobs: timeout-minutes: 90 permissions: contents: write + outputs: + version: ${{ steps.export.outputs.version }} + uploaded: ${{ steps.upload.outputs.done }} env: EXPO_NO_TELEMETRY: "1" # App Store Connect refuses a CFBundleVersion it has already seen for this @@ -254,6 +257,7 @@ jobs: if-no-files-found: error - name: Upload to TestFlight + id: upload # The IPA is not attached to the GitHub release: without an Apple device # provisioned for it, it is not installable, and TestFlight is the only # distribution channel for it. @@ -271,4 +275,35 @@ jobs: --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" xcrun altool --upload-app --type ios --file "$IPA" \ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID" + echo "done=true" >> "$GITHUB_OUTPUT" echo "::notice::Uploaded build ${IOS_BUILD_NUMBER} to App Store Connect. Processing usually takes 5-30 minutes before it appears in TestFlight." + + # Puts the upload on the public TestFlight link (README): waits for App Store + # Connect to process it, adds it to the external group and submits it for + # beta review. On Linux, because most of this job is waiting. + public-testflight: + needs: build + if: needs.build.outputs.uploaded == 'true' + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install the App Store Connect API key + env: + ASC_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + run: | + mkdir -p ~/private_keys + echo "$ASC_KEY_BASE64" | base64 -d > ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 + chmod 600 ~/private_keys/AuthKey_${ASC_KEY_ID}.p8 + + - name: Add the build to the public group and submit it for beta review + env: + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + run: node scripts/testflight-public.mjs "${{ needs.build.outputs.version }}" "${{ github.run_number }}" diff --git a/README.md b/README.md index 45fcc087..91d2eae7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwarkmail/webmail) - a JMAP-based mail, calendar, and contacts app. +## Try it + +- **iPhone and iPad:** join the beta on TestFlight: https://testflight.apple.com/join/rumcjNSp. A new release shows up there once Apple has reviewed it, usually within a day. +- **Android:** download the APK from the [latest release](https://github.com/bulwarkmail/native/releases/latest). + ## What works today - Sign in to any JMAP server (e.g. Stalwart) @@ -30,7 +35,7 @@ React Native (Expo SDK 54) client for [Bulwark Webmail](https://github.com/bulwa - iOS builds, but push notifications, client certificates and syncing with the phone's contacts and calendars are Android-only so far - S/MIME, plugins, themes - UI stubs only (filters & rules, the vacation responder and file storage are real implementations) - Calendar editing is partial; contacts editing is basic -- No Play Store distribution yet (sideload APK from Releases); iOS ships via TestFlight +- No Play Store or App Store listing yet: Android is a sideloaded APK, iOS a TestFlight beta (see [Try it](#try-it)) ## Run locally diff --git a/docs/ios-release.md b/docs/ios-release.md index cf0c6b09..972ffe89 100644 --- a/docs/ios-release.md +++ b/docs/ios-release.md @@ -142,6 +142,20 @@ Internal testers (up to 100, must be on your App Store Connect team) get it immediately. External testers (up to 10,000) need a Beta App Review, which takes about a day the first time and is usually waved through afterwards. +The workflow's `public-testflight` job does the external part on its own. It +waits for processing, adds the build to the `external` group (the one behind +the public link in the README), sets "What to Test" to the GitHub release and +submits it for Beta App Review (`scripts/testflight-public.mjs`). To do this by +hand for a build, for example after a failed run: + +```bash +ASC_KEY_ID=… ASC_ISSUER_ID=… ASC_KEY_PATH=path/to/AuthKey_….p8 node scripts/testflight-public.mjs 0.1.72 18 # marketing version, build number +``` + +If App Store Connect answers `REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED` (altool +reports it as "Cannot determine the Apple ID from Bundle ID"), the Account +Holder has to accept an updated agreement under Business in App Store Connect. + ## What does not work on iOS yet - **Push notifications.** The push path is a hand-written Android module talking diff --git a/scripts/testflight-public.mjs b/scripts/testflight-public.mjs new file mode 100644 index 00000000..e9f5195b --- /dev/null +++ b/scripts/testflight-public.mjs @@ -0,0 +1,171 @@ +// Put an uploaded build on the public TestFlight link: wait until App Store +// Connect has processed it, add it to the external beta group, set its +// "What to Test" text and submit it for beta review. Builds only reach +// external testers (and so the public link) once Apple approves them. +// +// node scripts/testflight-public.mjs +// +// Environment: +// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key +// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_.p8) +// BUNDLE_ID default org.bulwarkmail.mobile +// TESTFLIGHT_GROUP external group name (default "external") +// WHATS_NEW "What to Test" text (default: link to the GitHub release) +// +// Every step can be repeated: a build already in the group, already described +// or already submitted is left as it is, so a re-run picks up where the last +// one stopped. +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const [version, buildNumber] = process.argv.slice(2); +if (!version || !buildNumber) { + console.error('usage: node scripts/testflight-public.mjs '); + process.exit(2); +} + +const keyId = process.env.ASC_KEY_ID; +const issuer = process.env.ASC_ISSUER_ID; +if (!keyId || !issuer) { + console.error('ASC_KEY_ID and ASC_ISSUER_ID are required.'); + process.exit(2); +} +const keyPath = process.env.ASC_KEY_PATH || path.join(os.homedir(), 'private_keys', `AuthKey_${keyId}.p8`); +const privateKey = fs.readFileSync(keyPath, 'utf8'); +const bundleId = process.env.BUNDLE_ID || 'org.bulwarkmail.mobile'; +const groupName = process.env.TESTFLIGHT_GROUP || 'external'; +const repo = process.env.GITHUB_REPOSITORY || 'bulwarkmail/native'; +const whatsNew = process.env.WHATS_NEW || `Release notes: https://github.com/${repo}/releases/tag/${version}`; + +// Processing usually takes 5-30 minutes; give up well inside the job timeout. +const PROCESSING_TIMEOUT_MS = 60 * 60 * 1000; +const POLL_MS = 30 * 1000; + +function token() { + const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); + const now = Math.floor(Date.now() / 1000); + const head = b64({ alg: 'ES256', kid: keyId, typ: 'JWT' }); + const body = b64({ iss: issuer, iat: now, exp: now + 15 * 60, aud: 'appstoreconnect-v1' }); + const sig = crypto + .sign('sha256', Buffer.from(`${head}.${body}`), { key: privateKey, dsaEncoding: 'ieee-p1363' }) + .toString('base64url'); + return `${head}.${body}.${sig}`; +} + +async function api(method, p, body) { + const res = await fetch(`https://api.appstoreconnect.apple.com${p}`, { + method, + headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + const json = text ? JSON.parse(text) : null; + return { status: res.status, ok: res.ok, json }; +} + +function fail(step, r) { + const e = r.json?.errors?.[0]; + console.error(`${step} failed: ${r.status} ${e?.code ?? ''} ${e?.detail ?? e?.title ?? ''}`); + if (e?.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') { + console.error('An Apple agreement needs accepting in App Store Connect > Business.'); + } + process.exit(1); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// ── The app and the group ────────────────────────────────────── +const apps = await api('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}&fields[apps]=name`); +if (!apps.ok) fail('Looking up the app', apps); +const app = apps.json.data[0]; +if (!app) { + console.error(`No app with bundle ID ${bundleId} in App Store Connect.`); + process.exit(1); +} + +const groups = await api( + 'GET', + `/v1/betaGroups?filter[app]=${app.id}&filter[name]=${encodeURIComponent(groupName)}&fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled`, +); +if (!groups.ok) fail('Looking up the beta group', groups); +const group = groups.json.data.find((g) => !g.attributes.isInternalGroup && g.attributes.name === groupName); +if (!group) { + console.error(`No external beta group named "${groupName}".`); + process.exit(1); +} + +// ── Wait for the build ───────────────────────────────────────── +const query = + `/v1/builds?filter[app]=${app.id}&filter[version]=${encodeURIComponent(buildNumber)}` + + `&filter[preReleaseVersion.version]=${encodeURIComponent(version)}&fields[builds]=version,processingState`; +const started = Date.now(); +let build; +for (;;) { + const r = await api('GET', query); + if (!r.ok) fail('Looking up the build', r); + build = r.json.data[0]; + const state = build?.attributes.processingState; + if (state === 'VALID') break; + if (state === 'FAILED' || state === 'INVALID') { + console.error(`Build ${version} (${buildNumber}) is ${state} in App Store Connect.`); + process.exit(1); + } + if (Date.now() - started > PROCESSING_TIMEOUT_MS) { + console.error(`Build ${version} (${buildNumber}) was not processed within an hour (state: ${state ?? 'not found'}).`); + process.exit(1); + } + console.log(`Build ${version} (${buildNumber}): ${state ?? 'not visible yet'}, waiting…`); + await sleep(POLL_MS); +} +console.log(`Build ${version} (${buildNumber}) is processed.`); + +// ── Add it to the group ──────────────────────────────────────── +const add = await api('POST', `/v1/betaGroups/${group.id}/relationships/builds`, { + data: [{ type: 'builds', id: build.id }], +}); +if (!add.ok) fail(`Adding the build to "${groupName}"`, add); +console.log(`Added to the "${groupName}" group.`); + +// ── "What to Test" ───────────────────────────────────────────── +const locs = await api('GET', `/v1/builds/${build.id}/betaBuildLocalizations`); +if (!locs.ok) fail('Reading the build notes', locs); +const en = locs.json.data.find((l) => l.attributes.locale === 'en-US'); +const notes = en + ? await api('PATCH', `/v1/betaBuildLocalizations/${en.id}`, { + data: { type: 'betaBuildLocalizations', id: en.id, attributes: { whatsNew } }, + }) + : await api('POST', '/v1/betaBuildLocalizations', { + data: { + type: 'betaBuildLocalizations', + attributes: { locale: 'en-US', whatsNew }, + relationships: { build: { data: { type: 'builds', id: build.id } } }, + }, + }); +if (!notes.ok) fail('Setting "What to Test"', notes); + +// ── Beta review ──────────────────────────────────────────────── +const detail = await api('GET', `/v1/builds/${build.id}/buildBetaDetail`); +if (!detail.ok) fail('Reading the build state', detail); +const external = detail.json.data.attributes.externalBuildState; +if (external === 'MISSING_EXPORT_COMPLIANCE') { + console.error('The build has no export compliance answer; set usesNonExemptEncryption in app.config.js.'); + process.exit(1); +} +if (external === 'READY_FOR_BETA_SUBMISSION') { + const submit = await api('POST', '/v1/betaAppReviewSubmissions', { + data: { type: 'betaAppReviewSubmissions', relationships: { build: { data: { type: 'builds', id: build.id } } } }, + }); + if (!submit.ok) fail('Submitting for beta review', submit); + console.log('Submitted for beta review.'); +} else { + console.log(`Not submitted again: the build is ${external}.`); +} + +const link = group.attributes.publicLinkEnabled ? group.attributes.publicLink : null; +console.log( + link + ? `Testers with ${link} get it once Apple approves the build.` + : `The "${groupName}" group has no public link enabled.`, +);