Skip to content
Closed
33 changes: 31 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ go install ./cmd/builder
./builder ios build --profile store --submit # Short for: ios release (no groups)
./builder ios build --profile development --distribute # Build, then print an over-the-air install link + QR code
./builder ios distribute [--ipa x.ipa] [--once] [--json] # Same for an existing IPA; --cleanup removes leftovers
./builder ios distribute --backend s3|azure [--ttl 168h] # Bucket backends (distribute.* in builder.json)
./builder ios distribute --backend testflight --group <internal group> # App Store IPA to an internal group
./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json)
./builder asc groups create <name> [--external] # also: groups delete, groups add-build
./builder asc testers add <email>... --group <name> # also: testers remove, users invite
Expand Down Expand Up @@ -197,6 +199,12 @@ builder ios distribute ──► otainstall.Inspect: Info.plist + embedded.mobil
▼
Print link + QR (half blocks); re-mint a minute before expiry
or on Enter; q / Ctrl-C / --timeout → DELETE gist + release
--backend s3|azure (otainstall.Bucket): PUT IPA + m.plist under
<prefix>ios-builder/<id>/ (marker metadata) → SigV4 presign / service
SAS for --ttl → DELETE both on exit
--backend testflight: Inspect wants a store IPA →
distribute.ToInternalGroup (group check → Upload wait → SubmitTestFlight
Internal); ios build --distribute → release.Run with InternalGroups
```

### Module Layout
Expand All @@ -214,7 +222,8 @@ internal/
# beta groups, beta testers, team users/invitations, review)
distribute/ # Upload / TestFlight / App Store / tester flows on top of asc
ipa/ # Info.plist and embedded.mobileprovision reading from .ipa archives
otainstall/ # ios distribute: over-the-air install links (manifest, QR, GitHub draft release + gist backend)
otainstall/ # ios distribute: over-the-air install links (manifest, QR; backends: GitHub draft
# release + gist, S3/S3-compatible via SigV4, Azure Blob via service SAS)
build/ # Build coordination (snapshot + trigger + poll + download)
signing/ # CSR generation, .p12 assembly, and Auto (portal-free provisioning on top of asc)
snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref
Expand Down Expand Up @@ -407,7 +416,7 @@ internal/
interface a foreign build backend implements.
- **OTA Install, Not OTA Updates** (`internal/otainstall`): `ios distribute` serves a whole signed IPA
through an `itms-services://` link; iOS installs only development/ad-hoc (device on the profile) or
enterprise builds, so `Inspect` refuses unsigned and App Store IPAs and `CheckDistribution` refuses
enterprise builds, so `Inspect` refuses unsigned and App Store IPAs (except for `--backend testflight`) and `CheckDistribution` refuses
the profile of `ios build --distribute` before the snapshot push. `--distribute` excludes `--submit`.
- **Draft Releases Create No Tag**: the IPA is an asset of a draft release tagged `ios-builder/distribute-<id>`
(nothing in `refs/tags`, nothing on the repo page). `GET releases/assets/{id}` with `Accept:
Expand All @@ -428,6 +437,26 @@ internal/
- **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per
line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light);
`TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`.
- **Distribute Backends**: `--backend`, else `distribute.backend`, else github (`otainstall.BackendName`).
`cmd/builder` resolves the whole target (`distributeTarget`: client, bucket, credentials, group) before a
build is pushed; `--ttl` is s3/azure only, `--group` testflight only, and on `ios build` all three need
`--distribute`. `Inspect`/`CheckDistribution` take the backend: testflight is the inverse of the OTA check
(store only; an empty profile passes so `release.Preflight` can pick the only store profile).
- **Bucket Backends** (`otainstall.Bucket` over `objectStore`): one folder `<prefix>ios-builder/<id>/` with the
IPA and `m.plist` (short: the manifest URL is the QR code). Mint presigns the IPA, rewrites the manifest in
place (an older, still valid link serves the newest one) and presigns it; `ExpiresAt` is now + `--ttl`
(2m to 168h, SigV4's cap, kept for azure too). Cleanup lists the folder and deletes only marked objects
(S3: `x-amz-meta-ios-builder` via HEAD, since listings carry no metadata; Azure: `iosbuilder` via
`include=metadata`). Missing keys on DELETE are success.
- **SigV4 Without The SDK** (`sigv4.go`): header signing signs every header on the request plus host; query
presign signs host only with `UNSIGNED-PAYLOAD`; PUTs stream with `UNSIGNED-PAYLOAD`. `TestSigV4*` holds AWS's
published S3 vectors, and the fake S3 re-signs what arrives on the wire. Custom endpoints are path-style;
AWS is virtual-hosted unless the bucket has a dot. Credentials: `AWS_*` env, else `AWS_PROFILE`/`default` in
the shared credentials file (static keys only: SSO/`credential_process` are refused with a hint).
- **Azure Service SAS**: every request, Builder's own included, carries a SAS (`sv=2022-11-02`, the 16-field
string-to-sign of 2020-12-06+); only the signature's `+` is escaped, since `Link` doubles every `%`.
- **QR Size Per Backend**: github 41 modules, azure 57, s3 69 (R2 73), s3 with a session token ~105.
`TestBucketQRSizes` pins them; `Options.print` adds a note when the code is wider than 80 columns.
- **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets`
(internal/signing/sets.go) hold the non-interactive core of `signing setup`
and on-demand provisioning; cmd/builder keeps the prompts, the plan and the
Expand Down
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ builder ios build --profile development --distribute # Build, then print an ins
builder ios distribute # Same for the newest IPA in ./dist/ (or --ipa)
builder ios distribute --once # One link, no refresh, uploads left in place
builder ios distribute --cleanup # Remove uploads earlier sessions left behind
builder ios distribute --backend s3 --once --ttl 168h # A week-long link from an S3/R2 bucket
builder ios distribute --backend testflight --group Team # App Store build to an internal TestFlight group

# App Store Connect management (needs builder auth apple)
builder asc apps # Apps the API key can see
Expand Down Expand Up @@ -872,6 +874,74 @@ drops the code, `--qr` prints it off a terminal and `--qr-invert` renders it for
a dark-on-light one. Codemagic and Bitrise builds work the same way, since the
uploads always go to the GitHub repository in `builder.json`.

### Other backends

`--backend` picks where the install goes; without it `distribute.backend` in
`builder.json` decides, else GitHub as above. Every flag works the same on
`ios build --distribute`, and the backend is checked before the build is pushed.

| Backend | Holds | Link lifetime | QR code |
| --- | --- | --- | --- |
| `github` (default) | draft release + secret gist | 5 minutes, refreshed | 41 modules |
| `s3` | S3 or S3-compatible bucket | `--ttl`, 2m to 7 days (default 1h) | 69 modules (R2: 73) |
| `azure` | Azure Blob container | `--ttl`, 2m to 7 days (default 1h) | 57 modules |
| `testflight` | App Store Connect, internal group | as long as the build | none |

```json
"distribute": {
"backend": "s3",
"bucket": "my-app-builds",
"region": "eu-central-1",
"prefix": "ota/"
}
```

**s3** works with Amazon S3 and with S3-compatible stores through `endpoint`
(addressed path-style): Cloudflare R2 (`"endpoint":
"https://<account>.r2.cloudflarestorage.com", "region": "auto"`), MinIO, or
Google Cloud Storage with HMAC keys (`"endpoint":
"https://storage.googleapis.com"`). Credentials come from
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` (plus `AWS_SESSION_TOKEN`), else the
`AWS_PROFILE` (or `default`) section of `~/.aws/credentials`; the region from
`region`, `AWS_REGION` or `AWS_DEFAULT_REGION`, else `us-east-1`. The key needs
`s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` and, for `--cleanup`,
`s3:ListBucket`. The bucket stays private: the IPA and a small manifest go to
`<prefix>ios-builder/<id>/` and are linked with SigV4 presigned URLs. A
presigned URL is about 350 characters, so the QR code is larger than GitHub's
(69 modules, 73 columns with its border, still inside an 80-column terminal).
Temporary credentials (SSO, assumed roles) put their session token in the URL
and the code grows to about 105 modules; Builder says so when it does not fit,
and the link itself works either way. A presigned URL also never outlives the
credentials that signed it.

**azure** needs `"account"` and `"container"` (and `"endpoint"` for Azurite or
a sovereign cloud) and the account key in `AZURE_STORAGE_KEY` or
`AZURE_STORAGE_CONNECTION_STRING`; links are service SAS URLs signed with that
key. The container stays private.

With either bucket backend `--ttl` sets how long a link lives; the session
re-mints a minute before expiry as usual, and `--once --ttl 168h` gives a link
to send around that stays valid for a week. Ending a session deletes both
objects; `--once` leaves them, and `--cleanup` deletes every object under
`<prefix>ios-builder/` that carries Builder's marker metadata, nothing else.

**testflight** is the route for App Store signed builds, which cannot be
installed over the air:

```bash
builder ios distribute --backend testflight --group Team # an existing App Store IPA
builder ios build --profile store --distribute --backend testflight --group Team
```

It uploads the IPA (needs `builder auth apple`), waits until App Store Connect
has processed it and adds it to the **internal** group `--group` (or
`distribute.group`), which is created when missing. Internal groups need no
beta review, so testers install from the TestFlight app within minutes; an
external group is refused before the upload. `ios build --distribute
--backend testflight` is `ios release` to that one group, so it also picks
the next build number. Instead of a QR code it prints the build and its App
Store Connect link; `--notes` and `--no-encryption` work as for `ios submit`.

Alternatively, [MobAI](https://mobai.run) installs an IPA over the cable, signed
or not: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI
asks for the account).
Expand Down
Loading
Loading