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,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()
|