diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 000000000..2c9a1bf82 --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,258 @@ +name: TestFlight + +# Never add pull_request or push triggers: this job holds an Admin App Store Connect key that can +# sign and upload for the whole team. Pass secrets only through step-level env, never print the +# decoded key, and never upload artifacts, caches or logs. CONTRIBUTING.md > Distribution explains. +on: + workflow_dispatch: + inputs: + app: + description: App to archive and upload + type: choice + options: + - house-scan + - measure-lab + default: house-scan + +permissions: + contents: read + +concurrency: + group: testflight + cancel-in-progress: false + +jobs: + upload: + name: Upload ${{ inputs.app }} to TestFlight + runs-on: macos-26 + timeout-minutes: 45 + environment: testflight + env: + # Same toolchain as .github/workflows/ios.yml; change both together. + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + XCODEGEN_VERSION: 2.46.0 + XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 + APP: ${{ inputs.app }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + BUNDLE_ID_PREFIX: ${{ vars.BUNDLE_ID_PREFIX }} + # Includes the attempt so a rerun does not reuse a build number App Store Connect accepted. + BUILD_NUMBER: ${{ github.run_number }}.${{ github.run_attempt }} + steps: + - name: Check secrets and variables + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + missing=0 + for name in ASC_KEY_ID ASC_ISSUER_ID ASC_KEY_P8; do + if [ -z "${!name}" ]; then + echo "::error::Secret $name is empty. Add it to the 'testflight' environment (Settings > Environments). See CONTRIBUTING.md > Distribution." + missing=1 + fi + done + for name in APPLE_TEAM_ID BUNDLE_ID_PREFIX; do + if [ -z "${!name}" ]; then + echo "::error::Variable $name is empty. Add it to the 'testflight' environment (Settings > Environments). See CONTRIBUTING.md > Distribution." + missing=1 + fi + done + [ "$missing" -eq 0 ] || exit 1 + if ! [[ "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "::error::APPLE_TEAM_ID must be the 10-character Team ID from developer.apple.com > Membership details." + exit 1 + fi + if ! [[ "$BUNDLE_ID_PREFIX" =~ ^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$ ]]; then + echo "::error::BUNDLE_ID_PREFIX must be reverse-DNS, such as com.example.team." + exit 1 + fi + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select project + run: | + case "$APP" in + house-scan) dir=ios; name=HouseScan; packages="HouseScanKit" ;; + measure-lab) dir=experiments/measure-lab; name=MeasureLab; packages="Geometry" ;; + *) echo "::error::Unknown app '$APP'."; exit 1 ;; + esac + if [ ! -f "$dir/project.yml" ]; then + echo "::error::$dir/project.yml is not on $GITHUB_REF_NAME. Run the workflow from a branch that contains $APP." + exit 1 + fi + { + echo "PROJECT_DIR=$dir" + echo "PROJECT_NAME=$name" + echo "ALLOWED_LOCAL_PACKAGES=$packages" + } >> "$GITHUB_ENV" + + - name: Show toolchain + run: | + if [ ! -d "$DEVELOPER_DIR" ]; then + echo "::error::$DEVELOPER_DIR is not on this runner image. Pick an installed Xcode from the image readme." + exit 1 + fi + xcodebuild -version + + - name: Install XcodeGen + run: | + curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \ + "https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip" + echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c - + unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" + + # Build what project.yml generates, so the guard below checks the project that is built. + - name: Generate project + run: xcodegen generate --spec "$PROJECT_DIR/project.yml" + + # Repository code must not run while the key is on disk. Script phases, build rules, scheme + # actions, package plugins, macros and compiler plugins all run during xcodebuild. An allowed + # local package's manifest is still evaluated, so its contents are checked too. + - name: Check build inputs + run: | + spec="$PROJECT_DIR/project.yml" + pbxproj="$PROJECT_DIR/$PROJECT_NAME.xcodeproj/project.pbxproj" + failed=0 + refuse() { + echo "::error file=$1::$2 The TestFlight job refuses anything that runs repository code while the signing key exists. See CONTRIBUTING.md > Distribution." + failed=1 + } + refuse_match() { # ; an unreadable file also fails + local rc=0 + grep -nE -- "$2" "$1" > "$RUNNER_TEMP/guard-hits" || rc=$? + case "$rc" in + 0) refuse "$1" "$3 found at line(s) $(cut -d: -f1 "$RUNNER_TEMP/guard-hits" | paste -sd, -)." ;; + 1) ;; + *) refuse "$1" "Could not read this file to check it." ;; + esac + } + refuse_match "$spec" '^[[:space:]]*(preBuildScripts|postCompileScripts|postBuildScripts|buildToolPlugins|buildRules|preActions|postActions|projectReferences):' "Build script, build rule, scheme action, plugin or project reference" + refuse_match "$pbxproj" 'PBXShellScriptBuildPhase|PBXBuildRule|XCRemoteSwiftPackageReference|wrapper\.pb-project|productName = "?plugin:' "Shell script phase, build rule, remote package, plugin or nested project" + if [ -z "$ALLOWED_LOCAL_PACKAGES" ]; then + refuse_match "$spec" '^packages:' "Swift package section" + refuse_match "$pbxproj" 'XCLocalSwiftPackageReference|XCSwiftPackageProductDependency' "Swift package dependency" + else + declared="$(grep -c 'isa = XCLocalSwiftPackageReference;' "$pbxproj" || true)" + refs="$(sed -nE 's/^[[:space:]]*relativePath = "?([^";]*)"?;/\1/p' "$pbxproj")" + if [ "$declared" -ne "$(printf '%s' "$refs" | grep -c . || true)" ]; then + refuse "$pbxproj" "Could not match every local package reference to a relativePath." + fi + for ref in $refs; do + case " $ALLOWED_LOCAL_PACKAGES " in + *" $ref "*) ;; + *) refuse "$pbxproj" "Local package '$ref' is not in ALLOWED_LOCAL_PACKAGES." ;; + esac + done + for pkg in $ALLOWED_LOCAL_PACKAGES; do + refuse_match "$PROJECT_DIR/$pkg/Package.swift" '\.package\(|\.plugin\(|plugins:|\.macro\(|CompilerPluginSupport|\.binaryTarget\(|unsafeFlags' "Package dependency, plugin, macro, binary target or unsafe flag in $pkg/Package.swift" + done + fi + while IFS= read -r file; do + refuse_match "$file" 'load-plugin|plugin-path' "Swift compiler plugin flag" + done < <(find "$PROJECT_DIR" -name '*.xcconfig' -o -name project.yml -o -name project.pbxproj) + while IFS= read -r scheme; do + refuse_match "$scheme" 'ExecutionAction' "Scheme pre- or post-action" + done < <(find "$PROJECT_DIR/$PROJECT_NAME.xcodeproj" -name '*.xcscheme') + while IFS= read -r resolved; do + refuse "$resolved" "Package.resolved means the project pins external packages." + done < <(find "$PROJECT_DIR" -name Package.resolved) + rm -f "$RUNNER_TEMP/guard-hits" + [ "$failed" -eq 0 ] || exit 1 + echo "No build scripts, build rules, scheme actions, plugins, macros or external packages in $PROJECT_DIR." + + - name: Archive + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + # The key exists only in this step. GitHub masks the base64 secret but not the decoded + # file, so nothing may print it. + umask 077 + keydir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" + # INT and TERM exit through the EXIT trap, so a cancelled step deletes the key and fails. + trap 'rm -rf "$keydir"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + key="$keydir/AuthKey_${ASC_KEY_ID}.p8" + if ! printf '%s' "$ASC_KEY_P8" | base64 --decode > "$key" 2>/dev/null \ + || ! grep -q -- '-----BEGIN PRIVATE KEY-----' "$key"; then + echo "::error::ASC_KEY_P8 is not a base64-encoded .p8 file. See CONTRIBUTING.md > Distribution." + exit 1 + fi + umask 022 + xcodebuild archive \ + -project "$PROJECT_DIR/$PROJECT_NAME.xcodeproj" \ + -scheme "$PROJECT_NAME" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "$RUNNER_TEMP/$PROJECT_NAME.xcarchive" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$key" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + BUNDLE_ID_PREFIX="$BUNDLE_ID_PREFIX" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" + + # destination=upload makes -exportArchive send the signed build to App Store Connect instead + # of writing an .ipa. manageAppVersionAndBuildNumber=false keeps the run number as the build. + - name: Export and upload + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + plist="$RUNNER_TEMP/ExportOptions.plist" + cat > "$plist" < + + + + method + app-store-connect + destination + upload + signingStyle + automatic + teamID + ${APPLE_TEAM_ID} + manageAppVersionAndBuildNumber + + uploadSymbols + + + + EOF + # The key exists only in this step. GitHub masks the base64 secret but not the decoded + # file, so nothing may print it. + umask 077 + keydir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" + # INT and TERM exit through the EXIT trap, so a cancelled step deletes the key and fails. + trap 'rm -rf "$keydir"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + key="$keydir/AuthKey_${ASC_KEY_ID}.p8" + if ! printf '%s' "$ASC_KEY_P8" | base64 --decode > "$key" 2>/dev/null \ + || ! grep -q -- '-----BEGIN PRIVATE KEY-----' "$key"; then + echo "::error::ASC_KEY_P8 is not a base64-encoded .p8 file. See CONTRIBUTING.md > Distribution." + exit 1 + fi + umask 022 + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/$PROJECT_NAME.xcarchive" \ + -exportOptionsPlist "$plist" \ + -exportPath "$RUNNER_TEMP/export" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$key" \ + -authenticationKeyID "$ASC_KEY_ID" \ + -authenticationKeyIssuerID "$ASC_ISSUER_ID" + echo "Uploaded $PROJECT_NAME build $BUILD_NUMBER. It appears in TestFlight after App Store Connect finishes processing." >> "$GITHUB_STEP_SUMMARY" + + # Fallback for a key step that died before its trap ran. + - name: Remove App Store Connect key + if: always() + run: rm -rf -- "$RUNNER_TEMP"/asc-key.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2e1ceb9..d9fd21641 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,64 @@ The `size:*` label describes the effective diff. It is a review signal, not a me | iOS build | `ios/` changes | `make ios` | | Sync label definitions | `.github/labels.json` changes on `main` | none | | Label PR size | a pull request opens or updates | none | +| TestFlight | someone runs it from the Actions tab | none | `make check` runs the three local suites. They need uv, Node 24 with pnpm, and Xcode 26 or newer; each directory's README has details. No check is required by branch rules yet. Don't call one required until the rules require its status. Keep workflows that run pull-request code away from production credentials and destructive external systems. A green CI run is evidence for the checks it ran, not proof that a capture works on a real house. + +## Distribution + +`.github/workflows/testflight.yml` archives one app, signs it and uploads it to TestFlight. It runs only when started by hand from the Actions tab. The build number is `.`, such as `12.1`. + +| App | Project | Bundle id | +| --- | --- | --- | +| House Scan (`house-scan`) | `ios/` | `.housescan` | +| Measure Lab (`measure-lab`) | `experiments/measure-lab/` | `.measurelab` | + +### Before the first upload + +App Store Connect rejects a build without an app icon. House Scan needs an `AppIcon` set with a 1024×1024 image in an asset catalog inside `ios/HouseScan/`, added by the lane A owner. Measure Lab has one. + +Neither `Info.plist` sets `ITSAppUsesNonExemptEncryption`, so each build waits under "Missing Compliance" until someone answers the encryption question on its TestFlight page. Setting the key to `false` removes that step for an app that uses only HTTPS and Apple's system encryption. + +### One-time setup + +1. **Create an App Store Connect API key.** In [App Store Connect](https://appstoreconnect.apple.com) > Users and Access > Integrations > App Store Connect API, create a team key with the **Admin** role. The export signs with a cloud-managed distribution certificate, which non-Admin keys cannot use. Download the `.p8` file (Apple offers it once) and note the Key ID and Issuer ID. + + The `.p8` and its base64 text are equally sensitive: either lets anyone sign and upload as the team. Never paste either into a chat, issue, pull request, screenshot, log or commit. If either was copied, clear the clipboard (`pbcopy < /dev/null`). +2. **Add the `testflight` environment.** In Settings > Environments, create `testflight` with two protection rules: + + - Required reviewers: Sam. Every run waits for his approval before GitHub releases any secret. Leave "Prevent self-review" off so he can approve runs he starts. + - Deployment branches and tags: Selected branches, `main` only. + + An Admin key is acceptable here only because both rules hold: a run needs Sam's approval and code merged to `main`. + + Set the secrets and variables with the [GitHub CLI](https://cli.github.com), so the key never appears on screen. `gh secret set` without `--body` prompts without echoing. + + ```sh + base64 -i AuthKey_.p8 | gh secret set ASC_KEY_P8 --env testflight --repo SamGu-NRX/house-scanning + gh secret set ASC_KEY_ID --env testflight --repo SamGu-NRX/house-scanning + gh secret set ASC_ISSUER_ID --env testflight --repo SamGu-NRX/house-scanning + gh variable set APPLE_TEAM_ID --env testflight --repo SamGu-NRX/house-scanning --body + gh variable set BUNDLE_ID_PREFIX --env testflight --repo SamGu-NRX/house-scanning --body + rm AuthKey_.p8 + ``` + + `APPLE_TEAM_ID` is the 10-character Team ID from developer.apple.com > Account > Membership details. `BUNDLE_ID_PREFIX` is a reverse-DNS prefix such as `com.yourname`. +3. **Register a device.** Archiving needs a development profile, which Apple does not issue to a team with no devices. Running either app on your iPhone from Xcode once registers it. +4. **Create the app records.** App Store Connect has no API for this. Register the App IDs `.housescan` and `.measurelab` under Certificates, Identifiers & Profiles > Identifiers. Then in App Store Connect > Apps > New App, create **House Scan** and **Measure Lab** for iOS with the matching bundle ids. Add a suffix if a name is taken. +5. **Add internal testers.** In each app's TestFlight tab, create an internal group, add team members who are App Store Connect users, and turn on automatic distribution. +6. **Run the workflow.** Actions > TestFlight > Run workflow, pick `main` and the app. Sam approves the pending deployment on the run page. The build appears in TestFlight after Apple processes it. + +### What approving a run means + +Approving a run also means Sam has reviewed the build inputs at the commit the run shows: `project.yml`, the generated project, xcconfig files and any local package manifest. That code builds while the key is on the runner. + +The workflow limits what that code can do. "Check build inputs" runs before any key exists and fails the run on shell script build phases, build rules, scheme pre- or post-actions, remote Swift packages, package plugins, `Package.resolved`, nested projects and Swift compiler plugin flags. Each app may use only the in-repo packages named in the workflow's "Select project" step (`Geometry` for Measure Lab, `HouseScanKit` for House Scan), and their manifests may not declare dependencies, plugins, macros, binary targets or unsafe flags. + +The key exists only inside the Archive step and the Export and upload step. Each decodes it into its own private directory under `RUNNER_TEMP` and deletes the directory when the step ends, fails or is cancelled. A final step removes any leftover. If the runner is killed before any of that runs, GitHub destroys the hosted runner after the job. + +### Upkeep + +Each run creates a new "Apple Development: Created via API" certificate. Revoke old ones under Certificates, Identifiers & Profiles > Certificates now and then. Uploaded builds are unaffected.