@daemux/store-automator 0.10.79 → 0.10.80

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.80"
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.80",
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.80",
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.80",
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.
@@ -32,62 +38,142 @@ from asc_common import get_json, request
32
38
  PROFILE_PREFIX = "CI-"
33
39
  PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
34
40
 
41
+ # Product types that must be signed with a provisioning profile. App
42
+ # extensions share the common ``com.apple.product-type.app-extension`` prefix
43
+ # (messages, widgets, NetworkExtension, WatchKit, etc).
44
+ _SIGNABLE_PRODUCT_TYPES = {
45
+ "com.apple.product-type.application",
46
+ "com.apple.product-type.application.on-demand-install-capable",
47
+ "com.apple.product-type.application.watchapp2",
48
+ "com.apple.product-type.watchkit2-extension",
49
+ }
50
+ _SIGNABLE_PRODUCT_PREFIXES = (
51
+ "com.apple.product-type.app-extension",
52
+ )
53
+
35
54
 
36
55
  # --------------------------------------------------------------------------- #
37
- # Bundle ID discovery #
56
+ # pbxproj parsing #
38
57
  # --------------------------------------------------------------------------- #
39
58
 
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).
59
+ def _load_pbxproj(project_path: str) -> dict:
60
+ pbx = Path(project_path) / "project.pbxproj"
61
+ out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
62
+ return json.loads(out)
63
+
64
+
65
+ def _is_signable_product_type(product_type: str) -> bool:
66
+ if product_type in _SIGNABLE_PRODUCT_TYPES:
67
+ return True
68
+ return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
69
+
70
+
71
+ def discover_signable_targets(project_path: str) -> list[dict]:
72
+ """Return one entry per signable native target.
73
+
74
+ Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
75
+ where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
76
+ ``buildSettings`` dict we need to patch (one per build configuration —
77
+ typically Debug + Release).
52
78
  """
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:
79
+ pbx = _load_pbxproj(project_path)
80
+ objects = pbx["objects"]
81
+ targets: list[dict] = []
82
+ for _obj_id, obj in objects.items():
83
+ if obj.get("isa") != "PBXNativeTarget":
84
+ continue
85
+ product_type = obj.get("productType") or ""
86
+ if not _is_signable_product_type(product_type):
87
+ continue
88
+ name = obj.get("name") or "<unknown>"
89
+ config_list_id = obj.get("buildConfigurationList")
90
+ config_list = objects.get(config_list_id) or {}
91
+ config_ids = list(config_list.get("buildConfigurations") or [])
92
+ bundle_id = _bundle_id_from_configs(objects, config_ids)
93
+ if not bundle_id:
94
+ print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
95
+ continue
96
+ targets.append(
97
+ {"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
98
+ )
99
+ if not targets:
73
100
  raise SystemExit(
74
- "discover_bundle_ids: no PRODUCT_BUNDLE_IDENTIFIER values found "
75
- f"for scheme {scheme!r} (configuration={configuration!r})"
101
+ f"discover_signable_targets: no signable targets found in {project_path}"
102
+ )
103
+ return targets
104
+
105
+
106
+ def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
107
+ """Return the first non-empty bundle id across the given configs."""
108
+ for cid in config_ids:
109
+ cfg = objects.get(cid) or {}
110
+ settings = cfg.get("buildSettings") or {}
111
+ bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
112
+ if bid and "$(" not in bid and "${" not in bid:
113
+ return bid
114
+ return ""
115
+
116
+
117
+ # --------------------------------------------------------------------------- #
118
+ # pbxproj mutation #
119
+ # --------------------------------------------------------------------------- #
120
+
121
+ def patch_project_signing(
122
+ project_path: str,
123
+ targets: list[dict],
124
+ team_id: str,
125
+ ) -> None:
126
+ """Set manual signing + per-target profile name for every signable target.
127
+
128
+ Uses ``plutil -replace`` to mutate the pbxproj in place. Scope is limited
129
+ to native app + extension targets so SwiftPM / resource-bundle targets
130
+ (which reject PROVISIONING_PROFILE_SPECIFIER) are untouched.
131
+ """
132
+ pbx = Path(project_path) / "project.pbxproj"
133
+ for target in targets:
134
+ profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
135
+ for cid in target["config_ids"]:
136
+ _set_build_setting(pbx, cid, "CODE_SIGN_STYLE", "Manual")
137
+ _set_build_setting(pbx, cid, "CODE_SIGN_IDENTITY", "Apple Distribution")
138
+ _set_build_setting(pbx, cid, "DEVELOPMENT_TEAM", team_id)
139
+ _set_build_setting(
140
+ pbx, cid, "PROVISIONING_PROFILE_SPECIFIER", profile_name
141
+ )
142
+ # Clear any legacy autogenerated PROVISIONING_PROFILE uuid that
143
+ # would otherwise override the specifier we just set.
144
+ _clear_build_setting(pbx, cid, "PROVISIONING_PROFILE")
145
+ print(
146
+ f"Patched {target['name']!r} -> {profile_name} "
147
+ f"(configs={len(target['config_ids'])})"
76
148
  )
77
- return sorted(found)
78
149
 
79
150
 
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
151
+ def _keypath(config_id: str, key: str) -> str:
152
+ return f"objects.{config_id}.buildSettings.{key}"
153
+
154
+
155
+ def _set_build_setting(
156
+ pbx_path: Path, config_id: str, key: str, value: str
157
+ ) -> None:
158
+ keypath = _keypath(config_id, key)
159
+ # -replace fails if the key doesn't exist, so try replace first then
160
+ # fall back to -insert for a fresh add.
161
+ rc = subprocess.run(
162
+ ["plutil", "-replace", keypath, "-string", value, str(pbx_path)],
163
+ capture_output=True,
164
+ ).returncode
165
+ if rc != 0:
166
+ subprocess.check_call(
167
+ ["plutil", "-insert", keypath, "-string", value, str(pbx_path)]
168
+ )
169
+
170
+
171
+ def _clear_build_setting(pbx_path: Path, config_id: str, key: str) -> None:
172
+ subprocess.run(
173
+ ["plutil", "-remove", _keypath(config_id, key), str(pbx_path)],
174
+ capture_output=True,
175
+ check=False,
176
+ )
91
177
 
92
178
 
93
179
  # --------------------------------------------------------------------------- #
@@ -95,7 +181,6 @@ def _is_real_bundle_id(value: str) -> bool:
95
181
  # --------------------------------------------------------------------------- #
96
182
 
97
183
  def ensure_bundle_id(token: str, identifier: str) -> str:
98
- """Return the ASC primary key for ``identifier``; register if missing."""
99
184
  data = get_json(
100
185
  "/bundleIds",
101
186
  token,
@@ -129,12 +214,10 @@ def _register_bundle_id(token: str, identifier: str) -> str:
129
214
  # --------------------------------------------------------------------------- #
130
215
 
131
216
  def profile_name_for(bundle_id: str) -> str:
132
- """Return the deterministic CI profile name for a bundle id."""
133
217
  return f"{PROFILE_PREFIX}{bundle_id}"
134
218
 
135
219
 
136
220
  def delete_profile_by_name(token: str, name: str) -> None:
137
- """Delete every existing profile with the given name (paginated scan)."""
138
221
  deleted_any = False
139
222
  next_path: str | None = "/profiles?limit=200"
140
223
  while next_path:
@@ -148,7 +231,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
148
231
  next_link = (data.get("links") or {}).get("next")
149
232
  if not next_link:
150
233
  break
151
- # Convert absolute next URL to path component the helper expects.
152
234
  idx = next_link.find("/v1")
153
235
  next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
154
236
  if not deleted_any:
@@ -158,7 +240,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
158
240
  def create_profile(
159
241
  token: str, name: str, bundle_pk: str, cert_id: str
160
242
  ) -> bytes:
161
- """Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
162
243
  body = {
163
244
  "data": {
164
245
  "type": "profiles",
@@ -180,7 +261,6 @@ def create_profile(
180
261
 
181
262
 
182
263
  def install_profile(profile_der: bytes) -> str:
183
- """Write the .mobileprovision into Xcode's profile dir; return UUID."""
184
264
  PROFILES_DIR.mkdir(parents=True, exist_ok=True)
185
265
  tmp_path = PROFILES_DIR / "tmp.mobileprovision"
186
266
  tmp_path.write_bytes(profile_der)
@@ -204,8 +284,7 @@ def provision_all_bundles(
204
284
  ) -> list[tuple[str, str, str]]:
205
285
  """Create + install a CI profile for each bundle id.
206
286
 
207
- Returns a list of ``(bundle_id, profile_name, uuid)`` tuples in input
208
- order so callers can log the full mapping.
287
+ Returns a list of ``(bundle_id, profile_name, uuid)`` tuples.
209
288
  """
210
289
  results: list[tuple[str, str, str]] = []
211
290
  for bid in bundle_ids: