@daemux/store-automator 0.10.79 → 0.10.81

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.79"
8
+ "version": "0.10.81"
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.79",
15
+ "version": "0.10.81",
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.79",
3
+ "version": "0.10.81",
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.79",
3
+ "version": "0.10.81",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -7,26 +7,24 @@ Steps:
7
7
  1. Generate an RSA private key + CSR.
8
8
  2. Create a new Apple Distribution certificate from the CSR; if the per-team
9
9
  cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
10
- 3. Enumerate PRODUCT_BUNDLE_IDENTIFIER for every signable target in the
11
- Xcode project / workspace (main app + extensions).
10
+ 3. Discover every signable native target in the Xcode project (main app +
11
+ extensions like Network Extensions, Widgets, WatchKit apps). SwiftPM
12
+ resource bundles are skipped — they can't be signed manually.
12
13
  4. Register each bundle ID on App Store Connect if missing.
13
14
  5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
14
15
  ``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
15
16
  with the same name so the reference matches the fresh cert).
16
17
  6. Install every profile into ``~/Library/MobileDevice/Provisioning Profiles/``.
17
- 7. Import cert + private key into a temporary keychain placed at the head of
18
+ 7. Patch the pbxproj in place so each signable target's build settings
19
+ select manual signing + the matching CI profile. SwiftPM targets are
20
+ left alone — they keep their default automatic (no-sign) config.
21
+ 8. Import cert + private key into a temporary keychain placed at the head of
18
22
  the user search list so codesign / xcodebuild can find it.
19
23
 
20
- Pairs with a temporary xcconfig that sets
21
- ``PROVISIONING_PROFILE_SPECIFIER = CI-$(PRODUCT_BUNDLE_IDENTIFIER)``, which
22
- Xcode resolves per target at build time. This lets apps with Network
23
- Extensions (and any other aux targets) sign manually in CI without
24
- per-project pbxproj edits.
25
-
26
24
  Environment inputs:
27
25
  ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
28
- PROJECT or WORKSPACE - Xcode project / workspace path
29
- SCHEME, CONFIGURATION - Scheme + configuration to inspect
26
+ PROJECT - Xcode project path (.xcodeproj)
27
+ TEAM_ID - Apple developer team identifier
30
28
  RUNNER_TEMP - GitHub Actions temp dir
31
29
 
32
30
  Writes nothing to stdout that would leak secrets.
@@ -46,7 +44,11 @@ from cryptography.hazmat.primitives import hashes, serialization
46
44
  from cryptography.hazmat.primitives.asymmetric import rsa
47
45
  from cryptography.hazmat.primitives.serialization import pkcs12
48
46
  from cryptography.x509.oid import NameOID
49
- from profile_manager import discover_bundle_ids, provision_all_bundles
47
+ from profile_manager import (
48
+ discover_signable_targets,
49
+ patch_project_signing,
50
+ provision_all_bundles,
51
+ )
50
52
 
51
53
 
52
54
  def env(name: str) -> str:
@@ -61,7 +63,6 @@ def optional_env(name: str) -> str:
61
63
 
62
64
 
63
65
  def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
64
- """Return (private_key, csr_payload_b64_bytes)."""
65
66
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
66
67
  csr = (
67
68
  x509.CertificateSigningRequestBuilder()
@@ -83,7 +84,6 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
83
84
 
84
85
 
85
86
  def newest_distribution_cert_id(token: str) -> str | None:
86
- """Return the ID of the most-recently-created DISTRIBUTION cert, or None."""
87
87
  data = get_json(
88
88
  "/certificates",
89
89
  token,
@@ -105,11 +105,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
105
105
 
106
106
 
107
107
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
108
- """Return (certificate_id, DER-encoded certificate bytes).
109
-
110
- Apple enforces a per-team cap on active Distribution certs. If the POST
111
- comes back 409, revoke the newest existing one and retry once.
112
- """
113
108
  body = {
114
109
  "data": {
115
110
  "type": "certificates",
@@ -231,20 +226,26 @@ def main() -> None:
231
226
  issuer_id = env("ASC_ISSUER_ID")
232
227
  asc_key_path = env("ASC_KEY_PATH")
233
228
  runner_temp = env("RUNNER_TEMP")
229
+ team_id = env("TEAM_ID")
234
230
 
235
231
  project = optional_env("PROJECT")
236
232
  workspace = optional_env("WORKSPACE")
237
- scheme = env("SCHEME")
238
- configuration = env("CONFIGURATION")
239
- if not project and not workspace:
233
+ if workspace and not project:
240
234
  raise SystemExit(
241
- "prepare_signing: either PROJECT or WORKSPACE env var must be set"
235
+ "prepare_signing: WORKSPACE-only mode is not supported; the "
236
+ "underlying .xcodeproj must be passed via PROJECT so we can "
237
+ "patch its signing settings."
242
238
  )
239
+ if not project:
240
+ raise SystemExit("prepare_signing: PROJECT env var is required")
243
241
 
244
242
  token = make_jwt(key_id, issuer_id, asc_key_path)
245
243
 
246
- bundle_ids = discover_bundle_ids(project, workspace, scheme, configuration)
247
- print(f"Bundle ids to provision ({len(bundle_ids)}): {bundle_ids}")
244
+ targets = discover_signable_targets(project)
245
+ bundle_ids = sorted({t["bundle_id"] for t in targets})
246
+ print(f"Signable targets ({len(targets)}):")
247
+ for t in targets:
248
+ print(f" - {t['name']} -> {t['bundle_id']} ({len(t['config_ids'])} configs)")
248
249
 
249
250
  private_key, csr_b64 = generate_key_and_csr()
250
251
  cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
@@ -254,9 +255,10 @@ def main() -> None:
254
255
  for bid, pname, uuid in mappings:
255
256
  print(f" {bid} -> {pname} ({uuid})")
256
257
 
257
- # Persist the bundle_id -> profile_name map so later steps (exportArchive)
258
- # can build ExportOptions.plist's `provisioningProfiles` dict without
259
- # re-deriving it. JSON keeps it trivial to parse from bash/python.
258
+ patch_project_signing(project, targets, team_id)
259
+
260
+ # Persist mapping for downstream Export IPA step (ExportOptions.plist
261
+ # provisioningProfiles dict).
260
262
  map_path = Path(runner_temp) / "signing_map.json"
261
263
  map_path.write_text(
262
264
  json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
@@ -5,14 +5,20 @@ Provisioning profile + bundleId helpers for multi-target iOS projects.
5
5
  Handles:
6
6
 
7
7
  * Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
8
- Xcode project / workspace (main app + extensions like Network Extensions,
9
- Widgets, WatchKit apps, etc).
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).
10
13
  * Ensuring each bundle ID is registered on App Store Connect.
11
14
  * Creating one IOS_APP_STORE provisioning profile per bundle ID, named
12
15
  ``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
13
16
  profile with that name already exists, it is deleted first so we always
14
17
  end up with a profile that references the fresh cert.
15
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.
16
22
 
17
23
  Kept separate from ``prepare_signing.py`` so both files stay well under the
18
24
  400-line cap.
@@ -23,6 +29,7 @@ from __future__ import annotations
23
29
  import base64
24
30
  import json
25
31
  import plistlib
32
+ import re
26
33
  import subprocess
27
34
  from pathlib import Path
28
35
 
@@ -32,62 +39,222 @@ from asc_common import get_json, request
32
39
  PROFILE_PREFIX = "CI-"
33
40
  PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
34
41
 
42
+ # Product types that must be signed with a provisioning profile. App
43
+ # extensions share the common ``com.apple.product-type.app-extension`` prefix
44
+ # (messages, widgets, NetworkExtension, WatchKit, etc).
45
+ _SIGNABLE_PRODUCT_TYPES = {
46
+ "com.apple.product-type.application",
47
+ "com.apple.product-type.application.on-demand-install-capable",
48
+ "com.apple.product-type.application.watchapp2",
49
+ "com.apple.product-type.watchkit2-extension",
50
+ }
51
+ _SIGNABLE_PRODUCT_PREFIXES = (
52
+ "com.apple.product-type.app-extension",
53
+ )
54
+
35
55
 
36
56
  # --------------------------------------------------------------------------- #
37
- # Bundle ID discovery #
57
+ # pbxproj parsing #
38
58
  # --------------------------------------------------------------------------- #
39
59
 
40
- def discover_bundle_ids(
41
- project: str,
42
- workspace: str,
43
- scheme: str,
44
- configuration: str,
45
- ) -> list[str]:
46
- """Return the sorted, de-duplicated list of signable bundle IDs.
47
-
48
- Uses ``xcodebuild -showBuildSettings -json`` which returns one settings
49
- dictionary per target in the dependency graph for the given scheme. We
50
- keep every ``PRODUCT_BUNDLE_IDENTIFIER`` that looks real (non-empty,
51
- not a placeholder, contains at least one dot).
60
+ def _load_pbxproj(project_path: str) -> dict:
61
+ pbx = Path(project_path) / "project.pbxproj"
62
+ out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
63
+ return json.loads(out)
64
+
65
+
66
+ def _is_signable_product_type(product_type: str) -> bool:
67
+ if product_type in _SIGNABLE_PRODUCT_TYPES:
68
+ return True
69
+ return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
70
+
71
+
72
+ def discover_signable_targets(project_path: str) -> list[dict]:
73
+ """Return one entry per signable native target.
74
+
75
+ Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
76
+ where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
77
+ ``buildSettings`` dict we need to patch (one per build configuration —
78
+ typically Debug + Release).
52
79
  """
53
- proj_args = ["-workspace", workspace] if workspace else ["-project", project]
54
- cmd = [
55
- "xcodebuild",
56
- *proj_args,
57
- "-scheme", scheme,
58
- "-configuration", configuration,
59
- "-showBuildSettings",
60
- "-json",
61
- ]
62
- result = subprocess.run(
63
- cmd, capture_output=True, text=True, check=True,
64
- )
65
- settings_list = json.loads(result.stdout or "[]")
66
- found: set[str] = set()
67
- for entry in settings_list:
68
- attrs = entry.get("buildSettings") or {}
69
- bundle_id = (attrs.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
70
- if _is_real_bundle_id(bundle_id):
71
- found.add(bundle_id)
72
- if not found:
80
+ pbx = _load_pbxproj(project_path)
81
+ objects = pbx["objects"]
82
+ targets: list[dict] = []
83
+ for _obj_id, obj in objects.items():
84
+ if obj.get("isa") != "PBXNativeTarget":
85
+ continue
86
+ product_type = obj.get("productType") or ""
87
+ if not _is_signable_product_type(product_type):
88
+ continue
89
+ name = obj.get("name") or "<unknown>"
90
+ config_list_id = obj.get("buildConfigurationList")
91
+ config_list = objects.get(config_list_id) or {}
92
+ config_ids = list(config_list.get("buildConfigurations") or [])
93
+ bundle_id = _bundle_id_from_configs(objects, config_ids)
94
+ if not bundle_id:
95
+ print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
96
+ continue
97
+ targets.append(
98
+ {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
99
+ )
100
+ if not targets:
73
101
  raise SystemExit(
74
- "discover_bundle_ids: no PRODUCT_BUNDLE_IDENTIFIER values found "
75
- f"for scheme {scheme!r} (configuration={configuration!r})"
102
+ f"discover_signable_targets: no signable targets found in {project_path}"
76
103
  )
77
- return sorted(found)
104
+ return targets
105
+
106
+
107
+ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
108
+ """Return the first non-empty bundle id across the given configs."""
109
+ for cid in config_ids:
110
+ cfg = objects.get(cid) or {}
111
+ settings = cfg.get("buildSettings") or {}
112
+ bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
113
+ if bid and "$(" not in bid and "${" not in bid:
114
+ return bid
115
+ return ""
78
116
 
79
117
 
80
- def _is_real_bundle_id(value: str) -> bool:
81
- if not value:
82
- return False
83
- if "$(" in value or "${" in value:
84
- return False
85
- if "." not in value:
86
- return False
87
- # Xcode emits these when a target has no explicit identifier.
88
- if value.lower().startswith("com.apple."):
89
- return False
90
- return True
118
+ # --------------------------------------------------------------------------- #
119
+ # pbxproj mutation #
120
+ # --------------------------------------------------------------------------- #
121
+
122
+ def patch_project_signing(
123
+ project_path: str,
124
+ targets: list[dict],
125
+ team_id: str,
126
+ ) -> None:
127
+ """Set manual signing + per-target profile name for every signable target.
128
+
129
+ The pbxproj stays in its original OpenStep format — Xcode is very
130
+ opinionated about that file and any conversion (to XML/JSON) risks
131
+ re-ordering keys or dropping comments. Instead, we edit the specific
132
+ ``XCBuildConfiguration`` blocks by their UUID and inject/replace the
133
+ handful of keys we care about. Targets outside ``targets`` (most
134
+ importantly SwiftPM / resource-bundle configs) are untouched.
135
+ """
136
+ pbx_path = Path(project_path) / "project.pbxproj"
137
+ text = pbx_path.read_text(encoding="utf-8")
138
+
139
+ for target in targets:
140
+ profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
141
+ for cid in target["config_ids"]:
142
+ text = _apply_signing_to_config(text, cid, team_id, profile_name)
143
+ print(
144
+ f"Patched {target['name']!r} -> {profile_name} "
145
+ f"(configs={len(target['config_ids'])})"
146
+ )
147
+
148
+ pbx_path.write_text(text, encoding="utf-8")
149
+ # Sanity check — plutil -lint rejects malformed output so CI fails
150
+ # fast before ``xcodebuild`` tries (and mangles) the file.
151
+ subprocess.check_call(["plutil", "-lint", str(pbx_path)])
152
+
153
+
154
+ def _apply_signing_to_config(
155
+ text: str, config_id: str, team_id: str, profile_name: str
156
+ ) -> str:
157
+ """Return ``text`` with the given XCBuildConfiguration's buildSettings
158
+ patched for manual signing. Raises ``SystemExit`` if the config block
159
+ can't be located (indicates a pbxproj format we don't understand).
160
+ """
161
+ start = text.find(f"\t\t{config_id} ")
162
+ if start < 0:
163
+ start = text.find(f"\t\t{config_id}\t")
164
+ if start < 0:
165
+ start = text.find(f"{config_id} = {{")
166
+ if start < 0:
167
+ raise SystemExit(
168
+ f"patch_project_signing: config {config_id} not found in pbxproj"
169
+ )
170
+ settings_key = "buildSettings = {"
171
+ s_idx = text.find(settings_key, start)
172
+ if s_idx < 0:
173
+ raise SystemExit(
174
+ f"patch_project_signing: buildSettings not found for {config_id}"
175
+ )
176
+ # Find matching closing '};' for buildSettings — brace-balance forward.
177
+ depth = 0
178
+ i = s_idx + len(settings_key) - 1 # position at the opening '{'
179
+ end = -1
180
+ while i < len(text):
181
+ ch = text[i]
182
+ if ch == "{":
183
+ depth += 1
184
+ elif ch == "}":
185
+ depth -= 1
186
+ if depth == 0:
187
+ end = i
188
+ break
189
+ i += 1
190
+ if end < 0:
191
+ raise SystemExit(
192
+ f"patch_project_signing: unbalanced buildSettings for {config_id}"
193
+ )
194
+ inner = text[s_idx + len(settings_key): end]
195
+ patched = _patch_inner_settings(inner, team_id, profile_name)
196
+ return text[: s_idx + len(settings_key)] + patched + text[end:]
197
+
198
+
199
+ _SIGNING_KEYS = {
200
+ "CODE_SIGN_STYLE": "Manual",
201
+ "CODE_SIGN_IDENTITY": '"Apple Distribution"',
202
+ }
203
+
204
+ _STRIP_KEYS = ("PROVISIONING_PROFILE",)
205
+
206
+
207
+ def _patch_inner_settings(
208
+ inner: str, team_id: str, profile_name: str
209
+ ) -> str:
210
+ """Rewrite build setting key/value lines inside one buildSettings block."""
211
+ # Always-set values
212
+ values = dict(_SIGNING_KEYS)
213
+ values["DEVELOPMENT_TEAM"] = team_id
214
+ values["PROVISIONING_PROFILE_SPECIFIER"] = f'"{profile_name}"'
215
+
216
+ lines = inner.splitlines(keepends=True)
217
+ emitted_keys: set[str] = set()
218
+ out: list[str] = []
219
+ # Each setting line looks like (whitespace-prefixed):
220
+ # KEY = VALUE;
221
+ # We match leading indent, the key, `= `, the rest.
222
+ pattern = re.compile(r"^(\s*)([A-Z_][A-Z0-9_]*)\s*=\s*(.+);\s*$")
223
+
224
+ for line in lines:
225
+ m = pattern.match(line)
226
+ if not m:
227
+ out.append(line)
228
+ continue
229
+ indent, key, _old_value = m.group(1), m.group(2), m.group(3)
230
+ if key in _STRIP_KEYS:
231
+ # Drop these keys entirely.
232
+ continue
233
+ if key in values:
234
+ out.append(f"{indent}{key} = {values[key]};\n")
235
+ emitted_keys.add(key)
236
+ continue
237
+ out.append(line)
238
+
239
+ # Any key we wanted to set but didn't find — append just before close.
240
+ missing = [k for k in values if k not in emitted_keys]
241
+ if missing:
242
+ # Determine indent from the first KEY= line we can find, else use
243
+ # two tabs (the pbxproj default).
244
+ indent = "\t\t\t\t"
245
+ for line in lines:
246
+ m = pattern.match(line)
247
+ if m:
248
+ indent = m.group(1)
249
+ break
250
+ tail = out[-1] if out else ""
251
+ # Ensure we inject before the trailing whitespace on the last line.
252
+ new_lines = [f"{indent}{k} = {values[k]};\n" for k in missing]
253
+ if tail.strip() == "":
254
+ out = out[:-1] + new_lines + [tail]
255
+ else:
256
+ out = out + new_lines
257
+ return "".join(out)
91
258
 
92
259
 
93
260
  # --------------------------------------------------------------------------- #
@@ -95,7 +262,6 @@ def _is_real_bundle_id(value: str) -> bool:
95
262
  # --------------------------------------------------------------------------- #
96
263
 
97
264
  def ensure_bundle_id(token: str, identifier: str) -> str:
98
- """Return the ASC primary key for ``identifier``; register if missing."""
99
265
  data = get_json(
100
266
  "/bundleIds",
101
267
  token,
@@ -129,12 +295,10 @@ def _register_bundle_id(token: str, identifier: str) -> str:
129
295
  # --------------------------------------------------------------------------- #
130
296
 
131
297
  def profile_name_for(bundle_id: str) -> str:
132
- """Return the deterministic CI profile name for a bundle id."""
133
298
  return f"{PROFILE_PREFIX}{bundle_id}"
134
299
 
135
300
 
136
301
  def delete_profile_by_name(token: str, name: str) -> None:
137
- """Delete every existing profile with the given name (paginated scan)."""
138
302
  deleted_any = False
139
303
  next_path: str | None = "/profiles?limit=200"
140
304
  while next_path:
@@ -148,7 +312,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
148
312
  next_link = (data.get("links") or {}).get("next")
149
313
  if not next_link:
150
314
  break
151
- # Convert absolute next URL to path component the helper expects.
152
315
  idx = next_link.find("/v1")
153
316
  next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
154
317
  if not deleted_any:
@@ -158,7 +321,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
158
321
  def create_profile(
159
322
  token: str, name: str, bundle_pk: str, cert_id: str
160
323
  ) -> bytes:
161
- """Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
162
324
  body = {
163
325
  "data": {
164
326
  "type": "profiles",
@@ -180,7 +342,6 @@ def create_profile(
180
342
 
181
343
 
182
344
  def install_profile(profile_der: bytes) -> str:
183
- """Write the .mobileprovision into Xcode's profile dir; return UUID."""
184
345
  PROFILES_DIR.mkdir(parents=True, exist_ok=True)
185
346
  tmp_path = PROFILES_DIR / "tmp.mobileprovision"
186
347
  tmp_path.write_bytes(profile_der)
@@ -204,8 +365,7 @@ def provision_all_bundles(
204
365
  ) -> list[tuple[str, str, str]]:
205
366
  """Create + install a CI profile for each bundle id.
206
367
 
207
- Returns a list of ``(bundle_id, profile_name, uuid)`` tuples in input
208
- order so callers can log the full mapping.
368
+ Returns a list of ``(bundle_id, profile_name, uuid)`` tuples.
209
369
  """
210
370
  results: list[tuple[str, str, str]] = []
211
371
  for bid in bundle_ids: