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