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