Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
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
291 changes: 291 additions & 0 deletions .github/workflows/testflight-internal-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
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

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
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 }}"
--marketing-version "${{ steps.release.outputs.version }}"

- 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 }}"
--marketing-version "${{ steps.release.outputs.version }}"
--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 }}"
--marketing-version "${{ steps.release.outputs.version }}"
--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"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
71 changes: 71 additions & 0 deletions docs/app-store-connect.md
Original file line number Diff line number Diff line change
@@ -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).
Loading