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