Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions .github/workflows/testflight.yml
Original file line number Diff line number Diff line change
@@ -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() { # <file> <extended regex> <description>; 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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>teamID</key>
<string>${APPLE_TEAM_ID}</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
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.*
57 changes: 57 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<run number>.<attempt>`, such as `12.1`.

| App | Project | Bundle id |
| --- | --- | --- |
| House Scan (`house-scan`) | `ios/` | `<BUNDLE_ID_PREFIX>.housescan` |
| Measure Lab (`measure-lab`) | `experiments/measure-lab/` | `<BUNDLE_ID_PREFIX>.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_<KEY_ID>.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 <TEAM_ID>
gh variable set BUNDLE_ID_PREFIX --env testflight --repo SamGu-NRX/house-scanning --body <PREFIX>
rm AuthKey_<KEY_ID>.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 `<BUNDLE_ID_PREFIX>.housescan` and `<BUNDLE_ID_PREFIX>.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.