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,391 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Xcode project / scheme / bundle_id auto-detection.
|
|
4
|
+
|
|
5
|
+
Ported from gowalk-step/fastlane_standalone.sh — keeps us in Python (our
|
|
6
|
+
ecosystem) while still shelling out to ``xcodebuild`` for the two queries
|
|
7
|
+
only it can reliably answer:
|
|
8
|
+
|
|
9
|
+
``xcodebuild -list -json`` -> enumerate schemes
|
|
10
|
+
``xcodebuild -showBuildSettings -json`` -> PRODUCT_TYPE + bundle id
|
|
11
|
+
|
|
12
|
+
The detection lets ``ci.config.yaml`` become entirely optional: with just
|
|
13
|
+
``creds/AuthKey_*.p8`` + the 18-line ``deploy.yml`` the pipeline can still
|
|
14
|
+
figure out what to build.
|
|
15
|
+
|
|
16
|
+
Rules (kept intentionally boring — if auto-detect is ambiguous we log a
|
|
17
|
+
warning and return a deterministic choice, falling back to a clear
|
|
18
|
+
``::error::`` only when truly nothing works):
|
|
19
|
+
|
|
20
|
+
1. ``auto_detect_project(workspace)``
|
|
21
|
+
- Prefer a single ``*.xcworkspace`` at the repo root.
|
|
22
|
+
- Else a single ``*.xcodeproj``.
|
|
23
|
+
- When multiple ``.xcodeproj`` exist, prefer one matching the repo
|
|
24
|
+
basename, else alphabetically first (with a warning).
|
|
25
|
+
|
|
26
|
+
2. ``auto_detect_scheme(workspace, project, workspace_file)``
|
|
27
|
+
- ``xcodebuild -list -json`` to enumerate schemes.
|
|
28
|
+
- For each scheme, ``-showBuildSettings -json`` and keep those whose
|
|
29
|
+
``PRODUCT_TYPE == com.apple.product-type.application``.
|
|
30
|
+
- Single application scheme -> that one. Multiple: prefer repo-basename
|
|
31
|
+
match, else alphabetical first.
|
|
32
|
+
|
|
33
|
+
3. ``auto_detect_bundle_id(workspace, project, workspace_file, scheme, configuration)``
|
|
34
|
+
- Read ``PRODUCT_BUNDLE_IDENTIFIER`` from the same ``-showBuildSettings``
|
|
35
|
+
invocation (cached from step 2 when possible).
|
|
36
|
+
- Reject unresolved variable references like ``$(PRODUCT_NAME)``.
|
|
37
|
+
|
|
38
|
+
All xcodebuild invocations are cached per-process so repeated calls in the
|
|
39
|
+
same ``read_config.py`` run don't re-shell.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import subprocess
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Optional
|
|
48
|
+
|
|
49
|
+
from cfg_io import log, notice
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
APP_PRODUCT_TYPE = "com.apple.product-type.application"
|
|
53
|
+
# Timeouts (seconds) — xcodebuild can hang on missing toolchains or
|
|
54
|
+
# package resolution. CI should fail fast rather than spin forever.
|
|
55
|
+
LIST_TIMEOUT = 60
|
|
56
|
+
SETTINGS_TIMEOUT = 120
|
|
57
|
+
|
|
58
|
+
# Process-local caches keyed by (project, workspace_file) or
|
|
59
|
+
# (project, workspace_file, scheme, configuration). Cleared between test
|
|
60
|
+
# cases via ``_clear_caches`` but otherwise persist for the lifetime of
|
|
61
|
+
# the read_config.py process.
|
|
62
|
+
_list_cache: dict[tuple, Optional[list[str]]] = {}
|
|
63
|
+
_settings_cache: dict[tuple, Optional[dict]] = {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _clear_caches() -> None:
|
|
67
|
+
"""Reset caches — used by tests. Not part of the public API."""
|
|
68
|
+
_list_cache.clear()
|
|
69
|
+
_settings_cache.clear()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_xcodegen_spec(workspace: Path) -> Path | None:
|
|
73
|
+
"""Locate an xcodegen spec file (project.yml / Project.yml / project.yaml).
|
|
74
|
+
|
|
75
|
+
xcodegen accepts any of these names. We check them in this order. Case
|
|
76
|
+
mismatch on case-sensitive filesystems has bitten us before.
|
|
77
|
+
"""
|
|
78
|
+
for candidate in ("project.yml", "project.yaml", "Project.yml", "Project.yaml"):
|
|
79
|
+
path = workspace / candidate
|
|
80
|
+
if path.is_file():
|
|
81
|
+
return path
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def auto_detect_project(workspace: Path) -> tuple[str, str]:
|
|
86
|
+
"""Discover the repo-root Xcode project / workspace.
|
|
87
|
+
|
|
88
|
+
Returns ``(project, workspace_file)`` where exactly one is a non-empty
|
|
89
|
+
filename. ``("", "")`` means nothing was found.
|
|
90
|
+
|
|
91
|
+
If no ``.xcworkspace``/``.xcodeproj`` is present but a ``project.yml``
|
|
92
|
+
exists (xcodegen spec), we invoke ``xcodegen generate`` once to
|
|
93
|
+
materialize the ``.xcodeproj`` and retry. xcodegen-based projects
|
|
94
|
+
commonly gitignore the generated ``.xcodeproj``, so the runner sees
|
|
95
|
+
only the spec.
|
|
96
|
+
"""
|
|
97
|
+
log(f"auto-detect: scanning workspace={workspace}")
|
|
98
|
+
workspaces = sorted(workspace.glob("*.xcworkspace"))
|
|
99
|
+
if workspaces:
|
|
100
|
+
picked = _pick_by_basename(workspaces, workspace, "xcworkspace")
|
|
101
|
+
log(f"auto-detect: workspace={picked.name}")
|
|
102
|
+
return "", picked.name
|
|
103
|
+
|
|
104
|
+
projects = sorted(workspace.glob("*.xcodeproj"))
|
|
105
|
+
if not projects:
|
|
106
|
+
spec = _find_xcodegen_spec(workspace)
|
|
107
|
+
if spec is not None:
|
|
108
|
+
log(f"auto-detect: xcodegen spec found at {spec.name}")
|
|
109
|
+
if _run_xcodegen(workspace):
|
|
110
|
+
projects = sorted(workspace.glob("*.xcodeproj"))
|
|
111
|
+
else:
|
|
112
|
+
# Surface what we DID see so CI logs are actionable when the
|
|
113
|
+
# expected spec file is missing / misnamed / gitignored.
|
|
114
|
+
entries = sorted(p.name for p in workspace.iterdir() if not p.name.startswith("."))
|
|
115
|
+
log(
|
|
116
|
+
f"auto-detect: no .xcodeproj / .xcworkspace / project.yml at "
|
|
117
|
+
f"{workspace}; root entries={entries[:20]}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not projects:
|
|
121
|
+
return "", ""
|
|
122
|
+
picked = _pick_by_basename(projects, workspace, "xcodeproj")
|
|
123
|
+
log(f"auto-detect: project={picked.name}")
|
|
124
|
+
return picked.name, ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _run_xcodegen(workspace: Path) -> bool:
|
|
128
|
+
"""Run ``xcodegen generate`` in ``workspace``. Returns True on success.
|
|
129
|
+
|
|
130
|
+
Logs a notice on failure but never raises — the caller will see an
|
|
131
|
+
empty project/workspace result and the normal ``::error::`` path
|
|
132
|
+
will guide the user.
|
|
133
|
+
"""
|
|
134
|
+
log("auto-detect: no .xcodeproj/.xcworkspace found, xcodegen spec present — running xcodegen")
|
|
135
|
+
try:
|
|
136
|
+
result = subprocess.run(
|
|
137
|
+
["xcodegen", "generate"],
|
|
138
|
+
cwd=str(workspace),
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
timeout=LIST_TIMEOUT,
|
|
142
|
+
)
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
notice(
|
|
145
|
+
"xcodegen not on PATH — installing via Homebrew (one-shot). "
|
|
146
|
+
"If this fails, pre-install xcodegen or commit the generated .xcodeproj."
|
|
147
|
+
)
|
|
148
|
+
if not _install_xcodegen():
|
|
149
|
+
return False
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["xcodegen", "generate"],
|
|
153
|
+
cwd=str(workspace),
|
|
154
|
+
capture_output=True,
|
|
155
|
+
text=True,
|
|
156
|
+
timeout=LIST_TIMEOUT,
|
|
157
|
+
)
|
|
158
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
159
|
+
notice(f"xcodegen still unavailable after install: {exc!r}")
|
|
160
|
+
return False
|
|
161
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
162
|
+
notice(
|
|
163
|
+
f"xcodegen not available or timed out ({exc!r}); "
|
|
164
|
+
"install xcodegen or commit the generated .xcodeproj."
|
|
165
|
+
)
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
if result.returncode != 0:
|
|
169
|
+
notice(
|
|
170
|
+
f"xcodegen generate failed (rc={result.returncode}): "
|
|
171
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
172
|
+
)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
log("auto-detect: xcodegen generate succeeded")
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _install_xcodegen() -> bool:
|
|
180
|
+
"""Install xcodegen via Homebrew. Returns True on success.
|
|
181
|
+
|
|
182
|
+
Called only when `xcodegen` is missing from PATH. macOS GitHub runners
|
|
183
|
+
normally preinstall it, but when they don't we need a self-bootstrap
|
|
184
|
+
path — otherwise the entire action fails at auto-detect for
|
|
185
|
+
xcodegen-based projects with no actionable error.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
result = subprocess.run(
|
|
189
|
+
["brew", "install", "xcodegen"],
|
|
190
|
+
capture_output=True,
|
|
191
|
+
text=True,
|
|
192
|
+
timeout=300,
|
|
193
|
+
)
|
|
194
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
195
|
+
notice(f"brew install xcodegen failed: {exc!r}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
if result.returncode != 0:
|
|
199
|
+
notice(
|
|
200
|
+
f"brew install xcodegen failed (rc={result.returncode}): "
|
|
201
|
+
f"{result.stderr.strip() or result.stdout.strip()}"
|
|
202
|
+
)
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
log("auto-detect: xcodegen installed via Homebrew")
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _pick_by_basename(matches: list[Path], workspace: Path, label: str) -> Path:
|
|
210
|
+
"""Pick the match whose name stem equals the repo basename, else first."""
|
|
211
|
+
if len(matches) == 1:
|
|
212
|
+
return matches[0]
|
|
213
|
+
repo_base = workspace.resolve().name.lower()
|
|
214
|
+
for match in matches:
|
|
215
|
+
if match.stem.lower() == repo_base:
|
|
216
|
+
return match
|
|
217
|
+
notice(
|
|
218
|
+
f"multiple *.{label} found; picking {matches[0].name} alphabetically. "
|
|
219
|
+
f"Set xcode.project in ci.config.yaml to disambiguate."
|
|
220
|
+
)
|
|
221
|
+
return matches[0]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _list_schemes(
|
|
225
|
+
workspace: Path, project: str, workspace_file: str
|
|
226
|
+
) -> Optional[list[str]]:
|
|
227
|
+
"""Run ``xcodebuild -list -json`` and return the schemes list (cached)."""
|
|
228
|
+
key = (project, workspace_file)
|
|
229
|
+
if key in _list_cache:
|
|
230
|
+
return _list_cache[key]
|
|
231
|
+
|
|
232
|
+
cmd = ["xcodebuild", "-list", "-json"]
|
|
233
|
+
if workspace_file:
|
|
234
|
+
cmd += ["-workspace", workspace_file]
|
|
235
|
+
elif project:
|
|
236
|
+
cmd += ["-project", project]
|
|
237
|
+
else:
|
|
238
|
+
_list_cache[key] = None
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
cmd, cwd=str(workspace), capture_output=True, text=True,
|
|
244
|
+
timeout=LIST_TIMEOUT,
|
|
245
|
+
)
|
|
246
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
247
|
+
log(f"auto-detect: xcodebuild -list failed: {exc!r}")
|
|
248
|
+
_list_cache[key] = None
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
if result.returncode != 0:
|
|
252
|
+
log(
|
|
253
|
+
f"auto-detect: xcodebuild -list returned {result.returncode}: "
|
|
254
|
+
f"{result.stderr.strip()}"
|
|
255
|
+
)
|
|
256
|
+
_list_cache[key] = None
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
data = json.loads(result.stdout)
|
|
261
|
+
except json.JSONDecodeError as exc:
|
|
262
|
+
log(f"auto-detect: xcodebuild -list produced invalid JSON: {exc!r}")
|
|
263
|
+
_list_cache[key] = None
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
# Workspace output has {"workspace": {"schemes": [...]}}; project has
|
|
267
|
+
# {"project": {"schemes": [...]}}.
|
|
268
|
+
container = data.get("workspace") or data.get("project") or {}
|
|
269
|
+
schemes = container.get("schemes") or []
|
|
270
|
+
_list_cache[key] = list(schemes)
|
|
271
|
+
return _list_cache[key]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _show_build_settings(
|
|
275
|
+
workspace: Path,
|
|
276
|
+
project: str,
|
|
277
|
+
workspace_file: str,
|
|
278
|
+
scheme: str,
|
|
279
|
+
configuration: str,
|
|
280
|
+
) -> Optional[dict]:
|
|
281
|
+
"""Run ``xcodebuild -showBuildSettings -json`` and return buildSettings (cached)."""
|
|
282
|
+
key = (project, workspace_file, scheme, configuration or "Release")
|
|
283
|
+
if key in _settings_cache:
|
|
284
|
+
return _settings_cache[key]
|
|
285
|
+
|
|
286
|
+
cmd = ["xcodebuild", "-showBuildSettings", "-json", "-scheme", scheme]
|
|
287
|
+
if configuration:
|
|
288
|
+
cmd += ["-configuration", configuration]
|
|
289
|
+
if workspace_file:
|
|
290
|
+
cmd += ["-workspace", workspace_file]
|
|
291
|
+
elif project:
|
|
292
|
+
cmd += ["-project", project]
|
|
293
|
+
else:
|
|
294
|
+
_settings_cache[key] = None
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
result = subprocess.run(
|
|
299
|
+
cmd, cwd=str(workspace), capture_output=True, text=True,
|
|
300
|
+
timeout=SETTINGS_TIMEOUT,
|
|
301
|
+
)
|
|
302
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
303
|
+
log(f"auto-detect: showBuildSettings({scheme!r}) failed: {exc!r}")
|
|
304
|
+
_settings_cache[key] = None
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
if result.returncode != 0:
|
|
308
|
+
log(
|
|
309
|
+
f"auto-detect: showBuildSettings({scheme!r}) returned "
|
|
310
|
+
f"{result.returncode}: {result.stderr.strip()}"
|
|
311
|
+
)
|
|
312
|
+
_settings_cache[key] = None
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
data = json.loads(result.stdout)
|
|
317
|
+
except json.JSONDecodeError as exc:
|
|
318
|
+
log(f"auto-detect: showBuildSettings({scheme!r}) invalid JSON: {exc!r}")
|
|
319
|
+
_settings_cache[key] = None
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
if not isinstance(data, list) or not data:
|
|
323
|
+
_settings_cache[key] = None
|
|
324
|
+
return None
|
|
325
|
+
settings = data[0].get("buildSettings") or {}
|
|
326
|
+
_settings_cache[key] = settings if isinstance(settings, dict) else None
|
|
327
|
+
return _settings_cache[key]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def auto_detect_scheme(
|
|
331
|
+
workspace: Path, project: str, workspace_file: str
|
|
332
|
+
) -> Optional[str]:
|
|
333
|
+
"""Pick the single ``com.apple.product-type.application`` scheme."""
|
|
334
|
+
schemes = _list_schemes(workspace, project, workspace_file)
|
|
335
|
+
if not schemes:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
app_schemes: list[str] = []
|
|
339
|
+
for scheme in schemes:
|
|
340
|
+
settings = _show_build_settings(
|
|
341
|
+
workspace, project, workspace_file, scheme, "Release",
|
|
342
|
+
)
|
|
343
|
+
if not settings:
|
|
344
|
+
continue
|
|
345
|
+
if settings.get("PRODUCT_TYPE") == APP_PRODUCT_TYPE:
|
|
346
|
+
app_schemes.append(scheme)
|
|
347
|
+
|
|
348
|
+
if not app_schemes:
|
|
349
|
+
return None
|
|
350
|
+
if len(app_schemes) == 1:
|
|
351
|
+
log(f"auto-detect: scheme={app_schemes[0]}")
|
|
352
|
+
return app_schemes[0]
|
|
353
|
+
|
|
354
|
+
repo_base = workspace.resolve().name.lower()
|
|
355
|
+
for scheme in app_schemes:
|
|
356
|
+
if scheme.lower() == repo_base:
|
|
357
|
+
log(f"auto-detect: scheme={scheme} (basename match)")
|
|
358
|
+
return scheme
|
|
359
|
+
|
|
360
|
+
picked = sorted(app_schemes)[0]
|
|
361
|
+
notice(
|
|
362
|
+
f"multiple application schemes found ({', '.join(sorted(app_schemes))}); "
|
|
363
|
+
f"picking {picked} alphabetically. Set xcode.scheme in ci.config.yaml "
|
|
364
|
+
f"to disambiguate."
|
|
365
|
+
)
|
|
366
|
+
return picked
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def auto_detect_bundle_id(
|
|
370
|
+
workspace: Path,
|
|
371
|
+
project: str,
|
|
372
|
+
workspace_file: str,
|
|
373
|
+
scheme: str,
|
|
374
|
+
configuration: str,
|
|
375
|
+
) -> Optional[str]:
|
|
376
|
+
"""Extract ``PRODUCT_BUNDLE_IDENTIFIER`` for the given scheme/config."""
|
|
377
|
+
settings = _show_build_settings(
|
|
378
|
+
workspace, project, workspace_file, scheme, configuration or "Release",
|
|
379
|
+
)
|
|
380
|
+
if not settings:
|
|
381
|
+
return None
|
|
382
|
+
bundle = settings.get("PRODUCT_BUNDLE_IDENTIFIER")
|
|
383
|
+
if not bundle or not isinstance(bundle, str):
|
|
384
|
+
return None
|
|
385
|
+
# xcodebuild sometimes emits unresolved variable references when build
|
|
386
|
+
# settings depend on xcconfig files that aren't in the default context.
|
|
387
|
+
if "$(" in bundle or bundle.startswith("$") or "=" in bundle:
|
|
388
|
+
log(f"auto-detect: bundle_id {bundle!r} contains unresolved variable — rejecting")
|
|
389
|
+
return None
|
|
390
|
+
log(f"auto-detect: bundle_id={bundle}")
|
|
391
|
+
return bundle
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Checks npm for newer gowalk-cicd, re-vendors via npx if newer.
|
|
3
|
+
# Stages refreshed files for commit. Exits 0 on no-op or non-fatal failure.
|
|
4
|
+
#
|
|
5
|
+
# DESIGN DECISION (intentional, not a bug):
|
|
6
|
+
# This script runs `npx --yes gowalk-cicd@<latest>` on every default-
|
|
7
|
+
# branch CI run with `permissions: contents: write`. That means the consumer
|
|
8
|
+
# repo trusts whatever code gowalk-cicd publishes to npm.
|
|
9
|
+
#
|
|
10
|
+
# Trust model — internally consistent with the rest of the action:
|
|
11
|
+
# - Consumers initially install via the same `npx --yes gowalk-cicd`
|
|
12
|
+
# command. They've already accepted the trust relationship.
|
|
13
|
+
# - The package is published from gowalk-cicd via a dedicated GitHub Actions
|
|
14
|
+
# workflow using npm Trusted Publishing (OIDC after initial bootstrap).
|
|
15
|
+
# Compromise of that release workflow would already be catastrophic
|
|
16
|
+
# regardless of this autoupdate path.
|
|
17
|
+
# - We pin to the exact version returned by `npm view` immediately above
|
|
18
|
+
# (`@$latest`) so the small TOCTOU window between view and exec can't
|
|
19
|
+
# swap a different version.
|
|
20
|
+
# - Consumers who want to pin can set `with: { auto-update: 'false' }` in
|
|
21
|
+
# their workflow.
|
|
22
|
+
#
|
|
23
|
+
# Hardening considered, not adopted:
|
|
24
|
+
# - `npm audit signatures` provenance check: requires npm to have published
|
|
25
|
+
# with --provenance, which the gowalk-cicd publish workflow does not currently
|
|
26
|
+
# emit. Worth revisiting if/when gowalk-cicd enables provenance.
|
|
27
|
+
# - Pinning to a fixed hash: defeats the autoupdate purpose.
|
|
28
|
+
# - Out-of-band verification (signed manifest): overkill for the threat
|
|
29
|
+
# model described above.
|
|
30
|
+
set -euo pipefail
|
|
31
|
+
|
|
32
|
+
VERSION_FILE=".github/actions/swift-app/.daemux-version"
|
|
33
|
+
PKG="gowalk-cicd"
|
|
34
|
+
|
|
35
|
+
current=""
|
|
36
|
+
if [[ -f "$VERSION_FILE" ]]; then
|
|
37
|
+
current="$(tr -d '[:space:]' < "$VERSION_FILE")"
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if ! latest="$(npm view "$PKG" version 2>/dev/null)"; then
|
|
41
|
+
echo "::warning::autoupdate: failed to query npm for $PKG"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
latest="$(printf '%s' "$latest" | tr -d '[:space:]')"
|
|
45
|
+
|
|
46
|
+
if [[ -z "$latest" ]]; then
|
|
47
|
+
echo "::warning::autoupdate: empty version returned for $PKG"
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [[ "$current" == "$latest" ]]; then
|
|
52
|
+
echo "autoupdate: $PKG already at $latest"
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
echo "autoupdate: $PKG $current -> $latest; re-vendoring"
|
|
57
|
+
|
|
58
|
+
if ! npx --yes "$PKG@$latest"; then
|
|
59
|
+
echo "::warning::autoupdate: npx $PKG@$latest failed"
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
git config user.name >/dev/null 2>&1 || git config user.name "github-actions[bot]"
|
|
64
|
+
git config user.email >/dev/null 2>&1 || \
|
|
65
|
+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
66
|
+
|
|
67
|
+
# NOTE: We deliberately do NOT stage .github/workflows/deploy.yml here.
|
|
68
|
+
# GITHUB_TOKEN cannot push workflow-file changes regardless of
|
|
69
|
+
# `permissions: contents: write` (GH security policy: prevents CI from
|
|
70
|
+
# modifying its own triggers). Consumer-side deploy.yml updates require
|
|
71
|
+
# manual `npx --yes gowalk-cicd` by the user. Document this in
|
|
72
|
+
# the action README and changelog when shipping a deploy.yml schema change.
|
|
73
|
+
|
|
74
|
+
# Stage everything under the vendored action dir EXCEPT Python bytecode
|
|
75
|
+
# caches. The action's own scripts run during this CI job and Python
|
|
76
|
+
# generates `__pycache__/*.pyc` files inside scripts/ as a side effect of
|
|
77
|
+
# importing them. Those caches are build artifacts of *this* run, not part
|
|
78
|
+
# of the action distribution -- sweeping them into the autoupdate bot
|
|
79
|
+
# commit is benign but noisy (every clean run emits a "refresh
|
|
80
|
+
# __pycache__" diff).
|
|
81
|
+
#
|
|
82
|
+
# DESIGN DECISION: use `git add -A` (stages additions, modifications, AND
|
|
83
|
+
# deletions) instead of the previous `git ls-files -mo` flow. `ls-files
|
|
84
|
+
# -mo` enumerates modified + other (untracked) paths but NOT deletions:
|
|
85
|
+
# when an autoupdate cycle removes a script (e.g. `prepare_signing.py`
|
|
86
|
+
# gets renamed to `prepare_signing_v2.py` in a new release), the old
|
|
87
|
+
# file would stay tracked in the consumer repo forever -- the bot
|
|
88
|
+
# commit would add the new file but never remove the orphan. `git add
|
|
89
|
+
# -A` handles all three change kinds in one call. We then unstage any
|
|
90
|
+
# pycache paths that the broad `-A` swept in.
|
|
91
|
+
#
|
|
92
|
+
# Force flag (`-f`) is implicit in `git add -A` for explicitly listed
|
|
93
|
+
# pathspecs that match .gitignore patterns? No -- `-A` still honors
|
|
94
|
+
# .gitignore for untracked files. To preserve the prior semantics
|
|
95
|
+
# (consumer's .gitignore must not silently drop autoupdate-managed
|
|
96
|
+
# files like `.daemux-version`), we pass `--force` explicitly.
|
|
97
|
+
#
|
|
98
|
+
# BSD xargs (default on macos-15 runners) does NOT support
|
|
99
|
+
# `-r`/`--no-run-if-empty`, so we capture pycache paths into a variable
|
|
100
|
+
# and skip the xargs call when empty -- portable across BSD and GNU.
|
|
101
|
+
git add -A --force -- \
|
|
102
|
+
.github/actions/swift-app/ \
|
|
103
|
+
.github/actions/android-app/ 2>/dev/null || true
|
|
104
|
+
|
|
105
|
+
# Unstage Python bytecode that the broad `-A` swept in. `git diff
|
|
106
|
+
# --cached --name-only` lists every staged path; we filter to pycache
|
|
107
|
+
# entries and `git reset HEAD --` to drop them from the index. Working
|
|
108
|
+
# tree is left untouched (the .pyc files remain on disk; only their
|
|
109
|
+
# index entries are removed).
|
|
110
|
+
pycache="$(git diff --cached --name-only -- \
|
|
111
|
+
.github/actions/swift-app/ .github/actions/android-app/ \
|
|
112
|
+
| grep -E '(/__pycache__/|\.pyc$)' || true)"
|
|
113
|
+
if [[ -n "$pycache" ]]; then
|
|
114
|
+
printf '%s\n' "$pycache" \
|
|
115
|
+
| tr '\n' '\0' \
|
|
116
|
+
| xargs -0 git reset HEAD -- 2>/dev/null || true
|
|
117
|
+
fi
|
|
118
|
+
echo "autoupdate: staged refreshed iOS + Android actions (deploy.yml not staged; __pycache__ excluded)"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cert-creation primitives for the iOS native TestFlight action.
|
|
4
|
+
|
|
5
|
+
Owns the cryptographic legwork (RSA key + CSR), Apple's per-team cert
|
|
6
|
+
cap rotation (revoke OLDEST on 409), and PKCS12 serialisation. Split
|
|
7
|
+
from ``prepare_signing.py`` so the orchestrator there stays focused on
|
|
8
|
+
the load-or-regen control flow and the file count stays under the
|
|
9
|
+
project's per-file limits.
|
|
10
|
+
|
|
11
|
+
Nothing here touches the on-disk cache — callers receive raw bytes and
|
|
12
|
+
hand them to ``creds_store`` for atomic persistence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from asc_common import get_json, request
|
|
22
|
+
from cryptography import x509
|
|
23
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
25
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
26
|
+
from cryptography.x509.oid import NameOID
|
|
27
|
+
|
|
28
|
+
# Apple's cert revoke -> create pipeline is eventually consistent: a
|
|
29
|
+
# DELETE on the per-team cap-blocking cert sometimes still shows up as
|
|
30
|
+
# "active" to a follow-up POST for a second or two, returning a fresh
|
|
31
|
+
# 409 even though we just freed a slot. Sleep then retry-with-backoff
|
|
32
|
+
# rather than failing the whole CI run on a known-transient race.
|
|
33
|
+
CERT_REVOKE_PROPAGATION_DELAY_SEC = float(
|
|
34
|
+
os.getenv("CERT_REVOKE_PROPAGATION_DELAY_SEC", "2.0")
|
|
35
|
+
)
|
|
36
|
+
CERT_POST_RETRIES_AFTER_REVOKE = int(
|
|
37
|
+
os.getenv("CERT_POST_RETRIES_AFTER_REVOKE", "2")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
42
|
+
"""Generate a fresh 2048-bit RSA key + PEM CSR (base64 body only).
|
|
43
|
+
|
|
44
|
+
Returns the private key (kept in memory; the caller serialises it
|
|
45
|
+
into the PKCS12) and the CSR with PEM headers stripped, ready for
|
|
46
|
+
Apple's ``csrContent`` field.
|
|
47
|
+
"""
|
|
48
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
49
|
+
csr = (
|
|
50
|
+
x509.CertificateSigningRequestBuilder()
|
|
51
|
+
.subject_name(
|
|
52
|
+
x509.Name(
|
|
53
|
+
[
|
|
54
|
+
x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
|
|
55
|
+
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
.sign(private_key, hashes.SHA256())
|
|
60
|
+
)
|
|
61
|
+
csr_pem = csr.public_bytes(serialization.Encoding.PEM)
|
|
62
|
+
csr_payload = b"".join(
|
|
63
|
+
line for line in csr_pem.splitlines() if not line.startswith(b"-----")
|
|
64
|
+
)
|
|
65
|
+
return private_key, csr_payload
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def oldest_distribution_cert_id(token: str) -> str | None:
|
|
69
|
+
"""Return the DISTRIBUTION cert with the EARLIEST expirationDate.
|
|
70
|
+
|
|
71
|
+
Apple caps each team at 2 distribution certs; when CI hits the cap
|
|
72
|
+
we must revoke one. Picking the OLDEST is the safest choice — the
|
|
73
|
+
NEWEST cert signed the most recent build that may still be in ASC
|
|
74
|
+
processing, and revoking that mid-processing produces ITMS-90035
|
|
75
|
+
("signed with an ad-hoc certificate, not a distribution
|
|
76
|
+
certificate") on the prior run. Older certs have long since cleared
|
|
77
|
+
processing.
|
|
78
|
+
"""
|
|
79
|
+
data = get_json(
|
|
80
|
+
"/certificates",
|
|
81
|
+
token,
|
|
82
|
+
params={
|
|
83
|
+
"limit": "200",
|
|
84
|
+
"sort": "-id",
|
|
85
|
+
"filter[certificateType]": "DISTRIBUTION",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
oldest_id = None
|
|
89
|
+
oldest_exp: str | None = None
|
|
90
|
+
for cert in data.get("data", []):
|
|
91
|
+
attrs = cert.get("attributes") or {}
|
|
92
|
+
exp = attrs.get("expirationDate") or ""
|
|
93
|
+
if not exp:
|
|
94
|
+
continue
|
|
95
|
+
if oldest_exp is None or exp < oldest_exp:
|
|
96
|
+
oldest_exp = exp
|
|
97
|
+
oldest_id = cert["id"]
|
|
98
|
+
return oldest_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _post_with_revoke_backoff(token: str, body: dict):
|
|
102
|
+
"""POST a cert create after a revoke, tolerating ASC propagation lag.
|
|
103
|
+
|
|
104
|
+
Apple's DELETE -> POST pipeline is eventually consistent: a freshly
|
|
105
|
+
revoked cert may still count against the per-team cap for a brief
|
|
106
|
+
window, producing a spurious 409 on the immediate re-POST. Sleep
|
|
107
|
+
once for ``CERT_REVOKE_PROPAGATION_DELAY_SEC`` then retry up to
|
|
108
|
+
``CERT_POST_RETRIES_AFTER_REVOKE`` times with exponential backoff.
|
|
109
|
+
The final attempt drops ``allow_status`` so a real 409 surfaces as
|
|
110
|
+
Apple's full body via ``request``'s SystemExit.
|
|
111
|
+
"""
|
|
112
|
+
time.sleep(CERT_REVOKE_PROPAGATION_DELAY_SEC)
|
|
113
|
+
delay = CERT_REVOKE_PROPAGATION_DELAY_SEC
|
|
114
|
+
for attempt in range(CERT_POST_RETRIES_AFTER_REVOKE):
|
|
115
|
+
resp = request(
|
|
116
|
+
"POST", "/certificates", token, json_body=body, allow_status={409}
|
|
117
|
+
)
|
|
118
|
+
if resp.status_code != 409:
|
|
119
|
+
return resp
|
|
120
|
+
print(
|
|
121
|
+
f"Post-revoke 409 on attempt {attempt + 1}/"
|
|
122
|
+
f"{CERT_POST_RETRIES_AFTER_REVOKE}; "
|
|
123
|
+
f"sleeping {delay}s before final retry"
|
|
124
|
+
)
|
|
125
|
+
time.sleep(delay)
|
|
126
|
+
delay *= 2
|
|
127
|
+
# Final attempt without allow_status — let Apple's body surface via
|
|
128
|
+
# request()'s SystemExit if the cap is genuinely still hit.
|
|
129
|
+
return request("POST", "/certificates", token, json_body=body)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
133
|
+
"""POST a CSR to ASC; return ``(cert_id, cert_der)``.
|
|
134
|
+
|
|
135
|
+
On 409 (per-team cap of 2 hit) revoke the OLDEST existing cert
|
|
136
|
+
(see :func:`oldest_distribution_cert_id` for why) and retry with
|
|
137
|
+
backoff to absorb Apple's revoke -> create propagation lag (see
|
|
138
|
+
:func:`_post_with_revoke_backoff`).
|
|
139
|
+
"""
|
|
140
|
+
body = {
|
|
141
|
+
"data": {
|
|
142
|
+
"type": "certificates",
|
|
143
|
+
"attributes": {
|
|
144
|
+
"csrContent": csr_b64,
|
|
145
|
+
"certificateType": "DISTRIBUTION",
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
resp = request(
|
|
150
|
+
"POST", "/certificates", token, json_body=body, allow_status={409}
|
|
151
|
+
)
|
|
152
|
+
if resp.status_code == 409:
|
|
153
|
+
# Revoke the OLDEST cert, NOT the newest. The newest cert signed
|
|
154
|
+
# the previous build that may still be in ASC processing — revoking
|
|
155
|
+
# it during processing yields ITMS-90035 on the prior build.
|
|
156
|
+
# See oldest_distribution_cert_id() for the full rationale.
|
|
157
|
+
print("Distribution cert cap hit; revoking oldest existing cert")
|
|
158
|
+
target = oldest_distribution_cert_id(token)
|
|
159
|
+
if not target:
|
|
160
|
+
raise SystemExit(
|
|
161
|
+
"409 from cert create but no existing DISTRIBUTION cert "
|
|
162
|
+
"found to revoke"
|
|
163
|
+
)
|
|
164
|
+
request("DELETE", f"/certificates/{target}", token)
|
|
165
|
+
resp = _post_with_revoke_backoff(token, body)
|
|
166
|
+
data = resp.json()["data"]
|
|
167
|
+
cert_id = data["id"]
|
|
168
|
+
cert_der = base64.b64decode(data["attributes"]["certificateContent"])
|
|
169
|
+
print(f"Created DISTRIBUTION cert {cert_id}")
|
|
170
|
+
return cert_id, cert_der
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def serialize_p12(
|
|
174
|
+
private_key: rsa.RSAPrivateKey, cert_der: bytes, passwd: str
|
|
175
|
+
) -> bytes:
|
|
176
|
+
"""Return the PKCS12 bytes encrypted with ``passwd``.
|
|
177
|
+
|
|
178
|
+
Caller decides whether to write to disk via the cache layer or as a
|
|
179
|
+
transient artifact in ``$RUNNER_TEMP``.
|
|
180
|
+
"""
|
|
181
|
+
cert = x509.load_der_x509_certificate(cert_der)
|
|
182
|
+
return pkcs12.serialize_key_and_certificates(
|
|
183
|
+
name=b"Daemux CI",
|
|
184
|
+
key=private_key,
|
|
185
|
+
cert=cert,
|
|
186
|
+
cas=None,
|
|
187
|
+
encryption_algorithm=serialization.BestAvailableEncryption(
|
|
188
|
+
passwd.encode()
|
|
189
|
+
),
|
|
190
|
+
)
|