gowalk-cicd 1.0.0

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.
Files changed (84) hide show
  1. package/CLAUDE.md +94 -0
  2. package/README.md +417 -0
  3. package/action/.daemux-version +1 -0
  4. package/action/MIGRATION.md +232 -0
  5. package/action/action.yml +973 -0
  6. package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
  7. package/action/prompts/generate_descriptions.prompt.yml +59 -0
  8. package/action/scripts/app_context_scanner.py +239 -0
  9. package/action/scripts/asc_build_history.py +284 -0
  10. package/action/scripts/asc_common.py +246 -0
  11. package/action/scripts/asc_metadata_applier.py +368 -0
  12. package/action/scripts/asc_metadata_detector.py +250 -0
  13. package/action/scripts/asc_version_create.py +365 -0
  14. package/action/scripts/asc_version_fetch.py +274 -0
  15. package/action/scripts/asc_version_reuse.py +177 -0
  16. package/action/scripts/auto_detect.py +391 -0
  17. package/action/scripts/autoupdate_check.sh +118 -0
  18. package/action/scripts/cert_factory.py +190 -0
  19. package/action/scripts/cfg_io.py +27 -0
  20. package/action/scripts/cfg_resolve.py +147 -0
  21. package/action/scripts/commit_bot_changes.sh +82 -0
  22. package/action/scripts/creds_store.py +328 -0
  23. package/action/scripts/keychain.py +103 -0
  24. package/action/scripts/lookup_app_id.py +51 -0
  25. package/action/scripts/manage_marketing_version.py +337 -0
  26. package/action/scripts/metadata_constants.py +102 -0
  27. package/action/scripts/mmv_decide_create.py +181 -0
  28. package/action/scripts/mmv_floor_check.py +265 -0
  29. package/action/scripts/next_build_number.py +195 -0
  30. package/action/scripts/pbxproj_editor.py +220 -0
  31. package/action/scripts/prepare_signing.py +223 -0
  32. package/action/scripts/profile_io.py +64 -0
  33. package/action/scripts/profile_manager.py +307 -0
  34. package/action/scripts/read_config.py +315 -0
  35. package/action/scripts/resolve_marketing_version.py +173 -0
  36. package/action/scripts/set_app_store_whats_new.py +317 -0
  37. package/action/scripts/team_resolver.py +130 -0
  38. package/action/scripts/test_app_context_scanner.py +167 -0
  39. package/action/scripts/test_asc_build_history.py +221 -0
  40. package/action/scripts/test_asc_builds_prerelease.py +219 -0
  41. package/action/scripts/test_asc_common_timeouts.py +121 -0
  42. package/action/scripts/test_asc_metadata_applier.py +850 -0
  43. package/action/scripts/test_asc_metadata_detector.py +445 -0
  44. package/action/scripts/test_auto_detect.py +432 -0
  45. package/action/scripts/test_cfg_resolve_credentials.py +48 -0
  46. package/action/scripts/test_compute_next_version.py +95 -0
  47. package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
  48. package/action/scripts/test_compute_next_version_strictness.py +67 -0
  49. package/action/scripts/test_fetch_versions.py +163 -0
  50. package/action/scripts/test_ground_truth_floor.py +104 -0
  51. package/action/scripts/test_manage_marketing_version.py +134 -0
  52. package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
  53. package/action/scripts/test_manage_marketing_version_floor.py +170 -0
  54. package/action/scripts/test_manage_marketing_version_match.py +187 -0
  55. package/action/scripts/test_mmv_409_classifier.py +231 -0
  56. package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
  57. package/action/scripts/test_mmv_decide_create.py +119 -0
  58. package/action/scripts/test_mmv_floor_check.py +158 -0
  59. package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
  60. package/action/scripts/test_mmv_helpers.py +57 -0
  61. package/action/scripts/test_next_build_number.py +174 -0
  62. package/action/scripts/test_read_config_auto_detect.py +257 -0
  63. package/action/scripts/test_resolve_marketing_version.py +298 -0
  64. package/action/scripts/test_reuse_stale_editable.py +182 -0
  65. package/action/scripts/test_set_app_store_whats_new.py +71 -0
  66. package/action/scripts/test_team_fallback_wiring.py +157 -0
  67. package/action/scripts/test_team_resolver.py +249 -0
  68. package/action/scripts/tests_common.py +167 -0
  69. package/action/scripts/version_utils.py +373 -0
  70. package/android-action/.daemux-version +1 -0
  71. package/android-action/action.yml +92 -0
  72. package/android-action/scripts/android_config.py +130 -0
  73. package/android-action/scripts/bitrise_deploy.py +160 -0
  74. package/android-action/scripts/find_bundle.py +25 -0
  75. package/android-action/scripts/play_preflight.py +69 -0
  76. package/android-action/scripts/resolve_android.py +70 -0
  77. package/android-action/scripts/sign_bundle.py +70 -0
  78. package/android-action/scripts/test_android_config.py +97 -0
  79. package/android-action/scripts/test_bitrise_deploy.py +124 -0
  80. package/android-action/scripts/test_sign_bundle.py +82 -0
  81. package/bin/cli.mjs +63 -0
  82. package/package.json +57 -0
  83. package/src/install.mjs +208 -0
  84. package/templates/deploy.yml +150 -0
@@ -0,0 +1,973 @@
1
+ # Note: this file is ~800 lines, above the project's 400-line composite-
2
+ # action guideline. The size pre-dates the cert-caching feature; the
3
+ # signing + commit-back step, the AI-metadata phases, and the
4
+ # export / upload chunk are candidates for extraction into separate
5
+ # scripts under ``scripts/`` (similar to how prepare_signing.py /
6
+ # cert_factory.py / keychain.py / profile_io.py split the cert +
7
+ # profile lifecycle). Refactor is a separate concern tracked outside
8
+ # this file.
9
+ # autoupdate test: 2026-05-06
10
+ name: iOS native TestFlight deploy
11
+ description: >
12
+ Build a native Swift/SwiftUI iOS app on a macOS runner and upload it to
13
+ TestFlight. Provisions an Apple Distribution cert + App Store provisioning
14
+ profiles at runtime via the App Store Connect API and persists them under
15
+ ``creds/`` so subsequent runs reuse the same identity (cert auto-renews at
16
+ T-30 days). Caller workflow MUST grant ``permissions: contents: write`` so
17
+ the action can commit the refreshed signing identity back to the default
18
+ branch on push events. Only secrets required are the ASC API key trio.
19
+
20
+ inputs:
21
+ project:
22
+ description: "Path to the .xcodeproj (or omit if using workspace)"
23
+ required: false
24
+ default: ""
25
+ workspace:
26
+ description: "Path to the .xcworkspace (takes precedence over project)"
27
+ required: false
28
+ default: ""
29
+ scheme:
30
+ description: "Xcode scheme to archive"
31
+ required: false
32
+ default: ""
33
+ configuration:
34
+ description: "Xcode build configuration"
35
+ required: false
36
+ default: ""
37
+ bundle-id:
38
+ description: "Application bundle identifier (must already exist in ASC)"
39
+ required: false
40
+ default: ""
41
+ team-id:
42
+ description: "Apple developer team identifier (10-char)"
43
+ required: false
44
+ default: ""
45
+ app-store-apple-id:
46
+ description: "App Store Connect numeric app id (for build-number lookup)"
47
+ required: false
48
+ default: ""
49
+ profile-name:
50
+ description: >
51
+ DEPRECATED (ignored). Profile names are now derived per-target from
52
+ PRODUCT_BUNDLE_IDENTIFIER as `CI-<bundle_id>`. Kept for backward
53
+ compatibility — passing a value emits a notice.
54
+ required: false
55
+ default: ""
56
+ upload:
57
+ description: "Set to 'false' to stop after producing the IPA"
58
+ required: false
59
+ default: "true"
60
+ archive:
61
+ description: >
62
+ Set to 'false' to skip archive + export + upload entirely. Useful for
63
+ PR / branch runs that only need to build and test without code signing
64
+ secrets. When 'false', `upload` is ignored.
65
+ required: false
66
+ default: "true"
67
+ run-tests:
68
+ description: "Set to 'false' to skip the simulator test stage"
69
+ required: false
70
+ default: ""
71
+ test-command:
72
+ description: >
73
+ Shell command used for the simulator test stage. Defaults to running
74
+ `bash Scripts/run-tests.sh` when that file exists, otherwise
75
+ `xcodebuild test` against the chosen scheme on a booted simulator with
76
+ code-signing disabled. Override to run a custom runner.
77
+ required: false
78
+ default: ""
79
+ test-destination:
80
+ description: >
81
+ Destination passed to the default `xcodebuild test` invocation when
82
+ `test-command` is empty and no `Scripts/run-tests.sh` is present.
83
+ required: false
84
+ default: ""
85
+ uses-non-exempt-encryption:
86
+ description: >
87
+ Value to set for ITSAppUsesNonExemptEncryption in Info.plist
88
+ (default: false). Set to 'true' only if the app uses custom crypto
89
+ beyond Apple-standard; set to empty string to skip the write entirely.
90
+ required: false
91
+ default: ""
92
+ app-store-whats-new:
93
+ description: >
94
+ What's New text for the App Store version (appStoreVersionLocalizations.whatsNew).
95
+ Multi-line OK. Empty = skip. First-release versions (1.0 / 0.0) are skipped.
96
+ required: false
97
+ default: ""
98
+ app-store-locale:
99
+ description: "Locale for App Store whatsNew (default: en-US)"
100
+ required: false
101
+ default: ""
102
+ ai-metadata:
103
+ description: "Auto-fill empty ASC metadata fields via GitHub Models AI. 'false' to disable."
104
+ required: false
105
+ default: "true"
106
+ ai-metadata-model:
107
+ description: "GitHub Models model id for AI metadata generation."
108
+ required: false
109
+ default: "openai/gpt-4o"
110
+ # DESIGN DECISION: marketing-version-auto-bump trades unattended-CI
111
+ # smoothness against silent-semver-decisions visibility. Default
112
+ # 'rollover' lets CI keep the build moving when ASC's combined floor
113
+ # advances ahead of the project's MARKETING_VERSION (typical cause:
114
+ # a manual TestFlight upload at a higher version, or a prior
115
+ # release moving to READY_FOR_SALE between commits). 'rollover'
116
+ # bumps the patch component with carry: at .9 it rolls into the
117
+ # next minor (1.0.9 -> 1.1.0), and at minor=9 it cascades into the
118
+ # next major (1.9.9 -> 2.0.0). This produces the more natural
119
+ # human-readable progression most projects want -- patch numbers
120
+ # never grow past 9 silently. The bumped value is written into the
121
+ # project file and committed back via the existing bot-commit step,
122
+ # so the change is visible in repo history.
123
+ #
124
+ # Other policies:
125
+ # 'patch' -- legacy unbounded patch bump (1.0.9 -> 1.0.10).
126
+ # Preserved unchanged for backward compat with consumers
127
+ # pinning the historical default; existing 0.0.27 callers that
128
+ # set `marketing-version-auto-bump: 'patch'` keep their current
129
+ # behavior. New installs get 'rollover'.
130
+ # 'minor' -- bump minor, reset patch to 0 (major.(max(minor)+1).0).
131
+ # For projects that ship every release as a minor.
132
+ # 'none' -- preserve the historical "fail the build, ask the
133
+ # human to bump" behavior when explicit semver control is
134
+ # required (e.g. release-train workflows).
135
+ marketing-version-auto-bump:
136
+ description: >
137
+ When the ASC combined floor exceeds the project's MARKETING_VERSION,
138
+ automatically bump and stage the project file instead of failing.
139
+ Values: 'rollover' (default — patch with carry: 1.0.9 -> 1.1.0,
140
+ 1.9.9 -> 2.0.0; major has no upper limit), 'patch' (legacy
141
+ unbounded: 1.0.9 -> 1.0.10), 'minor' (bump minor, reset patch
142
+ to 0), 'none' (preserve existing fail behavior; requires human
143
+ bump). The bumped value is committed alongside cert and
144
+ autoupdate changes via the existing commit-back step.
145
+ required: false
146
+ default: "rollover"
147
+ # DESIGN DECISION: auto-update defaults to 'true' as the explicit feature.
148
+ # Trust model documented in scripts/autoupdate_check.sh DESIGN DECISION
149
+ # block. Consumers can pin via `with: { auto-update: 'false' }`.
150
+ auto-update:
151
+ description: >
152
+ Per-run check for a newer gowalk-cicd on npm. When 'true'
153
+ (default), the action queries npm on default-branch push runs and
154
+ re-vendors the package via `npx --yes` if the published version is
155
+ newer than the marker at .github/actions/swift-app/.daemux-version.
156
+ The refreshed action files (NOT deploy.yml, which GITHUB_TOKEN
157
+ cannot push) are committed alongside any cert refresh in a single
158
+ combined commit. Set to 'false' to pin the vendored copy.
159
+ required: false
160
+ default: "true"
161
+
162
+ runs:
163
+ using: composite
164
+ steps:
165
+ - name: Resolve credentials + auto-detect Xcode project
166
+ shell: bash
167
+ env:
168
+ INPUT_PROJECT: ${{ inputs.project }}
169
+ INPUT_WORKSPACE: ${{ inputs.workspace }}
170
+ INPUT_SCHEME: ${{ inputs.scheme }}
171
+ INPUT_CONFIGURATION: ${{ inputs.configuration }}
172
+ INPUT_BUNDLE_ID: ${{ inputs.bundle-id }}
173
+ INPUT_TEAM_ID: ${{ inputs.team-id }}
174
+ INPUT_APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id }}
175
+ INPUT_PROFILE_NAME: ${{ inputs.profile-name }}
176
+ INPUT_USES_NON_EXEMPT: ${{ inputs.uses-non-exempt-encryption }}
177
+ INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
178
+ INPUT_LOCALE: ${{ inputs.app-store-locale }}
179
+ INPUT_RUN_TESTS: ${{ inputs.run-tests }}
180
+ INPUT_TEST_COMMAND: ${{ inputs.test-command }}
181
+ INPUT_TEST_DESTINATION: ${{ inputs.test-destination }}
182
+ run: |
183
+ # read_config.py runs FIRST (it derives every other value), so it must
184
+ # install its full dep set inline. All later steps reuse these.
185
+ # No YAML loader needed — there is no config file.
186
+ python3 -m pip install --quiet --break-system-packages PyJWT cryptography requests
187
+ # xcodegen: when project.yml is present but .xcodeproj is not committed
188
+ # (common xcodegen flow), auto_detect will shell out to `xcodegen
189
+ # generate`. Install it here so the command is available. Idempotent:
190
+ # no-op when already present (GitHub-hosted macOS runners preinstall it).
191
+ if [ -f project.yml ] && ! command -v xcodegen >/dev/null 2>&1; then
192
+ echo "Installing xcodegen (project.yml present, binary missing)…"
193
+ brew install xcodegen >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install xcodegen
194
+ fi
195
+ python3 "${{ github.action_path }}/scripts/read_config.py"
196
+
197
+ - name: Select latest Xcode
198
+ shell: bash
199
+ run: |
200
+ XCODE_PATH=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1)
201
+ if [ -z "$XCODE_PATH" ]; then
202
+ XCODE_PATH=$(ls -d /Applications/Xcode*.app 2>/dev/null | sort -V | tail -1)
203
+ fi
204
+ echo "Selecting $XCODE_PATH"
205
+ sudo xcode-select -s "$XCODE_PATH"
206
+ xcodebuild -version
207
+
208
+ - name: Run simulator tests
209
+ if: ${{ (inputs.run-tests || env.CFG_RUN_TESTS) == 'true' }}
210
+ shell: bash
211
+ env:
212
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
213
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
214
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
215
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
216
+ TEST_COMMAND: ${{ inputs.test-command || env.CFG_TEST_COMMAND }}
217
+ TEST_DESTINATION: >-
218
+ ${{ inputs.test-destination || env.CFG_TEST_DESTINATION
219
+ || 'platform=iOS Simulator,name=iPhone 15' }}
220
+ run: |
221
+ set -o pipefail
222
+ if [ -n "$TEST_COMMAND" ]; then
223
+ echo "Running custom test command: $TEST_COMMAND"
224
+ bash -c "$TEST_COMMAND"
225
+ elif [ -f "Scripts/run-tests.sh" ]; then
226
+ echo "Running Scripts/run-tests.sh"
227
+ bash Scripts/run-tests.sh
228
+ else
229
+ if [ -n "$WORKSPACE" ]; then
230
+ PROJ_ARGS=(-workspace "$WORKSPACE")
231
+ else
232
+ PROJ_ARGS=(-project "$PROJECT")
233
+ fi
234
+ echo "Running xcodebuild test on $TEST_DESTINATION"
235
+ xcodebuild \
236
+ "${PROJ_ARGS[@]}" \
237
+ -scheme "$SCHEME" \
238
+ -configuration "$CONFIGURATION" \
239
+ -destination "$TEST_DESTINATION" \
240
+ CODE_SIGNING_ALLOWED=NO \
241
+ CODE_SIGNING_REQUIRED=NO \
242
+ CODE_SIGN_IDENTITY="" \
243
+ DEVELOPMENT_TEAM="" \
244
+ test
245
+ fi
246
+
247
+ - name: Stage ASC API key for xcrun / altool
248
+ if: ${{ inputs.archive == 'true' }}
249
+ shell: bash
250
+ run: |
251
+ # SECURITY: never consume the raw key through an env var — GH Actions
252
+ # prints env: blocks on step-group expansion, which leaks secrets.
253
+ # read_config.py already validated creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8
254
+ # exists and exported ASC_KEY_P8_PATH; we copy the file on disk and
255
+ # stage it where xcrun/altool expect it (~/.appstoreconnect/private_keys).
256
+ : "${ASC_KEY_ID:?ASC_KEY_ID env var is required}"
257
+ : "${ASC_ISSUER_ID:?ASC_ISSUER_ID env var is required}"
258
+ : "${ASC_KEY_P8_PATH:?ASC_KEY_P8_PATH env var is required (set by read_config.py)}"
259
+ if [ ! -f "$ASC_KEY_P8_PATH" ]; then
260
+ echo "::error::ASC_KEY_P8_PATH points to missing file: $ASC_KEY_P8_PATH"
261
+ exit 1
262
+ fi
263
+ mkdir -p "$RUNNER_TEMP/asc"
264
+ cp "$ASC_KEY_P8_PATH" "$RUNNER_TEMP/asc/AuthKey.p8"
265
+ chmod 600 "$RUNNER_TEMP/asc/AuthKey.p8"
266
+ mkdir -p ~/.appstoreconnect/private_keys
267
+ cp "$RUNNER_TEMP/asc/AuthKey.p8" \
268
+ ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8
269
+ echo "ASC_KEY_PATH=$RUNNER_TEMP/asc/AuthKey.p8" >> "$GITHUB_ENV"
270
+
271
+ - name: Resolve marketing version (project source of truth)
272
+ if: ${{ inputs.archive == 'true' }}
273
+ shell: bash
274
+ env:
275
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
276
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
277
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
278
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
279
+ run: |
280
+ set -o pipefail
281
+ python3 "${{ github.action_path }}/scripts/resolve_marketing_version.py" \
282
+ | tee -a "$GITHUB_ENV"
283
+
284
+ - name: Resolve App Store version slot (REUSE or CREATE)
285
+ if: ${{ inputs.archive == 'true' }}
286
+ shell: bash
287
+ env:
288
+ APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
289
+ # When the ASC combined floor exceeds the project's MARKETING_VERSION,
290
+ # mmv auto-bumps the project file (pbxproj or Info.plist) per this
291
+ # policy and continues. 'none' preserves the historical fail-the-
292
+ # build behavior.
293
+ MARKETING_VERSION_AUTO_BUMP: ${{ inputs.marketing-version-auto-bump }}
294
+ # Auto-bump persistence-context inputs. mmv_floor_check refuses to
295
+ # auto-bump unless this run is push-to-default-branch -- the same
296
+ # gate the commit-back step uses. Without this gate,
297
+ # workflow_dispatch / pull_request / feature-branch runs would
298
+ # auto-bump in-tree, archive an IPA at the bumped version, and
299
+ # then drop the bump on the floor (commit-back is gated to
300
+ # default-branch push). The next run would re-bump or drift.
301
+ GITHUB_EVENT_NAME: ${{ github.event_name }}
302
+ GITHUB_REF: ${{ github.ref }}
303
+ GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
304
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
305
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
306
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
307
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
308
+ run: |
309
+ set -o pipefail
310
+ BUILD_NUMBER=$(python3 "${{ github.action_path }}/scripts/next_build_number.py")
311
+ DECISION_JSON=$(python3 "${{ github.action_path }}/scripts/manage_marketing_version.py")
312
+ APP_STORE_VERSION_ID=$(python3 -c \
313
+ "import json,sys; d=json.loads(sys.argv[1]); print(d.get('appStoreVersionId',''))" \
314
+ "$DECISION_JSON")
315
+ # The decision's versionString is authoritative: it reflects any
316
+ # auto-bump that mmv applied to MARKETING_VERSION when the project
317
+ # was below the ASC combined floor. Re-export so subsequent steps
318
+ # (Stamp Info.plist, Archive) see the bumped value.
319
+ EFFECTIVE_MV=$(python3 -c \
320
+ "import json,sys; d=json.loads(sys.argv[1]); print(d.get('versionString',''))" \
321
+ "$DECISION_JSON")
322
+ if [ -n "$EFFECTIVE_MV" ] && [ "$EFFECTIVE_MV" != "$MARKETING_VERSION" ]; then
323
+ echo "::notice::auto-bump rewrote MARKETING_VERSION $MARKETING_VERSION -> $EFFECTIVE_MV"
324
+ echo "MARKETING_VERSION=$EFFECTIVE_MV" >> "$GITHUB_ENV"
325
+ MARKETING_VERSION="$EFFECTIVE_MV"
326
+ fi
327
+ echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
328
+ echo "APP_STORE_VERSION_ID=$APP_STORE_VERSION_ID" >> "$GITHUB_ENV"
329
+ echo "Using marketing version $MARKETING_VERSION build $BUILD_NUMBER (decision=$DECISION_JSON)"
330
+
331
+ - name: Notice on deprecated profile-name input
332
+ if: ${{ inputs.archive == 'true' && inputs.profile-name != '' }}
333
+ shell: bash
334
+ run: |
335
+ echo "::notice::The 'profile-name' input is deprecated and ignored." \
336
+ "Profile names are derived per-target as CI-<bundle_id>."
337
+
338
+ - name: Prepare signing (cert + keychain + per-target profiles + pbxproj)
339
+ if: ${{ inputs.archive == 'true' }}
340
+ shell: bash
341
+ env:
342
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
343
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
344
+ TEAM_ID: ${{ inputs.team-id || env.CFG_TEAM_ID }}
345
+ CREDS_DIR: ${{ github.workspace }}/creds
346
+ run: python3 "${{ github.action_path }}/scripts/prepare_signing.py"
347
+
348
+ # DESIGN DECISION (intentional, not a bug):
349
+ # The next four steps stage + commit-back the Apple Distribution
350
+ # code-signing identity (.p12 + provisioning profiles) AND any
351
+ # autoupdate of the vendored app-ci actions into the consumer
352
+ # repo's default branch. This is a deliberate trade-off chosen over
353
+ # GitHub Secrets-based storage:
354
+ # - Consumer repos already commit the ASC API key (.p8) to creds/, so
355
+ # the threat model already accepts repo-stored Apple credentials.
356
+ # - Each app uses a different Apple Developer account / .p8, so
357
+ # org-level GitHub Secrets do not amortize setup work.
358
+ # - "Drop file in creds/" remains the consistent UX for new projects.
359
+ # Non-negotiable mitigations (see IOS_NATIVE_CI_SETUP.md):
360
+ # - Repo MUST stay private (branch protection on visibility recommended).
361
+ # - permissions: contents: write is required; expands attack surface
362
+ # of any compromised step in the workflow — keep workflow minimal.
363
+ # - .gitignore must NOT block the new creds/ files (see setup doc).
364
+ #
365
+ # Split into four steps so cert refresh and autoupdate can both fire
366
+ # in a single combined commit when they coincide, but either can fire
367
+ # alone without redundant retries: (1) copy bot scripts to RUNNER_TEMP
368
+ # so autoupdate's `npx` cannot mid-run swap script bytes under us,
369
+ # (2) stage cert files, (3) check for newer app-ci on npm and
370
+ # stage refreshed action files, (4) commit + push whatever is staged.
371
+
372
+ # Stage scripts to RUNNER_TEMP so the autoupdate step's `npx` (which
373
+ # overwrites .github/actions/swift-app/ — the same dir as ${action_path}
374
+ # for local-action consumers) cannot mid-run swap the script bytes
375
+ # under us. action.yml step definitions are already loaded by the
376
+ # runner at job start, but the shell scripts are read fresh per step,
377
+ # so without this stage we'd risk loading commit_bot_changes.sh from
378
+ # the post-`npx` (newer) tree while the surrounding step definitions
379
+ # came from the pre-`npx` (older) action.yml — a mid-run version mix.
380
+ - name: Stage bot-side scripts to runner temp
381
+ if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
382
+ && github.event_name == 'push'
383
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
384
+ shell: bash
385
+ run: |
386
+ set -euo pipefail
387
+ mkdir -p "$RUNNER_TEMP/swift-app-bot-scripts"
388
+ cp "${{ github.action_path }}/scripts/autoupdate_check.sh" \
389
+ "$RUNNER_TEMP/swift-app-bot-scripts/"
390
+ cp "${{ github.action_path }}/scripts/commit_bot_changes.sh" \
391
+ "$RUNNER_TEMP/swift-app-bot-scripts/"
392
+ chmod +x "$RUNNER_TEMP/swift-app-bot-scripts/"*.sh
393
+ echo "BOT_SCRIPTS_DIR=$RUNNER_TEMP/swift-app-bot-scripts" >> "$GITHUB_ENV"
394
+
395
+ - name: Stage refreshed signing identity
396
+ # Only stage on push to the default branch — PR runs and feature
397
+ # branches must NOT mutate creds/ (they'd race the default branch
398
+ # commit and clutter PR diffs). The signing identity is a long-lived
399
+ # artifact owned by the canonical branch.
400
+ if: ${{ inputs.archive == 'true'
401
+ && github.event_name == 'push'
402
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
403
+ shell: bash
404
+ working-directory: ${{ github.workspace }}
405
+ run: |
406
+ set -euo pipefail
407
+ # Default checkout is shallow (fetch-depth=1). Pushing back to the
408
+ # default branch needs a full ref so git can rebase on top of any
409
+ # commits that landed since checkout — promote to a full clone.
410
+ # Either form may fail on already-deep checkouts; ignore non-zero.
411
+ git fetch --unshallow 2>/dev/null \
412
+ || git fetch --depth=2147483647 2>/dev/null \
413
+ || true
414
+ git config user.name 'github-actions[bot]'
415
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
416
+ # Stage ONLY the specific files prepare_signing.py manages — never
417
+ # `creds/` wholesale. The user's ASC .p8 key is already tracked
418
+ # (committed separately at setup) so `git add` of new files leaves
419
+ # it alone, but `creds/` may also hold notes, build artifacts, or
420
+ # other unmanaged files we MUST NOT commit. Each `git add -f`
421
+ # bypasses any consumer .gitignore that hides creds/. Missing
422
+ # files (shouldn't happen given step ordering, but defensive)
423
+ # produce a non-zero exit which we swallow — the commit-step is
424
+ # the source of truth for "is there anything to commit".
425
+ git add -f -- creds/cert.p12 2>/dev/null || true
426
+ git add -f -- creds/cert.meta.json 2>/dev/null || true
427
+ git add -f -- creds/profiles.manifest.json 2>/dev/null || true
428
+ # The whole profiles/ directory is action-managed (one
429
+ # .mobileprovision per signable bundle, GC'd when bundles are
430
+ # removed); staging the directory picks up adds + removes in one
431
+ # shot. Force-flag overrides any creds/profiles/ ignore rule.
432
+ git add -f -- creds/profiles/ 2>/dev/null || true
433
+ # Marketing-version auto-bump: NOTHING TO DO HERE.
434
+ # version_utils.write_marketing_version() does an immediate
435
+ # `git add -f -- <path>` right after the write, which captures
436
+ # the post-bump, pre-prepare_signing state in the index.
437
+ # prepare_signing.py later mutates the working-tree pbxproj
438
+ # for manual-signing settings, but those edits land only in
439
+ # the working tree -- the index keeps the version bump alone.
440
+ # The marker at $RUNNER_TEMP/swift-app-mv-bumped is for log /
441
+ # diagnostics; we surface it here so CI logs show exactly
442
+ # which file mmv touched.
443
+ bump_marker="$RUNNER_TEMP/swift-app-mv-bumped"
444
+ if [ -f "$bump_marker" ]; then
445
+ echo "auto-bump: marker present; the following file(s) were"
446
+ echo "auto-bumped and pre-staged by version_utils:"
447
+ sed 's/^/ /' "$bump_marker"
448
+ fi
449
+
450
+ - name: Check for app-ci update
451
+ # Same gate as cert staging: default branch + push event only.
452
+ # `auto-update` is on by default; consumers can pin via `false`.
453
+ # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP) so its own `npx`
454
+ # overwriting ${action_path} mid-run can't swap the script under us.
455
+ if: ${{ inputs.auto-update == 'true'
456
+ && github.event_name == 'push'
457
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
458
+ shell: bash
459
+ working-directory: ${{ github.workspace }}
460
+ run: bash "$BOT_SCRIPTS_DIR/autoupdate_check.sh"
461
+
462
+ - name: Commit + push staged bot changes
463
+ # Fires when EITHER cert staging OR autoupdate could have produced
464
+ # diffs. The script itself is a no-op when nothing is staged, so a
465
+ # PR / feature-branch run that somehow reaches this step does no
466
+ # harm — but we still gate on default-branch push for clarity.
467
+ # Runs from $BOT_SCRIPTS_DIR (RUNNER_TEMP) — see step above for why
468
+ # we don't read directly from ${action_path} after autoupdate.
469
+ if: ${{ (inputs.archive == 'true' || inputs.auto-update == 'true')
470
+ && github.event_name == 'push'
471
+ && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
472
+ shell: bash
473
+ working-directory: ${{ github.workspace }}
474
+ env:
475
+ GITHUB_REF_NAME: ${{ github.ref_name }}
476
+ run: bash "$BOT_SCRIPTS_DIR/commit_bot_changes.sh"
477
+
478
+ - name: Stamp Info.plist versions (CFBundleVersion + CFBundleShortVersionString)
479
+ if: ${{ inputs.archive == 'true' }}
480
+ shell: bash
481
+ env:
482
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
483
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
484
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
485
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
486
+ run: |
487
+ set -o pipefail
488
+ # Some Xcode projects hardcode CFBundleVersion / CFBundleShortVersionString
489
+ # as literal values in Info.plist instead of using $(CURRENT_PROJECT_VERSION)
490
+ # and $(MARKETING_VERSION) substitution. When that's the case, passing
491
+ # CURRENT_PROJECT_VERSION=N on the xcodebuild CLI has no effect on the
492
+ # archived plist and altool rejects the upload with
493
+ # `previousBundleVersion`. Stamp the resolved source Info.plist directly
494
+ # so the archive ships with the computed values regardless of whether
495
+ # the plist uses substitutions or hardcoded literals.
496
+ if [ -n "$WORKSPACE" ]; then
497
+ PROJ_ARGS=(-workspace "$WORKSPACE")
498
+ PROJ_BASE=$(dirname "$WORKSPACE")
499
+ else
500
+ PROJ_ARGS=(-project "$PROJECT")
501
+ PROJ_BASE=$(dirname "$PROJECT")
502
+ fi
503
+ # Try agvtool first — it's Apple's official tool and handles projects
504
+ # using the generic versioning system. Harmless no-op on projects
505
+ # without VERSIONING_SYSTEM set. Run from the project directory.
506
+ # Marketing version is intentionally NOT stamped here: the project's
507
+ # MARKETING_VERSION build setting is the single source of truth and
508
+ # was already resolved into env earlier; CI must never mutate it.
509
+ (
510
+ cd "$PROJ_BASE" 2>/dev/null || exit 0
511
+ xcrun agvtool new-version -all "$BUILD_NUMBER" >/dev/null 2>&1 || true
512
+ )
513
+ # Now resolve the actual Info.plist path and stamp literal values.
514
+ # This is idempotent with agvtool and also covers projects where the
515
+ # plist has hardcoded numbers (the sample1 failure mode).
516
+ INFOPLIST=$(xcodebuild "${PROJ_ARGS[@]}" -scheme "$SCHEME" -configuration "${CONFIGURATION:-Release}" \
517
+ -showBuildSettings 2>/dev/null \
518
+ | awk -F' = ' '/^[[:space:]]*INFOPLIST_FILE/ {
519
+ gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit
520
+ }')
521
+ if [ -z "$INFOPLIST" ]; then
522
+ echo "::warning::INFOPLIST_FILE not set for scheme $SCHEME; skipping version stamping"
523
+ exit 0
524
+ fi
525
+ INFOPLIST_PATH="$PROJ_BASE/$INFOPLIST"
526
+ if [ ! -f "$INFOPLIST_PATH" ]; then
527
+ INFOPLIST_PATH="$INFOPLIST"
528
+ fi
529
+ if [ ! -f "$INFOPLIST_PATH" ]; then
530
+ echo "::warning::Info.plist not found at $INFOPLIST_PATH; skipping version stamping"
531
+ exit 0
532
+ fi
533
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFOPLIST_PATH" 2>/dev/null \
534
+ || /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$INFOPLIST_PATH"
535
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$INFOPLIST_PATH" 2>/dev/null \
536
+ || /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $MARKETING_VERSION" "$INFOPLIST_PATH"
537
+ echo "Stamped CFBundleVersion=$BUILD_NUMBER CFBundleShortVersionString=$MARKETING_VERSION in $INFOPLIST_PATH"
538
+
539
+ - name: Declare ITSAppUsesNonExemptEncryption in Info.plist
540
+ if: ${{ inputs.archive == 'true' && (inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT) != '' }}
541
+ shell: bash
542
+ env:
543
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
544
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
545
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
546
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
547
+ VALUE: ${{ inputs.uses-non-exempt-encryption || env.CFG_USES_NON_EXEMPT }}
548
+ run: |
549
+ set -o pipefail
550
+ if [ -n "$WORKSPACE" ]; then
551
+ PROJ_ARGS=(-workspace "$WORKSPACE")
552
+ PROJ_BASE=$(dirname "$WORKSPACE")
553
+ else
554
+ PROJ_ARGS=(-project "$PROJECT")
555
+ PROJ_BASE=$(dirname "$PROJECT")
556
+ fi
557
+ INFOPLIST=$(xcodebuild "${PROJ_ARGS[@]}" -scheme "$SCHEME" -configuration "${CONFIGURATION:-Release}" \
558
+ -showBuildSettings 2>/dev/null \
559
+ | awk -F' = ' '/^[[:space:]]*INFOPLIST_FILE/ {
560
+ gsub(/^[[:space:]]+|[[:space:]]+$/,"",$2); print $2; exit
561
+ }')
562
+ if [ -z "$INFOPLIST" ]; then
563
+ echo "::warning::INFOPLIST_FILE not set for scheme $SCHEME; skipping ITSAppUsesNonExemptEncryption"
564
+ exit 0
565
+ fi
566
+ INFOPLIST_PATH="$PROJ_BASE/$INFOPLIST"
567
+ if [ ! -f "$INFOPLIST_PATH" ]; then
568
+ INFOPLIST_PATH="$INFOPLIST"
569
+ fi
570
+ if [ ! -f "$INFOPLIST_PATH" ]; then
571
+ echo "::warning::Info.plist not found at $INFOPLIST_PATH; skipping"
572
+ exit 0
573
+ fi
574
+ /usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption $VALUE" "$INFOPLIST_PATH" 2>/dev/null \
575
+ || /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool $VALUE" "$INFOPLIST_PATH"
576
+ echo "Set ITSAppUsesNonExemptEncryption=$VALUE in $INFOPLIST_PATH"
577
+
578
+ - name: Archive
579
+ if: ${{ inputs.archive == 'true' }}
580
+ shell: bash
581
+ env:
582
+ PROJECT: ${{ inputs.project || env.CFG_PROJECT }}
583
+ WORKSPACE: ${{ inputs.workspace || env.CFG_WORKSPACE }}
584
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
585
+ CONFIGURATION: ${{ inputs.configuration || env.CFG_CONFIGURATION }}
586
+ run: |
587
+ set -o pipefail
588
+ if [ -n "$WORKSPACE" ]; then
589
+ PROJ_ARGS=(-workspace "$WORKSPACE")
590
+ else
591
+ PROJ_ARGS=(-project "$PROJECT")
592
+ fi
593
+ # Manual signing. prepare_signing.py patched each signable target's
594
+ # build settings in-place (CODE_SIGN_STYLE=Manual,
595
+ # PROVISIONING_PROFILE_SPECIFIER=CI-<bundle_id>, etc) so xcodebuild
596
+ # just honors what's in the pbxproj. SwiftPM / resource-bundle
597
+ # targets are untouched and keep their default (no-sign) config.
598
+ # Speed: the index store is IDE-only overhead, and SwiftPM plugin/macro
599
+ # validation prompts have no place on a throwaway CI runner.
600
+ xcodebuild \
601
+ "${PROJ_ARGS[@]}" \
602
+ -scheme "$SCHEME" \
603
+ -configuration "$CONFIGURATION" \
604
+ -destination "generic/platform=iOS" \
605
+ -archivePath "$RUNNER_TEMP/app.xcarchive" \
606
+ -skipPackagePluginValidation \
607
+ -skipMacroValidation \
608
+ CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
609
+ MARKETING_VERSION="$MARKETING_VERSION" \
610
+ COMPILER_INDEX_STORE_ENABLE=NO \
611
+ archive
612
+
613
+ - name: Export IPA
614
+ if: ${{ inputs.archive == 'true' }}
615
+ shell: bash
616
+ run: |
617
+ set -o pipefail
618
+ # Manual signing: re-sign every target against its CI-<bundle_id>
619
+ # profile. exportArchive requires a full provisioningProfiles map
620
+ # in ExportOptions.plist for any entitlement that triggers
621
+ # validation (Personal VPN, NetworkExtension, Widgets, etc).
622
+ # signing_map.json also carries the ASC API key's effective team,
623
+ # which is what the profiles were actually issued under.
624
+ python3 - <<'PY'
625
+ import json
626
+ import os
627
+ from pathlib import Path
628
+ runner_temp = os.environ["RUNNER_TEMP"]
629
+ data = json.loads(
630
+ Path(runner_temp, "signing_map.json").read_text()
631
+ )
632
+ team_id = data["team_id"]
633
+ mapping = data["profiles"]
634
+ profile_entries = "".join(
635
+ f" <key>{bid}</key><string>{name}</string>\n"
636
+ for bid, name in mapping.items()
637
+ )
638
+ plist = f"""<?xml version="1.0" encoding="UTF-8"?>
639
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
640
+ <plist version="1.0">
641
+ <dict>
642
+ <key>method</key><string>app-store-connect</string>
643
+ <key>destination</key><string>export</string>
644
+ <key>teamID</key><string>{team_id}</string>
645
+ <key>signingStyle</key><string>manual</string>
646
+ <key>stripSwiftSymbols</key><true/>
647
+ <key>uploadSymbols</key><true/>
648
+ <key>provisioningProfiles</key>
649
+ <dict>
650
+ {profile_entries} </dict>
651
+ </dict>
652
+ </plist>
653
+ """
654
+ Path(runner_temp, "ExportOptions.plist").write_text(plist)
655
+ print(plist)
656
+ PY
657
+ xcodebuild \
658
+ -exportArchive \
659
+ -archivePath "$RUNNER_TEMP/app.xcarchive" \
660
+ -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" \
661
+ -exportPath "$RUNNER_TEMP/export"
662
+ ls -la "$RUNNER_TEMP/export"
663
+
664
+ - name: Upload to TestFlight
665
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' }}
666
+ shell: bash
667
+ run: |
668
+ IPA=$(ls "$RUNNER_TEMP"/export/*.ipa | head -1)
669
+ echo "Uploading $IPA"
670
+ LOG="$RUNNER_TEMP/altool.log"
671
+ set +e
672
+ xcrun altool --upload-app \
673
+ --type ios \
674
+ --file "$IPA" \
675
+ --apiKey "$ASC_KEY_ID" \
676
+ --apiIssuer "$ASC_ISSUER_ID" \
677
+ --output-format normal 2>&1 | tee "$LOG"
678
+ ALTOOL_RC=${PIPESTATUS[0]}
679
+ set -e
680
+ # altool occasionally exits 0 even on failure; verify the final outcome marker.
681
+ # Apple's altool prints transient "ERROR:" lines for retried network blips
682
+ # before emitting "UPLOAD SUCCEEDED" — don't false-positive on those.
683
+ if [ "$ALTOOL_RC" -ne 0 ] \
684
+ || grep -qE '^Failed to upload|ERROR ITMS-' "$LOG" \
685
+ || ! grep -qE 'UPLOAD SUCCEEDED|No errors uploading archive' "$LOG"; then
686
+ echo "altool reported an upload failure (rc=$ALTOOL_RC)"
687
+ exit 1
688
+ fi
689
+ echo "Upload succeeded"
690
+
691
+ - name: Detect empty ASC metadata fields
692
+ id: detect_metadata
693
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true' && inputs.ai-metadata != 'false' }}
694
+ shell: bash
695
+ env:
696
+ APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
697
+ APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
698
+ run: |
699
+ set -o pipefail
700
+ python3 "${{ github.action_path }}/scripts/asc_metadata_detector.py" \
701
+ > "$RUNNER_TEMP/asc_empty_metadata.json" || {
702
+ echo "::warning::metadata detector failed; skipping AI step"
703
+ echo "has_empty=false" >> "$GITHUB_OUTPUT"
704
+ exit 0
705
+ }
706
+ HAS_EMPTY=$(python3 -c \
707
+ "import json,sys; d=json.load(open(sys.argv[1])); print('true' if d.get('empty_fields') else 'false')" \
708
+ "$RUNNER_TEMP/asc_empty_metadata.json")
709
+ LOCALES=$(python3 -c \
710
+ "import json,sys; d=json.load(open(sys.argv[1])); print(','.join(sorted(d.get('locales',[]))))" \
711
+ "$RUNNER_TEMP/asc_empty_metadata.json")
712
+ echo "has_empty=$HAS_EMPTY" >> "$GITHUB_OUTPUT"
713
+ echo "locales=$LOCALES" >> "$GITHUB_OUTPUT"
714
+ echo "AI metadata detector: has_empty=$HAS_EMPTY locales=[$LOCALES]"
715
+
716
+ - name: Scan repo for AI prompt context
717
+ if: ${{ steps.detect_metadata.outputs.has_empty == 'true' }}
718
+ shell: bash
719
+ env:
720
+ BUNDLE_ID: ${{ inputs.bundle-id || env.CFG_BUNDLE_ID }}
721
+ SCHEME: ${{ inputs.scheme || env.CFG_SCHEME }}
722
+ run: |
723
+ python3 "${{ github.action_path }}/scripts/app_context_scanner.py" \
724
+ > "$RUNNER_TEMP/app_context.txt"
725
+ wc -c "$RUNNER_TEMP/app_context.txt"
726
+
727
+ - name: Split empty_fields into Phase-1 (description) and Phase-2 (dependents)
728
+ id: derive_phases
729
+ if: ${{ steps.detect_metadata.outputs.has_empty == 'true' }}
730
+ shell: bash
731
+ run: |
732
+ # description is authoritative context for the dependent fields, so it
733
+ # is generated first (Phase 1) and injected into the Phase-2 prompt.
734
+ set -euo pipefail
735
+ python3 -m pip install --quiet --break-system-packages PyYAML
736
+ python3 - <<'PY'
737
+ import json, os, pathlib
738
+ temp = pathlib.Path(os.environ['RUNNER_TEMP'])
739
+ state = json.loads((temp / 'asc_empty_metadata.json').read_text())
740
+ empty = state.get('empty_fields') or {}
741
+ locales_needing_desc = sorted(loc for loc, fields in empty.items() if 'description' in fields)
742
+ non_desc = {loc: [f for f in fields if f != 'description'] for loc, fields in empty.items()}
743
+ non_desc = {loc: fields for loc, fields in non_desc.items() if fields}
744
+ (temp / 'locales_needing_description.json').write_text(json.dumps(locales_needing_desc))
745
+ (temp / 'empty_fields_excl_desc.json').write_text(json.dumps(non_desc))
746
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
747
+ fh.write(f"needs_phase1={'true' if locales_needing_desc else 'false'}\n")
748
+ fh.write(f"needs_phase2={'true' if non_desc else 'false'}\n")
749
+ print(f"Phase-1 locales needing description: {locales_needing_desc}")
750
+ print(f"Phase-2 non-description empty fields: {non_desc}")
751
+ PY
752
+
753
+ - name: "Phase 1: Render descriptions prompt"
754
+ if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
755
+ shell: bash
756
+ env:
757
+ TEMPLATE_PATH: ${{ github.action_path }}/prompts/generate_descriptions.prompt.yml
758
+ run: |
759
+ # Pre-render via PyYAML so multi-line app_context stays a block scalar
760
+ # and doesn't break ai-inference@v1's naive string substitution.
761
+ set -euo pipefail
762
+ python3 - <<'PY'
763
+ import os, pathlib, yaml
764
+ tmpl = yaml.safe_load(pathlib.Path(os.environ['TEMPLATE_PATH']).read_text())
765
+ temp = pathlib.Path(os.environ['RUNNER_TEMP'])
766
+ app_context = (temp / 'app_context.txt').read_text()
767
+ locales_json = (temp / 'locales_needing_description.json').read_text().strip()
768
+ for msg in tmpl.get('messages', []):
769
+ c = msg.get('content')
770
+ if isinstance(c, str):
771
+ msg['content'] = (
772
+ c.replace('{{app_context}}', app_context)
773
+ .replace('{{locales_needing_description}}', locales_json)
774
+ )
775
+ out = temp / 'rendered_phase1.yml'
776
+ out.write_text(yaml.safe_dump(tmpl, sort_keys=False, allow_unicode=True, default_flow_style=False))
777
+ print(f"Phase-1 prompt rendered -> {out} ({out.stat().st_size} bytes)")
778
+ PY
779
+
780
+ - name: "Phase 1: Generate descriptions via AI"
781
+ id: ai_phase1
782
+ if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
783
+ # AI metadata is best-effort: a model API error (413 request-too-large,
784
+ # rate limit, outage) must never fail a build whose TestFlight upload
785
+ # already succeeded. The apply step below tolerates an empty response.
786
+ continue-on-error: true
787
+ uses: actions/ai-inference@v1
788
+ with:
789
+ prompt-file: ${{ runner.temp }}/rendered_phase1.yml
790
+ model: ${{ inputs.ai-metadata-model }}
791
+ max-tokens: 16000
792
+
793
+ - name: "Phase 1: Apply AI-generated descriptions"
794
+ id: phase1_apply
795
+ if: ${{ steps.derive_phases.outputs.needs_phase1 == 'true' }}
796
+ shell: bash
797
+ env:
798
+ APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
799
+ APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
800
+ AI_RESPONSE: ${{ steps.ai_phase1.outputs.response }}
801
+ run: |
802
+ # Fence-strip + validate + apply description only. On parse failure
803
+ # emit phase1_ok=false so Phase-2 skips (architect §7: never regress
804
+ # to empty description by running dependents without a seed).
805
+ set -o pipefail
806
+ RAW="$RUNNER_TEMP/ai_descriptions.raw"
807
+ OUT="$RUNNER_TEMP/ai_descriptions.json"
808
+ printf '%s' "$AI_RESPONSE" > "$RAW"
809
+ echo "--- Phase-1 AI response: $(wc -c < "$RAW") bytes; first 200 chars ---"
810
+ head -c 200 "$RAW"; echo
811
+ echo "--- end preview ---"
812
+ python3 - "$RAW" "$OUT" <<'PY'
813
+ import pathlib, re, sys
814
+ src, dst = sys.argv[1], sys.argv[2]
815
+ text = pathlib.Path(src).read_text(encoding="utf-8").strip()
816
+ m = re.match(r"^```(?:json|yaml)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
817
+ if m:
818
+ text = m.group(1).strip()
819
+ pathlib.Path(dst).write_text(text, encoding="utf-8")
820
+ PY
821
+ if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$OUT"; then
822
+ echo "::warning::Phase-1 AI output not valid JSON; skipping Phase-2 to avoid empty-description regression"
823
+ head -c 500 "$OUT" || true
824
+ echo
825
+ echo "phase1_ok=false" >> "$GITHUB_OUTPUT"
826
+ exit 0
827
+ fi
828
+ python3 "${{ github.action_path }}/scripts/asc_metadata_applier.py" \
829
+ --state "$RUNNER_TEMP/asc_empty_metadata.json" \
830
+ --response "$OUT" \
831
+ --fields-filter description
832
+ echo "phase1_ok=true" >> "$GITHUB_OUTPUT"
833
+
834
+ - name: "Phase 2: Render dependent-fields prompt"
835
+ if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
836
+ && (steps.derive_phases.outputs.needs_phase1 != 'true'
837
+ || steps.phase1_apply.outputs.phase1_ok == 'true') }}
838
+ shell: bash
839
+ env:
840
+ TEMPLATE_PATH: ${{ github.action_path }}/prompts/generate_dependent_fields.prompt.yml
841
+ run: |
842
+ # Merge existing_fields.{locale}.description with Phase-1 outputs to
843
+ # produce authoritative per-locale description context, then render
844
+ # the Phase-2 prompt with empty_fields_excl_desc.
845
+ set -euo pipefail
846
+ python3 - <<'PY'
847
+ import json, os, pathlib, sys, yaml
848
+ temp = pathlib.Path(os.environ['RUNNER_TEMP'])
849
+ state = json.loads((temp / 'asc_empty_metadata.json').read_text())
850
+ descriptions = {
851
+ loc: (fields.get('description') or '')
852
+ for loc, fields in (state.get('existing_fields') or {}).items()
853
+ }
854
+ ai_path = temp / 'ai_descriptions.json'
855
+ if ai_path.exists():
856
+ try:
857
+ ai_data = json.loads(ai_path.read_text())
858
+ for loc, fields in (ai_data.get('localizations') or {}).items():
859
+ desc = (fields or {}).get('description')
860
+ if desc:
861
+ descriptions[loc] = desc
862
+ except json.JSONDecodeError as e:
863
+ print(
864
+ f"::warning::ignoring corrupt {ai_path.name}: {e}; "
865
+ f"falling back to existing descriptions",
866
+ file=sys.stderr,
867
+ )
868
+ descriptions = {loc: d for loc, d in descriptions.items() if d}
869
+ (temp / 'descriptions_by_locale.json').write_text(json.dumps(descriptions, ensure_ascii=False))
870
+ tmpl = yaml.safe_load(pathlib.Path(os.environ['TEMPLATE_PATH']).read_text())
871
+ app_context = (temp / 'app_context.txt').read_text()
872
+ empty_excl = (temp / 'empty_fields_excl_desc.json').read_text().strip()
873
+ descriptions_json = json.dumps(descriptions, ensure_ascii=False)
874
+ for msg in tmpl.get('messages', []):
875
+ c = msg.get('content')
876
+ if isinstance(c, str):
877
+ msg['content'] = (
878
+ c.replace('{{app_context}}', app_context)
879
+ .replace('{{descriptions_by_locale}}', descriptions_json)
880
+ .replace('{{empty_fields}}', empty_excl)
881
+ )
882
+ out = temp / 'rendered_phase2.yml'
883
+ out.write_text(yaml.safe_dump(tmpl, sort_keys=False, allow_unicode=True, default_flow_style=False))
884
+ print(f"Phase-2 prompt rendered -> {out} ({out.stat().st_size} bytes) with {len(descriptions)} descriptions")
885
+ PY
886
+
887
+ - name: "Phase 2: Generate dependent fields via AI"
888
+ id: ai_phase2
889
+ if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
890
+ && (steps.derive_phases.outputs.needs_phase1 != 'true'
891
+ || steps.phase1_apply.outputs.phase1_ok == 'true') }}
892
+ # Best-effort (see Phase 1): never fail the build on a model API error.
893
+ continue-on-error: true
894
+ uses: actions/ai-inference@v1
895
+ with:
896
+ prompt-file: ${{ runner.temp }}/rendered_phase2.yml
897
+ model: ${{ inputs.ai-metadata-model }}
898
+ max-tokens: 16000
899
+
900
+ - name: "Phase 2: Apply AI-generated dependent fields"
901
+ if: ${{ steps.derive_phases.outputs.needs_phase2 == 'true'
902
+ && (steps.derive_phases.outputs.needs_phase1 != 'true'
903
+ || steps.phase1_apply.outputs.phase1_ok == 'true') }}
904
+ shell: bash
905
+ env:
906
+ APP_STORE_APPLE_ID: ${{ inputs.app-store-apple-id || env.CFG_APP_STORE_APPLE_ID }}
907
+ APP_STORE_VERSION_ID: ${{ env.APP_STORE_VERSION_ID }}
908
+ AI_RESPONSE: ${{ steps.ai_phase2.outputs.response }}
909
+ run: |
910
+ # Fence-strip + validate + apply filtered to dependent fields only;
911
+ # description is never overwritten in Phase-2.
912
+ set -o pipefail
913
+ RAW="$RUNNER_TEMP/ai_dependent.raw"
914
+ OUT="$RUNNER_TEMP/ai_dependent.json"
915
+ printf '%s' "$AI_RESPONSE" > "$RAW"
916
+ echo "--- Phase-2 AI response: $(wc -c < "$RAW") bytes; first 200 chars ---"
917
+ head -c 200 "$RAW"; echo
918
+ echo "--- end preview ---"
919
+ python3 - "$RAW" "$OUT" <<'PY'
920
+ import pathlib, re, sys
921
+ src, dst = sys.argv[1], sys.argv[2]
922
+ text = pathlib.Path(src).read_text(encoding="utf-8").strip()
923
+ m = re.match(r"^```(?:json|yaml)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
924
+ if m:
925
+ text = m.group(1).strip()
926
+ pathlib.Path(dst).write_text(text, encoding="utf-8")
927
+ PY
928
+ if ! python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$OUT"; then
929
+ echo "::warning::Phase-2 AI output not valid JSON after fence strip; applier will fail-open"
930
+ head -c 500 "$OUT" || true
931
+ echo
932
+ fi
933
+ python3 "${{ github.action_path }}/scripts/asc_metadata_applier.py" \
934
+ --state "$RUNNER_TEMP/asc_empty_metadata.json" \
935
+ --response "$OUT" \
936
+ --fields-filter name,subtitle,keywords,promotionalText,whatsNew
937
+
938
+ # Runs unconditionally after the AI-metadata phase as a whatsNew backstop.
939
+ # An empty "What's New" makes a version un-submittable (ASC: "This field
940
+ # is required" on every locale). Previously this step was skipped whenever
941
+ # metadata was empty, deferring entirely to the AI phase — so when the AI
942
+ # call failed (e.g. a 413 on an app with 35 locales) every locale shipped
943
+ # blank. Now it always runs: with an explicit `app-store-whats-new` input
944
+ # it overwrites all locales; otherwise it fills ONLY still-empty locales
945
+ # with the default (CFG_WHATS_NEW), preserving any AI-generated notes.
946
+ - name: Set App Store "What's New"
947
+ if: ${{ inputs.archive == 'true' && inputs.upload == 'true'
948
+ && (inputs.app-store-whats-new != '' || env.CFG_WHATS_NEW != '') }}
949
+ shell: bash
950
+ env:
951
+ # Pass the whatsNew path, not the content. GitHub Actions ${{ }} YAML
952
+ # substitution can mangle multi-line strings; a file path is a
953
+ # single-line string that survives interpolation untouched. When the
954
+ # action caller supplies `app-store-whats-new` directly, we write it
955
+ # to a temp file here (still inside the run: block, so no ${{ }}
956
+ # re-interpolation of the multi-line value).
957
+ INPUT_WHATS_NEW: ${{ inputs.app-store-whats-new }}
958
+ APP_STORE_WHATS_NEW_FILE: ${{ env.CFG_WHATS_NEW_FILE }}
959
+ APP_STORE_LOCALE: ${{ inputs.app-store-locale || env.CFG_LOCALE }}
960
+ run: |
961
+ set -o pipefail
962
+ # No explicit caller text -> backstop mode: only fill locales whose
963
+ # whatsNew is still blank (don't clobber AI output). An explicit
964
+ # app-store-whats-new input forces the text onto every locale.
965
+ WHATS_NEW_ONLY_IF_EMPTY=true
966
+ if [ -n "$INPUT_WHATS_NEW" ]; then
967
+ INPUT_FILE="$RUNNER_TEMP/whats_new_input.txt"
968
+ printf '%s' "$INPUT_WHATS_NEW" > "$INPUT_FILE"
969
+ APP_STORE_WHATS_NEW_FILE="$INPUT_FILE"
970
+ WHATS_NEW_ONLY_IF_EMPTY=false
971
+ fi
972
+ export APP_STORE_WHATS_NEW_FILE WHATS_NEW_ONLY_IF_EMPTY
973
+ python3 "${{ github.action_path }}/scripts/set_app_store_whats_new.py"