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,317 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Set the App Store "What's New" text (appStoreVersionLocalizations.whatsNew) for
4
+ the draft App Store version slot prepared by manage_marketing_version.py.
5
+
6
+ Mirrors gowalk-step's `fastlane run set_changelog` behavior against the ASC
7
+ REST API (no fastlane dependency here):
8
+
9
+ * Uses the pre-resolved APP_STORE_VERSION_ID env var from the
10
+ "Resolve App Store version slot" step -- no polling, no lookup by version.
11
+ * Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
12
+ release notes to announce.
13
+ * Skips when the slot is not in a state that permits editing whatsNew.
14
+ * PATCHes whatsNew on EVERY appStoreVersionLocalization entry with the
15
+ same text. gowalk-step's set_changelog iterates every localization
16
+ (not just the default one) so users in every language see the release
17
+ notes rather than an empty "What's New" section when their locale
18
+ isn't en-US. If no localizations exist yet (brand-new version slot),
19
+ POST a single one for APP_STORE_LOCALE (default en-US) as a seed.
20
+
21
+ Non-fatal by design: any failure is reported as a ::warning:: and the action
22
+ continues. The TestFlight upload itself has already succeeded by this point.
23
+
24
+ Environment:
25
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
26
+ MARKETING_VERSION -- e.g. "1.2.3"
27
+ APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
28
+ APP_STORE_WHATS_NEW_FILE -- path to file holding release notes
29
+ (preferred; sidesteps any YAML env
30
+ interpolation that may mangle
31
+ multi-line content)
32
+ APP_STORE_WHATS_NEW -- release notes text (fallback when
33
+ _FILE is unset / unreadable)
34
+ APP_STORE_LOCALE -- locale to seed when no
35
+ localizations exist; default
36
+ "en-US". Ignored when
37
+ localizations already exist --
38
+ every existing locale is updated.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import os
44
+ import sys
45
+ from pathlib import Path
46
+
47
+ from asc_common import get_json, make_jwt, request
48
+
49
+
50
+ # gowalk-step parity: skip whatsNew for the initial release.
51
+ SKIP_VERSIONS = {"1.0", "1.0.0", "0.0", "0.0.0"}
52
+
53
+ # Apple permits editing appStoreVersionLocalizations.whatsNew only while the
54
+ # version is in an editable state. WAITING_FOR_REVIEW / IN_REVIEW /
55
+ # READY_FOR_SALE etc. are not editable -- skip non-fatally.
56
+ WHATSNEW_EDITABLE_STATES = {
57
+ "PREPARE_FOR_SUBMISSION",
58
+ "REJECTED",
59
+ "METADATA_REJECTED",
60
+ "DEVELOPER_REJECTED",
61
+ "INVALID_BINARY",
62
+ }
63
+
64
+
65
+ def _require_env(name: str) -> str:
66
+ value = os.environ.get(name, "").strip()
67
+ if not value:
68
+ print(f"::error::{name} env var is required", file=sys.stderr)
69
+ raise SystemExit(1)
70
+ return value
71
+
72
+
73
+ def _warn(msg: str) -> None:
74
+ print(f"::warning::{msg}", file=sys.stderr)
75
+
76
+
77
+ def _log(msg: str) -> None:
78
+ print(msg, file=sys.stderr)
79
+
80
+
81
+ # Apple's 409 STATE_ERROR detail string for the known-benign case where
82
+ # the per-localization slot is locked because the parent version is
83
+ # transitioning between states. Substring match (not regex / equality) so
84
+ # we don't break if Apple appends or reformats surrounding context.
85
+ _WHATSNEW_LOCKED_DETAIL = "cannot be edited at this time"
86
+
87
+
88
+ def _patch_localization(
89
+ token: str, localization_id: str, whats_new: str
90
+ ) -> bool:
91
+ """PATCH a single localization's whatsNew. Returns True on success,
92
+ False when Apple reports the localization is locked (409 STATE_ERROR).
93
+
94
+ See _handle_localization_409 for the rationale behind allow_status={409}
95
+ and the benign-lock vs unexpected-409 routing. Other non-2xx statuses
96
+ (auth, 5xx, malformed payloads, real ASC outages) are NOT in the
97
+ allow-list and continue to fail loud -- asc_common.request() retries
98
+ 5xx automatically and SystemExits on everything else.
99
+ """
100
+ resp = request(
101
+ "PATCH",
102
+ f"/appStoreVersionLocalizations/{localization_id}",
103
+ token,
104
+ json_body={
105
+ "data": {
106
+ "type": "appStoreVersionLocalizations",
107
+ "id": localization_id,
108
+ "attributes": {"whatsNew": whats_new},
109
+ }
110
+ },
111
+ allow_status={409},
112
+ )
113
+ if resp.status_code == 409:
114
+ _handle_localization_409(localization_id, resp)
115
+ return False
116
+ return True
117
+
118
+
119
+ def _handle_localization_409(localization_id: str, resp) -> None:
120
+ """Route an ASC 409 STATE_ERROR on a localization PATCH to the right
121
+ log channel based on the response detail.
122
+
123
+ Apple returns 409 STATE_ERROR ("Attribute 'whatsNew' cannot be edited
124
+ at this time") on individual localizations even when the parent
125
+ appStoreVersion's appStoreState is in the editable allow-list checked
126
+ upstream. This happens when the per-localization state is locked
127
+ independently (e.g. submitted, in-review at the localization level,
128
+ or transitioning) -- a race the version-level state check at the top
129
+ of main() cannot see.
130
+
131
+ The benign-lock detail is logged plain -- it's the expected,
132
+ non-actionable case and emitting `::warning::` every clean run is
133
+ noise. Any OTHER 409 detail (genuinely unexpected) is surfaced as
134
+ `::warning::`.
135
+
136
+ ASC error envelope: ``{"errors": [{"status": "409", "code":
137
+ "STATE_ERROR", "detail": "Attribute 'whatsNew' cannot be edited at
138
+ this time"}]}``. We collapse every error.detail into one string so
139
+ the benign-lock substring match survives multi-error responses.
140
+ Falls back to raw response text if JSON parsing fails (network
141
+ layer occasionally returns HTML on infrastructure faults).
142
+ """
143
+ try:
144
+ body = resp.json()
145
+ except ValueError:
146
+ body = None
147
+ if isinstance(body, dict):
148
+ errors = body.get("errors") or []
149
+ parts = [e.get("detail") or e.get("title") or ""
150
+ for e in errors if isinstance(e, dict)]
151
+ detail = " | ".join(p for p in parts if p)
152
+ else:
153
+ detail = resp.text or ""
154
+
155
+ if _WHATSNEW_LOCKED_DETAIL in detail:
156
+ _log(
157
+ f"[whatsNew] localization {localization_id} is currently "
158
+ f"locked, skipping (detail: {detail!r})"
159
+ )
160
+ return
161
+ _warn(
162
+ f"appStoreVersionLocalization {localization_id} returned 409 "
163
+ f"with unexpected detail {detail!r}; whatsNew not patched for "
164
+ f"this localization. Other localizations and the rest of the "
165
+ f"run continue."
166
+ )
167
+
168
+
169
+ def _create_localization(
170
+ token: str, version_id: str, locale: str, whats_new: str
171
+ ) -> str:
172
+ resp = request(
173
+ "POST",
174
+ "/appStoreVersionLocalizations",
175
+ token,
176
+ json_body={
177
+ "data": {
178
+ "type": "appStoreVersionLocalizations",
179
+ "attributes": {"locale": locale, "whatsNew": whats_new},
180
+ "relationships": {
181
+ "appStoreVersion": {
182
+ "data": {"type": "appStoreVersions", "id": version_id}
183
+ }
184
+ },
185
+ }
186
+ },
187
+ )
188
+ return resp.json()["data"]["id"]
189
+
190
+
191
+ def _read_whats_new() -> tuple[str, str]:
192
+ """Return (content, source). Prefer a file path (no env interpolation
193
+ risk). Fall back to APP_STORE_WHATS_NEW env var. Returns ("", "<empty>")
194
+ when neither is populated."""
195
+ path = os.environ.get("APP_STORE_WHATS_NEW_FILE", "").strip()
196
+ if path:
197
+ try:
198
+ content = Path(path).read_text()
199
+ return content, f"file:{path}"
200
+ except OSError as exc:
201
+ _warn(
202
+ f"APP_STORE_WHATS_NEW_FILE={path} unreadable ({exc!r}); "
203
+ f"falling back to APP_STORE_WHATS_NEW env var."
204
+ )
205
+ content = os.environ.get("APP_STORE_WHATS_NEW", "")
206
+ if content:
207
+ return content, "env:APP_STORE_WHATS_NEW"
208
+ return "", "<empty>"
209
+
210
+
211
+ def _update_all_localizations(
212
+ token: str, version_id: str, version: str, whats_new: str, seed_locale: str,
213
+ only_if_empty: bool = False,
214
+ ) -> int:
215
+ """PATCH whatsNew on every existing localization. If none exist, POST a
216
+ single seed localization in `seed_locale`. Returns the count of
217
+ localizations written.
218
+
219
+ When ``only_if_empty`` is set the text is a fallback backstop: any
220
+ localization that already has a non-blank whatsNew (e.g. AI-generated
221
+ release notes, or notes a human entered) is left untouched, and only
222
+ empty ones are filled. This lets the step run unconditionally after the
223
+ AI-metadata phase without clobbering good content, while still
224
+ guaranteeing no locale ships with an empty "What's New" (which App Store
225
+ Connect rejects as "This field is required")."""
226
+ locs = get_json(
227
+ f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
228
+ )
229
+ entries = locs.get("data") or []
230
+ if not entries:
231
+ new_id = _create_localization(token, version_id, seed_locale, whats_new)
232
+ _log(
233
+ f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
234
+ f"{version} ({seed_locale}) -- no prior localizations"
235
+ )
236
+ return 1
237
+ count = 0
238
+ skipped = 0
239
+ already = 0
240
+ for item in entries:
241
+ loc_id = item.get("id") or ""
242
+ loc = (item.get("attributes") or {}).get("locale") or "?"
243
+ if not loc_id:
244
+ _warn(f"skipping localization without id: {item!r}")
245
+ continue
246
+ if only_if_empty:
247
+ existing = ((item.get("attributes") or {}).get("whatsNew") or "").strip()
248
+ if existing:
249
+ already += 1
250
+ continue
251
+ if _patch_localization(token, loc_id, whats_new):
252
+ _log(
253
+ f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
254
+ f"for {version} ({loc})"
255
+ )
256
+ count += 1
257
+ else:
258
+ skipped += 1
259
+ if already:
260
+ _log(f"whatsNew already present on {already} localization(s); left as-is")
261
+ if skipped:
262
+ _log(f"whatsNew skipped for {skipped} locked localization(s)")
263
+ return count
264
+
265
+
266
+ def main() -> int:
267
+ whats_new, source = _read_whats_new()
268
+ _log(
269
+ f"whatsNew source={source} length={len(whats_new)} "
270
+ f"newlines={whats_new.count(chr(10))}"
271
+ )
272
+ if not whats_new.strip():
273
+ _log("whatsNew empty; skipping.")
274
+ return 0
275
+
276
+ version = _require_env("MARKETING_VERSION")
277
+ version_id = _require_env("APP_STORE_VERSION_ID")
278
+ seed_locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
279
+ only_if_empty = os.environ.get("WHATS_NEW_ONLY_IF_EMPTY", "").strip().lower() in {
280
+ "1", "true", "yes",
281
+ }
282
+
283
+ if version in SKIP_VERSIONS:
284
+ _log(f"Skipping whatsNew for first release {version}.")
285
+ return 0
286
+
287
+ token = make_jwt(
288
+ _require_env("ASC_KEY_ID"),
289
+ _require_env("ASC_ISSUER_ID"),
290
+ _require_env("ASC_KEY_PATH"),
291
+ )
292
+
293
+ ver = get_json(f"/appStoreVersions/{version_id}", token)
294
+ state = (ver.get("data") or {}).get("attributes", {}).get("appStoreState", "")
295
+ if state not in WHATSNEW_EDITABLE_STATES:
296
+ _warn(
297
+ f"appStoreVersion {version} is {state}; whatsNew not editable in "
298
+ f"this state, skipping."
299
+ )
300
+ return 0
301
+
302
+ count = _update_all_localizations(
303
+ token, version_id, version, whats_new, seed_locale,
304
+ only_if_empty=only_if_empty,
305
+ )
306
+ _log(f"whatsNew set for {count} localizations on {version}")
307
+ return 0
308
+
309
+
310
+ if __name__ == "__main__":
311
+ try:
312
+ sys.exit(main())
313
+ except SystemExit:
314
+ raise
315
+ except Exception as exc: # non-fatal per gowalk-step's set_changelog parity
316
+ _warn(f"whatsNew setter failed (non-fatal): {exc!r}")
317
+ sys.exit(0)
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Derive the Apple developer team identifier from an App Store Connect API key.
4
+
5
+ Why:
6
+ ``ci.config.yaml`` lists ``app.team_id`` as optional because the ASC API
7
+ key is always bound to exactly one team — so it's derivable. This module
8
+ implements that derivation via GET-only endpoints so the first CI run on
9
+ a fresh repo works without pre-seeding the team id.
10
+
11
+ Strategy (GET-only, no resource creation):
12
+ 1. ``GET /v1/certificates?limit=1&sort=-id`` — any Distribution /
13
+ Development cert carries the team id in its Subject's Organizational
14
+ Unit (OU) attribute. Most teams already have at least one.
15
+ 2. If no certs exist: ``GET /v1/profiles?limit=1`` — the attached
16
+ ``profileContent`` (base64-encoded ``.mobileprovision``) contains the
17
+ ``TeamIdentifier`` array in its plist payload. Profiles are CMS-signed
18
+ in production, so we fall back to ``security cms -D`` when
19
+ ``plistlib.loads`` refuses the raw bytes. When ``security`` isn't
20
+ available (unit tests), the test substitutes its own plist.
21
+ 3. If neither yields a team, return "" — the caller decides whether to
22
+ fail with an actionable message or defer to ``prepare_signing.py``'s
23
+ post-profile-install fallback.
24
+
25
+ The returned team id is a 10-character alphanumeric string (Apple's format).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import plistlib
32
+ import subprocess
33
+ from typing import Any
34
+
35
+ from asc_common import get_json
36
+ from cryptography import x509
37
+ from cryptography.x509.oid import NameOID
38
+
39
+
40
+ def _team_from_cert_der(cert_der: bytes) -> str:
41
+ """Return OU attribute from the cert's Subject, or '' if absent/malformed."""
42
+ try:
43
+ cert = x509.load_der_x509_certificate(cert_der)
44
+ except (ValueError, TypeError):
45
+ return ""
46
+ ou_attrs = cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)
47
+ if not ou_attrs:
48
+ return ""
49
+ value = ou_attrs[0].value
50
+ if isinstance(value, bytes):
51
+ value = value.decode(errors="replace")
52
+ return str(value).strip()
53
+
54
+
55
+ def _team_from_asc_certificates(token: str) -> str:
56
+ """Try GET /certificates and parse OU from the first cert's DER."""
57
+ data = get_json(
58
+ "/certificates",
59
+ token,
60
+ params={"limit": "1", "sort": "-id"},
61
+ )
62
+ items = data.get("data", []) if isinstance(data, dict) else []
63
+ if not items:
64
+ return ""
65
+ first = items[0]
66
+ attrs = first.get("attributes") or {}
67
+ b64 = attrs.get("certificateContent")
68
+ if not b64:
69
+ return ""
70
+ try:
71
+ cert_der = base64.b64decode(b64)
72
+ except (ValueError, TypeError):
73
+ return ""
74
+ return _team_from_cert_der(cert_der)
75
+
76
+
77
+ def _team_from_profile_plist(profile_bytes: bytes) -> str:
78
+ """Extract TeamIdentifier[0] from a mobileprovision plist payload."""
79
+ plist: Any
80
+ try:
81
+ plist = plistlib.loads(profile_bytes)
82
+ except (plistlib.InvalidFileException, ValueError, OSError):
83
+ # Real profiles are CMS-signed — strip the envelope via `security cms`.
84
+ try:
85
+ decoded = subprocess.check_output(
86
+ ["security", "cms", "-D", "-i", "/dev/stdin"],
87
+ input=profile_bytes,
88
+ )
89
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
90
+ return ""
91
+ try:
92
+ plist = plistlib.loads(decoded)
93
+ except (plistlib.InvalidFileException, ValueError, OSError):
94
+ return ""
95
+ if not isinstance(plist, dict):
96
+ return ""
97
+ teams = plist.get("TeamIdentifier") or []
98
+ if isinstance(teams, list) and teams:
99
+ return str(teams[0]).strip()
100
+ return ""
101
+
102
+
103
+ def _team_from_asc_profiles(token: str) -> str:
104
+ """Try GET /profiles and parse TeamIdentifier from the first profile's plist."""
105
+ data = get_json("/profiles", token, params={"limit": "1"})
106
+ items = data.get("data", []) if isinstance(data, dict) else []
107
+ if not items:
108
+ return ""
109
+ attrs = items[0].get("attributes") or {}
110
+ b64 = attrs.get("profileContent")
111
+ if not b64:
112
+ return ""
113
+ try:
114
+ profile_bytes = base64.b64decode(b64)
115
+ except (ValueError, TypeError):
116
+ return ""
117
+ return _team_from_profile_plist(profile_bytes)
118
+
119
+
120
+ def derive_team_id(token: str) -> str:
121
+ """Derive the developer team id bound to the ASC API key.
122
+
123
+ Returns the 10-char team identifier, or "" when neither a certificate
124
+ nor a provisioning profile exposes it. Never raises on missing data;
125
+ only propagates network / auth failures from the underlying ASC client.
126
+ """
127
+ team = _team_from_asc_certificates(token)
128
+ if team:
129
+ return team
130
+ return _team_from_asc_profiles(token)
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tests for app_context_scanner.py.
4
+
5
+ Uses tmp_path-style temp dirs to build a synthetic repo and asserts:
6
+ * total output stays <=10KB,
7
+ * sections appear in deterministic order across runs,
8
+ * missing files degrade gracefully to "<unavailable>",
9
+ * path-traversal / symlink escapes are rejected.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import plistlib
16
+ import sys
17
+ import tempfile
18
+ import unittest
19
+ from pathlib import Path
20
+ from unittest import mock
21
+
22
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
23
+
24
+ import app_context_scanner as scanner # noqa: E402
25
+
26
+
27
+ def _write_plist(path: Path, data: dict) -> None:
28
+ with open(path, "wb") as f:
29
+ plistlib.dump(data, f)
30
+
31
+
32
+ def _build_sample_repo(root: Path, scheme: str = "SampleApp") -> None:
33
+ (root / "README.md").write_text("# Sample App\n\nA sample VPN client.")
34
+ scheme_dir = root / scheme
35
+ scheme_dir.mkdir()
36
+ _write_plist(scheme_dir / "Info.plist", {
37
+ "CFBundleDisplayName": "Sample",
38
+ "CFBundleShortVersionString": "1.0.0",
39
+ "NSLocationWhenInUseUsageDescription": "Need location",
40
+ })
41
+ (root / "Package.swift").write_text(
42
+ "// swift-tools-version:5.9\n"
43
+ ".package(url: \"https://github.com/firebase/firebase-ios-sdk\", from: \"10.0.0\"),\n"
44
+ ".package(url: \"https://github.com/example/other\", from: \"1.0.0\"),\n"
45
+ )
46
+ (scheme_dir / "ContentView.swift").write_text(
47
+ "import SwiftUI\nstruct ContentView: View { var body: some View { Text(\"Hi\") } }\n"
48
+ )
49
+ (scheme_dir / "VPNManager.swift").write_text(
50
+ "import NetworkExtension\nclass VPNManager { }\n" * 4
51
+ )
52
+ ent = {"com.apple.developer.networking.vpn.api": ["allow-vpn"]}
53
+ _write_plist(root / "Sample.entitlements", ent)
54
+
55
+
56
+ class BuildBlobTests(unittest.TestCase):
57
+ def setUp(self):
58
+ self.tmp = tempfile.TemporaryDirectory()
59
+ self.addCleanup(self.tmp.cleanup)
60
+ self.root = Path(self.tmp.name).resolve()
61
+ _build_sample_repo(self.root)
62
+
63
+ def _run(self) -> str:
64
+ env = {
65
+ "GITHUB_WORKSPACE": str(self.root),
66
+ "BUNDLE_ID": "com.example.sample",
67
+ "SCHEME": "SampleApp",
68
+ }
69
+ with mock.patch.dict(os.environ, env, clear=True):
70
+ return scanner.build_blob()
71
+
72
+ def test_blob_has_all_sections_in_order(self):
73
+ blob = self._run()
74
+ ordered = [
75
+ "===APP CONTEXT===",
76
+ "===README===",
77
+ "===INFO_PLIST===",
78
+ "===DEPENDENCIES===",
79
+ "===SWIFT_FILES===",
80
+ "===ENTITLEMENTS===",
81
+ "===END===",
82
+ ]
83
+ positions = [blob.find(marker) for marker in ordered]
84
+ self.assertTrue(all(p >= 0 for p in positions), blob)
85
+ self.assertEqual(positions, sorted(positions))
86
+
87
+ def test_blob_under_10kb(self):
88
+ blob = self._run()
89
+ self.assertLess(len(blob.encode("utf-8")), 10_000)
90
+
91
+ def test_blob_is_deterministic_across_runs(self):
92
+ first = self._run()
93
+ runs = [self._run() for _ in range(4)]
94
+ for r in runs:
95
+ self.assertEqual(r, first)
96
+
97
+ def test_blob_contains_bundle_id_and_scheme(self):
98
+ blob = self._run()
99
+ self.assertIn("BUNDLE_ID: com.example.sample", blob)
100
+ self.assertIn("SCHEME: SampleApp", blob)
101
+
102
+ def test_missing_readme_renders_unavailable(self):
103
+ (self.root / "README.md").unlink()
104
+ blob = self._run()
105
+ # section header must still be present, with <unavailable>
106
+ readme_idx = blob.find("===README===")
107
+ info_idx = blob.find("===INFO_PLIST===")
108
+ section = blob[readme_idx:info_idx]
109
+ self.assertIn("<unavailable>", section)
110
+
111
+ def test_dependencies_lists_package_entries(self):
112
+ blob = self._run()
113
+ self.assertIn("firebase-ios-sdk", blob)
114
+
115
+ def test_entitlements_keys_present(self):
116
+ blob = self._run()
117
+ self.assertIn("com.apple.developer.networking.vpn.api", blob)
118
+
119
+ def test_info_plist_permissions_extracted(self):
120
+ blob = self._run()
121
+ self.assertIn("NSLocationWhenInUseUsageDescription", blob)
122
+
123
+
124
+ class SafePathTests(unittest.TestCase):
125
+ def test_safe_path_rejects_outside_root(self):
126
+ with tempfile.TemporaryDirectory() as tmp:
127
+ root = Path(tmp).resolve()
128
+ other = Path(tempfile.mkdtemp())
129
+ try:
130
+ (other / "leak.txt").write_text("secret")
131
+ got = scanner._safe_path(root, other / "leak.txt")
132
+ self.assertIsNone(got)
133
+ finally:
134
+ for p in other.iterdir():
135
+ p.unlink()
136
+ other.rmdir()
137
+
138
+ def test_safe_path_accepts_inside_root(self):
139
+ with tempfile.TemporaryDirectory() as tmp:
140
+ root = Path(tmp).resolve()
141
+ target = root / "inside.txt"
142
+ target.write_text("ok")
143
+ got = scanner._safe_path(root, target)
144
+ self.assertEqual(got, target.resolve())
145
+
146
+ def test_safe_path_returns_none_for_missing(self):
147
+ with tempfile.TemporaryDirectory() as tmp:
148
+ root = Path(tmp).resolve()
149
+ got = scanner._safe_path(root, root / "nope.txt")
150
+ self.assertIsNone(got)
151
+
152
+
153
+ class FailOpenTests(unittest.TestCase):
154
+ def test_main_fails_open_on_exception(self):
155
+ import io
156
+ stdout = io.StringIO()
157
+ with mock.patch.object(scanner, "build_blob", side_effect=RuntimeError("boom")), \
158
+ mock.patch.object(sys, "stdout", stdout):
159
+ rc = scanner.main()
160
+ self.assertEqual(rc, 0)
161
+ self.assertIn("===APP CONTEXT===", stdout.getvalue())
162
+ self.assertIn("<unavailable>", stdout.getvalue())
163
+ self.assertIn("===END===", stdout.getvalue())
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()