diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml
index 45b81acdf..3a0c348c4 100644
--- a/.github/workflows/ios-testflight.yml
+++ b/.github/workflows/ios-testflight.yml
@@ -25,16 +25,41 @@ jobs:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
run: |
mkdir -p "$RUNNER_TEMP/private_keys"
+ mkdir -p "$HOME/.appstoreconnect/private_keys"
printf '%s' "$APP_STORE_CONNECT_PRIVATE_KEY" > "$RUNNER_TEMP/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
+ cp "$RUNNER_TEMP/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" "$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
- name: Generate Xcode project
run: xcodegen generate --spec iosApp/project.yml --project iosApp
- - name: Archive
+ - name: Prepare signing assets
env:
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ IOS_BUNDLE_ID: ${{ secrets.IOS_BUNDLE_ID }}
+ ASC_KEY_PATH: ${{ runner.temp }}/private_keys/AuthKey_${{ secrets.APP_STORE_CONNECT_KEY_ID }}.p8
+ run: node iosApp/ci/prepare-signing.mjs
+
+ - name: Install signing assets
+ env:
+ KEYCHAIN_PASSWORD: ${{ github.run_id }}-arvio-ios
+ run: |
+ KEYCHAIN_PATH="$RUNNER_TEMP/arvio-signing.keychain-db"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security default-keychain -s "$KEYCHAIN_PATH"
+ security import "$IOS_CERT_P12_PATH" -k "$KEYCHAIN_PATH" -P "$IOS_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcodebuild
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
+ cp "$IOS_PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
+ security find-identity -v -p codesigning "$KEYCHAIN_PATH"
+ echo "IOS_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
+
+ - name: Archive
+ env:
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
xcodebuild \
-project iosApp/ARVIO.xcodeproj \
@@ -44,11 +69,11 @@ jobs:
-archivePath "$RUNNER_TEMP/ARVIO.xcarchive" \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \
- CODE_SIGN_STYLE=Automatic \
- -allowProvisioningUpdates \
- -authenticationKeyPath "$RUNNER_TEMP/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" \
- -authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \
- -authenticationKeyIssuerID "$APP_STORE_CONNECT_ISSUER_ID" \
+ CODE_SIGN_STYLE=Manual \
+ CODE_SIGN_IDENTITY="Apple Distribution" \
+ PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \
+ PROVISIONING_PROFILE="$IOS_PROFILE_UUID" \
+ OTHER_CODE_SIGN_FLAGS="--keychain $IOS_KEYCHAIN_PATH" \
clean archive
- name: Export IPA
@@ -67,7 +92,14 @@ jobs:
teamID
${APPLE_TEAM_ID}
signingStyle
- automatic
+ manual
+ signingCertificate
+ Apple Distribution
+ provisioningProfiles
+
+ ${IOS_BUNDLE_ID}
+ ${IOS_PROFILE_NAME}
+
uploadSymbols
diff --git a/.github/workflows/ios-upload-existing-ipa.yml b/.github/workflows/ios-upload-existing-ipa.yml
new file mode 100644
index 000000000..414a55a2a
--- /dev/null
+++ b/.github/workflows/ios-upload-existing-ipa.yml
@@ -0,0 +1,48 @@
+name: iOS Upload Existing IPA
+
+on:
+ workflow_dispatch:
+ inputs:
+ ipa_run_id:
+ description: GitHub Actions run ID containing the ARVIO-iOS-IPA artifact
+ required: true
+ type: string
+
+jobs:
+ upload:
+ name: Upload Existing IPA to TestFlight
+ runs-on: macos-15
+ timeout-minutes: 30
+
+ steps:
+ - name: Write App Store Connect key
+ env:
+ APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }}
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ run: |
+ mkdir -p "$HOME/.appstoreconnect/private_keys"
+ printf '%s' "$APP_STORE_CONNECT_PRIVATE_KEY" > "$HOME/.appstoreconnect/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8"
+
+ - name: Download IPA artifact
+ env:
+ GH_TOKEN: ${{ github.token }}
+ IPA_RUN_ID: ${{ inputs.ipa_run_id }}
+ run: |
+ mkdir -p "$RUNNER_TEMP/export"
+ gh run download "$IPA_RUN_ID" \
+ --repo "$GITHUB_REPOSITORY" \
+ --name ARVIO-iOS-IPA \
+ --dir "$RUNNER_TEMP/export"
+ ls -la "$RUNNER_TEMP/export"
+
+ - name: Upload to TestFlight
+ env:
+ APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
+ APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
+ run: |
+ xcrun altool \
+ --upload-app \
+ --type ios \
+ --file "$RUNNER_TEMP/export/ARVIO.ipa" \
+ --apiKey "$APP_STORE_CONNECT_KEY_ID" \
+ --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
diff --git a/iosApp/ci/prepare-signing.mjs b/iosApp/ci/prepare-signing.mjs
new file mode 100644
index 000000000..db01383d6
--- /dev/null
+++ b/iosApp/ci/prepare-signing.mjs
@@ -0,0 +1,207 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import https from "node:https";
+import os from "node:os";
+import path from "node:path";
+import { spawnSync } from "node:child_process";
+
+const requiredEnv = [
+ "APP_STORE_CONNECT_KEY_ID",
+ "APP_STORE_CONNECT_ISSUER_ID",
+ "APPLE_TEAM_ID",
+ "IOS_BUNDLE_ID",
+ "ASC_KEY_PATH",
+ "RUNNER_TEMP",
+ "GITHUB_ENV",
+ "GITHUB_RUN_ID",
+];
+
+for (const name of requiredEnv) {
+ if (!process.env[name]) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+}
+
+const keyId = process.env.APP_STORE_CONNECT_KEY_ID;
+const issuerId = process.env.APP_STORE_CONNECT_ISSUER_ID;
+const teamId = process.env.APPLE_TEAM_ID;
+const bundleIdentifier = process.env.IOS_BUNDLE_ID;
+const ascKeyPath = process.env.ASC_KEY_PATH;
+const runnerTemp = process.env.RUNNER_TEMP;
+const githubEnv = process.env.GITHUB_ENV;
+const runId = process.env.GITHUB_RUN_ID;
+const apiBase = "https://api.appstoreconnect.apple.com/v1";
+
+function base64Url(input) {
+ return Buffer.from(input)
+ .toString("base64")
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+}
+
+function createJwt() {
+ const now = Math.floor(Date.now() / 1000);
+ const header = base64Url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
+ const payload = base64Url(JSON.stringify({
+ iss: issuerId,
+ iat: now,
+ exp: now + 1200,
+ aud: "appstoreconnect-v1",
+ }));
+ const unsignedToken = `${header}.${payload}`;
+ const signature = crypto.sign("sha256", Buffer.from(unsignedToken), {
+ key: fs.readFileSync(ascKeyPath, "utf8"),
+ dsaEncoding: "ieee-p1363",
+ });
+ return `${unsignedToken}.${base64Url(signature)}`;
+}
+
+function apiRequest(method, endpoint, body = undefined) {
+ const url = new URL(`${apiBase}${endpoint}`);
+ const payload = body ? JSON.stringify(body) : undefined;
+ const token = createJwt();
+
+ return new Promise((resolve, reject) => {
+ const request = https.request(url, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json",
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
+ },
+ }, (response) => {
+ let data = "";
+ response.setEncoding("utf8");
+ response.on("data", (chunk) => { data += chunk; });
+ response.on("end", () => {
+ const parsed = data ? JSON.parse(data) : {};
+ if (response.statusCode >= 200 && response.statusCode < 300) {
+ resolve(parsed);
+ return;
+ }
+ reject(new Error(`${method} ${endpoint} failed with ${response.statusCode}: ${data}`));
+ });
+ });
+
+ request.on("error", reject);
+ if (payload) request.write(payload);
+ request.end();
+ });
+}
+
+function run(command, args) {
+ const result = spawnSync(command, args, { stdio: "inherit" });
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status}`);
+ }
+}
+
+async function findOrCreateBundleId() {
+ const query = `?filter%5Bidentifier%5D=${encodeURIComponent(bundleIdentifier)}&filter%5Bplatform%5D=IOS`;
+ const existing = await apiRequest("GET", `/bundleIds${query}`);
+ if (existing.data?.length) {
+ return existing.data[0];
+ }
+
+ return (await apiRequest("POST", "/bundleIds", {
+ data: {
+ type: "bundleIds",
+ attributes: {
+ identifier: bundleIdentifier,
+ name: "ARVIO iOS",
+ platform: "IOS",
+ },
+ },
+ })).data;
+}
+
+async function main() {
+ const signingDir = path.join(runnerTemp, "ios-signing");
+ fs.mkdirSync(signingDir, { recursive: true });
+
+ const privateKeyPath = path.join(signingDir, "ios_distribution.key");
+ const csrPath = path.join(signingDir, "ios_distribution.csr");
+ const certificatePath = path.join(signingDir, "ios_distribution.cer");
+ const certificatePemPath = path.join(signingDir, "ios_distribution.pem");
+ const p12Path = path.join(signingDir, "ios_distribution.p12");
+ const profilePath = path.join(signingDir, "ARVIO_App_Store.mobileprovision");
+ const p12Password = crypto.randomBytes(18).toString("base64url");
+ const profileName = `ARVIO CI App Store ${runId}`;
+
+ run("openssl", ["genrsa", "-out", privateKeyPath, "2048"]);
+ run("openssl", [
+ "req",
+ "-new",
+ "-key",
+ privateKeyPath,
+ "-out",
+ csrPath,
+ "-subj",
+ `/CN=ARVIO CI/OU=${teamId}/O=ARVIO/C=US`,
+ ]);
+
+ const certificate = (await apiRequest("POST", "/certificates", {
+ data: {
+ type: "certificates",
+ attributes: {
+ certificateType: "IOS_DISTRIBUTION",
+ csrContent: fs.readFileSync(csrPath, "utf8"),
+ },
+ },
+ })).data;
+
+ fs.writeFileSync(certificatePath, Buffer.from(certificate.attributes.certificateContent, "base64"));
+ run("openssl", ["x509", "-inform", "DER", "-in", certificatePath, "-out", certificatePemPath]);
+ run("openssl", [
+ "pkcs12",
+ "-export",
+ "-inkey",
+ privateKeyPath,
+ "-in",
+ certificatePemPath,
+ "-out",
+ p12Path,
+ "-password",
+ `pass:${p12Password}`,
+ ]);
+
+ const bundleId = await findOrCreateBundleId();
+ const profile = (await apiRequest("POST", "/profiles", {
+ data: {
+ type: "profiles",
+ attributes: {
+ name: profileName,
+ profileType: "IOS_APP_STORE",
+ },
+ relationships: {
+ bundleId: {
+ data: { type: "bundleIds", id: bundleId.id },
+ },
+ certificates: {
+ data: [{ type: "certificates", id: certificate.id }],
+ },
+ },
+ },
+ })).data;
+
+ fs.writeFileSync(profilePath, Buffer.from(profile.attributes.profileContent, "base64"));
+ fs.appendFileSync(githubEnv, [
+ `IOS_CERT_P12_PATH=${p12Path}`,
+ `IOS_CERT_PASSWORD=${p12Password}`,
+ `IOS_PROFILE_PATH=${profilePath}`,
+ `IOS_PROFILE_UUID=${profile.attributes.uuid}`,
+ `IOS_PROFILE_NAME=${profileName}`,
+ `IOS_BUNDLE_ID=${bundleIdentifier}`,
+ `IOS_CREATED_CERTIFICATE_ID=${certificate.id}`,
+ `IOS_CREATED_PROFILE_ID=${profile.id}`,
+ "",
+ ].join(os.EOL));
+
+ console.log(`Prepared App Store signing profile ${profile.attributes.uuid} for ${bundleIdentifier}.`);
+}
+
+main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+});