gowalk-cicd 1.0.52 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -178,6 +178,23 @@ node /path/to/gowalk-cicd/bin/cli.mjs
178
178
  pinned exactly because the Android job holds the keystore and the Play
179
179
  service account; the CLI runs from a temp cwd so its `.crashlytics/` debris
180
180
  never lands in the checkout.
181
+ - The iOS action reads every script and prompt from the snapshot it takes of
182
+ itself in its first step (`$SWIFT_APP_ACTION`, under `RUNNER_TEMP`), never
183
+ from `${{ github.action_path }}` after that step. The plugin self-update
184
+ (`autoupdate_check.sh`) runs right after the snapshot and commits on its own,
185
+ before the version/signing/archive steps, so a red deploy still receives the
186
+ release that fixes it; the snapshot is what keeps that run on one version.
187
+ `test_action_step_order.py` pins both. A new script reference in
188
+ `action/action.yml` therefore uses `"$SWIFT_APP_ACTION/scripts/..."`.
189
+ - Two situations end a green job with work for the panel, and each is a typed
190
+ check-run annotation (`::notice title=<name>::<one-line JSON with a versioned
191
+ schema>`), never prose in a `::warning::`: `play_review_pending`
192
+ (`templates/deploy.yml`, Play refused auto-submit and the bundle landed
193
+ unsubmitted) and `store_version_locked` (`action/action.yml`, the
194
+ `TESTFLIGHT_ONLY` decision of `manage_marketing_version.py`: an App Store
195
+ version is under review, so the build shipped to TestFlight under that
196
+ version and metadata was skipped). The README table lists the schemas; add a
197
+ field by bumping the version.
181
198
  - The JDK is chosen by `scripts/select_jdk.py`, called from the workflow before
182
199
  `setup-java`. The `JAVA_VERSION` repo variable always wins; otherwise Flutter
183
200
  gets 17 and native Gradle gets 21, except that a positively-detected Kotlin
package/README.md CHANGED
@@ -217,13 +217,72 @@ sent for review from the Google Play Console UI.
217
217
 
218
218
  That fails the whole upload, so the bundle you just built never reaches Play at
219
219
  all. The retry re-uploads with `changesNotSentForReview`, which lands the bundle
220
- and leaves it waiting, then emits a `::warning::` naming the package. Someone
221
- then presses **Send changes for review** on that app's Publishing overview page.
222
- There is no API for that press — it is the one step Google deliberately reserves
223
- for a human, and no amount of tooling removes it.
220
+ and leaves it waiting, then records that fact as a typed check-run annotation on
221
+ the job (see [Check-run annotations](#check-run-annotations-the-panel-reads)):
222
+
223
+ ```
224
+ ::notice title=play_review_pending::{"schema":"gowalk-cicd/play-review-pending.v1",
225
+ "package":"<applicationId>","track":"<track>","version_code":"<versionCode>"}
226
+ ```
227
+
228
+ There is no API for the **Send changes for review** press — Google reserves it
229
+ for the Play Console — so the panel that merged the commit reads the annotation
230
+ and opens its own Play console step, which promotes the uploaded version code to
231
+ the track Google named and presses it. Nobody is asked to notice a warning.
224
232
 
225
233
  In the ordinary case the first upload submits normally and the retry is skipped.
226
234
 
235
+ ### When an App Store version is in review
236
+
237
+ While any App Store version of the app is `WAITING_FOR_REVIEW`, `IN_REVIEW` or
238
+ `PENDING_DEVELOPER_RELEASE`, App Store Connect refuses to create or edit **any**
239
+ version — every `POST /appStoreVersions` answers
240
+ `409 ENTITY_ERROR.RELATIONSHIP.INVALID` ("You cannot create a new version of the
241
+ App in the current state"), whatever version string is asked for. The version
242
+ step used to see only rows at the project's own `MARKETING_VERSION`, so a sibling
243
+ in review (1.6.3 under review, project at 1.6.4) went unnoticed, CREATE was
244
+ attempted and the deploy failed on every push until the review ended.
245
+
246
+ The decision is now `TESTFLIGHT_ONLY`: the build is stamped with the **locked**
247
+ version string (so it joins that version's TestFlight train instead of opening a
248
+ new marketing-version train), the build number still advances, the archive is
249
+ uploaded to TestFlight, and the App Store metadata steps are skipped. The project
250
+ file is never touched — `MARKETING_VERSION` stays the source of truth for the next
251
+ release. The job records a typed annotation so the panel knows the build went to
252
+ TestFlight only:
253
+
254
+ ```
255
+ ::notice title=store_version_locked::{"schema":"gowalk-cicd/store-version-locked.v1",
256
+ "version":"1.6.3","state":"WAITING_FOR_REVIEW","build_number":"239"}
257
+ ```
258
+
259
+ ### Check-run annotations the panel reads
260
+
261
+ Two situations end a green job with work left for something other than this
262
+ workflow. Each is recorded as a GitHub check-run annotation whose **title** names
263
+ the situation and whose **message** is one line of JSON carrying a versioned
264
+ `schema`. The panel that merged the commit reads them through the check-runs API
265
+ — never by parsing log text — so the title and schema are a contract:
266
+
267
+ | title | schema | fields |
268
+ | --- | --- | --- |
269
+ | `play_review_pending` | `gowalk-cicd/play-review-pending.v1` | `package`, `track`, `version_code` |
270
+ | `store_version_locked` | `gowalk-cicd/store-version-locked.v1` | `version`, `state`, `build_number` |
271
+
272
+ Add a field by bumping the schema version; never change the meaning of an
273
+ existing one.
274
+
275
+ ### The plugin self-update runs first
276
+
277
+ The vendored iOS action checks npm for a newer `gowalk-cicd` at the **start** of
278
+ every default-branch run and commits the refreshed action directories in their
279
+ own commit, before the version, signing and archive steps. A deploy that is red
280
+ in one of those steps therefore still picks up the plugin release that fixes it,
281
+ on the very run that fails, and the next push runs the fixed plugin. The run that
282
+ performed the update keeps executing the version it started with: the action
283
+ snapshots itself to `$RUNNER_TEMP` before anything mutates and reads every script
284
+ and prompt from that copy.
285
+
227
286
  ### Google Play upload retry
228
287
 
229
288
  A Play edit is a short-lived server-side object, and an AAB upload that runs
@@ -604,8 +663,11 @@ Feature-branch runs installed from the standard workflow still archive and
604
663
  upload to TestFlight, but pass `manage-app-store-version: 'false'`. This keeps
605
664
  branch validation independent of the editable App Store release slot, so a
606
665
  build can upload while the current release is locked in review. Default-branch
607
- runs retain version creation and metadata automation. Direct action callers
608
- keep the historical behavior because the new input defaults to `true`.
666
+ runs retain version creation and metadata automation and while a version is
667
+ locked in review they take the same TestFlight-only path on their own (see
668
+ [When an App Store version is in review](#when-an-app-store-version-is-in-review)).
669
+ Direct action callers keep the historical behavior because the new input
670
+ defaults to `true`.
609
671
 
610
672
  ### App ID capabilities
611
673
 
@@ -1 +1 @@
1
- 1.0.52
1
+ 1.0.53
package/action/action.yml CHANGED
@@ -187,6 +187,79 @@ inputs:
187
187
  runs:
188
188
  using: composite
189
189
  steps:
190
+ # Every later step reads this action's scripts and prompts from the
191
+ # snapshot, never from ${action_path}. The autoupdate below re-vendors
192
+ # .github/actions/swift-app/ — the same directory as ${action_path} for
193
+ # local-action consumers — while this run is still executing the
194
+ # action.yml the runner loaded at job start. Reading scripts from the
195
+ # refreshed tree under the old step definitions is a mid-run version
196
+ # mix; one copy taken before anything mutates keeps the whole run on
197
+ # one version of the plugin.
198
+ - name: Snapshot this action for the whole run
199
+ shell: bash
200
+ run: |
201
+ set -euo pipefail
202
+ rm -rf "$RUNNER_TEMP/swift-app-action"
203
+ mkdir -p "$RUNNER_TEMP/swift-app-action"
204
+ cp -R "${{ github.action_path }}/." "$RUNNER_TEMP/swift-app-action/"
205
+ echo "SWIFT_APP_ACTION=$RUNNER_TEMP/swift-app-action" >> "$GITHUB_ENV"
206
+
207
+ # The plugin self-update runs BEFORE the version, signing and archive
208
+ # steps and lands in its own commit. It used to run after them, so a
209
+ # deploy that was red in the version step (App Store Connect refusing a
210
+ # new version while one is in review, GoVolt run 33743284641) never
211
+ # reached the self-update, and the plugin release that fixed exactly
212
+ # that failure could not arrive in the repository that needed it.
213
+ # Committing here rather than with the cert refresh below is the same
214
+ # point: a red step later in this run must not take the update with it.
215
+ - name: Stage bot-side scripts to runner temp
216
+ if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
217
+ && github.event_name == 'push'
218
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
219
+ shell: bash
220
+ run: |
221
+ set -euo pipefail
222
+ mkdir -p "$RUNNER_TEMP/swift-app-bot-scripts"
223
+ cp "$SWIFT_APP_ACTION/scripts/autoupdate_check.sh" \
224
+ "$RUNNER_TEMP/swift-app-bot-scripts/"
225
+ cp "$SWIFT_APP_ACTION/scripts/commit_bot_changes.sh" \
226
+ "$RUNNER_TEMP/swift-app-bot-scripts/"
227
+ chmod +x "$RUNNER_TEMP/swift-app-bot-scripts/"*.sh
228
+ echo "BOT_SCRIPTS_DIR=$RUNNER_TEMP/swift-app-bot-scripts" >> "$GITHUB_ENV"
229
+
230
+ - name: Check for app-ci update
231
+ # Default branch + push event only. `auto-update` is on by default;
232
+ # consumers can pin via `false`. Runs from $BOT_SCRIPTS_DIR
233
+ # (RUNNER_TEMP) so its own `npx` overwriting ${action_path} cannot
234
+ # swap the script under us.
235
+ if: ${{ inputs.auto-update == 'true'
236
+ && github.event_name == 'push'
237
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
238
+ shell: bash
239
+ working-directory: ${{ github.workspace }}
240
+ run: bash "$BOT_SCRIPTS_DIR/autoupdate_check.sh"
241
+
242
+ - name: Commit + push refreshed app-ci
243
+ # Its own commit, immediately: the refreshed action files must reach
244
+ # the default branch even when the version or signing steps of this
245
+ # very run fail. deploy.yml is deliberately not staged (GITHUB_TOKEN
246
+ # cannot push workflow files). No-op when the plugin was current.
247
+ if: ${{ inputs.auto-update == 'true'
248
+ && github.event_name == 'push'
249
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
250
+ shell: bash
251
+ working-directory: ${{ github.workspace }}
252
+ env:
253
+ GITHUB_REF_NAME: ${{ github.ref_name }}
254
+ run: |
255
+ set -euo pipefail
256
+ # A shallow checkout (fetch-depth 1 for direct action callers) must
257
+ # be able to rebase on a moved default branch inside the push retry.
258
+ git fetch --unshallow 2>/dev/null \
259
+ || git fetch --depth=2147483647 2>/dev/null \
260
+ || true
261
+ bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
262
+
190
263
  - name: Resolve credentials + auto-detect Xcode project
191
264
  shell: bash
192
265
  env:
@@ -217,7 +290,7 @@ runs:
217
290
  echo "Installing xcodegen (project.yml present, binary missing)…"
218
291
  brew install xcodegen >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install xcodegen
219
292
  fi
220
- python3 "${{ github.action_path }}/scripts/read_config.py"
293
+ python3 "$SWIFT_APP_ACTION/scripts/read_config.py"
221
294
 
222
295
  - name: Select latest Xcode
223
296
  shell: bash
@@ -303,10 +376,12 @@ runs:
303
376
  CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
304
377
  run: |
305
378
  set -o pipefail
306
- python3 "${{ github.action_path }}/scripts/resolve_marketing_version.py" \
379
+ python3 "$SWIFT_APP_ACTION/scripts/resolve_marketing_version.py" \
307
380
  | tee -a "$GITHUB_ENV"
308
381
 
309
382
  - name: Resolve App Store version slot (REUSE or CREATE)
383
+ # A third answer, TESTFLIGHT_ONLY, is handled inside: see the
384
+ # STORE_VERSION_LOCKED branch of the script.
310
385
  if: ${{ inputs.archive == 'true' && inputs.manage-app-store-version == 'true' }}
311
386
  shell: bash
312
387
  env:
@@ -332,19 +407,40 @@ runs:
332
407
  CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
333
408
  run: |
334
409
  set -o pipefail
335
- BUILD_NUMBER=$(python3 "${{ github.action_path }}/scripts/next_build_number.py")
336
- DECISION_JSON=$(python3 "${{ github.action_path }}/scripts/manage_marketing_version.py")
410
+ BUILD_NUMBER=$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")
411
+ DECISION_JSON=$(python3 "$SWIFT_APP_ACTION/scripts/manage_marketing_version.py")
337
412
  APP_STORE_VERSION_ID=$(python3 -c \
338
413
  "import json,sys; d=json.loads(sys.argv[1]); print(d.get('appStoreVersionId',''))" \
339
414
  "$DECISION_JSON")
415
+ DECISION=$(python3 -c \
416
+ "import json,sys; d=json.loads(sys.argv[1]); print(d.get('decision',''))" \
417
+ "$DECISION_JSON")
340
418
  # The decision's versionString is authoritative: it reflects any
341
419
  # auto-bump that mmv applied to MARKETING_VERSION when the project
342
- # was below the ASC combined floor. Re-export so subsequent steps
343
- # (Stamp Info.plist, Archive) see the bumped value.
420
+ # was below the ASC combined floor or, for TESTFLIGHT_ONLY, the
421
+ # App Store version Apple is holding, which is the train this build
422
+ # joins. Re-export so subsequent steps (Stamp Info.plist, Archive)
423
+ # see that value.
344
424
  EFFECTIVE_MV=$(python3 -c \
345
425
  "import json,sys; d=json.loads(sys.argv[1]); print(d.get('versionString',''))" \
346
426
  "$DECISION_JSON")
347
- if [ -n "$EFFECTIVE_MV" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
427
+ if [ "$DECISION" = "TESTFLIGHT_ONLY" ]; then
428
+ # An App Store version is under review or awaiting the developer's
429
+ # release: App Store Connect refuses to create or edit ANY version
430
+ # (409 ENTITY_ERROR.RELATIONSHIP.INVALID). The build still ships to
431
+ # TestFlight, stamped with the locked version so it lands in that
432
+ # version's train; App Store metadata below is skipped
433
+ # (STORE_VERSION_LOCKED), and the typed check-run annotation tells
434
+ # the panel that merged this commit exactly what happened.
435
+ LOCKED_STATE=$(python3 -c \
436
+ "import json,sys; d=json.loads(sys.argv[1]); print(d.get('state',''))" \
437
+ "$DECISION_JSON")
438
+ echo "::notice::App Store version $EFFECTIVE_MV is $LOCKED_STATE; uploading build $BUILD_NUMBER to TestFlight under $EFFECTIVE_MV and skipping App Store metadata"
439
+ echo "STORE_VERSION_LOCKED=$EFFECTIVE_MV" >> "$GITHUB_ENV"
440
+ echo "::notice title=store_version_locked::{\"schema\":\"gowalk-cicd/store-version-locked.v1\",\"version\":\"$EFFECTIVE_MV\",\"state\":\"$LOCKED_STATE\",\"build_number\":\"$BUILD_NUMBER\"}"
441
+ echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"
442
+ MARKETING_VERSION="$EFFECTIVE_MV"
443
+ elif [ -n "$EFFECTIVE_MV" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
348
444
  echo "::notice::auto-bump rewrote MARKETING_VERSION $MARKETING_VERSION -> $EFFECTIVE_MV"
349
445
  echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"
350
446
  MARKETING_VERSION="$EFFECTIVE_MV"
@@ -360,7 +456,7 @@ runs:
360
456
  APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
361
457
  run: |
362
458
  set -o pipefail
363
- BUILD_NUMBER=$(python3 "${{ github.action_path }}/scripts/next_build_number.py")
459
+ BUILD_NUMBER=$(python3 "$SWIFT_APP_ACTION/scripts/next_build_number.py")
364
460
  echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
365
461
  echo "APP_STORE_VERSION_ID=" >> "$GITHUB_ENV"
366
462
  echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER without an App Store version slot"
@@ -383,7 +479,7 @@ runs:
383
479
  CERTIFICATE_CAP_POLICY: ${{ inputs.certificate-cap-policy }}
384
480
  run: |
385
481
  set +e
386
- python3 "${{ github.action_path }}/scripts/prepare_signing.py"
482
+ python3 "$SWIFT_APP_ACTION/scripts/prepare_signing.py"
387
483
  signing_exit=$?
388
484
  set -e
389
485
  echo "SIGNING_PREP_EXIT=$signing_exit" >> "$GITHUB_ENV"
@@ -408,36 +504,13 @@ runs:
408
504
  # of any compromised step in the workflow — keep workflow minimal.
409
505
  # - .gitignore must NOT block the new creds/ files (see setup doc).
410
506
  #
411
- # Split into four steps so cert refresh and autoupdate can both fire
412
- # in a single combined commit when they coincide, but either can fire
413
- # alone without redundant retries: (1) copy bot scripts to RUNNER_TEMP
414
- # so autoupdate's `npx` cannot mid-run swap script bytes under us,
415
- # (2) stage cert files, (3) check for newer app-ci on npm and
416
- # stage refreshed action files, (4) commit + push whatever is staged,
417
- # (5) re-raise a deferred prepare failure after preserving its cache.
507
+ # The autoupdate half runs FIRST in this action (see the top of the
508
+ # step list) and lands in its own commit, so a fixed plugin reaches a
509
+ # repository whose deploy is failing in the version or signing steps
510
+ # below. What remains here: (1) stage cert files, (2) commit + push
511
+ # whatever is staged, (3) re-raise a deferred prepare failure after
512
+ # preserving its cache.
418
513
 
419
- # Stage scripts to RUNNER_TEMP so the autoupdate step's `npx` (which
420
- # overwrites .github/actions/swift-app/ — the same dir as ${action_path}
421
- # for local-action consumers) cannot mid-run swap the script bytes
422
- # under us. action.yml step definitions are already loaded by the
423
- # runner at job start, but the shell scripts are read fresh per step,
424
- # so without this stage we'd risk loading commit_bot_changes.sh from
425
- # the post-`npx` (newer) tree while the surrounding step definitions
426
- # came from the pre-`npx` (older) action.yml — a mid-run version mix.
427
- - name: Stage bot-side scripts to runner temp
428
- if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
429
- && github.event_name == 'push'
430
- && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
431
- shell: bash
432
- run: |
433
- set -euo pipefail
434
- mkdir -p "$RUNNER_TEMP/swift-app-bot-scripts"
435
- cp "${{ github.action_path }}/scripts/autoupdate_check.sh" \
436
- "$RUNNER_TEMP/swift-app-bot-scripts/"
437
- cp "${{ github.action_path }}/scripts/commit_bot_changes.sh" \
438
- "$RUNNER_TEMP/swift-app-bot-scripts/"
439
- chmod +x "$RUNNER_TEMP/swift-app-bot-scripts/"*.sh
440
- echo "BOT_SCRIPTS_DIR=$RUNNER_TEMP/swift-app-bot-scripts" >> "$GITHUB_ENV"
441
514
 
442
515
  - name: Stage refreshed signing identity
443
516
  # Only stage on push to the default branch — PR runs and feature
@@ -495,25 +568,14 @@ runs:
495
568
  sed 's/^/ /' "$bump_marker"
496
569
  fi
497
570
 
498
- - name: Check for app-ci update
499
- # Same gate as cert staging: default branch + push event only.
500
- # `auto-update` is on by default; consumers can pin via `false`.
501
- # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP) so its own `npx`
502
- # overwriting ${action_path} mid-run can't swap the script under us.
503
- if: ${{ inputs.auto-update == 'true'
504
- && github.event_name == 'push'
505
- && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
506
- shell: bash
507
- working-directory: ${{ github.workspace }}
508
- run: bash "$BOT_SCRIPTS_DIR/autoupdate_check.sh"
509
-
510
571
  - name: Commit + push staged bot changes
511
- # Fires when EITHER cert staging OR autoupdate could have produced
512
- # diffs. The script itself is a no-op when nothing is staged, so a
572
+ # The cert refresh (and any marketing-version auto-bump the version
573
+ # step staged). The autoupdate landed in its own commit at the top of
574
+ # this action; this script is a no-op when nothing is staged, so a
513
575
  # PR / feature-branch run that somehow reaches this step does no
514
576
  # harm — but we still gate on default-branch push for clarity.
515
- # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP) see step above for why
516
- # we don't read directly from ${action_path} after autoupdate.
577
+ # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP): the autoupdate re-vendored
578
+ # ${action_path} earlier in this run and must not swap scripts under us.
517
579
  if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
518
580
  && github.event_name == 'push'
519
581
  && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
@@ -735,7 +797,7 @@ runs:
735
797
  PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
736
798
  WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
737
799
  run: |
738
- python3 "${{ github.action_path }}/scripts/crashlytics_dsyms.py" \
800
+ python3 "$SWIFT_APP_ACTION/scripts/crashlytics_dsyms.py" \
739
801
  --archive "$RUNNER_TEMP/app.xcarchive" \
740
802
  --search-root "$(dirname "${WORKSPACE:-$PROJECT}")"
741
803
 
@@ -766,18 +828,22 @@ runs:
766
828
  fi
767
829
  echo "Upload succeeded"
768
830
 
831
+ # Both App Store metadata writers are skipped while an App Store version
832
+ # is locked (STORE_VERSION_LOCKED, the TESTFLIGHT_ONLY decision): there is
833
+ # no editable version id to write to, and each script requires one.
769
834
  - name: Detect empty ASC metadata fields
770
835
  id: detect_metadata
771
836
  if: >-
772
837
  ${{ inputs.archive == 'true' && inputs.upload == 'true'
773
- && inputs.manage-app-store-version == 'true' && inputs.ai-metadata != 'false' }}
838
+ && inputs.manage-app-store-version == 'true' && inputs.ai-metadata != 'false'
839
+ && env.STORE_VERSION_LOCKED == '' }}
774
840
  shell: bash
775
841
  env:
776
842
  APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
777
843
  APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
778
844
  run: |
779
845
  set -o pipefail
780
- python3 "${{ github.action_path }}/scripts/asc_metadata_detector.py" \
846
+ python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_detector.py" \
781
847
  > "$RUNNER_TEMP/asc_empty_metadata.json" || {
782
848
  echo "::warning::metadata detector failed; skipping AI step"
783
849
  echo "has_empty=false" >> "$GITHUB_OUTPUT"
@@ -800,7 +866,7 @@ runs:
800
866
  BUNDLE_ID: ${{ inputs.bundle-id || env.CFG_BUNDLE_ID }}
801
867
  SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
802
868
  run: |
803
- python3 "${{ github.action_path }}/scripts/app_context_scanner.py" \
869
+ python3 "$SWIFT_APP_ACTION/scripts/app_context_scanner.py" \
804
870
  > "$RUNNER_TEMP/app_context.txt"
805
871
  wc -c "$RUNNER_TEMP/app_context.txt"
806
872
 
@@ -834,7 +900,7 @@ runs:
834
900
  if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
835
901
  shell: bash
836
902
  env:
837
- TEMPLATE_PATH: ${{ github.action_path }}/prompts/generate_descriptions.prompt.yml
903
+ TEMPLATE_PATH: ${{ env.SWIFT_APP_ACTION }}/prompts/generate_descriptions.prompt.yml
838
904
  run: |
839
905
  # Pre-render via PyYAML so multi-line app_context stays a block scalar
840
906
  # and doesn't break ai-inference@v1's naive string substitution.
@@ -905,7 +971,7 @@ runs:
905
971
  echo "phase1_ok=false" >> "$GITHUB_OUTPUT"
906
972
  exit 0
907
973
  fi
908
- python3 "${{ github.action_path }}/scripts/asc_metadata_applier.py" \
974
+ python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_applier.py" \
909
975
  --state "$RUNNER_TEMP/asc_empty_metadata.json" \
910
976
  --response "$OUT" \
911
977
  --fields-filter description
@@ -917,7 +983,7 @@ runs:
917
983
  || steps.phase1_apply.outputs.phase1_ok == 'true') }}
918
984
  shell: bash
919
985
  env:
920
- TEMPLATE_PATH: ${{ github.action_path }}/prompts/generate_dependent_fields.prompt.yml
986
+ TEMPLATE_PATH: ${{ env.SWIFT_APP_ACTION }}/prompts/generate_dependent_fields.prompt.yml
921
987
  run: |
922
988
  # Merge existing_fields.{locale}.description with Phase-1 outputs to
923
989
  # produce authoritative per-locale description context, then render
@@ -1010,7 +1076,7 @@ runs:
1010
1076
  head -c 500 "$OUT" || true
1011
1077
  echo
1012
1078
  fi
1013
- python3 "${{ github.action_path }}/scripts/asc_metadata_applier.py" \
1079
+ python3 "$SWIFT_APP_ACTION/scripts/asc_metadata_applier.py" \
1014
1080
  --state "$RUNNER_TEMP/asc_empty_metadata.json" \
1015
1081
  --response "$OUT" \
1016
1082
  --fields-filter name,subtitle,keywords,promotionalText,whatsNew
@@ -1026,6 +1092,7 @@ runs:
1026
1092
  - name: Set App Store "What's New"
1027
1093
  if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
1028
1094
  && inputs.manage-app-store-version == 'true'
1095
+ && env.STORE_VERSION_LOCKED == ''
1029
1096
  && (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
1030
1097
  shell: bash
1031
1098
  env:
@@ -1051,4 +1118,4 @@ runs:
1051
1118
  WHATS_NEW_ONLY_IF_EMPTY=false
1052
1119
  fi
1053
1120
  export APP_STORE_WHATS_NEW_FILE WHATS_NEW_ONLY_IF_EMPTY
1054
- python3 "${{ github.action_path }}/scripts/set_app_store_whats_new.py"
1121
+ python3 "$SWIFT_APP_ACTION/scripts/set_app_store_whats_new.py"
@@ -10,14 +10,24 @@ build number is bumped automatically (see next_build_number.py).
10
10
  Algorithm:
11
11
  1. Read MARKETING_VERSION from env (set by action.yml from
12
12
  xcodebuild -showBuildSettings or PlistBuddy).
13
- 2. Floor-check (non-strict): target >= combined floor across
13
+ 2. Store lock: if ANY App Store version is under Apple Review
14
+ (WAITING_FOR_REVIEW / IN_REVIEW) or approved and awaiting the
15
+ developer's release click (PENDING_DEVELOPER_RELEASE), no App Store
16
+ version can be created or edited at all -- App Store Connect answers
17
+ every POST with 409 ENTITY_ERROR.RELATIONSHIP.INVALID "You cannot
18
+ create a new version of the App in the current state". The decision
19
+ is TESTFLIGHT_ONLY: the build is stamped with the LOCKED version
20
+ string (so it joins that version's TestFlight train), the build
21
+ number still advances, and App Store metadata is skipped. Nothing
22
+ below runs and the project file is never touched.
23
+ 3. Floor-check (non-strict): target >= combined floor across
14
24
  appStoreVersions + preReleaseVersions + builds->preReleaseVersion.
15
- 3. If the target row already exists in /appStoreVersions:
16
- - any non-editable match (terminal/blocking) -> SystemExit(2);
17
- the slot is taken by a shipped or in-review row.
25
+ 4. If the target row already exists in /appStoreVersions:
26
+ - any terminal match -> auto-roll or SystemExit(2); the slot is
27
+ taken by a shipped row.
18
28
  - editable-only match -> REUSE its id (steady-state TestFlight
19
29
  iteration at unchanged MARKETING_VERSION).
20
- 4. CREATE path: floor-check (strict, target > floor); PATCH-rename any
30
+ 5. CREATE path: floor-check (strict, target > floor); PATCH-rename any
21
31
  stale editable to target; or POST a new row.
22
32
 
23
33
  This module is intentionally thin: floor logic lives in
@@ -27,7 +37,9 @@ through this one so test patches at ``mmv.<seam>`` keep winning.
27
37
 
28
38
  Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID,
29
39
  MARKETING_VERSION (project source of truth, set by action.yml).
30
- Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
40
+ Stdout: {"decision":"REUSE|CREATE|TESTFLIGHT_ONLY","versionString":"...",
41
+ "appStoreVersionId":"..."} -- TESTFLIGHT_ONLY carries an empty id,
42
+ the locked row's "state" and "storeVersionLocked" (== versionString).
31
43
  """
32
44
 
33
45
  from __future__ import annotations
@@ -67,6 +79,7 @@ from mmv_decide_create import (
67
79
  # working. The submodules look these up through this module at call
68
80
  # time, so a patch on ``mmv.fetch_versions`` propagates correctly.
69
81
  __all__ = (
82
+ "LOCKED_STATES",
70
83
  "SEM_RE",
71
84
  "create_or_reuse",
72
85
  "create_version",
@@ -78,8 +91,19 @@ __all__ = (
78
91
  "main",
79
92
  "make_jwt",
80
93
  "semver_tuple",
94
+ "store_version_locked",
81
95
  )
82
96
 
97
+ #: App Store version states in which App Store Connect refuses to create or
98
+ #: edit ANY version of the app -- the whole App Store slot is locked, not just
99
+ #: the row itself. WAITING_FOR_REVIEW / IN_REVIEW: Apple is reviewing.
100
+ #: PENDING_DEVELOPER_RELEASE: Apple approved and the developer has not
101
+ #: released yet. A build made while one of these exists can only go to
102
+ #: TestFlight (``TESTFLIGHT_ONLY``), and it goes under the locked version
103
+ #: string so it lands in that version's TestFlight train instead of opening a
104
+ #: new marketing-version train the floor logic would then have to roll past.
105
+ LOCKED_STATES = frozenset(IN_REVIEW_STATES) | frozenset(BLOCKING_STATES)
106
+
83
107
 
84
108
  _APP_ID_MISSING_HINT = (
85
109
  "::error::APP_STORE_APPLE_ID is empty. This usually means auto-detect "
@@ -102,7 +126,8 @@ def _log(msg: str) -> None:
102
126
  print(f"[decision] {msg}", file=sys.stderr)
103
127
 
104
128
 
105
- def _result(decision: str, version: str, vid: str, state: str = "") -> dict:
129
+ def _result(decision: str, version: str, vid: str, state: str = "",
130
+ **extra: str) -> dict:
106
131
  out = {
107
132
  "decision": decision,
108
133
  "versionString": version,
@@ -110,6 +135,7 @@ def _result(decision: str, version: str, vid: str, state: str = "") -> dict:
110
135
  }
111
136
  if state:
112
137
  out["state"] = state
138
+ out.update(extra)
113
139
  return out
114
140
 
115
141
 
@@ -284,20 +310,65 @@ def _auto_roll_past_locked(
284
310
  return rolled, new_status, new_match, new_also
285
311
 
286
312
 
313
+ def store_version_locked(versions: list[dict]) -> dict | None:
314
+ """The ``TESTFLIGHT_ONLY`` decision when any App Store version locks the
315
+ slot (:data:`LOCKED_STATES`), else None.
316
+
317
+ Decided BEFORE the floor check and before any row at the target is
318
+ classified, because the lock is a property of the APP, not of the row at
319
+ the target: CI run 33743284641 (GoVolt) had 1.6.3 WAITING_FOR_REVIEW and a
320
+ project at 1.6.4, the target classified as "none", CREATE was attempted,
321
+ and App Store Connect answered 409 RELATIONSHIP.INVALID -- exit 4, every
322
+ push to main, until a human noticed. A target that itself matches the
323
+ in-review row used to exit 2 asking a human to wait or bump; that was a
324
+ person in the loop for a state the API describes precisely.
325
+
326
+ Several locked rows (Apple allows one per platform, but fail-safe): the
327
+ highest semver wins, because that is the train a new build belongs to.
328
+ Nothing here reads or writes the project file: the locked string is
329
+ exported for THIS build only and the project's MARKETING_VERSION stays the
330
+ source of truth for the next release.
331
+ """
332
+ locked = [v for v in versions if v.get("state") in LOCKED_STATES
333
+ and v.get("versionString")]
334
+ if not locked:
335
+ return None
336
+ row = max(locked, key=lambda v: semver_tuple(v["versionString"]))
337
+ version, state, vid = row["versionString"], row.get("state", ""), row.get("id", "")
338
+ _log(f"decision=TESTFLIGHT_ONLY versionString={version} (App Store version "
339
+ f"{version} is {state} (id={vid}); App Store Connect refuses to create "
340
+ f"or edit any version while one is in that state, so this build is "
341
+ f"stamped {version}, uploaded to TestFlight only, and App Store "
342
+ f"metadata is skipped)")
343
+ print(
344
+ f"::notice::App Store version {version} is {state}: uploading this build "
345
+ f"to TestFlight under {version} and skipping App Store metadata until "
346
+ f"Apple's review concludes.",
347
+ file=sys.stderr,
348
+ )
349
+ return _result("TESTFLIGHT_ONLY", version, "", state,
350
+ storeVersionLocked=version)
351
+
352
+
287
353
  def decide_for_version(
288
354
  target_version: str, versions: list[dict],
289
355
  app_id: str, token: str,
290
356
  ) -> dict:
291
357
  """Find-or-create the ASC row at exactly ``target_version``.
292
358
 
293
- Validates semver, runs the non-strict floor check (auto-bump
294
- rewrites MARKETING_VERSION when below the ASC floor; the returned
295
- effective target is what downstream uses), then classifies any
359
+ Validates semver, answers ``TESTFLIGHT_ONLY`` while any App Store
360
+ version locks the slot (:func:`store_version_locked` -- no floor check,
361
+ no auto-bump, no CREATE), then runs the non-strict floor check
362
+ (auto-bump rewrites MARKETING_VERSION when below the ASC floor; the
363
+ returned effective target is what downstream uses), then classifies any
296
364
  row(s) at target. Terminal collisions auto-roll to the next patch
297
- (or reject when policy='none'); in-review and unknown-state
298
- collisions reject loudly (see ``_classify_match_at_target`` for
299
- the full taxonomy). No match -> CREATE; reusable editable -> REUSE."""
365
+ (or reject when policy='none'); unknown-state collisions reject loudly
366
+ (see ``_classify_match_at_target`` for the full taxonomy). No match ->
367
+ CREATE; reusable editable -> REUSE."""
300
368
  _validate_target(target_version)
369
+ locked = store_version_locked(versions)
370
+ if locked is not None:
371
+ return locked
301
372
  target_version = assert_target_meets_floor(
302
373
  target_version, app_id, token,
303
374
  appstore_versions=versions, strict=False,
@@ -0,0 +1,83 @@
1
+ import re
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+
6
+ ACTION_YAML = Path(__file__).resolve().parents[1] / "action.yml"
7
+ STEP_RE = re.compile(r"^ - name: (.+)$", re.M)
8
+
9
+
10
+ def step_names(source: str) -> list[str]:
11
+ return [m.group(1).strip() for m in STEP_RE.finditer(source)]
12
+
13
+
14
+ def step_block(source: str, name: str) -> str:
15
+ pattern = rf" - name: {re.escape(name)}\n(?P<body>.*?)(?=\n - name:|\Z)"
16
+ match = re.search(pattern, source, re.DOTALL)
17
+ if match is None:
18
+ raise AssertionError(f"missing action step: {name}")
19
+ return match.group("body")
20
+
21
+
22
+ class TestActionStepOrder(unittest.TestCase):
23
+ """The plugin self-update runs before the steps that can fail a deploy,
24
+ lands in its own commit, and the run keeps reading ONE version of the
25
+ plugin from the snapshot it took first."""
26
+
27
+ @classmethod
28
+ def setUpClass(cls) -> None:
29
+ cls.source = ACTION_YAML.read_text(encoding="utf-8")
30
+ cls.names = step_names(cls.source)
31
+
32
+ def test_the_snapshot_is_the_first_step(self) -> None:
33
+ self.assertEqual(self.names[0], "Snapshot this action for the whole run")
34
+ block = step_block(self.source, "Snapshot this action for the whole run")
35
+ self.assertIn('cp -R "${{ github.action_path }}/." "$RUNNER_TEMP/swift-app-action/"', block)
36
+ self.assertIn('echo "SWIFT_APP_ACTION=$RUNNER_TEMP/swift-app-action" >> "$GITHUB_ENV"', block)
37
+
38
+ def test_the_autoupdate_and_its_commit_precede_the_version_step(self) -> None:
39
+ order = {name: index for index, name in enumerate(self.names)}
40
+ version = order["Resolve marketing version (project source of truth)"]
41
+ self.assertLess(order["Stage bot-side scripts to runner temp"], version)
42
+ self.assertLess(order["Check for app-ci update"], version)
43
+ self.assertLess(order["Commit + push refreshed app-ci"], version)
44
+ self.assertLess(order["Stage bot-side scripts to runner temp"],
45
+ order["Check for app-ci update"])
46
+ self.assertLess(order["Check for app-ci update"],
47
+ order["Commit + push refreshed app-ci"])
48
+ # ... and before every other step that talks to a store or signs.
49
+ for later in ("Resolve credentials + auto-detect Xcode project",
50
+ "Stage ASC API key for xcrun / altool",
51
+ "Prepare signing (cert + keychain + per-target profiles + pbxproj)",
52
+ "Archive"):
53
+ self.assertLess(order["Commit + push refreshed app-ci"], order[later], later)
54
+
55
+ def test_the_autoupdate_commit_is_gated_like_the_check(self) -> None:
56
+ check = step_block(self.source, "Check for app-ci update")
57
+ commit = step_block(self.source, "Commit + push refreshed app-ci")
58
+ for block in (check, commit):
59
+ self.assertIn("inputs.auto-update == 'true'", block)
60
+ self.assertIn("github.event_name == 'push'", block)
61
+ self.assertIn("format('refs/heads/{0}', github.event.repository.default_branch)", block)
62
+ self.assertIn('bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"', commit)
63
+ self.assertIn("GITHUB_REF_NAME: ${{ github.ref_name }}", commit)
64
+ # The cert refresh keeps its own, later commit step.
65
+ self.assertIn("Commit + push staged bot changes", self.names)
66
+ self.assertLess(self.names.index("Stage refreshed signing identity"),
67
+ self.names.index("Commit + push staged bot changes"))
68
+
69
+ def test_every_script_and_prompt_is_read_from_the_snapshot(self) -> None:
70
+ lines = self.source.splitlines()
71
+ snapshot = step_block(self.source, "Snapshot this action for the whole run")
72
+ stray = [line for line in lines
73
+ if "github.action_path" in line and line.strip() not in snapshot]
74
+ self.assertEqual(stray, [], "scripts must be read from $SWIFT_APP_ACTION")
75
+ self.assertGreaterEqual(self.source.count('"$SWIFT_APP_ACTION/scripts/'), 10)
76
+ self.assertEqual(self.source.count("${{ env.SWIFT_APP_ACTION }}/prompts/"), 2)
77
+ bot = step_block(self.source, "Stage bot-side scripts to runner temp")
78
+ self.assertIn('cp "$SWIFT_APP_ACTION/scripts/autoupdate_check.sh"', bot)
79
+ self.assertIn('cp "$SWIFT_APP_ACTION/scripts/commit_bot_changes.sh"', bot)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -4,11 +4,12 @@ Tests for the auto-roll-vs-in-review policy in
4
4
  ``manage_marketing_version.decide_for_version``.
5
5
 
6
6
  Round-7 codex finding lock-in: auto-roll fires for TERMINAL collisions
7
- (READY_FOR_SALE etc.) but NOT for in-review collisions
8
- (WAITING_FOR_REVIEW / IN_REVIEW). In-review must always reject so the
9
- developer can resolve the ambiguity manually -- Apple is actively
10
- reviewing the row, and advancing past it would race the review
11
- process.
7
+ (READY_FOR_SALE etc.) but NEVER for in-review collisions
8
+ (WAITING_FOR_REVIEW / IN_REVIEW / PENDING_DEVELOPER_RELEASE). Apple is
9
+ reviewing (or holding) the row, and advancing past it would race the
10
+ review process -- so those rows lock the App Store slot and the decision
11
+ is TESTFLIGHT_ONLY, whatever the auto-bump policy: the build ships to
12
+ TestFlight under the locked version and no project file is written.
12
13
 
13
14
  Extracted from ``test_manage_marketing_version.py`` so each test file
14
15
  stays under the 10-functions-per-file cap (round 8).
@@ -97,33 +98,40 @@ class AutoRollTerminalVsInReviewTests(unittest.TestCase):
97
98
  self.assertEqual(result["decision"], "CREATE")
98
99
  self.assertEqual(result["versionString"], "1.0.6")
99
100
 
100
- def test_waiting_for_review_with_autobump_rejects_loudly(self):
101
- """Round-7: in-review must NEVER auto-roll regardless of policy."""
101
+ def test_waiting_for_review_with_autobump_is_testflight_only(self):
102
+ """Round-7: in-review must NEVER auto-roll regardless of policy.
103
+ The slot is locked, so the build goes to TestFlight under the
104
+ locked version and the project file is left alone."""
102
105
  result, exit_code, stderr = self._run("WAITING_FOR_REVIEW", "patch")
103
- self.assertIsNone(result)
104
- self.assertEqual(exit_code, 2)
105
- self.assertIn("currently in App Review", stderr)
106
+ self.assertIsNone(exit_code)
107
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
108
+ self.assertEqual(result["versionString"], "1.0.5")
109
+ self.assertEqual(result["appStoreVersionId"], "")
110
+ self.assertIn("decision=TESTFLIGHT_ONLY", stderr)
111
+ self.assertNotIn("auto-bumped", stderr)
106
112
 
107
- def test_in_review_with_autobump_rejects_loudly(self):
113
+ def test_in_review_with_autobump_is_testflight_only(self):
108
114
  result, exit_code, stderr = self._run("IN_REVIEW", "patch")
109
- self.assertIsNone(result)
110
- self.assertEqual(exit_code, 2)
111
- self.assertIn("currently in App Review", stderr)
115
+ self.assertIsNone(exit_code)
116
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
117
+ self.assertEqual(result["state"], "IN_REVIEW")
118
+ self.assertNotIn("auto-bumped", stderr)
112
119
 
113
- def test_pending_developer_release_with_autobump_rejects_loudly(self):
120
+ def test_pending_developer_release_with_autobump_is_testflight_only(self):
114
121
  """Round-11: PENDING_DEVELOPER_RELEASE means Apple approved
115
122
  the build and is waiting on the developer's manual release
116
123
  click. Auto-rolling past it would bypass the developer's
117
124
  release decision -- silently shipping the next patch while
118
- the approved build sits unreleased. Must reject loudly so a
119
- human resolves the ambiguity."""
125
+ the approved build sits unreleased. The slot is locked the same
126
+ way a review locks it: TestFlight only, under 1.0.5."""
120
127
  result, exit_code, stderr = self._run(
121
128
  "PENDING_DEVELOPER_RELEASE", "patch",
122
129
  )
123
- self.assertIsNone(result)
124
- self.assertEqual(exit_code, 2)
130
+ self.assertIsNone(exit_code)
131
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
132
+ self.assertEqual(result["versionString"], "1.0.5")
133
+ self.assertEqual(result["state"], "PENDING_DEVELOPER_RELEASE")
125
134
  self.assertIn("PENDING_DEVELOPER_RELEASE", stderr)
126
- self.assertIn("manual release", stderr)
127
135
 
128
136
  def test_terminal_with_policy_none_rejects(self):
129
137
  result, exit_code, stderr = self._run("READY_FOR_SALE", "none")
@@ -131,11 +139,12 @@ class AutoRollTerminalVsInReviewTests(unittest.TestCase):
131
139
  self.assertEqual(exit_code, 2)
132
140
  self.assertIn("READY_FOR_SALE", stderr)
133
141
 
134
- def test_in_review_with_policy_none_rejects(self):
142
+ def test_in_review_with_policy_none_is_testflight_only(self):
143
+ """The lock does not depend on the auto-bump policy at all."""
135
144
  result, exit_code, stderr = self._run("WAITING_FOR_REVIEW", "none")
136
- self.assertIsNone(result)
137
- self.assertEqual(exit_code, 2)
138
- self.assertIn("currently in App Review", stderr)
145
+ self.assertIsNone(exit_code)
146
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
147
+ self.assertIn("decision=TESTFLIGHT_ONLY", stderr)
139
148
 
140
149
  def test_editable_state_reuses_unchanged(self):
141
150
  """Sanity: PREPARE_FOR_SUBMISSION at target REUSEs unchanged."""
@@ -9,9 +9,12 @@ doesn't match) an existing ASC row:
9
9
  * editable PFS at target -> REUSE
10
10
  * steady-state TestFlight (target == combined_floor with editable
11
11
  match) -> REUSE
12
- * terminal / in-review collision at target -> SystemExit(2)
12
+ * terminal collision at target -> SystemExit(2)
13
13
  (the auto-roll lock-in test for terminal-but-autobump-enabled lives
14
14
  in test_manage_marketing_version_autoroll.py)
15
+ * in-review row at target -> TESTFLIGHT_ONLY (the App Store slot is
16
+ locked; the build goes to TestFlight under the locked version). The
17
+ sibling-in-review case lives in test_mmv_testflight_only.py.
15
18
  * DEVELOPER_REJECTED at target -> REUSE (developer pulled it from
16
19
  review; safe to mutate)
17
20
 
@@ -42,8 +45,8 @@ from tests_common import ( # noqa: E402
42
45
  def setUpModule() -> None:
43
46
  """Pin auto-bump to 'none' for the whole match-tests module.
44
47
 
45
- The terminal-at-target / in-review-at-target tests assert the
46
- historical SystemExit(2) reject behavior. With auto-bump enabled
48
+ The terminal-at-target tests assert the historical SystemExit(2)
49
+ reject behavior. With auto-bump enabled
47
50
  (the default 'patch'), the round-7 auto-roll would CREATE the
48
51
  next patch on TERMINAL collisions, which is a different code path
49
52
  covered by ``test_manage_marketing_version_autoroll.py``."""
@@ -139,35 +142,38 @@ class MatchAtTargetTests(unittest.TestCase):
139
142
  self.assertIn("fresh marketing version", stderr)
140
143
  _assert_mentions_build_setting_source(self, stderr)
141
144
 
142
- def test_target_matches_waiting_for_review_exits_2(self):
143
- """Issue 13: REUSE must NOT attach a TestFlight build to a
144
- version Apple is reviewing. WAITING_FOR_REVIEW is editable in
145
- ASC's broad sense, but mutating it would interfere with App
146
- Review. SystemExit(2) with "review" wording."""
145
+ def test_target_matches_waiting_for_review_is_testflight_only(self):
146
+ """A version Apple is reviewing locks the whole App Store slot:
147
+ REUSE would attach a build to the row under review and CREATE
148
+ would 409. Neither a human wait nor a bump is asked for any
149
+ more -- the build goes to TestFlight under the locked version
150
+ (TESTFLIGHT_ONLY) with no App Store version id, and no
151
+ CREATE/REUSE seam is touched."""
147
152
  in_review = _v("1.0.0", "WAITING_FOR_REVIEW", vid="ed-wfr-100")
148
- stderr = _assert_decide_exits(
149
- self, "1.0.0", [in_review],
153
+ result, stderr, calls = _run_decide_for_version(
154
+ "1.0.0", versions=[in_review],
150
155
  ground_truth="1.0.0", combined_floor="1.0.0",
151
156
  )
152
- self.assertIn("1.0.0", stderr)
153
- self.assertIn("WAITING_FOR_REVIEW", stderr)
157
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
158
+ self.assertEqual(result["versionString"], "1.0.0")
159
+ self.assertEqual(result["appStoreVersionId"], "")
160
+ self.assertEqual(result["state"], "WAITING_FOR_REVIEW")
161
+ self.assertEqual(result["storeVersionLocked"], "1.0.0")
162
+ self.assertEqual(calls, [])
163
+ self.assertIn("decision=TESTFLIGHT_ONLY", stderr)
154
164
  self.assertIn("ed-wfr-100", stderr)
155
- self.assertIn("review", stderr.lower())
156
- _assert_mentions_build_setting_source(self, stderr)
157
165
 
158
- def test_target_matches_in_review_exits_2(self):
159
- """Issue 13: IN_REVIEW match is also blocked, same as
160
- WAITING_FOR_REVIEW. Apple is actively reviewing the row."""
166
+ def test_target_matches_in_review_is_testflight_only(self):
167
+ """IN_REVIEW locks the slot exactly as WAITING_FOR_REVIEW does."""
161
168
  in_review = _v("1.0.0", "IN_REVIEW", vid="ed-ir-100")
162
- stderr = _assert_decide_exits(
163
- self, "1.0.0", [in_review],
169
+ result, stderr, calls = _run_decide_for_version(
170
+ "1.0.0", versions=[in_review],
164
171
  ground_truth="1.0.0", combined_floor="1.0.0",
165
172
  )
166
- self.assertIn("1.0.0", stderr)
167
- self.assertIn("IN_REVIEW", stderr)
168
- self.assertIn("ed-ir-100", stderr)
169
- self.assertIn("review", stderr.lower())
170
- _assert_mentions_build_setting_source(self, stderr)
173
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
174
+ self.assertEqual(result["state"], "IN_REVIEW")
175
+ self.assertEqual(calls, [])
176
+ self.assertIn("decision=TESTFLIGHT_ONLY", stderr)
171
177
 
172
178
  def test_target_matches_developer_rejected_reuses(self):
173
179
  """Issue 13: DEVELOPER_REJECTED is in REUSABLE_STATES — the
@@ -77,15 +77,17 @@ class StaleEditableSelectionTests(unittest.TestCase):
77
77
 
78
78
  def test_create_blocked_by_higher_editable_other_state(self):
79
79
  """Issue 10 covers every editable state, not just PFS. A draft
80
- in WAITING_FOR_REVIEW above target must also block CREATE."""
81
- higher = _v("1.2.0", "WAITING_FOR_REVIEW", vid="ed-wfr-120")
80
+ in METADATA_REJECTED above target must also block CREATE. (A
81
+ WAITING_FOR_REVIEW row locks the whole slot before this path is
82
+ reached -- see test_mmv_testflight_only.py.)"""
83
+ higher = _v("1.2.0", "METADATA_REJECTED", vid="ed-mr-120")
82
84
  stderr = _assert_decide_exits(
83
85
  self, "1.1.0", [higher], ground_truth=None,
84
86
  )
85
87
  self.assertIn("1.2.0", stderr)
86
- self.assertIn("ed-wfr-120", stderr)
88
+ self.assertIn("ed-mr-120", stderr)
87
89
  self.assertIn("downgrade", stderr.lower())
88
- self.assertIn("WAITING_FOR_REVIEW", stderr)
90
+ self.assertIn("METADATA_REJECTED", stderr)
89
91
 
90
92
  def test_create_blocked_by_equal_version_editable(self):
91
93
  """When an editable already sits at exactly target, REUSE wins
@@ -100,19 +102,23 @@ class StaleEditableSelectionTests(unittest.TestCase):
100
102
  self.assertEqual(calls, [])
101
103
  self.assertIn("decision=REUSE", stderr)
102
104
 
103
- def test_lower_in_review_skipped_create_via_post(self):
105
+ def test_lower_in_review_is_never_patch_renamed(self):
104
106
  """Issue 13: a WAITING_FOR_REVIEW row at version < target must
105
- NOT be PATCH-renamed (Apple is reviewing it). CREATE falls
106
- through to a plain POST with stale_editable_id=None."""
107
+ NOT be PATCH-renamed (Apple is reviewing it). Two fences keep
108
+ that true: the slot lock answers TESTFLIGHT_ONLY before the
109
+ CREATE path is reached at all, and the CREATE path's own
110
+ stale-editable pick still skips in-review rows as a backstop."""
107
111
  in_review = _v("1.0.5", "WAITING_FOR_REVIEW", vid="ed-wfr-105")
108
112
  result, stderr, calls = _run_decide_for_version(
109
113
  "1.1.0", versions=[in_review], ground_truth="1.0.5",
110
114
  )
111
- self.assertEqual(result["decision"], "CREATE")
112
- self.assertEqual(result["versionString"], "1.1.0")
113
- self.assertEqual(len(calls), 1)
114
- self.assertIsNone(calls[0].kwargs.get("stale_editable_id"))
115
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
116
+ self.assertEqual(result["versionString"], "1.0.5")
117
+ self.assertEqual(calls, [])
115
118
  self.assertNotIn("PATCH-rename", stderr)
119
+ import mmv_decide_create
120
+ self.assertIsNone(
121
+ mmv_decide_create._stale_editable_id([in_review], "1.1.0"))
116
122
 
117
123
 
118
124
  if __name__ == "__main__":
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for the ``TESTFLIGHT_ONLY`` decision of ``manage_marketing_version``.
4
+
5
+ The incident this pins (GoVolt, CI run 33743284641, 2026-09-03): App Store
6
+ version 1.6.3 sat WAITING_FOR_REVIEW while the project had moved to 1.6.4.
7
+ The classifier looked only at rows AT the target, saw none, decided CREATE,
8
+ and App Store Connect answered 409 ENTITY_ERROR.RELATIONSHIP.INVALID ("You
9
+ cannot create a new version of the App in the current state") -- exit 4 on
10
+ every push to main until a person noticed. A target that itself matched the
11
+ in-review row used to exit 2 asking a human to wait or bump.
12
+
13
+ Both are the same fact about the APP: while any version is under review (or
14
+ approved and awaiting the developer's release click) the whole App Store slot
15
+ is locked. The decision is now ``TESTFLIGHT_ONLY``: stamp the locked version
16
+ string, take the next build number, upload to TestFlight, skip App Store
17
+ metadata, and never touch the project file.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import json
24
+ import os
25
+ import sys
26
+ import unittest
27
+ from contextlib import redirect_stderr
28
+ from pathlib import Path
29
+ from unittest import mock
30
+
31
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
32
+
33
+ import manage_marketing_version as mmv # noqa: E402
34
+
35
+ from tests_common import _run_decide_for_version, _v # noqa: E402
36
+
37
+
38
+ def _boom(*_args, **_kwargs):
39
+ raise AssertionError("a seam ran on the TESTFLIGHT_ONLY path")
40
+
41
+
42
+ class TestFlightOnlyTests(unittest.TestCase):
43
+ """Any locked App Store version -> TESTFLIGHT_ONLY, nothing else runs."""
44
+
45
+ def _decide(self, target: str, versions: list[dict]):
46
+ """Run ``decide_for_version`` with every floor/create seam armed to
47
+ raise, so the test proves the lock is decided before any of them."""
48
+ buf = io.StringIO()
49
+ with mock.patch.object(mmv, "assert_target_meets_floor", _boom), \
50
+ mock.patch.object(mmv, "decide_create", _boom), \
51
+ mock.patch.object(mmv, "create_or_reuse_with_stale", _boom), \
52
+ mock.patch.object(mmv, "get_combined_floor", _boom), \
53
+ mock.patch.object(mmv, "get_ground_truth_floor", _boom), \
54
+ mock.patch.object(mmv, "maybe_auto_bump", _boom), \
55
+ redirect_stderr(buf):
56
+ result = mmv.decide_for_version(
57
+ target, versions, app_id="111", token="tok",
58
+ )
59
+ return result, buf.getvalue()
60
+
61
+ def test_sibling_in_review_is_testflight_only_under_the_sibling(self):
62
+ """The incident shape: project at 1.6.4, 1.6.3 WAITING_FOR_REVIEW.
63
+ The build is stamped 1.6.3 -- the locked version -- not 1.6.4."""
64
+ versions = [
65
+ _v("1.6.2", "READY_FOR_SALE", vid="rfs-162"),
66
+ _v("1.6.3", "WAITING_FOR_REVIEW", vid="wfr-163"),
67
+ ]
68
+ result, stderr = self._decide("1.6.4", versions)
69
+ self.assertEqual(result, {
70
+ "decision": "TESTFLIGHT_ONLY",
71
+ "versionString": "1.6.3",
72
+ "appStoreVersionId": "",
73
+ "state": "WAITING_FOR_REVIEW",
74
+ "storeVersionLocked": "1.6.3",
75
+ })
76
+ self.assertIn("decision=TESTFLIGHT_ONLY versionString=1.6.3", stderr)
77
+ self.assertIn("wfr-163", stderr)
78
+ self.assertIn("::notice::", stderr)
79
+
80
+ def test_target_equal_to_the_in_review_row_is_testflight_only(self):
81
+ result, _ = self._decide("1.6.3", [_v("1.6.3", "IN_REVIEW", vid="ir")])
82
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
83
+ self.assertEqual(result["versionString"], "1.6.3")
84
+ self.assertEqual(result["state"], "IN_REVIEW")
85
+
86
+ def test_pending_developer_release_locks_the_slot_too(self):
87
+ """Apple approved 1.6.3 and the developer has not released it: a
88
+ new App Store version cannot be created either."""
89
+ result, _ = self._decide(
90
+ "1.6.4", [_v("1.6.3", "PENDING_DEVELOPER_RELEASE", vid="pdr")])
91
+ self.assertEqual(result["decision"], "TESTFLIGHT_ONLY")
92
+ self.assertEqual(result["versionString"], "1.6.3")
93
+ self.assertEqual(result["state"], "PENDING_DEVELOPER_RELEASE")
94
+
95
+ def test_several_locked_rows_pick_the_highest_semver(self):
96
+ versions = [
97
+ _v("1.6.3", "PENDING_DEVELOPER_RELEASE", vid="pdr-163"),
98
+ _v("1.6.10", "WAITING_FOR_REVIEW", vid="wfr-1610"),
99
+ _v("1.6.9", "IN_REVIEW", vid="ir-169"),
100
+ ]
101
+ result, _ = self._decide("1.7.0", versions)
102
+ self.assertEqual(result["versionString"], "1.6.10")
103
+ self.assertEqual(result["storeVersionLocked"], "1.6.10")
104
+
105
+ def test_nothing_in_review_leaves_the_ordinary_path_untouched(self):
106
+ """Editable / terminal / archived rows do not lock the slot: the
107
+ ordinary floor + classify path runs and CREATEs as before."""
108
+ versions = [
109
+ _v("1.6.2", "READY_FOR_SALE", vid="rfs-162"),
110
+ _v("1.6.0", "REPLACED_WITH_NEW_VERSION", vid="old"),
111
+ _v("1.6.1", "DEVELOPER_REJECTED", vid="dr"),
112
+ ]
113
+ result, stderr, calls = _run_decide_for_version(
114
+ "1.6.4", versions=versions, ground_truth="1.6.2",
115
+ )
116
+ self.assertEqual(result["decision"], "CREATE")
117
+ self.assertEqual(result["versionString"], "1.6.4")
118
+ self.assertNotIn("storeVersionLocked", result)
119
+ self.assertEqual(len(calls), 1)
120
+ self.assertNotIn("TESTFLIGHT_ONLY", stderr)
121
+ self.assertIsNone(mmv.store_version_locked(versions))
122
+
123
+ def test_main_prints_the_locked_decision_as_json(self):
124
+ """End to end through ``main()``: the JSON action.yml parses."""
125
+ out_buf, err_buf = io.StringIO(), io.StringIO()
126
+ env = {
127
+ "ASC_KEY_ID": "kid", "ASC_ISSUER_ID": "iid",
128
+ "ASC_KEY_PATH": "/tmp/ignored.p8", "APP_STORE_APPLE_ID": "111",
129
+ "MARKETING_VERSION": "1.6.4",
130
+ }
131
+ rows = [_v("1.6.3", "WAITING_FOR_REVIEW", vid="wfr-163")]
132
+ with mock.patch.dict(os.environ, env, clear=False), \
133
+ mock.patch.object(mmv, "make_jwt", return_value="tok"), \
134
+ mock.patch.object(mmv, "fetch_versions", return_value=rows), \
135
+ mock.patch.object(mmv, "assert_target_meets_floor", _boom), \
136
+ mock.patch("sys.stdout", out_buf), \
137
+ redirect_stderr(err_buf):
138
+ mmv.main()
139
+ decision = json.loads(out_buf.getvalue().strip())
140
+ self.assertEqual(decision["decision"], "TESTFLIGHT_ONLY")
141
+ self.assertEqual(decision["versionString"], "1.6.3")
142
+ self.assertEqual(decision["appStoreVersionId"], "")
143
+ self.assertEqual(decision["storeVersionLocked"], "1.6.3")
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -45,6 +45,31 @@ class TestTestFlightVersionSlotWiring(unittest.TestCase):
45
45
  self.assertIn("inputs.manage-app-store-version == 'true'", detector)
46
46
  self.assertIn("inputs.manage-app-store-version == 'true'", whats_new)
47
47
 
48
+ def test_a_locked_store_version_ships_testflight_only(self) -> None:
49
+ """The TESTFLIGHT_ONLY decision: the locked version is re-exported
50
+ as MARKETING_VERSION (the build joins that train), no App Store
51
+ version id is set, STORE_VERSION_LOCKED gates the metadata
52
+ writers off, and the typed annotation carries the schema the
53
+ panel parses."""
54
+ store_slot = step_block(self.source, "Resolve App Store version slot (REUSE or CREATE)")
55
+ self.assertIn('if [ "$DECISION" = "TESTFLIGHT_ONLY" ]; then', store_slot)
56
+ self.assertIn('echo "STORE_VERSION_LOCKED=$EFFECTIVE_MV" >> "$GITHUB_ENV"', store_slot)
57
+ self.assertIn('::notice title=store_version_locked::', store_slot)
58
+ self.assertIn('\\"schema\\":\\"gowalk-cicd/store-version-locked.v1\\"', store_slot)
59
+ for field in ("version", "state", "build_number"):
60
+ self.assertIn(f'\\"{field}\\":', store_slot)
61
+ # The locked branch re-exports MARKETING_VERSION exactly as the
62
+ # auto-bump branch does, so Stamp Info.plist and Archive read it.
63
+ locked = store_slot.split('if [ "$DECISION" = "TESTFLIGHT_ONLY" ]; then', 1)[1]
64
+ locked = locked.split("elif", 1)[0]
65
+ self.assertIn('echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"', locked)
66
+ # Neither metadata writer runs against a locked slot: both scripts
67
+ # require APP_STORE_VERSION_ID, which is empty here.
68
+ detector = step_block(self.source, "Detect empty ASC metadata fields")
69
+ whats_new = step_block(self.source, 'Set App Store "What\'s New"')
70
+ self.assertIn("env.STORE_VERSION_LOCKED == ''", detector)
71
+ self.assertIn("env.STORE_VERSION_LOCKED == ''", whats_new)
72
+
48
73
 
49
74
  if __name__ == "__main__":
50
75
  unittest.main()
@@ -1 +1 @@
1
- 1.0.52
1
+ 1.0.53
@@ -1 +1 @@
1
- 1.0.52
1
+ 1.0.53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gowalk-cicd",
3
- "version": "1.0.52",
3
+ "version": "1.0.53",
4
4
  "description": "Zero-config GitHub Actions delivery for iOS TestFlight and Android Google Play.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -453,14 +453,21 @@ jobs:
453
453
  ${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
454
454
  changesNotSentForReview: true
455
455
 
456
- - name: Say what still needs a human
456
+ # The retry landed the bundle without submitting it. That is a fact for
457
+ # the panel that merged this commit, not a chore for a person: the panel
458
+ # reads this typed check-run annotation off the job (title + JSON with a
459
+ # versioned schema, never the log text) and opens its own Play console
460
+ # step that presses "Send changes for review". Normal after a policy
461
+ # rejection; the ordinary first upload submits and this never fires.
462
+ - name: Record that Play holds this release for a review submission
457
463
  if: ${{ always() && steps.play-upload-retry.outcome == 'success' }}
458
464
  shell: bash
465
+ env:
466
+ PACKAGE: ${{ steps.android.outputs.package-name }}
467
+ TRACK: ${{ vars.GOOGLE_PLAY_TRACK || 'internal' }}
468
+ VERSION_CODE: ${{ steps.android.outputs.build-number }}
459
469
  run: |
460
- echo "::warning::Play would not accept this release for review automatically, so it was \
461
- uploaded without being submitted. Open Play Console for \
462
- ${{ steps.android.outputs.package-name }} and press 'Send changes for review' on the \
463
- Publishing overview page. This is normal after a policy rejection."
470
+ echo "::notice title=play_review_pending::{\"schema\":\"gowalk-cicd/play-review-pending.v1\",\"package\":\"${PACKAGE}\",\"track\":\"${TRACK}\",\"version_code\":\"${VERSION_CODE}\"}"
464
471
 
465
472
  # GitHub only registers a newly added workflow after it reaches the default
466
473
  # branch. A brand-new app's feature branch therefore cannot dispatch the