diff --git a/CLAUDE.md b/CLAUDE.md index 555aafd..9c2b1e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ builder ios build ───────► Snapshots working tree (git commit-tr Triggers workflow_dispatch with snapshot_ref │ ▼ - GitHub Actions (macos-14) + GitHub Actions (macos-latest) ├─ Checks out the snapshot ref ├─ Detects Flutter/native ├─ Caches DerivedData @@ -401,7 +401,10 @@ internal/ name) to `apply_signing_to_app_target`, which signs each extension-type target (`xcodeproj.ExtensionProductTypes`) with the longest covering entry or fails naming the ids to add; `write_export_options` exports them - **Extension Points**: `ios release` composes `distribute.Upload` and - `distribute.SubmitTestFlight`; the `pkg/` wrappers do not expose `asc`. + `distribute.SubmitTestFlight`. `pkg/asc`, `pkg/distribute`, `pkg/release`, + `pkg/signing` and `pkg/ipa` alias the internal packages so another program + (mobai-dev) can drive the same flows; `release.Builder` is the one-method + interface a foreign build backend implements. - **OTA Install, Not OTA Updates** (`internal/otainstall`): `ios distribute` serves a whole signed IPA through an `itms-services://` link; iOS installs only development/ad-hoc (device on the profile) or enterprise builds, so `Inspect` refuses unsigned and App Store IPAs and `CheckDistribution` refuses @@ -425,6 +428,11 @@ internal/ - **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light); `TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`. +- **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets` + (internal/signing/sets.go) hold the non-interactive core of `signing setup` + and on-demand provisioning; cmd/builder keeps the prompts, the plan and the + summary, and forwards to them under its old names. `signing.Commands` lets an + embedding CLI name its own verbs in the error messages. ## Configuration diff --git a/cmd/builder/root.go b/cmd/builder/root.go index 1192e25..0dcd66e 100644 --- a/cmd/builder/root.go +++ b/cmd/builder/root.go @@ -22,6 +22,7 @@ import ( "github.com/MobAI-App/ios-builder/internal/github" "github.com/MobAI-App/ios-builder/internal/otainstall" "github.com/MobAI-App/ios-builder/internal/release" + "github.com/MobAI-App/ios-builder/internal/signing" "github.com/MobAI-App/ios-builder/internal/update" "github.com/MobAI-App/ios-builder/internal/workflow" "github.com/manifoldco/promptui" @@ -475,7 +476,7 @@ func runInit(cmd *cobra.Command, args []string) error { if cfg.IOS.BundleID == "" { cfg.IOS.BundleID = detectBundleID(iosPath) } - syncExtensions(cfg, os.Stdout) + signing.SyncExtensions(cfg, os.Stdout) if flutterVersion != "" { cfg.Flutter.Version = flutterVersion } diff --git a/cmd/builder/signing.go b/cmd/builder/signing.go index 26e84f1..c457fb9 100644 --- a/cmd/builder/signing.go +++ b/cmd/builder/signing.go @@ -258,23 +258,8 @@ func isPortalCertificate(path string) bool { return false } -// expandPath normalizes a path typed at a prompt. The shell never sees these, -// so a leading ~ is not expanded, and dragging a file into the terminal can -// wrap it in quotes and escape spaces. -func expandPath(path string) string { - path = strings.TrimSpace(path) - path = strings.Trim(path, `"'`) - path = strings.ReplaceAll(path, `\ `, " ") - - if path == "~" || strings.HasPrefix(path, "~/") { - home, err := os.UserHomeDir() - if err != nil { - return path - } - path = filepath.Join(home, strings.TrimPrefix(path, "~")) - } - return path -} +// expandPath normalizes a path typed at a prompt (~, quotes, escaped spaces). +func expandPath(path string) string { return signing.ExpandPath(path) } func runSigningSetup(cmd *cobra.Command, args []string) error { if certFlag, _ := cmd.Flags().GetString("certificate"); certFlag == "" { @@ -341,7 +326,7 @@ func runSigningSetup(cmd *cobra.Command, args []string) error { } fmt.Fprintf(out, "Distribution: %s (read from the profile), signing set %s, build profile %q\n", typ, set, profileName) - syncExtensions(cfg, out) + signing.SyncExtensions(cfg, out) extensionPaths, _ := cmd.Flags().GetStringArray("extension-profile") extensionFiles := make(map[string][]byte, len(extensionPaths)) for _, path := range extensionPaths { diff --git a/cmd/builder/signing_auto.go b/cmd/builder/signing_auto.go index 8a2fd6a..7fe613e 100644 --- a/cmd/builder/signing_auto.go +++ b/cmd/builder/signing_auto.go @@ -2,13 +2,10 @@ package main import ( "context" - "crypto/rand" - "encoding/base64" "errors" "fmt" "io" "maps" - "net/http" "os" "path/filepath" "regexp" @@ -17,11 +14,8 @@ import ( "github.com/MobAI-App/ios-builder/internal/asc" "github.com/MobAI-App/ios-builder/internal/config" - "github.com/MobAI-App/ios-builder/internal/github" - "github.com/MobAI-App/ios-builder/internal/ipa" "github.com/MobAI-App/ios-builder/internal/mobai" "github.com/MobAI-App/ios-builder/internal/signing" - "github.com/MobAI-App/ios-builder/internal/xcodeproj" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/term" @@ -32,16 +26,7 @@ const providerSecretsDoc = "https://github.com/MobAI-App/ios-builder/blob/main/d // signingAutoResult is the JSON output of the automatic `signing setup`. type signingAutoResult struct { - *signing.AutoResult - // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, - // STORE), which builds select by their profile's distribution. - SigningSet string `json:"signing_set"` - SecretsUploaded bool `json:"secrets_uploaded"` - // GitHubUpload is "ok" or why the upload failed; the values are printed - // either way, so a failure is reported, not fatal. - GitHubUpload string `json:"github_upload"` - // BuildProfile is the builder.json profile written with the distribution. - BuildProfile string `json:"build_profile"` + *signing.SetupResult // GeneratedPassword is set when no password was given: it is printed // exactly once, here. GeneratedPassword string `json:"generated_password,omitempty"` @@ -52,7 +37,8 @@ func stdinIsTerminal() bool { } // runSigningAuto is `signing setup` without --certificate/--profile: it -// provisions everything through the App Store Connect API. +// provisions everything through the App Store Connect API. The prompts, the +// plan and the summary live here; the work is signing.Setup. func runSigningAuto(cmd *cobra.Command) error { cfg, err := loadConfig() if err != nil { @@ -93,7 +79,7 @@ func runSigningAuto(cmd *cobra.Command) error { if err != nil { return err } - syncExtensions(cfg, out.log) + signing.SyncExtensions(cfg, out.log) devices, err := signingDevices(ctx, cmd, cfg, typ) if err != nil { return err @@ -148,34 +134,19 @@ func runSigningAuto(cmd *cobra.Command) error { } } - res := &signingAutoResult{SigningSet: set, BuildProfile: profileName, GeneratedPassword: generated} - res.AutoResult, err = signing.Auto(ctx, client, &signing.AutoOptions{ - BundleID: bundleID, Extensions: cfg.IOS.Extensions, Type: typ, Devices: devices, KeyPEM: keyPEM, CommonName: cfg.Project, - Password: password, Force: force, OutDir: outDir, Log: out.log, + res := &signingAutoResult{GeneratedPassword: generated} + res.SetupResult, err = signing.Setup(ctx, client, store, storeErr, cfg, &signing.SetupOptions{ + Type: typ, ProfileName: profileName, BundleID: bundleID, Devices: devices, KeyPEM: keyPEM, + Password: password, Force: force, OutDir: outDir, OutDirAsGiven: outDirFlag, Log: out.log, }) if err != nil { return finish(out, cmd, res, err, nil) } - fmt.Fprintln(out.log) - uploadErr := uploadSigningSet(ctx, store, storeErr, cfg, out.log, set, res.P12, password, res.ProfileContent, res.ExtensionProfiles) - res.SecretsUploaded = uploadErr == nil - res.GitHubUpload = "ok" + uploadErr := res.UploadError if uploadErr != nil { - res.GitHubUpload = uploadErr.Error() fmt.Fprintf(cmd.ErrOrStderr(), "Error: %v\n", uploadErr) } - - // The profile is written whatever the upload did: the material exists and - // the build that uses it is the same either way. - replaced := writeSigningProfile(cfg, profileName, typ) - recordSigningDir(cfg, outDirFlag) - if cfg.IOS.BundleID == "" { - cfg.IOS.BundleID = bundleID - } - if err := config.NewManager().Save(cfg); err != nil { - return finish(out, cmd, res, fmt.Errorf("failed to update config: %w", err), nil) - } - fmt.Fprintln(out.log, profileWritten(profileName, typ, replaced)) + fmt.Fprintln(out.log, profileWritten(profileName, typ, res.ReplacedDistribution)) // Everything is printed before the exit code, so finish's success-only // hook is not used. @@ -200,60 +171,46 @@ func setupDistribution(cfg *config.Config, profileName, flag string) (signing.Ty return signing.TypeDevelopment, nil } -// uploadSigningSet writes the secrets of a set to the GitHub repository -// in builder.json. storeErr is a client that could not be built (no login), -// reported like a failed upload since the values are printed afterwards. +// The signing-set helpers live in internal/signing so a program embedding +// Builder can provision the same way; these names are what this package and +// its tests call them. +type secretStore = signing.SecretStore + func uploadSigningSet(ctx context.Context, store secretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { - if storeErr != nil { - return storeErr - } - fmt.Fprintf(log, "Uploading secrets to %s/%s...\n", cfg.GitHub.Owner, cfg.GitHub.Repo) - return uploadSigningSecrets(ctx, store, cfg, log, set, p12, password, profile, extensions) + return signing.UploadSet(ctx, store, storeErr, cfg, log, set, p12, password, profile, extensions) } -// signingUploadFailed is what `signing setup` ends with when the set did not -// reach the repository: everything is printed by then, so this only carries -// the exit code and says what is left to do. -func signingUploadFailed(cfg *config.Config) error { - return fmt.Errorf("the signing set was not uploaded to %s/%s; add the secrets above by hand, or fix the access and run builder signing setup again", cfg.GitHub.Owner, cfg.GitHub.Repo) +func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { + return signing.UploadSecrets(ctx, gh, cfg, log, set, p12, password, profile, extensions) } -// syncExtensions appends the extension targets of the local Xcode project -// that ios.extensions does not list yet, keeping what was listed by hand (a -// managed Expo project has no project to read until the runner generates it) -// and returning the new ones. -func syncExtensions(cfg *config.Config, log io.Writer) []string { - found, err := xcodeproj.ExtensionBundleIDs(cfg.IOS.Path) - if err != nil { - fmt.Fprintf(log, "Warning: could not read the extension targets of the Xcode project: %v. List their bundle IDs in ios.extensions in builder.json.\n", err) - return nil - } - var added []string - for _, id := range found { - if !slices.Contains(cfg.IOS.Extensions, id) { - cfg.IOS.Extensions = append(cfg.IOS.Extensions, id) - added = append(added, id) - } - } - return added +func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile, provider string, log io.Writer) error { + return signing.EnsureSecrets(ctx, cfg, store, ascClient, &signing.EnsureOptions{Profile: profile, Provider: provider, Log: log}) } -// writeSigningProfile creates or updates the builder.json profile that builds -// with this distribution. Other fields of an existing profile are kept, and so -// is its own spelling of the same distribution (internal stays internal); a -// different distribution is replaced and returned so the caller can say so. func writeSigningProfile(cfg *config.Config, name string, typ signing.Type) (replaced string) { - if cfg.Profiles == nil { - cfg.Profiles = map[string]config.Profile{} - } - p := cfg.Profiles[name] - if d, err := config.ParseDistribution(p.Distribution); err == nil && d == string(typ) { - return "" - } - replaced = p.Distribution - p.Distribution = string(typ) - cfg.Profiles[name] = p - return replaced + return signing.WriteProfile(cfg, name, typ) +} + +func configuredBundleID(cfg *config.Config, log io.Writer) string { + return signing.ConfiguredBundleID(cfg, log) +} + +func signingKey(keyPath string, typ signing.Type, dirs ...string) (keyPEM []byte, path string, err error) { + return signing.ReadKey(keyPath, typ, dirs...) +} + +func randomPassword() (string, error) { return signing.RandomPassword() } + +func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword string) { + signing.PrintFiles(w, res, generatedPassword) +} + +// signingUploadFailed is what `signing setup` ends with when the set did not +// reach the repository: everything is printed by then, so this only carries +// the exit code and says what is left to do. +func signingUploadFailed(cfg *config.Config) error { + return fmt.Errorf("the signing set was not uploaded to %s/%s; add the secrets above by hand, or fix the access and run builder signing setup again", cfg.GitHub.Owner, cfg.GitHub.Repo) } // profileWritten is the "Updated: builder.json" line of both setup modes. @@ -287,21 +244,6 @@ func resolveSigningBundleID(cmd *cobra.Command, cfg *config.Config, out output) return id, nil } -// configuredBundleID is ios.bundleId, else the bundle ID of the newest IPA in -// ./dist; empty when neither is there. -func configuredBundleID(cfg *config.Config, log io.Writer) string { - if cfg.IOS.BundleID != "" { - return cfg.IOS.BundleID - } - if path, err := ipa.Newest("dist"); err == nil { - if id := ipa.BundleID(path); id != "" { - fmt.Fprintf(log, "Bundle ID %s read from %s\n", id, path) - return id - } - } - return "" -} - // signingDevices collects --device UDIDs and, with --devices-from-mobai, the // physical iOS devices MobAI has connected. func signingDevices(ctx context.Context, cmd *cobra.Command, cfg *config.Config, typ signing.Type) ([]signing.Device, error) { @@ -354,61 +296,6 @@ func mobaiSigningDevices(connected []mobai.Device) []signing.Device { return devices } -// signingKey returns the key at keyPath (--key), else the first -// ios-signing-.key or legacy ios-signing.key in dirs, else nil so a key -// is generated (path "" then). -func signingKey(keyPath string, typ signing.Type, dirs ...string) (keyPEM []byte, path string, err error) { - if keyPath == "" { - keyPath = findSigningKey(typ, dirs) - if keyPath == "" { - return nil, "", nil - } - } - keyPath = expandPath(keyPath) - keyPEM, err = os.ReadFile(keyPath) - if err != nil { - return nil, "", fmt.Errorf("failed to read private key %s: %w", keyPath, err) - } - return keyPEM, keyPath, nil -} - -// findSigningKey is the first key file of the type in dirs, or "". -func findSigningKey(typ signing.Type, dirs []string) string { - for _, dir := range dirs { - for _, name := range []string{signing.KeyFileName(typ), signing.LegacyKeyFileName} { - if candidate := filepath.Join(dir, name); fileExists(candidate) { - return candidate - } - } - } - return "" -} - -// recordSigningDir keeps `signing setup`'s --out-dir in builder.json as given -// (a ~ stays a ~, so the file works for every user of the repo), where -// on-demand provisioning looks for the key first; "." is not written. -func recordSigningDir(cfg *config.Config, outDir string) { - outDir = strings.TrimSpace(outDir) - if filepath.Clean(outDir) == "." { - cfg.Signing = nil - return - } - cfg.Signing = &config.SigningConfig{Dir: outDir} -} - -// signingKeyDirs is where on-demand provisioning looks for the private key -// and writes the material: the directory `signing setup` recorded, then the -// working directory. -func signingKeyDirs(cfg *config.Config) []string { - if cfg.Signing == nil { - return []string{"."} - } - if dir := expandPath(cfg.Signing.Dir); dir != "" && filepath.Clean(dir) != "." { - return []string{dir, "."} - } - return []string{"."} -} - func describeDevices(devices []signing.Device) string { if len(devices) == 0 { return "none given; the profile covers the devices already on the account" @@ -424,20 +311,6 @@ func describeDevices(devices []signing.Device) string { return strings.Join(parts, ", ") } -// randomPassword is 128 bits of randomness as URL-safe base64. -func randomPassword() (string, error) { - b := make([]byte, 16) - if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("generate password: %w", err) - } - return base64.RawURLEncoding.EncodeToString(b), nil -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - // signingSecretStore is the GitHub secrets API `signing setup` uploads // through, and signingASCClient the App Store Connect client it provisions // with. Both are vars so tests can replace them. @@ -452,182 +325,6 @@ var ( signingASCClient = getASCClient ) -// secretStore is the part of the GitHub client that signing writes through -// and reads the secret names back from. -type secretStore interface { - GetPublicKey(ctx context.Context, owner, repo string) (*github.PublicKey, error) - CreateOrUpdateSecret(ctx context.Context, owner, repo, name, encryptedValue, keyID string) error - ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) -} - -// uploadSigningSecrets encrypts and stores the signing secrets of a set -// (IOS_CERTIFICATE_, ...). Other sets, and the unsuffixed secrets of -// repositories set up before signing sets, are left alone. The extension -// profiles are written even when empty, so a removed extension's profile -// does not linger in the repository. -func uploadSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { - publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) - if err != nil { - return fmt.Errorf("failed to get repository public key: %w", err) - } - names := config.SigningSecretNames(set) - secrets := []struct{ name, value string }{ - {names.Certificate, base64.StdEncoding.EncodeToString(p12)}, - {names.Password, password}, - {names.Profile, base64.StdEncoding.EncodeToString(profile)}, - {names.Extensions, signing.EncodeExtensionProfiles(extensions)}, - } - for _, s := range secrets { - encrypted, err := github.EncryptSecret(publicKey.Key, s.value) - if err != nil { - return fmt.Errorf("failed to encrypt %s: %w", s.name, err) - } - if err := gh.CreateOrUpdateSecret(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, s.name, encrypted, publicKey.KeyID); err != nil { - return fmt.Errorf("failed to upload %s: %w", s.name, err) - } - fmt.Fprintf(log, " Uploaded: %s\n", s.name) - } - return nil -} - -// missingSigningSecrets names the secrets of a set that the repository does -// not hold; the extension profiles only count when the app has extensions. -func missingSigningSecrets(ctx context.Context, gh secretStore, cfg *config.Config, set string) ([]string, error) { - have, err := gh.ListSecretNames(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) - if err != nil { - return nil, err // names the repository already - } - names := config.SigningSecretNames(set) - var missing []string - for _, name := range names.Names() { - if name == names.Extensions && len(cfg.IOS.Extensions) == 0 { - continue - } - if !slices.Contains(have, name) { - missing = append(missing, name) - } - } - return missing, nil -} - -// ensureSigningSecrets provisions a missing or partial signing set through -// App Store Connect without prompts before a build is dispatched, so a -// distribution build never fails on the runner for want of secrets. It stops -// before anything is pushed when there are no Apple credentials. -func ensureSigningSecrets(ctx context.Context, cfg *config.Config, store secretStore, ascClient func() (*asc.Client, error), profile, provider string, log io.Writer) error { - s, err := cfg.ResolveProfile(profile) - if err != nil { - return err - } - if provider == "" { - provider = s.Provider - } - name, err := cfg.ProviderName(provider) - if err != nil { - return err - } - if s.Distribution == "" { - return nil - } - if name != "github" { - // Codemagic and Bitrise have no secrets API; their runner fails by name. - fmt.Fprintf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: builder signing setup --distribution %s\n", s.Profile, s.SigningSet(), name, s.Distribution) - return nil - } - typ, set := signing.Type(s.Distribution), s.SigningSet() - // A secret's contents cannot be read back, so an extension target that - // appeared since builder.json last listed it is provisioned like a - // missing secret. - newExtensions := syncExtensions(cfg, log) - missing, err := missingSigningSecrets(ctx, store, cfg, set) - if err != nil { - return err - } - if len(missing) == 0 && len(newExtensions) == 0 { - return nil - } - if len(missing) > 0 { - fmt.Fprintf(log, "Profile %q signs with set %s, but %s/%s is missing %s.\n", s.Profile, set, cfg.GitHub.Owner, cfg.GitHub.Repo, strings.Join(missing, ", ")) - } else { - fmt.Fprintf(log, "Profile %q signs with set %s, but the Xcode project has extension targets the set has no profile for: %s.\n", s.Profile, set, strings.Join(newExtensions, ", ")) - } - manual := fmt.Sprintf("builder signing setup --certificate --profile --name %s", s.Profile) - if typ == signing.TypeEnterprise { - return fmt.Errorf("enterprise (in-house) profiles are not issued through the App Store Connect API; upload the files from the portal with %s", manual) - } - client, err := ascClient() - if err != nil { - return fmt.Errorf("%w\nRun builder auth apple and build again to provision the %s set automatically, or upload your own files with %s", err, set, manual) - } - bundleID := configuredBundleID(cfg, log) - if bundleID == "" { - return fmt.Errorf("bundle ID unknown: set ios.bundleId in builder.json, or run builder signing setup --distribution %s --bundle-id ", typ) - } - dirs := signingKeyDirs(cfg) - keyPEM, keyPath, err := signingKey("", typ, dirs...) - if err != nil { - return err - } - password, err := randomPassword() - if err != nil { - return err - } - fmt.Fprintf(log, "Provisioning %s signing for %s through App Store Connect...\n", typ, bundleID) - res, err := signing.Auto(ctx, client, &signing.AutoOptions{ - BundleID: bundleID, Extensions: cfg.IOS.Extensions, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: dirs[0], Log: log, - }) - if err != nil { - if keyPath == "" && certificateRefused(err) { - // Apple has a certificate of this type already, and without its - // key Builder asked for another: say where the key was looked for. - return fmt.Errorf("%w\nNo private key of an existing %s certificate was found: looked for %s in %s. Pass the key of the certificate Apple already issued with builder signing setup --distribution %s --key , or --out-dir with the directory that holds it", err, typ, signing.KeyFileName(typ), strings.Join(dirs, ", "), typ) - } - return err - } - // A build cannot go on without the set in the repository, so here the - // upload is fatal. - fmt.Fprintln(log) - if err := uploadSigningSet(ctx, store, nil, cfg, log, set, res.P12, password, res.ProfileContent, res.ExtensionProfiles); err != nil { - return err - } - fmt.Fprintln(log) - printSigningFiles(log, res, password) - if cfg.IOS.BundleID == "" || len(newExtensions) > 0 { - if cfg.IOS.BundleID == "" { - cfg.IOS.BundleID = bundleID - } - if err := config.NewManager().Save(cfg); err != nil { - return fmt.Errorf("failed to update config: %w", err) - } - } - fmt.Fprintln(log) - return nil -} - -// certificateRefused reports App Store Connect's 409 on a certificate request: -// the team already holds one of that type (or is at its quota). -func certificateRefused(err error) bool { - var apiErr *asc.Error - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict && apiErr.Path == "/v1/certificates" -} - -// printSigningFiles lists what was written and, when Builder made it up, the -// .p12 password: it is printed exactly once. -func printSigningFiles(w io.Writer, res *signing.AutoResult, generatedPassword string) { - if res.Files.Key != "" { - fmt.Fprintf(w, "Private key: %s\n", res.Files.Key) - } - fmt.Fprintf(w, "Certificate: %s\n", res.Files.P12) - fmt.Fprintf(w, "Profile: %s\n", res.Files.Profile) - for i := range res.Extensions { - fmt.Fprintf(w, "Extension: %s\n", res.Extensions[i].File) - } - if generatedPassword != "" { - fmt.Fprintf(w, "Password: %s (generated; shown only now)\n", generatedPassword) - } - fmt.Fprintln(w, "Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") -} - func printSigningSummary(w io.Writer, cfg *config.Config, res *signingAutoResult, uploadErr error) { state := func(created bool, reason string) string { if !created { diff --git a/internal/auth/apple.go b/internal/auth/apple.go index 25e080d..f34f2b1 100644 --- a/internal/auth/apple.go +++ b/internal/auth/apple.go @@ -32,7 +32,7 @@ const ( // (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() + creds, err := AppleCredentialsFromEnv() if err != nil { return nil, "", err } @@ -50,7 +50,9 @@ func GetAppleCredentials() (*AppleCredentials, AppleSource, error) { return &stored, AppleSourceStored, nil } -func appleCredentialsFromEnv() (*AppleCredentials, error) { +// AppleCredentialsFromEnv reads the ASC_* environment variables: nil and no +// error when none is set, an error when only some are. +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") diff --git a/internal/signing/sets.go b/internal/signing/sets.go new file mode 100644 index 0000000..392d661 --- /dev/null +++ b/internal/signing/sets.go @@ -0,0 +1,487 @@ +package signing + +// Signing sets: the CI secrets of one distribution (IOS_CERTIFICATE_, ...), +// provisioned through App Store Connect and uploaded to the GitHub repository. +// `builder signing setup` and the on-demand path of `ios build --profile` / +// `ios release` both come through here, and so does any program embedding +// Builder as a library: nothing in this file prompts or prints a summary. + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/MobAI-App/ios-builder/internal/asc" + "github.com/MobAI-App/ios-builder/internal/config" + "github.com/MobAI-App/ios-builder/internal/github" + "github.com/MobAI-App/ios-builder/internal/ipa" + "github.com/MobAI-App/ios-builder/internal/xcodeproj" +) + +// SecretStore is the part of the GitHub client signing writes through and +// reads the secret names back from. +type SecretStore interface { + GetPublicKey(ctx context.Context, owner, repo string) (*github.PublicKey, error) + CreateOrUpdateSecret(ctx context.Context, owner, repo, name, encryptedValue, keyID string) error + ListSecretNames(ctx context.Context, owner, repo string) ([]string, error) +} + +// Commands names the CLI commands error messages point at, so a program that +// embeds this package can name its own verbs instead of Builder's. +type Commands struct { + // AuthApple saves an App Store Connect API key: "builder auth apple". + AuthApple string + // Setup provisions a signing set: "builder signing setup". + Setup string +} + +// BuilderCommands are the builder CLI's own verbs, the default. +var BuilderCommands = Commands{AuthApple: "builder auth apple", Setup: "builder signing setup"} + +func (c Commands) orBuilder() Commands { + if c.AuthApple == "" { + c.AuthApple = BuilderCommands.AuthApple + } + if c.Setup == "" { + c.Setup = BuilderCommands.Setup + } + return c +} + +// SetupOptions drives Setup, the automatic half of `signing setup`. +type SetupOptions struct { + Type Type + // ProfileName is the builder.json profile written with the distribution; + // empty means the distribution's own name. + ProfileName string + BundleID string + Devices []Device + // KeyPEM reuses a private key; nil generates one. + KeyPEM []byte + Password string + Force bool + // OutDir receives the key, .p12 and profiles; OutDirAsGiven is the same + // path as the user typed it (a ~ stays a ~) and is what builder.json + // records. Empty OutDir means the working directory. + OutDir string + OutDirAsGiven string + // Log receives progress lines; nil discards them. + Log io.Writer +} + +// SetupResult is what Setup reports. A failed upload is not an error: the +// material exists and the values are printed either way, so it is recorded +// in UploadError for the caller to report and turn into an exit code. +type SetupResult struct { + *AutoResult + // SigningSet is the suffix of the secrets written (DEVELOPMENT, AD_HOC, + // STORE), which builds select by their profile's distribution. + SigningSet string `json:"signing_set"` + SecretsUploaded bool `json:"secrets_uploaded"` + // GitHubUpload is "ok" or why the upload failed. + GitHubUpload string `json:"github_upload"` + // BuildProfile is the builder.json profile written with the distribution. + BuildProfile string `json:"build_profile"` + // ReplacedDistribution is the distribution the profile had before, when + // it was a different one. + ReplacedDistribution string `json:"replaced_distribution,omitempty"` + // UploadError is the failed upload, nil when the secrets reached GitHub. + UploadError error `json:"-"` +} + +// Setup provisions a signing set through App Store Connect, uploads it to +// the repository in cfg and records the profile in builder.json: everything +// `builder signing setup` does after its confirmation prompt. storeErr is a +// secret store that could not be built (no GitHub login); it is reported +// like a failed upload once the material exists. A partial result comes back +// with an error from provisioning. +func Setup(ctx context.Context, client *asc.Client, store SecretStore, storeErr error, cfg *config.Config, opts *SetupOptions) (*SetupResult, error) { + if opts.Type == TypeEnterprise { + return nil, errors.New("enterprise (in-house) profiles are not issued through the App Store Connect API; download the certificate and profile from the portal and pass --certificate and --profile") + } + profileName := opts.ProfileName + if profileName == "" { + profileName = string(opts.Type) + } + set, err := config.SigningSet(string(opts.Type)) + if err != nil { + return nil, err + } + outDir := opts.OutDir + if outDir == "" { + outDir = "." + } + res := &SetupResult{SigningSet: set, BuildProfile: profileName} + res.AutoResult, err = Auto(ctx, client, &AutoOptions{ + BundleID: opts.BundleID, Extensions: cfg.IOS.Extensions, Type: opts.Type, Devices: opts.Devices, KeyPEM: opts.KeyPEM, CommonName: cfg.Project, + Password: opts.Password, Force: opts.Force, OutDir: outDir, Log: opts.Log, + }) + if err != nil { + return res, err + } + logf(opts.Log, "") + res.UploadError = UploadSet(ctx, store, storeErr, cfg, opts.Log, set, res.P12, opts.Password, res.ProfileContent, res.ExtensionProfiles) + res.SecretsUploaded = res.UploadError == nil + res.GitHubUpload = "ok" + if res.UploadError != nil { + res.GitHubUpload = res.UploadError.Error() + } + + // The profile is written whatever the upload did: the material exists and + // the build that uses it is the same either way. + res.ReplacedDistribution = WriteProfile(cfg, profileName, opts.Type) + RecordDir(cfg, opts.OutDirAsGiven) + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = opts.BundleID + } + if err := config.NewManager().Save(cfg); err != nil { + return res, fmt.Errorf("failed to update config: %w", err) + } + return res, nil +} + +// UploadSet writes the secrets of a set to the GitHub repository in +// builder.json. storeErr is a client that could not be built (no login), +// reported like a failed upload since the values are printed afterwards. +func UploadSet(ctx context.Context, store SecretStore, storeErr error, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { + if storeErr != nil { + return storeErr + } + logf(log, "Uploading secrets to %s/%s...", cfg.GitHub.Owner, cfg.GitHub.Repo) + return UploadSecrets(ctx, store, cfg, log, set, p12, password, profile, extensions) +} + +// UploadSecrets encrypts and stores the signing secrets of a set +// (IOS_CERTIFICATE_, ...). Other sets, and the unsuffixed secrets of +// repositories set up before signing sets, are left alone. The extension +// profiles are written even when empty, so a removed extension's profile +// does not linger in the repository. +func UploadSecrets(ctx context.Context, gh SecretStore, cfg *config.Config, log io.Writer, set string, p12 []byte, password string, profile []byte, extensions map[string][]byte) error { + publicKey, err := gh.GetPublicKey(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) + if err != nil { + return fmt.Errorf("failed to get repository public key: %w", err) + } + names := config.SigningSecretNames(set) + secrets := []struct{ name, value string }{ + {names.Certificate, base64.StdEncoding.EncodeToString(p12)}, + {names.Password, password}, + {names.Profile, base64.StdEncoding.EncodeToString(profile)}, + {names.Extensions, EncodeExtensionProfiles(extensions)}, + } + for _, s := range secrets { + encrypted, err := github.EncryptSecret(publicKey.Key, s.value) + if err != nil { + return fmt.Errorf("failed to encrypt %s: %w", s.name, err) + } + if err := gh.CreateOrUpdateSecret(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo, s.name, encrypted, publicKey.KeyID); err != nil { + return fmt.Errorf("failed to upload %s: %w", s.name, err) + } + logf(log, " Uploaded: %s", s.name) + } + return nil +} + +// MissingSecrets names the secrets of a set that the repository does not +// hold; the extension profiles only count when the app has extensions. +func MissingSecrets(ctx context.Context, gh SecretStore, cfg *config.Config, set string) ([]string, error) { + have, err := gh.ListSecretNames(ctx, cfg.GitHub.Owner, cfg.GitHub.Repo) + if err != nil { + return nil, err // names the repository already + } + names := config.SigningSecretNames(set) + var missing []string + for _, name := range names.Names() { + if name == names.Extensions && len(cfg.IOS.Extensions) == 0 { + continue + } + if !slices.Contains(have, name) { + missing = append(missing, name) + } + } + return missing, nil +} + +// EnsureOptions drives EnsureSecrets. +type EnsureOptions struct { + // Profile is the builder.json profile about to be built; empty means + // defaultProfile. Provider overrides the profile's CI provider. + Profile string + Provider string + // Log receives progress lines; nil discards them. + Log io.Writer + // Commands names the verbs error messages point at; zero means Builder's. + Commands Commands +} + +// EnsureSecrets provisions a missing or partial signing set through App +// Store Connect without prompts before a build is dispatched, so a +// distribution build never fails on the runner for want of secrets. It stops +// before anything is pushed when there are no Apple credentials, which +// ascClient reports by returning an error. +func EnsureSecrets(ctx context.Context, cfg *config.Config, store SecretStore, ascClient func() (*asc.Client, error), opts *EnsureOptions) error { + cmds := opts.Commands.orBuilder() + log := opts.Log + s, err := cfg.ResolveProfile(opts.Profile) + if err != nil { + return err + } + provider := opts.Provider + if provider == "" { + provider = s.Provider + } + name, err := cfg.ProviderName(provider) + if err != nil { + return err + } + if s.Distribution == "" { + return nil + } + if name != "github" { + // Codemagic and Bitrise have no secrets API; their runner fails by name. + logf(log, "Profile %q signs with set %s. Builder cannot check %s secrets; if the build fails on signing, run: %s --distribution %s", s.Profile, s.SigningSet(), name, cmds.Setup, s.Distribution) + return nil + } + typ, set := Type(s.Distribution), s.SigningSet() + // A secret's contents cannot be read back, so an extension target that + // appeared since builder.json last listed it is provisioned like a + // missing secret. + newExtensions := SyncExtensions(cfg, log) + missing, err := MissingSecrets(ctx, store, cfg, set) + if err != nil { + return err + } + if len(missing) == 0 && len(newExtensions) == 0 { + return nil + } + if len(missing) > 0 { + logf(log, "Profile %q signs with set %s, but %s/%s is missing %s.", s.Profile, set, cfg.GitHub.Owner, cfg.GitHub.Repo, strings.Join(missing, ", ")) + } else { + logf(log, "Profile %q signs with set %s, but the Xcode project has extension targets the set has no profile for: %s.", s.Profile, set, strings.Join(newExtensions, ", ")) + } + manual := fmt.Sprintf("%s --certificate --profile --name %s", cmds.Setup, s.Profile) + if typ == TypeEnterprise { + return fmt.Errorf("enterprise (in-house) profiles are not issued through the App Store Connect API; upload the files from the portal with %s", manual) + } + client, err := ascClient() + if err != nil { + return fmt.Errorf("%w\nRun %s and build again to provision the %s set automatically, or upload your own files with %s", err, cmds.AuthApple, set, manual) + } + bundleID := ConfiguredBundleID(cfg, log) + if bundleID == "" { + return fmt.Errorf("bundle ID unknown: set ios.bundleId in builder.json, or run %s --distribution %s --bundle-id ", cmds.Setup, typ) + } + dirs := KeyDirs(cfg) + keyPEM, keyPath, err := ReadKey("", typ, dirs...) + if err != nil { + return err + } + password, err := RandomPassword() + if err != nil { + return err + } + logf(log, "Provisioning %s signing for %s through App Store Connect...", typ, bundleID) + res, err := Auto(ctx, client, &AutoOptions{ + BundleID: bundleID, Extensions: cfg.IOS.Extensions, Type: typ, KeyPEM: keyPEM, CommonName: cfg.Project, Password: password, OutDir: dirs[0], Log: log, + }) + if err != nil { + if keyPath == "" && CertificateRefused(err) { + // Apple has a certificate of this type already, and without its + // key Builder asked for another: say where the key was looked for. + return fmt.Errorf("%w\nNo private key of an existing %s certificate was found: looked for %s in %s. Pass the key of the certificate Apple already issued with %s --distribution %s --key , or --out-dir with the directory that holds it", err, typ, KeyFileName(typ), strings.Join(dirs, ", "), cmds.Setup, typ) + } + return err + } + // A build cannot go on without the set in the repository, so here the + // upload is fatal. + logf(log, "") + if err := UploadSet(ctx, store, nil, cfg, log, set, res.P12, password, res.ProfileContent, res.ExtensionProfiles); err != nil { + return err + } + logf(log, "") + PrintFiles(log, res, password) + if cfg.IOS.BundleID == "" || len(newExtensions) > 0 { + if cfg.IOS.BundleID == "" { + cfg.IOS.BundleID = bundleID + } + if err := config.NewManager().Save(cfg); err != nil { + return fmt.Errorf("failed to update config: %w", err) + } + } + logf(log, "") + return nil +} + +// PrintFiles lists what Auto wrote and, when Builder made it up, the .p12 +// password: it is printed exactly once. +func PrintFiles(w io.Writer, res *AutoResult, generatedPassword string) { + if w == nil { + return + } + if res.Files.Key != "" { + fmt.Fprintf(w, "Private key: %s\n", res.Files.Key) + } + fmt.Fprintf(w, "Certificate: %s\n", res.Files.P12) + fmt.Fprintf(w, "Profile: %s\n", res.Files.Profile) + for i := range res.Extensions { + fmt.Fprintf(w, "Extension: %s\n", res.Extensions[i].File) + } + if generatedPassword != "" { + fmt.Fprintf(w, "Password: %s (generated; shown only now)\n", generatedPassword) + } + fmt.Fprintln(w, "Keep these out of git (add them to .gitignore); gitignored files are also left out of build snapshots.") +} + +// CertificateRefused reports App Store Connect's 409 on a certificate +// request: the team already holds one of that type (or is at its quota). +func CertificateRefused(err error) bool { + var apiErr *asc.Error + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict && apiErr.Path == "/v1/certificates" +} + +// SyncExtensions appends the extension targets of the local Xcode project +// that ios.extensions does not list yet, keeping what was listed by hand (a +// managed Expo project has no project to read until the runner generates it) +// and returning the new ones. +func SyncExtensions(cfg *config.Config, log io.Writer) []string { + found, err := xcodeproj.ExtensionBundleIDs(cfg.IOS.Path) + if err != nil { + logf(log, "Warning: could not read the extension targets of the Xcode project: %v. List their bundle IDs in ios.extensions in builder.json.", err) + return nil + } + var added []string + for _, id := range found { + if !slices.Contains(cfg.IOS.Extensions, id) { + cfg.IOS.Extensions = append(cfg.IOS.Extensions, id) + added = append(added, id) + } + } + return added +} + +// WriteProfile creates or updates the builder.json profile that builds with +// this distribution. Other fields of an existing profile are kept, and so is +// its own spelling of the same distribution (internal stays internal); a +// different distribution is replaced and returned so the caller can say so. +func WriteProfile(cfg *config.Config, name string, typ Type) (replaced string) { + if cfg.Profiles == nil { + cfg.Profiles = map[string]config.Profile{} + } + p := cfg.Profiles[name] + if d, err := config.ParseDistribution(p.Distribution); err == nil && d == string(typ) { + return "" + } + replaced = p.Distribution + p.Distribution = string(typ) + cfg.Profiles[name] = p + return replaced +} + +// ConfiguredBundleID is ios.bundleId, else the bundle ID of the newest IPA +// in ./dist; empty when neither is there. +func ConfiguredBundleID(cfg *config.Config, log io.Writer) string { + if cfg.IOS.BundleID != "" { + return cfg.IOS.BundleID + } + if path, err := ipa.Newest("dist"); err == nil { + if id := ipa.BundleID(path); id != "" { + logf(log, "Bundle ID %s read from %s", id, path) + return id + } + } + return "" +} + +// ReadKey returns the key at keyPath, else the first ios-signing-.key +// or legacy ios-signing.key in dirs, else nil so a key is generated (path "" +// then). +func ReadKey(keyPath string, typ Type, dirs ...string) (keyPEM []byte, path string, err error) { + if keyPath == "" { + keyPath = FindKey(typ, dirs) + if keyPath == "" { + return nil, "", nil + } + } + keyPath = ExpandPath(keyPath) + keyPEM, err = os.ReadFile(keyPath) + if err != nil { + return nil, "", fmt.Errorf("failed to read private key %s: %w", keyPath, err) + } + return keyPEM, keyPath, nil +} + +// FindKey is the first key file of the type in dirs, or "". +func FindKey(typ Type, dirs []string) string { + for _, dir := range dirs { + for _, name := range []string{KeyFileName(typ), LegacyKeyFileName} { + if candidate := filepath.Join(dir, name); fileExists(candidate) { + return candidate + } + } + } + return "" +} + +// RecordDir keeps `signing setup`'s --out-dir in builder.json as given (a ~ +// stays a ~, so the file works for every user of the repo), where on-demand +// provisioning looks for the key first; "." is not written. +func RecordDir(cfg *config.Config, outDir string) { + outDir = strings.TrimSpace(outDir) + if filepath.Clean(outDir) == "." { + cfg.Signing = nil + return + } + cfg.Signing = &config.SigningConfig{Dir: outDir} +} + +// KeyDirs is where on-demand provisioning looks for the private key and +// writes the material: the directory `signing setup` recorded, then the +// working directory. +func KeyDirs(cfg *config.Config) []string { + if cfg.Signing == nil { + return []string{"."} + } + if dir := ExpandPath(cfg.Signing.Dir); dir != "" && filepath.Clean(dir) != "." { + return []string{dir, "."} + } + return []string{"."} +} + +// ExpandPath normalizes a path typed at a prompt. The shell never sees these, +// so a leading ~ is not expanded, and dragging a file into the terminal can +// wrap it in quotes and escape spaces. +func ExpandPath(path string) string { + path = strings.TrimSpace(path) + path = strings.Trim(path, `"'`) + path = strings.ReplaceAll(path, `\ `, " ") + + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return path + } + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + return path +} + +// RandomPassword is 128 bits of randomness as URL-safe base64. +func RandomPassword() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/asc/asc.go b/pkg/asc/asc.go new file mode 100644 index 0000000..c6ab5c8 --- /dev/null +++ b/pkg/asc/asc.go @@ -0,0 +1,120 @@ +// Package asc exposes the App Store Connect API client to code outside this +// module. +// +// The implementation lives in internal/asc. Types are aliases, so values pass +// between this package, pkg/distribute, pkg/release and pkg/signing without +// conversion; every method of Client is available on the alias. +package asc + +import ( + "time" + + "github.com/MobAI-App/ios-builder/internal/asc" +) + +// DefaultBaseURL is the production App Store Connect API endpoint. +const DefaultBaseURL = asc.DefaultBaseURL + +type ( + Client = asc.Client + Credentials = asc.Credentials + Option = asc.Option + Error = asc.Error + ErrorDetail = asc.ErrorDetail + ErrorSource = asc.ErrorSource + + App = asc.App + AppStoreVersion = asc.AppStoreVersion + AppStoreVersionUpdate = asc.AppStoreVersionUpdate + BetaAppReviewSubmission = asc.BetaAppReviewSubmission + BetaBuildLocalization = asc.BetaBuildLocalization + BetaGroup = asc.BetaGroup + BetaGroupSpec = asc.BetaGroupSpec + BetaTester = asc.BetaTester + BetaTesterFilter = asc.BetaTesterFilter + BetaTesterSpec = asc.BetaTesterSpec + Build = asc.Build + BuildFilter = asc.BuildFilter + BuildUpload = asc.BuildUpload + BuildUploadFile = asc.BuildUploadFile + BundleID = asc.BundleID + Certificate = asc.Certificate + Device = asc.Device + Profile = asc.Profile + ReviewSubmission = asc.ReviewSubmission + ReviewSubmissionItem = asc.ReviewSubmissionItem + StateDetail = asc.StateDetail + UploadBuildOptions = asc.UploadBuildOptions + UploadFailedError = asc.UploadFailedError + UploadOperation = asc.UploadOperation + User = asc.User + UserInvitation = asc.UserInvitation + UserInvitationSpec = asc.UserInvitationSpec +) + +const ( + PlatformIOS = asc.PlatformIOS + + ProcessingStateProcessing = asc.ProcessingStateProcessing + ProcessingStateFailed = asc.ProcessingStateFailed + ProcessingStateInvalid = asc.ProcessingStateInvalid + ProcessingStateValid = asc.ProcessingStateValid + + UploadStateAwaitingUpload = asc.UploadStateAwaitingUpload + UploadStateProcessing = asc.UploadStateProcessing + UploadStateComplete = asc.UploadStateComplete + UploadStateFailed = asc.UploadStateFailed + + BetaReviewWaiting = asc.BetaReviewWaiting + BetaReviewInReview = asc.BetaReviewInReview + BetaReviewApproved = asc.BetaReviewApproved + BetaReviewRejected = asc.BetaReviewRejected + + BetaTesterNotInvited = asc.BetaTesterNotInvited + BetaTesterInvited = asc.BetaTesterInvited + BetaTesterAccepted = asc.BetaTesterAccepted + BetaTesterInstalled = asc.BetaTesterInstalled + BetaTesterRevoked = asc.BetaTesterRevoked + + ReleaseTypeManual = asc.ReleaseTypeManual + ReleaseTypeAfterApproval = asc.ReleaseTypeAfterApproval + ReleaseTypeScheduled = asc.ReleaseTypeScheduled + + ReviewStateReadyForReview = asc.ReviewStateReadyForReview + ReviewStateWaitingForReview = asc.ReviewStateWaitingForReview + ReviewStateInReview = asc.ReviewStateInReview + ReviewStateUnresolvedIssues = asc.ReviewStateUnresolvedIssues + ReviewStateCanceling = asc.ReviewStateCanceling + ReviewStateCompleting = asc.ReviewStateCompleting + ReviewStateComplete = asc.ReviewStateComplete + + VersionStateWaitingForReview = asc.VersionStateWaitingForReview + VersionStateInReview = asc.VersionStateInReview + + RoleCustomerSupport = asc.RoleCustomerSupport + CodeNoInstallableBuilds = asc.CodeNoInstallableBuilds +) + +// NewClient validates the credentials and returns a client. No network call +// is made. +func NewClient(creds Credentials, opts ...Option) (*Client, error) { + return asc.NewClient(creds, opts...) +} + +// WithBaseURL points the client at another server, e.g. a test server. +func WithBaseURL(baseURL string) Option { return asc.WithBaseURL(baseURL) } + +// WithRetryDelay sets the base delay of the exponential backoff on 429/5xx. +func WithRetryDelay(d time.Duration) Option { return asc.WithRetryDelay(d) } + +// MatchBetaGroup returns the group called name (case-insensitive), nil when +// there is none, and an error when the name is ambiguous. +func MatchBetaGroup(groups []BetaGroup, name string) (*BetaGroup, error) { + return asc.MatchBetaGroup(groups, name) +} + +// HasCode reports whether err is an App Store Connect error carrying code. +func HasCode(err error, code string) bool { return asc.HasCode(err, code) } + +// IsStatus reports whether err is an App Store Connect error with the HTTP status. +func IsStatus(err error, status int) bool { return asc.IsStatus(err, status) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index c7787ae..75dbc05 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -36,3 +36,35 @@ func StoreProviderToken(provider, token string) error { return auth.StoreProviderToken(provider, token) } func LogoutProvider(provider string) error { return auth.LogoutProvider(provider) } + +// App Store Connect API keys. A program with its own credential store keeps +// AppleCredentialsFromEnv and NormalizePEM and skips the stored login. +type ( + AppleCredentials = auth.AppleCredentials + AppleSource = auth.AppleSource +) + +const ( + AppleSourceEnv = auth.AppleSourceEnv + AppleSourceStored = auth.AppleSourceStored +) + +// GetAppleCredentials returns the key from the ASC_* environment, else the +// login saved by StoreAppleCredentials. +func GetAppleCredentials() (*AppleCredentials, AppleSource, error) { + return auth.GetAppleCredentials() +} + +// AppleCredentialsFromEnv reads ASC_ISSUER_ID, ASC_KEY_ID and ASC_PRIVATE_KEY +// or ASC_KEY_PATH: nil and no error when none is set, an error when only +// some are. +func AppleCredentialsFromEnv() (*AppleCredentials, error) { + return auth.AppleCredentialsFromEnv() +} + +// StoreAppleCredentials saves the API key as the Apple login. +func StoreAppleCredentials(c AppleCredentials) error { return auth.StoreAppleCredentials(c) } + +// NormalizePEM accepts a key pasted with literal "\n" sequences and returns +// it with real newlines. +func NormalizePEM(key string) string { return auth.NormalizePEM(key) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 408e102..a9a15f1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,12 +22,34 @@ type ( FlutterConfig = config.FlutterConfig WatchConfig = config.WatchConfig ReactNativeConfig = config.ReactNativeConfig + KMPConfig = config.KMPConfig MobAIConfig = config.MobAIConfig + SigningConfig = config.SigningConfig + Profile = config.Profile + BuildSettings = config.BuildSettings + SigningSecrets = config.SigningSecrets ValidationError = config.ValidationError Manager = config.Manager ) +// Distributions a build profile can name. Empty means an unsigned build. +const ( + DistributionDevelopment = config.DistributionDevelopment + DistributionAdHoc = config.DistributionAdHoc + DistributionStore = config.DistributionStore + DistributionEnterprise = config.DistributionEnterprise +) + // NewManager creates a configuration manager rooted at builder.json. func NewManager() *Manager { return config.NewManager() } + +// ParseDistribution canonicalizes a distribution name (internal is ad-hoc). +func ParseDistribution(s string) (string, error) { return config.ParseDistribution(s) } + +// SigningSet is the secret suffix of a distribution: STORE, AD_HOC, DEVELOPMENT. +func SigningSet(distribution string) (string, error) { return config.SigningSet(distribution) } + +// SigningSecretNames are the four secrets of a signing set. +func SigningSecretNames(set string) SigningSecrets { return config.SigningSecretNames(set) } diff --git a/pkg/distribute/distribute.go b/pkg/distribute/distribute.go new file mode 100644 index 0000000..2bb6a50 --- /dev/null +++ b/pkg/distribute/distribute.go @@ -0,0 +1,71 @@ +// Package distribute exposes the App Store Connect flows behind `builder ios +// upload` and `builder ios submit` to code outside this module: deliver an +// IPA, wait for processing, hand a build to TestFlight groups, submit an App +// Store version for review, and manage testers. +package distribute + +import ( + "context" + "io" + + "github.com/MobAI-App/ios-builder/internal/distribute" + "github.com/MobAI-App/ios-builder/pkg/asc" +) + +type ( + AppRef = distribute.AppRef + BuildRef = distribute.BuildRef + + UploadOptions = distribute.UploadOptions + IPARef = distribute.IPARef + UploadRef = distribute.UploadRef + UploadResult = distribute.UploadResult + + TestFlightOptions = distribute.TestFlightOptions + GroupRef = distribute.GroupRef + ReviewRef = distribute.ReviewRef + TestFlightResult = distribute.TestFlightResult + + AppStoreOptions = distribute.AppStoreOptions + VersionRef = distribute.VersionRef + AppStoreResult = distribute.AppStoreResult + + TesterOptions = distribute.TesterOptions + TesterResult = distribute.TesterResult +) + +const ( + TesterInvited = distribute.TesterInvited + TesterAdded = distribute.TesterAdded + TesterTeamInviteSent = distribute.TesterTeamInviteSent + TesterTeamInvitePending = distribute.TesterTeamInvitePending +) + +// Upload delivers an IPA to App Store Connect and, with Wait, follows +// processing and answers export compliance. +func Upload(ctx context.Context, client *asc.Client, opts *UploadOptions) (*UploadResult, error) { + return distribute.Upload(ctx, client, opts) +} + +// SubmitTestFlight hands a processed build to TestFlight groups, creating +// missing ones, and submits it for beta review when a group is external. +func SubmitTestFlight(ctx context.Context, client *asc.Client, opts *TestFlightOptions) (*TestFlightResult, error) { + return distribute.SubmitTestFlight(ctx, client, opts) +} + +// SubmitAppStore attaches a processed build to the App Store version for +// its marketing version and submits it for review. +func SubmitAppStore(ctx context.Context, client *asc.Client, opts *AppStoreOptions) (*AppStoreResult, error) { + return distribute.SubmitAppStore(ctx, client, opts) +} + +// AddTester puts one tester into a TestFlight group, inviting them to the +// team first when the group is internal and they are not a member. +func AddTester(ctx context.Context, client *asc.Client, opts *TesterOptions) (*TesterResult, error) { + return distribute.AddTester(ctx, client, opts) +} + +// InviteTester sends (or resends) the TestFlight invitation email. +func InviteTester(ctx context.Context, client *asc.Client, log io.Writer, appID string, tester *asc.BetaTester) (*asc.BetaTester, error) { + return distribute.InviteTester(ctx, client, log, appID, tester) +} diff --git a/pkg/ipa/ipa.go b/pkg/ipa/ipa.go new file mode 100644 index 0000000..25cd54f --- /dev/null +++ b/pkg/ipa/ipa.go @@ -0,0 +1,25 @@ +// Package ipa exposes the metadata reading of .ipa archives to code outside +// this module: the Info.plist of the app bundle and its embedded +// provisioning profile. +package ipa + +import "github.com/MobAI-App/ios-builder/internal/ipa" + +// ErrUnsigned is ReadProfile's error for an IPA with no embedded profile. +var ErrUnsigned = ipa.ErrUnsigned + +// Info is the subset of the app's Info.plist that Builder needs. +type Info = ipa.Info + +// ReadInfo returns the Info.plist of the app bundle inside the IPA. +func ReadInfo(path string) (*Info, error) { return ipa.ReadInfo(path) } + +// BundleID returns the bundle identifier of the IPA, or "" when it cannot be read. +func BundleID(path string) string { return ipa.BundleID(path) } + +// ReadProfile returns the embedded.mobileprovision of the app bundle inside +// the IPA, ErrUnsigned when it has none. +func ReadProfile(path string) ([]byte, error) { return ipa.ReadProfile(path) } + +// Newest returns the most recently modified .ipa in dir. +func Newest(dir string) (string, error) { return ipa.Newest(dir) } diff --git a/pkg/release/release.go b/pkg/release/release.go new file mode 100644 index 0000000..fa1aca1 --- /dev/null +++ b/pkg/release/release.go @@ -0,0 +1,38 @@ +// Package release exposes `builder ios release` to code outside this module: +// next build number from App Store Connect, build, verify the IPA, upload, +// submit. The build itself is whatever Builder the caller supplies, so a +// program with its own build backend can release through it. +package release + +import ( + "context" + "io" + + "github.com/MobAI-App/ios-builder/internal/release" + "github.com/MobAI-App/ios-builder/pkg/asc" + "github.com/MobAI-App/ios-builder/pkg/config" +) + +type ( + Builder = release.Builder + Options = release.Options + Result = release.Result +) + +// Preflight returns the profile to release with: the selected one when it +// has distribution store, else the only store profile in builder.json. +func Preflight(cfg *config.Config, profile string, log io.Writer) (string, error) { + return release.Preflight(cfg, profile, log) +} + +// Run builds, uploads and submits. A partial Result comes back with the +// error so callers can show how far it got. +func Run(ctx context.Context, cfg *config.Config, builder Builder, client *asc.Client, opts *Options) (*Result, error) { + return release.Run(ctx, cfg, builder, client, opts) +} + +// NextBuildNumber is one above the highest CFBundleVersion App Store Connect +// holds for the app across every marketing version (1 when none). +func NextBuildNumber(ctx context.Context, client *asc.Client, appID string) (string, error) { + return release.NextBuildNumber(ctx, client, appID) +} diff --git a/pkg/signing/signing.go b/pkg/signing/signing.go new file mode 100644 index 0000000..b74ba36 --- /dev/null +++ b/pkg/signing/signing.go @@ -0,0 +1,122 @@ +// Package signing exposes portal-free provisioning and signing sets to code +// outside this module: Auto creates bundle IDs, certificates, devices and +// profiles through App Store Connect; Setup and EnsureSecrets turn that +// material into the CI secrets of one distribution. +package signing + +import ( + "context" + "io" + + "github.com/MobAI-App/ios-builder/internal/signing" + "github.com/MobAI-App/ios-builder/pkg/asc" + "github.com/MobAI-App/ios-builder/pkg/config" +) + +type ( + Type = signing.Type + Device = signing.Device + AutoOptions = signing.AutoOptions + AutoResult = signing.AutoResult + BundleIDResult = signing.BundleIDResult + CertificateResult = signing.CertificateResult + DevicesResult = signing.DevicesResult + ProfileResult = signing.ProfileResult + ExtensionResult = signing.ExtensionResult + Files = signing.Files + + SecretStore = signing.SecretStore + Commands = signing.Commands + SetupOptions = signing.SetupOptions + SetupResult = signing.SetupResult + EnsureOptions = signing.EnsureOptions +) + +const ( + TypeDevelopment = signing.TypeDevelopment + TypeAdHoc = signing.TypeAdHoc + TypeStore = signing.TypeStore + TypeEnterprise = signing.TypeEnterprise + + LegacyKeyFileName = signing.LegacyKeyFileName +) + +// BuilderCommands are the builder CLI's own verbs, what error messages name +// unless EnsureOptions.Commands says otherwise. +var BuilderCommands = signing.BuilderCommands + +// ParseType accepts a distribution name (development, ad-hoc or internal, +// store, enterprise). +func ParseType(s string) (Type, error) { return signing.ParseType(s) } + +// Auto provisions bundle IDs, a certificate, devices and profiles for one +// distribution; idempotent, and it never revokes anything. +func Auto(ctx context.Context, client *asc.Client, opts *AutoOptions) (*AutoResult, error) { + return signing.Auto(ctx, client, opts) +} + +// Setup provisions a signing set, uploads it to the repository in cfg and +// records the profile in builder.json: `builder signing setup` without its +// prompts and summary. +func Setup(ctx context.Context, client *asc.Client, store SecretStore, storeErr error, cfg *config.Config, opts *SetupOptions) (*SetupResult, error) { + return signing.Setup(ctx, client, store, storeErr, cfg, opts) +} + +// EnsureSecrets provisions a missing or partial signing set before a build +// is dispatched, so a distribution build never fails on the runner for want +// of secrets. A nil error with nothing logged means the set was complete. +func EnsureSecrets(ctx context.Context, cfg *config.Config, store SecretStore, ascClient func() (*asc.Client, error), opts *EnsureOptions) error { + return signing.EnsureSecrets(ctx, cfg, store, ascClient, opts) +} + +// MissingSecrets names the secrets of a set that the repository does not hold. +func MissingSecrets(ctx context.Context, store SecretStore, cfg *config.Config, set string) ([]string, error) { + return signing.MissingSecrets(ctx, store, cfg, set) +} + +// SyncExtensions appends the extension targets of the local Xcode project +// that ios.extensions does not list yet and returns the new ones. +func SyncExtensions(cfg *config.Config, log io.Writer) []string { + return signing.SyncExtensions(cfg, log) +} + +// ConfiguredBundleID is ios.bundleId, else the bundle ID of the newest IPA +// in ./dist; empty when neither is there. +func ConfiguredBundleID(cfg *config.Config, log io.Writer) string { + return signing.ConfiguredBundleID(cfg, log) +} + +// ReadKey returns the key at keyPath, else the first key file of the type +// in dirs, else nil so a key is generated. +func ReadKey(keyPath string, typ Type, dirs ...string) (keyPEM []byte, path string, err error) { + return signing.ReadKey(keyPath, typ, dirs...) +} + +// KeyDirs is where provisioning looks for the private key and writes the +// material. +func KeyDirs(cfg *config.Config) []string { return signing.KeyDirs(cfg) } + +// KeyFileName is the private key file of a distribution, ios-signing-.key. +func KeyFileName(t Type) string { return signing.KeyFileName(t) } + +// P12FileName is the certificate file of a distribution, ios-signing-.p12. +func P12FileName(t Type) string { return signing.P12FileName(t) } + +// ExpandPath normalizes a path typed at a prompt (~, quotes, escaped spaces). +func ExpandPath(path string) string { return signing.ExpandPath(path) } + +// RandomPassword is 128 bits of randomness as URL-safe base64. +func RandomPassword() (string, error) { return signing.RandomPassword() } + +// CertificateRefused reports App Store Connect's 409 on a certificate +// request: the team already holds one of that type. +func CertificateRefused(err error) bool { return signing.CertificateRefused(err) } + +// PrintFiles lists what Auto wrote and, when Builder made it up, the .p12 +// password. +func PrintFiles(w io.Writer, res *AutoResult, generatedPassword string) { + signing.PrintFiles(w, res, generatedPassword) +} + +// ProfileType reads a .mobileprovision and reports its distribution. +func ProfileType(data []byte) (Type, error) { return signing.ProfileType(data) }