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 +17 -0
- package/README.md +68 -6
- package/action/.daemux-version +1 -1
- package/action/action.yml +130 -63
- package/action/scripts/manage_marketing_version.py +84 -13
- package/action/scripts/test_action_step_order.py +83 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +33 -24
- package/action/scripts/test_manage_marketing_version_match.py +30 -24
- package/action/scripts/test_mmv_decide_create.py +17 -11
- package/action/scripts/test_mmv_testflight_only.py +147 -0
- package/action/scripts/test_testflight_version_slot_wiring.py +25 -0
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
- package/templates/deploy.yml +12 -5
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
608
|
-
|
|
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
|
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
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 "$
|
|
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 "$
|
|
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 "$
|
|
336
|
-
DECISION_JSON=$(python3 "$
|
|
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
|
|
343
|
-
#
|
|
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 [
|
|
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 "$
|
|
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 "$
|
|
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
|
-
#
|
|
412
|
-
#
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
#
|
|
416
|
-
#
|
|
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
|
-
#
|
|
512
|
-
#
|
|
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)
|
|
516
|
-
#
|
|
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 "$
|
|
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 "$
|
|
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 "$
|
|
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: ${{
|
|
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 "$
|
|
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: ${{
|
|
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 "$
|
|
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 "$
|
|
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.
|
|
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
|
-
|
|
16
|
-
- any
|
|
17
|
-
|
|
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
|
-
|
|
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":"...",
|
|
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 = ""
|
|
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,
|
|
294
|
-
|
|
295
|
-
|
|
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');
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
8
|
-
(WAITING_FOR_REVIEW / IN_REVIEW).
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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(
|
|
104
|
-
self.assertEqual(
|
|
105
|
-
self.
|
|
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
|
|
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(
|
|
110
|
-
self.assertEqual(
|
|
111
|
-
self.
|
|
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
|
|
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.
|
|
119
|
-
|
|
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(
|
|
124
|
-
self.assertEqual(
|
|
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
|
|
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(
|
|
137
|
-
self.assertEqual(
|
|
138
|
-
self.assertIn("
|
|
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
|
|
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
|
|
46
|
-
|
|
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
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
149
|
-
|
|
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.
|
|
153
|
-
self.
|
|
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
|
|
159
|
-
"""
|
|
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 =
|
|
163
|
-
|
|
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.
|
|
167
|
-
self.
|
|
168
|
-
self.
|
|
169
|
-
self.assertIn("
|
|
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
|
|
81
|
-
|
|
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-
|
|
88
|
+
self.assertIn("ed-mr-120", stderr)
|
|
87
89
|
self.assertIn("downgrade", stderr.lower())
|
|
88
|
-
self.assertIn("
|
|
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
|
|
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).
|
|
106
|
-
|
|
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"], "
|
|
112
|
-
self.assertEqual(result["versionString"], "1.
|
|
113
|
-
self.assertEqual(
|
|
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.
|
|
1
|
+
1.0.53
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.53
|
package/package.json
CHANGED
package/templates/deploy.yml
CHANGED
|
@@ -453,14 +453,21 @@ jobs:
|
|
|
453
453
|
${{ hashFiles('distribution/whatsnew/**') != '' && 'distribution/whatsnew' || '' }}
|
|
454
454
|
changesNotSentForReview: true
|
|
455
455
|
|
|
456
|
-
|
|
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 "::
|
|
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
|