From ed1c9033a4e1d30a0212deca471d9eb6c3eb6fff Mon Sep 17 00:00:00 2001 From: Interlap Date: Sun, 20 Sep 2026 12:41:37 +0200 Subject: [PATCH] pkg: expose signing, asc, distribute, release and ipa as a library Another program embedding Builder (mobai-dev) could already reach the build and config packages through pkg/, but not the App Store Connect flows: `signing setup` and on-demand provisioning lived in cmd/builder, so nothing outside this module could provision a signing set or release a build. Move the non-interactive core of `signing setup` and `ios build`'s on-demand provisioning into internal/signing/sets.go as Setup and EnsureSecrets. cmd/builder keeps everything a CLI owns -- the prompts, the plan, the confirmation and the summary -- and calls them; the JSON result is now signing.SetupResult, with the failed upload carried in UploadError instead of being recomputed by the caller. signing.Commands lets an embedding CLI name its own verbs where the error messages point at `builder auth apple` / `builder signing setup`. Add pkg/asc, pkg/distribute, pkg/release, pkg/ipa and pkg/signing as alias wrappers, so values pass between them without conversion, and release.Builder stays the one-method interface a foreign build backend implements. pkg/auth grows the App Store Connect key accessors and pkg/config the distributions, profiles and signing-set names those need; auth.AppleCredentialsFromEnv is exported for a program with its own credential store. No behaviour change to any command. --- CLAUDE.md | 12 +- cmd/builder/root.go | 3 +- cmd/builder/signing.go | 21 +- cmd/builder/signing_auto.go | 387 +++------------------------- internal/auth/apple.go | 6 +- internal/signing/sets.go | 487 +++++++++++++++++++++++++++++++++++ pkg/asc/asc.go | 120 +++++++++ pkg/auth/auth.go | 32 +++ pkg/config/config.go | 22 ++ pkg/distribute/distribute.go | 71 +++++ pkg/ipa/ipa.go | 25 ++ pkg/release/release.go | 38 +++ pkg/signing/signing.go | 122 +++++++++ 13 files changed, 978 insertions(+), 368 deletions(-) create mode 100644 internal/signing/sets.go create mode 100644 pkg/asc/asc.go create mode 100644 pkg/distribute/distribute.go create mode 100644 pkg/ipa/ipa.go create mode 100644 pkg/release/release.go create mode 100644 pkg/signing/signing.go 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) }