@daemux/store-automator 0.10.82 → 0.10.84
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-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/scripts/ci/ios-native/manage_marketing_version.py +119 -55
- package/templates/scripts/ci/ios-native/pbxproj_editor.py +218 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +25 -9
- package/templates/scripts/ci/ios-native/profile_manager.py +38 -250
- package/templates/scripts/ci/ios-native/read_config.py +23 -1
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +33 -3
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.84"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "store-automator",
|
|
13
13
|
"source": "./plugins/store-automator",
|
|
14
14
|
"description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.84",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -2,22 +2,33 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Resolve the App Store Connect version slot for this run.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
CI -- not the Xcode project -- decides the marketing version. The archive
|
|
6
|
+
step overrides the pbxproj MARKETING_VERSION at build time via
|
|
7
|
+
`MARKETING_VERSION=...` on the xcodebuild command line.
|
|
8
|
+
|
|
9
|
+
Algorithm (improvement over gowalk-step's "latest by createdDate"):
|
|
10
|
+
1. Fetch all appStoreVersions for the app.
|
|
11
|
+
2. Partition by state:
|
|
12
|
+
editable (PREPARE_FOR_SUBMISSION, REJECTED, METADATA_REJECTED,
|
|
13
|
+
DEVELOPER_REJECTED, INVALID_BINARY, WAITING_FOR_REVIEW,
|
|
14
|
+
IN_REVIEW)
|
|
15
|
+
blocking (PENDING_DEVELOPER_RELEASE)
|
|
16
|
+
terminal (READY_FOR_SALE, PROCESSING_FOR_APP_STORE,
|
|
17
|
+
PENDING_APPLE_RELEASE, REPLACED_WITH_NEW_VERSION,
|
|
18
|
+
REMOVED_FROM_SALE, NOT_APPLICABLE)
|
|
19
|
+
3. If editable: REUSE the HIGHEST SEMVER among editable. Picking the
|
|
20
|
+
highest semver (not the latest createdDate) matches how Apple's UI
|
|
21
|
+
presents the "next" version and avoids the "later version closed"
|
|
22
|
+
error Apple returns on uploads to a lower editable version when a
|
|
23
|
+
higher one already exists.
|
|
24
|
+
4. Elif blocking: fail with a clear error -- a human must release the
|
|
25
|
+
pending version manually before CI can proceed.
|
|
26
|
+
5. Elif terminal: pick the highest-semver terminal version.
|
|
27
|
+
- READY_FOR_SALE -> CREATE the next version via rollover bump.
|
|
28
|
+
- anything else -> fail (manual intervention required; state is
|
|
29
|
+
neither editable nor live).
|
|
30
|
+
6. No versions at all -> first release, CREATE "1.0.0".
|
|
31
|
+
7. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE.
|
|
21
32
|
|
|
22
33
|
Env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID.
|
|
23
34
|
Stdout: {"decision":"REUSE|CREATE","versionString":"...","appStoreVersionId":"..."}
|
|
@@ -31,7 +42,14 @@ import os
|
|
|
31
42
|
import re
|
|
32
43
|
import sys
|
|
33
44
|
|
|
34
|
-
from asc_common import
|
|
45
|
+
from asc_common import (
|
|
46
|
+
BLOCKING_STATES,
|
|
47
|
+
EDITABLE_STATES,
|
|
48
|
+
TERMINAL_STATES,
|
|
49
|
+
get_json,
|
|
50
|
+
make_jwt,
|
|
51
|
+
request,
|
|
52
|
+
)
|
|
35
53
|
|
|
36
54
|
|
|
37
55
|
SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
|
|
@@ -57,14 +75,23 @@ def env(name: str) -> str:
|
|
|
57
75
|
return val
|
|
58
76
|
|
|
59
77
|
|
|
78
|
+
def semver_tuple(version_string: str) -> tuple[int, int, int]:
|
|
79
|
+
"""Parse 'M.m[.p]' -> (M, m, p). Non-semver returns (-1,-1,-1) so it
|
|
80
|
+
sorts below any real version."""
|
|
81
|
+
m = SEM_RE.match(version_string or "")
|
|
82
|
+
if not m:
|
|
83
|
+
return (-1, -1, -1)
|
|
84
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or "0"))
|
|
85
|
+
|
|
86
|
+
|
|
60
87
|
def calculate_next_version(current_version: str) -> str:
|
|
61
88
|
"""Rollover bump: patch+1; patch>=10 resets to 0 and minor+1;
|
|
62
|
-
minor>=10 resets both to 0 and major+1. Accepts
|
|
89
|
+
minor>=10 resets both to 0 and major+1. Accepts 'M.m' or 'M.m.p'."""
|
|
63
90
|
m = SEM_RE.match(current_version or "")
|
|
64
91
|
if not m:
|
|
65
92
|
raise SystemExit(
|
|
66
|
-
f"::error::
|
|
67
|
-
f"
|
|
93
|
+
f"::error::App Store versionString {current_version!r} is not a "
|
|
94
|
+
f"valid semantic version (expected MAJOR.MINOR[.PATCH])."
|
|
68
95
|
)
|
|
69
96
|
major = int(m.group(1))
|
|
70
97
|
minor = int(m.group(2))
|
|
@@ -86,8 +113,7 @@ def calculate_next_version(current_version: str) -> str:
|
|
|
86
113
|
|
|
87
114
|
|
|
88
115
|
def fetch_versions(app_id: str, token: str) -> list[dict]:
|
|
89
|
-
"""All appStoreVersions for the app
|
|
90
|
-
limit, no platform filter -- we do client-side selection."""
|
|
116
|
+
"""All appStoreVersions for the app (no sort/limit/filter)."""
|
|
91
117
|
data = get_json(f"/apps/{app_id}/appStoreVersions", token)
|
|
92
118
|
out: list[dict] = []
|
|
93
119
|
for item in data.get("data", []):
|
|
@@ -122,13 +148,11 @@ def create_version(app_id: str, version: str, token: str):
|
|
|
122
148
|
|
|
123
149
|
|
|
124
150
|
def create_or_reuse(app_id: str, version: str, token: str) -> str:
|
|
125
|
-
"""POST a new version; on 409 race, re-fetch and return existing id.
|
|
126
|
-
Returns the appStoreVersion id."""
|
|
151
|
+
"""POST a new version; on 409 race, re-fetch and return existing id."""
|
|
127
152
|
resp = create_version(app_id, version, token)
|
|
128
153
|
if resp.status_code != 409:
|
|
129
154
|
return resp.json()["data"]["id"]
|
|
130
155
|
|
|
131
|
-
# Race: someone else created it between our GET and POST. Find by name.
|
|
132
156
|
print(
|
|
133
157
|
f"POST /appStoreVersions returned 409 for {version}; "
|
|
134
158
|
"re-fetching to locate the existing slot.",
|
|
@@ -143,53 +167,93 @@ def create_or_reuse(app_id: str, version: str, token: str) -> str:
|
|
|
143
167
|
)
|
|
144
168
|
|
|
145
169
|
|
|
146
|
-
def
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
app_id = env("APP_STORE_APPLE_ID")
|
|
170
|
+
def _summarize(versions: list[dict]) -> str:
|
|
171
|
+
return ", ".join(
|
|
172
|
+
f"{v['versionString']}({v['state']})" for v in versions
|
|
173
|
+
) or "<none>"
|
|
151
174
|
|
|
152
|
-
token = make_jwt(key_id, issuer_id, key_path)
|
|
153
|
-
versions = fetch_versions(app_id, token)
|
|
154
175
|
|
|
155
|
-
|
|
176
|
+
def _pick_highest_semver(versions: list[dict]) -> dict:
|
|
177
|
+
"""Tiebreak by createdDate (newer wins) when semvers collide."""
|
|
178
|
+
return max(
|
|
179
|
+
versions,
|
|
180
|
+
key=lambda v: (semver_tuple(v["versionString"]), v.get("createdDate", "")),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def decide(versions: list[dict], app_id: str, token: str) -> dict:
|
|
156
185
|
if not versions:
|
|
157
186
|
new_version = "1.0.0"
|
|
158
187
|
new_id = create_or_reuse(app_id, new_version, token)
|
|
159
188
|
_log(f"CREATE {new_version} (first release) -> appStoreVersion id={new_id}")
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
return _result("CREATE", new_version, new_id)
|
|
190
|
+
|
|
191
|
+
editable = [v for v in versions if v["state"] in EDITABLE_STATES]
|
|
192
|
+
blocking = [v for v in versions if v["state"] in BLOCKING_STATES]
|
|
193
|
+
terminal = [v for v in versions if v["state"] in TERMINAL_STATES]
|
|
194
|
+
_log(
|
|
195
|
+
f"editable=[{_summarize(editable)}] "
|
|
196
|
+
f"blocking=[{_summarize(blocking)}] "
|
|
197
|
+
f"terminal=[{_summarize(terminal)}]"
|
|
198
|
+
)
|
|
162
199
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
200
|
+
if editable:
|
|
201
|
+
target = _pick_highest_semver(editable)
|
|
202
|
+
_log(
|
|
203
|
+
f"REUSE {target['versionString']} (highest-semver editable, "
|
|
204
|
+
f"state={target['state']}, id={target['id']})"
|
|
205
|
+
)
|
|
206
|
+
return _result("REUSE", target["versionString"], target["id"])
|
|
168
207
|
|
|
169
|
-
|
|
170
|
-
|
|
208
|
+
if blocking:
|
|
209
|
+
target = _pick_highest_semver(blocking)
|
|
171
210
|
print(
|
|
172
|
-
f"::error::
|
|
173
|
-
f"
|
|
174
|
-
f"then retry.",
|
|
211
|
+
f"::error::App Store version {target['versionString']} is "
|
|
212
|
+
f"{target['state']}. Release it in App Store Connect, then retry.",
|
|
175
213
|
file=sys.stderr,
|
|
176
214
|
)
|
|
177
215
|
raise SystemExit(1)
|
|
178
216
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
217
|
+
if terminal:
|
|
218
|
+
target = _pick_highest_semver(terminal)
|
|
219
|
+
if target["state"] != "READY_FOR_SALE":
|
|
220
|
+
print(
|
|
221
|
+
f"::error::Highest App Store version {target['versionString']} "
|
|
222
|
+
f"is {target['state']}; neither editable nor live. Manual "
|
|
223
|
+
f"intervention required in App Store Connect.",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
raise SystemExit(1)
|
|
227
|
+
new_version = calculate_next_version(target["versionString"])
|
|
182
228
|
new_id = create_or_reuse(app_id, new_version, token)
|
|
183
229
|
_log(
|
|
184
|
-
f"CREATE {new_version} (previous={
|
|
185
|
-
f"previous_state={state}) -> appStoreVersion id={new_id}"
|
|
230
|
+
f"CREATE {new_version} (previous={target['versionString']}, "
|
|
231
|
+
f"previous_state={target['state']}) -> appStoreVersion id={new_id}"
|
|
186
232
|
)
|
|
187
|
-
|
|
188
|
-
|
|
233
|
+
return _result("CREATE", new_version, new_id)
|
|
234
|
+
|
|
235
|
+
# Unknown state (not editable, not blocking, not terminal) -- refuse to
|
|
236
|
+
# guess. Lists both states in the error so ops can update the classifier.
|
|
237
|
+
unknown = ", ".join(f"{v['versionString']}({v['state']})" for v in versions)
|
|
238
|
+
print(
|
|
239
|
+
f"::error::No editable/blocking/terminal App Store versions found; "
|
|
240
|
+
f"all versions have unrecognized states: {unknown}. Update "
|
|
241
|
+
f"asc_common.EDITABLE_STATES / TERMINAL_STATES / BLOCKING_STATES.",
|
|
242
|
+
file=sys.stderr,
|
|
243
|
+
)
|
|
244
|
+
raise SystemExit(1)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main() -> None:
|
|
248
|
+
key_id = env("ASC_KEY_ID")
|
|
249
|
+
issuer_id = env("ASC_ISSUER_ID")
|
|
250
|
+
key_path = env("ASC_KEY_PATH")
|
|
251
|
+
app_id = env("APP_STORE_APPLE_ID")
|
|
189
252
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
253
|
+
token = make_jwt(key_id, issuer_id, key_path)
|
|
254
|
+
versions = fetch_versions(app_id, token)
|
|
255
|
+
result = decide(versions, app_id, token)
|
|
256
|
+
print(json.dumps(result))
|
|
193
257
|
|
|
194
258
|
|
|
195
259
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Xcode ``project.pbxproj`` helpers.
|
|
4
|
+
|
|
5
|
+
Parses native targets and patches their XCBuildConfiguration buildSettings
|
|
6
|
+
blocks with manual signing values, while keeping the file's original
|
|
7
|
+
OpenStep format (comments, ordering, the !$*UTF8*$! header) intact. SwiftPM
|
|
8
|
+
and other non-native targets are ignored so their build settings stay on
|
|
9
|
+
whatever defaults Xcode / SwiftPM picked.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
PROFILE_PREFIX = "CI-"
|
|
21
|
+
|
|
22
|
+
# Product types that must be signed with a provisioning profile. App
|
|
23
|
+
# extensions share the common ``com.apple.product-type.app-extension``
|
|
24
|
+
# prefix (Message Filter, Widgets, NetworkExtension, WatchKit, etc).
|
|
25
|
+
_SIGNABLE_PRODUCT_TYPES = {
|
|
26
|
+
"com.apple.product-type.application",
|
|
27
|
+
"com.apple.product-type.application.on-demand-install-capable",
|
|
28
|
+
"com.apple.product-type.application.watchapp2",
|
|
29
|
+
"com.apple.product-type.watchkit2-extension",
|
|
30
|
+
}
|
|
31
|
+
_SIGNABLE_PRODUCT_PREFIXES = (
|
|
32
|
+
"com.apple.product-type.app-extension",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_SIGNING_KEYS = {
|
|
36
|
+
"CODE_SIGN_STYLE": "Manual",
|
|
37
|
+
"CODE_SIGN_IDENTITY": '"Apple Distribution"',
|
|
38
|
+
}
|
|
39
|
+
_STRIP_KEYS = ("PROVISIONING_PROFILE",)
|
|
40
|
+
|
|
41
|
+
_SETTING_LINE = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Parsing #
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
|
|
48
|
+
def _load_pbxproj(project_path: str) -> dict:
|
|
49
|
+
pbx = Path(project_path) / "project.pbxproj"
|
|
50
|
+
out = subprocess.check_output(
|
|
51
|
+
["plutil", "-convert", "json", "-o", "-", str(pbx)]
|
|
52
|
+
)
|
|
53
|
+
return json.loads(out)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_signable_product_type(product_type: str) -> bool:
|
|
57
|
+
if product_type in _SIGNABLE_PRODUCT_TYPES:
|
|
58
|
+
return True
|
|
59
|
+
return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
63
|
+
"""Return the first non-empty bundle id across the given configs."""
|
|
64
|
+
for cid in config_ids:
|
|
65
|
+
cfg = objects.get(cid) or {}
|
|
66
|
+
settings = cfg.get("buildSettings") or {}
|
|
67
|
+
bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
|
|
68
|
+
if bid and "$(" not in bid and "${" not in bid:
|
|
69
|
+
return bid
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def discover_signable_targets(project_path: str) -> list[dict]:
|
|
74
|
+
"""Return one entry per signable native target.
|
|
75
|
+
|
|
76
|
+
Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
|
|
77
|
+
where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
|
|
78
|
+
``buildSettings`` dict we need to patch (typically Debug + Release).
|
|
79
|
+
"""
|
|
80
|
+
pbx = _load_pbxproj(project_path)
|
|
81
|
+
objects = pbx["objects"]
|
|
82
|
+
targets: list[dict] = []
|
|
83
|
+
for obj in objects.values():
|
|
84
|
+
if obj.get("isa") != "PBXNativeTarget":
|
|
85
|
+
continue
|
|
86
|
+
if not _is_signable_product_type(obj.get("productType") or ""):
|
|
87
|
+
continue
|
|
88
|
+
name = obj.get("name") or "<unknown>"
|
|
89
|
+
config_list = objects.get(obj.get("buildConfigurationList")) or {}
|
|
90
|
+
config_ids = list(config_list.get("buildConfigurations") or [])
|
|
91
|
+
bundle_id = _bundle_id_from_configs(objects, config_ids)
|
|
92
|
+
if not bundle_id:
|
|
93
|
+
print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
|
|
94
|
+
continue
|
|
95
|
+
targets.append(
|
|
96
|
+
{"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
|
|
97
|
+
)
|
|
98
|
+
if not targets:
|
|
99
|
+
raise SystemExit(
|
|
100
|
+
f"discover_signable_targets: no signable targets found in "
|
|
101
|
+
f"{project_path}"
|
|
102
|
+
)
|
|
103
|
+
return targets
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
# Mutation #
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
|
|
110
|
+
def patch_project_signing(
|
|
111
|
+
project_path: str,
|
|
112
|
+
targets: list[dict],
|
|
113
|
+
team_id: str,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Set manual signing + per-target profile name for every target.
|
|
116
|
+
|
|
117
|
+
Edits the pbxproj as text so formatting (comments, ordering, header)
|
|
118
|
+
survives. Scope is strictly the XCBuildConfiguration blocks referenced
|
|
119
|
+
by the ``targets`` list. SwiftPM / resource-bundle configs stay put.
|
|
120
|
+
"""
|
|
121
|
+
pbx_path = Path(project_path) / "project.pbxproj"
|
|
122
|
+
text = pbx_path.read_text(encoding="utf-8")
|
|
123
|
+
for target in targets:
|
|
124
|
+
profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
|
|
125
|
+
for cid in target["config_ids"]:
|
|
126
|
+
text = _apply_signing_to_config(text, cid, team_id, profile_name)
|
|
127
|
+
print(
|
|
128
|
+
f"Patched {target['name']!r} -> {profile_name} "
|
|
129
|
+
f"(configs={len(target['config_ids'])})"
|
|
130
|
+
)
|
|
131
|
+
pbx_path.write_text(text, encoding="utf-8")
|
|
132
|
+
subprocess.check_call(["plutil", "-lint", str(pbx_path)])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _apply_signing_to_config(
|
|
136
|
+
text: str, config_id: str, team_id: str, profile_name: str
|
|
137
|
+
) -> str:
|
|
138
|
+
start = text.find(f"\t\t{config_id} ")
|
|
139
|
+
if start < 0:
|
|
140
|
+
start = text.find(f"\t\t{config_id}\t")
|
|
141
|
+
if start < 0:
|
|
142
|
+
start = text.find(f"{config_id} = {{")
|
|
143
|
+
if start < 0:
|
|
144
|
+
raise SystemExit(
|
|
145
|
+
f"patch_project_signing: config {config_id} not found"
|
|
146
|
+
)
|
|
147
|
+
key = "buildSettings = {"
|
|
148
|
+
s_idx = text.find(key, start)
|
|
149
|
+
if s_idx < 0:
|
|
150
|
+
raise SystemExit(
|
|
151
|
+
f"patch_project_signing: buildSettings not found for {config_id}"
|
|
152
|
+
)
|
|
153
|
+
end = _match_brace(text, s_idx + len(key) - 1)
|
|
154
|
+
if end < 0:
|
|
155
|
+
raise SystemExit(
|
|
156
|
+
f"patch_project_signing: unbalanced buildSettings for {config_id}"
|
|
157
|
+
)
|
|
158
|
+
inner = text[s_idx + len(key): end]
|
|
159
|
+
patched = _patch_inner_settings(inner, team_id, profile_name)
|
|
160
|
+
return text[: s_idx + len(key)] + patched + text[end:]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _match_brace(text: str, open_idx: int) -> int:
|
|
164
|
+
"""Return the index of the ``}`` that closes the ``{`` at ``open_idx``."""
|
|
165
|
+
depth = 0
|
|
166
|
+
i = open_idx
|
|
167
|
+
while i < len(text):
|
|
168
|
+
ch = text[i]
|
|
169
|
+
if ch == "{":
|
|
170
|
+
depth += 1
|
|
171
|
+
elif ch == "}":
|
|
172
|
+
depth -= 1
|
|
173
|
+
if depth == 0:
|
|
174
|
+
return i
|
|
175
|
+
i += 1
|
|
176
|
+
return -1
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _patch_inner_settings(
|
|
180
|
+
inner: str, team_id: str, profile_name: str
|
|
181
|
+
) -> str:
|
|
182
|
+
"""Rewrite build-setting lines inside one buildSettings block."""
|
|
183
|
+
values = dict(_SIGNING_KEYS)
|
|
184
|
+
values["DEVELOPMENT_TEAM"] = team_id
|
|
185
|
+
values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
|
|
186
|
+
|
|
187
|
+
lines = inner.splitlines(keepends=True)
|
|
188
|
+
emitted: set[str] = set()
|
|
189
|
+
out: list[str] = []
|
|
190
|
+
for line in lines:
|
|
191
|
+
m = _SETTING_LINE.match(line)
|
|
192
|
+
if not m:
|
|
193
|
+
out.append(line)
|
|
194
|
+
continue
|
|
195
|
+
indent, key_name = m.group(1), m.group(2)
|
|
196
|
+
if key_name in _STRIP_KEYS:
|
|
197
|
+
continue
|
|
198
|
+
if key_name in values:
|
|
199
|
+
out.append(f"{indent}{key_name} = {values[key_name]};\n")
|
|
200
|
+
emitted.add(key_name)
|
|
201
|
+
continue
|
|
202
|
+
out.append(line)
|
|
203
|
+
|
|
204
|
+
missing = [k for k in values if k not in emitted]
|
|
205
|
+
if missing:
|
|
206
|
+
indent = "\t\t\t\t"
|
|
207
|
+
for line in lines:
|
|
208
|
+
m = _SETTING_LINE.match(line)
|
|
209
|
+
if m:
|
|
210
|
+
indent = m.group(1)
|
|
211
|
+
break
|
|
212
|
+
tail = out[-1] if out else ""
|
|
213
|
+
new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
|
|
214
|
+
if tail.strip() == "":
|
|
215
|
+
out = out[:-1] + new_lines + [tail]
|
|
216
|
+
else:
|
|
217
|
+
out = out + new_lines
|
|
218
|
+
return "".join(out)
|
|
@@ -44,11 +44,8 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|
|
44
44
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
45
45
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
46
46
|
from cryptography.x509.oid import NameOID
|
|
47
|
-
from
|
|
48
|
-
|
|
49
|
-
patch_project_signing,
|
|
50
|
-
provision_all_bundles,
|
|
51
|
-
)
|
|
47
|
+
from pbxproj_editor import discover_signable_targets, patch_project_signing
|
|
48
|
+
from profile_manager import provision_all_bundles
|
|
52
49
|
|
|
53
50
|
|
|
54
51
|
def env(name: str) -> str:
|
|
@@ -250,18 +247,37 @@ def main() -> None:
|
|
|
250
247
|
private_key, csr_b64 = generate_key_and_csr()
|
|
251
248
|
cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
|
|
252
249
|
|
|
253
|
-
mappings = provision_all_bundles(token, bundle_ids, cert_id)
|
|
250
|
+
mappings, effective_team = provision_all_bundles(token, bundle_ids, cert_id)
|
|
254
251
|
print("Profile map:")
|
|
255
252
|
for bid, pname, uuid in mappings:
|
|
256
253
|
print(f" {bid} -> {pname} ({uuid})")
|
|
257
254
|
|
|
258
|
-
|
|
255
|
+
# The ASC API key is bound to a single developer team. Every profile it
|
|
256
|
+
# issues lives in THAT team, so Xcode's DEVELOPMENT_TEAM setting must
|
|
257
|
+
# match it exactly — otherwise the profile can't be matched to the
|
|
258
|
+
# target at archive time. If the ci.config.yaml team differs, warn and
|
|
259
|
+
# use the profile's team as the source of truth.
|
|
260
|
+
pbx_team = effective_team or team_id
|
|
261
|
+
if effective_team and effective_team != team_id:
|
|
262
|
+
print(
|
|
263
|
+
f"::warning::Config team {team_id} differs from ASC API key's "
|
|
264
|
+
f"team {effective_team}; patching pbxproj with {effective_team} "
|
|
265
|
+
"(the team that actually issued the provisioning profiles)."
|
|
266
|
+
)
|
|
267
|
+
patch_project_signing(project, targets, pbx_team)
|
|
259
268
|
|
|
260
269
|
# Persist mapping for downstream Export IPA step (ExportOptions.plist
|
|
261
|
-
# provisioningProfiles dict).
|
|
270
|
+
# provisioningProfiles dict). Store the effective team alongside so
|
|
271
|
+
# Export uses the same team Apple assigned to the profiles.
|
|
262
272
|
map_path = Path(runner_temp) / "signing_map.json"
|
|
263
273
|
map_path.write_text(
|
|
264
|
-
json.dumps(
|
|
274
|
+
json.dumps(
|
|
275
|
+
{
|
|
276
|
+
"team_id": pbx_team,
|
|
277
|
+
"profiles": {bid: pname for bid, pname, _ in mappings},
|
|
278
|
+
},
|
|
279
|
+
indent=2,
|
|
280
|
+
)
|
|
265
281
|
)
|
|
266
282
|
print(f"Wrote signing map to {map_path}")
|
|
267
283
|
|
|
@@ -1,274 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Provisioning profile
|
|
3
|
+
Provisioning profile lifecycle helpers for the iOS native TestFlight action.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Talks to the App Store Connect API to:
|
|
6
6
|
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* Ensuring each bundle ID is registered on App Store Connect.
|
|
14
|
-
* Creating one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
15
|
-
``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
|
|
16
|
-
profile with that name already exists, it is deleted first so we always
|
|
17
|
-
end up with a profile that references the fresh cert.
|
|
18
|
-
* Installing every profile into the standard Xcode profile directory.
|
|
19
|
-
* Patching the pbxproj in place so each signable target's build settings use
|
|
20
|
-
manual signing + the freshly-created profile. SwiftPM / resource bundle
|
|
21
|
-
targets (which cannot carry provisioning profiles) are left untouched.
|
|
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).
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
Xcode pbxproj editing lives in ``pbxproj_editor.py``; this file is
|
|
15
|
+
ASC-facing only.
|
|
25
16
|
"""
|
|
26
17
|
|
|
27
18
|
from __future__ import annotations
|
|
28
19
|
|
|
29
20
|
import base64
|
|
30
|
-
import json
|
|
31
21
|
import plistlib
|
|
32
|
-
import re
|
|
33
22
|
import subprocess
|
|
34
23
|
from pathlib import Path
|
|
35
24
|
|
|
36
25
|
from asc_common import get_json, request
|
|
26
|
+
from pbxproj_editor import PROFILE_PREFIX
|
|
37
27
|
|
|
38
28
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
#
|
|
42
|
-
# ~/Library/MobileDevice/Provisioning Profiles/. We write to BOTH so the same
|
|
43
|
-
# script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
|
|
29
|
+
# Xcode 16+ moved the canonical profile directory. Older Xcode releases
|
|
30
|
+
# used ~/Library/MobileDevice/Provisioning Profiles/. Write to BOTH so the
|
|
31
|
+
# same script works on macos-14 (Xcode 15) and macos-15 (Xcode 26).
|
|
44
32
|
_PROFILE_DIRS = [
|
|
45
33
|
Path.home() / "Library/Developer/Xcode/UserData/Provisioning Profiles",
|
|
46
34
|
Path.home() / "Library/MobileDevice/Provisioning Profiles",
|
|
47
35
|
]
|
|
48
36
|
|
|
49
|
-
# Product types that must be signed with a provisioning profile. App
|
|
50
|
-
# extensions share the common ``com.apple.product-type.app-extension`` prefix
|
|
51
|
-
# (messages, widgets, NetworkExtension, WatchKit, etc).
|
|
52
|
-
_SIGNABLE_PRODUCT_TYPES = {
|
|
53
|
-
"com.apple.product-type.application",
|
|
54
|
-
"com.apple.product-type.application.on-demand-install-capable",
|
|
55
|
-
"com.apple.product-type.application.watchapp2",
|
|
56
|
-
"com.apple.product-type.watchkit2-extension",
|
|
57
|
-
}
|
|
58
|
-
_SIGNABLE_PRODUCT_PREFIXES = (
|
|
59
|
-
"com.apple.product-type.app-extension",
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# --------------------------------------------------------------------------- #
|
|
64
|
-
# pbxproj parsing #
|
|
65
|
-
# --------------------------------------------------------------------------- #
|
|
66
|
-
|
|
67
|
-
def _load_pbxproj(project_path: str) -> dict:
|
|
68
|
-
pbx = Path(project_path) / "project.pbxproj"
|
|
69
|
-
out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
|
|
70
|
-
return json.loads(out)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _is_signable_product_type(product_type: str) -> bool:
|
|
74
|
-
if product_type in _SIGNABLE_PRODUCT_TYPES:
|
|
75
|
-
return True
|
|
76
|
-
return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def discover_signable_targets(project_path: str) -> list[dict]:
|
|
80
|
-
"""Return one entry per signable native target.
|
|
81
|
-
|
|
82
|
-
Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
|
|
83
|
-
where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
|
|
84
|
-
``buildSettings`` dict we need to patch (one per build configuration —
|
|
85
|
-
typically Debug + Release).
|
|
86
|
-
"""
|
|
87
|
-
pbx = _load_pbxproj(project_path)
|
|
88
|
-
objects = pbx["objects"]
|
|
89
|
-
targets: list[dict] = []
|
|
90
|
-
for _obj_id, obj in objects.items():
|
|
91
|
-
if obj.get("isa") != "PBXNativeTarget":
|
|
92
|
-
continue
|
|
93
|
-
product_type = obj.get("productType") or ""
|
|
94
|
-
if not _is_signable_product_type(product_type):
|
|
95
|
-
continue
|
|
96
|
-
name = obj.get("name") or "<unknown>"
|
|
97
|
-
config_list_id = obj.get("buildConfigurationList")
|
|
98
|
-
config_list = objects.get(config_list_id) or {}
|
|
99
|
-
config_ids = list(config_list.get("buildConfigurations") or [])
|
|
100
|
-
bundle_id = _bundle_id_from_configs(objects, config_ids)
|
|
101
|
-
if not bundle_id:
|
|
102
|
-
print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
|
|
103
|
-
continue
|
|
104
|
-
targets.append(
|
|
105
|
-
{"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
|
|
106
|
-
)
|
|
107
|
-
if not targets:
|
|
108
|
-
raise SystemExit(
|
|
109
|
-
f"discover_signable_targets: no signable targets found in {project_path}"
|
|
110
|
-
)
|
|
111
|
-
return targets
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
115
|
-
"""Return the first non-empty bundle id across the given configs."""
|
|
116
|
-
for cid in config_ids:
|
|
117
|
-
cfg = objects.get(cid) or {}
|
|
118
|
-
settings = cfg.get("buildSettings") or {}
|
|
119
|
-
bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
|
|
120
|
-
if bid and "$(" not in bid and "${" not in bid:
|
|
121
|
-
return bid
|
|
122
|
-
return ""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# --------------------------------------------------------------------------- #
|
|
126
|
-
# pbxproj mutation #
|
|
127
|
-
# --------------------------------------------------------------------------- #
|
|
128
|
-
|
|
129
|
-
def patch_project_signing(
|
|
130
|
-
project_path: str,
|
|
131
|
-
targets: list[dict],
|
|
132
|
-
team_id: str,
|
|
133
|
-
) -> None:
|
|
134
|
-
"""Set manual signing + per-target profile name for every signable target.
|
|
135
|
-
|
|
136
|
-
The pbxproj stays in its original OpenStep format — Xcode is very
|
|
137
|
-
opinionated about that file and any conversion (to XML/JSON) risks
|
|
138
|
-
re-ordering keys or dropping comments. Instead, we edit the specific
|
|
139
|
-
``XCBuildConfiguration`` blocks by their UUID and inject/replace the
|
|
140
|
-
handful of keys we care about. Targets outside ``targets`` (most
|
|
141
|
-
importantly SwiftPM / resource-bundle configs) are untouched.
|
|
142
|
-
"""
|
|
143
|
-
pbx_path = Path(project_path) / "project.pbxproj"
|
|
144
|
-
text = pbx_path.read_text(encoding="utf-8")
|
|
145
|
-
|
|
146
|
-
for target in targets:
|
|
147
|
-
profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
|
|
148
|
-
for cid in target["config_ids"]:
|
|
149
|
-
text = _apply_signing_to_config(text, cid, team_id, profile_name)
|
|
150
|
-
print(
|
|
151
|
-
f"Patched {target['name']!r} -> {profile_name} "
|
|
152
|
-
f"(configs={len(target['config_ids'])})"
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
pbx_path.write_text(text, encoding="utf-8")
|
|
156
|
-
# Sanity check — plutil -lint rejects malformed output so CI fails
|
|
157
|
-
# fast before ``xcodebuild`` tries (and mangles) the file.
|
|
158
|
-
subprocess.check_call(["plutil", "-lint", str(pbx_path)])
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _apply_signing_to_config(
|
|
162
|
-
text: str, config_id: str, team_id: str, profile_name: str
|
|
163
|
-
) -> str:
|
|
164
|
-
"""Return ``text`` with the given XCBuildConfiguration's buildSettings
|
|
165
|
-
patched for manual signing. Raises ``SystemExit`` if the config block
|
|
166
|
-
can't be located (indicates a pbxproj format we don't understand).
|
|
167
|
-
"""
|
|
168
|
-
start = text.find(f"\t\t{config_id} ")
|
|
169
|
-
if start < 0:
|
|
170
|
-
start = text.find(f"\t\t{config_id}\t")
|
|
171
|
-
if start < 0:
|
|
172
|
-
start = text.find(f"{config_id} = {{")
|
|
173
|
-
if start < 0:
|
|
174
|
-
raise SystemExit(
|
|
175
|
-
f"patch_project_signing: config {config_id} not found in pbxproj"
|
|
176
|
-
)
|
|
177
|
-
settings_key = "buildSettings = {"
|
|
178
|
-
s_idx = text.find(settings_key, start)
|
|
179
|
-
if s_idx < 0:
|
|
180
|
-
raise SystemExit(
|
|
181
|
-
f"patch_project_signing: buildSettings not found for {config_id}"
|
|
182
|
-
)
|
|
183
|
-
# Find matching closing '};' for buildSettings — brace-balance forward.
|
|
184
|
-
depth = 0
|
|
185
|
-
i = s_idx + len(settings_key) - 1 # position at the opening '{'
|
|
186
|
-
end = -1
|
|
187
|
-
while i < len(text):
|
|
188
|
-
ch = text[i]
|
|
189
|
-
if ch == "{":
|
|
190
|
-
depth += 1
|
|
191
|
-
elif ch == "}":
|
|
192
|
-
depth -= 1
|
|
193
|
-
if depth == 0:
|
|
194
|
-
end = i
|
|
195
|
-
break
|
|
196
|
-
i += 1
|
|
197
|
-
if end < 0:
|
|
198
|
-
raise SystemExit(
|
|
199
|
-
f"patch_project_signing: unbalanced buildSettings for {config_id}"
|
|
200
|
-
)
|
|
201
|
-
inner = text[s_idx + len(settings_key): end]
|
|
202
|
-
patched = _patch_inner_settings(inner, team_id, profile_name)
|
|
203
|
-
return text[: s_idx + len(settings_key)] + patched + text[end:]
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
_SIGNING_KEYS = {
|
|
207
|
-
"CODE_SIGN_STYLE": "Manual",
|
|
208
|
-
"CODE_SIGN_IDENTITY": '"Apple Distribution"',
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
_STRIP_KEYS = ("PROVISIONING_PROFILE",)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _patch_inner_settings(
|
|
215
|
-
inner: str, team_id: str, profile_name: str
|
|
216
|
-
) -> str:
|
|
217
|
-
"""Rewrite build setting key/value lines inside one buildSettings block."""
|
|
218
|
-
# Always-set values
|
|
219
|
-
values = dict(_SIGNING_KEYS)
|
|
220
|
-
values["DEVELOPMENT_TEAM"] = team_id
|
|
221
|
-
values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
|
|
222
|
-
|
|
223
|
-
lines = inner.splitlines(keepends=True)
|
|
224
|
-
emitted_keys: set[str] = set()
|
|
225
|
-
out: list[str] = []
|
|
226
|
-
# Each setting line looks like (whitespace-prefixed):
|
|
227
|
-
# KEY = VALUE;
|
|
228
|
-
# We match leading indent, the key, `= `, the rest.
|
|
229
|
-
pattern = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
|
|
230
|
-
|
|
231
|
-
for line in lines:
|
|
232
|
-
m = pattern.match(line)
|
|
233
|
-
if not m:
|
|
234
|
-
out.append(line)
|
|
235
|
-
continue
|
|
236
|
-
indent, key, _old_value = m.group(1), m.group(2), m.group(3)
|
|
237
|
-
if key in _STRIP_KEYS:
|
|
238
|
-
# Drop these keys entirely.
|
|
239
|
-
continue
|
|
240
|
-
if key in values:
|
|
241
|
-
out.append(f"{indent}{key} = {values[key]};\n")
|
|
242
|
-
emitted_keys.add(key)
|
|
243
|
-
continue
|
|
244
|
-
out.append(line)
|
|
245
|
-
|
|
246
|
-
# Any key we wanted to set but didn't find — append just before close.
|
|
247
|
-
missing = [k for k in values if k not in emitted_keys]
|
|
248
|
-
if missing:
|
|
249
|
-
# Determine indent from the first KEY= line we can find, else use
|
|
250
|
-
# two tabs (the pbxproj default).
|
|
251
|
-
indent = "\t\t\t\t"
|
|
252
|
-
for line in lines:
|
|
253
|
-
m = pattern.match(line)
|
|
254
|
-
if m:
|
|
255
|
-
indent = m.group(1)
|
|
256
|
-
break
|
|
257
|
-
tail = out[-1] if out else ""
|
|
258
|
-
# Ensure we inject before the trailing whitespace on the last line.
|
|
259
|
-
new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
|
|
260
|
-
if tail.strip() == "":
|
|
261
|
-
out = out[:-1] + new_lines + [tail]
|
|
262
|
-
else:
|
|
263
|
-
out = out + new_lines
|
|
264
|
-
return "".join(out)
|
|
265
|
-
|
|
266
37
|
|
|
267
38
|
# --------------------------------------------------------------------------- #
|
|
268
39
|
# ASC bundle ID registration #
|
|
269
40
|
# --------------------------------------------------------------------------- #
|
|
270
41
|
|
|
271
42
|
def ensure_bundle_id(token: str, identifier: str) -> str:
|
|
43
|
+
"""Return the ASC primary key for ``identifier``; register if missing."""
|
|
272
44
|
data = get_json(
|
|
273
45
|
"/bundleIds",
|
|
274
46
|
token,
|
|
@@ -306,6 +78,7 @@ def profile_name_for(bundle_id: str) -> str:
|
|
|
306
78
|
|
|
307
79
|
|
|
308
80
|
def delete_profile_by_name(token: str, name: str) -> None:
|
|
81
|
+
"""Delete every existing profile with the given name (paginated scan)."""
|
|
309
82
|
deleted_any = False
|
|
310
83
|
next_path: str | None = "/profiles?limit=200"
|
|
311
84
|
while next_path:
|
|
@@ -348,7 +121,15 @@ def create_profile(
|
|
|
348
121
|
return base64.b64decode(payload)
|
|
349
122
|
|
|
350
123
|
|
|
351
|
-
def install_profile(profile_der: bytes) -> str:
|
|
124
|
+
def install_profile(profile_der: bytes) -> tuple[str, str]:
|
|
125
|
+
"""Install the raw profile into every Xcode-visible directory.
|
|
126
|
+
|
|
127
|
+
Returns ``(uuid, team_id)``. ``team_id`` is the team Apple assigned to
|
|
128
|
+
the profile — the effective team that Xcode expects to find in
|
|
129
|
+
``DEVELOPMENT_TEAM``. It may differ from any team value in
|
|
130
|
+
ci.config.yaml; the ASC API key is bound to exactly one team and every
|
|
131
|
+
profile it issues inherits that team.
|
|
132
|
+
"""
|
|
352
133
|
primary = _PROFILE_DIRS[0]
|
|
353
134
|
primary.mkdir(parents=True, exist_ok=True)
|
|
354
135
|
tmp_path = primary / "tmp.mobileprovision"
|
|
@@ -359,6 +140,7 @@ def install_profile(profile_der: bytes) -> str:
|
|
|
359
140
|
plist = plistlib.loads(decoded)
|
|
360
141
|
uuid = plist["UUID"]
|
|
361
142
|
team_ids = plist.get("TeamIdentifier") or []
|
|
143
|
+
team_id = team_ids[0] if team_ids else ""
|
|
362
144
|
profile_name = plist.get("Name") or "<unknown>"
|
|
363
145
|
final_name = f"{uuid}.mobileprovision"
|
|
364
146
|
for directory in _PROFILE_DIRS:
|
|
@@ -366,10 +148,10 @@ def install_profile(profile_der: bytes) -> str:
|
|
|
366
148
|
(directory / final_name).write_bytes(profile_der)
|
|
367
149
|
tmp_path.unlink(missing_ok=True)
|
|
368
150
|
print(
|
|
369
|
-
f" profile {profile_name!r}: uuid={uuid} team={
|
|
151
|
+
f" profile {profile_name!r}: uuid={uuid} team={team_id} "
|
|
370
152
|
f"dirs={[str(d) for d in _PROFILE_DIRS]}"
|
|
371
153
|
)
|
|
372
|
-
return uuid
|
|
154
|
+
return uuid, team_id
|
|
373
155
|
|
|
374
156
|
|
|
375
157
|
# --------------------------------------------------------------------------- #
|
|
@@ -380,18 +162,24 @@ def provision_all_bundles(
|
|
|
380
162
|
token: str,
|
|
381
163
|
bundle_ids: list[str],
|
|
382
164
|
cert_id: str,
|
|
383
|
-
) -> list[tuple[str, str, str]]:
|
|
165
|
+
) -> tuple[list[tuple[str, str, str]], str]:
|
|
384
166
|
"""Create + install a CI profile for each bundle id.
|
|
385
167
|
|
|
386
|
-
Returns
|
|
168
|
+
Returns ``(mappings, team_id)`` where mappings is a list of
|
|
169
|
+
``(bundle_id, profile_name, uuid)`` tuples and ``team_id`` is the
|
|
170
|
+
team Apple assigned to the profiles (shared — the ASC API key binds
|
|
171
|
+
every profile it issues to a single team).
|
|
387
172
|
"""
|
|
388
173
|
results: list[tuple[str, str, str]] = []
|
|
174
|
+
effective_team = ""
|
|
389
175
|
for bid in bundle_ids:
|
|
390
176
|
name = profile_name_for(bid)
|
|
391
177
|
bundle_pk = ensure_bundle_id(token, bid)
|
|
392
178
|
delete_profile_by_name(token, name)
|
|
393
179
|
profile_der = create_profile(token, name, bundle_pk, cert_id)
|
|
394
|
-
uuid = install_profile(profile_der)
|
|
180
|
+
uuid, team_id = install_profile(profile_der)
|
|
181
|
+
if team_id:
|
|
182
|
+
effective_team = team_id
|
|
395
183
|
print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
|
|
396
184
|
results.append((bid, name, uuid))
|
|
397
|
-
return results
|
|
185
|
+
return results, effective_team
|
|
@@ -38,8 +38,14 @@ Writes to $GITHUB_ENV:
|
|
|
38
38
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH, ASC_KEY_P8,
|
|
39
39
|
CFG_PROJECT, CFG_WORKSPACE, CFG_SCHEME, CFG_CONFIGURATION,
|
|
40
40
|
CFG_BUNDLE_ID, CFG_TEAM_ID, CFG_APP_STORE_APPLE_ID, CFG_PROFILE_NAME,
|
|
41
|
-
CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_LOCALE,
|
|
41
|
+
CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_WHATS_NEW_FILE, CFG_LOCALE,
|
|
42
42
|
CFG_RUN_TESTS, CFG_TEST_COMMAND, CFG_TEST_DESTINATION
|
|
43
|
+
|
|
44
|
+
CFG_WHATS_NEW_FILE points at a file under $RUNNER_TEMP holding the raw
|
|
45
|
+
multi-line release notes. Downstream steps MUST prefer the file over the
|
|
46
|
+
env-var copy: GitHub Actions YAML's `${{ }}` substitution serializes
|
|
47
|
+
multi-line strings in ways that can mangle embedded newlines, while a file
|
|
48
|
+
path is a single-line string that survives interpolation untouched.
|
|
43
49
|
"""
|
|
44
50
|
|
|
45
51
|
from __future__ import annotations
|
|
@@ -284,6 +290,22 @@ def main() -> None:
|
|
|
284
290
|
shown = value if name != "CFG_WHATS_NEW" else value.replace("\n", " / ")
|
|
285
291
|
log(f"{name}={shown!r} (source: {src})")
|
|
286
292
|
|
|
293
|
+
# Persist whatsNew to a file under $RUNNER_TEMP so downstream steps can
|
|
294
|
+
# read raw multi-line content without any GitHub Actions ${{ }} YAML
|
|
295
|
+
# interpolation mangling embedded newlines. CFG_WHATS_NEW still carries
|
|
296
|
+
# the same content for backward compatibility; the file is the source
|
|
297
|
+
# of truth.
|
|
298
|
+
whats_new_value = tf["whats_new"][0]
|
|
299
|
+
runner_temp = os.environ.get("RUNNER_TEMP") or str(workspace)
|
|
300
|
+
whats_new_path = Path(runner_temp) / "whats_new.txt"
|
|
301
|
+
whats_new_path.write_text(whats_new_value)
|
|
302
|
+
emit(env_file, "CFG_WHATS_NEW_FILE", str(whats_new_path))
|
|
303
|
+
log(
|
|
304
|
+
f"CFG_WHATS_NEW_FILE={whats_new_path} "
|
|
305
|
+
f"(length={len(whats_new_value)}, "
|
|
306
|
+
f"newlines={whats_new_value.count(chr(10))})"
|
|
307
|
+
)
|
|
308
|
+
|
|
287
309
|
|
|
288
310
|
if __name__ == "__main__":
|
|
289
311
|
main()
|
|
@@ -21,7 +21,12 @@ Environment:
|
|
|
21
21
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH -- App Store Connect API credentials
|
|
22
22
|
MARKETING_VERSION -- e.g. "1.2.3"
|
|
23
23
|
APP_STORE_VERSION_ID -- appStoreVersion id (REUSE or CREATE)
|
|
24
|
-
|
|
24
|
+
APP_STORE_WHATS_NEW_FILE -- path to file holding release notes
|
|
25
|
+
(preferred; sidesteps any YAML env
|
|
26
|
+
interpolation that may mangle
|
|
27
|
+
multi-line content)
|
|
28
|
+
APP_STORE_WHATS_NEW -- release notes text (fallback when
|
|
29
|
+
_FILE is unset / unreadable)
|
|
25
30
|
APP_STORE_LOCALE -- locale code, default "en-US"
|
|
26
31
|
"""
|
|
27
32
|
|
|
@@ -29,6 +34,7 @@ from __future__ import annotations
|
|
|
29
34
|
|
|
30
35
|
import os
|
|
31
36
|
import sys
|
|
37
|
+
from pathlib import Path
|
|
32
38
|
|
|
33
39
|
from asc_common import get_json, make_jwt, request
|
|
34
40
|
|
|
@@ -101,10 +107,34 @@ def _create_localization(
|
|
|
101
107
|
return resp.json()["data"]["id"]
|
|
102
108
|
|
|
103
109
|
|
|
110
|
+
def _read_whats_new() -> tuple[str, str]:
|
|
111
|
+
"""Return (content, source). Prefer a file path (no env interpolation
|
|
112
|
+
risk). Fall back to APP_STORE_WHATS_NEW env var. Returns ("", "<empty>")
|
|
113
|
+
when neither is populated."""
|
|
114
|
+
path = os.environ.get("APP_STORE_WHATS_NEW_FILE", "").strip()
|
|
115
|
+
if path:
|
|
116
|
+
try:
|
|
117
|
+
content = Path(path).read_text()
|
|
118
|
+
return content, f"file:{path}"
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
_warn(
|
|
121
|
+
f"APP_STORE_WHATS_NEW_FILE={path} unreadable ({exc!r}); "
|
|
122
|
+
f"falling back to APP_STORE_WHATS_NEW env var."
|
|
123
|
+
)
|
|
124
|
+
content = os.environ.get("APP_STORE_WHATS_NEW", "")
|
|
125
|
+
if content:
|
|
126
|
+
return content, "env:APP_STORE_WHATS_NEW"
|
|
127
|
+
return "", "<empty>"
|
|
128
|
+
|
|
129
|
+
|
|
104
130
|
def main() -> int:
|
|
105
|
-
whats_new =
|
|
131
|
+
whats_new, source = _read_whats_new()
|
|
132
|
+
_log(
|
|
133
|
+
f"whatsNew source={source} length={len(whats_new)} "
|
|
134
|
+
f"newlines={whats_new.count(chr(10))}"
|
|
135
|
+
)
|
|
106
136
|
if not whats_new.strip():
|
|
107
|
-
_log("
|
|
137
|
+
_log("whatsNew empty; skipping.")
|
|
108
138
|
return 0
|
|
109
139
|
|
|
110
140
|
version = _require_env("MARKETING_VERSION")
|