diff --git a/CLAUDE.md b/CLAUDE.md index b15fd87..7a448ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Builder** is a Go CLI tool for iOS development without a Mac. It has two main capabilities: +**Builder** is a Go CLI tool for iOS development without a Mac. It has three main capabilities: 1. **Remote builds**: Build iOS apps via GitHub Actions from any platform 2. **Dev tools**: Hot reload on real iOS devices using MobAI (Flutter and React Native) +3. **Distribution**: Upload builds to App Store Connect and submit them to TestFlight or App Review ## Build Commands @@ -29,6 +30,10 @@ go install ./cmd/builder ./builder dev kmp # Kotlin Multiplatform install + launch (no hot reload) ./builder dev flutter --skip-install --bundle-id # Use already installed app ./builder dev rn --skip-install --bundle-id # Use already installed app +./builder auth apple # Save an App Store Connect API key +./builder ios upload --wait # Upload dist/*.ipa to App Store Connect, wait for processing +./builder ios submit --testflight --group --notes # TestFlight +./builder ios submit --app-store --release after-approval # App Review ``` ## Architecture @@ -100,6 +105,27 @@ builder dev kmp ─────────► Connects to MobAI │ ▼ Launches app and streams output (no hot reload) + +builder ios upload ──────► Reads bundle ID / version / build number from dist/*.ipa + │ + ▼ + App Store Connect API (ES256 JWT from the .p8 key) + ├─ apps?filter[bundleId] + ├─ POST buildUploads → POST buildUploadFiles + ├─ PUT chunks to presigned URLs + ├─ PATCH buildUploadFiles uploaded=true + └─ --wait: poll buildUploads state, then builds → VALID + │ + ▼ + PATCH builds usesNonExemptEncryption (plist / --no-encryption) + +builder ios submit ──────► Picks the newest VALID build (or --build-number) + ├─ --testflight: betaBuildLocalizations (notes), + │ betaAppReviewSubmissions (external groups), + │ builds/{id}/relationships/betaGroups + └─ --app-store: appStoreVersions (find/create, attach + build, releaseType), reviewSubmissions + + reviewSubmissionItems, PATCH submitted=true ``` ### Module Layout @@ -107,8 +133,11 @@ builder dev kmp ─────────► Connects to MobAI ``` cmd/builder/ # CLI entrypoint (Cobra) internal/ - auth/ # GitHub OAuth device flow + keyring storage + auth/ # GitHub OAuth device flow + keyring storage (also CI tokens, ASC API key) github/ # GitHub REST API (workflow dispatch, artifacts) + asc/ # App Store Connect API client (JWT, JSON:API, builds, uploads, TestFlight, review) + distribute/ # Upload / TestFlight / App Store flows on top of asc + ipa/ # Info.plist reading from .ipa archives build/ # Build coordination (snapshot + trigger + poll + download) signing/ # CSR generation and .p12 assembly (signing without a Mac) snapshot/ # Working-tree snapshot as a throwaway commit on a remote ref @@ -169,6 +198,24 @@ internal/ CLI calls KMP but the runner does not gets no JDK, and vice versa. - **KMP Has No Hot Reload**: shared Kotlin compiles to a native framework at build time, so `dev kmp` only installs, launches and streams output; code changes need `ios build` +- **ASC Client** (`internal/asc`): runs locally, never on the runner; ES256 JWT (15 min, cached) + from the `.p8`, generic JSON:API plumbing (`getOne`/`getAll`/`post`/`patch`, `getAll` follows + `links.next`). 429 retries on any method, 5xx only off POST; every wait goes through `Client.sleep`. +- **ASC Credentials**: one JSON secret (`apple-asc-key`) in the keyring/file store, via the shared + `readSecret`/`writeSecret`/`deleteSecret` helpers. `ASC_ISSUER_ID`, `ASC_KEY_ID` + + `ASC_PRIVATE_KEY`|`ASC_KEY_PATH` win; a partial environment is an error. Only `auth apple` prompts. +- **Build Upload**: `buildUploads` → `buildUploadFiles` (returns `uploadOperations`) → PUT each byte + range with its `requestHeaders`, no bearer token → PATCH `uploaded=true` → poll the upload `state`, + then `builds` until VALID. The IPA must be App Store signed with an ever-higher `CFBundleVersion`. +- **Export Compliance**: a build sits in "Missing Compliance" until `usesNonExemptEncryption` is + answered; `upload --wait` PATCHes it from the plist or `--no-encryption`. The build must exist + first, so without `--wait` it falls to `submit`, which refuses unanswered builds for TestFlight. +- **Submit Order**: TestFlight is compliance → notes → `betaAppReviewSubmissions` (only for a new + external group) → add groups. App Store reuses an open `reviewSubmission`, skips an item the + version is already in, and rewrites ASC 409/422 with a "complete the metadata" hint. +- **Extension Points**: a future `ios release` composes `distribute.Upload` and + `distribute.SubmitTestFlight`, reading `asc.Client.ListBuilds` for the latest build number; the + `pkg/` wrappers do not expose `asc` yet. ## Configuration diff --git a/README.md b/README.md index d99627b..b11157a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Builder is a CLI tool for iOS development without a Mac. It uses GitHub Actions - **Flutter & React Native dev tools**: Hot reload on real iOS devices from Windows/Linux - **Simple setup**: One command to add the workflow to your repo - **Code signing**: Optional signing with your certificate and provisioning profile +- **TestFlight and App Store**: Upload builds and submit them for review through the App Store Connect API, from any platform - **Device integration**: Install and run apps via MobAI ## How It Works @@ -165,8 +166,9 @@ go build -o builder ./cmd/builder # Setup builder auth github # Authenticate with GitHub builder auth codemagic # Authenticate with Codemagic (also: bitrise) +builder auth apple # Save an App Store Connect API key builder auth status # Show which providers you are signed in to -builder auth logout [name] # Remove stored credentials +builder auth logout [name] # Remove stored credentials (github, codemagic, bitrise, apple) builder init # Set up workflows in current repo builder update # Update builder to the latest release @@ -199,8 +201,16 @@ builder mobai forward # Forward a device port builder signing csr # Create a private key + certificate signing request builder signing p12 # Assemble a .p12 from the key and Apple's certificate builder signing setup # Upload code signing secrets to GitHub + +# TestFlight and App Store (needs builder auth apple) +builder ios upload --wait # Upload ./dist/*.ipa to App Store Connect and wait for processing +builder ios submit --testflight --group "Beta Testers" --notes "What to test" +builder ios submit --app-store --release after-approval # Submit the version for App Review ``` +Every `upload`/`submit` command takes `--json` for machine-readable output and +never prompts, so agents and CI jobs can drive them. + ## Configuration `builder.json`: @@ -342,6 +352,97 @@ secrets by hand as described in the [signing and MobAI secrets guide](docs/provider-secrets.md), then set `ios.signing` to `true` yourself. +## TestFlight and App Store + +Builder uploads builds to App Store Connect and submits them to TestFlight or +App Review through the App Store Connect API, from Windows, Linux or macOS. No +Transporter, `altool` or Xcode is involved, and the API key never leaves your +machine: the CI runner only builds and signs, the upload happens locally from +the IPA in `./dist/`. + +You need: + +- A paid [Apple Developer Program](https://developer.apple.com/programs/) + membership and an app record in App Store Connect (My Apps → +) with your + bundle ID +- An IPA signed with an **Apple Distribution** certificate and an **App Store** + provisioning profile. `builder signing setup` accepts both, exactly as in the + steps above; pick those types on the portal instead of the development ones. + An IPA signed for development is rejected at upload. +- `"configuration": "Release"` under `ios` in `builder.json`: `ios build` + defaults to `Debug`, which is what the dev commands expect, not what you want + to ship. +- An App Store Connect API key: App Store Connect → Users and Access → + Integrations → App Store Connect API → Team Keys. Give it the **App Manager** + role, note the **Issuer ID** and **Key ID**, and download the + `AuthKey_.p8` file (Apple offers the download once). + +### 1. Save the API key + +```bash +builder auth apple --issuer-id 12345678-abcd-... --key-id ABC123DEFG --key AuthKey_ABC123DEFG.p8 +``` + +Flags you leave out are prompted for. Builder verifies the key against App +Store Connect and stores it like the other logins (keychain, or a `0600` file on +Linux/WSL); `builder auth status` shows it and `builder auth logout apple` +removes it. In CI or for a coding agent, set `ASC_ISSUER_ID`, `ASC_KEY_ID` and +either `ASC_PRIVATE_KEY` (the .p8 contents; literal `\n` is fine) or +`ASC_KEY_PATH` instead — they take precedence over the saved login. + +### 2. Upload the build + +```bash +builder ios build # produces a signed dist/*.ipa +builder ios upload --wait +``` + +`upload` reads the bundle ID, version and build number from the newest IPA in +`./dist/` (or `--ipa `), finds the app, uploads the archive in chunks +and, with `--wait`, follows App Store Connect until the build has finished +processing and prints its build ID and TestFlight link. Without `--wait` it +returns as soon as Apple has the file. + +Two things Apple checks on every upload: + +- **Build numbers must increase.** A second upload with the same + `CFBundleVersion` for the same version is rejected (`ITMS-90189`), so bump + it before rebuilding. +- **Export compliance.** A build shows as *Missing Compliance* in TestFlight + until you say whether it uses non-exempt encryption. If your Info.plist sets + `ITSAppUsesNonExemptEncryption` to `false`, `upload --wait` answers that + automatically; otherwise pass `--no-encryption` (here or to `submit`) when + your app only uses standard iOS encryption. + +### 3. Distribute to TestFlight + +```bash +builder ios submit --testflight --group "Beta Testers" --notes "New login flow" +``` + +This takes the newest processed build (or `--build-number N`), sets the *What +to Test* notes and adds it to the named groups (`--group` repeats). Internal +groups get the build immediately; the first external group triggers Apple's +beta review, which Builder submits for you (`--wait` follows the decision). Run +it without `--group` to see the build and the groups the app has. + +### 4. Submit to the App Store + +```bash +builder ios submit --app-store --release after-approval +``` + +Builder finds or creates the App Store version matching the IPA's marketing +version (or `--version X.Y.Z`), attaches the build, sets the release type +(`manual` or `after-approval`) and submits it for review. The version's +metadata — description, screenshots, age rating, pricing, privacy — must +already be complete: App Store Connect refuses the submission otherwise and +Builder prints Apple's reasons verbatim. Builder does not manage metadata, +screenshots or in-app purchases; fill them in App Store Connect, or on a Mac +with [asc-cli](https://github.com/tddworks/asc-cli), whose production use of +the `buildUploads` API also proved that the Mac-free upload path works and +served as the reference for Builder's implementation. + ## Installing the IPA Use [MobAI](https://mobai.run) to install your IPA directly on your device. It works with both signed and unsigned builds: an unsigned IPA can be re-signed on install with a free Apple ID (MobAI asks for the account). diff --git a/cmd/builder/auth.go b/cmd/builder/auth.go index fcea181..b7be08a 100644 --- a/cmd/builder/auth.go +++ b/cmd/builder/auth.go @@ -2,14 +2,17 @@ package main import ( "context" + "errors" "fmt" "io" "os" "strings" + "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/auth" "github.com/MobAI-App/ios-builder/internal/ci" "github.com/spf13/cobra" + "golang.org/x/term" ) var authCmd = &cobra.Command{ @@ -24,8 +27,24 @@ var authGitHubCmd = &cobra.Command{ RunE: runAuthGitHub, } +var authAppleCmd = &cobra.Command{ + Use: "apple", + Short: "Authenticate with App Store Connect (API key)", + Long: `Saves an App Store Connect API key for builder ios upload and builder ios submit. + +Create the key in App Store Connect under Users and Access → Integrations → +App Store Connect API (Team key, role App Manager or Admin). Note the Issuer ID +and Key ID shown there and download the AuthKey_.p8 file; Apple lets you +download it only once. + +Flags left out are prompted for. In CI, set ASC_ISSUER_ID, ASC_KEY_ID and +ASC_PRIVATE_KEY (or ASC_KEY_PATH) instead; they take precedence over the saved login.`, + Args: cobra.NoArgs, + RunE: runAuthApple, +} + var authLogoutCmd = &cobra.Command{ - Use: "logout [github|codemagic|bitrise]", + Use: "logout [github|codemagic|bitrise|apple]", Args: cobra.MaximumNArgs(1), Short: "Remove stored credentials", RunE: runAuthLogout, @@ -39,6 +58,10 @@ func init() { cmd.Flags().Bool("token-stdin", false, "Read API token from stdin instead of a hidden-input prompt") authCmd.AddCommand(cmd) } + authAppleCmd.Flags().String("issuer-id", "", "Issuer ID from App Store Connect → Users and Access → Integrations") + authAppleCmd.Flags().String("key-id", "", "Key ID of the API key") + authAppleCmd.Flags().String("key", "", "Path to the AuthKey_.p8 private key") + authCmd.AddCommand(authAppleCmd) authCmd.AddCommand(&cobra.Command{Use: "status", Short: "Show login availability for all providers", Args: cobra.NoArgs, RunE: runAuthStatus}) } @@ -69,7 +92,10 @@ func runAuthLogout(cmd *cobra.Command, args []string) error { return err } fmt.Printf("Removed saved %s login\n", provider) - if provider != "github" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "" { + switch { + case provider == "apple" && os.Getenv("ASC_ISSUER_ID") != "": + fmt.Println("ASC_* environment variables are still set; unset them in your shell to stop using them.") + case provider != "github" && provider != "apple" && os.Getenv(strings.ToUpper(provider)+"_API_TOKEN") != "": fmt.Println("An environment token is still set; unset it in your shell to stop using it.") } return nil @@ -110,6 +136,58 @@ func runAuthProvider(cmd *cobra.Command, _ []string) error { return nil } +func runAuthApple(cmd *cobra.Command, _ []string) error { + issuerID, _ := cmd.Flags().GetString("issuer-id") + keyID, _ := cmd.Flags().GetString("key-id") + keyPath, _ := cmd.Flags().GetString("key") + if issuerID == "" || keyID == "" || keyPath == "" { + if stdin, ok := cmd.InOrStdin().(*os.File); !ok || !term.IsTerminal(int(stdin.Fd())) { + return fmt.Errorf("--issuer-id, --key-id and --key are required without a terminal (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_KEY_PATH)") + } + fmt.Println("App Store Connect → Users and Access → Integrations → App Store Connect API") + var err error + if issuerID == "" { + if issuerID, err = promptString("Issuer ID", ""); err != nil { + return err + } + } + if keyID == "" { + if keyID, err = promptString("Key ID", ""); err != nil { + return err + } + } + if keyPath == "" { + if keyPath, err = promptString("Path to AuthKey_"+keyID+".p8", ""); err != nil { + return err + } + } + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return fmt.Errorf("read private key: %w", err) + } + creds := auth.AppleCredentials{IssuerID: strings.TrimSpace(issuerID), KeyID: strings.TrimSpace(keyID), PrivateKey: auth.NormalizePEM(string(keyPEM))} + client, err := asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey}) + if err != nil { + return err + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + if err := client.CheckAccess(ctx); err != nil { + return fmt.Errorf("the key was rejected by App Store Connect: %w", err) + } + if err := auth.StoreAppleCredentials(creds); err != nil { + return err + } + fmt.Printf("Verified and saved App Store Connect API key %s.\n", creds.KeyID) + if os.Getenv("ASC_ISSUER_ID") != "" { + fmt.Println("ASC_* environment variables are set and take precedence over this saved login.") + } + return nil +} + func runAuthStatus(_ *cobra.Command, _ []string) error { for _, name := range []string{"github", "codemagic", "bitrise"} { _, err := auth.GetProviderToken(name) @@ -119,5 +197,16 @@ func runAuthStatus(_ *cobra.Command, _ []string) error { } fmt.Printf("%s: %s\n", name, state) } + creds, source, err := auth.GetAppleCredentials() + switch { + case errors.Is(err, auth.ErrNotAuthenticated): + fmt.Println("apple: not logged in") + case err != nil: + fmt.Printf("apple: %v\n", err) + case source == auth.AppleSourceEnv: + fmt.Printf("apple: login available from ASC_* environment (key %s, not checked remotely)\n", creds.KeyID) + default: + fmt.Printf("apple: login available (key %s, not checked remotely)\n", creds.KeyID) + } return nil } diff --git a/cmd/builder/submit.go b/cmd/builder/submit.go new file mode 100644 index 0000000..c3e664a --- /dev/null +++ b/cmd/builder/submit.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/spf13/cobra" +) + +var iosSubmitCmd = &cobra.Command{ + Use: "submit", + Short: "Hand a processed build to TestFlight groups or App Review", + Long: `Distributes a build that App Store Connect has already processed. + + --testflight adds the build to the named TestFlight groups (--group, repeatable), + sets the "What to Test" notes (--notes) and, for external groups, + submits the build for beta review. Without --group it reports the + build and lists the available groups. + --app-store finds or creates the App Store version for the marketing version, + attaches the build, sets the release type and submits it for review. + The version's metadata (description, screenshots, pricing, privacy) + must already be complete in App Store Connect. + +The app is identified by the IPA in ./dist (or --ipa), or by --bundle-id. The +newest VALID build is used unless --build-number is given.`, + Args: cobra.NoArgs, + RunE: runIOSSubmit, +} + +func init() { + iosSubmitCmd.Flags().Bool("testflight", false, "Distribute to TestFlight") + iosSubmitCmd.Flags().Bool("app-store", false, "Submit an App Store version for review") + iosSubmitCmd.Flags().String("ipa", "", "IPA whose bundle ID and version identify the app (default: newest .ipa in ./dist)") + iosSubmitCmd.Flags().String("bundle-id", "", "App bundle ID, instead of reading an IPA") + iosSubmitCmd.Flags().String("build-number", "", "Build number (CFBundleVersion) to use (default: newest VALID build)") + iosSubmitCmd.Flags().String("version", "", "Marketing version (default: from the IPA; required with --app-store and --bundle-id)") + iosSubmitCmd.Flags().StringArray("group", nil, "TestFlight group name to add the build to (repeatable)") + iosSubmitCmd.Flags().String("notes", "", "What to Test notes for the build") + iosSubmitCmd.Flags().String("locale", "", "Locale for --notes (default: the app's primary locale)") + iosSubmitCmd.Flags().String("release", "", "App Store release: manual or after-approval") + iosSubmitCmd.Flags().Bool("no-encryption", false, "Declare the app uses no non-exempt encryption (export compliance)") + iosSubmitCmd.Flags().Bool("wait", false, "Wait for the external beta review decision (--testflight)") + iosSubmitCmd.Flags().Duration("timeout", 30*time.Minute, "Give up waiting after this long") + iosSubmitCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + iosCmd.AddCommand(iosSubmitCmd) +} + +func runIOSSubmit(cmd *cobra.Command, _ []string) error { + testflight, _ := cmd.Flags().GetBool("testflight") + appStore, _ := cmd.Flags().GetBool("app-store") + if testflight == appStore { + return fmt.Errorf("pass exactly one of --testflight or --app-store") + } + client, err := getASCClient() + if err != nil { + return err + } + bundleID, _ := cmd.Flags().GetString("bundle-id") + version, _ := cmd.Flags().GetString("version") + if bundleID == "" { + ipaPath, _ := cmd.Flags().GetString("ipa") + if ipaPath, err = resolveIPA(ipaPath); err != nil { + return fmt.Errorf("%w (or pass --bundle-id)", err) + } + info, err := ipa.ReadInfo(ipaPath) + if err != nil { + return err + } + bundleID = info.BundleID + if version == "" && appStore { + version = info.Version + } + } + buildNumber, _ := cmd.Flags().GetString("build-number") + noEncryption, _ := cmd.Flags().GetBool("no-encryption") + wait, _ := cmd.Flags().GetBool("wait") + ctx, cancel := commandContext(cmd, wait) + defer cancel() + out := newOutput(cmd) + + if testflight { + groups, _ := cmd.Flags().GetStringArray("group") + notes, _ := cmd.Flags().GetString("notes") + locale, _ := cmd.Flags().GetString("locale") + res, err := distribute.SubmitTestFlight(ctx, client, &distribute.TestFlightOptions{ + BundleID: bundleID, Version: version, BuildNumber: buildNumber, Groups: groups, Notes: notes, Locale: locale, + NoEncryption: noEncryption, Wait: wait, Log: out.log, + }) + return finish(out, cmd, res, err, func() { + fmt.Println() + fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) + if res.BetaReview != nil { + fmt.Printf("Beta review: %s\n", res.BetaReview.State) + } + fmt.Printf("Link: %s\n", res.Link) + }) + } + + releaseFlag, _ := cmd.Flags().GetString("release") + releaseType, err := parseReleaseType(releaseFlag) + if err != nil { + return err + } + res, err := distribute.SubmitAppStore(ctx, client, &distribute.AppStoreOptions{ + BundleID: bundleID, Version: version, BuildNumber: buildNumber, ReleaseType: releaseType, NoEncryption: noEncryption, Log: out.log, + }) + return finish(out, cmd, res, err, func() { + fmt.Println() + fmt.Printf("Version: %s (%s)\n", res.Version.VersionString, res.Version.State) + fmt.Printf("Build ID: %s (build %s)\n", res.Build.ID, res.Build.BuildNumber) + fmt.Printf("Submission: %s (%s)\n", res.Submission.ID, res.Submission.State) + fmt.Printf("Link: %s\n", res.Link) + }) +} + +func parseReleaseType(flag string) (string, error) { + switch flag { + case "": + return "", nil + case "manual": + return asc.ReleaseTypeManual, nil + case "after-approval": + return asc.ReleaseTypeAfterApproval, nil + } + return "", fmt.Errorf("--release must be manual or after-approval, got %q", flag) +} diff --git a/cmd/builder/submit_test.go b/cmd/builder/submit_test.go new file mode 100644 index 0000000..dedd396 --- /dev/null +++ b/cmd/builder/submit_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestParseReleaseType(t *testing.T) { + for flag, want := range map[string]string{"": "", "manual": "MANUAL", "after-approval": "AFTER_APPROVAL"} { + if got, err := parseReleaseType(flag); err != nil || got != want { + t.Errorf("%q: %q %v", flag, got, err) + } + } + if _, err := parseReleaseType("scheduled"); err == nil { + t.Error("unknown release type accepted") + } +} diff --git a/cmd/builder/upload.go b/cmd/builder/upload.go new file mode 100644 index 0000000..606c211 --- /dev/null +++ b/cmd/builder/upload.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/auth" + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/spf13/cobra" +) + +var iosUploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload the IPA to App Store Connect", + Long: `Uploads an IPA to App Store Connect through the API, from any platform: no +Mac, Transporter or altool involved. The IPA must be signed with an Apple +Distribution certificate and an App Store provisioning profile. + +The bundle ID, version and build number are read from the IPA. With --wait the +command follows processing until the build is usable, and answers the export +compliance question when Info.plist declares ITSAppUsesNonExemptEncryption +false (or --no-encryption is given), so the build does not sit in "Missing +Compliance". + +Needs an App Store Connect API key: builder auth apple.`, + Args: cobra.NoArgs, + RunE: runIOSUpload, +} + +func init() { + iosUploadCmd.Flags().String("ipa", "", "IPA to upload (default: newest .ipa in ./dist)") + iosUploadCmd.Flags().Bool("wait", false, "Wait until App Store Connect has processed the build") + iosUploadCmd.Flags().Duration("timeout", 30*time.Minute, "Give up waiting after this long") + iosUploadCmd.Flags().Bool("no-encryption", false, "Declare the app uses no non-exempt encryption (export compliance)") + iosUploadCmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)") + iosCmd.AddCommand(iosUploadCmd) +} + +func getASCClient() (*asc.Client, error) { + creds, _, err := auth.GetAppleCredentials() + if err != nil { + if errors.Is(err, auth.ErrNotAuthenticated) { + return nil, fmt.Errorf("no App Store Connect API key configured. Run: builder auth apple (or set ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH)") + } + return nil, err + } + return asc.NewClient(asc.Credentials{IssuerID: creds.IssuerID, KeyID: creds.KeyID, PrivateKey: creds.PrivateKey}) +} + +func resolveIPA(path string) (string, error) { + if path != "" { + return path, nil + } + return ipa.Newest("dist") +} + +// commandContext cancels on Ctrl-C and, when waiting, after --timeout. +func commandContext(cmd *cobra.Command, wait bool) (context.Context, context.CancelFunc) { + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + if !wait { + return ctx, stop + } + timeout, _ := cmd.Flags().GetDuration("timeout") + ctx, cancel := context.WithTimeout(ctx, timeout) + return ctx, func() { cancel(); stop() } +} + +// output separates human progress from the machine-readable result. +type output struct { + json bool + log io.Writer +} + +func newOutput(cmd *cobra.Command) output { + asJSON, _ := cmd.Flags().GetBool("json") + if asJSON { + return output{json: true, log: cmd.ErrOrStderr()} + } + return output{log: cmd.OutOrStdout()} +} + +// finish prints the result (JSON, or the human summary on success) and +// returns err with a timeout translated into something actionable. A partial +// result on failure is still printed as JSON so agents see how far it got. +func finish[T any](o output, cmd *cobra.Command, result *T, err error, human func()) error { + if o.json && result != nil { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + _ = enc.Encode(result) + } + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("timed out waiting for App Store Connect; processing continues server-side, check later with builder ios submit --testflight or raise --timeout") + } + return err + } + if !o.json && human != nil { + human() + } + return nil +} + +func runIOSUpload(cmd *cobra.Command, _ []string) error { + client, err := getASCClient() + if err != nil { + return err + } + ipaPath, _ := cmd.Flags().GetString("ipa") + if ipaPath, err = resolveIPA(ipaPath); err != nil { + return err + } + wait, _ := cmd.Flags().GetBool("wait") + noEncryption, _ := cmd.Flags().GetBool("no-encryption") + ctx, cancel := commandContext(cmd, wait) + defer cancel() + out := newOutput(cmd) + + res, err := distribute.Upload(ctx, client, &distribute.UploadOptions{IPAPath: ipaPath, Wait: wait, NoEncryption: noEncryption, Log: out.log}) + return finish(out, cmd, res, err, func() { + fmt.Println() + fmt.Printf("Upload ID: %s (%s)\n", res.Upload.ID, res.Upload.State) + if res.Build != nil { + fmt.Printf("Build ID: %s (build %s, %s)\n", res.Build.ID, res.Build.BuildNumber, res.Build.ProcessingState) + } else { + fmt.Println("Processing continues in App Store Connect; rerun with --wait to follow it.") + } + fmt.Printf("Link: %s\n", res.Link) + }) +} diff --git a/internal/asc/apps.go b/internal/asc/apps.go new file mode 100644 index 0000000..d9cf135 --- /dev/null +++ b/internal/asc/apps.go @@ -0,0 +1,49 @@ +package asc + +import ( + "context" + "fmt" + "net/url" +) + +// App is an App Store Connect app record. +type App struct { + ID string + BundleID string + Name string + SKU string + PrimaryLocale string +} + +type appAttributes struct { + BundleID string `json:"bundleId,omitempty"` + Name string `json:"name,omitempty"` + SKU string `json:"sku,omitempty"` + PrimaryLocale string `json:"primaryLocale,omitempty"` +} + +func toApp(r Resource[appAttributes]) App { + return App{ID: r.ID, BundleID: r.Attributes.BundleID, Name: r.Attributes.Name, SKU: r.Attributes.SKU, PrimaryLocale: r.Attributes.PrimaryLocale} +} + +// AppByBundleID finds the app record for a bundle identifier. +func (c *Client) AppByBundleID(ctx context.Context, bundleID string) (*App, error) { + // The filter may match more than the exact ID, so page through and compare. + apps, err := getAll[appAttributes](ctx, c, "/v1/apps", url.Values{"filter[bundleId]": {bundleID}}) + if err != nil { + return nil, err + } + for _, r := range apps { + if r.Attributes.BundleID == bundleID { + app := toApp(r) + return &app, nil + } + } + return nil, fmt.Errorf("no App Store Connect app has bundle ID %s; create the app record in App Store Connect (My Apps → +) with that bundle ID first, and check the API key can see it", bundleID) +} + +// CheckAccess makes the cheapest authenticated call to verify the key works. +func (c *Client) CheckAccess(ctx context.Context) error { + _, err := getPage[appAttributes](ctx, c, "/v1/apps", url.Values{"limit": {"1"}}) + return err +} diff --git a/internal/asc/builds.go b/internal/asc/builds.go new file mode 100644 index 0000000..6c1451e --- /dev/null +++ b/internal/asc/builds.go @@ -0,0 +1,141 @@ +package asc + +import ( + "context" + "net/url" + "strconv" + "time" +) + +// PlatformIOS is the App Store Connect platform value for iOS. +const PlatformIOS = "IOS" + +// Build processing states. +const ( + ProcessingStateProcessing = "PROCESSING" + ProcessingStateFailed = "FAILED" + ProcessingStateInvalid = "INVALID" + ProcessingStateValid = "VALID" +) + +// Build is a processed (or processing) build of an app. +type Build struct { + ID string + BuildNumber string // CFBundleVersion; ASC calls it "version" + ProcessingState string + UploadedDate time.Time + ExpirationDate time.Time + Expired bool + MinOSVersion string + // UsesNonExemptEncryption is nil while the export compliance question is + // unanswered ("Missing Compliance" in TestFlight). + UsesNonExemptEncryption *bool +} + +type buildAttributes struct { + Version string `json:"version,omitempty"` + UploadedDate *time.Time `json:"uploadedDate,omitempty"` + ExpirationDate *time.Time `json:"expirationDate,omitempty"` + Expired *bool `json:"expired,omitempty"` + MinOsVersion string `json:"minOsVersion,omitempty"` + ProcessingState string `json:"processingState,omitempty"` + UsesNonExemptEncryption *bool `json:"usesNonExemptEncryption,omitempty"` +} + +func toBuild(r Resource[buildAttributes]) Build { + b := Build{ + ID: r.ID, + BuildNumber: r.Attributes.Version, + ProcessingState: r.Attributes.ProcessingState, + MinOSVersion: r.Attributes.MinOsVersion, + UsesNonExemptEncryption: r.Attributes.UsesNonExemptEncryption, + } + if r.Attributes.UploadedDate != nil { + b.UploadedDate = *r.Attributes.UploadedDate + } + if r.Attributes.ExpirationDate != nil { + b.ExpirationDate = *r.Attributes.ExpirationDate + } + if r.Attributes.Expired != nil { + b.Expired = *r.Attributes.Expired + } + return b +} + +// BuildFilter narrows ListBuilds. Empty fields are not filtered on. +type BuildFilter struct { + AppID string + Platform string // e.g. PlatformIOS + Version string // marketing version (CFBundleShortVersionString) + BuildNumber string // CFBundleVersion + ProcessingState string + // ExcludeExpired drops builds past their 90-day TestFlight life. + ExcludeExpired bool + // Limit caps the result to the newest N builds; 0 returns every match. + Limit int +} + +// ListBuilds lists builds, newest first. +func (c *Client) ListBuilds(ctx context.Context, f *BuildFilter) ([]Build, error) { + q := url.Values{"sort": {"-uploadedDate"}} + if f.AppID != "" { + q.Set("filter[app]", f.AppID) + } + if f.Platform != "" { + q.Set("filter[preReleaseVersion.platform]", f.Platform) + } + if f.Version != "" { + q.Set("filter[preReleaseVersion.version]", f.Version) + } + if f.BuildNumber != "" { + q.Set("filter[version]", f.BuildNumber) + } + if f.ProcessingState != "" { + q.Set("filter[processingState]", f.ProcessingState) + } + if f.ExcludeExpired { + q.Set("filter[expired]", "false") + } + var rs []Resource[buildAttributes] + var err error + if f.Limit > 0 { + q.Set("limit", strconv.Itoa(f.Limit)) + rs, err = getPage[buildAttributes](ctx, c, "/v1/builds", q) + } else { + rs, err = getAll[buildAttributes](ctx, c, "/v1/builds", q) + } + if err != nil { + return nil, err + } + builds := make([]Build, 0, len(rs)) + for _, r := range rs { + builds = append(builds, toBuild(r)) + } + return builds, nil +} + +// GetBuild fetches one build. +func (c *Client) GetBuild(ctx context.Context, id string) (*Build, error) { + r, err := getOne[buildAttributes](ctx, c, "/v1/builds/"+id, nil) + if err != nil { + return nil, err + } + b := toBuild(*r) + return &b, nil +} + +// SetUsesNonExemptEncryption answers the export compliance question for a build. +func (c *Client) SetUsesNonExemptEncryption(ctx context.Context, buildID string, uses bool) (*Build, error) { + req := Resource[buildAttributes]{Type: "builds", ID: buildID, Attributes: buildAttributes{UsesNonExemptEncryption: &uses}} + r, err := patch[buildAttributes, buildAttributes](ctx, c, "/v1/builds/"+buildID, req) + if err != nil { + return nil, err + } + b := toBuild(*r) + return &b, nil +} + +// AddBuildToBetaGroups makes the build available to the given TestFlight groups. +func (c *Client) AddBuildToBetaGroups(ctx context.Context, buildID string, groupIDs []string) error { + return c.Post(ctx, "/v1/builds/"+buildID+"/relationships/betaGroups", ToMany("betaGroups", groupIDs), nil) +} diff --git a/internal/asc/client.go b/internal/asc/client.go new file mode 100644 index 0000000..6607d37 --- /dev/null +++ b/internal/asc/client.go @@ -0,0 +1,277 @@ +package asc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DefaultBaseURL is the production App Store Connect API endpoint. +const DefaultBaseURL = "https://api.appstoreconnect.apple.com" + +// Client talks to the App Store Connect API. +type Client struct { + baseURL string + http *http.Client + upload *http.Client + tokens *tokenSource + retryDelay time.Duration + maxRetries int + // sleep waits between retries and polls; tests replace it. + sleep func(context.Context, time.Duration) error +} + +// Option configures a Client. +type Option func(*Client) + +// WithBaseURL points the client at another server, e.g. a test server. +func WithBaseURL(baseURL string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(baseURL, "/") } +} + +// WithHTTPClient replaces the HTTP client used for API calls. +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { c.http = h } +} + +// WithRetryDelay sets the base delay of the exponential backoff on 429/5xx. +func WithRetryDelay(d time.Duration) Option { + return func(c *Client) { c.retryDelay = d } +} + +// NewClient validates the credentials and returns a client. No network call is made. +func NewClient(creds Credentials, opts ...Option) (*Client, error) { + tokens, err := newTokenSource(creds) + if err != nil { + return nil, fmt.Errorf("App Store Connect credentials: %w", err) + } + c := &Client{ + baseURL: DefaultBaseURL, + http: &http.Client{Timeout: 60 * time.Second}, + // Chunk PUTs go to Apple's storage, not the API; large chunks on a + // slow uplink can legitimately take minutes. + upload: &http.Client{Timeout: 15 * time.Minute}, + tokens: tokens, + retryDelay: time.Second, + maxRetries: 3, + sleep: sleep, + } + for _, opt := range opts { + opt(c) + } + return c, nil +} + +// Error is an error response from App Store Connect. +type Error struct { + StatusCode int + Method string + Path string + Errors []ErrorDetail + RetryAfter time.Duration +} + +// ErrorDetail is one entry of the JSON:API errors array. +type ErrorDetail struct { + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Code string `json:"code,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Source *ErrorSource `json:"source,omitempty"` +} + +// ErrorSource points at the request field or parameter an error refers to. +type ErrorSource struct { + Pointer string `json:"pointer,omitempty"` + Parameter string `json:"parameter,omitempty"` +} + +// Error renders the status and every ASC error on one line. +func (e *Error) Error() string { + var b strings.Builder + fmt.Fprintf(&b, "App Store Connect %s %s: HTTP %d", e.Method, e.Path, e.StatusCode) + for i, d := range e.Errors { + if i == 0 { + b.WriteString(": ") + } else { + b.WriteString("; ") + } + b.WriteString(d.String()) + } + return b.String() +} + +// String renders one error as "CODE: title (detail)". +func (d ErrorDetail) String() string { + var parts []string + if d.Code != "" { + parts = append(parts, d.Code) + } + if d.Title != "" { + parts = append(parts, d.Title) + } + s := strings.Join(parts, ": ") + if d.Detail != "" && d.Detail != d.Title { + if s != "" { + s += " (" + d.Detail + ")" + } else { + s = d.Detail + } + } + if d.Source != nil && d.Source.Pointer != "" { + s += " [" + d.Source.Pointer + "]" + } + return strings.ReplaceAll(s, "\n", " ") +} + +// IsStatus reports whether err is an App Store Connect error with the given HTTP status. +func IsStatus(err error, status int) bool { + var e *Error + return errors.As(err, &e) && e.StatusCode == status +} + +// Get performs a GET. path is relative to the base URL ("/v1/apps") or an +// absolute URL such as a pagination link; query is appended when non-nil. +func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error { + return c.do(ctx, http.MethodGet, path, query, nil, out) +} + +// Post performs a POST with a JSON body. +func (c *Client) Post(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPost, path, nil, body, out) +} + +// Patch performs a PATCH with a JSON body. +func (c *Client) Patch(ctx context.Context, path string, body, out any) error { + return c.do(ctx, http.MethodPatch, path, nil, body, out) +} + +// Delete performs a DELETE, with an optional JSON body (relationship removals). +func (c *Client) Delete(ctx context.Context, path string, body any) error { + return c.do(ctx, http.MethodDelete, path, nil, body, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error { + var payload []byte + if body != nil { + var err error + if payload, err = json.Marshal(body); err != nil { + return fmt.Errorf("encode request: %w", err) + } + } + for attempt := 0; ; attempt++ { + err := c.once(ctx, method, path, query, payload, out) + var apiErr *Error + if err == nil || attempt >= c.maxRetries || !errors.As(err, &apiErr) || !retryable(method, apiErr.StatusCode) { + return err + } + delay := c.retryDelay << attempt + if apiErr.RetryAfter > delay { + delay = apiErr.RetryAfter + } + if err := c.sleep(ctx, delay); err != nil { + return err + } + } +} + +func sleep(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// retryable: 429 was not processed, so any method may retry. A 5xx on a POST +// may have created the resource already, so only idempotent methods retry. +func retryable(method string, status int) bool { + if status == http.StatusTooManyRequests { + return true + } + return status >= 500 && status <= 599 && method != http.MethodPost +} + +func (c *Client) once(ctx context.Context, method, path string, query url.Values, payload []byte, out any) error { + target := path + if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { + target = c.baseURL + path + } + if len(query) > 0 { + sep := "?" + if strings.Contains(target, "?") { + sep = "&" + } + target += sep + query.Encode() + } + var bodyReader io.Reader + if payload != nil { + bodyReader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, target, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + token, err := c.tokens.Token() + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("App Store Connect request failed: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= 400 { + return decodeError(method, path, resp, data) + } + if out != nil && len(bytes.TrimSpace(data)) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} + +func decodeError(method, path string, resp *http.Response, data []byte) *Error { + e := &Error{StatusCode: resp.StatusCode, Method: method, Path: strings.SplitN(path, "?", 2)[0]} + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil && secs > 0 { + e.RetryAfter = time.Duration(secs) * time.Second + } + } + var body struct { + Errors []ErrorDetail `json:"errors"` + } + if json.Unmarshal(data, &body) == nil && len(body.Errors) > 0 { + e.Errors = body.Errors + return e + } + if text := strings.TrimSpace(string(data)); text != "" { + if len(text) > 200 { + text = text[:200] + "..." + } + e.Errors = []ErrorDetail{{Title: http.StatusText(resp.StatusCode), Detail: text}} + } + return e +} diff --git a/internal/asc/client_test.go b/internal/asc/client_test.go new file mode 100644 index 0000000..e893021 --- /dev/null +++ b/internal/asc/client_test.go @@ -0,0 +1,339 @@ +package asc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// newTestClient returns a client pointed at srv with fast retries. +func newTestClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + creds, _ := testCredentials(t) + c, err := NewClient(creds, WithBaseURL(srv.URL), WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// obj walks decoded JSON down the given object keys; a missing or non-object +// step fails the test and yields nil, which later lookups tolerate. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// arr is obj for a final array value. +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + if len(keys) > 0 { + v = obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]] + } + a, ok := v.([]any) + if !ok { + t.Errorf("JSON path %v: %T is not an array", keys, v) + } + return a +} + +// recordSleeps makes the client's waits instant and returns the requested durations. +func recordSleeps(c *Client) *[]time.Duration { + var slept []time.Duration + c.sleep = func(_ context.Context, d time.Duration) error { + slept = append(slept, d) + return nil + } + return &slept +} + +func TestGetSendsBearerTokenAndDecodes(t *testing.T) { + var authz string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authz = r.Header.Get("Authorization") + if r.URL.Path != "/v1/apps" || r.URL.Query().Get("filter[bundleId]") != "com.example.app" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{ + "type": "apps", "id": "app-1", + "attributes": map[string]any{"bundleId": "com.example.app", "name": "Example", "primaryLocale": "en-US"}, + }}}) + })) + defer srv.Close() + c := newTestClient(t, srv) + app, err := c.AppByBundleID(context.Background(), "com.example.app") + if err != nil { + t.Fatal(err) + } + if app.ID != "app-1" || app.Name != "Example" || app.PrimaryLocale != "en-US" { + t.Errorf("app = %+v", app) + } + if !strings.HasPrefix(authz, "Bearer ") || strings.Count(authz, ".") != 2 { + t.Errorf("Authorization = %q", authz) + } +} + +func TestAppByBundleIDNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + _, err := newTestClient(t, srv).AppByBundleID(context.Background(), "com.missing") + if err == nil || !strings.Contains(err.Error(), "com.missing") { + t.Errorf("err = %v", err) + } +} + +func TestErrorDecoding(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 409, map[string]any{"errors": []map[string]any{ + {"id": "x", "status": "409", "code": "STATE_ERROR.ENTITY_STATE_INVALID", "title": "Invalid state", "detail": "Metadata is missing.", "source": map[string]string{"pointer": "/data/relationships/build"}}, + {"status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.REQUIRED", "title": "Attribute required", "detail": "Attribute required"}, + }}) + })) + defer srv.Close() + err := newTestClient(t, srv).Post(context.Background(), "/v1/reviewSubmissions", map[string]any{}, nil) + var apiErr *Error + if !errors.As(err, &apiErr) { + t.Fatalf("err = %T %v", err, err) + } + if apiErr.StatusCode != 409 || len(apiErr.Errors) != 2 || apiErr.Errors[0].Code != "STATE_ERROR.ENTITY_STATE_INVALID" || !IsStatus(err, 409) { + t.Errorf("apiErr = %+v", apiErr) + } + msg := err.Error() + for _, want := range []string{"HTTP 409", "STATE_ERROR.ENTITY_STATE_INVALID: Invalid state (Metadata is missing.) [/data/relationships/build]", "; ENTITY_ERROR.ATTRIBUTE.REQUIRED: Attribute required"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q lacks %q", msg, want) + } + } + if strings.Contains(msg, "\n") { + t.Errorf("message is not one line: %q", msg) + } +} + +func TestNonJSONErrorBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(502) + _, _ = w.Write([]byte("Bad Gateway")) + })) + defer srv.Close() + err := newTestClient(t, srv).Post(context.Background(), "/v1/x", nil, nil) + if !IsStatus(err, 502) || !strings.Contains(err.Error(), "Bad Gateway") { + t.Errorf("err = %v", err) + } +} + +func TestPaginationFollowsNextLink(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("limit") != "200" || q.Get("filter[app]") != "app-1" { + t.Errorf("query = %v", q) + } + switch q.Get("cursor") { + case "": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "betaGroups", "id": "g1", "attributes": map[string]any{"name": "Internal", "isInternalGroup": true}}}, + "links": map[string]string{"next": srv.URL + "/v1/betaGroups?filter%5Bapp%5D=app-1&limit=200&cursor=abc"}, + }) + case "abc": + writeJSON(w, 200, map[string]any{ + "data": []map[string]any{{"type": "betaGroups", "id": "g2", "attributes": map[string]any{"name": "External", "isInternalGroup": false, "publicLinkEnabled": true}}}, + }) + default: + t.Errorf("unexpected cursor %q", q.Get("cursor")) + } + })) + defer srv.Close() + groups, err := newTestClient(t, srv).ListBetaGroups(context.Background(), "app-1") + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 || groups[0].Name != "Internal" || !groups[0].Internal || groups[1].Name != "External" || groups[1].Internal || !groups[1].PublicLinkEnabled { + t.Errorf("groups = %+v", groups) + } +} + +func TestRetryOn429HonorsRetryAfter(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + w.Header().Set("Retry-After", "1") + writeJSON(w, 429, map[string]any{"errors": []map[string]any{{"code": "RATE_LIMIT_EXCEEDED", "title": "Rate limit"}}}) + return + } + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "reviewSubmissions", "id": "rs-1", "attributes": map[string]any{"state": "READY_FOR_REVIEW"}}}) + })) + defer srv.Close() + c := newTestClient(t, srv) + slept := recordSleeps(c) + sub, err := c.CreateReviewSubmission(context.Background(), "app-1", PlatformIOS) + if err != nil { + t.Fatal(err) + } + if sub.ID != "rs-1" || calls.Load() != 2 { + t.Errorf("sub = %+v, calls = %d", sub, calls.Load()) + } + if len(*slept) != 1 || (*slept)[0] != time.Second { + t.Errorf("slept %v, want the 1s Retry-After over the 1ms base delay", *slept) + } +} + +func TestPollerBacksOffToCap(t *testing.T) { + c := &Client{} + slept := recordSleeps(c) + p := c.newPoller(10 * time.Second) + for range 6 { + if err := p.wait(context.Background()); err != nil { + t.Fatal(err) + } + } + want := []time.Duration{10 * time.Second, 15 * time.Second, 22500 * time.Millisecond, 33750 * time.Millisecond, 40 * time.Second, 40 * time.Second} + if len(*slept) != len(want) { + t.Fatalf("slept %v, want %v", *slept, want) + } + for i := range want { + if (*slept)[i] != want[i] { + t.Errorf("wait %d = %v, want %v", i, (*slept)[i], want[i]) + } + } +} + +func TestRetryOn5xxOnlyForIdempotentMethods(t *testing.T) { + var gets, posts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + if gets.Add(1) < 3 { + writeJSON(w, 503, map[string]any{"errors": []map[string]any{{"title": "unavailable"}}}) + return + } + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "b1", "attributes": map[string]any{"version": "7", "processingState": "VALID"}}}) + case http.MethodPost: + posts.Add(1) + writeJSON(w, 500, map[string]any{"errors": []map[string]any{{"title": "boom"}}}) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + b, err := c.GetBuild(context.Background(), "b1") + if err != nil || b.BuildNumber != "7" || b.ProcessingState != ProcessingStateValid { + t.Errorf("build = %+v, err = %v", b, err) + } + if gets.Load() != 3 { + t.Errorf("GET attempts = %d, want 3", gets.Load()) + } + if err := c.Post(context.Background(), "/v1/things", map[string]any{}, nil); !IsStatus(err, 500) { + t.Errorf("POST err = %v", err) + } + if posts.Load() != 1 { + t.Errorf("POST attempts = %d, want 1 (a 5xx POST may have created the resource)", posts.Load()) + } +} + +func TestRetryGivesUp(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeJSON(w, 503, map[string]any{"errors": []map[string]any{{"title": "unavailable"}}}) + })) + defer srv.Close() + err := newTestClient(t, srv).Get(context.Background(), "/v1/apps", url.Values{"limit": {"1"}}, nil) + if !IsStatus(err, 503) || calls.Load() != 4 { + t.Errorf("err = %v, calls = %d (want 1 + 3 retries)", err, calls.Load()) + } +} + +func TestRequestBodiesAreJSONAPI(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q", r.Header.Get("Content-Type")) + } + _ = json.NewDecoder(r.Body).Decode(&body) + switch r.URL.Path { + case "/v1/builds/b1": + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "b1", "attributes": map[string]any{"usesNonExemptEncryption": false}}}) + case "/v1/builds/b1/relationships/betaGroups": + w.WriteHeader(204) + case "/v1/betaAppReviewSubmissions": + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "betaAppReviewSubmissions", "id": "bar-1", "attributes": map[string]any{"betaReviewState": "WAITING_FOR_REVIEW"}}}) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + ctx := context.Background() + + b, err := c.SetUsesNonExemptEncryption(ctx, "b1", false) + if err != nil || b.UsesNonExemptEncryption == nil || *b.UsesNonExemptEncryption { + t.Fatalf("build = %+v, err = %v", b, err) + } + data := obj(t, body, "data") + if data["type"] != "builds" || data["id"] != "b1" || obj(t, data, "attributes")["usesNonExemptEncryption"] != false { + t.Errorf("PATCH body = %v", body) + } + + if err := c.AddBuildToBetaGroups(ctx, "b1", []string{"g1", "g2"}); err != nil { + t.Fatal(err) + } + linkages := arr(t, body, "data") + if len(linkages) != 2 || obj(t, linkages[1])["id"] != "g2" || obj(t, linkages[0])["type"] != "betaGroups" { + t.Errorf("relationship body = %v", body) + } + + sub, err := c.SubmitBuildForBetaReview(ctx, "b1") + if err != nil || sub.ID != "bar-1" || sub.State != BetaReviewWaiting { + t.Fatalf("sub = %+v, err = %v", sub, err) + } + data = obj(t, body, "data") + if _, has := data["attributes"]; has { + t.Errorf("empty attributes must be omitted: %v", body) + } + if obj(t, data, "relationships", "build", "data")["id"] != "b1" { + t.Errorf("POST body = %v", body) + } +} + +func TestBetaAppReviewSubmissionAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/builds/none/betaAppReviewSubmission": + writeJSON(w, 200, map[string]any{"data": nil}) + default: + writeJSON(w, 404, map[string]any{"errors": []map[string]any{{"code": "NOT_FOUND", "title": "not found"}}}) + } + })) + defer srv.Close() + c := newTestClient(t, srv) + for _, id := range []string{"none", "missing"} { + sub, err := c.GetBuildBetaAppReviewSubmission(context.Background(), id) + if err != nil || sub != nil { + t.Errorf("%s: sub = %+v, err = %v", id, sub, err) + } + } +} diff --git a/internal/asc/jsonapi.go b/internal/asc/jsonapi.go new file mode 100644 index 0000000..fb6d202 --- /dev/null +++ b/internal/asc/jsonapi.go @@ -0,0 +1,145 @@ +package asc + +import ( + "context" + "encoding/json" + "net/url" + "strconv" +) + +// Document is a JSON:API top-level document. T is a Resource for single +// resources and a []Resource for collections. +type Document[T any] struct { + Data T `json:"data"` + Links Links `json:"links,omitzero"` + Meta *Meta `json:"meta,omitempty"` +} + +// Links carries pagination links. +type Links struct { + Self string `json:"self,omitempty"` + Next string `json:"next,omitempty"` +} + +// Meta carries paging information on collections. +type Meta struct { + Paging struct { + Total int `json:"total"` + Limit int `json:"limit"` + } `json:"paging"` +} + +// Resource is a JSON:API resource object with typed attributes. +type Resource[A any] struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Attributes A `json:"attributes,omitzero"` + Relationships Relationships `json:"relationships,omitempty"` +} + +// Relationships maps relationship names to their linkage. +type Relationships map[string]Relationship + +// Relationship holds a to-one (object) or to-many (array) linkage. +type Relationship struct { + Data json.RawMessage `json:"data,omitempty"` +} + +// Linkage identifies a related resource. +type Linkage struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// ToOne builds a to-one relationship. +func ToOne(resourceType, id string) Relationship { + data, _ := json.Marshal(Linkage{Type: resourceType, ID: id}) + return Relationship{Data: data} +} + +// ToMany builds a to-many relationship. +func ToMany(resourceType string, ids []string) Relationship { + linkages := make([]Linkage, 0, len(ids)) + for _, id := range ids { + linkages = append(linkages, Linkage{Type: resourceType, ID: id}) + } + data, _ := json.Marshal(linkages) + return Relationship{Data: data} +} + +// One decodes a to-one linkage; ok is false when the relationship is null or absent. +func (r Relationship) One() (linkage Linkage, ok bool) { + if len(r.Data) == 0 || json.Unmarshal(r.Data, &linkage) != nil || linkage.ID == "" { + return Linkage{}, false + } + return linkage, true +} + +// One returns the named to-one linkage. +func (r Relationships) One(name string) (Linkage, bool) { + rel, ok := r[name] + if !ok { + return Linkage{}, false + } + return rel.One() +} + +// pageLimit is the largest page App Store Connect serves. +const pageLimit = 200 + +func getOne[A any](ctx context.Context, c *Client, path string, query url.Values) (*Resource[A], error) { + var doc Document[Resource[A]] + if err := c.Get(ctx, path, query, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} + +// getAll fetches a collection, following links.next until exhausted. +func getAll[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { + if query == nil { + query = url.Values{} + } + if query.Get("limit") == "" { + query.Set("limit", strconv.Itoa(pageLimit)) + } + var all []Resource[A] + next := path + for { + var doc Document[[]Resource[A]] + if err := c.Get(ctx, next, query, &doc); err != nil { + return nil, err + } + all = append(all, doc.Data...) + if doc.Links.Next == "" { + return all, nil + } + // The next link already carries the filters and cursor. + next, query = doc.Links.Next, nil + } +} + +// getPage fetches one page of a collection without following links. +func getPage[A any](ctx context.Context, c *Client, path string, query url.Values) ([]Resource[A], error) { + var doc Document[[]Resource[A]] + if err := c.Get(ctx, path, query, &doc); err != nil { + return nil, err + } + return doc.Data, nil +} + +func post[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { + var doc Document[Resource[Resp]] + if err := c.Post(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} + +func patch[Req, Resp any](ctx context.Context, c *Client, path string, req Resource[Req]) (*Resource[Resp], error) { + var doc Document[Resource[Resp]] + if err := c.Patch(ctx, path, Document[Resource[Req]]{Data: req}, &doc); err != nil { + return nil, err + } + return &doc.Data, nil +} diff --git a/internal/asc/jwt.go b/internal/asc/jwt.go new file mode 100644 index 0000000..f952ad7 --- /dev/null +++ b/internal/asc/jwt.go @@ -0,0 +1,147 @@ +// Package asc is a client for the App Store Connect API. +// +// It runs on the developer's machine (or a CI agent) rather than on the macOS +// runner, authenticating with an App Store Connect API key: no Mac, altool or +// Transporter is involved. +package asc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +// Credentials is an App Store Connect API key: the team's issuer ID, the key +// ID and the .p8 private key as downloaded from App Store Connect (PEM). +type Credentials struct { + IssuerID string + KeyID string + PrivateKey string +} + +// Validate checks that every field is present and that the key is a P-256 key. +func (c Credentials) Validate() error { + _, err := newTokenSource(c) + return err +} + +// ParsePrivateKey parses the PEM .p8 key App Store Connect issues (PKCS#8 or +// SEC 1 encoded) and checks it is usable for ES256. +func ParsePrivateKey(pemKey string) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(strings.TrimSpace(pemKey))) + if block == nil { + return nil, errors.New("private key is not PEM encoded (expected the .p8 file contents)") + } + var key any + var err error + switch block.Type { + case "PRIVATE KEY": + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(block.Bytes) + default: + return nil, fmt.Errorf("unsupported PEM block %q", block.Type) + } + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + ecKey, ok := key.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not an EC key; App Store Connect API keys are P-256") + } + if ecKey.Curve != elliptic.P256() { + return nil, errors.New("private key is not on the P-256 curve") + } + return ecKey, nil +} + +const ( + audience = "appstoreconnect-v1" + // Apple caps tokens at 20 minutes; leave a margin for clock skew. + tokenLifetime = 15 * time.Minute + // A token is reissued this long before it expires so an in-flight + // request never carries a token that lapses on the way. + refreshMargin = time.Minute +) + +// tokenSource signs and caches JWTs for one key. +type tokenSource struct { + creds Credentials + key *ecdsa.PrivateKey + now func() time.Time + + mu sync.Mutex + token string + expiry time.Time +} + +func newTokenSource(creds Credentials) (*tokenSource, error) { + if strings.TrimSpace(creds.IssuerID) == "" { + return nil, errors.New("issuer ID is empty") + } + if strings.TrimSpace(creds.KeyID) == "" { + return nil, errors.New("key ID is empty") + } + key, err := ParsePrivateKey(creds.PrivateKey) + if err != nil { + return nil, err + } + return &tokenSource{creds: creds, key: key, now: time.Now}, nil +} + +// Token returns a valid bearer token, reusing the cached one until it nears expiry. +func (t *tokenSource) Token() (string, error) { + t.mu.Lock() + defer t.mu.Unlock() + now := t.now() + if t.token != "" && now.Before(t.expiry.Add(-refreshMargin)) { + return t.token, nil + } + exp := now.Add(tokenLifetime) + token, err := signJWT(t.key, t.creds.KeyID, t.creds.IssuerID, now, exp) + if err != nil { + return "", err + } + t.token, t.expiry = token, exp + return token, nil +} + +// signJWT produces an ES256 JWT with the claims App Store Connect requires. +func signJWT(key *ecdsa.PrivateKey, keyID, issuerID string, issuedAt, expiresAt time.Time) (string, error) { + header, err := json.Marshal(map[string]string{"alg": "ES256", "kid": keyID, "typ": "JWT"}) + if err != nil { + return "", err + } + claims, err := json.Marshal(map[string]any{ + "iss": issuerID, + "iat": issuedAt.Unix(), + "exp": expiresAt.Unix(), + "aud": audience, + }) + if err != nil { + return "", err + } + enc := base64.RawURLEncoding + signingInput := enc.EncodeToString(header) + "." + enc.EncodeToString(claims) + digest := sha256.Sum256([]byte(signingInput)) + r, s, err := ecdsa.Sign(rand.Reader, key, digest[:]) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + // JWS wants the raw R||S pair, each left-padded to the curve size, not + // the ASN.1 sequence ecdsa.SignASN1 produces. + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + return signingInput + "." + enc.EncodeToString(sig), nil +} diff --git a/internal/asc/jwt_test.go b/internal/asc/jwt_test.go new file mode 100644 index 0000000..e26a7ab --- /dev/null +++ b/internal/asc/jwt_test.go @@ -0,0 +1,143 @@ +package asc + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "strings" + "testing" + "time" +) + +// testKey returns a fresh P-256 key and its PKCS#8 PEM, as Apple's .p8 files are encoded. +func testKey(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return key, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +func testCredentials(t *testing.T) (Credentials, *ecdsa.PrivateKey) { + t.Helper() + key, pemKey := testKey(t) + return Credentials{IssuerID: "issuer-1", KeyID: "KEY123", PrivateKey: pemKey}, key +} + +func decodeSegment(t *testing.T, s string, out any) { + t.Helper() + data, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, out); err != nil { + t.Fatal(err) + } +} + +func TestTokenClaimsAndSignature(t *testing.T) { + creds, key := testCredentials(t) + ts, err := newTokenSource(creds) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC) + ts.now = func() time.Time { return now } + + token, err := ts.Token() + if err != nil { + t.Fatal(err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d segments", len(parts)) + } + var header map[string]string + decodeSegment(t, parts[0], &header) + if header["alg"] != "ES256" || header["kid"] != "KEY123" || header["typ"] != "JWT" { + t.Errorf("header = %v", header) + } + var claims map[string]any + decodeSegment(t, parts[1], &claims) + if claims["iss"] != "issuer-1" || claims["aud"] != audience { + t.Errorf("claims = %v", claims) + } + iat, iatOK := claims["iat"].(float64) + exp, expOK := claims["exp"].(float64) + if !iatOK || !expOK { + t.Fatalf("iat/exp are not numbers: %v", claims) + } + if int64(iat) != now.Unix() { + t.Errorf("iat = %v, want %d", iat, now.Unix()) + } + if lifetime := exp - iat; lifetime <= 0 || lifetime > 20*60 { + t.Errorf("exp-iat = %vs, must be within Apple's 20 minute cap", lifetime) + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != 64 { + t.Fatalf("signature: %v, %d bytes (want raw 64-byte R||S)", err, len(sig)) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + r, s := new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) { + t.Error("signature does not verify with the key's public half") + } +} + +func TestTokenCachedAndRefreshedBeforeExpiry(t *testing.T) { + creds, _ := testCredentials(t) + ts, err := newTokenSource(creds) + if err != nil { + t.Fatal(err) + } + now := time.Now() + ts.now = func() time.Time { return now } + first, _ := ts.Token() + now = now.Add(5 * time.Minute) + if again, _ := ts.Token(); again != first { + t.Error("token reissued while still valid") + } + // Inside the refresh margin: a new token must be minted even though the + // old one has not technically expired yet. + now = now.Add(tokenLifetime - 5*time.Minute - refreshMargin/2) + if again, _ := ts.Token(); again == first { + t.Error("token not refreshed before expiry") + } +} + +func TestCredentialsValidate(t *testing.T) { + _, pemKey := testKey(t) + cases := map[string]Credentials{ + "missing issuer": {KeyID: "K", PrivateKey: pemKey}, + "missing key id": {IssuerID: "I", PrivateKey: pemKey}, + "not pem": {IssuerID: "I", KeyID: "K", PrivateKey: "-----BEGIN NOTHING"}, + "wrong block": {IssuerID: "I", KeyID: "K", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1}}))}, + } + for name, c := range cases { + if err := c.Validate(); err == nil { + t.Errorf("%s: want error", name) + } + } + if err := (Credentials{IssuerID: "I", KeyID: "K", PrivateKey: pemKey}).Validate(); err != nil { + t.Errorf("valid credentials rejected: %v", err) + } + // SEC 1 "EC PRIVATE KEY" encoding is accepted too. + key, _ := testKey(t) + der, _ := x509.MarshalECPrivateKey(key) + sec1 := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})) + if _, err := ParsePrivateKey(sec1); err != nil { + t.Errorf("SEC 1 key rejected: %v", err) + } +} diff --git a/internal/asc/review.go b/internal/asc/review.go new file mode 100644 index 0000000..2085abc --- /dev/null +++ b/internal/asc/review.go @@ -0,0 +1,145 @@ +package asc + +import ( + "context" + "net/url" + "strings" + "time" +) + +// Review submission states. +const ( + ReviewStateReadyForReview = "READY_FOR_REVIEW" + ReviewStateWaitingForReview = "WAITING_FOR_REVIEW" + ReviewStateInReview = "IN_REVIEW" + ReviewStateUnresolvedIssues = "UNRESOLVED_ISSUES" + ReviewStateCanceling = "CANCELING" + ReviewStateCompleting = "COMPLETING" + ReviewStateComplete = "COMPLETE" +) + +// ReviewSubmission groups the items submitted to App Review together. +type ReviewSubmission struct { + ID string + Platform string + State string + SubmittedDate time.Time +} + +type reviewSubmissionAttributes struct { + Platform string `json:"platform,omitempty"` + State string `json:"state,omitempty"` + SubmittedDate *time.Time `json:"submittedDate,omitempty"` +} + +type reviewSubmissionUpdate struct { + Submitted *bool `json:"submitted,omitempty"` + Canceled *bool `json:"canceled,omitempty"` +} + +func toReviewSubmission(r Resource[reviewSubmissionAttributes]) ReviewSubmission { + s := ReviewSubmission{ID: r.ID, Platform: r.Attributes.Platform, State: r.Attributes.State} + if r.Attributes.SubmittedDate != nil { + s.SubmittedDate = *r.Attributes.SubmittedDate + } + return s +} + +// ListReviewSubmissions lists the app's submissions on a platform, optionally limited to states. +func (c *Client) ListReviewSubmissions(ctx context.Context, appID, platform string, states []string) ([]ReviewSubmission, error) { + q := url.Values{"filter[app]": {appID}} + if platform != "" { + q.Set("filter[platform]", platform) + } + if len(states) > 0 { + q.Set("filter[state]", strings.Join(states, ",")) + } + rs, err := getAll[reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions", q) + if err != nil { + return nil, err + } + subs := make([]ReviewSubmission, 0, len(rs)) + for _, r := range rs { + subs = append(subs, toReviewSubmission(r)) + } + return subs, nil +} + +// CreateReviewSubmission opens a new submission for the app on a platform. +func (c *Client) CreateReviewSubmission(ctx context.Context, appID, platform string) (*ReviewSubmission, error) { + req := Resource[reviewSubmissionAttributes]{ + Type: "reviewSubmissions", + Attributes: reviewSubmissionAttributes{Platform: platform}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[reviewSubmissionAttributes, reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions", req) + if err != nil { + return nil, err + } + s := toReviewSubmission(*r) + return &s, nil +} + +// ReviewSubmissionItem is one thing under review, here always an App Store version. +type ReviewSubmissionItem struct { + ID string + State string + AppStoreVersionID string +} + +type reviewSubmissionItemAttributes struct { + State string `json:"state,omitempty"` +} + +func toReviewSubmissionItem(r Resource[reviewSubmissionItemAttributes]) ReviewSubmissionItem { + item := ReviewSubmissionItem{ID: r.ID, State: r.Attributes.State} + if l, ok := r.Relationships.One("appStoreVersion"); ok { + item.AppStoreVersionID = l.ID + } + return item +} + +// ListReviewSubmissionItems lists what a submission contains. +func (c *Client) ListReviewSubmissionItems(ctx context.Context, submissionID string) ([]ReviewSubmissionItem, error) { + rs, err := getAll[reviewSubmissionItemAttributes](ctx, c, "/v1/reviewSubmissions/"+submissionID+"/items", url.Values{"include": {"appStoreVersion"}}) + if err != nil { + return nil, err + } + items := make([]ReviewSubmissionItem, 0, len(rs)) + for _, r := range rs { + items = append(items, toReviewSubmissionItem(r)) + } + return items, nil +} + +// AddAppStoreVersionToReviewSubmission puts a version into the submission. +func (c *Client) AddAppStoreVersionToReviewSubmission(ctx context.Context, submissionID, versionID string) (*ReviewSubmissionItem, error) { + req := Resource[struct{}]{ + Type: "reviewSubmissionItems", + Relationships: Relationships{ + "reviewSubmission": ToOne("reviewSubmissions", submissionID), + "appStoreVersion": ToOne("appStoreVersions", versionID), + }, + } + r, err := post[struct{}, reviewSubmissionItemAttributes](ctx, c, "/v1/reviewSubmissionItems", req) + if err != nil { + return nil, err + } + item := toReviewSubmissionItem(*r) + if item.AppStoreVersionID == "" { + item.AppStoreVersionID = versionID + } + return &item, nil +} + +// SubmitReviewSubmission sends the submission to App Review. +func (c *Client) SubmitReviewSubmission(ctx context.Context, id string) (*ReviewSubmission, error) { + submitted := true + req := Resource[reviewSubmissionUpdate]{Type: "reviewSubmissions", ID: id, Attributes: reviewSubmissionUpdate{Submitted: &submitted}} + r, err := patch[reviewSubmissionUpdate, reviewSubmissionAttributes](ctx, c, "/v1/reviewSubmissions/"+id, req) + if err != nil { + return nil, err + } + s := toReviewSubmission(*r) + return &s, nil +} diff --git a/internal/asc/testflight.go b/internal/asc/testflight.go new file mode 100644 index 0000000..aaa2165 --- /dev/null +++ b/internal/asc/testflight.go @@ -0,0 +1,186 @@ +package asc + +import ( + "context" + "net/url" + "time" +) + +// BetaGroup is a TestFlight tester group. +type BetaGroup struct { + ID string + Name string + Internal bool + PublicLinkEnabled bool +} + +type betaGroupAttributes struct { + Name string `json:"name,omitempty"` + IsInternalGroup *bool `json:"isInternalGroup,omitempty"` + PublicLinkEnabled *bool `json:"publicLinkEnabled,omitempty"` + HasAccessToAllBuilds *bool `json:"hasAccessToAllBuilds,omitempty"` +} + +// ListBetaGroups lists the app's TestFlight groups. +func (c *Client) ListBetaGroups(ctx context.Context, appID string) ([]BetaGroup, error) { + rs, err := getAll[betaGroupAttributes](ctx, c, "/v1/betaGroups", url.Values{"filter[app]": {appID}}) + if err != nil { + return nil, err + } + groups := make([]BetaGroup, 0, len(rs)) + for _, r := range rs { + g := BetaGroup{ID: r.ID, Name: r.Attributes.Name} + if r.Attributes.IsInternalGroup != nil { + g.Internal = *r.Attributes.IsInternalGroup + } + if r.Attributes.PublicLinkEnabled != nil { + g.PublicLinkEnabled = *r.Attributes.PublicLinkEnabled + } + groups = append(groups, g) + } + return groups, nil +} + +// BetaBuildLocalization is the "What to Test" text of a build in one locale. +type BetaBuildLocalization struct { + ID string + Locale string + WhatsNew string +} + +type betaBuildLocalizationAttributes struct { + WhatsNew string `json:"whatsNew,omitempty"` + Locale string `json:"locale,omitempty"` +} + +func toBetaBuildLocalization(r Resource[betaBuildLocalizationAttributes]) BetaBuildLocalization { + return BetaBuildLocalization{ID: r.ID, Locale: r.Attributes.Locale, WhatsNew: r.Attributes.WhatsNew} +} + +// ListBetaBuildLocalizations lists the build's test notes per locale. +func (c *Client) ListBetaBuildLocalizations(ctx context.Context, buildID string) ([]BetaBuildLocalization, error) { + rs, err := getAll[betaBuildLocalizationAttributes](ctx, c, "/v1/builds/"+buildID+"/betaBuildLocalizations", nil) + if err != nil { + return nil, err + } + out := make([]BetaBuildLocalization, 0, len(rs)) + for _, r := range rs { + out = append(out, toBetaBuildLocalization(r)) + } + return out, nil +} + +// CreateBetaBuildLocalization adds test notes for a locale. +func (c *Client) CreateBetaBuildLocalization(ctx context.Context, buildID, locale, whatsNew string) (*BetaBuildLocalization, error) { + req := Resource[betaBuildLocalizationAttributes]{ + Type: "betaBuildLocalizations", + Attributes: betaBuildLocalizationAttributes{Locale: locale, WhatsNew: whatsNew}, + Relationships: Relationships{"build": ToOne("builds", buildID)}, + } + r, err := post[betaBuildLocalizationAttributes, betaBuildLocalizationAttributes](ctx, c, "/v1/betaBuildLocalizations", req) + if err != nil { + return nil, err + } + l := toBetaBuildLocalization(*r) + return &l, nil +} + +// UpdateBetaBuildLocalization replaces the test notes of an existing locale. +func (c *Client) UpdateBetaBuildLocalization(ctx context.Context, id, whatsNew string) (*BetaBuildLocalization, error) { + req := Resource[betaBuildLocalizationAttributes]{Type: "betaBuildLocalizations", ID: id, Attributes: betaBuildLocalizationAttributes{WhatsNew: whatsNew}} + r, err := patch[betaBuildLocalizationAttributes, betaBuildLocalizationAttributes](ctx, c, "/v1/betaBuildLocalizations/"+id, req) + if err != nil { + return nil, err + } + l := toBetaBuildLocalization(*r) + return &l, nil +} + +// SetWhatsNew creates or updates the build's test notes for the locale. +func (c *Client) SetWhatsNew(ctx context.Context, buildID, locale, whatsNew string) (*BetaBuildLocalization, error) { + existing, err := c.ListBetaBuildLocalizations(ctx, buildID) + if err != nil { + return nil, err + } + for _, l := range existing { + if l.Locale == locale { + return c.UpdateBetaBuildLocalization(ctx, l.ID, whatsNew) + } + } + return c.CreateBetaBuildLocalization(ctx, buildID, locale, whatsNew) +} + +// Beta review states. +const ( + BetaReviewWaiting = "WAITING_FOR_REVIEW" + BetaReviewInReview = "IN_REVIEW" + BetaReviewRejected = "REJECTED" + BetaReviewApproved = "APPROVED" +) + +// BetaAppReviewSubmission is a build's external TestFlight review. +type BetaAppReviewSubmission struct { + ID string + State string +} + +type betaAppReviewSubmissionAttributes struct { + BetaReviewState string `json:"betaReviewState,omitempty"` +} + +// GetBuildBetaAppReviewSubmission returns the build's beta review, or nil when +// the build was never submitted. +func (c *Client) GetBuildBetaAppReviewSubmission(ctx context.Context, buildID string) (*BetaAppReviewSubmission, error) { + r, err := getOne[betaAppReviewSubmissionAttributes](ctx, c, "/v1/builds/"+buildID+"/betaAppReviewSubmission", nil) + if err != nil { + if IsStatus(err, 404) { + return nil, nil + } + return nil, err + } + if r.ID == "" { + return nil, nil + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} + +// GetBetaAppReviewSubmission fetches a beta review by ID. +func (c *Client) GetBetaAppReviewSubmission(ctx context.Context, id string) (*BetaAppReviewSubmission, error) { + r, err := getOne[betaAppReviewSubmissionAttributes](ctx, c, "/v1/betaAppReviewSubmissions/"+id, nil) + if err != nil { + return nil, err + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} + +// WaitForBetaAppReview polls the beta review until Apple has decided it +// (APPROVED or REJECTED). onPoll, when set, sees every state change. +func (c *Client) WaitForBetaAppReview(ctx context.Context, id string, interval time.Duration, onPoll func(*BetaAppReviewSubmission)) (*BetaAppReviewSubmission, error) { + p := c.newPoller(interval) + for { + review, err := c.GetBetaAppReviewSubmission(ctx, id) + if err != nil { + return nil, err + } + if onPoll != nil { + onPoll(review) + } + switch review.State { + case BetaReviewApproved, BetaReviewRejected: + return review, nil + } + if err := p.wait(ctx); err != nil { + return review, err + } + } +} + +// SubmitBuildForBetaReview submits the build for external TestFlight review. +func (c *Client) SubmitBuildForBetaReview(ctx context.Context, buildID string) (*BetaAppReviewSubmission, error) { + req := Resource[struct{}]{Type: "betaAppReviewSubmissions", Relationships: Relationships{"build": ToOne("builds", buildID)}} + r, err := post[struct{}, betaAppReviewSubmissionAttributes](ctx, c, "/v1/betaAppReviewSubmissions", req) + if err != nil { + return nil, err + } + return &BetaAppReviewSubmission{ID: r.ID, State: r.Attributes.BetaReviewState}, nil +} diff --git a/internal/asc/uploads.go b/internal/asc/uploads.go new file mode 100644 index 0000000..b2fe948 --- /dev/null +++ b/internal/asc/uploads.go @@ -0,0 +1,382 @@ +package asc + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Build upload states. +const ( + UploadStateAwaitingUpload = "AWAITING_UPLOAD" + UploadStateProcessing = "PROCESSING" + UploadStateFailed = "FAILED" + UploadStateComplete = "COMPLETE" +) + +// StateDetail is one message App Store Connect attaches to an upload state. +type StateDetail struct { + Code string `json:"code,omitempty"` + Description string `json:"description,omitempty"` +} + +func (d StateDetail) String() string { + if d.Code == "" { + return d.Description + } + return d.Code + ": " + d.Description +} + +// BuildUpload is a build delivery in progress or finished. +type BuildUpload struct { + ID string + Version string + BuildNumber string + Platform string + State string + Errors []StateDetail + Warnings []StateDetail + Infos []StateDetail + CreatedDate time.Time + UploadedDate time.Time +} + +type uploadState struct { + State string `json:"state,omitempty"` + Errors []StateDetail `json:"errors,omitempty"` + Warnings []StateDetail `json:"warnings,omitempty"` + Infos []StateDetail `json:"infos,omitempty"` +} + +type buildUploadAttributes struct { + CFBundleShortVersionString string `json:"cfBundleShortVersionString,omitempty"` + CFBundleVersion string `json:"cfBundleVersion,omitempty"` + Platform string `json:"platform,omitempty"` + State *uploadState `json:"state,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` + UploadedDate *time.Time `json:"uploadedDate,omitempty"` +} + +func toBuildUpload(r Resource[buildUploadAttributes]) BuildUpload { + u := BuildUpload{ + ID: r.ID, + Version: r.Attributes.CFBundleShortVersionString, + BuildNumber: r.Attributes.CFBundleVersion, + Platform: r.Attributes.Platform, + } + if s := r.Attributes.State; s != nil { + u.State, u.Errors, u.Warnings, u.Infos = s.State, s.Errors, s.Warnings, s.Infos + } + if r.Attributes.CreatedDate != nil { + u.CreatedDate = *r.Attributes.CreatedDate + } + if r.Attributes.UploadedDate != nil { + u.UploadedDate = *r.Attributes.UploadedDate + } + return u +} + +// CreateBuildUpload opens a build delivery for the app. +func (c *Client) CreateBuildUpload(ctx context.Context, appID, version, buildNumber, platform string) (*BuildUpload, error) { + req := Resource[buildUploadAttributes]{ + Type: "buildUploads", + Attributes: buildUploadAttributes{CFBundleShortVersionString: version, CFBundleVersion: buildNumber, Platform: platform}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[buildUploadAttributes, buildUploadAttributes](ctx, c, "/v1/buildUploads", req) + if err != nil { + return nil, err + } + u := toBuildUpload(*r) + return &u, nil +} + +// GetBuildUpload fetches the current state of a delivery. +func (c *Client) GetBuildUpload(ctx context.Context, id string) (*BuildUpload, error) { + r, err := getOne[buildUploadAttributes](ctx, c, "/v1/buildUploads/"+id, nil) + if err != nil { + return nil, err + } + u := toBuildUpload(*r) + return &u, nil +} + +// HTTPHeader is a header a presigned upload URL requires. +type HTTPHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// UploadOperation is one chunk PUT to Apple's storage. +type UploadOperation struct { + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Length int64 `json:"length,omitempty"` + Offset int64 `json:"offset,omitempty"` + RequestHeaders []HTTPHeader `json:"requestHeaders,omitempty"` +} + +// BuildUploadFile is the reserved slot for the IPA within a delivery. +type BuildUploadFile struct { + ID string + FileName string + FileSize int64 + UploadOperations []UploadOperation +} + +type buildUploadFileAttributes struct { + AssetType string `json:"assetType,omitempty"` + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + UTI string `json:"uti,omitempty"` + UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` +} + +// The reference implementation sends no checksum: ASC accepts the upload +// without one and rejects some checksum encodings, so it stays out. +type buildUploadFileCommit struct { + Uploaded bool `json:"uploaded"` +} + +func toBuildUploadFile(r Resource[buildUploadFileAttributes]) BuildUploadFile { + return BuildUploadFile{ + ID: r.ID, + FileName: r.Attributes.FileName, + FileSize: r.Attributes.FileSize, + UploadOperations: r.Attributes.UploadOperations, + } +} + +func utiFor(fileName string) string { + if strings.EqualFold(filepath.Ext(fileName), ".pkg") { + return "com.apple.pkg" + } + return "com.apple.ipa" +} + +// CreateBuildUploadFile reserves the file slot and returns the presigned chunk operations. +func (c *Client) CreateBuildUploadFile(ctx context.Context, uploadID, fileName string, size int64) (*BuildUploadFile, error) { + req := Resource[buildUploadFileAttributes]{ + Type: "buildUploadFiles", + Attributes: buildUploadFileAttributes{AssetType: "ASSET", FileName: fileName, FileSize: size, UTI: utiFor(fileName)}, + Relationships: Relationships{"buildUpload": ToOne("buildUploads", uploadID)}, + } + r, err := post[buildUploadFileAttributes, buildUploadFileAttributes](ctx, c, "/v1/buildUploadFiles", req) + if err != nil { + return nil, err + } + f := toBuildUploadFile(*r) + return &f, nil +} + +// CommitBuildUploadFile tells App Store Connect every chunk has been sent. +func (c *Client) CommitBuildUploadFile(ctx context.Context, fileID string) error { + req := Resource[buildUploadFileCommit]{Type: "buildUploadFiles", ID: fileID, Attributes: buildUploadFileCommit{Uploaded: true}} + return c.Patch(ctx, "/v1/buildUploadFiles/"+fileID, Document[Resource[buildUploadFileCommit]]{Data: req}, nil) +} + +// UploadChunks PUTs each operation's byte range of file to its presigned URL. +// progress, when set, is called after every chunk with the bytes sent so far. +func (c *Client) UploadChunks(ctx context.Context, file io.ReaderAt, ops []UploadOperation, progress func(sent, total int64)) error { + var total, sent int64 + for _, op := range ops { + total += op.Length + } + for i, op := range ops { + if err := c.uploadChunk(ctx, file, op); err != nil { + return fmt.Errorf("upload chunk %d/%d: %w", i+1, len(ops), err) + } + sent += op.Length + if progress != nil { + progress(sent, total) + } + } + return nil +} + +func (c *Client) uploadChunk(ctx context.Context, file io.ReaderAt, op UploadOperation) error { + if op.URL == "" { + return errors.New("upload operation has no URL") + } + method := op.Method + if method == "" { + method = http.MethodPut + } + var lastErr error + for attempt := 0; attempt <= c.maxRetries; attempt++ { + if attempt > 0 { + if err := c.sleep(ctx, c.retryDelay<<(attempt-1)); err != nil { + return err + } + } + req, err := http.NewRequestWithContext(ctx, method, op.URL, io.NewSectionReader(file, op.Offset, op.Length)) + if err != nil { + return err + } + req.ContentLength = op.Length + for _, h := range op.RequestHeaders { + req.Header.Set(h.Name, h.Value) + } + resp, err := c.upload.Do(req) + if err != nil { + lastErr = err + continue + } + // Storage error bodies are short XML; 1 KB keeps the reason without + // echoing a whole presigned request back into the error. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + if resp.StatusCode < 300 { + return nil + } + lastErr = fmt.Errorf("storage returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + if resp.StatusCode < 500 && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode != http.StatusRequestTimeout { + return lastErr + } + } + return lastErr +} + +// UploadBuildOptions describes a build delivery. +type UploadBuildOptions struct { + AppID string + Version string // CFBundleShortVersionString + BuildNumber string // CFBundleVersion + Platform string // defaults to PlatformIOS + Path string // .ipa (or .pkg) on disk + // Progress, when set, receives the bytes sent so far and the total. + Progress func(sent, total int64) +} + +// UploadBuild runs the buildUploads flow end to end: create the delivery, +// reserve the file, PUT the chunks and commit. It returns as soon as App +// Store Connect has the file; use WaitForBuildUpload to follow processing. +func (c *Client) UploadBuild(ctx context.Context, opts *UploadBuildOptions) (*BuildUpload, error) { + platform := opts.Platform + if platform == "" { + platform = PlatformIOS + } + f, err := os.Open(opts.Path) + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, err + } + upload, err := c.CreateBuildUpload(ctx, opts.AppID, opts.Version, opts.BuildNumber, platform) + if err != nil { + return nil, fmt.Errorf("create build upload: %w", err) + } + file, err := c.CreateBuildUploadFile(ctx, upload.ID, filepath.Base(opts.Path), st.Size()) + if err != nil { + return nil, fmt.Errorf("reserve upload file: %w", err) + } + if len(file.UploadOperations) == 0 { + return nil, errors.New("App Store Connect returned no upload operations for the file") + } + if err := c.UploadChunks(ctx, f, file.UploadOperations, opts.Progress); err != nil { + return nil, err + } + if err := c.CommitBuildUploadFile(ctx, file.ID); err != nil { + return nil, fmt.Errorf("commit upload: %w", err) + } + return c.GetBuildUpload(ctx, upload.ID) +} + +// UploadFailedError reports a delivery App Store Connect rejected. +type UploadFailedError struct { + Upload *BuildUpload +} + +func (e *UploadFailedError) Error() string { + msgs := make([]string, 0, len(e.Upload.Errors)) + for _, d := range e.Upload.Errors { + msgs = append(msgs, d.String()) + } + if len(msgs) == 0 { + return "App Store Connect rejected the upload without details" + } + return "App Store Connect rejected the upload: " + strings.Join(msgs, "; ") +} + +// poller spaces out status polls: the wait starts at the base interval and +// grows by half each time, capped at four times the base, so a long +// processing run costs fewer requests without making short ones sluggish. +type poller struct { + c *Client + next time.Duration + maximum time.Duration +} + +func (c *Client) newPoller(interval time.Duration) *poller { + return &poller{c: c, next: interval, maximum: 4 * interval} +} + +func (p *poller) wait(ctx context.Context) error { + d := p.next + if p.next = p.next * 3 / 2; p.next > p.maximum { + p.next = p.maximum + } + return p.c.sleep(ctx, d) +} + +// WaitForBuildUpload polls the delivery until it is COMPLETE, returning an +// *UploadFailedError when it FAILED. onPoll, when set, sees every poll result. +func (c *Client) WaitForBuildUpload(ctx context.Context, id string, interval time.Duration, onPoll func(*BuildUpload)) (*BuildUpload, error) { + p := c.newPoller(interval) + for { + u, err := c.GetBuildUpload(ctx, id) + if err != nil { + return nil, err + } + if onPoll != nil { + onPoll(u) + } + switch u.State { + case UploadStateComplete: + return u, nil + case UploadStateFailed: + return u, &UploadFailedError{Upload: u} + } + if err := p.wait(ctx); err != nil { + return u, err + } + } +} + +// WaitForBuild polls until the build for the version pair exists and has +// left PROCESSING. A FAILED or INVALID build is returned with an error. +func (c *Client) WaitForBuild(ctx context.Context, appID, version, buildNumber string, interval time.Duration, onPoll func(*Build)) (*Build, error) { + p := c.newPoller(interval) + for { + builds, err := c.ListBuilds(ctx, &BuildFilter{AppID: appID, Platform: PlatformIOS, Version: version, BuildNumber: buildNumber, Limit: 1}) + if err != nil { + return nil, err + } + if len(builds) > 0 { + b := &builds[0] + if onPoll != nil { + onPoll(b) + } + switch b.ProcessingState { + case ProcessingStateValid: + return b, nil + case ProcessingStateFailed, ProcessingStateInvalid: + return b, fmt.Errorf("build %s (%s) finished processing as %s; App Store Connect emails the reason to the team", b.BuildNumber, b.ID, b.ProcessingState) + } + } else if onPoll != nil { + onPoll(nil) + } + if err := p.wait(ctx); err != nil { + return nil, err + } + } +} diff --git a/internal/asc/uploads_test.go b/internal/asc/uploads_test.go new file mode 100644 index 0000000..14fbdec --- /dev/null +++ b/internal/asc/uploads_test.go @@ -0,0 +1,249 @@ +package asc + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" +) + +// fakeASC is a minimal buildUploads backend: it hands out two chunk +// operations pointing back at itself, records the PUT bodies, and walks the +// upload state PROCESSING -> COMPLETE, after which the build appears. +type fakeASC struct { + t *testing.T + mu sync.Mutex + srv *httptest.Server + fileSize int64 + chunks map[int64][]byte + headers map[int64]http.Header + created map[string]any + fileReq map[string]any + commit map[string]any + polls int + buildGet int + patched map[string]any + failing bool + chunk500 int +} + +func newFakeASC(t *testing.T, fileSize int64) *fakeASC { + f := &fakeASC{t: t, fileSize: fileSize, chunks: map[int64][]byte{}, headers: map[int64]http.Header{}} + f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeASC) handle(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + switch { + case r.Method == "POST" && r.URL.Path == "/v1/buildUploads": + _ = json.NewDecoder(r.Body).Decode(&f.created) + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "buildUploads", "id": "up-1", "attributes": map[string]any{ + "cfBundleShortVersionString": "1.2.3", "cfBundleVersion": "42", "platform": "IOS", "state": map[string]any{"state": "AWAITING_UPLOAD"}, + }}}) + case r.Method == "POST" && r.URL.Path == "/v1/buildUploadFiles": + _ = json.NewDecoder(r.Body).Decode(&f.fileReq) + half := f.fileSize / 2 + ops := []map[string]any{ + {"method": "PUT", "url": f.srv.URL + "/chunk?offset=0", "offset": 0, "length": half, "requestHeaders": []map[string]string{{"name": "Content-Type", "value": "application/octet-stream"}, {"name": "X-Chunk", "value": "first"}}}, + {"method": "PUT", "url": f.srv.URL + "/chunk?offset=" + strconv.FormatInt(half, 10), "offset": half, "length": f.fileSize - half, "requestHeaders": []map[string]string{{"name": "X-Chunk", "value": "second"}}}, + } + writeJSON(w, 201, map[string]any{"data": map[string]any{"type": "buildUploadFiles", "id": "file-1", "attributes": map[string]any{"fileName": "App.ipa", "fileSize": f.fileSize, "uploadOperations": ops}}}) + case r.Method == "PUT" && r.URL.Path == "/chunk": + if r.Header.Get("Authorization") != "" { + f.t.Error("bearer token leaked to storage URL") + } + if f.chunk500 > 0 { + f.chunk500-- + w.WriteHeader(503) + return + } + offset, _ := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64) + data, _ := io.ReadAll(r.Body) + if r.ContentLength != int64(len(data)) { + f.t.Errorf("Content-Length %d for %d bytes", r.ContentLength, len(data)) + } + f.chunks[offset] = data + f.headers[offset] = r.Header.Clone() + w.WriteHeader(200) + case r.Method == "PATCH" && r.URL.Path == "/v1/buildUploadFiles/file-1": + _ = json.NewDecoder(r.Body).Decode(&f.commit) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "buildUploadFiles", "id": "file-1"}}) + case r.Method == "GET" && r.URL.Path == "/v1/buildUploads/up-1": + f.polls++ + state := map[string]any{"state": "PROCESSING"} + if f.polls >= 3 { + state["state"] = "COMPLETE" + if f.failing { + state = map[string]any{"state": "FAILED", "errors": []map[string]string{{"code": "ITMS-90189", "description": "Redundant Binary Upload."}}} + } + } + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "buildUploads", "id": "up-1", "attributes": map[string]any{"cfBundleShortVersionString": "1.2.3", "cfBundleVersion": "42", "platform": "IOS", "state": state}}}) + case r.Method == "GET" && r.URL.Path == "/v1/builds": + q := r.URL.Query() + if q.Get("filter[preReleaseVersion.version]") != "1.2.3" || q.Get("filter[version]") != "42" || q.Get("filter[app]") != "app-1" || q.Get("limit") != "1" { + f.t.Errorf("builds query = %v", q) + } + f.buildGet++ + if f.buildGet == 1 { + writeJSON(w, 200, map[string]any{"data": []any{}}) + return + } + state := "PROCESSING" + if f.buildGet >= 3 { + state = "VALID" + } + writeJSON(w, 200, map[string]any{"data": []map[string]any{{"type": "builds", "id": "build-9", "attributes": map[string]any{"version": "42", "processingState": state, "uploadedDate": "2026-09-16T10:00:00Z"}}}}) + case r.Method == "PATCH" && r.URL.Path == "/v1/builds/build-9": + _ = json.NewDecoder(r.Body).Decode(&f.patched) + writeJSON(w, 200, map[string]any{"data": map[string]any{"type": "builds", "id": "build-9", "attributes": map[string]any{"version": "42", "processingState": "VALID", "usesNonExemptEncryption": false}}}) + default: + f.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + } +} + +func writeRandomFile(t *testing.T, name string, size int) (string, []byte) { + t.Helper() + data := make([]byte, size) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, data +} + +func TestUploadBuildFlow(t *testing.T) { + path, data := writeRandomFile(t, "App.ipa", 10_001) + fake := newFakeASC(t, int64(len(data))) + c := newTestClient(t, fake.srv) + ctx := context.Background() + + var progress []int64 + upload, err := c.UploadBuild(ctx, &UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path, Progress: func(sent, total int64) { + progress = append(progress, sent) + if total != int64(len(data)) { + t.Errorf("total = %d", total) + } + }}) + if err != nil { + t.Fatal(err) + } + if upload.ID != "up-1" || upload.State != UploadStateProcessing { + t.Errorf("upload = %+v", upload) + } + + fake.mu.Lock() + attrs := obj(t, fake.created, "data", "attributes") + if attrs["cfBundleShortVersionString"] != "1.2.3" || attrs["cfBundleVersion"] != "42" || attrs["platform"] != "IOS" { + t.Errorf("buildUploads attributes = %v", attrs) + } + if obj(t, fake.created, "data", "relationships", "app", "data")["id"] != "app-1" { + t.Errorf("buildUploads relationships = %v", fake.created) + } + fileAttrs := obj(t, fake.fileReq, "data", "attributes") + if fileAttrs["assetType"] != "ASSET" || fileAttrs["fileName"] != "App.ipa" || fileAttrs["fileSize"] != float64(len(data)) || fileAttrs["uti"] != "com.apple.ipa" { + t.Errorf("buildUploadFiles attributes = %v", fileAttrs) + } + if obj(t, fake.fileReq, "data", "relationships", "buildUpload", "data")["id"] != "up-1" { + t.Errorf("buildUploadFiles relationships = %v", fake.fileReq) + } + got := append(append([]byte{}, fake.chunks[0]...), fake.chunks[int64(len(data))/2]...) + if !bytes.Equal(got, data) { + t.Errorf("reassembled %d bytes differ from the %d-byte file", len(got), len(data)) + } + if fake.headers[0].Get("X-Chunk") != "first" || fake.headers[0].Get("Content-Type") != "application/octet-stream" || fake.headers[int64(len(data))/2].Get("X-Chunk") != "second" { + t.Errorf("request headers not honored: %v %v", fake.headers[0], fake.headers[int64(len(data))/2]) + } + commit := obj(t, fake.commit, "data") + if commit["id"] != "file-1" || obj(t, commit, "attributes")["uploaded"] != true { + t.Errorf("commit body = %v", fake.commit) + } + if _, has := obj(t, commit, "attributes")["sourceFileChecksums"]; has { + t.Error("checksum must not be sent") + } + fake.mu.Unlock() + if len(progress) != 2 || progress[1] != int64(len(data)) { + t.Errorf("progress = %v", progress) + } + + var states []string + done, err := c.WaitForBuildUpload(ctx, upload.ID, time.Millisecond, func(u *BuildUpload) { states = append(states, u.State) }) + // UploadBuild already fetched the delivery once, so the wait sees the + // remaining PROCESSING poll and then COMPLETE. + if err != nil || done.State != UploadStateComplete || len(states) != 2 { + t.Errorf("wait: %+v %v %v", done, err, states) + } + + var seen int + build, err := c.WaitForBuild(ctx, "app-1", "1.2.3", "42", time.Millisecond, func(*Build) { seen++ }) + if err != nil || build.ID != "build-9" || build.ProcessingState != ProcessingStateValid || seen != 3 { + t.Errorf("build = %+v, err = %v, polls = %d", build, err, seen) + } +} + +func TestUploadBuildRejected(t *testing.T) { + path, data := writeRandomFile(t, "App.ipa", 64) + fake := newFakeASC(t, int64(len(data))) + fake.failing = true + c := newTestClient(t, fake.srv) + ctx := context.Background() + upload, err := c.UploadBuild(ctx, &UploadBuildOptions{AppID: "app-1", Version: "1.2.3", BuildNumber: "42", Path: path}) + if err != nil { + t.Fatal(err) + } + _, err = c.WaitForBuildUpload(ctx, upload.ID, time.Millisecond, nil) + var failed *UploadFailedError + if !errors.As(err, &failed) || failed.Upload.Errors[0].Code != "ITMS-90189" { + t.Fatalf("err = %v", err) + } + if want := "ITMS-90189: Redundant Binary Upload."; !bytes.Contains([]byte(err.Error()), []byte(want)) { + t.Errorf("message %q lacks %q", err.Error(), want) + } +} + +func TestUploadChunkRetriesOn5xx(t *testing.T) { + path, data := writeRandomFile(t, "App.pkg", 100) + fake := newFakeASC(t, int64(len(data))) + fake.chunk500 = 2 + c := newTestClient(t, fake.srv) + if _, err := c.UploadBuild(context.Background(), &UploadBuildOptions{AppID: "app-1", Version: "1.0", BuildNumber: "1", Path: path}); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + defer fake.mu.Unlock() + if obj(t, fake.fileReq, "data", "attributes")["uti"] != "com.apple.pkg" { + t.Error("pkg uti not detected") + } + if len(fake.chunks[0]) != 50 || len(fake.chunks[50]) != 50 { + t.Errorf("chunks = %d/%d bytes", len(fake.chunks[0]), len(fake.chunks[50])) + } +} + +func TestWaitForBuildCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{"data": []any{}}) + })) + defer srv.Close() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _, err := newTestClient(t, srv).WaitForBuild(ctx, "app-1", "1.0", "1", time.Millisecond, nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v", err) + } +} diff --git a/internal/asc/versions.go b/internal/asc/versions.go new file mode 100644 index 0000000..63918a8 --- /dev/null +++ b/internal/asc/versions.go @@ -0,0 +1,119 @@ +package asc + +import ( + "context" + "net/url" + "time" +) + +// Release types of an App Store version. +const ( + ReleaseTypeManual = "MANUAL" + ReleaseTypeAfterApproval = "AFTER_APPROVAL" + ReleaseTypeScheduled = "SCHEDULED" +) + +// App Store version states (appVersionState) the flows act on; others include +// PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW, REJECTED and READY_FOR_DISTRIBUTION. +const ( + VersionStateWaitingForReview = "WAITING_FOR_REVIEW" + VersionStateInReview = "IN_REVIEW" +) + +// AppStoreVersion is a version of the app on the App Store. +type AppStoreVersion struct { + ID string + Platform string + VersionString string + // State is appVersionState. + State string + AppStoreState string + ReleaseType string + BuildID string + CreatedDate time.Time +} + +type appStoreVersionAttributes struct { + Platform string `json:"platform,omitempty"` + VersionString string `json:"versionString,omitempty"` + AppStoreState string `json:"appStoreState,omitempty"` + AppVersionState string `json:"appVersionState,omitempty"` + ReleaseType string `json:"releaseType,omitempty"` + CreatedDate *time.Time `json:"createdDate,omitempty"` +} + +func toAppStoreVersion(r Resource[appStoreVersionAttributes]) AppStoreVersion { + v := AppStoreVersion{ + ID: r.ID, + Platform: r.Attributes.Platform, + VersionString: r.Attributes.VersionString, + State: r.Attributes.AppVersionState, + AppStoreState: r.Attributes.AppStoreState, + ReleaseType: r.Attributes.ReleaseType, + } + if r.Attributes.CreatedDate != nil { + v.CreatedDate = *r.Attributes.CreatedDate + } + if l, ok := r.Relationships.One("build"); ok { + v.BuildID = l.ID + } + return v +} + +// ListAppStoreVersions lists the app's versions for a platform, optionally one version string. +func (c *Client) ListAppStoreVersions(ctx context.Context, appID, platform, versionString string) ([]AppStoreVersion, error) { + q := url.Values{"include": {"build"}} + if platform != "" { + q.Set("filter[platform]", platform) + } + if versionString != "" { + q.Set("filter[versionString]", versionString) + } + rs, err := getAll[appStoreVersionAttributes](ctx, c, "/v1/apps/"+appID+"/appStoreVersions", q) + if err != nil { + return nil, err + } + versions := make([]AppStoreVersion, 0, len(rs)) + for _, r := range rs { + versions = append(versions, toAppStoreVersion(r)) + } + return versions, nil +} + +// CreateAppStoreVersion adds a new version to the app. +func (c *Client) CreateAppStoreVersion(ctx context.Context, appID, platform, versionString string) (*AppStoreVersion, error) { + req := Resource[appStoreVersionAttributes]{ + Type: "appStoreVersions", + Attributes: appStoreVersionAttributes{Platform: platform, VersionString: versionString}, + Relationships: Relationships{"app": ToOne("apps", appID)}, + } + r, err := post[appStoreVersionAttributes, appStoreVersionAttributes](ctx, c, "/v1/appStoreVersions", req) + if err != nil { + return nil, err + } + v := toAppStoreVersion(*r) + return &v, nil +} + +// AppStoreVersionUpdate lists the fields UpdateAppStoreVersion changes; empty ones are left alone. +type AppStoreVersionUpdate struct { + ReleaseType string + BuildID string +} + +// UpdateAppStoreVersion attaches a build and/or sets the release type. +func (c *Client) UpdateAppStoreVersion(ctx context.Context, id string, u AppStoreVersionUpdate) (*AppStoreVersion, error) { + req := Resource[appStoreVersionAttributes]{Type: "appStoreVersions", ID: id, Attributes: appStoreVersionAttributes{ReleaseType: u.ReleaseType}} + if u.BuildID != "" { + req.Relationships = Relationships{"build": ToOne("builds", u.BuildID)} + } + r, err := patch[appStoreVersionAttributes, appStoreVersionAttributes](ctx, c, "/v1/appStoreVersions/"+id, req) + if err != nil { + return nil, err + } + v := toAppStoreVersion(*r) + if v.BuildID == "" { + v.BuildID = u.BuildID + } + return &v, nil +} diff --git a/internal/auth/apple.go b/internal/auth/apple.go new file mode 100644 index 0000000..25e080d --- /dev/null +++ b/internal/auth/apple.go @@ -0,0 +1,93 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" +) + +// appleSecretName is the keyring entry / fallback file holding the ASC API key. +const appleSecretName = "apple-asc-key" + +// AppleCredentials is an App Store Connect API key (Users and Access → Integrations). +type AppleCredentials struct { + IssuerID string `json:"issuer_id"` + KeyID string `json:"key_id"` + PrivateKey string `json:"private_key"` // .p8 contents, PEM +} + +// AppleSource says where GetAppleCredentials found the key. +type AppleSource string + +const ( + // AppleSourceEnv means the ASC_* environment variables were used. + AppleSourceEnv AppleSource = "environment" + // AppleSourceStored means the login saved by `builder auth apple` was used. + AppleSourceStored AppleSource = "stored" +) + +// GetAppleCredentials returns the App Store Connect API key. The environment +// (ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY or ASC_KEY_PATH) takes +// precedence over the saved login so CI jobs and agents need no keychain. +func GetAppleCredentials() (*AppleCredentials, AppleSource, error) { + creds, err := appleCredentialsFromEnv() + if err != nil { + return nil, "", err + } + if creds != nil { + return creds, AppleSourceEnv, nil + } + raw, err := readSecret(appleSecretName) + if err != nil { + return nil, "", err + } + var stored AppleCredentials + if err := json.Unmarshal([]byte(raw), &stored); err != nil || stored.IssuerID == "" || stored.KeyID == "" || stored.PrivateKey == "" { + return nil, "", errors.New("saved Apple login is unreadable; run builder auth apple again") + } + return &stored, AppleSourceStored, nil +} + +func appleCredentialsFromEnv() (*AppleCredentials, error) { + issuer := strings.TrimSpace(os.Getenv("ASC_ISSUER_ID")) + keyID := strings.TrimSpace(os.Getenv("ASC_KEY_ID")) + key := os.Getenv("ASC_PRIVATE_KEY") + path := strings.TrimSpace(os.Getenv("ASC_KEY_PATH")) + if issuer == "" && keyID == "" && key == "" && path == "" { + return nil, nil + } + if issuer == "" || keyID == "" || (key == "" && path == "") { + return nil, errors.New("ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY (or ASC_KEY_PATH) must all be set to use App Store Connect credentials from the environment") + } + if key == "" { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ASC_KEY_PATH: %w", err) + } + key = string(data) + } + return &AppleCredentials{IssuerID: issuer, KeyID: keyID, PrivateKey: NormalizePEM(key)}, nil +} + +// NormalizePEM accepts a key pasted with literal "\n" sequences (as CI secret +// stores often flatten it) and returns it with real newlines. +func NormalizePEM(key string) string { + key = strings.ReplaceAll(key, `\n`, "\n") + return strings.TrimSpace(key) + "\n" +} + +// StoreAppleCredentials saves the API key as the Apple login. +func StoreAppleCredentials(c AppleCredentials) error { + c.IssuerID, c.KeyID = strings.TrimSpace(c.IssuerID), strings.TrimSpace(c.KeyID) + c.PrivateKey = NormalizePEM(c.PrivateKey) + if c.IssuerID == "" || c.KeyID == "" || strings.TrimSpace(c.PrivateKey) == "" { + return errors.New("issuer ID, key ID and private key are all required") + } + data, err := json.Marshal(c) + if err != nil { + return err + } + return writeSecret(appleSecretName, string(data)) +} diff --git a/internal/auth/apple_test.go b/internal/auth/apple_test.go new file mode 100644 index 0000000..c7381a3 --- /dev/null +++ b/internal/auth/apple_test.go @@ -0,0 +1,92 @@ +package auth + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/zalando/go-keyring" +) + +func clearAppleEnv(t *testing.T) { + for _, name := range []string{"ASC_ISSUER_ID", "ASC_KEY_ID", "ASC_PRIVATE_KEY", "ASC_KEY_PATH"} { + t.Setenv(name, "") + } +} + +func TestAppleCredentialsStoreGetLogout(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("APPDATA", dir) + clearAppleEnv(t) + + if _, _, err := GetAppleCredentials(); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("before login: %v", err) + } + want := AppleCredentials{IssuerID: " issuer ", KeyID: "KEY1", PrivateKey: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----"} + if err := StoreAppleCredentials(want); err != nil { + t.Fatal(err) + } + // Other logins are untouched by the Apple one. + if err := StoreProviderToken("codemagic", "cm-secret"); err != nil { + t.Fatal(err) + } + got, source, err := GetAppleCredentials() + if err != nil { + t.Fatal(err) + } + if source != AppleSourceStored || got.IssuerID != "issuer" || got.KeyID != "KEY1" || got.PrivateKey != "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n" { + t.Errorf("got %+v from %s", got, source) + } + if err := LogoutProvider("apple"); err != nil { + t.Fatal(err) + } + if _, _, err := GetAppleCredentials(); !errors.Is(err, ErrNotAuthenticated) { + t.Errorf("after logout: %v", err) + } + if token, err := GetProviderToken("codemagic"); err != nil || token != "cm-secret" { + t.Errorf("codemagic login lost: %q %v", token, err) + } + if err := StoreAppleCredentials(AppleCredentials{IssuerID: "i"}); err == nil { + t.Error("incomplete credentials accepted") + } +} + +func TestAppleCredentialsFromEnvironment(t *testing.T) { + keyring.MockInit() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("APPDATA", dir) + clearAppleEnv(t) + if err := StoreAppleCredentials(AppleCredentials{IssuerID: "stored", KeyID: "S", PrivateKey: "pem"}); err != nil { + t.Fatal(err) + } + + t.Setenv("ASC_ISSUER_ID", "env-issuer") + if _, _, err := GetAppleCredentials(); err == nil { + t.Error("partial environment must be an error, not a silent fallback") + } + t.Setenv("ASC_KEY_ID", "ENVKEY") + t.Setenv("ASC_PRIVATE_KEY", "line1\\nline2") + got, source, err := GetAppleCredentials() + if err != nil || source != AppleSourceEnv || got.IssuerID != "env-issuer" || got.KeyID != "ENVKEY" || got.PrivateKey != "line1\nline2\n" { + t.Errorf("env credentials: %+v %s %v", got, source, err) + } + + t.Setenv("ASC_PRIVATE_KEY", "") + keyPath := filepath.Join(dir, "AuthKey.p8") + if err := os.WriteFile(keyPath, []byte("from-file\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ASC_KEY_PATH", keyPath) + got, _, err = GetAppleCredentials() + if err != nil || got.PrivateKey != "from-file\n" { + t.Errorf("key path: %+v %v", got, err) + } + t.Setenv("ASC_KEY_PATH", filepath.Join(dir, "missing.p8")) + if _, _, err := GetAppleCredentials(); err == nil { + t.Error("missing key file must be an error") + } +} diff --git a/internal/auth/providers.go b/internal/auth/providers.go index 3f3a596..611bfad 100644 --- a/internal/auth/providers.go +++ b/internal/auth/providers.go @@ -31,20 +31,25 @@ func GetProviderToken(provider string) (string, error) { if token := strings.TrimSpace(os.Getenv(strings.ToUpper(provider) + "_API_TOKEN")); token != "" { return token, nil } + return readSecret(provider + "-token") +} + +// readSecret returns a saved secret by name, checking the fallback file before +// the keyring: the file may contain a newer login than an inaccessible old +// keyring entry, and a successful keyring save removes the file. +func readSecret(name string) (string, error) { dir, err := getConfigDir() if err != nil { return "", err } - // A fallback file may contain a newer login than an inaccessible old - // keyring entry. A successful keyring save removes this file. - if token, err := readProviderFile(filepath.Join(dir, provider+"-token")); err == nil { - return token, nil + if value, err := readProviderFile(filepath.Join(dir, name)); err == nil { + return value, nil } else if !errors.Is(err, ErrNotAuthenticated) { return "", err } if runtime.GOOS != "linux" { - if token, err := keyring.Get(keyringService, provider+"-token"); err == nil && token != "" { - return token, nil + if value, err := keyring.Get(keyringService, name); err == nil && value != "" { + return value, nil } } return "", ErrNotAuthenticated @@ -78,20 +83,26 @@ func StoreProviderToken(provider, token string) error { if provider == "github" { return storeToken(token) } + return writeSecret(provider+"-token", token) +} + +// writeSecret saves a secret in the keyring, falling back to a 0600 file in +// the config directory on Linux/WSL or when the keyring is unavailable. +func writeSecret(name, value string) error { dir, err := getConfigDir() if err != nil { return err } - path := filepath.Join(dir, provider+"-token") + path := filepath.Join(dir, name) if runtime.GOOS != "linux" { - if err := keyring.Set(keyringService, provider+"-token", token); err == nil { + if err := keyring.Set(keyringService, name, value); err == nil { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return nil } } - return writeProviderFile(path, token) + return writeProviderFile(path, value) } func writeProviderFile(path, token string) error { @@ -113,15 +124,23 @@ func writeProviderFile(path, token string) error { // LogoutProvider removes only this provider's saved login, leaving others intact. // It does not unset environment variables in the parent shell. func LogoutProvider(provider string) error { + switch provider { + case "github": + return Logout() + case "apple": + return deleteSecret(appleSecretName) + } if err := validateProvider(provider); err != nil { return err } - if provider == "github" { - return Logout() - } + return deleteSecret(provider + "-token") +} + +// deleteSecret removes a secret from both the keyring and the fallback file. +func deleteSecret(name string) error { var keyringErr error if runtime.GOOS != "linux" { - if err := keyring.Delete(keyringService, provider+"-token"); err != nil && err != keyring.ErrNotFound { + if err := keyring.Delete(keyringService, name); err != nil && err != keyring.ErrNotFound { keyringErr = err } } @@ -129,7 +148,7 @@ func LogoutProvider(provider string) error { if err != nil { return err } - err = os.Remove(filepath.Join(dir, provider+"-token")) + err = os.Remove(filepath.Join(dir, name)) if os.IsNotExist(err) { err = nil } diff --git a/internal/dev/session.go b/internal/dev/session.go index 09beae5..b3bf72d 100644 --- a/internal/dev/session.go +++ b/internal/dev/session.go @@ -2,19 +2,17 @@ package dev import ( - "archive/zip" "context" "fmt" - "io" "os" "os/exec" "path/filepath" "strings" + "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/gorilla/websocket" "github.com/manifoldco/promptui" - "howett.net/plist" ) // FrameworkHandler handles framework-specific dev workflow. @@ -203,7 +201,7 @@ func (s *Session) installApp(ctx context.Context) error { // Read the IPA from the local path; on WSL absPath becomes a Windows path // that only MobAI can open. - ipaBundleID := extractBundleIDFromIPA(absPath) + ipaBundleID := ipa.BundleID(absPath) absPath = toWindowsPathIfWSL(absPath) req := mobai.InstallAppRequest{Path: absPath} @@ -262,37 +260,6 @@ func guessBundleID(resp *mobai.InstallAppResponse, ipaBundleID string, resigned return ipaBundleID } -func extractBundleIDFromIPA(ipaPath string) string { - r, err := zip.OpenReader(ipaPath) - if err != nil { - return "" - } - defer func() { _ = r.Close() }() - - for _, f := range r.File { - if strings.HasPrefix(f.Name, "Payload/") && strings.HasSuffix(f.Name, ".app/Info.plist") { - rc, err := f.Open() - if err != nil { - return "" - } - data, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return "" - } - - var info struct { - BundleID string `plist:"CFBundleIdentifier"` - } - if _, err := plist.Unmarshal(data, &info); err != nil { - return "" - } - return info.BundleID - } - } - return "" -} - func (s *Session) launchApp(ctx context.Context) (<-chan mobai.DebugOutput, error) { fmt.Println("Launching app with debugger...") diff --git a/internal/dev/session_test.go b/internal/dev/session_test.go index 2faf286..d0dabd2 100644 --- a/internal/dev/session_test.go +++ b/internal/dev/session_test.go @@ -1,51 +1,11 @@ package dev import ( - "archive/zip" - "os" - "path/filepath" "testing" "github.com/MobAI-App/ios-builder/internal/mobai" ) -func writeTestIPA(t *testing.T, bundleID string) string { - t.Helper() - path := filepath.Join(t.TempDir(), "App.ipa") - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - zw := zip.NewWriter(f) - w, err := zw.Create("Payload/App.app/Info.plist") - if err != nil { - t.Fatal(err) - } - plist := ` - -CFBundleIdentifier` + bundleID + `` - if _, err := w.Write([]byte(plist)); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - return path -} - -func TestExtractBundleIDFromIPA(t *testing.T) { - path := writeTestIPA(t, "com.example.app") - if got := extractBundleIDFromIPA(path); got != "com.example.app" { - t.Errorf("extractBundleIDFromIPA = %q, want com.example.app", got) - } - if got := extractBundleIDFromIPA(filepath.Join(t.TempDir(), "missing.ipa")); got != "" { - t.Errorf("missing IPA = %q, want empty", got) - } -} - func TestGuessBundleID(t *testing.T) { response := func(teamID string) *mobai.InstallAppResponse { resp := &mobai.InstallAppResponse{} diff --git a/internal/distribute/appstore.go b/internal/distribute/appstore.go new file mode 100644 index 0000000..1fdd274 --- /dev/null +++ b/internal/distribute/appstore.go @@ -0,0 +1,142 @@ +package distribute + +import ( + "context" + "fmt" + "io" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// AppStoreOptions configures SubmitAppStore. +type AppStoreOptions struct { + BundleID string + // Version is the marketing version to submit (CFBundleShortVersionString). + Version string + // BuildNumber narrows the build; empty picks the newest VALID build of Version. + BuildNumber string + // ReleaseType is asc.ReleaseTypeManual or asc.ReleaseTypeAfterApproval; empty leaves it as is. + ReleaseType string + // NoEncryption answers export compliance with "no" when still unanswered. + NoEncryption bool + Log io.Writer +} + +// VersionRef describes the App Store version. +type VersionRef struct { + ID string `json:"id"` + VersionString string `json:"version_string"` + State string `json:"state"` + ReleaseType string `json:"release_type,omitempty"` + Created bool `json:"created"` +} + +// AppStoreResult is what SubmitAppStore reports. +type AppStoreResult struct { + App AppRef `json:"app"` + Build BuildRef `json:"build"` + Compliance string `json:"encryption_compliance"` + Version VersionRef `json:"version"` + Submission ReviewRef `json:"submission"` + Link string `json:"link"` +} + +// SubmitAppStore attaches a build to the App Store version and submits it for review. +func SubmitAppStore(ctx context.Context, client *asc.Client, opts *AppStoreOptions) (*AppStoreResult, error) { + if opts.Version == "" { + return nil, fmt.Errorf("a marketing version is required (--version, or --ipa to read it from the archive)") + } + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + build, err := pickBuild(ctx, client, app.ID, opts.Version, opts.BuildNumber) + if err != nil { + return nil, err + } + res := &AppStoreResult{App: appRef(app), Build: buildRef(app.ID, opts.Version, build), Link: distributionLink(app.ID)} + logf(opts.Log, "Using build %s (%s) for version %s", build.BuildNumber, build.ID, opts.Version) + + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, opts.NoEncryption) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + + versions, err := client.ListAppStoreVersions(ctx, app.ID, asc.PlatformIOS, opts.Version) + if err != nil { + return res, err + } + var version *asc.AppStoreVersion + if len(versions) > 0 { + version = &versions[0] + logf(opts.Log, "App Store version %s exists (%s)", version.VersionString, version.State) + } else { + logf(opts.Log, "Creating App Store version %s...", opts.Version) + version, err = client.CreateAppStoreVersion(ctx, app.ID, asc.PlatformIOS, opts.Version) + if err != nil { + return res, stateErrorHint(err, "create App Store version") + } + res.Version.Created = true + } + res.Version.ID, res.Version.VersionString, res.Version.State, res.Version.ReleaseType = version.ID, version.VersionString, version.State, version.ReleaseType + switch version.State { + case asc.VersionStateWaitingForReview, asc.VersionStateInReview: + return res, fmt.Errorf("version %s is already %s; cancel that submission in App Store Connect before submitting another build", version.VersionString, version.State) + } + + update := asc.AppStoreVersionUpdate{ReleaseType: opts.ReleaseType} + if version.BuildID != build.ID { + update.BuildID = build.ID + } + if update.BuildID != "" || update.ReleaseType != "" { + version, err = client.UpdateAppStoreVersion(ctx, version.ID, update) + if err != nil { + return res, stateErrorHint(err, "attach build to version") + } + res.Version.State, res.Version.ReleaseType = version.State, version.ReleaseType + logf(opts.Log, "Attached build %s to version %s (release: %s)", build.BuildNumber, version.VersionString, version.ReleaseType) + } + + // Reuse an open submission: App Store Connect allows one per platform. + subs, err := client.ListReviewSubmissions(ctx, app.ID, asc.PlatformIOS, []string{asc.ReviewStateReadyForReview, asc.ReviewStateUnresolvedIssues}) + if err != nil { + return res, err + } + var sub *asc.ReviewSubmission + if len(subs) > 0 { + sub = &subs[0] + logf(opts.Log, "Using open review submission %s (%s)", sub.ID, sub.State) + } else { + sub, err = client.CreateReviewSubmission(ctx, app.ID, asc.PlatformIOS) + if err != nil { + return res, stateErrorHint(err, "create review submission") + } + } + res.Submission = ReviewRef{ID: sub.ID, State: sub.State} + + items, err := client.ListReviewSubmissionItems(ctx, sub.ID) + if err != nil { + return res, err + } + hasVersion := false + for _, item := range items { + if item.AppStoreVersionID == version.ID { + hasVersion = true + } + } + if !hasVersion { + if _, err := client.AddAppStoreVersionToReviewSubmission(ctx, sub.ID, version.ID); err != nil { + return res, stateErrorHint(err, "add version to review submission") + } + } + + logf(opts.Log, "Submitting version %s for App Review...", version.VersionString) + submitted, err := client.SubmitReviewSubmission(ctx, sub.ID) + if err != nil { + return res, stateErrorHint(err, "submit for review") + } + res.Submission.State = submitted.State + logf(opts.Log, "Submitted: %s (%s)", res.Link, submitted.State) + return res, nil +} diff --git a/internal/distribute/distribute.go b/internal/distribute/distribute.go new file mode 100644 index 0000000..bfffd15 --- /dev/null +++ b/internal/distribute/distribute.go @@ -0,0 +1,141 @@ +// Package distribute drives the App Store Connect flows behind +// `builder ios upload` and `builder ios submit`: deliver an IPA, wait for +// processing, hand a build to TestFlight groups, and submit an App Store +// version for review. It only orchestrates; every API call lives in asc. +package distribute + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// AppRef identifies the App Store Connect app in results. +type AppRef struct { + ID string `json:"id"` + Name string `json:"name"` + BundleID string `json:"bundle_id"` +} + +// BuildRef describes a build in results. +type BuildRef struct { + ID string `json:"id"` + Version string `json:"version,omitempty"` + BuildNumber string `json:"build_number"` + ProcessingState string `json:"processing_state"` + UsesNonExemptEncryption *bool `json:"uses_non_exempt_encryption"` + Link string `json:"link"` +} + +func appRef(a *asc.App) AppRef { + return AppRef{ID: a.ID, Name: a.Name, BundleID: a.BundleID} +} + +func buildRef(appID, version string, b *asc.Build) BuildRef { + return BuildRef{ + ID: b.ID, + Version: version, + BuildNumber: b.BuildNumber, + ProcessingState: b.ProcessingState, + UsesNonExemptEncryption: b.UsesNonExemptEncryption, + Link: buildLink(appID, b.ID), + } +} + +func testflightLink(appID string) string { + return "https://appstoreconnect.apple.com/apps/" + appID + "/testflight/ios" +} + +func buildLink(appID, buildID string) string { + return testflightLink(appID) + "/" + buildID +} + +func distributionLink(appID string) string { + return "https://appstoreconnect.apple.com/apps/" + appID + "/distribution" +} + +func logf(w io.Writer, format string, args ...any) { + if w != nil { + fmt.Fprintf(w, format+"\n", args...) + } +} + +func pollInterval(d time.Duration) time.Duration { + if d <= 0 { + return 15 * time.Second + } + return d +} + +// pickBuild returns the newest VALID, unexpired build matching the filters. +func pickBuild(ctx context.Context, client *asc.Client, appID, version, buildNumber string) (*asc.Build, error) { + f := &asc.BuildFilter{AppID: appID, Platform: asc.PlatformIOS, Version: version, BuildNumber: buildNumber, ProcessingState: asc.ProcessingStateValid, ExcludeExpired: true, Limit: 1} + builds, err := client.ListBuilds(ctx, f) + if err != nil { + return nil, err + } + if len(builds) > 0 { + return &builds[0], nil + } + // Explain why rather than just "not found": the build may still be processing. + f.ProcessingState, f.ExcludeExpired = "", false + matches, err := client.ListBuilds(ctx, f) + if err != nil { + return nil, err + } + what := "no build" + if buildNumber != "" { + what = "build " + buildNumber + } + if version != "" { + what += " of version " + version + } + if len(matches) == 0 { + return nil, fmt.Errorf("%s is available in App Store Connect; upload one with builder ios upload --wait", what) + } + b := matches[0] + if b.Expired { + return nil, fmt.Errorf("%s (%s) has expired; upload a new build", what, b.ID) + } + return nil, fmt.Errorf("%s (%s) is %s; wait for processing to finish (builder ios upload --wait) and retry", what, b.ID, b.ProcessingState) +} + +// setCompliance answers the export compliance question with "no non-exempt +// encryption" when the caller asked for it and the build is still unanswered. +func setCompliance(ctx context.Context, client *asc.Client, log io.Writer, build *asc.Build, exempt bool) (string, error) { + switch { + case build.UsesNonExemptEncryption != nil: + return "already_set", nil + case !exempt: + return "pending", nil + } + logf(log, "Setting export compliance: no non-exempt encryption") + updated, err := client.SetUsesNonExemptEncryption(ctx, build.ID, false) + if err != nil { + return "", fmt.Errorf("set export compliance: %w", err) + } + build.UsesNonExemptEncryption = updated.UsesNonExemptEncryption + return "set_exempt", nil +} + +// stateErrorHint rephrases App Store Connect's 409 state conflicts, which are +// nearly always incomplete metadata or a version in the wrong state. +func stateErrorHint(err error, what string) error { + var e *asc.Error + if errors.As(err, &e) && (e.StatusCode == 409 || e.StatusCode == 422) { + return fmt.Errorf("%s: %w. App Store Connect needs the version's metadata complete before review (description, screenshots, age rating, pricing, privacy); finish it in App Store Connect or with asc-cli (https://github.com/tddworks/asc-cli), then rerun", what, err) + } + return fmt.Errorf("%s: %w", what, err) +} + +func joinDetails(details []asc.StateDetail) []string { + out := make([]string, 0, len(details)) + for _, d := range details { + out = append(out, d.String()) + } + return out +} diff --git a/internal/distribute/distribute_test.go b/internal/distribute/distribute_test.go new file mode 100644 index 0000000..8b1597e --- /dev/null +++ b/internal/distribute/distribute_test.go @@ -0,0 +1,374 @@ +package distribute + +import ( + "archive/zip" + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +func writeIPA(t *testing.T, plistBody string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "App.ipa") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + w, err := zw.Create("Payload/App.app/Info.plist") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`` + plistBody + ``)) + bin, _ := zw.Create("Payload/App.app/App") + payload := make([]byte, 3000) + _, _ = rand.Read(payload) + _, _ = bin.Write(payload) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + return path +} + +const plistExempt = `CFBundleIdentifiercom.example.appCFBundleShortVersionString2.0.0CFBundleVersion7ITSAppUsesNonExemptEncryption` +const plistUndeclared = `CFBundleIdentifiercom.example.appCFBundleShortVersionString2.0.0CFBundleVersion7` + +// fake is an in-memory App Store Connect covering the routes the flows use. +type fake struct { + t *testing.T + srv *httptest.Server + mu sync.Mutex + // calls lists "METHOD /path" in order; bodies keeps the last body per call. + calls []string + bodies map[string]map[string]any + // state knobs + buildState string + buildEncryption *bool + versionExists bool + versionState string + openSubmission bool + submitStatus int + betaReviewExists bool +} + +func newFake(t *testing.T) *fake { + f := &fake{t: t, bodies: map[string]map[string]any{}, buildState: "VALID", versionState: "PREPARE_FOR_SUBMISSION", submitStatus: 200} + mux := http.NewServeMux() + res := func(typ, id string, attrs map[string]any, rels map[string]any) map[string]any { + r := map[string]any{"type": typ, "id": id, "attributes": attrs} + if rels != nil { + r["relationships"] = rels + } + return r + } + one := func(w http.ResponseWriter, status int, r any) { writeJSON(w, status, map[string]any{"data": r}) } + many := func(w http.ResponseWriter, rs ...any) { + if rs == nil { + rs = []any{} + } + writeJSON(w, 200, map[string]any{"data": rs}) + } + record := func(r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + key := r.Method + " " + r.URL.Path + f.calls = append(f.calls, key) + if r.Body != nil { + var body map[string]any + data, _ := io.ReadAll(r.Body) + if json.Unmarshal(data, &body) == nil { + f.bodies[key] = body + } + } + } + wrap := func(h func(w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chunk" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + f.t.Errorf("%s %s without bearer token", r.Method, r.URL.Path) + } + record(r) + f.mu.Lock() + defer f.mu.Unlock() + h(w, r) + } + } + build := func() map[string]any { + attrs := map[string]any{"version": "7", "processingState": f.buildState, "uploadedDate": "2026-09-16T10:00:00Z", "expired": false} + if f.buildEncryption != nil { + attrs["usesNonExemptEncryption"] = *f.buildEncryption + } + return res("builds", "build-9", attrs, nil) + } + mux.HandleFunc("GET /v1/apps", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[bundleId]") != "com.example.app" { + many(w) + return + } + many(w, res("apps", "app-1", map[string]any{"bundleId": "com.example.app", "name": "Example", "primaryLocale": "de-DE"}, nil)) + })) + mux.HandleFunc("POST /v1/buildUploads", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("buildUploads", "up-1", map[string]any{"state": map[string]any{"state": "AWAITING_UPLOAD"}}, nil)) + })) + mux.HandleFunc("POST /v1/buildUploadFiles", wrap(func(w http.ResponseWriter, r *http.Request) { + size, _ := obj(f.t, f.bodies["POST /v1/buildUploadFiles"], "data", "attributes")["fileSize"].(float64) + one(w, 201, res("buildUploadFiles", "file-1", map[string]any{"uploadOperations": []map[string]any{{"method": "PUT", "url": f.srv.URL + "/chunk", "offset": 0, "length": int64(size), "requestHeaders": []map[string]string{{"name": "X-Test", "value": "1"}}}}}, nil)) + })) + mux.HandleFunc("PUT /chunk", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Test") != "1" { + f.t.Error("chunk request header missing") + } + w.WriteHeader(200) + })) + mux.HandleFunc("PATCH /v1/buildUploadFiles/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { one(w, 200, res("buildUploadFiles", "file-1", nil, nil)) })) + mux.HandleFunc("GET /v1/buildUploads/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("buildUploads", "up-1", map[string]any{"cfBundleShortVersionString": "2.0.0", "cfBundleVersion": "7", "state": map[string]any{"state": "COMPLETE", "warnings": []map[string]string{{"code": "ITMS-90000", "description": "Some warning"}}}}, nil)) + })) + mux.HandleFunc("GET /v1/builds", wrap(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("filter[app]") != "app-1" || q.Get("filter[preReleaseVersion.platform]") != "IOS" || q.Get("sort") != "-uploadedDate" { + f.t.Errorf("builds query = %v", q) + } + if q.Get("filter[processingState]") == "VALID" && f.buildState != "VALID" { + many(w) + return + } + many(w, build()) + })) + mux.HandleFunc("PATCH /v1/builds/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + v, _ := obj(f.t, f.bodies["PATCH /v1/builds/build-9"], "data", "attributes")["usesNonExemptEncryption"].(bool) + f.buildEncryption = &v + one(w, 200, build()) + })) + mux.HandleFunc("GET /v1/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { + many(w, + res("betaGroups", "g-int", map[string]any{"name": "Team", "isInternalGroup": true}, nil), + res("betaGroups", "g-ext", map[string]any{"name": "Beta Testers", "isInternalGroup": false, "publicLinkEnabled": true}, nil)) + })) + mux.HandleFunc("GET /v1/builds/{id}/betaBuildLocalizations", wrap(func(w http.ResponseWriter, r *http.Request) { + many(w, res("betaBuildLocalizations", "loc-en", map[string]any{"locale": "en-US", "whatsNew": "old"}, nil)) + })) + mux.HandleFunc("POST /v1/betaBuildLocalizations", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("betaBuildLocalizations", "loc-de", map[string]any{"locale": "de-DE"}, nil)) + })) + mux.HandleFunc("PATCH /v1/betaBuildLocalizations/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("betaBuildLocalizations", "loc-en", map[string]any{"locale": "en-US"}, nil)) + })) + mux.HandleFunc("GET /v1/builds/{id}/betaAppReviewSubmission", wrap(func(w http.ResponseWriter, r *http.Request) { + if f.betaReviewExists { + one(w, 200, res("betaAppReviewSubmissions", "bar-0", map[string]any{"betaReviewState": "APPROVED"}, nil)) + return + } + one(w, 200, nil) + })) + mux.HandleFunc("POST /v1/betaAppReviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("betaAppReviewSubmissions", "bar-1", map[string]any{"betaReviewState": "WAITING_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("GET /v1/betaAppReviewSubmissions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 200, res("betaAppReviewSubmissions", "bar-1", map[string]any{"betaReviewState": "APPROVED"}, nil)) + })) + mux.HandleFunc("POST /v1/builds/{id}/relationships/betaGroups", wrap(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) + version := func() map[string]any { + return res("appStoreVersions", "ver-1", map[string]any{"platform": "IOS", "versionString": "2.0.0", "appVersionState": f.versionState, "releaseType": "MANUAL"}, map[string]any{"build": map[string]any{"data": nil}}) + } + mux.HandleFunc("GET /v1/apps/{id}/appStoreVersions", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[versionString]") != "2.0.0" || r.URL.Query().Get("filter[platform]") != "IOS" { + f.t.Errorf("versions query = %v", r.URL.Query()) + } + if f.versionExists { + many(w, version()) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/appStoreVersions", wrap(func(w http.ResponseWriter, r *http.Request) { f.versionExists = true; one(w, 201, version()) })) + mux.HandleFunc("PATCH /v1/appStoreVersions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + v := version() + obj(f.t, v, "attributes")["releaseType"] = "AFTER_APPROVAL" + v["relationships"] = map[string]any{"build": map[string]any{"data": map[string]string{"type": "builds", "id": "build-9"}}} + one(w, 200, v) + })) + mux.HandleFunc("GET /v1/reviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("filter[state]") != "READY_FOR_REVIEW,UNRESOLVED_ISSUES" { + f.t.Errorf("submissions query = %v", r.URL.Query()) + } + if f.openSubmission { + many(w, res("reviewSubmissions", "rs-0", map[string]any{"platform": "IOS", "state": "READY_FOR_REVIEW"}, nil)) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/reviewSubmissions", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("reviewSubmissions", "rs-1", map[string]any{"platform": "IOS", "state": "READY_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("GET /v1/reviewSubmissions/{id}/items", wrap(func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("id") == "rs-0" { + many(w, res("reviewSubmissionItems", "item-0", map[string]any{"state": "READY_FOR_REVIEW"}, map[string]any{"appStoreVersion": map[string]any{"data": map[string]string{"type": "appStoreVersions", "id": "ver-1"}}})) + return + } + many(w) + })) + mux.HandleFunc("POST /v1/reviewSubmissionItems", wrap(func(w http.ResponseWriter, r *http.Request) { + one(w, 201, res("reviewSubmissionItems", "item-1", map[string]any{"state": "READY_FOR_REVIEW"}, nil)) + })) + mux.HandleFunc("PATCH /v1/reviewSubmissions/{id}", wrap(func(w http.ResponseWriter, r *http.Request) { + if f.submitStatus != 200 { + writeJSON(w, f.submitStatus, map[string]any{"errors": []map[string]any{{"status": "409", "code": "STATE_ERROR.ENTITY_STATE_INVALID", "title": "The request cannot be fulfilled because of the state of another resource.", "detail": "You must provide a screenshot for iPhone 6.5\" displays."}}}) + return + } + one(w, 200, res("reviewSubmissions", r.PathValue("id"), map[string]any{"platform": "IOS", "state": "WAITING_FOR_REVIEW", "submittedDate": "2026-09-16T11:00:00Z"}, nil)) + })) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + f.t.Errorf("unexpected request %s %s", r.Method, r.URL) + w.WriteHeader(404) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// obj walks decoded JSON down the given object keys; a missing or non-object +// step fails the test and yields nil, which later lookups tolerate. +func obj(t *testing.T, v any, keys ...string) map[string]any { + t.Helper() + for i := 0; ; i++ { + m, ok := v.(map[string]any) + if !ok { + t.Errorf("JSON path %v: %T is not an object", keys[:i], v) + return nil + } + if i == len(keys) { + return m + } + v = m[keys[i]] + } +} + +// arr is obj for a final array value. +func arr(t *testing.T, v any, keys ...string) []any { + t.Helper() + a, ok := obj(t, v, keys[:len(keys)-1]...)[keys[len(keys)-1]].([]any) + if !ok { + t.Errorf("JSON path %v is not an array", keys) + } + return a +} + +func (f *fake) client(t *testing.T) *asc.Client { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(key) + creds := asc.Credentials{IssuerID: "iss", KeyID: "kid", PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} + c, err := asc.NewClient(creds, asc.WithBaseURL(f.srv.URL), asc.WithRetryDelay(time.Millisecond)) + if err != nil { + t.Fatal(err) + } + return c +} + +func (f *fake) called(key string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c == key { + return true + } + } + return false +} + +func (f *fake) body(key string) map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return f.bodies[key] +} + +func TestUploadWithWaitSetsCompliance(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistExempt), Wait: true, PollInterval: time.Millisecond, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if res.App.ID != "app-1" || res.IPA.Version != "2.0.0" || res.IPA.BuildNumber != "7" || res.Upload.ID != "up-1" || res.Upload.State != "COMPLETE" { + t.Errorf("result = %+v", res) + } + if res.Build == nil || res.Build.ID != "build-9" || res.Build.ProcessingState != "VALID" || res.Compliance != "set_exempt" || res.Build.UsesNonExemptEncryption == nil || *res.Build.UsesNonExemptEncryption { + t.Errorf("build = %+v, compliance = %s", res.Build, res.Compliance) + } + if res.Link != "https://appstoreconnect.apple.com/apps/app-1/testflight/ios/build-9" { + t.Errorf("link = %s", res.Link) + } + if len(res.Upload.Warnings) != 1 || !strings.Contains(log.String(), "ITMS-90000") { + t.Errorf("warnings not surfaced: %+v\n%s", res.Upload.Warnings, log.String()) + } + for _, key := range []string{"POST /v1/buildUploads", "POST /v1/buildUploadFiles", "PUT /chunk", "PATCH /v1/buildUploadFiles/file-1", "GET /v1/buildUploads/up-1", "GET /v1/builds", "PATCH /v1/builds/build-9"} { + if !f.called(key) { + t.Errorf("%s not called; calls = %v", key, f.calls) + } + } + attrs := obj(t, f.body("POST /v1/buildUploads"), "data", "attributes") + if attrs["cfBundleShortVersionString"] != "2.0.0" || attrs["cfBundleVersion"] != "7" || attrs["platform"] != "IOS" { + t.Errorf("upload attributes = %v", attrs) + } +} + +func TestUploadWithoutWaitLeavesComplianceForLater(t *testing.T) { + f := newFake(t) + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistUndeclared), NoEncryption: true}) + if err != nil { + t.Fatal(err) + } + if res.Build != nil || res.Compliance != "pending" || res.Link != "https://appstoreconnect.apple.com/apps/app-1/testflight/ios" { + t.Errorf("result = %+v", res) + } + if f.called("PATCH /v1/builds/build-9") || f.called("GET /v1/builds") { + t.Errorf("must not touch builds without --wait: %v", f.calls) + } +} + +func TestUploadUndeclaredEncryptionStaysPending(t *testing.T) { + f := newFake(t) + res, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, plistUndeclared), Wait: true, PollInterval: time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if res.Compliance != "pending" || f.called("PATCH /v1/builds/build-9") { + t.Errorf("compliance = %s, calls = %v", res.Compliance, f.calls) + } +} + +func TestUploadUnknownApp(t *testing.T) { + f := newFake(t) + _, err := Upload(context.Background(), f.client(t), &UploadOptions{IPAPath: writeIPA(t, strings.ReplaceAll(plistExempt, "com.example.app", "com.other"))}) + if err == nil || !strings.Contains(err.Error(), "com.other") { + t.Errorf("err = %v", err) + } +} diff --git a/internal/distribute/submit_test.go b/internal/distribute/submit_test.go new file mode 100644 index 0000000..2a4f740 --- /dev/null +++ b/internal/distribute/submit_test.go @@ -0,0 +1,175 @@ +package distribute + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +func TestSubmitTestFlightExternalGroup(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{ + BundleID: "com.example.app", Groups: []string{"team", "Beta Testers"}, Notes: "Try the new login", NoEncryption: true, Wait: true, PollInterval: time.Millisecond, Log: &log, + }) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if res.Build.ID != "build-9" || res.Compliance != "set_exempt" || len(res.Groups) != 2 || res.Groups[0].Name != "Team" || !res.Groups[0].Internal || res.Groups[1].Internal { + t.Errorf("result = %+v", res) + } + if res.BetaReview == nil || res.BetaReview.ID != "bar-1" || res.BetaReview.State != "APPROVED" { + t.Errorf("beta review = %+v", res.BetaReview) + } + // Notes go to the app's primary locale, which has no localization yet, so it is created. + notes := obj(t, f.body("POST /v1/betaBuildLocalizations"), "data", "attributes") + if notes["locale"] != "de-DE" || notes["whatsNew"] != "Try the new login" { + t.Errorf("localization body = %v", notes) + } + links := arr(t, f.body("POST /v1/builds/build-9/relationships/betaGroups"), "data") + if len(links) != 2 || obj(t, links[1])["id"] != "g-ext" { + t.Errorf("group linkage = %v", links) + } + // Review must be requested before the build lands in the external group. + var reviewAt, groupAt int + for i, c := range f.calls { + switch c { + case "POST /v1/betaAppReviewSubmissions": + reviewAt = i + case "POST /v1/builds/build-9/relationships/betaGroups": + groupAt = i + } + } + if reviewAt == 0 || groupAt < reviewAt { + t.Errorf("order: %v", f.calls) + } +} + +func TestSubmitTestFlightUpdatesExistingNotesAndSkipsReviewForInternal(t *testing.T) { + f := newFake(t) + yes := false + f.buildEncryption = &yes + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7", Groups: []string{"Team"}, Notes: "n", Locale: "en-US"}) + if err != nil { + t.Fatal(err) + } + if res.Compliance != "already_set" || res.BetaReview != nil || f.called("POST /v1/betaAppReviewSubmissions") || f.called("PATCH /v1/builds/build-9") { + t.Errorf("result = %+v, calls = %v", res, f.calls) + } + if !f.called("PATCH /v1/betaBuildLocalizations/loc-en") || f.called("POST /v1/betaBuildLocalizations") { + t.Errorf("existing locale must be updated: %v", f.calls) + } +} + +func TestSubmitTestFlightListsGroupsWithoutGroupFlag(t *testing.T) { + f := newFake(t) + res, err := SubmitTestFlight(context.Background(), f.client(t), &TestFlightOptions{BundleID: "com.example.app"}) + if err != nil { + t.Fatal(err) + } + if len(res.AvailableGroups) != 2 || len(res.Groups) != 0 || f.called("POST /v1/builds/build-9/relationships/betaGroups") { + t.Errorf("result = %+v", res) + } +} + +func TestSubmitTestFlightErrors(t *testing.T) { + f := newFake(t) + c := f.client(t) + _, err := SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Nobody"}, NoEncryption: true}) + if err == nil || !strings.Contains(err.Error(), "Nobody") || !strings.Contains(err.Error(), "Beta Testers") { + t.Errorf("unknown group: %v", err) + } + f.mu.Lock() + f.buildEncryption = nil // the call above answered it + f.mu.Unlock() + _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", Groups: []string{"Team"}}) + if err == nil || !strings.Contains(err.Error(), "export compliance") { + t.Errorf("missing compliance: %v", err) + } + f.buildState = "PROCESSING" + _, err = SubmitTestFlight(context.Background(), c, &TestFlightOptions{BundleID: "com.example.app", BuildNumber: "7"}) + if err == nil || !strings.Contains(err.Error(), "PROCESSING") { + t.Errorf("processing build: %v", err) + } +} + +func TestSubmitAppStoreCreatesVersionAndSubmission(t *testing.T) { + f := newFake(t) + var log bytes.Buffer + res, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", ReleaseType: asc.ReleaseTypeAfterApproval, NoEncryption: true, Log: &log}) + if err != nil { + t.Fatalf("%v\n%s", err, log.String()) + } + if !res.Version.Created || res.Version.ID != "ver-1" || res.Version.ReleaseType != "AFTER_APPROVAL" || res.Submission.ID != "rs-1" || res.Submission.State != "WAITING_FOR_REVIEW" { + t.Errorf("result = %+v", res) + } + create := obj(t, f.body("POST /v1/appStoreVersions"), "data") + if attrs := obj(t, create, "attributes"); attrs["versionString"] != "2.0.0" || attrs["platform"] != "IOS" || obj(t, create, "relationships", "app", "data")["id"] != "app-1" { + t.Errorf("version create = %v", create) + } + upd := obj(t, f.body("PATCH /v1/appStoreVersions/ver-1"), "data") + if obj(t, upd, "attributes")["releaseType"] != "AFTER_APPROVAL" || obj(t, upd, "relationships", "build", "data")["id"] != "build-9" { + t.Errorf("version update = %v", upd) + } + item := obj(t, f.body("POST /v1/reviewSubmissionItems"), "data", "relationships") + if obj(t, item, "reviewSubmission", "data")["id"] != "rs-1" || obj(t, item, "appStoreVersion", "data")["id"] != "ver-1" { + t.Errorf("item = %v", item) + } + submit := f.body("PATCH /v1/reviewSubmissions/rs-1") + if obj(t, submit, "data", "attributes")["submitted"] != true { + t.Errorf("submit = %v", submit) + } +} + +func TestSubmitAppStoreReusesOpenSubmission(t *testing.T) { + f := newFake(t) + f.versionExists, f.openSubmission = true, true + yes := true + f.buildEncryption = &yes + res, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0"}) + if err != nil { + t.Fatal(err) + } + if res.Version.Created || res.Submission.ID != "rs-0" || f.called("POST /v1/appStoreVersions") || f.called("POST /v1/reviewSubmissions") || f.called("POST /v1/reviewSubmissionItems") { + t.Errorf("result = %+v, calls = %v", res, f.calls) + } + if !f.called("PATCH /v1/reviewSubmissions/rs-0") { + t.Errorf("not submitted: %v", f.calls) + } +} + +func TestSubmitAppStoreMetadataConflict(t *testing.T) { + f := newFake(t) + f.submitStatus = 409 + _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + var apiErr *asc.Error + if !errors.As(err, &apiErr) || apiErr.StatusCode != 409 { + t.Fatalf("err = %v", err) + } + msg := err.Error() + for _, want := range []string{"screenshot for iPhone", "metadata", "asc-cli"} { + if !strings.Contains(msg, want) { + t.Errorf("%q lacks %q", msg, want) + } + } + if strings.Contains(msg, "\n") { + t.Error("error spans lines") + } +} + +func TestSubmitAppStoreRefusesVersionInReview(t *testing.T) { + f := newFake(t) + f.versionExists, f.versionState = true, "IN_REVIEW" + _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app", Version: "2.0.0", NoEncryption: true}) + if err == nil || !strings.Contains(err.Error(), "IN_REVIEW") { + t.Errorf("err = %v", err) + } + if _, err := SubmitAppStore(context.Background(), f.client(t), &AppStoreOptions{BundleID: "com.example.app"}); err == nil { + t.Error("missing version accepted") + } +} diff --git a/internal/distribute/testflight.go b/internal/distribute/testflight.go new file mode 100644 index 0000000..a43cfe8 --- /dev/null +++ b/internal/distribute/testflight.go @@ -0,0 +1,182 @@ +package distribute + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// TestFlightOptions configures SubmitTestFlight. +type TestFlightOptions struct { + BundleID string + // Version and BuildNumber narrow the build; empty picks the newest VALID build. + Version string + BuildNumber string + // Groups are TestFlight group names (case-insensitive). Empty adds the + // build nowhere and reports the available groups instead. + Groups []string + // Notes is the "What to Test" text; Locale defaults to the app's primary locale. + Notes string + Locale string + // NoEncryption answers export compliance with "no" when still unanswered. + NoEncryption bool + // Wait follows the external beta review until it is decided. + Wait bool + PollInterval time.Duration + Log io.Writer +} + +// GroupRef describes a TestFlight group. +type GroupRef struct { + ID string `json:"id"` + Name string `json:"name"` + Internal bool `json:"internal"` +} + +// ReviewRef describes a review's state. +type ReviewRef struct { + ID string `json:"id"` + State string `json:"state"` +} + +// TestFlightResult is what SubmitTestFlight reports. +type TestFlightResult struct { + App AppRef `json:"app"` + Build BuildRef `json:"build"` + Compliance string `json:"encryption_compliance"` + Notes string `json:"notes,omitempty"` + Groups []GroupRef `json:"groups"` + AvailableGroups []GroupRef `json:"available_groups,omitempty"` + // BetaReview is set when an external group required App Review. + BetaReview *ReviewRef `json:"beta_review,omitempty"` + Link string `json:"link"` +} + +// SubmitTestFlight hands a processed build to TestFlight groups. +func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightOptions) (*TestFlightResult, error) { + app, err := client.AppByBundleID(ctx, opts.BundleID) + if err != nil { + return nil, err + } + build, err := pickBuild(ctx, client, app.ID, opts.Version, opts.BuildNumber) + if err != nil { + return nil, err + } + res := &TestFlightResult{App: appRef(app), Build: buildRef(app.ID, opts.Version, build), Groups: []GroupRef{}, Link: buildLink(app.ID, build.ID)} + logf(opts.Log, "Using build %s (%s, uploaded %s)", build.BuildNumber, build.ID, build.UploadedDate.Local().Format("2006-01-02 15:04")) + + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, opts.NoEncryption) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + + if opts.Notes != "" { + locale := opts.Locale + if locale == "" { + locale = app.PrimaryLocale + } + if locale == "" { + locale = "en-US" + } + if _, err := client.SetWhatsNew(ctx, build.ID, locale, opts.Notes); err != nil { + return res, fmt.Errorf("set test notes: %w", err) + } + res.Notes = opts.Notes + logf(opts.Log, "Set What to Test (%s)", locale) + } + + groups, err := client.ListBetaGroups(ctx, app.ID) + if err != nil { + return res, err + } + if len(opts.Groups) == 0 { + for _, g := range groups { + res.AvailableGroups = append(res.AvailableGroups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) + } + logf(opts.Log, "No --group given; the build was added to no TestFlight group. Available groups:") + for _, g := range groups { + kind := "external" + if g.Internal { + kind = "internal" + } + logf(opts.Log, " %s (%s)", g.Name, kind) + } + if res.Compliance == "pending" { + logf(opts.Log, "Export compliance is unanswered (Missing Compliance); pass --no-encryption if the app uses no non-exempt encryption.") + } + return res, nil + } + + var ids []string + var external bool + var unknown []string + for _, name := range opts.Groups { + found := false + for _, g := range groups { + if strings.EqualFold(g.Name, name) { + ids = append(ids, g.ID) + res.Groups = append(res.Groups, GroupRef{ID: g.ID, Name: g.Name, Internal: g.Internal}) + external = external || !g.Internal + found = true + break + } + } + if !found { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + names := make([]string, 0, len(groups)) + for _, g := range groups { + names = append(names, g.Name) + } + return res, fmt.Errorf("no TestFlight group named %s; %s has: %s", strings.Join(unknown, ", "), app.Name, strings.Join(names, ", ")) + } + if res.Compliance == "pending" { + return res, fmt.Errorf("build %s has no export compliance answer, so TestFlight cannot distribute it; pass --no-encryption if the app uses no non-exempt encryption, or answer in App Store Connect", build.BuildNumber) + } + + if external { + review, err := client.GetBuildBetaAppReviewSubmission(ctx, build.ID) + if err != nil { + return res, err + } + if review == nil { + logf(opts.Log, "Submitting build for external TestFlight review...") + review, err = client.SubmitBuildForBetaReview(ctx, build.ID) + if err != nil { + return res, stateErrorHint(err, "submit for beta review") + } + } else { + logf(opts.Log, "Beta review already %s", review.State) + } + res.BetaReview = &ReviewRef{ID: review.ID, State: review.State} + } + + if err := client.AddBuildToBetaGroups(ctx, build.ID, ids); err != nil { + return res, fmt.Errorf("add build to groups: %w", err) + } + logf(opts.Log, "Added build %s to %s", build.BuildNumber, strings.Join(opts.Groups, ", ")) + + if opts.Wait && res.BetaReview != nil { + review, err := client.WaitForBetaAppReview(ctx, res.BetaReview.ID, pollInterval(opts.PollInterval), func(r *asc.BetaAppReviewSubmission) { + if r.State != res.BetaReview.State { + logf(opts.Log, " beta review: %s", r.State) + } + res.BetaReview.State = r.State + }) + if err != nil { + return res, fmt.Errorf("wait for beta review: %w", err) + } + if review.State == asc.BetaReviewRejected { + return res, fmt.Errorf("beta review rejected build %s; see the resolution center in App Store Connect", build.BuildNumber) + } + } + logf(opts.Log, "TestFlight: %s", res.Link) + return res, nil +} diff --git a/internal/distribute/upload.go b/internal/distribute/upload.go new file mode 100644 index 0000000..a608497 --- /dev/null +++ b/internal/distribute/upload.go @@ -0,0 +1,157 @@ +package distribute + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/ipa" +) + +// UploadOptions configures Upload. +type UploadOptions struct { + IPAPath string + // Wait polls until the delivery completes and the build is VALID. + Wait bool + // NoEncryption answers the export compliance question with "no", even + // when the IPA's Info.plist does not declare ITSAppUsesNonExemptEncryption. + NoEncryption bool + PollInterval time.Duration + // Log receives progress lines; nil discards them. + Log io.Writer +} + +// IPARef describes the uploaded archive. +type IPARef struct { + Path string `json:"path"` + Version string `json:"version"` + BuildNumber string `json:"build_number"` + UsesNonExemptEncryption *bool `json:"uses_non_exempt_encryption"` +} + +// UploadRef describes the delivery. +type UploadRef struct { + ID string `json:"id"` + State string `json:"state"` + Errors []string `json:"errors,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +// UploadResult is what Upload reports. +type UploadResult struct { + App AppRef `json:"app"` + IPA IPARef `json:"ipa"` + Upload UploadRef `json:"upload"` + // Build is set once processing finished (Wait). + Build *BuildRef `json:"build,omitempty"` + // Compliance is set_exempt, already_set, pending or skipped. + Compliance string `json:"encryption_compliance"` + Link string `json:"link"` +} + +// Upload delivers the IPA to App Store Connect and, with Wait, follows it +// until the build is VALID and its export compliance is answered. +func Upload(ctx context.Context, client *asc.Client, opts *UploadOptions) (*UploadResult, error) { + info, err := ipa.ReadInfo(opts.IPAPath) + if err != nil { + return nil, err + } + if info.Version == "" || info.BuildNumber == "" { + return nil, fmt.Errorf("%s: Info.plist lacks CFBundleShortVersionString or CFBundleVersion", opts.IPAPath) + } + app, err := client.AppByBundleID(ctx, info.BundleID) + if err != nil { + return nil, err + } + res := &UploadResult{ + App: appRef(app), + IPA: IPARef{Path: opts.IPAPath, Version: info.Version, BuildNumber: info.BuildNumber, UsesNonExemptEncryption: info.UsesNonExemptEncryption}, + Compliance: "skipped", + Link: testflightLink(app.ID), + } + exempt := opts.NoEncryption || (info.UsesNonExemptEncryption != nil && !*info.UsesNonExemptEncryption) + + logf(opts.Log, "Uploading %s (%s build %s) to %s...", opts.IPAPath, info.Version, info.BuildNumber, app.Name) + var lastPercent int64 = -1 + upload, err := client.UploadBuild(ctx, &asc.UploadBuildOptions{ + AppID: app.ID, Version: info.Version, BuildNumber: info.BuildNumber, Platform: asc.PlatformIOS, Path: opts.IPAPath, + Progress: func(sent, total int64) { + if total == 0 { + return + } + if pct := sent * 100 / total; pct/10 > lastPercent/10 || pct == 100 { + lastPercent = pct + logf(opts.Log, " %d%% (%d/%d MB)", pct, sent>>20, total>>20) + } + }, + }) + if err != nil { + return nil, err + } + res.Upload = UploadRef{ID: upload.ID, State: upload.State, Errors: joinDetails(upload.Errors), Warnings: joinDetails(upload.Warnings)} + logf(opts.Log, "Upload %s accepted; App Store Connect is processing it.", upload.ID) + + if !opts.Wait { + if exempt { + res.Compliance = "pending" + logf(opts.Log, "Export compliance will be set once the build exists: rerun with --wait, or pass --no-encryption to builder ios submit.") + } + return res, nil + } + + interval := pollInterval(opts.PollInterval) + lastState := "" + upload, err = client.WaitForBuildUpload(ctx, upload.ID, interval, func(u *asc.BuildUpload) { + if u.State != lastState { + lastState = u.State + logf(opts.Log, " delivery: %s", u.State) + } + }) + if upload != nil { + res.Upload = UploadRef{ID: upload.ID, State: upload.State, Errors: joinDetails(upload.Errors), Warnings: joinDetails(upload.Warnings)} + } + if err != nil { + var failed *asc.UploadFailedError + if errors.As(err, &failed) { + return res, err + } + return res, fmt.Errorf("wait for delivery: %w", err) + } + for _, w := range res.Upload.Warnings { + logf(opts.Log, " warning: %s", w) + } + + logf(opts.Log, "Waiting for build %s to finish processing...", info.BuildNumber) + lastState = "" + build, err := client.WaitForBuild(ctx, app.ID, info.Version, info.BuildNumber, interval, func(b *asc.Build) { + state := "not visible yet" + if b != nil { + state = b.ProcessingState + } + if state != lastState { + lastState = state + logf(opts.Log, " build: %s", state) + } + }) + if build != nil { + ref := buildRef(app.ID, info.Version, build) + res.Build = &ref + res.Link = ref.Link + } + if err != nil { + return res, err + } + res.Compliance, err = setCompliance(ctx, client, opts.Log, build, exempt) + if err != nil { + return res, err + } + res.Build.UsesNonExemptEncryption = build.UsesNonExemptEncryption + if res.Compliance == "pending" { + logf(opts.Log, "Export compliance is unanswered; TestFlight shows the build as Missing Compliance until it is. Declare ITSAppUsesNonExemptEncryption in Info.plist, or pass --no-encryption.") + } + logf(opts.Log, "Build %s (%s) is VALID: %s", build.BuildNumber, build.ID, res.Link) + return res, nil +} diff --git a/internal/ipa/ipa.go b/internal/ipa/ipa.go new file mode 100644 index 0000000..63e13ea --- /dev/null +++ b/internal/ipa/ipa.go @@ -0,0 +1,98 @@ +// Package ipa reads the metadata of an .ipa archive without extracting it. +package ipa + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "howett.net/plist" +) + +// Info is the subset of the app's Info.plist that Builder needs. +type Info struct { + BundleID string `plist:"CFBundleIdentifier"` + Version string `plist:"CFBundleShortVersionString"` + BuildNumber string `plist:"CFBundleVersion"` + // UsesNonExemptEncryption is nil when the plist does not declare + // ITSAppUsesNonExemptEncryption, in which case App Store Connect asks for + // the export compliance answer before a build can be distributed. + UsesNonExemptEncryption *bool `plist:"ITSAppUsesNonExemptEncryption"` +} + +// ReadInfo returns the Info.plist of the app bundle inside the IPA. +func ReadInfo(path string) (*Info, error) { + r, err := zip.OpenReader(path) + if err != nil { + return nil, fmt.Errorf("open IPA: %w", err) + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + if !isAppInfoPlist(f.Name) { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + return nil, fmt.Errorf("read %s: %w", f.Name, err) + } + var info Info + if _, err := plist.Unmarshal(data, &info); err != nil { + return nil, fmt.Errorf("parse %s: %w", f.Name, err) + } + if info.BundleID == "" { + return nil, fmt.Errorf("%s has no CFBundleIdentifier", f.Name) + } + return &info, nil + } + return nil, errors.New("no Payload/*.app/Info.plist in IPA") +} + +// isAppInfoPlist matches the top-level app's plist only, not the ones of +// embedded frameworks, extensions or watch apps. +func isAppInfoPlist(name string) bool { + return strings.HasPrefix(name, "Payload/") && + strings.HasSuffix(name, ".app/Info.plist") && + strings.Count(name, "/") == 2 +} + +// BundleID returns the bundle identifier of the IPA, or "" when it cannot be read. +func BundleID(path string) string { + info, err := ReadInfo(path) + if err != nil { + return "" + } + return info.BundleID +} + +// Newest returns the most recently modified .ipa in dir. +func Newest(dir string) (string, error) { + matches, err := filepath.Glob(filepath.Join(dir, "*.ipa")) + if err != nil { + return "", err + } + var newest string + var newestTime int64 + for _, m := range matches { + st, err := os.Stat(m) + if err != nil || st.IsDir() { + continue + } + if newest == "" || st.ModTime().UnixNano() > newestTime { + newest, newestTime = m, st.ModTime().UnixNano() + } + } + if newest == "" { + return "", fmt.Errorf("no .ipa found in %s; run builder ios build or pass --ipa", dir) + } + return newest, nil +} diff --git a/internal/ipa/ipa_test.go b/internal/ipa/ipa_test.go new file mode 100644 index 0000000..e46212f --- /dev/null +++ b/internal/ipa/ipa_test.go @@ -0,0 +1,102 @@ +package ipa + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + "time" +) + +func writeIPA(t *testing.T, path string, entries map[string]string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + zw := zip.NewWriter(f) + for name, body := range entries { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} + +const appPlist = ` + +CFBundleIdentifiercom.example.app +CFBundleShortVersionString1.2.3 +CFBundleVersion42 +ITSAppUsesNonExemptEncryption +` + +const frameworkPlist = ` +CFBundleIdentifiercom.example.framework` + +func TestReadInfo(t *testing.T) { + path := filepath.Join(t.TempDir(), "App.ipa") + writeIPA(t, path, map[string]string{ + // Listed first so a naive suffix match would pick the framework. + "Payload/App.app/Frameworks/Lib.framework/Info.plist": frameworkPlist, + "Payload/App.app/Info.plist": appPlist, + }) + info, err := ReadInfo(path) + if err != nil { + t.Fatal(err) + } + if info.BundleID != "com.example.app" || info.Version != "1.2.3" || info.BuildNumber != "42" { + t.Errorf("unexpected info: %+v", info) + } + if info.UsesNonExemptEncryption == nil || *info.UsesNonExemptEncryption { + t.Errorf("UsesNonExemptEncryption = %v, want false", info.UsesNonExemptEncryption) + } + if got := BundleID(path); got != "com.example.app" { + t.Errorf("BundleID = %q", got) + } +} + +func TestReadInfoErrors(t *testing.T) { + if _, err := ReadInfo(filepath.Join(t.TempDir(), "missing.ipa")); err == nil { + t.Error("missing IPA: want error") + } + path := filepath.Join(t.TempDir(), "NoPlist.ipa") + writeIPA(t, path, map[string]string{"Payload/App.app/app": "bin"}) + if _, err := ReadInfo(path); err == nil { + t.Error("IPA without plist: want error") + } + if got := BundleID(path); got != "" { + t.Errorf("BundleID = %q, want empty", got) + } +} + +func TestNewest(t *testing.T) { + dir := t.TempDir() + if _, err := Newest(dir); err == nil { + t.Error("empty dir: want error") + } + old := filepath.Join(dir, "old.ipa") + recent := filepath.Join(dir, "recent.ipa") + for _, p := range []string{old, recent} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + past := time.Now().Add(-time.Hour) + if err := os.Chtimes(old, past, past); err != nil { + t.Fatal(err) + } + got, err := Newest(dir) + if err != nil || got != recent { + t.Errorf("Newest = %q, %v; want %q", got, err, recent) + } +}