From 7f6106f6d647c0dd9d47c48dd519ce32523e4e62 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 23 Aug 2026 14:26:02 +0100 Subject: [PATCH 1/2] ci: add internal TestFlight releases --- .../workflows/testflight-internal-release.yml | 284 ++++++++++++++++ README.md | 2 +- app.json | 4 + docs/app-store-connect.md | 71 ++++ docs/versioning.md | 20 +- scripts/app-store-connect.cjs | 318 ++++++++++++++++++ scripts/app-store-connect.test.js | 237 +++++++++++++ 7 files changed, 927 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/testflight-internal-release.yml create mode 100644 docs/app-store-connect.md create mode 100644 scripts/app-store-connect.cjs create mode 100644 scripts/app-store-connect.test.js diff --git a/.github/workflows/testflight-internal-release.yml b/.github/workflows/testflight-internal-release.yml new file mode 100644 index 0000000..07fc340 --- /dev/null +++ b/.github/workflows/testflight-internal-release.yml @@ -0,0 +1,284 @@ +name: TestFlight Internal Release + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published prerelease tag to upload + required: true + type: string + +permissions: + contents: read + +jobs: + publish-internal: + runs-on: macos-26 + timeout-minutes: 60 + env: + APP_BUNDLE_ID: com.enaboapps.switchify.remote + APPLE_TEAM_ID: 4N2QY254XW + ARCHIVE_PATH: ${{ runner.temp }}/SwitchifyRemote.xcarchive + EXPORT_PATH: ${{ runner.temp }}/testflight-export + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + steps: + - name: Check out reviewed release tooling + uses: actions/checkout@v6 + with: + ref: main + path: tooling + fetch-depth: 0 + + - name: Check out released application source + uses: actions/checkout@v6 + with: + ref: ${{ env.RELEASE_TAG }} + path: source + fetch-depth: 0 + + - name: Validate published prerelease + shell: bash + env: + GH_TOKEN: ${{ github.token }} + IS_PRERELEASE: "true" + working-directory: source + run: | + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" > "$RUNNER_TEMP/release.json" + jq -e --arg tag "$RELEASE_TAG" \ + '.tag_name == $tag and .draft == false and .prerelease == true' \ + "$RUNNER_TEMP/release.json" > /dev/null + node scripts/versioning.cjs release + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor HEAD origin/main + + - name: Read release metadata + id: release + shell: bash + working-directory: source + run: | + node -e ' + const fs = require("node:fs"); + const app = require("./app.json").expo; + const output = process.env.GITHUB_OUTPUT; + fs.appendFileSync(output, `version=${app.version}\nbuild=${app.ios.buildNumber}\n`); + ' + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: source/package-lock.json + + - name: Select Xcode 26.6 + run: sudo xcode-select --switch /Applications/Xcode_26.6.app + + - name: Install App Store Connect API key + id: credentials + shell: bash + env: + ASC_API_PRIVATE_KEY_BASE64: ${{ secrets.ASC_API_PRIVATE_KEY_BASE64 }} + run: | + set -euo pipefail + api_key_directory="$HOME/.appstoreconnect/private_keys" + mkdir -p "$api_key_directory" + chmod 700 "$api_key_directory" + api_key_path="$api_key_directory/AuthKey_${{ secrets.ASC_API_KEY_ID }}.p8" + printf '%s' "$ASC_API_PRIVATE_KEY_BASE64" | base64 --decode > "$api_key_path" + chmod 600 "$api_key_path" + printf 'api_key_path=%s\n' "$api_key_path" >> "$GITHUB_OUTPUT" + + - name: Check for an existing build + id: preflight + env: + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_PRIVATE_KEY_PATH: ${{ steps.credentials.outputs.api_key_path }} + run: >- + node tooling/scripts/app-store-connect.cjs preflight + --bundle-id "$APP_BUNDLE_ID" + --build-number "${{ steps.release.outputs.build }}" + + - name: Wait for an existing processing build + if: steps.preflight.outputs.decision == 'poll' + env: + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_PRIVATE_KEY_PATH: ${{ steps.credentials.outputs.api_key_path }} + run: >- + node tooling/scripts/app-store-connect.cjs wait + --bundle-id "$APP_BUNDLE_ID" + --build-number "${{ steps.release.outputs.build }}" + --timeout-seconds 2700 + + - name: Install application dependencies + if: steps.preflight.outputs.decision == 'upload' + working-directory: source + run: npm ci + + - name: Generate a clean iOS project + if: steps.preflight.outputs.decision == 'upload' + working-directory: source + run: npx expo prebuild --clean --platform ios --no-install + + - name: Install CocoaPods dependencies + if: steps.preflight.outputs.decision == 'upload' + working-directory: source/ios + run: pod install + + - name: Install distribution certificate and profile + if: steps.preflight.outputs.decision == 'upload' + id: signing + shell: bash + env: + IOS_DISTRIBUTION_CERTIFICATE_BASE64: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_BASE64 }} + IOS_DISTRIBUTION_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DISTRIBUTION_CERTIFICATE_PASSWORD }} + IOS_APP_STORE_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_APP_STORE_PROVISIONING_PROFILE_BASE64 }} + run: | + set -euo pipefail + signing_directory="$RUNNER_TEMP/testflight-signing" + keychain_path="$signing_directory/build.keychain-db" + p12_path="$signing_directory/distribution.p12" + source_profile="$signing_directory/profile.mobileprovision" + decoded_profile="$signing_directory/profile.plist" + mkdir -p "$signing_directory" "$HOME/Library/MobileDevice/Provisioning Profiles" + chmod 700 "$signing_directory" + printf '%s' "$IOS_DISTRIBUTION_CERTIFICATE_BASE64" | base64 --decode > "$p12_path" + printf '%s' "$IOS_APP_STORE_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$source_profile" + keychain_password="$(openssl rand -hex 32)" + 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 import "$p12_path" -P "$IOS_DISTRIBUTION_CERTIFICATE_PASSWORD" \ + -A -f pkcs12 -k "$keychain_path" + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$keychain_password" "$keychain_path" > /dev/null + security list-keychains -d user -s "$keychain_path" + security find-identity -v -p codesigning "$keychain_path" | grep -q 'Apple Distribution:' + security cms -D -i "$source_profile" > "$decoded_profile" + profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print UUID' "$decoded_profile")" + profile_name="$(/usr/libexec/PlistBuddy -c 'Print Name' "$decoded_profile")" + if [[ "$profile_name" != 'Switchify Remote App Store' ]]; then + echo 'The provisioning profile has the wrong name.' >&2 + exit 1 + fi + installed_profile="$HOME/Library/MobileDevice/Provisioning Profiles/$profile_uuid.mobileprovision" + cp "$source_profile" "$installed_profile" + printf 'keychain_path=%s\n' "$keychain_path" >> "$GITHUB_OUTPUT" + printf 'profile_path=%s\n' "$installed_profile" >> "$GITHUB_OUTPUT" + + - name: Archive signed iOS app + if: steps.preflight.outputs.decision == 'upload' + working-directory: source + run: | + xcodebuild \ + -workspace ios/SwitchifyRemote.xcworkspace \ + -scheme SwitchifyRemote \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath "$ARCHIVE_PATH" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY='Apple Distribution' \ + PROVISIONING_PROFILE_SPECIFIER='Switchify Remote App Store' \ + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption=NO \ + archive + + - name: Export App Store Connect IPA + if: steps.preflight.outputs.decision == 'upload' + shell: bash + run: | + export_options="$RUNNER_TEMP/ExportOptions.plist" + rm -f "$export_options" + /usr/libexec/PlistBuddy -c 'Add :method string app-store-connect' "$export_options" + /usr/libexec/PlistBuddy -c 'Add :destination string export' "$export_options" + /usr/libexec/PlistBuddy -c 'Add :signingStyle string manual' "$export_options" + /usr/libexec/PlistBuddy -c "Add :teamID string $APPLE_TEAM_ID" "$export_options" + /usr/libexec/PlistBuddy -c 'Add :stripSwiftSymbols bool true' "$export_options" + /usr/libexec/PlistBuddy -c 'Add :uploadSymbols bool true' "$export_options" + /usr/libexec/PlistBuddy -c 'Add :provisioningProfiles dict' "$export_options" + /usr/libexec/PlistBuddy \ + -c "Add :provisioningProfiles:$APP_BUNDLE_ID string Switchify Remote App Store" \ + "$export_options" + xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$export_options" + + - name: Verify IPA identity and provisioning + if: steps.preflight.outputs.decision == 'upload' + id: ipa + shell: bash + run: | + set -euo pipefail + ipa_path="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)" + if [[ -z "$ipa_path" ]]; then + echo 'The App Store Connect export did not produce an IPA.' >&2 + exit 1 + fi + app_directory="$RUNNER_TEMP/ipa-verification" + mkdir -p "$app_directory" + ditto -x -k "$ipa_path" "$app_directory" + app_path="$(find "$app_directory/Payload" -maxdepth 1 -name '*.app' -print -quit)" + codesign --verify --deep --strict "$app_path" + codesign -d --verbose=4 "$app_path" 2> "$RUNNER_TEMP/codesign.txt" + grep -q '^Authority=Apple Distribution:' "$RUNNER_TEMP/codesign.txt" + info_plist="$app_path/Info.plist" + test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$info_plist")" = "$APP_BUNDLE_ID" + test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$info_plist")" = "${{ steps.release.outputs.version }}" + test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleVersion' "$info_plist")" = "${{ steps.release.outputs.build }}" + test "$(/usr/libexec/PlistBuddy -c 'Print ITSAppUsesNonExemptEncryption' "$info_plist")" = 'false' + security cms -D -i "$app_path/embedded.mobileprovision" > "$RUNNER_TEMP/embedded-profile.plist" + test "$(/usr/libexec/PlistBuddy -c 'Print Name' "$RUNNER_TEMP/embedded-profile.plist")" = 'Switchify Remote App Store' + test "$(/usr/libexec/PlistBuddy -c 'Print Entitlements:application-identifier' "$RUNNER_TEMP/embedded-profile.plist")" = "$APPLE_TEAM_ID.$APP_BUNDLE_ID" + printf 'path=%s\n' "$ipa_path" >> "$GITHUB_OUTPUT" + + - name: Validate IPA with App Store Connect + if: steps.preflight.outputs.decision == 'upload' + run: >- + xcrun altool --validate-app + --file "${{ steps.ipa.outputs.path }}" + --type ios + --apiKey "${{ secrets.ASC_API_KEY_ID }}" + --apiIssuer "${{ secrets.ASC_API_ISSUER_ID }}" + + - name: Upload IPA to App Store Connect + if: steps.preflight.outputs.decision == 'upload' + run: >- + xcrun altool --upload-app + --file "${{ steps.ipa.outputs.path }}" + --type ios + --apiKey "${{ secrets.ASC_API_KEY_ID }}" + --apiIssuer "${{ secrets.ASC_API_ISSUER_ID }}" + + - name: Wait for App Store Connect validation + if: steps.preflight.outputs.decision == 'upload' + env: + ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_API_PRIVATE_KEY_PATH: ${{ steps.credentials.outputs.api_key_path }} + run: >- + node tooling/scripts/app-store-connect.cjs wait + --bundle-id "$APP_BUNDLE_ID" + --build-number "${{ steps.release.outputs.build }}" + --timeout-seconds 2700 + + - name: Upload IPA artifact + if: steps.preflight.outputs.decision == 'upload' + uses: actions/upload-artifact@v7 + with: + name: switchify-remote-testflight-${{ github.run_id }} + path: ${{ steps.ipa.outputs.path }} + if-no-files-found: error + + - name: Clean up signing material + if: always() + shell: bash + run: | + security delete-keychain "${{ steps.signing.outputs.keychain_path }}" 2>/dev/null || true + rm -f "${{ steps.signing.outputs.profile_path }}" + rm -f "${{ steps.credentials.outputs.api_key_path }}" + rm -rf "$RUNNER_TEMP/testflight-signing" "$RUNNER_TEMP/ipa-verification" "$EXPORT_PATH" "$ARCHIVE_PATH" + rm -f "$RUNNER_TEMP/ExportOptions.plist" "$RUNNER_TEMP/embedded-profile.plist" "$RUNNER_TEMP/codesign.txt" "$RUNNER_TEMP/release.json" diff --git a/README.md b/README.md index 58818a7..0b21fbc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ npm run validate Automated tests use fake transports and never require Bluetooth hardware or inject input. Pairing tokens and typed text must never appear in logs, diagnostics, fixtures, or bug reports. -See [app versioning](docs/versioning.md), [protocol compatibility](docs/protocol-compatibility.md), the [accessibility acceptance criteria](docs/accessibility.md), and the [physical-device smoke test](docs/physical-smoke-test.md) for preview validation. +See [app versioning](docs/versioning.md), [App Store Connect and TestFlight](docs/app-store-connect.md), [protocol compatibility](docs/protocol-compatibility.md), the [accessibility acceptance criteria](docs/accessibility.md), and the [physical-device smoke test](docs/physical-smoke-test.md) for release validation. ## Preview scope diff --git a/app.json b/app.json index 9683a02..55c5546 100644 --- a/app.json +++ b/app.json @@ -8,10 +8,14 @@ "scheme": "switchify-remote", "userInterfaceStyle": "automatic", "ios": { + "appleTeamId": "4N2QY254XW", "bundleIdentifier": "com.enaboapps.switchify.remote", "buildNumber": "13", "icon": "./assets/images/switchify-remote-ios-icon.png", "supportsTablet": true, + "config": { + "usesNonExemptEncryption": false + }, "infoPlist": { "NSBluetoothAlwaysUsageDescription": "Allow Switchify Remote to find and connect to Switchify PC." } diff --git a/docs/app-store-connect.md b/docs/app-store-connect.md new file mode 100644 index 0000000..45a7a18 --- /dev/null +++ b/docs/app-store-connect.md @@ -0,0 +1,71 @@ +# App Store Connect and internal TestFlight + +Switchify Remote uses App Store Connect only for internal TestFlight distribution in this milestone. External testers, beta review, public App Store metadata completion, pricing, screenshots, and App Review are separate work. + +## Apple records + +The Apple Developer and App Store Connect records must use these fixed values: + +| Record | Value | +| ---------------------- | ----------------------------------------- | +| Team | `4N2QY254XW` | +| Explicit App ID name | `Switchify Remote` | +| Bundle ID | `com.enaboapps.switchify.remote` | +| App Store Connect name | `Switchify Remote` | +| Platform | iOS, including iPad | +| Primary language | English (UK) | +| SKU | `switchify-remote-ios` | +| User access | Full Access | +| Privacy policy | `https://switchifyapp.com/privacy/remote` | +| Feedback email | `owen@switchifyapp.com` | +| Provisioning profile | `Switchify Remote App Store` | +| Internal group | `Switchify Internal` | + +The internal group automatically distributes compatible builds and initially contains only the current Account Holder. Do not add external testers or submit a build for beta or App Review as part of the internal release procedure. + +The app declares `ITSAppUsesNonExemptEncryption=false`. Switchify Remote uses HMAC authentication and system Bluetooth security, but does not implement non-exempt encryption. Reassess this declaration before adding cryptographic functionality beyond those existing uses. + +## GitHub credentials + +The `TestFlight Internal Release` workflow reuses the Developer-role `Switchify PC Notarization CI` App Store Connect team API key. Configure these repository secrets: + +- `ASC_API_KEY_ID` +- `ASC_API_ISSUER_ID` +- `ASC_API_PRIVATE_KEY_BASE64` +- `IOS_DISTRIBUTION_CERTIFICATE_BASE64` +- `IOS_DISTRIBUTION_CERTIFICATE_PASSWORD` +- `IOS_APP_STORE_PROVISIONING_PROFILE_BASE64` + +Base64 values must contain the raw P8, password-protected P12, and App Store provisioning profile respectively. Never commit credentials, copy them into workflow logs, attach them to issues, or upload them as ordinary artifacts. Keep the originals in one encrypted local backup with access limited to the Account Holder. + +The workflow creates a temporary keychain, installs the profile and API key only for the job, and removes the keychain, profile, P8, P12, extracted IPA, and staging directories in an unconditional cleanup step. + +## Credential renewal + +Apple Distribution certificates and App Store provisioning profiles expire. Before expiry: + +1. Create a replacement Apple Distribution certificate in Xcode and export it as a password-protected P12. +2. Regenerate `Switchify Remote App Store` for the explicit App ID and replacement certificate. +3. Replace the three distribution-certificate/profile GitHub secrets together. +4. Update the encrypted local backup. +5. Dispatch the workflow for a new prerelease build and confirm its signature and embedded profile before revoking the old certificate. + +The App Store Connect API key is reused while it remains active. If it is revoked, create a reviewed replacement with the minimum upload role and replace all three `ASC_API_*` secrets together. A P8 private key cannot be downloaded again. + +## Internal release procedure + +Follow [versioning](versioning.md) to prepare, review, merge, and publish a prerelease. Publication starts both the Play internal and TestFlight internal workflows. The TestFlight job: + +1. verifies that the tag is a published prerelease, matches `package.json`, points to a commit contained in `main`, and has a monotonic shared build ordinal; +2. checks App Store Connect for the build before doing signing work; +3. checks out the exact release tag as application source and current `main` as reviewed workflow tooling; +4. performs a clean Expo iOS prebuild, installs CocoaPods, archives with manual App Store signing, and exports an IPA; +5. verifies the distribution signature, profile, application identifier, bundle ID, marketing version, build number, and export-compliance flag; +6. validates and uploads with `xcrun altool`; and +7. polls the App Store Connect API until the build is `VALID`. + +An existing valid build is a successful no-op. An existing processing build is polled without uploading a duplicate. A `FAILED` or `INVALID` build must never be retried with the same ordinal; prepare a new prerelease build instead. + +The manual dispatch input accepts only an existing published prerelease tag. Use it for the `v1.0.0-beta.11` build 13 backfill or an idempotent retry, not to bypass the normal release review process. + +After processing, verify that the build appears in `Switchify Internal`, install it from TestFlight on the physical iPad, and run the Bluetooth pairing plus Mouse, Typing, Window, and quick-PC-switching checks from [the physical smoke test](physical-smoke-test.md). diff --git a/docs/versioning.md b/docs/versioning.md index b3294e5..d003745 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -1,14 +1,14 @@ # App versioning -Switchify Remote uses separate identifiers for the release, the user-visible app version, and the store build. Releases are prepared manually when a new Play internal or future TestFlight upload is needed; merging to `main` does not publish a build. +Switchify Remote uses separate identifiers for the release, the user-visible app version, and the store build. Releases are prepared manually when a new Play internal or TestFlight upload is needed; merging to `main` does not publish a build. ## Version fields -| Purpose | Source | Example | -| --- | --- | --- | -| Release identifier and Git tag | `package.json` `version` | `0.1.0-alpha.2` and `v0.1.0-alpha.2` | -| Android and iOS marketing version | `app.json` `expo.version` | `0.1.0` | -| Global store build | `android.versionCode` and `ios.buildNumber` | `2` and `"2"` | +| Purpose | Source | Example | +| --------------------------------- | ------------------------------------------- | ------------------------------------ | +| Release identifier and Git tag | `package.json` `version` | `0.1.0-alpha.2` and `v0.1.0-alpha.2` | +| Android and iOS marketing version | `app.json` `expo.version` | `0.1.0` | +| Global store build | `android.versionCode` and `ios.buildNumber` | `2` and `"2"` | The package version uses Semantic Versioning with an optional `alpha.N`, `beta.N`, or `rc.N` suffix. The Expo version is always the numeric `MAJOR.MINOR.PATCH` core so it is valid as both Android `versionName` and iOS `CFBundleShortVersionString`. @@ -27,6 +27,10 @@ npm run validate The bump command requires a release identifier greater than the current one, derives the numeric marketing version, increments the shared build ordinal by exactly one, and updates `package.json`, `package-lock.json`, and `app.json`. Review all three changes in the release PR. -After the release PR is reviewed, green, approved, and merged, publish a GitHub prerelease from the merge commit with a tag exactly matching `v` plus the package version. The Play internal workflow rejects a mismatched tag, a stable release, a commit outside `main`, or a build ordinal that is not greater than every earlier reachable release tag. +After the release PR is reviewed, green, approved, and merged, publish a GitHub prerelease from the merge commit with a tag exactly matching `v` plus the package version. The Play internal and TestFlight internal workflows reject a mismatched tag, a stable release, a commit outside `main`, or a build ordinal that is not greater than every earlier reachable release tag. -The current workflow supports prereleases on the Play internal track only. Do not publish a stable or production release until a separately reviewed production workflow exists. +Publishing the prerelease starts both internal-store workflows. Android is delivered to the Play internal track, and iOS is delivered to the automatically distributed `Switchify Internal` TestFlight group. If one store rejects an upload, increment the shared build ordinal and publish a new prerelease; never reuse the rejected build number on either platform. + +The TestFlight workflow can also be dispatched manually with an existing published prerelease tag. This exists for the reviewed Beta 11 backfill and safe idempotent retries: a valid existing build succeeds, a processing build is polled, and an invalid build requires a new ordinal. The workflow always uses release source from the exact tag and release tooling from current `main`. + +See [App Store Connect and TestFlight](app-store-connect.md) for signing credentials, renewal, and internal testing. Do not publish a stable or production release until a separately reviewed production workflow exists. diff --git a/scripts/app-store-connect.cjs b/scripts/app-store-connect.cjs new file mode 100644 index 0000000..b3492ba --- /dev/null +++ b/scripts/app-store-connect.cjs @@ -0,0 +1,318 @@ +const crypto = require("node:crypto"); +const { Buffer } = require("node:buffer"); +const fs = require("node:fs"); +const https = require("node:https"); + +const API_HOST = "api.appstoreconnect.apple.com"; +const API_AUDIENCE = "appstoreconnect-v1"; +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; +const DEFAULT_INTERVAL_MS = 30 * 1000; +const REQUEST_TIMEOUT_MS = 30 * 1000; +const TERMINAL_FAILURE_STATES = new Set(["FAILED", "INVALID"]); + +class AppStoreConnectError extends Error { + constructor(message, options = {}) { + super(message, options); + this.name = "AppStoreConnectError"; + } +} + +function base64Url(value) { + return Buffer.from(value).toString("base64url"); +} + +function createJwt({ + issuerId, + keyId, + privateKey, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + if (!issuerId || !keyId || !privateKey) { + throw new AppStoreConnectError( + "App Store Connect API credentials are incomplete.", + ); + } + + const header = base64Url( + JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }), + ); + const payload = base64Url( + JSON.stringify({ + iss: issuerId, + iat: nowSeconds, + exp: nowSeconds + 20 * 60, + aud: API_AUDIENCE, + }), + ); + const signingInput = `${header}.${payload}`; + + let signature; + try { + signature = crypto.sign("sha256", Buffer.from(signingInput), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }); + } catch (error) { + throw new AppStoreConnectError( + "Unable to sign the App Store Connect API request.", + { + cause: error, + }, + ); + } + + return `${signingInput}.${base64Url(signature)}`; +} + +function sanitizedRequestError(statusCode, requestId) { + const status = Number.isInteger(statusCode) + ? `HTTP ${statusCode}` + : "a network error"; + const suffix = requestId ? ` (request ${requestId})` : ""; + return new AppStoreConnectError( + `App Store Connect request failed with ${status}${suffix}.`, + ); +} + +function requestJson({ token, requestPath, request = https.request }) { + return new Promise((resolve, reject) => { + const req = request( + { + hostname: API_HOST, + method: "GET", + path: requestPath, + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const requestId = response.headers["x-request-id"]; + if ( + !response.statusCode || + response.statusCode < 200 || + response.statusCode >= 300 + ) { + reject(sanitizedRequestError(response.statusCode, requestId)); + return; + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch (error) { + reject( + new AppStoreConnectError( + "App Store Connect returned an unreadable response.", + { + cause: error, + }, + ), + ); + } + }); + }, + ); + req.on("error", () => reject(sanitizedRequestError())); + if (typeof req.setTimeout === "function") { + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(); + reject(sanitizedRequestError()); + }); + } + req.end(); + }); +} + +function createClient({ issuerId, keyId, privateKey, request, nowSeconds }) { + return { + async get(requestPath) { + const token = createJwt({ issuerId, keyId, privateKey, nowSeconds }); + return requestJson({ token, requestPath, request }); + }, + }; +} + +async function findApp(client, bundleId) { + const query = new URLSearchParams({ + "filter[bundleId]": bundleId, + limit: "2", + }); + const response = await client.get(`/v1/apps?${query}`); + if (!Array.isArray(response.data)) { + throw new AppStoreConnectError( + "App Store Connect returned an invalid app response.", + ); + } + if (response.data.length === 0) { + throw new AppStoreConnectError( + `No App Store Connect app exists for ${bundleId}.`, + ); + } + if (response.data.length > 1 || typeof response.data[0]?.id !== "string") { + throw new AppStoreConnectError( + `App Store Connect returned an ambiguous app for ${bundleId}.`, + ); + } + return response.data[0]; +} + +async function findBuild(client, appId, buildNumber) { + const query = new URLSearchParams({ + "filter[app]": appId, + "filter[version]": String(buildNumber), + sort: "-uploadedDate", + limit: "2", + }); + const response = await client.get(`/v1/builds?${query}`); + if (!Array.isArray(response.data)) { + throw new AppStoreConnectError( + "App Store Connect returned an invalid build response.", + ); + } + if (response.data.length === 0) return null; + if (response.data.length > 1) { + throw new AppStoreConnectError( + `Multiple App Store Connect builds use build ${buildNumber}.`, + ); + } + const build = response.data[0]; + if ( + typeof build?.id !== "string" || + typeof build.attributes?.processingState !== "string" + ) { + throw new AppStoreConnectError( + "App Store Connect returned an invalid build record.", + ); + } + return build; +} + +function classifyBuild(build) { + if (build === null) return { decision: "upload", buildId: null }; + const state = build.attributes.processingState.toUpperCase(); + if (state === "VALID") return { decision: "complete", buildId: build.id }; + if (state === "PROCESSING") return { decision: "poll", buildId: build.id }; + if (TERMINAL_FAILURE_STATES.has(state)) { + throw new AppStoreConnectError( + `App Store Connect build ${build.id} finished in the ${state} state; use a new build ordinal.`, + ); + } + throw new AppStoreConnectError( + `App Store Connect build ${build.id} has an unsupported processing state.`, + ); +} + +async function lookupBuild(client, bundleId, buildNumber) { + const app = await findApp(client, bundleId); + const build = await findBuild(client, app.id, buildNumber); + return { appId: app.id, ...classifyBuild(build) }; +} + +async function waitForBuild({ + lookup, + timeoutMs = DEFAULT_TIMEOUT_MS, + intervalMs = DEFAULT_INTERVAL_MS, + now = Date.now, + sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)), +}) { + const deadline = now() + timeoutMs; + for (;;) { + const result = await lookup(); + if (result.decision === "complete") return result; + if (now() >= deadline) { + throw new AppStoreConnectError( + "Timed out waiting for App Store Connect to validate the build.", + ); + } + await sleep(intervalMs); + } +} + +function parseArguments(argumentsList) { + const options = {}; + for (let index = 0; index < argumentsList.length; index += 2) { + const name = argumentsList[index]; + const value = argumentsList[index + 1]; + if (!name?.startsWith("--") || value === undefined) { + throw new AppStoreConnectError( + "App Store Connect helper arguments are invalid.", + ); + } + options[name.slice(2)] = value; + } + return options; +} + +function writeGithubOutput(result) { + if (!process.env.GITHUB_OUTPUT) return; + const lines = [`decision=${result.decision}`, `app_id=${result.appId}`]; + if (result.buildId) lines.push(`build_id=${result.buildId}`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`, "utf8"); +} + +async function runCli() { + const [command, ...rawOptions] = process.argv.slice(2); + if (!["preflight", "wait"].includes(command)) { + throw new AppStoreConnectError( + "Usage: node scripts/app-store-connect.cjs --bundle-id --build-number ", + ); + } + const options = parseArguments(rawOptions); + if ( + !options["bundle-id"] || + !/^[1-9]\d*$/.test(options["build-number"] ?? "") + ) { + throw new AppStoreConnectError( + "A bundle ID and positive build number are required.", + ); + } + const keyPath = process.env.ASC_API_PRIVATE_KEY_PATH; + if (!keyPath) + throw new AppStoreConnectError("ASC_API_PRIVATE_KEY_PATH is required."); + const privateKey = fs.readFileSync(keyPath, "utf8"); + const client = createClient({ + issuerId: process.env.ASC_API_ISSUER_ID, + keyId: process.env.ASC_API_KEY_ID, + privateKey, + }); + const lookup = () => + lookupBuild(client, options["bundle-id"], options["build-number"]); + const result = + command === "wait" + ? await waitForBuild({ + lookup, + timeoutMs: + Number(options["timeout-seconds"] ?? DEFAULT_TIMEOUT_MS / 1000) * + 1000, + }) + : await lookup(); + writeGithubOutput(result); + process.stdout.write( + `${result.decision === "complete" ? "Build is valid" : `Build action: ${result.decision}`} (${result.appId}).\n`, + ); +} + +if (require.main === module) { + runCli().catch((error) => { + const message = + error instanceof AppStoreConnectError + ? error.message + : "Unexpected helper failure."; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + AppStoreConnectError, + classifyBuild, + createClient, + createJwt, + findApp, + findBuild, + lookupBuild, + requestJson, + waitForBuild, +}; diff --git a/scripts/app-store-connect.test.js b/scripts/app-store-connect.test.js new file mode 100644 index 0000000..52ee46e --- /dev/null +++ b/scripts/app-store-connect.test.js @@ -0,0 +1,237 @@ +const crypto = require("node:crypto"); +const { Buffer } = require("node:buffer"); +const { EventEmitter } = require("node:events"); +const { describe, expect, test } = require("@jest/globals"); + +const { + classifyBuild, + createJwt, + findApp, + findBuild, + lookupBuild, + requestJson, + waitForBuild, +} = require("./app-store-connect.cjs"); + +function decodePart(value) { + return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); +} + +function build(state, id = "build-13") { + return { id, attributes: { processingState: state } }; +} + +describe("App Store Connect JWT", () => { + test("uses the required ES256 header and bounded claims", () => { + const { privateKey, publicKey } = crypto.generateKeyPairSync("ec", { + namedCurve: "prime256v1", + }); + const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }); + const token = createJwt({ + issuerId: "issuer-id", + keyId: "KEY123", + privateKey: privateKeyPem, + nowSeconds: 1_800_000_000, + }); + const [header, payload, signature] = token.split("."); + + expect(decodePart(header)).toEqual({ + alg: "ES256", + kid: "KEY123", + typ: "JWT", + }); + expect(decodePart(payload)).toEqual({ + iss: "issuer-id", + iat: 1_800_000_000, + exp: 1_800_001_200, + aud: "appstoreconnect-v1", + }); + expect( + crypto.verify( + "sha256", + Buffer.from(`${header}.${payload}`), + { + key: publicKey, + dsaEncoding: "ieee-p1363", + }, + Buffer.from(signature, "base64url"), + ), + ).toBe(true); + }); + + test("does not expose key material when signing fails", () => { + expect(() => + createJwt({ + issuerId: "issuer-id", + keyId: "KEY123", + privateKey: "private-secret-that-must-not-leak", + }), + ).toThrow("Unable to sign"); + try { + createJwt({ + issuerId: "issuer-id", + keyId: "KEY123", + privateKey: "private-secret", + }); + } catch (error) { + expect(error.message).not.toContain("private-secret"); + } + }); +}); + +describe("App Store Connect lookup", () => { + test("finds the app and build with encoded filters", async () => { + const requests = []; + const client = { + get: async (requestPath) => { + requests.push(requestPath); + if (requestPath.startsWith("/v1/apps?")) + return { data: [{ id: "app-1" }] }; + return { data: [build("VALID")] }; + }, + }; + + await expect( + lookupBuild(client, "com.enaboapps.switchify.remote", 13), + ).resolves.toEqual({ + appId: "app-1", + buildId: "build-13", + decision: "complete", + }); + expect(requests[0]).toContain( + "filter%5BbundleId%5D=com.enaboapps.switchify.remote", + ); + expect(requests[1]).toContain("filter%5Bversion%5D=13"); + }); + + test("reports an absent build as ready to upload", async () => { + const responses = [{ data: [{ id: "app-1" }] }, { data: [] }]; + const client = { get: async () => responses.shift() }; + await expect( + lookupBuild(client, "com.enaboapps.switchify.remote", 13), + ).resolves.toEqual({ + appId: "app-1", + buildId: null, + decision: "upload", + }); + }); + + test("reports a processing build for polling", () => { + expect(classifyBuild(build("PROCESSING"))).toEqual({ + buildId: "build-13", + decision: "poll", + }); + }); + + test("treats an existing valid build as an idempotent success", () => { + expect(classifyBuild(build("VALID"))).toEqual({ + buildId: "build-13", + decision: "complete", + }); + }); + + test.each(["FAILED", "INVALID"])("rejects the terminal %s state", (state) => { + expect(() => classifyBuild(build(state))).toThrow( + "use a new build ordinal", + ); + }); + + test("rejects missing and ambiguous apps", async () => { + await expect( + findApp({ get: async () => ({ data: [] }) }, "missing.app"), + ).rejects.toThrow("No App Store Connect app"); + await expect( + findApp( + { get: async () => ({ data: [{ id: "1" }, { id: "2" }] }) }, + "duplicate.app", + ), + ).rejects.toThrow("ambiguous app"); + }); + + test("rejects duplicate build ordinals", async () => { + await expect( + findBuild( + { + get: async () => ({ + data: [build("VALID", "one"), build("VALID", "two")], + }), + }, + "app-1", + 13, + ), + ).rejects.toThrow("Multiple App Store Connect builds"); + }); +}); + +describe("App Store Connect polling", () => { + test("waits through absence and processing until the build is valid", async () => { + const results = [ + { decision: "upload", appId: "app-1", buildId: null }, + { decision: "poll", appId: "app-1", buildId: "build-13" }, + { decision: "complete", appId: "app-1", buildId: "build-13" }, + ]; + let now = 0; + const result = await waitForBuild({ + lookup: async () => results.shift(), + intervalMs: 100, + timeoutMs: 1_000, + now: () => now, + sleep: async (duration) => { + now += duration; + }, + }); + expect(result.decision).toBe("complete"); + }); + + test("fails after the configured timeout", async () => { + let now = 0; + await expect( + waitForBuild({ + lookup: async () => ({ + decision: "poll", + appId: "app-1", + buildId: "build-13", + }), + intervalMs: 100, + timeoutMs: 200, + now: () => now, + sleep: async (duration) => { + now += duration; + }, + }), + ).rejects.toThrow("Timed out"); + }); +}); + +describe("sanitized request failures", () => { + test("reports only status and request ID", async () => { + const request = (_options, callback) => { + const response = new EventEmitter(); + response.statusCode = 401; + response.headers = { "x-request-id": "safe-request-id" }; + const req = new EventEmitter(); + req.end = () => { + callback(response); + response.emit("data", Buffer.from('{"detail":"server-secret"}')); + response.emit("end"); + }; + return req; + }; + + let message = ""; + try { + await requestJson({ + token: "jwt-secret", + requestPath: "/v1/apps", + request, + }); + } catch (error) { + message = error.message; + } + expect(message).toBe( + "App Store Connect request failed with HTTP 401 (request safe-request-id).", + ); + expect(message).not.toContain("server-secret"); + expect(message).not.toContain("jwt-secret"); + }); +}); From ce4296debf84f70978e33358b6e2aaebf5d4cdc6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 23 Aug 2026 14:33:12 +0100 Subject: [PATCH 2/2] fix(ci): harden TestFlight release checks --- .../workflows/testflight-internal-release.yml | 7 +++ scripts/app-store-connect.cjs | 53 ++++++++++++++--- scripts/app-store-connect.test.js | 57 ++++++++++++++++--- 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/.github/workflows/testflight-internal-release.yml b/.github/workflows/testflight-internal-release.yml index 07fc340..ca666de 100644 --- a/.github/workflows/testflight-internal-release.yml +++ b/.github/workflows/testflight-internal-release.yml @@ -13,6 +13,10 @@ on: permissions: contents: read +concurrency: + group: testflight-internal-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + jobs: publish-internal: runs-on: macos-26 @@ -100,6 +104,7 @@ jobs: node tooling/scripts/app-store-connect.cjs preflight --bundle-id "$APP_BUNDLE_ID" --build-number "${{ steps.release.outputs.build }}" + --marketing-version "${{ steps.release.outputs.version }}" - name: Wait for an existing processing build if: steps.preflight.outputs.decision == 'poll' @@ -111,6 +116,7 @@ jobs: node tooling/scripts/app-store-connect.cjs wait --bundle-id "$APP_BUNDLE_ID" --build-number "${{ steps.release.outputs.build }}" + --marketing-version "${{ steps.release.outputs.version }}" --timeout-seconds 2700 - name: Install application dependencies @@ -263,6 +269,7 @@ jobs: node tooling/scripts/app-store-connect.cjs wait --bundle-id "$APP_BUNDLE_ID" --build-number "${{ steps.release.outputs.build }}" + --marketing-version "${{ steps.release.outputs.version }}" --timeout-seconds 2700 - name: Upload IPA artifact diff --git a/scripts/app-store-connect.cjs b/scripts/app-store-connect.cjs index b3492ba..a25d45f 100644 --- a/scripts/app-store-connect.cjs +++ b/scripts/app-store-connect.cjs @@ -162,6 +162,7 @@ async function findBuild(client, appId, buildNumber) { const query = new URLSearchParams({ "filter[app]": appId, "filter[version]": String(buildNumber), + include: "preReleaseVersion", sort: "-uploadedDate", limit: "2", }); @@ -186,11 +187,33 @@ async function findBuild(client, appId, buildNumber) { "App Store Connect returned an invalid build record.", ); } - return build; + const preReleaseVersionId = build.relationships?.preReleaseVersion?.data?.id; + const preReleaseVersion = response.included?.find( + (record) => + record?.type === "preReleaseVersions" && + record.id === preReleaseVersionId, + ); + if ( + typeof preReleaseVersionId !== "string" || + typeof preReleaseVersion?.attributes?.version !== "string" + ) { + throw new AppStoreConnectError( + "App Store Connect returned a build without a readable marketing version.", + ); + } + return { + ...build, + marketingVersion: preReleaseVersion.attributes.version, + }; } -function classifyBuild(build) { +function classifyBuild(build, expectedMarketingVersion) { if (build === null) return { decision: "upload", buildId: null }; + if (build.marketingVersion !== expectedMarketingVersion) { + throw new AppStoreConnectError( + `App Store Connect build ${build.id} belongs to a different marketing version; use a new build ordinal.`, + ); + } const state = build.attributes.processingState.toUpperCase(); if (state === "VALID") return { decision: "complete", buildId: build.id }; if (state === "PROCESSING") return { decision: "poll", buildId: build.id }; @@ -204,10 +227,18 @@ function classifyBuild(build) { ); } -async function lookupBuild(client, bundleId, buildNumber) { +async function lookupBuild( + client, + bundleId, + buildNumber, + expectedMarketingVersion, +) { const app = await findApp(client, bundleId); const build = await findBuild(client, app.id, buildNumber); - return { appId: app.id, ...classifyBuild(build) }; + return { + appId: app.id, + ...classifyBuild(build, expectedMarketingVersion), + }; } async function waitForBuild({ @@ -256,16 +287,17 @@ async function runCli() { const [command, ...rawOptions] = process.argv.slice(2); if (!["preflight", "wait"].includes(command)) { throw new AppStoreConnectError( - "Usage: node scripts/app-store-connect.cjs --bundle-id --build-number ", + "Usage: node scripts/app-store-connect.cjs --bundle-id --build-number --marketing-version ", ); } const options = parseArguments(rawOptions); if ( !options["bundle-id"] || - !/^[1-9]\d*$/.test(options["build-number"] ?? "") + !/^[1-9]\d*$/.test(options["build-number"] ?? "") || + !/^\d+\.\d+\.\d+$/.test(options["marketing-version"] ?? "") ) { throw new AppStoreConnectError( - "A bundle ID and positive build number are required.", + "A bundle ID, positive build number, and semantic marketing version are required.", ); } const keyPath = process.env.ASC_API_PRIVATE_KEY_PATH; @@ -278,7 +310,12 @@ async function runCli() { privateKey, }); const lookup = () => - lookupBuild(client, options["bundle-id"], options["build-number"]); + lookupBuild( + client, + options["bundle-id"], + options["build-number"], + options["marketing-version"], + ); const result = command === "wait" ? await waitForBuild({ diff --git a/scripts/app-store-connect.test.js b/scripts/app-store-connect.test.js index 52ee46e..d707990 100644 --- a/scripts/app-store-connect.test.js +++ b/scripts/app-store-connect.test.js @@ -17,8 +17,30 @@ function decodePart(value) { return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); } -function build(state, id = "build-13") { - return { id, attributes: { processingState: state } }; +function build(state, id = "build-13", marketingVersion = "1.0.0") { + return { + id, + attributes: { processingState: state }, + relationships: { + preReleaseVersion: { data: { id: `version-${marketingVersion}` } }, + }, + marketingVersion, + }; +} + +function buildResponse(state, id = "build-13", marketingVersion = "1.0.0") { + const record = build(state, id, marketingVersion); + delete record.marketingVersion; + return { + data: [record], + included: [ + { + type: "preReleaseVersions", + id: `version-${marketingVersion}`, + attributes: { version: marketingVersion }, + }, + ], + }; } describe("App Store Connect JWT", () => { @@ -87,12 +109,12 @@ describe("App Store Connect lookup", () => { requests.push(requestPath); if (requestPath.startsWith("/v1/apps?")) return { data: [{ id: "app-1" }] }; - return { data: [build("VALID")] }; + return buildResponse("VALID"); }, }; await expect( - lookupBuild(client, "com.enaboapps.switchify.remote", 13), + lookupBuild(client, "com.enaboapps.switchify.remote", 13, "1.0.0"), ).resolves.toEqual({ appId: "app-1", buildId: "build-13", @@ -102,13 +124,14 @@ describe("App Store Connect lookup", () => { "filter%5BbundleId%5D=com.enaboapps.switchify.remote", ); expect(requests[1]).toContain("filter%5Bversion%5D=13"); + expect(requests[1]).toContain("include=preReleaseVersion"); }); test("reports an absent build as ready to upload", async () => { const responses = [{ data: [{ id: "app-1" }] }, { data: [] }]; const client = { get: async () => responses.shift() }; await expect( - lookupBuild(client, "com.enaboapps.switchify.remote", 13), + lookupBuild(client, "com.enaboapps.switchify.remote", 13, "1.0.0"), ).resolves.toEqual({ appId: "app-1", buildId: null, @@ -117,25 +140,31 @@ describe("App Store Connect lookup", () => { }); test("reports a processing build for polling", () => { - expect(classifyBuild(build("PROCESSING"))).toEqual({ + expect(classifyBuild(build("PROCESSING"), "1.0.0")).toEqual({ buildId: "build-13", decision: "poll", }); }); test("treats an existing valid build as an idempotent success", () => { - expect(classifyBuild(build("VALID"))).toEqual({ + expect(classifyBuild(build("VALID"), "1.0.0")).toEqual({ buildId: "build-13", decision: "complete", }); }); test.each(["FAILED", "INVALID"])("rejects the terminal %s state", (state) => { - expect(() => classifyBuild(build(state))).toThrow( + expect(() => classifyBuild(build(state), "1.0.0")).toThrow( "use a new build ordinal", ); }); + test("rejects an existing build from a different marketing version", () => { + expect(() => + classifyBuild(build("VALID", "build-13", "0.9.0"), "1.0.0"), + ).toThrow("different marketing version"); + }); + test("rejects missing and ambiguous apps", async () => { await expect( findApp({ get: async () => ({ data: [] }) }, "missing.app"), @@ -161,6 +190,18 @@ describe("App Store Connect lookup", () => { ), ).rejects.toThrow("Multiple App Store Connect builds"); }); + + test("rejects a build without an included marketing version", async () => { + const record = build("VALID"); + delete record.marketingVersion; + await expect( + findBuild( + { get: async () => ({ data: [record], included: [] }) }, + "app-1", + 13, + ), + ).rejects.toThrow("without a readable marketing version"); + }); }); describe("App Store Connect polling", () => {