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.
- package/CLAUDE.md +94 -0
- package/README.md +417 -0
- package/action/.daemux-version +1 -0
- package/action/MIGRATION.md +232 -0
- package/action/action.yml +973 -0
- package/action/prompts/generate_dependent_fields.prompt.yml +78 -0
- package/action/prompts/generate_descriptions.prompt.yml +59 -0
- package/action/scripts/app_context_scanner.py +239 -0
- package/action/scripts/asc_build_history.py +284 -0
- package/action/scripts/asc_common.py +246 -0
- package/action/scripts/asc_metadata_applier.py +368 -0
- package/action/scripts/asc_metadata_detector.py +250 -0
- package/action/scripts/asc_version_create.py +365 -0
- package/action/scripts/asc_version_fetch.py +274 -0
- package/action/scripts/asc_version_reuse.py +177 -0
- package/action/scripts/auto_detect.py +391 -0
- package/action/scripts/autoupdate_check.sh +118 -0
- package/action/scripts/cert_factory.py +190 -0
- package/action/scripts/cfg_io.py +27 -0
- package/action/scripts/cfg_resolve.py +147 -0
- package/action/scripts/commit_bot_changes.sh +82 -0
- package/action/scripts/creds_store.py +328 -0
- package/action/scripts/keychain.py +103 -0
- package/action/scripts/lookup_app_id.py +51 -0
- package/action/scripts/manage_marketing_version.py +337 -0
- package/action/scripts/metadata_constants.py +102 -0
- package/action/scripts/mmv_decide_create.py +181 -0
- package/action/scripts/mmv_floor_check.py +265 -0
- package/action/scripts/next_build_number.py +195 -0
- package/action/scripts/pbxproj_editor.py +220 -0
- package/action/scripts/prepare_signing.py +223 -0
- package/action/scripts/profile_io.py +64 -0
- package/action/scripts/profile_manager.py +307 -0
- package/action/scripts/read_config.py +315 -0
- package/action/scripts/resolve_marketing_version.py +173 -0
- package/action/scripts/set_app_store_whats_new.py +317 -0
- package/action/scripts/team_resolver.py +130 -0
- package/action/scripts/test_app_context_scanner.py +167 -0
- package/action/scripts/test_asc_build_history.py +221 -0
- package/action/scripts/test_asc_builds_prerelease.py +219 -0
- package/action/scripts/test_asc_common_timeouts.py +121 -0
- package/action/scripts/test_asc_metadata_applier.py +850 -0
- package/action/scripts/test_asc_metadata_detector.py +445 -0
- package/action/scripts/test_auto_detect.py +432 -0
- package/action/scripts/test_cfg_resolve_credentials.py +48 -0
- package/action/scripts/test_compute_next_version.py +95 -0
- package/action/scripts/test_compute_next_version_patch_backcompat.py +41 -0
- package/action/scripts/test_compute_next_version_strictness.py +67 -0
- package/action/scripts/test_fetch_versions.py +163 -0
- package/action/scripts/test_ground_truth_floor.py +104 -0
- package/action/scripts/test_manage_marketing_version.py +134 -0
- package/action/scripts/test_manage_marketing_version_autoroll.py +149 -0
- package/action/scripts/test_manage_marketing_version_floor.py +170 -0
- package/action/scripts/test_manage_marketing_version_match.py +187 -0
- package/action/scripts/test_mmv_409_classifier.py +231 -0
- package/action/scripts/test_mmv_autobump_persist_gate.py +131 -0
- package/action/scripts/test_mmv_decide_create.py +119 -0
- package/action/scripts/test_mmv_floor_check.py +158 -0
- package/action/scripts/test_mmv_floor_crosscheck.py +132 -0
- package/action/scripts/test_mmv_helpers.py +57 -0
- package/action/scripts/test_next_build_number.py +174 -0
- package/action/scripts/test_read_config_auto_detect.py +257 -0
- package/action/scripts/test_resolve_marketing_version.py +298 -0
- package/action/scripts/test_reuse_stale_editable.py +182 -0
- package/action/scripts/test_set_app_store_whats_new.py +71 -0
- package/action/scripts/test_team_fallback_wiring.py +157 -0
- package/action/scripts/test_team_resolver.py +249 -0
- package/action/scripts/tests_common.py +167 -0
- package/action/scripts/version_utils.py +373 -0
- package/android-action/.daemux-version +1 -0
- package/android-action/action.yml +92 -0
- package/android-action/scripts/android_config.py +130 -0
- package/android-action/scripts/bitrise_deploy.py +160 -0
- package/android-action/scripts/find_bundle.py +25 -0
- package/android-action/scripts/play_preflight.py +69 -0
- package/android-action/scripts/resolve_android.py +70 -0
- package/android-action/scripts/sign_bundle.py +70 -0
- package/android-action/scripts/test_android_config.py +97 -0
- package/android-action/scripts/test_bitrise_deploy.py +124 -0
- package/android-action/scripts/test_sign_bundle.py +82 -0
- package/bin/cli.mjs +63 -0
- package/package.json +57 -0
- package/src/install.mjs +208 -0
- 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"
|