From 438d3908430a3e46b537c155c13b929503af6388 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 25 Sep 2026 22:57:29 -0500 Subject: [PATCH 1/6] ci: add manual TestFlight upload workflow A workflow_dispatch job archives House Scan or Measure Lab with automatic signing through an App Store Connect API key, sets the build number from the run number, and exports with destination=upload. CONTRIBUTING.md gains a Distribution section with the one-time setup. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 180 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 43 ++++++++ 2 files changed, 223 insertions(+) create mode 100644 .github/workflows/testflight.yml diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 000000000..7df1159c1 --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,180 @@ +name: TestFlight + +# Run by hand from the Actions tab. Never add pull_request or push triggers: this job holds an +# App Store Connect key that can sign and upload builds for the whole team. +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-${{ inputs.app }} + cancel-in-progress: false + +jobs: + upload: + name: Upload ${{ inputs.app }} to TestFlight + runs-on: macos-26 + timeout-minutes: 45 + # Holds ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (secrets) and APPLE_TEAM_ID, BUNDLE_ID_PREFIX + # (variables). CONTRIBUTING.md > Distribution lists the setup. + 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 }} + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + BUNDLE_ID_PREFIX: ${{ vars.BUNDLE_ID_PREFIX }} + # GitHub counts runs per workflow, so both apps share one increasing build number. + BUILD_NUMBER: ${{ github.run_number }} + steps: + - name: Check secrets and variables + env: + 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 ;; + measure-lab) dir=experiments/measure-lab; name=MeasureLab ;; + *) 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" + } >> "$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" + + # The build CI for each app fails on drift between project.yml and the committed project, + # so generating here only guards against a branch that skipped that check. + - name: Generate project + run: xcodegen generate --spec "$PROJECT_DIR/project.yml" + + - name: Write App Store Connect key + env: + ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} + run: | + key="$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8" + umask 077 + if ! printf '%s' "$ASC_KEY_P8" | base64 --decode > "$key" 2>/dev/null \ + || ! grep -q -- '-----BEGIN PRIVATE KEY-----' "$key"; then + rm -f "$key" + echo "::error::ASC_KEY_P8 is not a base64-encoded .p8 file. Store the output of: base64 -i AuthKey_.p8" + exit 1 + fi + echo "ASC_KEY_PATH=$key" >> "$GITHUB_ENV" + + - name: Archive + run: >- + 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 "$ASC_KEY_PATH" + -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 + run: | + plist="$RUNNER_TEMP/ExportOptions.plist" + cat > "$plist" < + + + + method + app-store-connect + destination + upload + signingStyle + automatic + teamID + ${APPLE_TEAM_ID} + manageAppVersionAndBuildNumber + + uploadSymbols + + + + EOF + plutil -lint "$plist" + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/$PROJECT_NAME.xcarchive" \ + -exportOptionsPlist "$plist" \ + -exportPath "$RUNNER_TEMP/export" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$ASC_KEY_PATH" \ + -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" + + - name: Remove App Store Connect key + if: always() + run: rm -f "$RUNNER_TEMP"/AuthKey_*.p8 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2e1ceb9..57524b3dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,50 @@ 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 someone starts it by hand. The build number is the workflow's run number, so each upload is higher than the last. + +| App | Project | Bundle id | +| --- | --- | --- | +| House Scan (`house-scan`) | `ios/` | `.housescan` | +| Measure Lab (`measure-lab`) | `experiments/measure-lab/` | `.measurelab` | + +### Before the first upload + +Each app needs an app icon. App Store Connect rejects a build without one, and neither app has one yet. That means an `AppIcon` set in an asset catalog inside the app's source folder (`ios/HouseScan/` or `experiments/measure-lab/MeasureLab/`) with a 1024×1024 image. The projects already set `ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon`. The owner of each app adds its icon. + +Neither `Info.plist` declares `ITSAppUsesNonExemptEncryption`. Until one does, every new build waits in TestFlight under "Missing Compliance" until someone answers the encryption question on the build page. Setting the key to `false` skips that step for an app that uses only HTTPS and Apple's system encryption. + +### One-time setup + +The account holder does this once, in order. + +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 step signs with a cloud-managed distribution certificate, and API keys with the App Manager role get `Cloud signing permission error` because keys have no setting to grant that access. Download the `.p8` file (Apple offers it once) and note the Key ID and the Issuer ID shown above the key list. +2. **Add the `testflight` environment.** In the GitHub repository, open Settings > Environments > New environment and name it `testflight`. Under Deployment branches and tags, choose Selected branches and add `main`, so the Admin key only signs reviewed code. Add these: + + | Kind | Name | Value | + | --- | --- | --- | + | Secret | `ASC_KEY_ID` | the Key ID | + | Secret | `ASC_ISSUER_ID` | the Issuer ID | + | Secret | `ASC_KEY_P8` | output of `base64 -i AuthKey_.p8` | + | Variable | `APPLE_TEAM_ID` | the 10-character Team ID from developer.apple.com > Account > Membership details | + | Variable | `BUNDLE_ID_PREFIX` | a reverse-DNS prefix the team can register, such as `com.yourname` | + + Delete the downloaded `.p8` from Downloads once the secret is saved. Never commit it. +3. **Register a device.** The archive step signs for development first and asks Apple for a development profile, which Apple refuses to a team with no registered devices. Running either app on your iPhone from Xcode once registers it. You can also add the device under Certificates, Identifiers & Profiles > Devices. +4. **Create the app records.** App Store Connect has no API for creating apps. First register the two explicit 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 and pick the matching bundle id for each. The name must be unique across the App Store, so add a suffix if Apple says it is taken. +5. **Add internal testers.** In each app's TestFlight tab, create an internal group and add team members. Internal testers must already be users in App Store Connect. Turn on automatic distribution so new builds reach the group without another click. +6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick the branch and the app, and start it. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. + +Measure Lab is only on its pull request branch until that merges, and the environment rule above only lets `main` use the key. Its uploads start working once it lands on `main`. + +### Upkeep + +Each run happens on a fresh runner, so the archive step creates a new "Apple Development: Created via API" certificate every time. Revoke old ones under Certificates, Identifiers & Profiles > Certificates when the list grows. TestFlight builds carry the distribution signature, so revoking these does not affect them. From e0a975cb9aa0835f8e3714a07bd5d0337c94659d Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 25 Sep 2026 23:09:53 -0500 Subject: [PATCH 2/6] ci: confine the TestFlight key and require approval per run Secrets now reach only the steps that use them through step-level env. The decoded .p8 lives in a mktemp -d directory under RUNNER_TEMP created with umask 077, and an always() final step deletes the directory. One concurrency group serialises runs across both apps. The Distribution docs add Sam as required reviewer on the testflight environment, explain why that makes an Admin key acceptable, and note Measure Lab's icon from #7. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 34 +++++++++++++++++++++++++------- CONTRIBUTING.md | 11 ++++++++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 7df1159c1..8975e9317 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -1,7 +1,12 @@ name: TestFlight # Run by hand from the Actions tab. Never add pull_request or push triggers: this job holds an -# App Store Connect key that can sign and upload builds for the whole team. +# Admin App Store Connect key that can sign and upload builds for the whole team. The testflight +# environment only admits main and waits for a required reviewer before any secret is released. +# +# Key handling: secrets reach only the steps that use them, through step-level env. The decoded +# .p8 lives in a private temporary directory, only its path is passed on, nothing prints it, and +# the last step deletes the directory. No step uploads artifacts, caches or logs. on: workflow_dispatch: inputs: @@ -16,8 +21,9 @@ on: permissions: contents: read +# One upload at a time across both apps; a second run waits instead of cancelling the first. concurrency: - group: testflight-${{ inputs.app }} + group: testflight cancel-in-progress: false jobs: @@ -34,15 +40,16 @@ jobs: XCODEGEN_VERSION: 2.46.0 XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806 APP: ${{ inputs.app }} - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} BUNDLE_ID_PREFIX: ${{ vars.BUNDLE_ID_PREFIX }} # GitHub counts runs per workflow, so both apps share one increasing build number. BUILD_NUMBER: ${{ github.run_number }} steps: + # Tests only whether each value is empty; it never prints one. - 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 @@ -109,21 +116,28 @@ jobs: - name: Generate project run: xcodegen generate --spec "$PROJECT_DIR/project.yml" + # GitHub masks the base64 secret in logs but not the decoded key, so no command here may + # print the file: base64 and grep -q write nothing to the log. - name: Write App Store Connect key env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} run: | - key="$RUNNER_TEMP/AuthKey_${ASC_KEY_ID}.p8" umask 077 + dir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" + echo "ASC_KEY_DIR=$dir" >> "$GITHUB_ENV" + key="$dir/AuthKey_${ASC_KEY_ID}.p8" if ! printf '%s' "$ASC_KEY_P8" | base64 --decode > "$key" 2>/dev/null \ || ! grep -q -- '-----BEGIN PRIVATE KEY-----' "$key"; then - rm -f "$key" echo "::error::ASC_KEY_P8 is not a base64-encoded .p8 file. Store the output of: base64 -i AuthKey_.p8" exit 1 fi echo "ASC_KEY_PATH=$key" >> "$GITHUB_ENV" - name: Archive + env: + ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} run: >- xcodebuild archive -project "$PROJECT_DIR/$PROJECT_NAME.xcodeproj" @@ -142,6 +156,9 @@ jobs: # 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 }} run: | plist="$RUNNER_TEMP/ExportOptions.plist" cat > "$plist" < Users and Access > Integrations > App Store Connect API, create a team key with the **Admin** role. The export step signs with a cloud-managed distribution certificate, and API keys with the App Manager role get `Cloud signing permission error` because keys have no setting to grant that access. Download the `.p8` file (Apple offers it once) and note the Key ID and the Issuer ID shown above the key list. -2. **Add the `testflight` environment.** In the GitHub repository, open Settings > Environments > New environment and name it `testflight`. Under Deployment branches and tags, choose Selected branches and add `main`, so the Admin key only signs reviewed code. Add these: +2. **Add the `testflight` environment.** In the GitHub repository, open Settings > Environments > New environment and name it `testflight`. Set two protection rules: + + - Required reviewers: add Sam. Every run then pauses until he approves it, and GitHub releases no secret to the job before that. Leave "Prevent self-review" off so he can approve runs he starts. + - Deployment branches and tags: choose Selected branches and add `main`, so the key only signs reviewed code. + + An Admin key can sign and upload for the whole team, so it is acceptable here only because both rules hold. A run needs Sam's approval and code already merged to `main`. Then add these: | Kind | Name | Value | | --- | --- | --- | @@ -71,7 +76,7 @@ The account holder does this once, in order. 3. **Register a device.** The archive step signs for development first and asks Apple for a development profile, which Apple refuses to a team with no registered devices. Running either app on your iPhone from Xcode once registers it. You can also add the device under Certificates, Identifiers & Profiles > Devices. 4. **Create the app records.** App Store Connect has no API for creating apps. First register the two explicit 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 and pick the matching bundle id for each. The name must be unique across the App Store, so add a suffix if Apple says it is taken. 5. **Add internal testers.** In each app's TestFlight tab, create an internal group and add team members. Internal testers must already be users in App Store Connect. Turn on automatic distribution so new builds reach the group without another click. -6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick the branch and the app, and start it. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. +6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick `main` and the app, and start it. Sam approves the pending deployment on the run page. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. Measure Lab is only on its pull request branch until that merges, and the environment rule above only lets `main` use the key. Its uploads start working once it lands on `main`. From d625f7d62809093df2039bf272708246a6af0195 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 25 Sep 2026 23:14:46 -0500 Subject: [PATCH 3/6] ci: add the run attempt to the TestFlight build number A rerun kept github.run_number, so rerunning after App Store Connect had accepted the upload would be rejected as a duplicate build. The build number is now ., which CFBundleVersion accepts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 6 ++++-- CONTRIBUTING.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 8975e9317..ad9ad7d80 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -42,8 +42,10 @@ jobs: APP: ${{ inputs.app }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} BUNDLE_ID_PREFIX: ${{ vars.BUNDLE_ID_PREFIX }} - # GitHub counts runs per workflow, so both apps share one increasing build number. - BUILD_NUMBER: ${{ github.run_number }} + # ., such as 12.1. GitHub counts runs per workflow, so both apps share + # one increasing number, and a rerun gets a new attempt instead of a duplicate App Store + # Connect rejects. CFBundleVersion allows up to three dot-separated integers. + BUILD_NUMBER: ${{ github.run_number }}.${{ github.run_attempt }} steps: # Tests only whether each value is empty; it never prints one. - name: Check secrets and variables diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fb3ae555..4c866f231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ Keep workflows that run pull-request code away from production credentials and d ## Distribution -`.github/workflows/testflight.yml` archives one app, signs it and uploads it to TestFlight. It runs only when someone starts it by hand. The build number is the workflow's run number, so each upload is higher than the last. +`.github/workflows/testflight.yml` archives one app, signs it and uploads it to TestFlight. It runs only when someone starts it by hand. The build number is the workflow's run number and attempt, such as `12.1`, so each upload, reruns included, is higher than the last. | App | Project | Bundle id | | --- | --- | --- | From 26a75542b2fb02ea49c3ca3e4bda02ad72510272 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Fri, 25 Sep 2026 23:23:16 -0500 Subject: [PATCH 4/6] ci: scope the TestFlight key to each xcodebuild step and guard build inputs The key used to be decoded in its own step, handed on through GITHUB_ENV and removed only by a final always() step. Archive and Export and upload now each decode it into a private mktemp directory and delete it through an EXIT trap; INT and TERM exit through that trap so a cancelled step fails. No key path crosses a step boundary, and the final step remains a fallback. A new "Check build inputs" step runs before either key step and fails on anything that runs repository code during xcodebuild: script phases, build rules, scheme actions, remote packages, plugins, Package.resolved, nested projects and compiler plugin flags. Measure Lab's in-repo Geometry package is allowed by name, provided its manifest declares no dependencies, plugins, macros, binary targets or unsafe flags. The docs set secrets with gh so the key never shows on screen, treat the .p8 and its base64 text as equally sensitive, and state that approving a run covers reviewing that commit's build inputs. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 150 +++++++++++++++++++++++-------- CONTRIBUTING.md | 37 +++++--- 2 files changed, 140 insertions(+), 47 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index ad9ad7d80..762d5baf2 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -4,9 +4,11 @@ name: TestFlight # Admin App Store Connect key that can sign and upload builds for the whole team. The testflight # environment only admits main and waits for a required reviewer before any secret is released. # -# Key handling: secrets reach only the steps that use them, through step-level env. The decoded -# .p8 lives in a private temporary directory, only its path is passed on, nothing prints it, and -# the last step deletes the directory. No step uploads artifacts, caches or logs. +# Key handling: secrets reach only the steps that use them, through step-level env. Each step +# that runs xcodebuild with the key decodes it into its own private directory and deletes it on +# exit, so no key exists between steps and no key path crosses a step boundary. Nothing prints +# the key, and no step uploads artifacts, caches or logs. Before any key exists, "Check build +# inputs" refuses projects that could run repository code during the signed build. on: workflow_dispatch: inputs: @@ -84,8 +86,8 @@ jobs: - name: Select project run: | case "$APP" in - house-scan) dir=ios; name=HouseScan ;; - measure-lab) dir=experiments/measure-lab; name=MeasureLab ;; + house-scan) dir=ios; name=HouseScan; packages="" ;; + measure-lab) dir=experiments/measure-lab; name=MeasureLab; packages="Geometry" ;; *) echo "::error::Unknown app '$APP'."; exit 1 ;; esac if [ ! -f "$dir/project.yml" ]; then @@ -95,6 +97,7 @@ jobs: { echo "PROJECT_DIR=$dir" echo "PROJECT_NAME=$name" + echo "ALLOWED_LOCAL_PACKAGES=$packages" } >> "$GITHUB_ENV" - name: Show toolchain @@ -118,42 +121,99 @@ jobs: - name: Generate project run: xcodegen generate --spec "$PROJECT_DIR/project.yml" - # GitHub masks the base64 secret in logs but not the decoded key, so no command here may - # print the file: base64 and grep -q write nothing to the log. - - name: Write App Store Connect key + # Code from the repository must not run while the key is on disk. Build-script phases, build + # rules, scheme pre/post actions, package plugins, macros and compiler plugins all run during + # xcodebuild, so any of them fails the run here. The only package allowed is an in-repo one + # named in ALLOWED_LOCAL_PACKAGES (set in "Select project") whose manifest declares no + # dependencies, plugins, macros, binary targets or unsafe flags. SwiftPM still evaluates that + # manifest in its macOS sandbox, which is why its contents are checked too. + - name: Check build inputs + run: | + spec="$PROJECT_DIR/project.yml" + pbx="$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 + } + flag() { # flag ; 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 + } + flag "$spec" '^[[:space:]]*(preBuildScripts|postCompileScripts|postBuildScripts|buildToolPlugins|buildRules|preActions|postActions|projectReferences):' "Build script, build rule, scheme action, plugin or project reference" + flag "$pbx" '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 + flag "$spec" '^packages:' "Swift package section" + flag "$pbx" 'XCLocalSwiftPackageReference|XCSwiftPackageProductDependency' "Swift package dependency" + else + declared="$(grep -c 'isa = XCLocalSwiftPackageReference;' "$pbx" || true)" + refs="$(sed -nE 's/^[[:space:]]*relativePath = "?([^";]*)"?;/\1/p' "$pbx")" + if [ "$declared" -ne "$(printf '%s' "$refs" | grep -c . || true)" ]; then + refuse "$pbx" "Could not match every local package reference to a relativePath." + fi + for ref in $refs; do + case " $ALLOWED_LOCAL_PACKAGES " in + *" $ref "*) ;; + *) refuse "$pbx" "Local package '$ref' is not in ALLOWED_LOCAL_PACKAGES." ;; + esac + done + for pkg in $ALLOWED_LOCAL_PACKAGES; do + flag "$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 + flag "$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 + flag "$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 lives only in this step. GitHub masks the base64 secret in logs but not the + # decoded file, so nothing may print it; base64 and grep -q write nothing to the log. umask 077 - dir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" - echo "ASC_KEY_DIR=$dir" >> "$GITHUB_ENV" - key="$dir/AuthKey_${ASC_KEY_ID}.p8" + keydir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" + # EXIT deletes the key on success and failure. INT and TERM exit through it, so a cancelled + # step also fails instead of resuming after the handler. + 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. Store the output of: base64 -i AuthKey_.p8" + echo "::error::ASC_KEY_P8 is not a base64-encoded .p8 file. See CONTRIBUTING.md > Distribution." exit 1 fi - echo "ASC_KEY_PATH=$key" >> "$GITHUB_ENV" - - - name: Archive - env: - ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} - run: >- - 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 "$ASC_KEY_PATH" - -authenticationKeyID "$ASC_KEY_ID" - -authenticationKeyIssuerID "$ASC_ISSUER_ID" - DEVELOPMENT_TEAM="$APPLE_TEAM_ID" - BUNDLE_ID_PREFIX="$BUNDLE_ID_PREFIX" - CURRENT_PROJECT_VERSION="$BUILD_NUMBER" + 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. @@ -161,6 +221,7 @@ jobs: 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" < EOF plutil -lint "$plist" + # The key lives only in this step. GitHub masks the base64 secret in logs but not the + # decoded file, so nothing may print it; base64 and grep -q write nothing to the log. + umask 077 + keydir="$(mktemp -d "$RUNNER_TEMP/asc-key.XXXXXX")" + # EXIT deletes the key on success and failure. INT and TERM exit through it, so a cancelled + # step also fails instead of resuming after the handler. + 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 "$ASC_KEY_PATH" \ + -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 only: each key step's trap already removed its directory. A runner killed without + # a chance to run steps leaves cleanup to GitHub destroying the hosted runner. - name: Remove App Store Connect key if: always() - run: | - if [ -n "${ASC_KEY_DIR:-}" ]; then - rm -rf -- "$ASC_KEY_DIR" - fi + run: rm -rf -- "$RUNNER_TEMP"/asc-key.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c866f231..619908b3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,29 +57,46 @@ Neither `Info.plist` declares `ITSAppUsesNonExemptEncryption`. Until one does, e The account holder does this once, in order. 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 step signs with a cloud-managed distribution certificate, and API keys with the App Manager role get `Cloud signing permission error` because keys have no setting to grant that access. Download the `.p8` file (Apple offers it once) and note the Key ID and the Issuer ID shown above the key list. + + The `.p8` file and its base64 text are equally sensitive: either one lets anyone sign and upload as the team. Never paste either into a chat, issue, pull request, screenshot, log or commit. If either was ever copied, clear the clipboard afterwards (for example `pbcopy < /dev/null`). 2. **Add the `testflight` environment.** In the GitHub repository, open Settings > Environments > New environment and name it `testflight`. Set two protection rules: - Required reviewers: add Sam. Every run then pauses until he approves it, and GitHub releases no secret to the job before that. Leave "Prevent self-review" off so he can approve runs he starts. - Deployment branches and tags: choose Selected branches and add `main`, so the key only signs reviewed code. - An Admin key can sign and upload for the whole team, so it is acceptable here only because both rules hold. A run needs Sam's approval and code already merged to `main`. Then add these: + An Admin key can sign and upload for the whole team, so it is acceptable here only because both rules hold. A run needs Sam's approval and code already merged to `main`. + + Then set the secrets and variables with the [GitHub CLI](https://cli.github.com), so the key never shows on screen. The first command pipes the base64 text straight into GitHub. The next two prompt for their value without echoing it. - | Kind | Name | Value | - | --- | --- | --- | - | Secret | `ASC_KEY_ID` | the Key ID | - | Secret | `ASC_ISSUER_ID` | the Issuer ID | - | Secret | `ASC_KEY_P8` | output of `base64 -i AuthKey_.p8` | - | Variable | `APPLE_TEAM_ID` | the 10-character Team ID from developer.apple.com > Account > Membership details | - | Variable | `BUNDLE_ID_PREFIX` | a reverse-DNS prefix the team can register, such as `com.yourname` | + ```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 + ``` - Delete the downloaded `.p8` from Downloads once the secret is saved. Never commit it. + `APPLE_TEAM_ID` is the 10-character Team ID from developer.apple.com > Account > Membership details. `BUNDLE_ID_PREFIX` is a reverse-DNS prefix the team can register, such as `com.yourname`. Delete the `.p8` as soon as the secret is set; GitHub keeps the only copy the workflow needs. 3. **Register a device.** The archive step signs for development first and asks Apple for a development profile, which Apple refuses to a team with no registered devices. Running either app on your iPhone from Xcode once registers it. You can also add the device under Certificates, Identifiers & Profiles > Devices. 4. **Create the app records.** App Store Connect has no API for creating apps. First register the two explicit 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 and pick the matching bundle id for each. The name must be unique across the App Store, so add a suffix if Apple says it is taken. 5. **Add internal testers.** In each app's TestFlight tab, create an internal group and add team members. Internal testers must already be users in App Store Connect. Turn on automatic distribution so new builds reach the group without another click. -6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick `main` and the app, and start it. Sam approves the pending deployment on the run page. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. +6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick `main` and the app, and start it. Sam approves the pending deployment on the run page. That approval also means he has reviewed the build inputs at the exact commit the run shows (`project.yml`, the generated project, xcconfig files and any local package manifest), because that code builds while the key is on the runner. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. Measure Lab is only on its pull request branch until that merges, and the environment rule above only lets `main` use the key. Its uploads start working once it lands on `main`. +### How the key is kept in check + +The key exists only inside the two steps that call `xcodebuild`, Archive and Export and upload. Each one decodes it into its own private directory under `RUNNER_TEMP` and deletes that directory when the step exits, fails or is cancelled. The key is absent between the two steps, and no key path is passed from one step to another. A final step deletes any leftover key directory as a fallback. A runner killed without a chance to run that step, for example by a lost machine or an untrappable kill, leaves cleanup to GitHub, which destroys every hosted runner after its job. + +Before either key step, "Check build inputs" fails the run if the chosen app could run repository code during `xcodebuild`. It rejects: + +- shell script build phases, build rules and scheme pre- or post-actions; +- remote Swift packages, package plugins, `Package.resolved` files and nested projects; +- Swift compiler plugin flags. + +Each app may use only the in-repo packages listed for it in the workflow's "Select project" step. Measure Lab lists `Geometry`, and House Scan lists none. A listed package's manifest must not declare package dependencies, plugins, macros, binary targets or unsafe flags. Adding a package or a build script therefore means editing the workflow in a reviewed pull request. + ### Upkeep Each run happens on a fresh runner, so the archive step creates a new "Apple Development: Created via API" certificate every time. Revoke old ones under Certificates, Identifiers & Profiles > Certificates when the list grows. TestFlight builds carry the distribution signature, so revoking these does not affect them. From b896d59fb564321df5fcdea1a5b50b45d27bc7a1 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 03:14:46 -0500 Subject: [PATCH 5/6] ci: trim TestFlight workflow comments and Distribution docs Cuts comments that restated the code or the docs, keeping the ones that explain key handling and the build-input guard. Renames the guard helper flag to refuse_match and pbx to pbxproj, and drops plutil -lint on a plist the step writes from a fixed template. The Distribution section now holds only setup and what approving a run covers. No behavior change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 76 ++++++++++++-------------------- CONTRIBUTING.md | 48 +++++++++----------- 2 files changed, 48 insertions(+), 76 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 762d5baf2..8a2956e2b 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -1,14 +1,8 @@ name: TestFlight -# Run by hand from the Actions tab. Never add pull_request or push triggers: this job holds an -# Admin App Store Connect key that can sign and upload builds for the whole team. The testflight -# environment only admits main and waits for a required reviewer before any secret is released. -# -# Key handling: secrets reach only the steps that use them, through step-level env. Each step -# that runs xcodebuild with the key decodes it into its own private directory and deletes it on -# exit, so no key exists between steps and no key path crosses a step boundary. Nothing prints -# the key, and no step uploads artifacts, caches or logs. Before any key exists, "Check build -# inputs" refuses projects that could run repository code during the signed build. +# 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: @@ -23,7 +17,6 @@ on: permissions: contents: read -# One upload at a time across both apps; a second run waits instead of cancelling the first. concurrency: group: testflight cancel-in-progress: false @@ -33,8 +26,6 @@ jobs: name: Upload ${{ inputs.app }} to TestFlight runs-on: macos-26 timeout-minutes: 45 - # Holds ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (secrets) and APPLE_TEAM_ID, BUNDLE_ID_PREFIX - # (variables). CONTRIBUTING.md > Distribution lists the setup. environment: testflight env: # Same toolchain as .github/workflows/ios.yml; change both together. @@ -44,12 +35,9 @@ jobs: APP: ${{ inputs.app }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} BUNDLE_ID_PREFIX: ${{ vars.BUNDLE_ID_PREFIX }} - # ., such as 12.1. GitHub counts runs per workflow, so both apps share - # one increasing number, and a rerun gets a new attempt instead of a duplicate App Store - # Connect rejects. CFBundleVersion allows up to three dot-separated integers. + # 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: - # Tests only whether each value is empty; it never prints one. - name: Check secrets and variables env: ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} @@ -116,27 +104,23 @@ jobs: unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP" echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH" - # The build CI for each app fails on drift between project.yml and the committed project, - # so generating here only guards against a branch that skipped that check. + # 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" - # Code from the repository must not run while the key is on disk. Build-script phases, build - # rules, scheme pre/post actions, package plugins, macros and compiler plugins all run during - # xcodebuild, so any of them fails the run here. The only package allowed is an in-repo one - # named in ALLOWED_LOCAL_PACKAGES (set in "Select project") whose manifest declares no - # dependencies, plugins, macros, binary targets or unsafe flags. SwiftPM still evaluates that - # manifest in its macOS sandbox, which is why its contents are checked too. + # 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" - pbx="$PROJECT_DIR/$PROJECT_NAME.xcodeproj/project.pbxproj" + 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 } - flag() { # flag ; an unreadable file also fails + refuse_match() { # ; an unreadable file also fails local rc=0 grep -nE -- "$2" "$1" > "$RUNNER_TEMP/guard-hits" || rc=$? case "$rc" in @@ -145,32 +129,32 @@ jobs: *) refuse "$1" "Could not read this file to check it." ;; esac } - flag "$spec" '^[[:space:]]*(preBuildScripts|postCompileScripts|postBuildScripts|buildToolPlugins|buildRules|preActions|postActions|projectReferences):' "Build script, build rule, scheme action, plugin or project reference" - flag "$pbx" 'PBXShellScriptBuildPhase|PBXBuildRule|XCRemoteSwiftPackageReference|wrapper\.pb-project|productName = "?plugin:' "Shell script phase, build rule, remote package, plugin or nested project" + 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 - flag "$spec" '^packages:' "Swift package section" - flag "$pbx" 'XCLocalSwiftPackageReference|XCSwiftPackageProductDependency' "Swift package dependency" + refuse_match "$spec" '^packages:' "Swift package section" + refuse_match "$pbxproj" 'XCLocalSwiftPackageReference|XCSwiftPackageProductDependency' "Swift package dependency" else - declared="$(grep -c 'isa = XCLocalSwiftPackageReference;' "$pbx" || true)" - refs="$(sed -nE 's/^[[:space:]]*relativePath = "?([^";]*)"?;/\1/p' "$pbx")" + 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 "$pbx" "Could not match every local package reference to a relativePath." + 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 "$pbx" "Local package '$ref' is not in ALLOWED_LOCAL_PACKAGES." ;; + *) refuse "$pbxproj" "Local package '$ref' is not in ALLOWED_LOCAL_PACKAGES." ;; esac done for pkg in $ALLOWED_LOCAL_PACKAGES; do - flag "$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" + 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 - flag "$file" 'load-plugin|plugin-path' "Swift compiler plugin flag" + 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 - flag "$scheme" 'ExecutionAction' "Scheme pre- or post-action" + 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." @@ -185,12 +169,11 @@ jobs: ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }} run: | - # The key lives only in this step. GitHub masks the base64 secret in logs but not the - # decoded file, so nothing may print it; base64 and grep -q write nothing to the log. + # 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")" - # EXIT deletes the key on success and failure. INT and TERM exit through it, so a cancelled - # step also fails instead of resuming after the handler. + # 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 @@ -244,13 +227,11 @@ jobs: EOF - plutil -lint "$plist" - # The key lives only in this step. GitHub masks the base64 secret in logs but not the - # decoded file, so nothing may print it; base64 and grep -q write nothing to the log. + # 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")" - # EXIT deletes the key on success and failure. INT and TERM exit through it, so a cancelled - # step also fails instead of resuming after the handler. + # 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 @@ -271,8 +252,7 @@ jobs: -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 only: each key step's trap already removed its directory. A runner killed without - # a chance to run steps leaves cleanup to GitHub destroying the hosted runner. + # 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 619908b3f..c88cdee52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ Keep workflows that run pull-request code away from production credentials and d ## Distribution -`.github/workflows/testflight.yml` archives one app, signs it and uploads it to TestFlight. It runs only when someone starts it by hand. The build number is the workflow's run number and attempt, such as `12.1`, so each upload, reruns included, is higher than the last. +`.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 | | --- | --- | --- | @@ -48,25 +48,23 @@ Keep workflows that run pull-request code away from production credentials and d ### Before the first upload -Each app needs an app icon, because App Store Connect rejects a build without one. Measure Lab has one in `experiments/measure-lab/MeasureLab/Assets.xcassets`, added in #7. House Scan does not yet. It needs an `AppIcon` set with a 1024×1024 image in an asset catalog inside `ios/HouseScan/`, added by the lane A owner. The project already sets `ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon`. +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` declares `ITSAppUsesNonExemptEncryption`. Until one does, every new build waits in TestFlight under "Missing Compliance" until someone answers the encryption question on the build page. Setting the key to `false` skips that step for an app that uses only HTTPS and Apple's system encryption. +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 -The account holder does this once, in order. +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. -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 step signs with a cloud-managed distribution certificate, and API keys with the App Manager role get `Cloud signing permission error` because keys have no setting to grant that access. Download the `.p8` file (Apple offers it once) and note the Key ID and the Issuer ID shown above the key list. + 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: - The `.p8` file and its base64 text are equally sensitive: either one lets anyone sign and upload as the team. Never paste either into a chat, issue, pull request, screenshot, log or commit. If either was ever copied, clear the clipboard afterwards (for example `pbcopy < /dev/null`). -2. **Add the `testflight` environment.** In the GitHub repository, open Settings > Environments > New environment and name it `testflight`. Set 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. - - Required reviewers: add Sam. Every run then pauses until he approves it, and GitHub releases no secret to the job before that. Leave "Prevent self-review" off so he can approve runs he starts. - - Deployment branches and tags: choose Selected branches and add `main`, so the key only signs reviewed code. + An Admin key is acceptable here only because both rules hold: a run needs Sam's approval and code merged to `main`. - An Admin key can sign and upload for the whole team, so it is acceptable here only because both rules hold. A run needs Sam's approval and code already merged to `main`. - - Then set the secrets and variables with the [GitHub CLI](https://cli.github.com), so the key never shows on screen. The first command pipes the base64 text straight into GitHub. The next two prompt for their value without echoing it. + 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 @@ -77,26 +75,20 @@ The account holder does this once, in order. 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 the team can register, such as `com.yourname`. Delete the `.p8` as soon as the secret is set; GitHub keeps the only copy the workflow needs. -3. **Register a device.** The archive step signs for development first and asks Apple for a development profile, which Apple refuses to a team with no registered devices. Running either app on your iPhone from Xcode once registers it. You can also add the device under Certificates, Identifiers & Profiles > Devices. -4. **Create the app records.** App Store Connect has no API for creating apps. First register the two explicit 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 and pick the matching bundle id for each. The name must be unique across the App Store, so add a suffix if Apple says it is taken. -5. **Add internal testers.** In each app's TestFlight tab, create an internal group and add team members. Internal testers must already be users in App Store Connect. Turn on automatic distribution so new builds reach the group without another click. -6. **Run the workflow.** Open Actions > TestFlight > Run workflow, pick `main` and the app, and start it. Sam approves the pending deployment on the run page. That approval also means he has reviewed the build inputs at the exact commit the run shows (`project.yml`, the generated project, xcconfig files and any local package manifest), because that code builds while the key is on the runner. The run summary reports the uploaded build number. The build shows in TestFlight once Apple finishes processing it. - -Measure Lab is only on its pull request branch until that merges, and the environment rule above only lets `main` use the key. Its uploads start working once it lands on `main`. - -### How the key is kept in check + `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. -The key exists only inside the two steps that call `xcodebuild`, Archive and Export and upload. Each one decodes it into its own private directory under `RUNNER_TEMP` and deletes that directory when the step exits, fails or is cancelled. The key is absent between the two steps, and no key path is passed from one step to another. A final step deletes any leftover key directory as a fallback. A runner killed without a chance to run that step, for example by a lost machine or an untrappable kill, leaves cleanup to GitHub, which destroys every hosted runner after its job. +### What approving a run means -Before either key step, "Check build inputs" fails the run if the chosen app could run repository code during `xcodebuild`. It rejects: +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. -- shell script build phases, build rules and scheme pre- or post-actions; -- remote Swift packages, package plugins, `Package.resolved` files and nested projects; -- Swift compiler plugin flags. +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, none for House Scan), and their manifests may not declare dependencies, plugins, macros, binary targets or unsafe flags. -Each app may use only the in-repo packages listed for it in the workflow's "Select project" step. Measure Lab lists `Geometry`, and House Scan lists none. A listed package's manifest must not declare package dependencies, plugins, macros, binary targets or unsafe flags. Adding a package or a build script therefore means editing the workflow in a reviewed pull request. +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 happens on a fresh runner, so the archive step creates a new "Apple Development: Created via API" certificate every time. Revoke old ones under Certificates, Identifiers & Profiles > Certificates when the list grows. TestFlight builds carry the distribution signature, so revoking these does not affect them. +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. From 3a1097c8b7062318187c7d0dc17fb7a8da3cd4a4 Mon Sep 17 00:00:00 2001 From: SamGu-NRX Date: Sat, 26 Sep 2026 10:47:31 -0500 Subject: [PATCH 6/6] ci: let the TestFlight build use HouseScanKit, the app's in-repo capture package House Scan now depends on its local Swift package ios/HouseScanKit, so the "Check build inputs" guard, which allowed no local packages for House Scan, would refuse every build. HouseScanKit's manifest declares no external packages, plugins, macros, binary targets or unsafe flags, which the guard still enforces. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/testflight.yml | 2 +- CONTRIBUTING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml index 8a2956e2b..2c9a1bf82 100644 --- a/.github/workflows/testflight.yml +++ b/.github/workflows/testflight.yml @@ -74,7 +74,7 @@ jobs: - name: Select project run: | case "$APP" in - house-scan) dir=ios; name=HouseScan; packages="" ;; + 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c88cdee52..d9fd21641 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ Neither `Info.plist` sets `ITSAppUsesNonExemptEncryption`, so each build waits u 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, none for House Scan), and their manifests may not declare dependencies, plugins, macros, binary targets or unsafe flags. +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.