From 7c357050477f58ca91084ac48bac3763eef37fa1 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sat, 16 May 2026 21:30:45 +0200 Subject: [PATCH 1/5] Force iOS distribution signing --- .github/workflows/ios-testflight.yml | 1 + iosApp/project.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 45b81acdf..921239cb9 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -45,6 +45,7 @@ jobs: DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \ CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_IDENTITY="Apple Distribution" \ -allowProvisioningUpdates \ -authenticationKeyPath "$RUNNER_TEMP/private_keys/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" \ -authenticationKeyID "$APP_STORE_CONNECT_KEY_ID" \ diff --git a/iosApp/project.yml b/iosApp/project.yml index d889e0825..65d13da9b 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -9,6 +9,7 @@ settings: IPHONEOS_DEPLOYMENT_TARGET: "17.0" DEVELOPMENT_TEAM: YSKCRJKAF3 CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: Apple Distribution TARGETED_DEVICE_FAMILY: "1,2" targets: ARVIO: @@ -22,6 +23,7 @@ targets: PRODUCT_NAME: ARVIO INFOPLIST_FILE: ARVIO/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + CODE_SIGN_IDENTITY: Apple Distribution MARKETING_VERSION: 0.1.0 CURRENT_PROJECT_VERSION: 1 GENERATE_INFOPLIST_FILE: NO From 19c087900e4efd4f0b28929f4f4b8e2dfbaba000 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sat, 16 May 2026 21:36:33 +0200 Subject: [PATCH 2/5] Use manual iOS signing assets --- .github/workflows/ios-testflight.yml | 43 +++++- iosApp/ci/prepare-signing.mjs | 205 +++++++++++++++++++++++++++ iosApp/project.yml | 2 - 3 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 iosApp/ci/prepare-signing.mjs diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 921239cb9..eeb86d783 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -30,11 +30,34 @@ jobs: - 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,12 +67,11 @@ jobs: -archivePath "$RUNNER_TEMP/ARVIO.xcarchive" \ DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" \ - CODE_SIGN_STYLE=Automatic \ + CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Apple Distribution" \ - -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" \ + PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \ + PROVISIONING_PROFILE="$IOS_PROFILE_UUID" \ + OTHER_CODE_SIGN_FLAGS="--keychain $IOS_KEYCHAIN_PATH" \ clean archive - name: Export IPA @@ -68,7 +90,14 @@ jobs: teamID ${APPLE_TEAM_ID} signingStyle - automatic + manual + signingCertificate + Apple Distribution + provisioningProfiles + + ${IOS_BUNDLE_ID} + ${IOS_PROFILE_NAME} + uploadSymbols diff --git a/iosApp/ci/prepare-signing.mjs b/iosApp/ci/prepare-signing.mjs new file mode 100644 index 000000000..163eeee8f --- /dev/null +++ b/iosApp/ci/prepare-signing.mjs @@ -0,0 +1,205 @@ +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 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", [ + "pkcs12", + "-export", + "-inkey", + privateKeyPath, + "-in", + certificatePath, + "-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); +}); diff --git a/iosApp/project.yml b/iosApp/project.yml index 65d13da9b..d889e0825 100644 --- a/iosApp/project.yml +++ b/iosApp/project.yml @@ -9,7 +9,6 @@ settings: IPHONEOS_DEPLOYMENT_TARGET: "17.0" DEVELOPMENT_TEAM: YSKCRJKAF3 CODE_SIGN_STYLE: Automatic - CODE_SIGN_IDENTITY: Apple Distribution TARGETED_DEVICE_FAMILY: "1,2" targets: ARVIO: @@ -23,7 +22,6 @@ targets: PRODUCT_NAME: ARVIO INFOPLIST_FILE: ARVIO/Info.plist ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - CODE_SIGN_IDENTITY: Apple Distribution MARKETING_VERSION: 0.1.0 CURRENT_PROJECT_VERSION: 1 GENERATE_INFOPLIST_FILE: NO From ca44b9bd5ca93003cb4df762bd4afcf088cc0e32 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sat, 16 May 2026 21:37:34 +0200 Subject: [PATCH 3/5] Convert Apple certificate before packaging --- iosApp/ci/prepare-signing.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/iosApp/ci/prepare-signing.mjs b/iosApp/ci/prepare-signing.mjs index 163eeee8f..db01383d6 100644 --- a/iosApp/ci/prepare-signing.mjs +++ b/iosApp/ci/prepare-signing.mjs @@ -123,6 +123,7 @@ async function main() { 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"); @@ -151,13 +152,14 @@ async function main() { })).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", - certificatePath, + certificatePemPath, "-out", p12Path, "-password", From bce888312dc65309c19fb53d7272bd655a2b0080 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sat, 16 May 2026 21:38:55 +0200 Subject: [PATCH 4/5] Place App Store key for altool --- .github/workflows/ios-testflight.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index eeb86d783..3a0c348c4 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -25,7 +25,9 @@ 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 From 3c881a949d606600e1b6a3ed505bca2ea9b405e0 Mon Sep 17 00:00:00 2001 From: Arvin Date: Sat, 16 May 2026 21:39:56 +0200 Subject: [PATCH 5/5] Add iOS IPA upload workflow --- .github/workflows/ios-upload-existing-ipa.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ios-upload-existing-ipa.yml 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"