Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ go install ./cmd/builder
./builder ios release --profile store --group <name> --notes <text> # Build with the next build number, upload, wait, TestFlight
./builder ios release --profile store --app-store --release after-approval # Same, then App Review
./builder ios build --profile store --submit # Short for: ios release (no groups)
./builder ios metadata pull [--screenshots] [--clean] # App Store listing → metadata/<locale>/*.txt (fastlane layout)
./builder ios metadata push --dry-run|--yes [--version X.Y] [--screenshots [--replace-screenshots]] # push what differs
./builder ios build --profile development --distribute # Build, then print an over-the-air install link + QR code
./builder ios distribute [--ipa x.ipa] [--once] [--json] # Same for an existing IPA; --cleanup removes leftovers
./builder asc apps|builds|groups|testers|users # App Store Connect listings (--json)
Expand Down Expand Up @@ -182,6 +184,21 @@ builder ios release ─────► Preflight: API key; --profile/defaultProf
▼
distribute.Upload (wait) → SubmitTestFlight | SubmitAppStore

builder ios metadata ────► LoadLocal + Validate (lengths, URLs, categories) and LoadScreenshots
(display type from pixel size or a <DISPLAY_TYPE>/ subfolder) before any request
│
▼
resolveTarget: editable appStoreVersion (or --version, created on push) +
editable appInfo (include=primaryCategory,secondaryCategory)
│
▼
pull: appInfoLocalizations / appStoreVersionLocalizations → metadata/<locale>/*.txt;
--screenshots: appScreenshotSets → appScreenshots → imageAsset templateUrl download
push: diff → "Will ..." plan → --yes: [POST appStoreVersions → replan] →
POST/PATCH appInfoLocalizations → POST/PATCH appStoreVersionLocalizations →
PATCH appInfos relationships → POST appScreenshotSets → DELETE (replace) →
POST appScreenshots → PUT chunks → PATCH uploaded + MD5 → poll assetDeliveryState

builder ios distribute ──► otainstall.Inspect: Info.plist + embedded.mobileprovision
(unsigned / App Store profile → error naming the alternative)
│
Expand Down Expand Up @@ -213,6 +230,7 @@ internal/
asc/ # App Store Connect API client (JWT, JSON:API, apps, builds, uploads, TestFlight,
# beta groups, beta testers, team users/invitations, review)
distribute/ # Upload / TestFlight / App Store / tester flows on top of asc
metadata/ # ios metadata pull/push: fastlane-layout files ↔ version/app-info localizations, categories, screenshots
ipa/ # Info.plist and embedded.mobileprovision reading from .ipa archives
otainstall/ # ios distribute: over-the-air install links (manifest, QR, GitHub draft release + gist backend)
build/ # Build coordination (snapshot + trigger + poll + download)
Expand Down Expand Up @@ -402,7 +420,7 @@ internal/
with the longest covering entry or fails naming the ids to add; `write_export_options` exports them
- **Extension Points**: `ios release` composes `distribute.Upload` and
`distribute.SubmitTestFlight`. `pkg/asc`, `pkg/distribute`, `pkg/release`,
`pkg/signing` and `pkg/ipa` alias the internal packages so another program
`pkg/signing`, `pkg/ipa` and `pkg/metadata` alias the internal packages so another program
(mobai-dev) can drive the same flows; `release.Builder` is the one-method
interface a foreign build backend implements.
- **OTA Install, Not OTA Updates** (`internal/otainstall`): `ios distribute` serves a whole signed IPA
Expand All @@ -428,6 +446,19 @@ internal/
- **QR Rendering**: `skip2/go-qrcode` at error-correction Low, Unicode half blocks (two module rows per
line, 2-module quiet zone), light modules as `█` so it scans on a dark terminal (`--qr-invert` for light);
`TestQRFitsATerminal` pins a representative link at 41 modules (version 6). Printed only on a TTY or `--qr`.
- **App Store Metadata** (`internal/metadata`): fastlane deliver layout (`metadata/<locale>/<field>.txt`,
`{primary,secondary}_category.txt`, `screenshots/<locale>/[<DISPLAY_TYPE>/]`); `Fields` is the one table of
file → attribute → resource → limit. `NewPlan` validates everything locally before the first request, then
diffs; `Apply` writes app info locales, version locales, categories, screenshots in that order. Text is
compared after CRLF → LF and TrimSpace on both sides. A field without a file is untouched; an empty file clears.
- **Metadata Version Targeting**: no `--version` = the `Editable()` version (pull falls back to the newest);
`--version` that does not exist is created by push, which then re-resolves and recomputes the plan, since
App Store Connect copies the previous version's localizations (and opens a new editable appInfo) on create.
App-info changes on a non-editable appInfo fail at plan time.
- **Screenshot Diff**: by `sourceFileChecksum` (MD5 hex) per locale + display type. Default appends the files a
set lacks (refusing past 10); `--replace-screenshots` deletes and re-uploads a set whose ordered checksums
differ. Pull names files `NN_<name>` (an existing `NN_` prefix replaced) under the display-type subfolder, so
a pulled tree pushes back as an empty plan. Pixel sizes two types share go to the newer type.
- **Signing Sets As A Library**: `signing.Setup` and `signing.EnsureSecrets`
(internal/signing/sets.go) hold the non-interactive core of `signing setup`
and on-demand provisioning; cmd/builder keeps the prompts, the plan and the
Expand Down
87 changes: 86 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ builder ios build --profile store --submit # Short for: ios release (TestFlig
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
builder ios metadata pull # App Store listing into ./metadata (fastlane deliver layout)
builder ios metadata push --yes # Push what differs (--dry-run for the plan; --screenshots for images)

# Install on a device over the air (development, ad-hoc or enterprise build)
builder ios build --profile development --distribute # Build, then print an install link + QR code
Expand All @@ -273,7 +275,7 @@ builder asc users # Team members and whether they can test internall
builder asc users invite dev@example.com --role DEVELOPER --first Dee --last Vee
```

Every `release`/`upload`/`submit`/`distribute`/`asc` command takes `--json` for
Every `release`/`upload`/`submit`/`metadata`/`distribute`/`asc` command takes `--json` for
machine-readable output and never prompts, so agents and CI jobs can drive them.

## Configuration
Expand Down Expand Up @@ -832,6 +834,89 @@ the tester from TestFlight team-wide), print what goes and then need `--yes`.
Group names match case-insensitively; when two differ only by case, the
command refuses and lists both.

## App Store metadata

`builder ios metadata pull` and `push` keep the App Store listing in files,
through the App Store Connect API, from any platform. The layout is fastlane
deliver's, so an existing `fastlane/metadata` and `fastlane/screenshots` work
with `--metadata-dir` and `--screenshots-dir`:

```
metadata/
primary_category.txt PRODUCTIVITY (App Store Connect IDs; MZGenre.* accepted)
secondary_category.txt
en-US/
name.txt 30 characters (app info)
subtitle.txt 30 (app info)
privacy_url.txt (app info)
description.txt 4000
keywords.txt 100
release_notes.txt 4000 (What's New)
promotional_text.txt 170
marketing_url.txt
support_url.txt
screenshots/
en-US/
01_home.png display type from the pixel size
APP_IPAD_PRO_129/home.png or explicit: a subfolder named after it
```

```bash
builder ios metadata pull # text and categories of the version being prepared
builder ios metadata pull --screenshots # also download screenshots/<locale>/<DISPLAY_TYPE>/
builder ios metadata push --dry-run # print what would change
builder ios metadata push --yes # change it
builder ios metadata push --version 1.3 --yes # target (or create) version 1.3
builder ios metadata push --screenshots --replace-screenshots --yes
```

- **Version.** Both commands work on the App Store version being prepared
(`PREPARE_FOR_SUBMISSION`, or rejected); pull falls back to the newest
version when none is. `--version X.Y` picks one, and push creates it when it
does not exist, then compares against the localizations App Store Connect
copied into it. Name, subtitle, privacy URL and categories live on the app
info, which is only editable while a version is being prepared.
- **Push changes only what differs.** It prints one `Will ...` line per
difference and needs `--yes`; `--dry-run` prints the plan and exits 0. A
field without a file is left alone, an empty file clears it, and a new
locale directory adds that language (a new language needs `name.txt`).
Lengths, URLs, categories, screenshot sizes and the 10-per-set limit are
checked before anything is sent. `review_information/` and `default/` are ignored.
- **Pull never deletes** local files App Store Connect has no value for unless
`--clean`; empty fields get no file. Screenshots download only with
`--screenshots`.
- **Screenshots** are compared by MD5 per locale and display type. Without
`--replace-screenshots` push appends the local images a set lacks; with it,
a set that differs is emptied and uploaded again in file-name order. Each
upload is reserved, sent in the chunks App Store Connect hands out,
committed with its checksum and followed until processed.

Display types inferred from the pixel size (portrait or landscape):

| Size | Display type |
|------|--------------|
| 1320x2868, 1290x2796, 1260x2736 | `APP_IPHONE_67` (6.9"/6.7") |
| 1284x2778, 1242x2688 | `APP_IPHONE_65` |
| 1206x2622, 1179x2556, 1170x2532 | `APP_IPHONE_61` (6.3"/6.1") |
| 1125x2436, 1080x2340 | `APP_IPHONE_58` |
| 1242x2208 | `APP_IPHONE_55` |
| 750x1334 | `APP_IPHONE_47` |
| 640x1136 | `APP_IPHONE_40` |
| 640x960 | `APP_IPHONE_35` |
| 2064x2752, 2048x2732 | `APP_IPAD_PRO_3GEN_129` (13"/12.9") |
| 1668x2420, 1668x2388, 1640x2360, 1488x2266 | `APP_IPAD_PRO_3GEN_11` |
| 1668x2224 | `APP_IPAD_105` |
| 1536x2048 | `APP_IPAD_97` |
| 1280x800, 1440x900, 2560x1600, 2880x1800 | `APP_DESKTOP` |

2048x2732 is also the size of the older 12.9" iPad Pro (`APP_IPAD_PRO_129`):
put those in a `APP_IPAD_PRO_129/` subfolder. Any other size, Apple TV, Watch,
Vision Pro and iMessage screenshots need the subfolder too; other subfolders
(fastlane's `iMessage/`) are skipped with a warning.

Not covered yet: copyright, review information, age rating, subcategories,
app previews (video) and screenshot reordering within a set.

## Install on a device (internal distribution)

```bash
Expand Down
166 changes: 166 additions & 0 deletions cmd/builder/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package main

import (
"fmt"

"github.com/MobAI-App/ios-builder/internal/metadata"
"github.com/spf13/cobra"
)

var iosMetadataCmd = &cobra.Command{
Use: "metadata",
Short: "Pull and push the App Store listing (text, categories, screenshots)",
Long: `Syncs the App Store listing with files laid out like fastlane deliver's, so
an existing fastlane setup works unchanged:

metadata/<locale>/name.txt, subtitle.txt, privacy_url.txt (app info)
metadata/<locale>/description.txt, keywords.txt, release_notes.txt,
promotional_text.txt, marketing_url.txt, support_url.txt
metadata/primary_category.txt, secondary_category.txt (PRODUCTIVITY, ...)
screenshots/<locale>/*.png|jpg display type from the pixel size
screenshots/<locale>/<DISPLAY_TYPE>/*.png explicit, e.g. APP_IPHONE_67

The version worked on is the one being prepared for submission, or --version.
The app is identified by --bundle-id, --ipa, ios.bundleId in builder.json, or
the newest IPA in ./dist. Needs an App Store Connect API key: builder auth apple.`,
}

var iosMetadataPullCmd = &cobra.Command{
Use: "pull",
Short: "Write the App Store listing into ./metadata (and ./screenshots)",
Long: `Writes every non-empty field of the version being prepared (else the newest
version) into the metadata directory. Local files App Store Connect has no
value for are kept unless --clean. Screenshots download only with --screenshots,
into screenshots/<locale>/<DISPLAY_TYPE>/NN_<name>.`,
Args: cobra.NoArgs,
RunE: runIOSMetadataPull,
}

var iosMetadataPushCmd = &cobra.Command{
Use: "push",
Short: "Update the App Store listing from ./metadata (and ./screenshots)",
Long: `Compares the files with App Store Connect, prints a "Will ..." line per
difference and, with --yes, writes only those. Fields without a file are left
alone; an empty file clears the field. Lengths (name and subtitle 30,
keywords 100, promotional text 170, description and release notes 4000), URLs,
categories and screenshot sizes are checked before anything is sent.

--version X.Y targets that version, creating it when it does not exist.
Screenshots are compared by checksum and only pushed with --screenshots:
missing ones are appended to their set, and --replace-screenshots deletes a
set's screenshots and uploads the local ones whenever the two differ.`,
Args: cobra.NoArgs,
RunE: runIOSMetadataPush,
}

func init() {
for _, cmd := range []*cobra.Command{iosMetadataPullCmd, iosMetadataPushCmd} {
cmd.Flags().String("bundle-id", "", "App bundle ID (default: ios.bundleId in builder.json, else the newest IPA in ./dist)")
cmd.Flags().String("ipa", "", "Read the bundle ID from this IPA")
cmd.Flags().String("version", "", "App Store version (default: the one being prepared for submission)")
cmd.Flags().String("metadata-dir", "metadata", "Directory of <locale>/<field>.txt files")
cmd.Flags().String("screenshots-dir", "screenshots", "Directory of <locale>/ screenshot folders")
cmd.Flags().Bool("screenshots", false, "Include screenshots")
cmd.Flags().Bool("json", false, "Print the result as JSON (progress goes to stderr)")
}
iosMetadataPullCmd.Flags().Bool("clean", false, "Delete local files App Store Connect has no value for")
iosMetadataPushCmd.Flags().Bool("yes", false, "Apply the changes (without it the plan is printed and the command fails)")
iosMetadataPushCmd.Flags().Bool("dry-run", false, "Print the plan and exit successfully without changing anything")
iosMetadataPushCmd.Flags().Bool("replace-screenshots", false, "Replace a set's screenshots when they differ from the local ones")
iosMetadataCmd.AddCommand(iosMetadataPullCmd, iosMetadataPushCmd)
iosCmd.AddCommand(iosMetadataCmd)
}

func metadataOptions(cmd *cobra.Command, log output) (*metadata.Options, error) {
bundleID, _, err := resolveApp(cmd)
if err != nil {
return nil, err
}
opts := &metadata.Options{BundleID: bundleID, Log: log.log}
opts.Version, _ = cmd.Flags().GetString("version")
opts.MetadataDir, _ = cmd.Flags().GetString("metadata-dir")
opts.ScreenshotsDir, _ = cmd.Flags().GetString("screenshots-dir")
opts.Screenshots, _ = cmd.Flags().GetBool("screenshots")
if cmd.Flags().Lookup("clean") != nil {
opts.Clean, _ = cmd.Flags().GetBool("clean")
}
if cmd.Flags().Lookup("replace-screenshots") != nil {
opts.ReplaceScreenshots, _ = cmd.Flags().GetBool("replace-screenshots")
}
return opts, nil
}

func runIOSMetadataPull(cmd *cobra.Command, _ []string) error {
client, err := getASCClient()
if err != nil {
return err
}
out := newOutput(cmd)
opts, err := metadataOptions(cmd, out)
if err != nil {
return err
}
ctx, cancel := commandContext(cmd, false)
defer cancel()
res, err := metadata.Pull(ctx, client, opts)
if res != nil {
for _, w := range res.Warnings {
logf(out.log, "Warning: %s", w)
}
}
return finish(out, cmd, res, err, func() {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "Pulled %s version %s: %d locales, %d files written, %d unchanged", res.App.Name, res.Version.VersionString, len(res.Locales), len(res.Written), res.Unchanged)
if len(res.Screenshots) > 0 {
fmt.Fprintf(w, ", %d screenshots downloaded", len(res.Screenshots))
}
if len(res.Removed) > 0 {
fmt.Fprintf(w, ", %d removed", len(res.Removed))
}
fmt.Fprintln(w)
})
}

func runIOSMetadataPush(cmd *cobra.Command, _ []string) error {
yes, _ := cmd.Flags().GetBool("yes")
dryRun, _ := cmd.Flags().GetBool("dry-run")
if yes && dryRun {
return fmt.Errorf("--yes and --dry-run exclude each other")
}
client, err := getASCClient()
if err != nil {
return err
}
out := newOutput(cmd)
opts, err := metadataOptions(cmd, out)
if err != nil {
return err
}
ctx, cancel := commandContext(cmd, false)
defer cancel()
plan, err := metadata.NewPlan(ctx, client, opts)
if err != nil {
return err
}
for _, w := range plan.Warnings {
logf(out.log, "Warning: %s", w)
}
if plan.Empty() {
logf(out.log, "%s version %s already matches %s; nothing to push", plan.App.Name, plan.Version.VersionString, opts.MetadataDir)
return finish(out, cmd, plan, nil, nil)
}
logf(out.log, "%s version %s:", plan.App.Name, plan.Version.VersionString)
for i := range plan.Changes {
logf(out.log, " %s", plan.Changes[i].String())
}
if dryRun {
return finish(out, cmd, plan, nil, nil)
}
if !yes {
return finish(out, cmd, plan, fmt.Errorf("pass --yes to apply these %d changes (or --dry-run to only print them)", len(plan.Changes)), nil)
}
err = plan.Apply(ctx)
return finish(out, cmd, plan, err, func() {
fmt.Fprintf(cmd.OutOrStdout(), "Updated %s version %s: %d changes\n", plan.App.Name, plan.Version.VersionString, len(plan.Changes))
})
}
Loading
Loading