# Migration: project-owned MARKETING_VERSION

## Symptom

CI runs of the `ios-native-testflight` composite action used to bump the
marketing version on every TestFlight upload, even when the consumer
project's `MARKETING_VERSION` build setting (or `Info.plist`'s
`CFBundleShortVersionString`) had not changed. Across consecutive runs
the marketing version would drift upward in App Store Connect:

```
project.yml MARKETING_VERSION: "1.0.0"
CI run #16  -> TestFlight 1.0.7 (16)
CI run #17  -> TestFlight 1.0.8 (17)
CI run #18  -> TestFlight 1.0.9 (18)
CI run #19  -> TestFlight 1.1.0 (19)
CI run #20  -> TestFlight 1.1.1 (20)
```

The bumped value never round-tripped to the consumer repo (zero
"bump version" commits in the project's git log) — each run started
from `MARKETING_VERSION: "1.0.0"` again. This was confusing for users
("why does my TestFlight build claim to be 1.1.1 when my project says
1.0.0?") and produced unintended user-visible version numbers.

## Cause

The previous version of `manage_marketing_version.py` computed
`next = combined_floor + patch` on every CI run, treating App Store
Connect prerelease history as the source of truth. Because the project
itself never told CI which marketing version the user wanted to ship,
the script had to guess — and it guessed by walking forward from the
floor it discovered server-side. With every successful upload, the
floor moved up; the guess walked further forward; the project-side
value stayed at 1.0.0 indefinitely.

## Fix

The action now treats the project's `MARKETING_VERSION` build setting
(or `Info.plist`'s `CFBundleShortVersionString`) as the **single source
of truth**. CI never bumps it. CI only bumps the build number (still
handled automatically via `next_build_number.py`).

When CI starts a release run it now:

1. Resolves `MARKETING_VERSION` from the project via
   `xcodebuild -showBuildSettings`, falling back to PlistBuddy on
   `CFBundleShortVersionString` when the build setting is absent.
2. Refuses the build if the resolved value is missing or non-semver
   (must match `^\d+\.\d+(\.\d+)?$`). The error message names the
   build-setting sources you can edit.
3. Compares the resolved value to the App Store Connect floor (max
   of `appStoreVersions`, `preReleaseVersions`, and
   `builds->preReleaseVersion`):
   - **REUSE path**: target ≥ floor is allowed. Equality with the
     floor is the steady-state TestFlight case where the editable
     row at the target IS the floor source — uploading another
     build at the same marketing version with a higher build
     number is the whole point.
   - **CREATE path**: target > floor is required (strict). A new
     row at-or-below the floor would collide with already-shipped
     or in-flight versions.
4. Finds-or-creates the App Store Connect row at exactly that
   versionString:
   - If an editable row already exists at the target → REUSE it.
   - If a non-editable row exists at the target (shipped, in
     review, etc.) → refuse with a clear error.
   - If no row exists at the target → POST a new one, OR PATCH-
     rename a leftover lower-version editable to the target (see
     "Stale-editable rename" below).

## Stale-editable rename

When the developer bumps `MARKETING_VERSION` (e.g., 1.0.0 → 1.1.0)
and there's a leftover editable App Store version at the OLD value
(1.0.0 `PREPARE_FOR_SUBMISSION`, never finalized), the action will
PATCH-rename that editable to the new target. This is the correct
behaviour: the developer has explicitly chosen a new marketing
version, so promoting the leftover draft to match their intent
avoids ASC's "single editable per app" 409 collision.

The rename is **only** applied when the existing editable's
versionString is **strictly less than** the target. If the existing
editable is at a versionString **>= the target** (e.g., editable at
1.2.0, target=1.1.0), the action refuses with `SystemExit(2)`.
PATCH-renaming a higher-version editable would silently downgrade
an in-progress draft, which is never what the developer wants.

## How to migrate

Pick the section that matches your project layout.

### xcodegen (project.yml)

If your repo uses `project.yml` and (re)generates `.xcodeproj` in CI:

```yaml
# project.yml
name: MyApp
options:
  bundleIdPrefix: com.example
settings:
  base:
    MARKETING_VERSION: "1.1.2"   # <-- bump this when you want a release
    CURRENT_PROJECT_VERSION: "1" # CI overrides this anyway
```

When you decide to ship a new release, edit `MARKETING_VERSION`,
commit, push, and let CI take over. CI never edits this file.

### Traditional .xcodeproj (committed Xcode project)

Two options. Pick whichever matches how your project is structured:

**Option A — `MARKETING_VERSION` build setting in xcconfig or pbxproj:**

In Xcode: `Project > Build Settings`, search for `Marketing Version`,
edit the value. Or edit your `.xcconfig` directly:

```text
// Config/Release.xcconfig
MARKETING_VERSION = 1.1.2
```

**Option B — `CFBundleShortVersionString` literal in `Info.plist`:**

If your project uses literal values in `Info.plist` instead of
`$(MARKETING_VERSION)` substitution, edit the plist:

```xml
<key>CFBundleShortVersionString</key>
<string>1.1.2</string>
```

The action looks at `MARKETING_VERSION` first. If that build setting
is empty or unset, it falls back to reading
`CFBundleShortVersionString` from the resolved `Info.plist`.

## Recovery for projects already drifted by old CI

If your `MARKETING_VERSION` in the repo is *behind* the App Store
Connect floor (because earlier CI runs server-side-bumped past it),
the new action will exit 2 with a message like:

```
::error::MARKETING_VERSION 1.0.0 in your project's build settings
(typically project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION
xcconfig, or Info.plist's CFBundleShortVersionString) must be strictly
greater than the App Store Connect floor 1.1.1. Bump it (e.g. to
1.1.2), commit, and rerun. ...
```

Recovery (verbatim, from `gowalk-public/vpn-swift-tempalte`):

1. Edit `project.yml`: change `MARKETING_VERSION: "1.0.0"` to
   `MARKETING_VERSION: "1.1.2"` (or whatever the suggested bump is).
2. (Optional) Run `xcodegen generate` if the repo commits
   `.xcodeproj`; otherwise CI's xcodegen step handles it.
3. `git add project.yml && git commit -m "Bump MARKETING_VERSION to 1.1.2"`
4. `git push origin main`
5. The next CI run will produce TestFlight `1.1.2 (22)` (or whatever
   your auto-incremented build number happens to be).

For traditional `.xcodeproj` projects, replace step 1 with editing the
appropriate `.xcconfig` or `Info.plist` (see "How to migrate" above)
and step 2 with no-op (Xcode reads the build setting directly).

## Failure modes

CI surfaces `SystemExit(2)` errors for the following anomalies. Each
error message points to this document and names the build-setting
sources you can edit.

### 1. Floor too high

```
::error::MARKETING_VERSION 1.0.0 ... must be strictly greater than the
App Store Connect floor 1.1.1. ...
Sources contributing to floor: appStoreVersions=..., preReleaseVersions=...,
buildsViaPreRelease=...
```

**Cause**: an earlier upload (often by a different developer or a
previous CI run from before this fix) pushed a version higher than
your project's value into App Store Connect.

**Resolution**: bump `MARKETING_VERSION` in your project to a value
strictly greater than the floor named in the per-source breakdown,
commit, and push.

### 2. Higher editable exists

```
::error::Cannot CREATE MARKETING_VERSION 1.1.0: an editable App Store
version exists at 1.2.0 (id=..., state=PREPARE_FOR_SUBMISSION).
Renaming a higher or equal-version editable would downgrade an
in-progress draft. ...
```

**Cause**: a future-train draft already exists in App Store Connect
at a higher version than your project's `MARKETING_VERSION`. CI
refuses to PATCH-rename it down to your target.

**Resolution**: either bump `MARKETING_VERSION` in your project to
≥ the editable's version (matching the existing draft), OR manually
delete or rename the existing draft in App Store Connect.

### 3. Duplicate versionString already shipped

```
::error::MARKETING_VERSION 1.0.0 already exists in App Store Connect
in state READY_FOR_SALE (id=...). REUSE/CREATE both require a fresh
marketing version. ...
```

**Cause**: the project's `MARKETING_VERSION` matches a row that is
already shipped (or in review, awaiting release, etc.) — not an
editable draft.

**Resolution**: bump `MARKETING_VERSION` past the shipped version.
The version slot is taken; you must ship a fresh marketing version.

## Quick checklist

- [ ] `project.yml` (or `.xcconfig` / `Info.plist`) carries the
      desired marketing version.
- [ ] If CI exits with "floor too high", bump above the floor.
- [ ] If CI exits with "higher editable exists", either bump to
      match the existing draft, or delete the draft in ASC.
- [ ] If CI exits with "already exists in state X", bump past the
      shipped version.
- [ ] Commit and push. CI takes it from there.
