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,223 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Prepare iOS code signing on a fresh CI runner (manual-signing variant).
|
|
4
|
+
|
|
5
|
+
Caching: the Apple Distribution cert + per-bundle provisioning profiles
|
|
6
|
+
live under the workspace's ``creds/`` directory and are reused across
|
|
7
|
+
runs. See ``creds_store.py`` for the on-disk layout and reuse rules.
|
|
8
|
+
This script orchestrates the load-or-regen flow:
|
|
9
|
+
|
|
10
|
+
1. Try to reuse the cached cert (decrypts + NotAfter > 30d +
|
|
11
|
+
``GET /certificates/{cert_id}`` = 200).
|
|
12
|
+
2. On miss, invalidate the cache and create a fresh cert via
|
|
13
|
+
``cert_factory`` (handling Apple's per-team cap of 2 by revoking
|
|
14
|
+
the OLDEST existing one).
|
|
15
|
+
3. Hand the cert id + cache_hit flag to ``provision_all_bundles``,
|
|
16
|
+
which decides per-bundle whether to reuse a cached profile or
|
|
17
|
+
create a new one and updates the manifest.
|
|
18
|
+
4. Orphan profiles (manifest entries for bundles no longer in the
|
|
19
|
+
target list) get GC'd from disk + manifest.
|
|
20
|
+
5. After successful prep the workspace ``creds/`` tree is dirty with
|
|
21
|
+
any refreshed artifacts; ``action.yml`` commits the diff back when
|
|
22
|
+
the run is on the default branch.
|
|
23
|
+
|
|
24
|
+
Sibling modules: ``cert_factory`` (key+CSR+ASC POST), ``keychain``
|
|
25
|
+
(throwaway runner keychain), ``profile_manager`` (per-bundle profile
|
|
26
|
+
lifecycle), ``profile_io`` (CMS decode), ``creds_store`` (on-disk cache).
|
|
27
|
+
|
|
28
|
+
Env inputs: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (key trio), PROJECT
|
|
29
|
+
(.xcodeproj), TEAM_ID (optional; derived from profile plist when empty),
|
|
30
|
+
RUNNER_TEMP, CREDS_DIR (optional override; defaults to
|
|
31
|
+
``$GITHUB_WORKSPACE/creds``). Writes nothing to stdout that would leak
|
|
32
|
+
secrets.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
from datetime import datetime, timedelta, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import cert_factory
|
|
43
|
+
import creds_store
|
|
44
|
+
from asc_common import make_jwt
|
|
45
|
+
from keychain import setup_keychain
|
|
46
|
+
from pbxproj_editor import discover_signable_targets, patch_project_signing
|
|
47
|
+
from profile_manager import provision_all_bundles
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def env(name: str) -> str:
|
|
51
|
+
val = os.environ.get(name)
|
|
52
|
+
if not val:
|
|
53
|
+
raise SystemExit(f"missing env var: {name}")
|
|
54
|
+
return val
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def optional_env(name: str) -> str:
|
|
58
|
+
return os.environ.get(name, "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_or_create_cert(
|
|
62
|
+
token: str, creds_dir: Path
|
|
63
|
+
) -> tuple[str, bytes, str, bool]:
|
|
64
|
+
"""Return ``(cert_id, p12_bytes, password, cache_hit)``.
|
|
65
|
+
|
|
66
|
+
Cache hit when the on-disk cert decrypts, NotAfter > 30d away, and
|
|
67
|
+
Apple confirms the cert id is still alive. On miss the cache is
|
|
68
|
+
invalidated, a new cert is created (handling Apple's per-team cap),
|
|
69
|
+
and the cache is repopulated atomically.
|
|
70
|
+
"""
|
|
71
|
+
cached = creds_store.load_cached_cert(creds_dir)
|
|
72
|
+
if cached and creds_store.verify_cert_alive(token, cached.cert_id):
|
|
73
|
+
print(
|
|
74
|
+
f"Reusing cached distribution cert {cached.cert_id} "
|
|
75
|
+
f"(NotAfter {cached.not_after.isoformat()})"
|
|
76
|
+
)
|
|
77
|
+
# Warn when inside T-60d..T-30d so operators see renewal is pending
|
|
78
|
+
# but auto-renew has not yet fired.
|
|
79
|
+
now = datetime.now(timezone.utc)
|
|
80
|
+
renew_at = now + timedelta(days=creds_store.RENEW_THRESHOLD_DAYS)
|
|
81
|
+
warn_at = now + timedelta(days=creds_store.WARN_THRESHOLD_DAYS)
|
|
82
|
+
if renew_at < cached.not_after <= warn_at:
|
|
83
|
+
print(
|
|
84
|
+
f"::warning::Cert {cached.cert_id} expires in "
|
|
85
|
+
f"{(cached.not_after - now).days}d; auto-renew fires at "
|
|
86
|
+
f"{creds_store.RENEW_THRESHOLD_DAYS}d remaining."
|
|
87
|
+
)
|
|
88
|
+
return cached.cert_id, cached.p12_bytes, cached.password, True
|
|
89
|
+
|
|
90
|
+
if cached:
|
|
91
|
+
print(
|
|
92
|
+
f"Cached cert {cached.cert_id} no longer alive on Apple; "
|
|
93
|
+
"invalidating cache and regenerating"
|
|
94
|
+
)
|
|
95
|
+
creds_store.invalidate_cache(creds_dir)
|
|
96
|
+
|
|
97
|
+
private_key, csr_b64 = cert_factory.generate_key_and_csr()
|
|
98
|
+
cert_id, cert_der = cert_factory.create_distribution_cert(
|
|
99
|
+
token, csr_b64.decode()
|
|
100
|
+
)
|
|
101
|
+
password = creds_store.P12_PASSWORD
|
|
102
|
+
p12_bytes = cert_factory.serialize_p12(private_key, cert_der, password)
|
|
103
|
+
not_after = creds_store.cert_not_after_from_der(cert_der)
|
|
104
|
+
creds_store.write_cert_bundle(
|
|
105
|
+
creds_dir, cert_id, p12_bytes, password, not_after
|
|
106
|
+
)
|
|
107
|
+
print(f"Persisted fresh cert bundle to {creds_dir}")
|
|
108
|
+
return cert_id, p12_bytes, password, False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_creds_dir() -> Path:
|
|
112
|
+
"""Return the workspace creds/ root, honoring the CREDS_DIR override."""
|
|
113
|
+
override = optional_env("CREDS_DIR")
|
|
114
|
+
if override:
|
|
115
|
+
return Path(override)
|
|
116
|
+
workspace = optional_env("GITHUB_WORKSPACE") or os.getcwd()
|
|
117
|
+
return Path(workspace) / "creds"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_pbx_team(effective_team: str, configured_team: str) -> str:
|
|
121
|
+
"""Pick the team Xcode must see and warn on mismatch with config.
|
|
122
|
+
|
|
123
|
+
The ASC API key is bound to a single developer team. Every profile
|
|
124
|
+
it issues lives in THAT team, so Xcode's ``DEVELOPMENT_TEAM`` must
|
|
125
|
+
match it exactly — otherwise the profile can't be matched to the
|
|
126
|
+
target at archive time. If the ci.config.yaml team differs, warn
|
|
127
|
+
and use the profile's team as the source of truth.
|
|
128
|
+
"""
|
|
129
|
+
pbx_team = effective_team or configured_team
|
|
130
|
+
if not pbx_team:
|
|
131
|
+
raise SystemExit(
|
|
132
|
+
"Unable to determine Apple developer team. The installed "
|
|
133
|
+
"provisioning profile did not expose a TeamIdentifier and no "
|
|
134
|
+
"TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
|
|
135
|
+
"Find your team id at https://developer.apple.com/account -> "
|
|
136
|
+
"Membership (look for 'Team ID')."
|
|
137
|
+
)
|
|
138
|
+
if effective_team and configured_team and effective_team != configured_team:
|
|
139
|
+
print(
|
|
140
|
+
f"::warning::Config team {configured_team} differs from ASC API "
|
|
141
|
+
f"key's team {effective_team}; patching pbxproj with "
|
|
142
|
+
f"{effective_team} (the team that actually issued the "
|
|
143
|
+
"provisioning profiles)."
|
|
144
|
+
)
|
|
145
|
+
return pbx_team
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _write_signing_map(
|
|
149
|
+
runner_temp: str, pbx_team: str, mappings: list
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Persist mapping for the downstream Export IPA step.
|
|
152
|
+
|
|
153
|
+
``ExportOptions.plist`` needs a ``provisioningProfiles`` dict and
|
|
154
|
+
the effective team that issued the profiles.
|
|
155
|
+
"""
|
|
156
|
+
map_path = Path(runner_temp) / "signing_map.json"
|
|
157
|
+
map_path.write_text(
|
|
158
|
+
json.dumps(
|
|
159
|
+
{
|
|
160
|
+
"team_id": pbx_team,
|
|
161
|
+
"profiles": {bid: pname for bid, pname, _ in mappings},
|
|
162
|
+
},
|
|
163
|
+
indent=2,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
print(f"Wrote signing map to {map_path}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_project_path() -> str:
|
|
170
|
+
"""Return the .xcodeproj path; reject WORKSPACE-only mode loudly."""
|
|
171
|
+
project = optional_env("PROJECT")
|
|
172
|
+
if optional_env("WORKSPACE") and not project:
|
|
173
|
+
raise SystemExit(
|
|
174
|
+
"prepare_signing: WORKSPACE-only mode is not supported; the "
|
|
175
|
+
"underlying .xcodeproj must be passed via PROJECT so we can "
|
|
176
|
+
"patch its signing settings."
|
|
177
|
+
)
|
|
178
|
+
if not project:
|
|
179
|
+
raise SystemExit("prepare_signing: PROJECT env var is required")
|
|
180
|
+
return project
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def main() -> None:
|
|
184
|
+
runner_temp = env("RUNNER_TEMP")
|
|
185
|
+
# TEAM_ID is optional — provision_all_bundles returns the effective
|
|
186
|
+
# team from the installed profile's plist; see _resolve_pbx_team.
|
|
187
|
+
team_id = optional_env("TEAM_ID")
|
|
188
|
+
project = _resolve_project_path()
|
|
189
|
+
token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
|
|
190
|
+
creds_dir = _resolve_creds_dir()
|
|
191
|
+
|
|
192
|
+
targets = discover_signable_targets(project)
|
|
193
|
+
bundle_ids = sorted({t["bundle_id"] for t in targets})
|
|
194
|
+
print(f"Signable targets ({len(targets)}):")
|
|
195
|
+
for t in targets:
|
|
196
|
+
print(
|
|
197
|
+
f" - {t['name']} -> {t['bundle_id']} "
|
|
198
|
+
f"({len(t['config_ids'])} configs)"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
cert_id, p12_bytes, p12_pass, cache_hit = _load_or_create_cert(
|
|
202
|
+
token, creds_dir
|
|
203
|
+
)
|
|
204
|
+
mappings, effective_team = provision_all_bundles(
|
|
205
|
+
token, bundle_ids, cert_id, creds_dir=creds_dir, cache_hit=cache_hit
|
|
206
|
+
)
|
|
207
|
+
print("Profile map:")
|
|
208
|
+
for bid, pname, uuid in mappings:
|
|
209
|
+
print(f" {bid} -> {pname} ({uuid})")
|
|
210
|
+
|
|
211
|
+
pbx_team = _resolve_pbx_team(effective_team, team_id)
|
|
212
|
+
patch_project_signing(project, targets, pbx_team)
|
|
213
|
+
_write_signing_map(runner_temp, pbx_team, mappings)
|
|
214
|
+
|
|
215
|
+
# Stage p12 in $RUNNER_TEMP for the keychain importer (the cache copy
|
|
216
|
+
# under creds/cert.p12 stays untouched as the source of truth).
|
|
217
|
+
p12_path = Path(runner_temp) / "cert.p12"
|
|
218
|
+
p12_path.write_bytes(p12_bytes)
|
|
219
|
+
setup_keychain(p12_path, p12_pass, runner_temp)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
On-disk + plist-decode helpers for ``.mobileprovision`` files.
|
|
4
|
+
|
|
5
|
+
Split from ``profile_manager.py`` so the ASC-API-facing module stays
|
|
6
|
+
focused on lifecycle (create / delete / install) and this module owns
|
|
7
|
+
the file-format gymnastics (CMS envelope decode via ``security cms -D``,
|
|
8
|
+
plist date normalisation).
|
|
9
|
+
|
|
10
|
+
Nothing in this module talks to App Store Connect. Callers pass raw DER
|
|
11
|
+
bytes and receive parsed Python primitives.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import plistlib
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Apple guarantees ``ExpirationDate`` in every issued profile; if it is
|
|
23
|
+
# missing or non-datetime the profile is malformed — treat as already-
|
|
24
|
+
# expired so callers never reuse it.
|
|
25
|
+
_EXPIRED_SENTINEL = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def decode_profile_plist(profile_der: bytes, scratch_dir: Path) -> dict:
|
|
29
|
+
"""Run ``security cms -D`` against ``profile_der`` and parse the plist.
|
|
30
|
+
|
|
31
|
+
The decoded payload's only authoritative source is the macOS
|
|
32
|
+
``security`` binary; ``plistlib`` alone can't parse the CMS envelope.
|
|
33
|
+
``scratch_dir`` is used for a temporary file (``security`` only
|
|
34
|
+
accepts file paths, not stdin); it is created if missing.
|
|
35
|
+
|
|
36
|
+
May raise ``subprocess.CalledProcessError`` (decode failure),
|
|
37
|
+
``ValueError`` (plist parse failure), or ``OSError`` (filesystem
|
|
38
|
+
failure). Callers that want graceful cache fallback must catch.
|
|
39
|
+
"""
|
|
40
|
+
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp_path = scratch_dir / "tmp.mobileprovision"
|
|
42
|
+
tmp_path.write_bytes(profile_der)
|
|
43
|
+
try:
|
|
44
|
+
decoded = subprocess.check_output(
|
|
45
|
+
["security", "cms", "-D", "-i", str(tmp_path)]
|
|
46
|
+
)
|
|
47
|
+
finally:
|
|
48
|
+
tmp_path.unlink(missing_ok=True)
|
|
49
|
+
return plistlib.loads(decoded)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_utc(value) -> datetime:
|
|
53
|
+
"""Normalise a plist datetime to a UTC-aware ``datetime``.
|
|
54
|
+
|
|
55
|
+
Plist dates parsed by ``plistlib`` come back as naive UTC; explicitly
|
|
56
|
+
attach ``timezone.utc`` so downstream comparisons stay timezone-safe.
|
|
57
|
+
Anything that is not a ``datetime`` returns the expired sentinel so
|
|
58
|
+
a malformed profile is never treated as reusable.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(value, datetime):
|
|
61
|
+
return _EXPIRED_SENTINEL
|
|
62
|
+
if value.tzinfo is None:
|
|
63
|
+
return value.replace(tzinfo=timezone.utc)
|
|
64
|
+
return value.astimezone(timezone.utc)
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Provisioning profile lifecycle helpers for the iOS native TestFlight action.
|
|
4
|
+
|
|
5
|
+
Talks to the App Store Connect API to:
|
|
6
|
+
|
|
7
|
+
* Register a bundle ID if it doesn't exist yet (``ensure_bundle_id``).
|
|
8
|
+
* Delete any stale profile with the same name and create a fresh one that
|
|
9
|
+
references the just-issued distribution cert (``delete_profile_by_name``,
|
|
10
|
+
``create_profile``).
|
|
11
|
+
* Install the resulting ``.mobileprovision`` into every Xcode profile
|
|
12
|
+
directory (Xcode 15 and Xcode 16+ use different paths).
|
|
13
|
+
|
|
14
|
+
Caching
|
|
15
|
+
-------
|
|
16
|
+
|
|
17
|
+
``provision_all_bundles`` is now cache-aware. Callers pass the workspace
|
|
18
|
+
``creds_dir`` and a ``cache_hit`` flag indicating whether the cert was
|
|
19
|
+
itself reused from cache. The function:
|
|
20
|
+
|
|
21
|
+
* If the manifest's cached cert_id matches the current cert_id AND the
|
|
22
|
+
cert was reused, it tries to reuse each bundle's profile from disk
|
|
23
|
+
(``find_reusable_profile``) before falling back to a fresh
|
|
24
|
+
``create_profile`` round-trip. Profile create on Apple's side is
|
|
25
|
+
rate-limited and disturbs the team's profile-list view, so caching
|
|
26
|
+
is worth it.
|
|
27
|
+
* If the cert changed (cap rotation, expiry, manual revoke) it forces
|
|
28
|
+
a full regen — Apple invalidates a profile the moment its leaf cert
|
|
29
|
+
goes away, so reusing the on-disk file would archive an unsigned
|
|
30
|
+
binary.
|
|
31
|
+
* After the loop it GCs orphan ``.mobileprovision`` files whose bundle
|
|
32
|
+
is no longer in ``bundle_ids`` (e.g. an extension was deleted from
|
|
33
|
+
the target list) and rewrites the manifest with the current set.
|
|
34
|
+
|
|
35
|
+
Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
|
|
36
|
+
ASC-facing only.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import base64
|
|
42
|
+
import subprocess
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
import creds_store
|
|
47
|
+
from asc_common import get_json, request
|
|
48
|
+
from pbxproj_editor import PROFILE_PREFIX
|
|
49
|
+
from profile_io import decode_profile_plist, ensure_utc
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Xcode 16+ moved the canonical profile directory. Older Xcode releases
|
|
53
|
+
# used ~/Library/MobileDevice/Provisioning Profiles/. Write to BOTH so the
|
|
54
|
+
# same script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
|
|
55
|
+
_PROFILE_DIRS = [
|
|
56
|
+
Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
|
|
57
|
+
Path.home() / "Library/MobileDevice/Provisioning Profiles",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# ASC bundle ID registration #
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
|
|
65
|
+
def ensure_bundle_id(token: str, identifier: str) -> str:
|
|
66
|
+
"""Return the ASC primary key for ``identifier``; register if missing."""
|
|
67
|
+
data = get_json(
|
|
68
|
+
"/bundleIds",
|
|
69
|
+
token,
|
|
70
|
+
params={"filter[identifier]": identifier, "limit": "5"},
|
|
71
|
+
)
|
|
72
|
+
for item in data.get("data", []):
|
|
73
|
+
if item["attributes"]["identifier"] == identifier:
|
|
74
|
+
return item["id"]
|
|
75
|
+
return _register_bundle_id(token, identifier)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _register_bundle_id(token: str, identifier: str) -> str:
|
|
79
|
+
body = {
|
|
80
|
+
"data": {
|
|
81
|
+
"type": "bundleIds",
|
|
82
|
+
"attributes": {
|
|
83
|
+
"identifier": identifier,
|
|
84
|
+
"name": identifier.replace(".", "-"),
|
|
85
|
+
"platform": "IOS",
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
resp = request("POST", "/bundleIds", token, json_body=body)
|
|
90
|
+
bundle_pk = resp.json()["data"]["id"]
|
|
91
|
+
print(f"Registered new bundle id {identifier!r} (pk={bundle_pk})")
|
|
92
|
+
return bundle_pk
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------- #
|
|
96
|
+
# Profile lifecycle #
|
|
97
|
+
# --------------------------------------------------------------------------- #
|
|
98
|
+
|
|
99
|
+
def profile_name_for(bundle_id: str) -> str:
|
|
100
|
+
return f"{PROFILE_PREFIX}{bundle_id}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete_profile_by_name(token: str, name: str) -> None:
|
|
104
|
+
"""Delete every existing profile with the given name (paginated scan)."""
|
|
105
|
+
deleted_any = False
|
|
106
|
+
next_path: str | None = "/profiles?limit=200"
|
|
107
|
+
while next_path:
|
|
108
|
+
data = get_json(next_path, token)
|
|
109
|
+
for p in data.get("data", []):
|
|
110
|
+
if (p["attributes"].get("name") or "") == name:
|
|
111
|
+
pid = p["id"]
|
|
112
|
+
print(f"Deleting stale profile {pid} ({name})")
|
|
113
|
+
request("DELETE", f"/profiles/{pid}", token)
|
|
114
|
+
deleted_any = True
|
|
115
|
+
next_link = (data.get("links") or {}).get("next")
|
|
116
|
+
if not next_link:
|
|
117
|
+
break
|
|
118
|
+
idx = next_link.find("/v1")
|
|
119
|
+
next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
|
|
120
|
+
if not deleted_any:
|
|
121
|
+
print(f"No existing profile named {name!r}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def create_profile(
|
|
125
|
+
token: str, name: str, bundle_pk: str, cert_id: str
|
|
126
|
+
) -> bytes:
|
|
127
|
+
body = {
|
|
128
|
+
"data": {
|
|
129
|
+
"type": "profiles",
|
|
130
|
+
"attributes": {
|
|
131
|
+
"name": name,
|
|
132
|
+
"profileType": "IOS_APP_STORE",
|
|
133
|
+
},
|
|
134
|
+
"relationships": {
|
|
135
|
+
"bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}},
|
|
136
|
+
"certificates": {
|
|
137
|
+
"data": [{"type": "certificates", "id": cert_id}]
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
resp = request("POST", "/profiles", token, json_body=body)
|
|
143
|
+
payload = resp.json()["data"]["attributes"]["profileContent"]
|
|
144
|
+
return base64.b64decode(payload)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def install_profile(profile_der: bytes) -> tuple[str, str, datetime]:
|
|
148
|
+
"""Install the raw profile into every Xcode-visible directory.
|
|
149
|
+
|
|
150
|
+
Returns ``(uuid, team_id, expiration)``. ``team_id`` is the team
|
|
151
|
+
Apple assigned to the profile — the effective team that Xcode
|
|
152
|
+
expects to find in ``DEVELOPMENT_TEAM``. ``expiration`` is the
|
|
153
|
+
profile's ``ExpirationDate`` normalised to UTC; the caller persists
|
|
154
|
+
it in the cache manifest.
|
|
155
|
+
"""
|
|
156
|
+
plist = decode_profile_plist(profile_der, _PROFILE_DIRS[0])
|
|
157
|
+
uuid = plist["UUID"]
|
|
158
|
+
team_ids = plist.get("TeamIdentifier") or []
|
|
159
|
+
team_id = team_ids[0] if team_ids else ""
|
|
160
|
+
profile_name = plist.get("Name") or "<unknown>"
|
|
161
|
+
expiration = ensure_utc(plist.get("ExpirationDate"))
|
|
162
|
+
final_name = f"{uuid}.mobileprovision"
|
|
163
|
+
for directory in _PROFILE_DIRS:
|
|
164
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
(directory / final_name).write_bytes(profile_der)
|
|
166
|
+
print(
|
|
167
|
+
f" profile {profile_name!r}: uuid={uuid} team={team_id} "
|
|
168
|
+
f"exp={expiration.isoformat()} dirs={[str(d) for d in _PROFILE_DIRS]}"
|
|
169
|
+
)
|
|
170
|
+
return uuid, team_id, expiration
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# Orchestration #
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
|
|
177
|
+
def _try_reuse_cached(
|
|
178
|
+
bid: str,
|
|
179
|
+
name: str,
|
|
180
|
+
cert_id: str,
|
|
181
|
+
creds_dir: Path,
|
|
182
|
+
manifest: dict,
|
|
183
|
+
) -> tuple[str, str, dict] | None:
|
|
184
|
+
"""Return ``(uuid, team, entry)`` if a cached profile reinstalls cleanly.
|
|
185
|
+
|
|
186
|
+
A corrupt .mobileprovision (truncated, manually edited, ``security
|
|
187
|
+
cms`` decode failure) must NEVER abort the build — log and return
|
|
188
|
+
``None`` so the caller falls through to the fresh-create path.
|
|
189
|
+
"""
|
|
190
|
+
cached = creds_store.find_reusable_profile(manifest, bid, cert_id, creds_dir)
|
|
191
|
+
if cached is None:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
der = creds_store.profile_path(creds_dir, cached.uuid).read_bytes()
|
|
195
|
+
uuid, team_id, _ = install_profile(der)
|
|
196
|
+
except (OSError, subprocess.CalledProcessError, ValueError, KeyError) as exc:
|
|
197
|
+
print(
|
|
198
|
+
f"::warning::cached profile {cached.filename!r} unusable "
|
|
199
|
+
f"({exc!r}); regenerating"
|
|
200
|
+
)
|
|
201
|
+
return None
|
|
202
|
+
# cached.expiration is already UTC-aware (load_profile_manifest routes
|
|
203
|
+
# it through _parse_iso); no astimezone needed.
|
|
204
|
+
entry = {
|
|
205
|
+
"bundle_id": bid,
|
|
206
|
+
"name": cached.name or name,
|
|
207
|
+
"uuid": uuid,
|
|
208
|
+
"filename": f"{uuid}.mobileprovision",
|
|
209
|
+
"expiration": cached.expiration.isoformat(),
|
|
210
|
+
}
|
|
211
|
+
print(f"Reused cached profile {name} -> {uuid} ({bid})")
|
|
212
|
+
return uuid, team_id, entry
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _provision_bundle(
|
|
216
|
+
token: str,
|
|
217
|
+
bid: str,
|
|
218
|
+
cert_id: str,
|
|
219
|
+
creds_dir: Path,
|
|
220
|
+
manifest: dict,
|
|
221
|
+
can_reuse: bool,
|
|
222
|
+
) -> tuple[str, str, str, dict]:
|
|
223
|
+
"""Provision one bundle; return ``(name, uuid, team, manifest_entry)``."""
|
|
224
|
+
name = profile_name_for(bid)
|
|
225
|
+
if can_reuse:
|
|
226
|
+
reused = _try_reuse_cached(bid, name, cert_id, creds_dir, manifest)
|
|
227
|
+
if reused is not None:
|
|
228
|
+
uuid, team_id, entry = reused
|
|
229
|
+
return name, uuid, team_id, entry
|
|
230
|
+
|
|
231
|
+
bundle_pk = ensure_bundle_id(token, bid)
|
|
232
|
+
delete_profile_by_name(token, name)
|
|
233
|
+
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
234
|
+
uuid, team_id, expiration = install_profile(profile_der)
|
|
235
|
+
creds_store.write_cached_profile(creds_dir, uuid, profile_der)
|
|
236
|
+
entry = {
|
|
237
|
+
"bundle_id": bid,
|
|
238
|
+
"name": name,
|
|
239
|
+
"uuid": uuid,
|
|
240
|
+
"filename": f"{uuid}.mobileprovision",
|
|
241
|
+
"expiration": expiration.isoformat(),
|
|
242
|
+
}
|
|
243
|
+
print(f"Installed fresh profile {name} -> {uuid} ({bid})")
|
|
244
|
+
return name, uuid, team_id, entry
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _gc_orphan_profiles(
|
|
248
|
+
creds_dir: Path, old_manifest: dict, new_entries: list[dict]
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Remove cached .mobileprovision files for bundles no longer present."""
|
|
251
|
+
new_uuids = {e["uuid"] for e in new_entries}
|
|
252
|
+
for raw in old_manifest.get("profiles", []):
|
|
253
|
+
uuid = raw.get("uuid")
|
|
254
|
+
if not uuid or uuid in new_uuids:
|
|
255
|
+
continue
|
|
256
|
+
path = creds_store.profile_path(creds_dir, uuid)
|
|
257
|
+
if path.exists():
|
|
258
|
+
path.unlink(missing_ok=True)
|
|
259
|
+
print(f"GC'd orphan profile {uuid} ({raw.get('bundle_id')!r})")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def provision_all_bundles(
|
|
263
|
+
token: str,
|
|
264
|
+
bundle_ids: list[str],
|
|
265
|
+
cert_id: str,
|
|
266
|
+
*,
|
|
267
|
+
creds_dir: Path,
|
|
268
|
+
cache_hit: bool,
|
|
269
|
+
) -> tuple[list[tuple[str, str, str]], str]:
|
|
270
|
+
"""Create + install a CI profile for each bundle id, with caching.
|
|
271
|
+
|
|
272
|
+
Returns ``(mappings, team_id)`` where mappings is a list of
|
|
273
|
+
``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
|
|
274
|
+
team Apple assigned to the profiles (shared — the ASC API key binds
|
|
275
|
+
every profile it issues to a single team).
|
|
276
|
+
|
|
277
|
+
``cache_hit`` says whether the cert came from cache. ``creds_dir``
|
|
278
|
+
is the workspace ``creds/`` root. When the manifest's cert_id
|
|
279
|
+
differs from ``cert_id`` we force a full regen — Apple invalidates
|
|
280
|
+
every profile bound to the prior cert the moment that cert is
|
|
281
|
+
revoked, so reusing on-disk profiles would archive unsigned bits.
|
|
282
|
+
"""
|
|
283
|
+
manifest = creds_store.load_profile_manifest(creds_dir)
|
|
284
|
+
can_reuse = cache_hit and manifest.get("cert_id") == cert_id
|
|
285
|
+
if cache_hit and not can_reuse:
|
|
286
|
+
print(
|
|
287
|
+
f"Manifest cert_id {manifest.get('cert_id')!r} differs from "
|
|
288
|
+
f"current {cert_id!r}; regenerating all profiles"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
results: list[tuple[str, str, str]] = []
|
|
292
|
+
new_entries: list[dict] = []
|
|
293
|
+
effective_team = ""
|
|
294
|
+
for bid in bundle_ids:
|
|
295
|
+
name, uuid, team_id, entry = _provision_bundle(
|
|
296
|
+
token, bid, cert_id, creds_dir, manifest, can_reuse
|
|
297
|
+
)
|
|
298
|
+
if team_id:
|
|
299
|
+
effective_team = team_id
|
|
300
|
+
results.append((bid, name, uuid))
|
|
301
|
+
new_entries.append(entry)
|
|
302
|
+
|
|
303
|
+
_gc_orphan_profiles(creds_dir, manifest, new_entries)
|
|
304
|
+
creds_store.write_profile_manifest(
|
|
305
|
+
creds_dir, {"cert_id": cert_id, "profiles": new_entries}
|
|
306
|
+
)
|
|
307
|
+
return results, effective_team
|