From 92e2d4cd487a58eef3b37efb6ef1eb638809cef8 Mon Sep 17 00:00:00 2001 From: zhangwei Date: Mon, 27 Apr 2026 20:42:47 +0800 Subject: [PATCH 1/2] Add App Store Connect build upload commands --- README.md | 75 +++++++++++ cmd/latest_build.go | 108 +++++++++++++++ cmd/upload.go | 231 +++++++++++++++++++++++++++++++++ internal/api/build_uploads.go | 191 +++++++++++++++++++++++++++ internal/api/builds.go | 35 +++++ internal/api/types.go | 112 ++++++++++++++++ internal/buildinfo/ipa.go | 170 ++++++++++++++++++++++++ internal/buildinfo/ipa_test.go | 60 +++++++++ 8 files changed, 982 insertions(+) create mode 100644 cmd/latest_build.go create mode 100644 cmd/upload.go create mode 100644 internal/api/build_uploads.go create mode 100644 internal/api/builds.go create mode 100644 internal/buildinfo/ipa.go create mode 100644 internal/buildinfo/ipa_test.go diff --git a/README.md b/README.md index 0503f33..3754c99 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ - **init** — 从 App Store Connect 最新版本下载元数据,按平台和语言保存到本地目录 - **release** — 创建新版本或更新已有版本,将本地元数据同步到 App Store Connect +- **upload** — 使用 Build Upload API 上传 `.ipa` / `.pkg` 构建文件到 App Store Connect +- **latest-build** — 查询指定平台最新的 App Store Connect build 号 ## 安装 @@ -85,6 +87,57 @@ asctl release --app-id --version 1.2.0 --whats-new "Bug fixes and impro 如果不指定 `--whats-new`,则从各语言目录下的 `whats_new.txt` 读取。 +### 上传构建 + +使用 App Store Connect Build Upload API 上传构建文件: + +```bash +asctl upload --app-id --platform ios --file ./build/MyApp.ipa +``` + +`.ipa` 文件会自动从 `Payload/*.app/Info.plist` 读取版本号和 build 号;如果需要覆盖,可以显式传入: + +```bash +asctl upload \ + --app-id \ + --platform ios \ + --file ./build/MyApp.ipa \ + --version 1.2.0 \ + --build 42 +``` + +上传 macOS `.pkg` 时需要显式提供版本号和 build 号: + +```bash +asctl upload --app-id --platform macos --file ./build/MyApp.pkg --version 1.2.0 --build 42 +``` + +如需等待 App Store Connect 完成上传处理: + +```bash +asctl upload --app-id --file ./build/MyApp.ipa --wait +``` + +### 查询最新 build 号 + +查询指定平台最新上传的 App Store Connect build 号: + +```bash +asctl latest-build --app-id --platform ios +``` + +默认只输出 build 号,方便在脚本中使用。需要更多信息时: + +```bash +asctl latest-build --app-id --platform ios --details +``` + +只查询某种处理状态的 build: + +```bash +asctl latest-build --app-id --platform ios --state valid +``` + ### 参数说明 **全局参数** @@ -111,6 +164,28 @@ asctl release --app-id --version 1.2.0 --whats-new "Bug fixes and impro | `--platform` | `-p` | `ios` | 平台:`ios` 或 `macos` | | `--whats-new` | — | — | 更新说明,覆盖所有语言的 `whats_new.txt` | +**upload 命令** + +| 参数 | 简写 | 默认值 | 说明 | +|------|------|--------|------| +| `--app-id` | `-a` | `ASC_APP_ID` | App Store Connect App ID(必填) | +| `--platform` | `-p` | `ios` | 平台:`ios` 或 `macos` | +| `--file` | `-f` | — | 构建文件路径,支持 `.ipa` / `.pkg`(必填) | +| `--version` | `-v` | — | 版本号,`.ipa` 可自动读取 | +| `--build` | `-b` | — | build 号,`.ipa` 可自动读取 | +| `--uti` | — | 按扩展名推断 | 构建文件 UTI 覆盖值 | +| `--wait` | — | `false` | 等待 App Store Connect 完成上传处理 | +| `--wait-timeout` | — | `30m` | `--wait` 的最长等待时间 | + +**latest-build 命令** + +| 参数 | 简写 | 默认值 | 说明 | +|------|------|--------|------| +| `--app-id` | `-a` | `ASC_APP_ID` | App Store Connect App ID(必填) | +| `--platform` | `-p` | `ios` | 平台:`ios` 或 `macos` | +| `--state` | — | `all` | 处理状态:`all`、`valid`、`processing`、`failed`、`invalid` | +| `--details` | — | `false` | 输出 build ID、处理状态、上传时间等详细信息 | + ## 典型工作流 ```bash diff --git a/cmd/latest_build.go b/cmd/latest_build.go new file mode 100644 index 0000000..de40d46 --- /dev/null +++ b/cmd/latest_build.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/rainbend/appstoreconnect-cli/internal/api" + "github.com/spf13/cobra" +) + +var latestBuildCmd = &cobra.Command{ + Use: "latest-build", + Short: "Print the latest build number for a platform", + Long: `Print the latest App Store Connect build number for an app platform. + +By default the command prints only Build.Attributes.version so it can be used +from scripts. Use --details to print the build ID, processing state, and upload +date as well.`, + RunE: runLatestBuild, +} + +func init() { + latestBuildCmd.Flags().StringP("app-id", "a", os.Getenv("ASC_APP_ID"), "App Store Connect App ID (env: ASC_APP_ID)") + latestBuildCmd.Flags().StringP("platform", "p", "ios", "Platform: ios or macos") + latestBuildCmd.Flags().String("state", "all", "Processing state: all, valid, processing, failed, or invalid") + latestBuildCmd.Flags().Bool("details", false, "Print build ID, state, and upload date") + rootCmd.AddCommand(latestBuildCmd) +} + +func runLatestBuild(cmd *cobra.Command, args []string) error { + if err := validateAuthFlags(); err != nil { + return err + } + + appID, _ := cmd.Flags().GetString("app-id") + if appID == "" { + return fmt.Errorf("--app-id or ASC_APP_ID environment variable is required") + } + + platformStr, _ := cmd.Flags().GetString("platform") + platform, err := api.ParsePlatform(platformStr) + if err != nil { + return err + } + + stateFlag, _ := cmd.Flags().GetString("state") + state, err := normalizeBuildProcessingState(stateFlag) + if err != nil { + return err + } + + client := api.NewClient(keyID, issuerID, privateKeyPath) + build, err := client.LatestBuild(appID, platform, state) + if err != nil { + return fmt.Errorf("fetching latest build: %w", err) + } + if build == nil { + stateText := "any processing state" + if state != "" { + stateText = state + } + return fmt.Errorf("no builds found for platform %s with %s", platformStr, stateText) + } + + details, _ := cmd.Flags().GetBool("details") + if !details { + fmt.Println(build.Attributes.Version) + return nil + } + + fmt.Printf("Build: %s\n", build.Attributes.Version) + fmt.Printf("ID: %s\n", build.ID) + if build.Attributes.ProcessingState != "" { + fmt.Printf("State: %s\n", build.Attributes.ProcessingState) + } + if build.Attributes.UploadedDate != "" { + fmt.Printf("Uploaded: %s\n", build.Attributes.UploadedDate) + } + if build.Attributes.MinOSVersion != "" { + fmt.Printf("Min OS: %s\n", build.Attributes.MinOSVersion) + } + if build.Attributes.ExpirationDate != "" { + fmt.Printf("Expires: %s\n", build.Attributes.ExpirationDate) + } + if build.Attributes.Expired { + fmt.Println("Expired: true") + } + + return nil +} + +func normalizeBuildProcessingState(state string) (string, error) { + switch strings.ToLower(strings.TrimSpace(state)) { + case "", "all": + return "", nil + case "valid": + return "VALID", nil + case "processing": + return "PROCESSING", nil + case "failed": + return "FAILED", nil + case "invalid": + return "INVALID", nil + default: + return "", fmt.Errorf("invalid --state %q: must be all, valid, processing, failed, or invalid", state) + } +} diff --git a/cmd/upload.go b/cmd/upload.go new file mode 100644 index 0000000..604f89a --- /dev/null +++ b/cmd/upload.go @@ -0,0 +1,231 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/rainbend/appstoreconnect-cli/internal/api" + "github.com/rainbend/appstoreconnect-cli/internal/buildinfo" + "github.com/spf13/cobra" +) + +var uploadCmd = &cobra.Command{ + Use: "upload", + Short: "Upload a build to App Store Connect", + Long: `Upload an app build using the App Store Connect Build Upload API. + +The command creates a build upload, reserves the build file, uploads all +parts returned by App Store Connect, and commits the upload for processing. + +For .ipa files, version and build number are read from Payload/*.app/Info.plist +when --version or --build are omitted. For .pkg files, pass both values.`, + RunE: runUpload, +} + +func init() { + uploadCmd.Flags().StringP("app-id", "a", os.Getenv("ASC_APP_ID"), "App Store Connect App ID (env: ASC_APP_ID)") + uploadCmd.Flags().StringP("platform", "p", "ios", "Platform: ios or macos") + uploadCmd.Flags().StringP("file", "f", "", "Build file to upload (.ipa or .pkg)") + uploadCmd.Flags().StringP("version", "v", "", "CFBundleShortVersionString, e.g. 1.2.0 (auto-read for .ipa)") + uploadCmd.Flags().StringP("build", "b", "", "CFBundleVersion/build number, e.g. 42 (auto-read for .ipa)") + uploadCmd.Flags().String("uti", "", "Build file UTI override (defaults by extension)") + uploadCmd.Flags().Bool("wait", false, "Wait for App Store Connect to finish processing the build upload") + uploadCmd.Flags().Duration("wait-timeout", 30*time.Minute, "Maximum time to wait when --wait is set") + _ = uploadCmd.MarkFlagRequired("file") + rootCmd.AddCommand(uploadCmd) +} + +func runUpload(cmd *cobra.Command, args []string) error { + if err := validateAuthFlags(); err != nil { + return err + } + + appID, _ := cmd.Flags().GetString("app-id") + if appID == "" { + return fmt.Errorf("--app-id or ASC_APP_ID environment variable is required") + } + + platformStr, _ := cmd.Flags().GetString("platform") + platform, err := api.ParsePlatform(platformStr) + if err != nil { + return err + } + + filePath, _ := cmd.Flags().GetString("file") + if filePath == "" { + return fmt.Errorf("--file is required") + } + + fileInfo, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("stat build file: %w", err) + } + if fileInfo.IsDir() { + return fmt.Errorf("build file cannot be a directory: %s", filePath) + } + + versionString, _ := cmd.Flags().GetString("version") + buildNumber, _ := cmd.Flags().GetString("build") + if versionString == "" || buildNumber == "" { + if strings.EqualFold(filepath.Ext(filePath), ".ipa") { + info, err := buildinfo.ReadIPA(filePath) + if err != nil { + return fmt.Errorf("reading version from ipa: %w; pass --version and --build to set them explicitly", err) + } + if versionString == "" { + versionString = info.VersionString + } + if buildNumber == "" { + buildNumber = info.BuildNumber + } + if info.BundleID != "" { + fmt.Printf("Found bundle %s, version %s (%s)\n", info.BundleID, versionString, buildNumber) + } + } + } + if versionString == "" { + return fmt.Errorf("--version is required when it cannot be read from the build file") + } + if buildNumber == "" { + return fmt.Errorf("--build is required when it cannot be read from the build file") + } + + uti, _ := cmd.Flags().GetString("uti") + if uti == "" { + uti, err = inferBuildFileUTI(filePath) + if err != nil { + return err + } + } + + waitForProcessing, _ := cmd.Flags().GetBool("wait") + waitTimeout, _ := cmd.Flags().GetDuration("wait-timeout") + if waitForProcessing && waitTimeout <= 0 { + return fmt.Errorf("--wait-timeout must be greater than 0") + } + + client := api.NewClient(keyID, issuerID, privateKeyPath) + + fmt.Printf("Creating build upload for %s (%s) on %s...\n", versionString, buildNumber, platformStr) + buildUpload, err := client.CreateBuildUpload(appID, versionString, buildNumber, platform) + if err != nil { + return fmt.Errorf("creating build upload: %w", err) + } + fmt.Printf("Build Upload ID: %s\n", buildUpload.ID) + + fileName := filepath.Base(filePath) + fmt.Printf("Reserving build file %s (%s)...\n", fileName, formatBytes(fileInfo.Size())) + buildUploadFile, err := client.CreateBuildUploadFile(buildUpload.ID, fileName, fileInfo.Size(), uti) + if err != nil { + return fmt.Errorf("creating build upload file: %w", err) + } + + operations := buildUploadFile.Attributes.UploadOperations + fmt.Printf("Uploading %d part(s)...\n", len(operations)) + err = client.UploadBuildFile(filePath, operations, func(index, total int, op api.UploadOperation) { + fmt.Printf(" Part %d/%d: offset %d, %s\n", index, total, op.Offset, formatBytes(op.Length)) + }) + if err != nil { + return err + } + + fmt.Println("Committing upload...") + committedFile, err := client.CommitBuildUploadFile(buildUploadFile.ID) + if err != nil { + return fmt.Errorf("committing build upload file: %w", err) + } + printAssetDeliveryMessages(committedFile.Attributes.AssetDeliveryState) + + if waitForProcessing { + if err := waitForBuildUpload(client, buildUpload.ID, waitTimeout); err != nil { + return err + } + fmt.Println("Build upload processing complete!") + } else { + fmt.Println("Upload complete! App Store Connect will continue processing the build.") + } + + return nil +} + +func inferBuildFileUTI(filePath string) (string, error) { + switch strings.ToLower(filepath.Ext(filePath)) { + case ".ipa": + return "com.apple.ipa", nil + case ".pkg": + return "com.apple.pkg", nil + default: + return "", fmt.Errorf("cannot infer UTI for %s; pass --uti explicitly", filePath) + } +} + +func waitForBuildUpload(client *api.Client, buildUploadID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + buildUpload, err := client.GetBuildUpload(buildUploadID) + if err != nil { + return fmt.Errorf("checking build upload status: %w", err) + } + + state := strings.ToUpper(buildUpload.Attributes.State) + if state == "" { + state = "UNKNOWN" + } + fmt.Printf(" Build upload state: %s\n", state) + + switch state { + case "COMPLETE": + return nil + case "FAILED": + return fmt.Errorf("build upload failed") + } + + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for build upload %s after %s", buildUploadID, timeout) + } + time.Sleep(30 * time.Second) + } +} + +func printAssetDeliveryMessages(state *api.AssetDeliveryState) { + if state == nil { + return + } + if state.State != "" { + fmt.Printf("Asset delivery state: %s\n", state.State) + } + for _, warning := range state.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", deliveryMessageText(warning)) + } + for _, errMsg := range state.Errors { + fmt.Fprintf(os.Stderr, "Error: %s\n", deliveryMessageText(errMsg)) + } +} + +func deliveryMessageText(message api.AssetDeliveryMessage) string { + for _, value := range []string{message.Detail, message.Message, message.Title, message.Code} { + if value != "" { + return value + } + } + return "unknown delivery message" +} + +func formatBytes(size int64) string { + if size < 1024 { + return fmt.Sprintf("%d B", size) + } + + value := float64(size) + units := []string{"B", "KiB", "MiB", "GiB", "TiB"} + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + + return fmt.Sprintf("%.1f %s", value, units[unit]) +} diff --git a/internal/api/build_uploads.go b/internal/api/build_uploads.go new file mode 100644 index 0000000..4e335cf --- /dev/null +++ b/internal/api/build_uploads.go @@ -0,0 +1,191 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +const buildUploadAssetType = "ASSET" + +func (c *Client) CreateBuildUpload(appID, versionString, buildNumber string, platform Platform) (*BuildUpload, error) { + req := CreateBuildUploadRequest{ + Data: CreateBuildUploadData{ + Type: "buildUploads", + Attributes: BuildUploadAttributes{ + CFBundleShortVersionString: versionString, + CFBundleVersion: buildNumber, + Platform: string(platform), + }, + Relationships: CreateBuildUploadRelationships{ + App: RelationshipData{ + Data: ResourceIdentifier{ + Type: "apps", + ID: appID, + }, + }, + }, + }, + } + + data, err := c.do("POST", "/buildUploads", req) + if err != nil { + return nil, err + } + + var resp SingleResponse[BuildUpload] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &resp.Data, nil +} + +func (c *Client) GetBuildUpload(buildUploadID string) (*BuildUpload, error) { + data, err := c.do("GET", fmt.Sprintf("/buildUploads/%s", buildUploadID), nil) + if err != nil { + return nil, err + } + + var resp SingleResponse[BuildUpload] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &resp.Data, nil +} + +func (c *Client) CreateBuildUploadFile(buildUploadID, fileName string, fileSize int64, uti string) (*BuildUploadFile, error) { + req := CreateBuildUploadFileRequest{ + Data: CreateBuildUploadFileData{ + Type: "buildUploadFiles", + Attributes: BuildUploadFileAttributes{ + FileName: fileName, + FileSize: fileSize, + AssetType: buildUploadAssetType, + UTI: uti, + }, + Relationships: CreateBuildUploadFileRelationships{ + BuildUpload: RelationshipData{ + Data: ResourceIdentifier{ + Type: "buildUploads", + ID: buildUploadID, + }, + }, + }, + }, + } + + data, err := c.do("POST", "/buildUploadFiles", req) + if err != nil { + return nil, err + } + + var resp SingleResponse[BuildUploadFile] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &resp.Data, nil +} + +func (c *Client) CommitBuildUploadFile(buildUploadFileID string) (*BuildUploadFile, error) { + req := UpdateBuildUploadFileRequest{ + Data: UpdateBuildUploadFileData{ + Type: "buildUploadFiles", + ID: buildUploadFileID, + Attributes: BuildUploadFileAttributes{ + Uploaded: true, + }, + }, + } + + data, err := c.do("PATCH", fmt.Sprintf("/buildUploadFiles/%s", buildUploadFileID), req) + if err != nil { + return nil, err + } + + var resp SingleResponse[BuildUploadFile] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &resp.Data, nil +} + +func (c *Client) UploadBuildFile(filePath string, operations []UploadOperation, progress func(index, total int, op UploadOperation)) error { + if len(operations) == 0 { + return fmt.Errorf("no upload operations returned by App Store Connect") + } + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening build file: %w", err) + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return fmt.Errorf("stat build file: %w", err) + } + + client := &http.Client{Timeout: 30 * time.Minute} + total := len(operations) + for i, op := range operations { + if op.Length <= 0 { + return fmt.Errorf("upload operation %d has invalid length %d", i+1, op.Length) + } + if op.Offset < 0 || op.Offset+op.Length > info.Size() { + return fmt.Errorf("upload operation %d range is outside file bounds", i+1) + } + if op.URL == "" { + return fmt.Errorf("upload operation %d is missing URL", i+1) + } + + if progress != nil { + progress(i+1, total, op) + } + if err := uploadBuildFilePart(client, file, op); err != nil { + return fmt.Errorf("uploading part %d/%d: %w", i+1, total, err) + } + } + + return nil +} + +func uploadBuildFilePart(client *http.Client, file *os.File, op UploadOperation) error { + method := op.Method + if method == "" { + method = http.MethodPut + } + + body := io.NewSectionReader(file, op.Offset, op.Length) + req, err := http.NewRequest(method, op.URL, body) + if err != nil { + return fmt.Errorf("creating upload request: %w", err) + } + req.ContentLength = op.Length + + for _, header := range op.RequestHeaders { + if header.Name == "" { + continue + } + req.Header.Set(header.Name, header.Value) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("executing upload request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("upload error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + return nil +} diff --git a/internal/api/builds.go b/internal/api/builds.go new file mode 100644 index 0000000..d2fb0ba --- /dev/null +++ b/internal/api/builds.go @@ -0,0 +1,35 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +func (c *Client) LatestBuild(appID string, platform Platform, processingState string) (*Build, error) { + values := url.Values{} + values.Set("filter[app]", appID) + values.Set("filter[preReleaseVersion.platform]", string(platform)) + values.Set("fields[builds]", "version,uploadedDate,expirationDate,expired,minOsVersion,processingState,buildAudienceType,usesNonExemptEncryption") + values.Set("sort", "-uploadedDate") + values.Set("limit", "1") + if processingState != "" { + values.Set("filter[processingState]", strings.ToUpper(processingState)) + } + + data, err := c.do("GET", "/builds?"+values.Encode(), nil) + if err != nil { + return nil, err + } + + var resp ListResponse[Build] + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + if len(resp.Data) == 0 { + return nil, nil + } + + return &resp.Data[0], nil +} diff --git a/internal/api/types.go b/internal/api/types.go index 2d13f8c..cda8a11 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -106,3 +106,115 @@ type UpdateLocalizationData struct { ID string `json:"id"` Attributes AppStoreVersionLocalizationAttributes `json:"attributes"` } + +type Build struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes BuildAttributes `json:"attributes"` +} + +type BuildAttributes struct { + Version string `json:"version"` + UploadedDate string `json:"uploadedDate,omitempty"` + ExpirationDate string `json:"expirationDate,omitempty"` + Expired bool `json:"expired,omitempty"` + MinOSVersion string `json:"minOsVersion,omitempty"` + ProcessingState string `json:"processingState,omitempty"` + BuildAudienceType string `json:"buildAudienceType,omitempty"` + UsesNonExemptEncryption bool `json:"usesNonExemptEncryption,omitempty"` +} + +type BuildUpload struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes BuildUploadAttributes `json:"attributes"` +} + +type BuildUploadAttributes struct { + CFBundleShortVersionString string `json:"cfBundleShortVersionString,omitempty"` + CFBundleVersion string `json:"cfBundleVersion,omitempty"` + CreatedDate string `json:"createdDate,omitempty"` + State string `json:"state,omitempty"` + Platform string `json:"platform,omitempty"` + UploadedDate string `json:"uploadedDate,omitempty"` +} + +type BuildUploadFile struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes BuildUploadFileAttributes `json:"attributes"` +} + +type BuildUploadFileAttributes struct { + FileName string `json:"fileName,omitempty"` + FileSize int64 `json:"fileSize,omitempty"` + AssetType string `json:"assetType,omitempty"` + UTI string `json:"uti,omitempty"` + Uploaded bool `json:"uploaded,omitempty"` + UploadOperations []UploadOperation `json:"uploadOperations,omitempty"` + AssetDeliveryState *AssetDeliveryState `json:"assetDeliveryState,omitempty"` +} + +type UploadOperation struct { + Method string `json:"method"` + URL string `json:"url"` + Offset int64 `json:"offset"` + Length int64 `json:"length"` + RequestHeaders []UploadOperationHeader `json:"requestHeaders,omitempty"` +} + +type UploadOperationHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type AssetDeliveryState struct { + State string `json:"state,omitempty"` + Errors []AssetDeliveryMessage `json:"errors,omitempty"` + Warnings []AssetDeliveryMessage `json:"warnings,omitempty"` +} + +type AssetDeliveryMessage struct { + Code string `json:"code,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Message string `json:"message,omitempty"` +} + +type CreateBuildUploadRequest struct { + Data CreateBuildUploadData `json:"data"` +} + +type CreateBuildUploadData struct { + Type string `json:"type"` + Attributes BuildUploadAttributes `json:"attributes"` + Relationships CreateBuildUploadRelationships `json:"relationships"` +} + +type CreateBuildUploadRelationships struct { + App RelationshipData `json:"app"` +} + +type CreateBuildUploadFileRequest struct { + Data CreateBuildUploadFileData `json:"data"` +} + +type CreateBuildUploadFileData struct { + Type string `json:"type"` + Attributes BuildUploadFileAttributes `json:"attributes"` + Relationships CreateBuildUploadFileRelationships `json:"relationships"` +} + +type CreateBuildUploadFileRelationships struct { + BuildUpload RelationshipData `json:"buildUpload"` +} + +type UpdateBuildUploadFileRequest struct { + Data UpdateBuildUploadFileData `json:"data"` +} + +type UpdateBuildUploadFileData struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes BuildUploadFileAttributes `json:"attributes"` +} diff --git a/internal/buildinfo/ipa.go b/internal/buildinfo/ipa.go new file mode 100644 index 0000000..0ce2983 --- /dev/null +++ b/internal/buildinfo/ipa.go @@ -0,0 +1,170 @@ +package buildinfo + +import ( + "archive/zip" + "bytes" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type AppInfo struct { + BundleID string + VersionString string + BuildNumber string +} + +func ReadIPA(path string) (*AppInfo, error) { + reader, err := zip.OpenReader(path) + if err != nil { + return nil, fmt.Errorf("opening ipa: %w", err) + } + defer reader.Close() + + var infoPlist *zip.File + for _, file := range reader.File { + if isAppInfoPlist(file.Name) { + infoPlist = file + break + } + } + if infoPlist == nil { + return nil, fmt.Errorf("Info.plist not found in Payload/*.app") + } + + rc, err := infoPlist.Open() + if err != nil { + return nil, fmt.Errorf("opening Info.plist: %w", err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("reading Info.plist: %w", err) + } + + info, err := parseXMLInfoPlist(data) + if err == nil && info.VersionString != "" && info.BuildNumber != "" { + return info, nil + } + + info, err = parseInfoPlistWithPlutil(data) + if err != nil { + return nil, fmt.Errorf("parsing Info.plist: %w", err) + } + if info.VersionString == "" { + return nil, fmt.Errorf("CFBundleShortVersionString not found in Info.plist") + } + if info.BuildNumber == "" { + return nil, fmt.Errorf("CFBundleVersion not found in Info.plist") + } + + return info, nil +} + +func isAppInfoPlist(name string) bool { + if !strings.HasPrefix(name, "Payload/") || !strings.HasSuffix(name, ".app/Info.plist") { + return false + } + parts := strings.Split(name, "/") + return len(parts) == 3 +} + +func parseXMLInfoPlist(data []byte) (*AppInfo, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + values := make(map[string]string) + var lastKey string + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + start, ok := token.(xml.StartElement) + if !ok { + continue + } + + switch start.Name.Local { + case "key": + var value string + if err := decoder.DecodeElement(&value, &start); err != nil { + return nil, err + } + lastKey = value + case "string", "integer": + if lastKey == "" { + continue + } + var value string + if err := decoder.DecodeElement(&value, &start); err != nil { + return nil, err + } + values[lastKey] = value + lastKey = "" + } + } + + return appInfoFromMap(values), nil +} + +func parseInfoPlistWithPlutil(data []byte) (*AppInfo, error) { + dir, err := os.MkdirTemp("", "asctl-info-plist-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(dir) + + plistPath := filepath.Join(dir, "Info.plist") + if err := os.WriteFile(plistPath, data, 0600); err != nil { + return nil, fmt.Errorf("writing temp Info.plist: %w", err) + } + + output, err := exec.Command("plutil", "-convert", "json", "-o", "-", plistPath).Output() + if err != nil { + return nil, fmt.Errorf("converting plist with plutil: %w", err) + } + + var values map[string]interface{} + if err := json.Unmarshal(output, &values); err != nil { + return nil, fmt.Errorf("decoding plutil JSON: %w", err) + } + + return appInfoFromAnyMap(values), nil +} + +func appInfoFromMap(values map[string]string) *AppInfo { + return &AppInfo{ + BundleID: values["CFBundleIdentifier"], + VersionString: values["CFBundleShortVersionString"], + BuildNumber: values["CFBundleVersion"], + } +} + +func appInfoFromAnyMap(values map[string]interface{}) *AppInfo { + stringValue := func(key string) string { + switch value := values[key].(type) { + case string: + return value + case float64: + return fmt.Sprintf("%.0f", value) + default: + return "" + } + } + + return &AppInfo{ + BundleID: stringValue("CFBundleIdentifier"), + VersionString: stringValue("CFBundleShortVersionString"), + BuildNumber: stringValue("CFBundleVersion"), + } +} diff --git a/internal/buildinfo/ipa_test.go b/internal/buildinfo/ipa_test.go new file mode 100644 index 0000000..3e31c7c --- /dev/null +++ b/internal/buildinfo/ipa_test.go @@ -0,0 +1,60 @@ +package buildinfo + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestReadIPAExtractsVersionInfo(t *testing.T) { + tmpDir := t.TempDir() + ipaPath := filepath.Join(tmpDir, "MyApp.ipa") + + file, err := os.Create(ipaPath) + if err != nil { + t.Fatalf("create ipa: %v", err) + } + + zipWriter := zip.NewWriter(file) + writer, err := zipWriter.Create("Payload/MyApp.app/Info.plist") + if err != nil { + t.Fatalf("create info plist entry: %v", err) + } + _, err = writer.Write([]byte(` + + + + CFBundleIdentifier + com.example.myapp + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 45 + +`)) + if err != nil { + t.Fatalf("write info plist: %v", err) + } + if err := zipWriter.Close(); err != nil { + t.Fatalf("close zip writer: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close ipa: %v", err) + } + + info, err := ReadIPA(ipaPath) + if err != nil { + t.Fatalf("ReadIPA returned error: %v", err) + } + + if info.BundleID != "com.example.myapp" { + t.Fatalf("BundleID = %q, want com.example.myapp", info.BundleID) + } + if info.VersionString != "1.2.3" { + t.Fatalf("VersionString = %q, want 1.2.3", info.VersionString) + } + if info.BuildNumber != "45" { + t.Fatalf("BuildNumber = %q, want 45", info.BuildNumber) + } +} From c2267b34e577a6ab5725757595597b4c29acc769 Mon Sep 17 00:00:00 2001 From: zhangwei Date: Mon, 27 Apr 2026 21:51:27 +0800 Subject: [PATCH 2/2] Handle build upload state as flexible JSON --- cmd/upload.go | 2 +- internal/api/types.go | 55 ++++++++++++++++++++++++++++++----- internal/api/types_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 internal/api/types_test.go diff --git a/cmd/upload.go b/cmd/upload.go index 604f89a..365b6d1 100644 --- a/cmd/upload.go +++ b/cmd/upload.go @@ -170,7 +170,7 @@ func waitForBuildUpload(client *api.Client, buildUploadID string, timeout time.D return fmt.Errorf("checking build upload status: %w", err) } - state := strings.ToUpper(buildUpload.Attributes.State) + state := strings.ToUpper(buildUpload.Attributes.State.String()) if state == "" { state = "UNKNOWN" } diff --git a/internal/api/types.go b/internal/api/types.go index cda8a11..64cc9cb 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -1,6 +1,9 @@ package api -import "fmt" +import ( + "encoding/json" + "fmt" +) type Platform string @@ -131,12 +134,50 @@ type BuildUpload struct { } type BuildUploadAttributes struct { - CFBundleShortVersionString string `json:"cfBundleShortVersionString,omitempty"` - CFBundleVersion string `json:"cfBundleVersion,omitempty"` - CreatedDate string `json:"createdDate,omitempty"` - State string `json:"state,omitempty"` - Platform string `json:"platform,omitempty"` - UploadedDate string `json:"uploadedDate,omitempty"` + CFBundleShortVersionString string `json:"cfBundleShortVersionString,omitempty"` + CFBundleVersion string `json:"cfBundleVersion,omitempty"` + CreatedDate string `json:"createdDate,omitempty"` + State BuildUploadState `json:"state,omitempty"` + Platform string `json:"platform,omitempty"` + UploadedDate string `json:"uploadedDate,omitempty"` +} + +type BuildUploadState string + +func (s BuildUploadState) String() string { + return string(s) +} + +func (s *BuildUploadState) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *s = "" + return nil + } + + var value string + if err := json.Unmarshal(data, &value); err == nil { + *s = BuildUploadState(value) + return nil + } + + var stateObject struct { + State string `json:"state"` + Code string `json:"code"` + Value string `json:"value"` + } + if err := json.Unmarshal(data, &stateObject); err != nil { + return err + } + + switch { + case stateObject.State != "": + *s = BuildUploadState(stateObject.State) + case stateObject.Code != "": + *s = BuildUploadState(stateObject.Code) + default: + *s = BuildUploadState(stateObject.Value) + } + return nil } type BuildUploadFile struct { diff --git a/internal/api/types_test.go b/internal/api/types_test.go new file mode 100644 index 0000000..3ccd08d --- /dev/null +++ b/internal/api/types_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestBuildUploadStateUnmarshalString(t *testing.T) { + var state BuildUploadState + if err := json.Unmarshal([]byte(`"COMPLETE"`), &state); err != nil { + t.Fatalf("unmarshal string state: %v", err) + } + if got := state.String(); got != "COMPLETE" { + t.Fatalf("state = %q, want COMPLETE", got) + } +} + +func TestBuildUploadStateUnmarshalObject(t *testing.T) { + var state BuildUploadState + if err := json.Unmarshal([]byte(`{"state":"AWAITING_UPLOAD","errors":[],"warnings":[]}`), &state); err != nil { + t.Fatalf("unmarshal object state: %v", err) + } + if got := state.String(); got != "AWAITING_UPLOAD" { + t.Fatalf("state = %q, want AWAITING_UPLOAD", got) + } +} + +func TestBuildUploadStateUnmarshalObjectFallbacks(t *testing.T) { + tests := []struct { + name string + json string + want string + }{ + {name: "code", json: `{"code":"COMPLETE"}`, want: "COMPLETE"}, + {name: "value", json: `{"value":"FAILED"}`, want: "FAILED"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var state BuildUploadState + if err := json.Unmarshal([]byte(test.json), &state); err != nil { + t.Fatalf("unmarshal object state: %v", err) + } + if got := state.String(); got != test.want { + t.Fatalf("state = %q, want %s", got, test.want) + } + }) + } +} + +func TestBuildUploadStateUnmarshalNull(t *testing.T) { + state := BuildUploadState("COMPLETE") + if err := json.Unmarshal([]byte(`null`), &state); err != nil { + t.Fatalf("unmarshal null state: %v", err) + } + if got := state.String(); got != "" { + t.Fatalf("state = %q, want empty", got) + } +}