Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -67,7 +92,14 @@ jobs:
<key>teamID</key>
<string>${APPLE_TEAM_ID}</string>
<key>signingStyle</key>
<string>automatic</string>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>${IOS_BUNDLE_ID}</key>
<string>${IOS_PROFILE_NAME}</string>
</dict>
<key>uploadSymbols</key>
<true/>
</dict>
Expand Down
48 changes: 48 additions & 0 deletions .github/workflows/ios-upload-existing-ipa.yml
Original file line number Diff line number Diff line change
@@ -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"
207 changes: 207 additions & 0 deletions iosApp/ci/prepare-signing.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading