mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
ci: put every iOS release on the public TestFlight link
Uploads only reached internal testers. The public link's "external" group still served 0.1.66, because nothing added later builds to it or submitted them for Beta App Review. A new `public-testflight` job runs after the upload on a Linux runner. It waits for App Store Connect to process the build, adds it to the external group, sets "What to Test" to the GitHub release and submits it for review (`scripts/testflight-public.mjs`, safe to re-run). The README links the TestFlight beta and the latest APK.
This commit is contained in:
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <marketing version> <build number>
|
||||
//
|
||||
// Environment:
|
||||
// ASC_KEY_ID, ASC_ISSUER_ID App Store Connect API key
|
||||
// ASC_KEY_PATH the .p8 file (default ~/private_keys/AuthKey_<id>.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 <marketing version> <build number>');
|
||||
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.`,
|
||||
);
|
||||
Reference in New Issue
Block a user