@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.82"
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.82",
15
+ "version": "0.10.84",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.82",
3
+ "version": "0.10.84",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.82",
3
+ "version": "0.10.84",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -2,22 +2,33 @@
2
2
  """
3
3
  Resolve the App Store Connect version slot for this run.
4
4
 
5
- Faithful port of gowalk-step/manage_version.py. The CI -- not the Xcode
6
- project -- decides the marketing version. The archive step overrides the
7
- pbxproj MARKETING_VERSION at build time via `MARKETING_VERSION=...` on the
8
- xcodebuild command line.
9
-
10
- Algorithm:
11
- 1. Fetch all appStoreVersions for the app (no sort, no filter).
12
- 2. No versions at all -> first release: CREATE "1.0.0".
13
- 3. Pick latest by createdDate.
14
- - state == PENDING_DEVELOPER_RELEASE -> fail (must be released first).
15
- - state == READY_FOR_SALE -> auto-bump and CREATE the next
16
- version with rollover (patch<10 -> +1; patch>=10 -> reset, minor+1;
17
- minor>=10 -> reset, major+1).
18
- - anything else (PREPARE_FOR_SUBMISSION, WAITING_FOR_REVIEW, IN_REVIEW,
19
- REJECTED, ...) -> REUSE it.
20
- 4. 409 on CREATE (race) -> re-fetch, match by versionString, REUSE that id.
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 get_json, make_jwt, request
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 "M.m" or "M.m.p"."""
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::Latest App Store versionString {current_version!r} is "
67
- f"not a valid semantic version (expected MAJOR.MINOR[.PATCH])."
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. Mirrors gowalk-step: no sort, no
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 main() -> None:
147
- key_id = env("ASC_KEY_ID")
148
- issuer_id = env("ASC_ISSUER_ID")
149
- key_path = env("ASC_KEY_PATH")
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
- # 1. First ever release.
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
- print(json.dumps(_result("CREATE", new_version, new_id)))
161
- return
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
- # 2. Pick the latest by Apple's createdDate.
164
- latest = max(versions, key=lambda v: v.get("createdDate", ""))
165
- state = latest["state"]
166
- version_string = latest["versionString"]
167
- version_id = latest["id"]
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
- # 3. Blocking: pending developer release.
170
- if state == "PENDING_DEVELOPER_RELEASE":
208
+ if blocking:
209
+ target = _pick_highest_semver(blocking)
171
210
  print(
172
- f"::error::Latest App Store version {version_string} is "
173
- f"PENDING_DEVELOPER_RELEASE. Release it in App Store Connect, "
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
- # 4. Live: auto-bump to next version.
180
- if state == "READY_FOR_SALE":
181
- new_version = calculate_next_version(version_string)
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={version_string}, "
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
- print(json.dumps(_result("CREATE", new_version, new_id)))
188
- return
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
- # 5. Editable/in-review/rejected/etc: reuse as-is.
191
- _log(f"REUSE {version_string} (state={state}, id={version_id})")
192
- print(json.dumps(_result("REUSE", version_string, version_id)))
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 profile_manager import (
48
- discover_signable_targets,
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
- patch_project_signing(project, targets, team_id)
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({bid: pname for bid, pname, _ in mappings}, indent=2)
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 + bundleId helpers for multi-target iOS projects.
3
+ Provisioning profile lifecycle helpers for the iOS native TestFlight action.
4
4
 
5
- Handles:
5
+ Talks to the App Store Connect API to:
6
6
 
7
- * Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
8
- Xcode project. We parse ``project.pbxproj`` directly (via ``plutil -convert
9
- json``) rather than rely on ``xcodebuild -showBuildSettings``, which only
10
- covers what a given scheme builds. Parsing the pbxproj lets us find every
11
- native app + extension target without tripping over SwiftPM resource
12
- bundles (whose product types are not signable).
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
- Kept separate from ``prepare_signing.py`` so both files stay well under the
24
- 400-line cap.
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
- PROFILE_PREFIX = "CI-"
40
-
41
- # Xcode 16+ moved the canonical profile directory. Older Xcode releases used
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={team_ids} "
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 a list of ``(bundle_id, profile_name, uuid)`` tuples.
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
- APP_STORE_WHATS_NEW -- release notes text (empty = skip)
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 = os.environ.get("APP_STORE_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("APP_STORE_WHATS_NEW empty; skipping.")
137
+ _log("whatsNew empty; skipping.")
108
138
  return 0
109
139
 
110
140
  version = _require_env("MARKETING_VERSION")