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,315 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Discover ASC API key in creds/ + auto-detect Xcode project/scheme/bundle,
4
+ then emit derived CI environment variables to $GITHUB_ENV.
5
+
6
+ There is NO config file. This script reads only:
7
+ - INPUT_* env vars (the composite action's `with:` overrides)
8
+ - creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8 (the only required file)
9
+ - the Xcode project (via auto_detect.py + xcodebuild)
10
+ - the App Store Connect API (for team_id and app_store_apple_id)
11
+
12
+ Precedence for every CFG_* value:
13
+ 1. INPUT_* env var (composite action input)
14
+ 2. Auto-detected value (xcodebuild, ASC API, glob)
15
+ 3. Built-in default ("Release", "false", "en-US", whatsNew boilerplate)
16
+ 4. Empty string (downstream step skips or applies its own fallback)
17
+
18
+ Built-in defaults emitted when nothing else applies:
19
+ CFG_CONFIGURATION=Release, CFG_USES_NON_EXEMPT=false,
20
+ CFG_RUN_TESTS=false, CFG_LOCALE=en-US,
21
+ CFG_PROFILE_NAME="<scheme> CI", CFG_WHATS_NEW=<boilerplate>.
22
+
23
+ The ASC .p8 key is located by globbing ``creds/AuthKey_*.p8`` with filename
24
+ regex AuthKey_<KEY_ID>[_Issuer_<ISSUER_UUID>].p8. If multiple match, we fail
25
+ with an instruction to remove the unwanted one(s) — there is no longer a
26
+ config-based disambiguation channel.
27
+
28
+ Writes to $GITHUB_ENV:
29
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH,
30
+ CFG_PROJECT, CFG_WORKSPACE, CFG_SCHEME, CFG_CONFIGURATION,
31
+ CFG_BUNDLE_ID, CFG_TEAM_ID, CFG_APP_STORE_APPLE_ID, CFG_PROFILE_NAME,
32
+ CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_WHATS_NEW_FILE, CFG_LOCALE,
33
+ CFG_RUN_TESTS, CFG_TEST_COMMAND, CFG_TEST_DESTINATION
34
+
35
+ CFG_WHATS_NEW_FILE points at a file under $RUNNER_TEMP holding the raw
36
+ multi-line release notes. Downstream steps MUST prefer the file over the
37
+ env-var copy: GitHub Actions YAML's `${{ }}` substitution serializes
38
+ multi-line strings in ways that can mangle embedded newlines, while a file
39
+ path is a single-line string that survives interpolation untouched.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ from pathlib import Path
46
+
47
+ import auto_detect
48
+ from cfg_io import fail, log
49
+ from cfg_resolve import (
50
+ derive_team_if_empty,
51
+ emit,
52
+ find_p8,
53
+ lookup_app_id_via_api,
54
+ )
55
+
56
+
57
+ DEFAULT_WHATS_NEW = (
58
+ "- Improved performance: faster, smoother app experience\n"
59
+ "- Bug fixes: minor issues resolved for seamless use\n"
60
+ "- Enhanced security: updated to protect your data\n"
61
+ )
62
+
63
+
64
+ def _pick(input_val: str, *, auto_val: str = "", default: str = "") -> tuple[str, str]:
65
+ """Apply precedence: input > auto > default > empty."""
66
+ if input_val:
67
+ return input_val, "input"
68
+ if auto_val:
69
+ return auto_val, "auto-detect"
70
+ if default:
71
+ return default, "default"
72
+ return "", "empty"
73
+
74
+
75
+ def emit_credentials(env_file: Path, workspace: Path) -> dict:
76
+ """Discover .p8, derive ASC_KEY_ID / ISSUER / PATH, emit to env_file.
77
+
78
+ SECURITY: the raw .p8 contents are never emitted to $GITHUB_ENV.
79
+ Downstream scripts read the key from the on-disk path (ASC_KEY_PATH),
80
+ which is a single-line string that cannot be dumped by GH Actions'
81
+ automatic env-var display when a step group expands. As belt-and-braces
82
+ defense, every non-empty line of the key body is registered with
83
+ ``::add-mask::`` so if it ever surfaces in a log it is redacted to ``***``.
84
+
85
+ Returns {'key_id', 'issuer_id', 'key_path'} for downstream use.
86
+ """
87
+ p8_path, key_id_from_file, issuer_from_file = find_p8(workspace)
88
+ try:
89
+ p8_contents = p8_path.read_text()
90
+ except OSError as exc:
91
+ fail(f"cannot read {p8_path}: {exc}")
92
+
93
+ if not issuer_from_file:
94
+ fail(
95
+ "ASC issuer id unknown: rename the .p8 to "
96
+ "AuthKey_<KEY_ID>_Issuer_<UUID>.p8 so the issuer id is in the filename."
97
+ )
98
+
99
+ # Register every non-empty line of the key with the GH Actions log masker
100
+ # BEFORE emitting anything else. ::add-mask:: is line-oriented: GitHub
101
+ # scans subsequent log output for exact substring matches and rewrites
102
+ # them to ***. Masking line-by-line catches accidental echoes (e.g.
103
+ # `set -x`, `env`, `cat key.p8`) regardless of newline handling.
104
+ for line in p8_contents.splitlines():
105
+ stripped = line.strip()
106
+ if stripped:
107
+ # Emit to stdout — GH Actions reads the workflow command from
108
+ # any step's stdout, not just dedicated steps.
109
+ print(f"::add-mask::{stripped}")
110
+
111
+ emit(env_file, "ASC_KEY_ID", str(key_id_from_file))
112
+ emit(env_file, "ASC_ISSUER_ID", str(issuer_from_file))
113
+ emit(env_file, "ASC_KEY_P8_PATH", str(p8_path))
114
+ # NB: ASC_KEY_P8 (raw key contents) is deliberately NOT emitted.
115
+ # Downstream steps read the key from ASC_KEY_P8_PATH / ASC_KEY_PATH.
116
+ log(f"ASC_KEY_ID={key_id_from_file} (source: filename)")
117
+ log(f"ASC_ISSUER_ID={issuer_from_file} (source: filename)")
118
+ log(f"ASC_KEY_P8_PATH={p8_path}")
119
+ return {
120
+ "key_id": str(key_id_from_file),
121
+ "issuer_id": str(issuer_from_file),
122
+ "key_path": str(p8_path),
123
+ }
124
+
125
+
126
+ def _detect_project_workspace(
127
+ workspace: Path,
128
+ project: tuple[str, str],
129
+ ws_val: tuple[str, str],
130
+ ) -> tuple[tuple[str, str], tuple[str, str]]:
131
+ """Run auto_detect.auto_detect_project when both are unset."""
132
+ if project[0] or ws_val[0]:
133
+ return project, ws_val
134
+ auto_proj, auto_ws = auto_detect.auto_detect_project(workspace)
135
+ if auto_ws:
136
+ return project, (auto_ws, "auto-detect")
137
+ if auto_proj:
138
+ return (auto_proj, "auto-detect"), ws_val
139
+ return project, ws_val
140
+
141
+
142
+ def resolve_xcode(workspace: Path, inp: dict) -> dict:
143
+ """Resolve project / workspace / scheme / configuration / profile_name.
144
+
145
+ Inputs win; otherwise we shell out to xcodebuild via auto_detect.
146
+ """
147
+ project = _pick(inp["PROJECT"])
148
+ ws_val = _pick(inp["WORKSPACE"])
149
+ project, ws_val = _detect_project_workspace(workspace, project, ws_val)
150
+
151
+ configuration = _pick(inp["CONFIGURATION"], default="Release")
152
+
153
+ scheme = _pick(inp["SCHEME"])
154
+ if not scheme[0] and (project[0] or ws_val[0]):
155
+ detected = auto_detect.auto_detect_scheme(workspace, project[0], ws_val[0])
156
+ if detected:
157
+ scheme = (detected, "auto-detect")
158
+
159
+ default_profile = f"{scheme[0]} CI" if scheme[0] else ""
160
+ profile_name = _pick(inp["PROFILE_NAME"], default=default_profile)
161
+ return {
162
+ "project": project,
163
+ "workspace": ws_val,
164
+ "scheme": scheme,
165
+ "configuration": configuration,
166
+ "profile_name": profile_name,
167
+ }
168
+
169
+
170
+ def _detect_bundle(
171
+ bundle: tuple[str, str], workspace: Path, xcode: dict,
172
+ ) -> tuple[str, str]:
173
+ """Auto-detect PRODUCT_BUNDLE_IDENTIFIER when bundle is unresolved."""
174
+ if bundle[0] or not xcode.get("scheme"):
175
+ return bundle
176
+ detected = auto_detect.auto_detect_bundle_id(
177
+ workspace,
178
+ xcode.get("project", ""),
179
+ xcode.get("workspace", ""),
180
+ xcode["scheme"],
181
+ xcode.get("configuration", "Release"),
182
+ )
183
+ if detected:
184
+ return detected, "auto-detect"
185
+ return bundle
186
+
187
+
188
+ def _resolve_apple_id(
189
+ inp: dict, bundle: tuple[str, str], creds: dict, scripts_dir: Path,
190
+ ) -> tuple[str, str]:
191
+ """Resolve App Store numeric app id: input -> ASC API lookup -> empty."""
192
+ if inp["APP_STORE_APPLE_ID"]:
193
+ return inp["APP_STORE_APPLE_ID"], "input"
194
+ if not bundle[0]:
195
+ return "", "empty"
196
+ os.environ["ASC_KEY_ID"] = creds["key_id"]
197
+ os.environ["ASC_ISSUER_ID"] = creds["issuer_id"]
198
+ os.environ["ASC_KEY_PATH"] = creds["key_path"]
199
+ return lookup_app_id_via_api(bundle[0], scripts_dir), "api-lookup"
200
+
201
+
202
+ def resolve_app(
203
+ inp: dict,
204
+ creds: dict,
205
+ scripts_dir: Path,
206
+ *,
207
+ workspace: Path,
208
+ xcode: dict,
209
+ ) -> dict:
210
+ """Resolve bundle_id / team_id / app_store_apple_id."""
211
+ bundle = _pick(inp["BUNDLE_ID"])
212
+ bundle = _detect_bundle(bundle, workspace, xcode)
213
+
214
+ team = _pick(inp["TEAM_ID"])
215
+ if not team[0]:
216
+ derived_val, derived_src = derive_team_if_empty(team[0], team[1], creds)
217
+ if derived_val:
218
+ log(f"derived team_id={derived_val} from ASC key {creds['key_id']}")
219
+ team = (derived_val, derived_src)
220
+
221
+ apple = _resolve_apple_id(inp, bundle, creds, scripts_dir)
222
+ return {"bundle_id": bundle, "team_id": team, "app_store_apple_id": apple}
223
+
224
+
225
+ def resolve_testflight(inp: dict) -> dict:
226
+ """Resolve whats_new / locale from inputs, with built-in defaults."""
227
+ return {
228
+ "whats_new": _pick(inp["WHATS_NEW"], default=DEFAULT_WHATS_NEW),
229
+ "locale": _pick(inp["LOCALE"], default="en-US"),
230
+ }
231
+
232
+
233
+ def resolve_tests(inp: dict) -> dict:
234
+ """Resolve run_tests / test_command / test_destination from inputs."""
235
+ return {
236
+ "run_tests": _pick(inp["RUN_TESTS"], default="false"),
237
+ "test_command": _pick(inp["TEST_COMMAND"]),
238
+ "test_destination": _pick(inp["TEST_DESTINATION"]),
239
+ }
240
+
241
+
242
+ def resolve_ios(inp: dict) -> dict:
243
+ """Resolve ios.uses_non_exempt_encryption from inputs."""
244
+ return {"uses_non_exempt": _pick(inp["USES_NON_EXEMPT"], default="false")}
245
+
246
+
247
+ def collect_inputs() -> dict:
248
+ """Read all INPUT_* env vars the composite action passes in."""
249
+ names = (
250
+ "PROJECT", "WORKSPACE", "SCHEME", "CONFIGURATION", "PROFILE_NAME",
251
+ "BUNDLE_ID", "TEAM_ID", "APP_STORE_APPLE_ID",
252
+ "USES_NON_EXEMPT", "WHATS_NEW", "LOCALE",
253
+ "RUN_TESTS", "TEST_COMMAND", "TEST_DESTINATION",
254
+ )
255
+ return {name: os.environ.get(f"INPUT_{name}", "") for name in names}
256
+
257
+
258
+ def main() -> None:
259
+ workspace = Path(os.environ.get("GITHUB_WORKSPACE", "."))
260
+ scripts_dir = Path(__file__).resolve().parent
261
+ env_file_path = os.environ.get("GITHUB_ENV")
262
+ if not env_file_path:
263
+ fail("GITHUB_ENV not set; this script must run inside a GitHub Actions step")
264
+ env_file = Path(env_file_path)
265
+
266
+ inputs = collect_inputs()
267
+ creds = emit_credentials(env_file, workspace)
268
+ xc = resolve_xcode(workspace, inputs)
269
+ xc_values = {k: v[0] for k, v in xc.items()}
270
+ app = resolve_app(
271
+ inputs, creds, scripts_dir,
272
+ workspace=workspace, xcode=xc_values,
273
+ )
274
+ tf = resolve_testflight(inputs)
275
+ ios = resolve_ios(inputs)
276
+ tests = resolve_tests(inputs)
277
+
278
+ derived = [
279
+ ("CFG_PROJECT", xc["project"]),
280
+ ("CFG_WORKSPACE", xc["workspace"]),
281
+ ("CFG_SCHEME", xc["scheme"]),
282
+ ("CFG_CONFIGURATION", xc["configuration"]),
283
+ ("CFG_BUNDLE_ID", app["bundle_id"]),
284
+ ("CFG_TEAM_ID", app["team_id"]),
285
+ ("CFG_APP_STORE_APPLE_ID", app["app_store_apple_id"]),
286
+ ("CFG_PROFILE_NAME", xc["profile_name"]),
287
+ ("CFG_USES_NON_EXEMPT", ios["uses_non_exempt"]),
288
+ ("CFG_WHATS_NEW", tf["whats_new"]),
289
+ ("CFG_LOCALE", tf["locale"]),
290
+ ("CFG_RUN_TESTS", tests["run_tests"]),
291
+ ("CFG_TEST_COMMAND", tests["test_command"]),
292
+ ("CFG_TEST_DESTINATION", tests["test_destination"]),
293
+ ]
294
+ for name, (value, src) in derived:
295
+ emit(env_file, name, value)
296
+ shown = value if name != "CFG_WHATS_NEW" else value.replace("\n", " / ")
297
+ log(f"{name}={shown!r} (source: {src})")
298
+
299
+ # Persist whatsNew to a file under $RUNNER_TEMP so downstream steps can
300
+ # read raw multi-line content without any GitHub Actions ${{ }} YAML
301
+ # interpolation mangling embedded newlines.
302
+ whats_new_value = tf["whats_new"][0]
303
+ runner_temp = os.environ.get("RUNNER_TEMP") or str(workspace)
304
+ whats_new_path = Path(runner_temp) / "whats_new.txt"
305
+ whats_new_path.write_text(whats_new_value)
306
+ emit(env_file, "CFG_WHATS_NEW_FILE", str(whats_new_path))
307
+ log(
308
+ f"CFG_WHATS_NEW_FILE={whats_new_path} "
309
+ f"(length={len(whats_new_value)}, "
310
+ f"newlines={whats_new_value.count(chr(10))})"
311
+ )
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Resolve the project's marketing version from build settings or Info.plist.
4
+
5
+ The project's MARKETING_VERSION is the single source of truth for the
6
+ marketing version. CI does not bump it. This script reads it from one of:
7
+ 1. ``xcodebuild -showBuildSettings`` (looking for ``MARKETING_VERSION``).
8
+ 2. ``CFBundleShortVersionString`` in the resolved Info.plist (fallback
9
+ for projects that hardcode the marketing version in the plist
10
+ instead of using the build setting).
11
+
12
+ Stdout: ``MARKETING_VERSION=<value>`` (suitable for piping into
13
+ ``$GITHUB_ENV``).
14
+ Stderr: ``Resolved MARKETING_VERSION=<value> (source: <source>)`` on
15
+ success, or a single-line ``::error::...`` message on failure.
16
+
17
+ Exit codes:
18
+ 0 -- resolved a valid semver value.
19
+ 1 -- could not resolve a valid value; user must set it in their
20
+ project's build settings or Info.plist.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import plistlib
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ SEM_RE = re.compile(r"^\d+\.\d+(\.\d+)?$")
33
+ _MIGRATION_HINT = (
34
+ "See .github/actions/swift-app/MIGRATION.md for the "
35
+ "migration procedure."
36
+ )
37
+
38
+
39
+ def _xcodebuild_args() -> tuple[list[str], Path]:
40
+ """Return (xcodebuild project/workspace args, project base dir).
41
+
42
+ Prefer the app -project over the -workspace when both are known.
43
+ MARKETING_VERSION is an app-target build setting, so reading it from the
44
+ bare project is sufficient AND avoids evaluating the entire CocoaPods
45
+ workspace graph (Firebase + use_frameworks! ~= 50 pods), which can push
46
+ `xcodebuild -showBuildSettings` past its timeout on CI runners. Falls
47
+ back to the workspace when no project path is provided.
48
+ """
49
+ workspace = os.environ.get("WORKSPACE", "").strip()
50
+ project = os.environ.get("PROJECT", "").strip()
51
+ if project:
52
+ return ["-project", project], Path(project).parent
53
+ return ["-workspace", workspace], Path(workspace).parent
54
+
55
+
56
+ def _read_build_settings() -> str:
57
+ """Run xcodebuild -showBuildSettings; return stdout (empty on error)."""
58
+ proj_args, _base = _xcodebuild_args()
59
+ scheme = os.environ.get("SCHEME", "").strip()
60
+ config = os.environ.get("CONFIGURATION", "").strip() or "Release"
61
+ cmd = ["xcodebuild", *proj_args, "-scheme", scheme,
62
+ "-configuration", config, "-showBuildSettings"]
63
+ try:
64
+ out = subprocess.run(
65
+ cmd, check=False, capture_output=True, text=True, timeout=300,
66
+ )
67
+ except (OSError, subprocess.SubprocessError):
68
+ return ""
69
+ return out.stdout or ""
70
+
71
+
72
+ def _grep_setting(settings: str, key: str) -> str:
73
+ """Pull `<KEY> = <value>` out of xcodebuild -showBuildSettings output."""
74
+ pattern = re.compile(rf"^\s*{re.escape(key)}\s*=\s*(.+?)\s*$", re.MULTILINE)
75
+ m = pattern.search(settings)
76
+ return m.group(1).strip() if m else ""
77
+
78
+
79
+ _VAR_REF_RE = re.compile(r"\$[({]([A-Za-z_][A-Za-z0-9_]*)(?::[^)}]*)?[)}]")
80
+
81
+
82
+ def _expand_setting_refs(value: str, settings: str) -> str:
83
+ """Expand $(VAR)/${VAR} references against -showBuildSettings output.
84
+
85
+ Flutter's iOS template ships Info.plist with
86
+ ``CFBundleShortVersionString = $(FLUTTER_BUILD_NAME)`` and no
87
+ MARKETING_VERSION on the target; the actual version lives in
88
+ ios/Flutter/Generated.xcconfig (written by ``flutter build ios
89
+ --config-only``) and therefore appears in the build settings. Reading
90
+ the raw plist yields the unexpanded reference, so expand it here.
91
+ Returns "" when any referenced variable cannot be resolved.
92
+ """
93
+ unresolved = False
94
+
95
+ def _sub(match: re.Match) -> str:
96
+ nonlocal unresolved
97
+ resolved = _grep_setting(settings, match.group(1))
98
+ if not resolved:
99
+ unresolved = True
100
+ return resolved
101
+
102
+ expanded = _VAR_REF_RE.sub(_sub, value)
103
+ return "" if unresolved else expanded.strip()
104
+
105
+
106
+ def _read_plist_short_version(settings: str) -> tuple[str, str]:
107
+ """(version_or_empty, source_label). Resolves INFOPLIST_FILE relative
108
+ to the project base when the setting holds a relative path."""
109
+ infoplist = _grep_setting(settings, "INFOPLIST_FILE")
110
+ if not infoplist:
111
+ return "", ""
112
+ _proj_args, base = _xcodebuild_args()
113
+ candidates = [base / infoplist, Path(infoplist)]
114
+ for path in candidates:
115
+ if not path.is_file():
116
+ continue
117
+ try:
118
+ with open(path, "rb") as fh:
119
+ data = plistlib.load(fh)
120
+ except (OSError, plistlib.InvalidFileException, ValueError):
121
+ continue
122
+ if not isinstance(data, dict):
123
+ continue
124
+ value = (data.get("CFBundleShortVersionString") or "").strip()
125
+ if not value:
126
+ continue
127
+ if "$(" in value or "${" in value:
128
+ expanded = _expand_setting_refs(value, settings)
129
+ if not expanded:
130
+ continue
131
+ return expanded, (
132
+ f"Info.plist CFBundleShortVersionString in {path} "
133
+ f"({value} expanded via build settings)"
134
+ )
135
+ return value, f"Info.plist CFBundleShortVersionString in {path}"
136
+ return "", ""
137
+
138
+
139
+ def _resolve() -> tuple[str, str]:
140
+ """(value, source). Empty value means resolution failed."""
141
+ settings = _read_build_settings()
142
+ mv = _grep_setting(settings, "MARKETING_VERSION")
143
+ if mv:
144
+ return mv, "xcodebuild -showBuildSettings (MARKETING_VERSION)"
145
+ return _read_plist_short_version(settings)
146
+
147
+
148
+ def _emit_error() -> None:
149
+ scheme = os.environ.get("SCHEME", "").strip()
150
+ config = os.environ.get("CONFIGURATION", "").strip() or "Release"
151
+ print(
152
+ f"::error::Could not resolve a valid MARKETING_VERSION for "
153
+ f"scheme={scheme} configuration={config}. Set MARKETING_VERSION "
154
+ f"in project.yml [xcodegen], the .xcodeproj's MARKETING_VERSION "
155
+ f"xcconfig, or CFBundleShortVersionString in Info.plist (must "
156
+ f"match ^\\d+\\.\\d+(\\.\\d+)?$). CI no longer bumps the marketing "
157
+ f"version automatically. " + _MIGRATION_HINT,
158
+ file=sys.stderr,
159
+ )
160
+
161
+
162
+ def main() -> None:
163
+ value, source = _resolve()
164
+ if not value or not SEM_RE.match(value):
165
+ _emit_error()
166
+ raise SystemExit(1)
167
+ print(f"MARKETING_VERSION={value}")
168
+ print(f"Resolved MARKETING_VERSION={value} (source: {source})",
169
+ file=sys.stderr)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ main()