[GH-ISSUE #78] Complete build upload flow (upload + commit) #24

Closed
opened 2026-02-26 21:32:50 +03:00 by kerem · 3 comments
Owner

Originally created by @rudrankriyam on GitHub (Jan 24, 2026).
Original GitHub issue: https://github.com/rudrankriyam/App-Store-Connect-CLI/issues/78

Summary

Implement full build upload flow (upload IPA to presigned URLs and finalize). Current asc builds upload only prepares upload records and returns upload operations.

Current State (verified)

asc builds upload creates the build upload + buildUploadFile reservation but does not perform the actual file upload or commit.

API Endpoints (App Store Connect OpenAPI)

  • POST /v1/buildUploads
  • GET /v1/buildUploads/{id}
  • POST /v1/buildUploadFiles
  • PATCH /v1/buildUploadFiles/{id} (commit/mark uploaded)

Proposed CLI

asc builds upload --ipa app.ipa --app APP_ID [--version 1.0.0 --build-number 123]
asc builds upload --ipa app.ipa --app APP_ID --upload   # performs upload + commit

Flags:

  • --upload (perform the PUT(s) to uploadOperations and then commit with UpdateBuildUploadFile)
  • --concurrency (optional, default 1)
  • --checksum (optional: verify upload checksums if provided by API)

Implementation Plan

  1. cmd/builds_upload.go
  • Extend existing builds upload command to optionally perform uploads.
  • Read uploadOperations from CreateBuildUploadFile response.
  • Execute PUT requests with offsets/lengths; then call UpdateBuildUploadFile to mark uploaded=true.
  1. internal/asc/upload.go
  • Helper to execute upload operations with retries and checksum verification.
  • Reuse existing retry/backoff logic for network reliability.
  1. Tests
  • Unit tests for upload operation handling (offsets/lengths).
  • HTTP client tests for UpdateBuildUploadFile commit.

Acceptance Criteria

  • With --upload, the IPA is uploaded via the presigned URLs and the upload file is committed.
  • Clear progress output (JSON metadata by default).
  • Safe behavior on failure: no partial overwrite; explicit errors.
Originally created by @rudrankriyam on GitHub (Jan 24, 2026). Original GitHub issue: https://github.com/rudrankriyam/App-Store-Connect-CLI/issues/78 ## Summary Implement full build upload flow (upload IPA to presigned URLs and finalize). Current `asc builds upload` only prepares upload records and returns upload operations. ## Current State (verified) `asc builds upload` creates the build upload + buildUploadFile reservation but does **not** perform the actual file upload or commit. ## API Endpoints (App Store Connect OpenAPI) - `POST /v1/buildUploads` - `GET /v1/buildUploads/{id}` - `POST /v1/buildUploadFiles` - `PATCH /v1/buildUploadFiles/{id}` (commit/mark uploaded) ## Proposed CLI ``` asc builds upload --ipa app.ipa --app APP_ID [--version 1.0.0 --build-number 123] asc builds upload --ipa app.ipa --app APP_ID --upload # performs upload + commit ``` Flags: - `--upload` (perform the PUT(s) to `uploadOperations` and then commit with UpdateBuildUploadFile) - `--concurrency` (optional, default 1) - `--checksum` (optional: verify upload checksums if provided by API) ## Implementation Plan 1) `cmd/builds_upload.go` - Extend existing `builds upload` command to optionally perform uploads. - Read uploadOperations from CreateBuildUploadFile response. - Execute PUT requests with offsets/lengths; then call UpdateBuildUploadFile to mark `uploaded=true`. 2) `internal/asc/upload.go` - Helper to execute upload operations with retries and checksum verification. - Reuse existing retry/backoff logic for network reliability. 3) Tests - Unit tests for upload operation handling (offsets/lengths). - HTTP client tests for UpdateBuildUploadFile commit. ## Acceptance Criteria - With `--upload`, the IPA is uploaded via the presigned URLs and the upload file is committed. - Clear progress output (JSON metadata by default). - Safe behavior on failure: no partial overwrite; explicit errors.
kerem closed this issue 2026-02-26 21:32:50 +03:00
Author
Owner

@rudrankriyam commented on GitHub (Jan 24, 2026):

@cursor

Implementation Guide

Codebase Context

This completes the build upload flow. The current cmd/builds.go and internal/asc/client_builds.go have the reservation logic. This issue adds the actual upload + commit.

Current State

Looking at internal/asc/client_builds.go:

  • CreateBuildUpload creates the upload reservation
  • CreateBuildUploadFile reserves the file slot and returns uploadOperations
  • Missing: executing PUT requests to uploadOperations URLs and calling UpdateBuildUploadFile to commit

File Structure

1. Extend internal/asc/client_builds.go (~50-100 additional lines)

// Add UpdateBuildUploadFile to commit the upload
func (c *Client) UpdateBuildUploadFile(ctx context.Context, fileID string, uploaded bool, checksum *Checksum) (*BuildUploadFileResponse, error) {
    // PATCH /v1/buildUploadFiles/{id}
    // Body: { "data": { "type": "buildUploadFiles", "id": fileID, "attributes": { "uploaded": true, "checksum": {...} } } }
}

2. Create internal/asc/upload.go (~150-200 lines)

// Generic upload helper for executing uploadOperations
type UploadOperation struct {
    Method       string                 `json:"method"`       // PUT
    URL          string                 `json:"url"`
    Length       int                    `json:"length"`
    Offset       int                    `json:"offset"`
    RequestHeaders []UploadRequestHeader `json:"requestHeaders"`
}

type UploadRequestHeader struct {
    Name  string `json:"name"`
    Value string `json:"value"`
}

// ExecuteUploadOperations performs the actual file upload
func ExecuteUploadOperations(ctx context.Context, filePath string, operations []UploadOperation, opts ...UploadOption) error {
    file, err := os.Open(filePath)
    if err != nil {
        return fmt.Errorf("open file: %w", err)
    }
    defer file.Close()

    for i, op := range operations {
        // Seek to offset
        if _, err := file.Seek(int64(op.Offset), io.SeekStart); err != nil {
            return fmt.Errorf("seek to offset %d: %w", op.Offset, err)
        }

        // Read chunk
        chunk := make([]byte, op.Length)
        n, err := io.ReadFull(file, chunk)
        if err != nil && err != io.ErrUnexpectedEOF {
            return fmt.Errorf("read chunk %d: %w", i, err)
        }
        chunk = chunk[:n]

        // Create request
        req, err := http.NewRequestWithContext(ctx, op.Method, op.URL, bytes.NewReader(chunk))
        if err != nil {
            return fmt.Errorf("create request %d: %w", i, err)
        }

        // Set headers from uploadOperation
        for _, h := range op.RequestHeaders {
            req.Header.Set(h.Name, h.Value)
        }

        // Execute with retry
        resp, err := WithRetry(ctx, func() (*http.Response, error) {
            return http.DefaultClient.Do(req)
        }, ResolveRetryOptions())
        if err != nil {
            return fmt.Errorf("upload chunk %d: %w", i, err)
        }
        resp.Body.Close()

        if resp.StatusCode >= 400 {
            return fmt.Errorf("upload chunk %d failed: %s", i, resp.Status)
        }
    }
    return nil
}

// ComputeFileChecksum computes checksum for verification
func ComputeFileChecksum(filePath string, algorithm ChecksumAlgorithm) (*Checksum, error) {
    file, err := os.Open(filePath)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    var hash hash.Hash
    switch algorithm {
    case ChecksumAlgorithmMD5:
        hash = md5.New()
    case ChecksumAlgorithmSHA256:
        hash = sha256.New()
    default:
        return nil, fmt.Errorf("unsupported algorithm: %s", algorithm)
    }

    if _, err := io.Copy(hash, file); err != nil {
        return nil, err
    }

    return &Checksum{
        Type:  algorithm,
        Value: hex.EncodeToString(hash.Sum(nil)),
    }, nil
}

3. Extend cmd/builds.go or cmd/builds_upload.go (~100-150 additional lines)

// Update BuildsUploadCommand to support --upload flag
func BuildsUploadCommand() *ffcli.Command {
    fs := flag.NewFlagSet("builds upload", flag.ExitOnError)

    appID := fs.String("app", "", "App ID (required)")
    ipaPath := fs.String("ipa", "", "Path to IPA file (required)")
    doUpload := fs.Bool("upload", false, "Perform actual upload + commit (not just reserve)")
    concurrency := fs.Int("concurrency", 1, "Upload concurrency (default 1)")
    output := fs.String("output", "json", "Output format")
    pretty := fs.Bool("pretty", false, "Pretty-print JSON")

    return &ffcli.Command{
        // ...
        Exec: func(ctx context.Context, args []string) error {
            // ... validation ...

            // Existing logic: CreateBuildUpload, CreateBuildUploadFile
            uploadResp, err := client.CreateBuildUpload(ctx, appID, version, buildNumber, platform)
            // ...
            fileResp, err := client.CreateBuildUploadFile(ctx, uploadResp.Data.ID, fileName, fileSize, checksumAlgo, checksumValue)
            // ...

            if *doUpload {
                // NEW: Execute the upload operations
                operations := fileResp.Data.Attributes.UploadOperations
                if err := asc.ExecuteUploadOperations(ctx, *ipaPath, operations); err != nil {
                    return fmt.Errorf("upload failed: %w", err)
                }

                // NEW: Commit the upload
                commitResp, err := client.UpdateBuildUploadFile(ctx, fileResp.Data.ID, true, &checksum)
                if err != nil {
                    return fmt.Errorf("commit failed: %w", err)
                }

                result.Uploaded = true
                result.CommitResponse = commitResp
            }

            return printOutput(result, *output, *pretty)
        },
    }
}

Upload Flow Summary

1. CreateBuildUpload (POST /v1/buildUploads)
   → returns buildUploadID

2. CreateBuildUploadFile (POST /v1/buildUploadFiles)
   → returns fileID + uploadOperations[]

3. For each uploadOperation:
   PUT {operation.url}
   Headers: {operation.requestHeaders}
   Body: file[operation.offset : operation.offset + operation.length]

4. UpdateBuildUploadFile (PATCH /v1/buildUploadFiles/{id})
   → attributes: { uploaded: true, checksum: {...} }

API Endpoints Reference

POST   /v1/buildUploads                              → CreateBuildUpload
GET    /v1/buildUploads/{id}                         → GetBuildUpload
POST   /v1/buildUploadFiles                          → CreateBuildUploadFile
PATCH  /v1/buildUploadFiles/{id}                     → UpdateBuildUploadFile (commit)

CLI Usage Examples

# Reserve upload only (current behavior)
asc builds upload --app "123456789" --ipa ./app.ipa

# Full upload + commit
asc builds upload --app "123456789" --ipa ./app.ipa --upload

# With version info
asc builds upload --app "123456789" --ipa ./app.ipa --version 1.0.0 --build-number 123 --upload

Testing

  • Run make test && make lint
  • Test upload operation execution with mocked URLs
  • Test checksum computation
  • Test error handling for partial uploads
<!-- gh-comment-id:3795329965 --> @rudrankriyam commented on GitHub (Jan 24, 2026): @cursor ## Implementation Guide ### Codebase Context This completes the build upload flow. The current `cmd/builds.go` and `internal/asc/client_builds.go` have the reservation logic. This issue adds the actual upload + commit. ### Current State Looking at `internal/asc/client_builds.go`: - `CreateBuildUpload` creates the upload reservation - `CreateBuildUploadFile` reserves the file slot and returns `uploadOperations` - Missing: executing PUT requests to `uploadOperations` URLs and calling `UpdateBuildUploadFile` to commit ### File Structure **1. Extend `internal/asc/client_builds.go`** (~50-100 additional lines) ```go // Add UpdateBuildUploadFile to commit the upload func (c *Client) UpdateBuildUploadFile(ctx context.Context, fileID string, uploaded bool, checksum *Checksum) (*BuildUploadFileResponse, error) { // PATCH /v1/buildUploadFiles/{id} // Body: { "data": { "type": "buildUploadFiles", "id": fileID, "attributes": { "uploaded": true, "checksum": {...} } } } } ``` **2. Create `internal/asc/upload.go`** (~150-200 lines) ```go // Generic upload helper for executing uploadOperations type UploadOperation struct { Method string `json:"method"` // PUT URL string `json:"url"` Length int `json:"length"` Offset int `json:"offset"` RequestHeaders []UploadRequestHeader `json:"requestHeaders"` } type UploadRequestHeader struct { Name string `json:"name"` Value string `json:"value"` } // ExecuteUploadOperations performs the actual file upload func ExecuteUploadOperations(ctx context.Context, filePath string, operations []UploadOperation, opts ...UploadOption) error { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("open file: %w", err) } defer file.Close() for i, op := range operations { // Seek to offset if _, err := file.Seek(int64(op.Offset), io.SeekStart); err != nil { return fmt.Errorf("seek to offset %d: %w", op.Offset, err) } // Read chunk chunk := make([]byte, op.Length) n, err := io.ReadFull(file, chunk) if err != nil && err != io.ErrUnexpectedEOF { return fmt.Errorf("read chunk %d: %w", i, err) } chunk = chunk[:n] // Create request req, err := http.NewRequestWithContext(ctx, op.Method, op.URL, bytes.NewReader(chunk)) if err != nil { return fmt.Errorf("create request %d: %w", i, err) } // Set headers from uploadOperation for _, h := range op.RequestHeaders { req.Header.Set(h.Name, h.Value) } // Execute with retry resp, err := WithRetry(ctx, func() (*http.Response, error) { return http.DefaultClient.Do(req) }, ResolveRetryOptions()) if err != nil { return fmt.Errorf("upload chunk %d: %w", i, err) } resp.Body.Close() if resp.StatusCode >= 400 { return fmt.Errorf("upload chunk %d failed: %s", i, resp.Status) } } return nil } // ComputeFileChecksum computes checksum for verification func ComputeFileChecksum(filePath string, algorithm ChecksumAlgorithm) (*Checksum, error) { file, err := os.Open(filePath) if err != nil { return nil, err } defer file.Close() var hash hash.Hash switch algorithm { case ChecksumAlgorithmMD5: hash = md5.New() case ChecksumAlgorithmSHA256: hash = sha256.New() default: return nil, fmt.Errorf("unsupported algorithm: %s", algorithm) } if _, err := io.Copy(hash, file); err != nil { return nil, err } return &Checksum{ Type: algorithm, Value: hex.EncodeToString(hash.Sum(nil)), }, nil } ``` **3. Extend `cmd/builds.go` or `cmd/builds_upload.go`** (~100-150 additional lines) ```go // Update BuildsUploadCommand to support --upload flag func BuildsUploadCommand() *ffcli.Command { fs := flag.NewFlagSet("builds upload", flag.ExitOnError) appID := fs.String("app", "", "App ID (required)") ipaPath := fs.String("ipa", "", "Path to IPA file (required)") doUpload := fs.Bool("upload", false, "Perform actual upload + commit (not just reserve)") concurrency := fs.Int("concurrency", 1, "Upload concurrency (default 1)") output := fs.String("output", "json", "Output format") pretty := fs.Bool("pretty", false, "Pretty-print JSON") return &ffcli.Command{ // ... Exec: func(ctx context.Context, args []string) error { // ... validation ... // Existing logic: CreateBuildUpload, CreateBuildUploadFile uploadResp, err := client.CreateBuildUpload(ctx, appID, version, buildNumber, platform) // ... fileResp, err := client.CreateBuildUploadFile(ctx, uploadResp.Data.ID, fileName, fileSize, checksumAlgo, checksumValue) // ... if *doUpload { // NEW: Execute the upload operations operations := fileResp.Data.Attributes.UploadOperations if err := asc.ExecuteUploadOperations(ctx, *ipaPath, operations); err != nil { return fmt.Errorf("upload failed: %w", err) } // NEW: Commit the upload commitResp, err := client.UpdateBuildUploadFile(ctx, fileResp.Data.ID, true, &checksum) if err != nil { return fmt.Errorf("commit failed: %w", err) } result.Uploaded = true result.CommitResponse = commitResp } return printOutput(result, *output, *pretty) }, } } ``` ### Upload Flow Summary ``` 1. CreateBuildUpload (POST /v1/buildUploads) → returns buildUploadID 2. CreateBuildUploadFile (POST /v1/buildUploadFiles) → returns fileID + uploadOperations[] 3. For each uploadOperation: PUT {operation.url} Headers: {operation.requestHeaders} Body: file[operation.offset : operation.offset + operation.length] 4. UpdateBuildUploadFile (PATCH /v1/buildUploadFiles/{id}) → attributes: { uploaded: true, checksum: {...} } ``` ### API Endpoints Reference ``` POST /v1/buildUploads → CreateBuildUpload GET /v1/buildUploads/{id} → GetBuildUpload POST /v1/buildUploadFiles → CreateBuildUploadFile PATCH /v1/buildUploadFiles/{id} → UpdateBuildUploadFile (commit) ``` ### CLI Usage Examples ```bash # Reserve upload only (current behavior) asc builds upload --app "123456789" --ipa ./app.ipa # Full upload + commit asc builds upload --app "123456789" --ipa ./app.ipa --upload # With version info asc builds upload --app "123456789" --ipa ./app.ipa --version 1.0.0 --build-number 123 --upload ``` ### Testing - Run `make test && make lint` - Test upload operation execution with mocked URLs - Test checksum computation - Test error handling for partial uploads
Author
Owner

@cursor[bot] commented on GitHub (Jan 24, 2026):

Summary:

  • Added full build upload execution with --upload, concurrency control, checksum verification, and commit via UpdateBuildUploadFile.
  • Introduced upload helper (ExecuteUploadOperations) with retries and checksum utilities, plus unit tests for slice uploads, failures, and checksum computation.
  • Extended build upload output to include uploaded/checksum status when available.

Tests:

  • make test
  • make lint

Notes:

  • --checksum requires --upload and will error if the API doesn’t provide source file checksums.

View PR
Open in Cursor Open in Web

<!-- gh-comment-id:3795330058 --> @cursor[bot] commented on GitHub (Jan 24, 2026): Summary: - Added full build upload execution with `--upload`, concurrency control, checksum verification, and commit via `UpdateBuildUploadFile`. - Introduced upload helper (`ExecuteUploadOperations`) with retries and checksum utilities, plus unit tests for slice uploads, failures, and checksum computation. - Extended build upload output to include uploaded/checksum status when available. Tests: - `make test` - `make lint` Notes: - `--checksum` requires `--upload` and will error if the API doesn’t provide source file checksums. <a href="https://github.com/rudrankriyam/App-Store-Connect-CLI/pull/100"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/view-pr-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/view-pr-light.svg"><img alt="View PR" src="https://cursor.com/view-pr-light.svg"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-ed6cd6a7-9bd9-4d0e-bd20-8a3e6f00abec"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-ed6cd6a7-9bd9-4d0e-bd20-8a3e6f00abec"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a>
Author
Owner

@rudrankriyam commented on GitHub (Jan 25, 2026):

Closed: implemented in #100.

<!-- gh-comment-id:3796237720 --> @rudrankriyam commented on GitHub (Jan 25, 2026): Closed: implemented in #100.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/App-Store-Connect-CLI#24
No description provided.