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
63 changes: 49 additions & 14 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ on:
description: "Unique increasing build number, never reuse after upload"
required: true
type: string
distribution_method:
description: "Create an App Store Connect or registered-device Ad Hoc package"
default: app-store-connect
type: choice
options:
- app-store-connect
- ad-hoc
upload_testflight:
description: "Upload to App Store Connect (does not submit review or invite testers)"
default: false
Expand All @@ -32,6 +39,7 @@ jobs:
env:
IOS_VERSION: ${{ inputs.version }}
IOS_BUILD_NUMBER: ${{ inputs.build_number }}
IOS_EXPORT_METHOD: ${{ inputs.distribution_method }}
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -41,7 +49,8 @@ jobs:
env:
HAS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE != '' }}
HAS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD != '' }}
HAS_PROFILE: ${{ secrets.IOS_MOBILE_PROVISION != '' }}
HAS_APP_STORE_PROFILE: ${{ secrets.IOS_MOBILE_PROVISION != '' }}
HAS_AD_HOC_PROFILE: ${{ secrets.IOS_ADHOC_MOBILE_PROVISION != '' }}
HAS_TEAM: ${{ secrets.APPLE_TEAM_ID != '' }}
HAS_API_KEY: ${{ secrets.ASC_PRIVATE_KEY != '' }}
HAS_API_KEY_ID: ${{ secrets.ASC_KEY_ID != '' }}
Expand All @@ -50,12 +59,25 @@ jobs:
APPROVED_SHA: ${{ vars.IOS_RELEASE_APPROVED_SHA }}
run: |
set -euo pipefail
for name in HAS_CERTIFICATE HAS_CERTIFICATE_PASSWORD HAS_PROFILE HAS_TEAM; do
for name in HAS_CERTIFICATE HAS_CERTIFICATE_PASSWORD HAS_TEAM; do
if [[ "${!name}" != true ]]; then
echo "::error::Missing iOS signing configuration: $name (see docs/releases/ios-launch-readiness.md)"
exit 1
fi
done
case "$IOS_EXPORT_METHOD" in
app-store-connect)
[[ "$HAS_APP_STORE_PROFILE" == true ]] || { echo '::error::Missing App Store Connect provisioning profile'; exit 1; }
;;
ad-hoc)
[[ "$HAS_AD_HOC_PROFILE" == true ]] || { echo '::error::Missing Ad Hoc provisioning profile'; exit 1; }
[[ "$UPLOAD" != true ]] || { echo '::error::Ad Hoc packages cannot be uploaded to TestFlight'; exit 1; }
;;
*)
echo "::error::Unsupported distribution method: $IOS_EXPORT_METHOD"
exit 1
;;
esac
if [[ "$UPLOAD" == true ]]; then
for name in HAS_API_KEY HAS_API_KEY_ID HAS_ISSUER; do
[[ "${!name}" == true ]] || { echo "::error::Missing upload configuration: $name"; exit 1; }
Expand Down Expand Up @@ -124,16 +146,16 @@ jobs:
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_MOBILE_PROVISION: ${{ secrets.IOS_MOBILE_PROVISION }}
IOS_MOBILE_PROVISION: ${{ inputs.distribution_method == 'ad-hoc' && secrets.IOS_ADHOC_MOBILE_PROVISION || secrets.IOS_MOBILE_PROVISION }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set +x
set -euo pipefail
umask 077

certificate_path="$RUNNER_TEMP/ios-distribution.p12"
profile_path="$RUNNER_TEMP/ios-app-store.mobileprovision"
profile_plist="$RUNNER_TEMP/ios-app-store.plist"
profile_path="$RUNNER_TEMP/ios-distribution.mobileprovision"
profile_plist="$RUNNER_TEMP/ios-distribution.plist"
keychain_path="$RUNNER_TEMP/ios-signing.keychain-db"
keychain_password="$(uuidgen)$(uuidgen)"
echo "::add-mask::$keychain_password"
Expand Down Expand Up @@ -219,12 +241,24 @@ jobs:
raise SystemExit("Provisioning profile application prefix mismatch")
if profile_bundle_id != bundle_id:
raise SystemExit("Provisioning profile bundle ID mismatch")
if profile.get("ProvisionedDevices") is not None or profile.get("ProvisionsAllDevices") is True:
raise SystemExit("Expected an App Store Connect provisioning profile")
if entitlements.get("get-task-allow") is not False:
raise SystemExit("Provisioning profile allows debugging")
if entitlements.get("beta-reports-active") is not True:
raise SystemExit("Provisioning profile is not for App Store distribution")
method = os.environ["IOS_EXPORT_METHOD"]
if profile.get("ProvisionsAllDevices") is True:
raise SystemExit("Enterprise provisioning profiles are not supported")
if method == "app-store-connect":
if profile.get("ProvisionedDevices") is not None:
raise SystemExit("Expected an App Store Connect provisioning profile")
if entitlements.get("beta-reports-active") is not True:
raise SystemExit("Provisioning profile is not for App Store distribution")
elif method == "ad-hoc":
devices = profile.get("ProvisionedDevices")
if not isinstance(devices, list) or not devices:
raise SystemExit("Ad Hoc provisioning profile has no registered devices")
if entitlements.get("beta-reports-active") is True:
raise SystemExit("Expected an Ad Hoc provisioning profile")
else:
raise SystemExit(f"Unsupported distribution method: {method}")
expiration = profile.get("ExpirationDate")
if not isinstance(expiration, datetime):
raise SystemExit("Provisioning profile expiration is missing")
Expand Down Expand Up @@ -256,6 +290,7 @@ jobs:
IOS_SIGNING_XCCONFIG="$signing_xcconfig" \
IOS_SIGNING_IDENTITY_FILE="$RUNNER_TEMP/ios-signing-identity" \
IOS_PROFILE_UUID_FILE="$RUNNER_TEMP/ios-profile-uuid" \
IOS_EXPORT_METHOD="$IOS_EXPORT_METHOD" \
node scripts/ios-release/manual-signing.mjs
plutil -lint "$export_options"

Expand All @@ -274,7 +309,7 @@ jobs:
run: |
set -euo pipefail
security unlock-keychain -p "$IOS_KEYCHAIN_PASSWORD" "$IOS_KEYCHAIN_PATH"
pnpm tauri:ios:build --ci --export-method app-store-connect --config "$RUNNER_TEMP/ios-release-config.json"
pnpm tauri:ios:build --ci --export-method "$IOS_EXPORT_METHOD" --config "$RUNNER_TEMP/ios-release-config.json"

- name: Validate the exported IPA, not just source configuration
env:
Expand All @@ -287,12 +322,12 @@ jobs:
ditto -x -k "$ipa" "$inspection"
node scripts/ios-release/readiness.mjs app "$inspection/Payload/ORG2 Remote.app"
shasum -a 256 "$ipa" >> "$GITHUB_STEP_SUMMARY"
printf 'Source: %s\nVersion: %s (%s)\n' "$GITHUB_SHA" "$IOS_VERSION" "$IOS_BUILD_NUMBER" >> "$GITHUB_STEP_SUMMARY"
printf 'Source: %s\nVersion: %s (%s)\nDistribution: %s\n' "$GITHUB_SHA" "$IOS_VERSION" "$IOS_BUILD_NUMBER" "$IOS_EXPORT_METHOD" >> "$GITHUB_STEP_SUMMARY"

- name: Retain only the signed candidate
uses: actions/upload-artifact@v4
with:
name: ios-release-candidate-${{ inputs.version }}-${{ inputs.build_number }}
name: ios-${{ inputs.distribution_method }}-${{ inputs.version }}-${{ inputs.build_number }}
path: apps/remote-ios/src-tauri/gen/apple/build/arm64/ORG2 Remote.ipa
if-no-files-found: error
retention-days: 7
Expand Down Expand Up @@ -347,8 +382,8 @@ jobs:
[[ -z "${IOS_EXPORT_OPTIONS_FILE:-}" || ! -f "$RUNNER_TEMP/ios-export-options.plist.backup" ]] || cp "$RUNNER_TEMP/ios-export-options.plist.backup" "$IOS_EXPORT_OPTIONS_FILE"
rm -f \
"$RUNNER_TEMP/ios-distribution.p12" \
"$RUNNER_TEMP/ios-app-store.mobileprovision" \
"$RUNNER_TEMP/ios-app-store.plist" \
"$RUNNER_TEMP/ios-distribution.mobileprovision" \
"$RUNNER_TEMP/ios-distribution.plist" \
"$RUNNER_TEMP/ios-profile-uuid" \
"$RUNNER_TEMP/ios-signing-identity" \
"$RUNNER_TEMP/ios-signing.xcconfig" \
Expand Down
10 changes: 9 additions & 1 deletion scripts/ios-release/manual-signing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { pathToFileURL } from "node:url";
import { resolve } from "node:path";

export const iosBundleId = "org2ai.org2.remote";
export const defaultExportMethod = "app-store-connect";
const supportedExportMethods = new Set([defaultExportMethod, "ad-hoc"]);

function xmlEscape(value) {
return value
Expand Down Expand Up @@ -89,11 +91,16 @@ export function signingXcconfig(inputs) {
export function exportOptionsPlist(inputs) {
validateInputs(inputs);
const identity = xmlEscape(inputs.identity);
const exportMethod = inputs.exportMethod ?? defaultExportMethod;
assert.ok(
supportedExportMethods.has(exportMethod),
"Unsupported iOS export method",
);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>method</key><string>${exportMethod}</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>${identity}</string>
<key>teamID</key><string>${inputs.team}</string>
Expand Down Expand Up @@ -133,6 +140,7 @@ if (
team: process.env.APPLE_TEAM_ID,
identity: readFileSync(process.env.IOS_SIGNING_IDENTITY_FILE, "utf8").trim(),
profileUuid: readFileSync(process.env.IOS_PROFILE_UUID_FILE, "utf8").trim(),
exportMethod: process.env.IOS_EXPORT_METHOD ?? defaultExportMethod,
});
} catch (error) {
console.error(`Failed to prepare explicit iOS signing: ${error.message}`);
Expand Down
14 changes: 14 additions & 0 deletions scripts/ios-release/manual-signing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ test("writes matching manual archive and export settings", () => {
assert.match(exportOptions, /12345678-1234-1234-1234-1234567890ab/);
});

test("writes Ad Hoc export options for registered-device distribution", () => {
const exportOptions = exportOptionsPlist({
...inputs,
exportMethod: "ad-hoc",
});
assert.match(exportOptions, /<key>method<\/key><string>ad-hoc<\/string>/);
});

test("rejects unsupported export methods", () => {
assert.throws(() =>
exportOptionsPlist({ ...inputs, exportMethod: "enterprise" }),
);
});

test("rejects identities from another team", () => {
assert.throws(() =>
signingXcconfig({
Expand Down
41 changes: 31 additions & 10 deletions scripts/ios-release/readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { pathToFileURL } from "node:url";

export const bundleId = "org2ai.org2.remote";
export const minimumIosSdkMajor = 26;
export const appStoreExportMethod = "app-store-connect";
export const adHocExportMethod = "ad-hoc";

export function releaseConfig(version, build, team) {
assert.match(
Expand Down Expand Up @@ -37,6 +39,11 @@ export function validateDistribution(
now = Date.now(),
) {
const { version, build, team } = expected;
const exportMethod = expected.exportMethod ?? appStoreExportMethod;
assert.ok(
[appStoreExportMethod, adHocExportMethod].includes(exportMethod),
"Unsupported iOS export method",
);
releaseConfig(version, build, team);
assert.equal(info.CFBundleIdentifier, bundleId, "Wrong application bundle");
assert.equal(
Expand Down Expand Up @@ -83,11 +90,6 @@ export function validateDistribution(
Date.parse(profile.ExpirationDate) > now,
"Provisioning profile expired or invalid",
);
assert.equal(
profile.ProvisionedDevices,
undefined,
"Ad hoc/development profiles cannot be used for App Store upload",
);
assert.notEqual(
profile.ProvisionsAllDevices,
true,
Expand All @@ -98,11 +100,29 @@ export function validateDistribution(
false,
"Profile must disable debugging",
);
assert.equal(
profile.Entitlements?.["beta-reports-active"],
true,
"Expected App Store distribution profile",
);
if (exportMethod === appStoreExportMethod) {
assert.equal(
profile.ProvisionedDevices,
undefined,
"Ad hoc/development profiles cannot be used for App Store upload",
);
assert.equal(
profile.Entitlements?.["beta-reports-active"],
true,
"Expected App Store distribution profile",
);
} else {
assert.ok(
Array.isArray(profile.ProvisionedDevices) &&
profile.ProvisionedDevices.length > 0,
"Ad Hoc profile must include registered devices",
);
assert.notEqual(
profile.Entitlements?.["beta-reports-active"],
true,
"Expected Ad Hoc distribution profile",
);
}
assert.equal(
entitlements["get-task-allow"],
false,
Expand Down Expand Up @@ -251,6 +271,7 @@ if (
version: process.env.IOS_VERSION,
build: process.env.IOS_BUILD_NUMBER,
team: process.env.APPLE_TEAM_ID,
exportMethod: process.env.IOS_EXPORT_METHOD ?? appStoreExportMethod,
};
if (process.argv[2] === "config") {
console.log(
Expand Down
17 changes: 17 additions & 0 deletions scripts/ios-release/readiness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ test("accepts matching distribution metadata, including legacy App ID prefixes",
validateDistribution(f.info, f.profile, f.entitlements, f.expected);
});

test("accepts an Ad Hoc distribution profile with registered devices", () => {
const f = fixture();
f.expected.exportMethod = "ad-hoc";
f.profile.ProvisionedDevices = ["registered-device"];
delete f.profile.Entitlements["beta-reports-active"];
validateDistribution(f.info, f.profile, f.entitlements, f.expected);
});

test("rejects an Ad Hoc profile without registered devices", () => {
const f = fixture();
f.expected.exportMethod = "ad-hoc";
delete f.profile.Entitlements["beta-reports-active"];
assert.throws(() =>
validateDistribution(f.info, f.profile, f.entitlements, f.expected),
);
});

test("accepts a minimal privacy manifest without invalid empty declarations", () => {
validatePrivacyManifest({ NSPrivacyTracking: false });
});
Expand Down
Loading