@daemux/store-automator 0.10.77 → 0.10.79

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.77"
8
+ "version": "0.10.79"
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.77",
15
+ "version": "0.10.79",
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.77",
3
+ "version": "0.10.79",
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.77",
3
+ "version": "0.10.79",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -1,26 +1,33 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Prepare iOS code signing on a fresh CI runner (automatic-signing variant).
3
+ Prepare iOS code signing on a fresh CI runner (manual-signing variant).
4
4
 
5
- This script prepares ONLY the credentials Xcode needs to drive
6
- `-allowProvisioningUpdates` against the App Store Connect API:
5
+ Steps:
7
6
 
8
- 1. Generate an RSA private key + CSR
7
+ 1. Generate an RSA private key + CSR.
9
8
  2. Create a new Apple Distribution certificate from the CSR; if the per-team
10
9
  cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
11
- 3. Import cert + private key into a dedicated temporary keychain and put
12
- that keychain on the search list so codesign / xcodebuild can find it.
13
-
14
- Provisioning profile creation + installation is DELEGATED to xcodebuild via
15
- `-allowProvisioningUpdates` + ASC API auth. That path handles apps with any
16
- number of targets (Network Extensions, Widgets, WatchKit extensions, etc)
17
- without us having to know the full target graph up-front.
10
+ 3. Enumerate PRODUCT_BUNDLE_IDENTIFIER for every signable target in the
11
+ Xcode project / workspace (main app + extensions).
12
+ 4. Register each bundle ID on App Store Connect if missing.
13
+ 5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
14
+ ``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
15
+ with the same name so the reference matches the fresh cert).
16
+ 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
+ the user search list so codesign / xcodebuild can find it.
19
+
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.
18
25
 
19
26
  Environment inputs:
20
- ASC_KEY_ID - App Store Connect API key ID
21
- ASC_ISSUER_ID - App Store Connect API issuer ID
22
- ASC_KEY_PATH - Path to the .p8 private key file
23
- RUNNER_TEMP - GitHub Actions temp dir (for keychain + intermediates)
27
+ 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
30
+ RUNNER_TEMP - GitHub Actions temp dir
24
31
 
25
32
  Writes nothing to stdout that would leak secrets.
26
33
  """
@@ -28,6 +35,7 @@ Writes nothing to stdout that would leak secrets.
28
35
  from __future__ import annotations
29
36
 
30
37
  import base64
38
+ import json
31
39
  import os
32
40
  import subprocess
33
41
  from pathlib import Path
@@ -38,6 +46,7 @@ from cryptography.hazmat.primitives import hashes, serialization
38
46
  from cryptography.hazmat.primitives.asymmetric import rsa
39
47
  from cryptography.hazmat.primitives.serialization import pkcs12
40
48
  from cryptography.x509.oid import NameOID
49
+ from profile_manager import discover_bundle_ids, provision_all_bundles
41
50
 
42
51
 
43
52
  def env(name: str) -> str:
@@ -47,6 +56,10 @@ def env(name: str) -> str:
47
56
  return val
48
57
 
49
58
 
59
+ def optional_env(name: str) -> str:
60
+ return os.environ.get(name, "") or ""
61
+
62
+
50
63
  def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
51
64
  """Return (private_key, csr_payload_b64_bytes)."""
52
65
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -55,14 +68,13 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
55
68
  .subject_name(
56
69
  x509.Name(
57
70
  [
58
- x509.NameAttribute(NameOID.COMMON_NAME, "ScudoVPN CI"),
71
+ x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
59
72
  x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
60
73
  ]
61
74
  )
62
75
  )
63
76
  .sign(private_key, hashes.SHA256())
64
77
  )
65
- # Apple wants the CSR PEM base64 (without headers)
66
78
  csr_pem = csr.public_bytes(serialization.Encoding.PEM)
67
79
  csr_payload = b"".join(
68
80
  line for line in csr_pem.splitlines() if not line.startswith(b"-----")
@@ -85,8 +97,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
85
97
  newest_exp = ""
86
98
  for cert in data.get("data", []):
87
99
  attrs = cert.get("attributes") or {}
88
- # Use expirationDate as a proxy for creation time (cert expiration
89
- # is exactly creation + 1 year for Apple Distribution certs).
90
100
  exp = attrs.get("expirationDate") or ""
91
101
  if exp > newest_exp:
92
102
  newest_exp = exp
@@ -94,17 +104,11 @@ def newest_distribution_cert_id(token: str) -> str | None:
94
104
  return newest_id
95
105
 
96
106
 
97
- def revoke_cert(token: str, cert_id: str) -> None:
98
- print(f"Revoking distribution cert {cert_id}")
99
- request("DELETE", f"/certificates/{cert_id}", token)
100
-
101
-
102
107
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
103
108
  """Return (certificate_id, DER-encoded certificate bytes).
104
109
 
105
110
  Apple enforces a per-team cap on active Distribution certs. If the POST
106
- comes back 409 (already have one or pending), revoke the newest existing
107
- one and retry once. This is the CI-owned cert from a prior run.
111
+ comes back 409, revoke the newest existing one and retry once.
108
112
  """
109
113
  body = {
110
114
  "data": {
@@ -126,12 +130,11 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
126
130
  "409 from cert create but no existing DISTRIBUTION cert "
127
131
  "found to revoke"
128
132
  )
129
- revoke_cert(token, target)
133
+ request("DELETE", f"/certificates/{target}", token)
130
134
  resp = request("POST", "/certificates", token, json_body=body)
131
135
  data = resp.json()["data"]
132
136
  cert_id = data["id"]
133
- cert_content_b64 = data["attributes"]["certificateContent"]
134
- cert_der = base64.b64decode(cert_content_b64)
137
+ cert_der = base64.b64decode(data["attributes"]["certificateContent"])
135
138
  print(f"Created DISTRIBUTION cert {cert_id}")
136
139
  return cert_id, cert_der
137
140
 
@@ -141,7 +144,7 @@ def write_p12(
141
144
  ) -> None:
142
145
  cert = x509.load_der_x509_certificate(cert_der)
143
146
  p12 = pkcs12.serialize_key_and_certificates(
144
- name=b"ScudoVPN CI",
147
+ name=b"Daemux CI",
145
148
  key=private_key,
146
149
  cert=cert,
147
150
  cas=None,
@@ -153,7 +156,6 @@ def write_p12(
153
156
 
154
157
 
155
158
  def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
156
- """Delete any stale keychain at this path, then create + unlock a fresh one."""
157
159
  subprocess.run(
158
160
  ["security", "delete-keychain", keychain_path],
159
161
  check=False,
@@ -173,18 +175,16 @@ def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
173
175
  def _import_p12(
174
176
  keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
175
177
  ) -> None:
176
- """Import p12 into the keychain and authorize codesign to use the key."""
177
178
  subprocess.check_call(
178
179
  [
179
180
  "security", "import", str(p12_path),
180
181
  "-P", p12_pass,
181
- "-A", # allow any app to read (simplest for CI)
182
+ "-A",
182
183
  "-t", "cert",
183
184
  "-f", "pkcs12",
184
185
  "-k", keychain_path,
185
186
  ]
186
187
  )
187
- # Allow codesign to use the key without prompts (modern macOS)
188
188
  subprocess.check_call(
189
189
  [
190
190
  "security", "set-key-partition-list",
@@ -197,7 +197,6 @@ def _import_p12(
197
197
 
198
198
 
199
199
  def _prepend_to_user_search_list(keychain_path: str) -> None:
200
- """Put `keychain_path` at the head of the user search list + make it default."""
201
200
  existing = subprocess.check_output(
202
201
  ["security", "list-keychains", "-d", "user"]
203
202
  ).decode()
@@ -233,14 +232,37 @@ def main() -> None:
233
232
  asc_key_path = env("ASC_KEY_PATH")
234
233
  runner_temp = env("RUNNER_TEMP")
235
234
 
235
+ project = optional_env("PROJECT")
236
+ workspace = optional_env("WORKSPACE")
237
+ scheme = env("SCHEME")
238
+ configuration = env("CONFIGURATION")
239
+ if not project and not workspace:
240
+ raise SystemExit(
241
+ "prepare_signing: either PROJECT or WORKSPACE env var must be set"
242
+ )
243
+
236
244
  token = make_jwt(key_id, issuer_id, asc_key_path)
237
245
 
246
+ bundle_ids = discover_bundle_ids(project, workspace, scheme, configuration)
247
+ print(f"Bundle ids to provision ({len(bundle_ids)}): {bundle_ids}")
248
+
238
249
  private_key, csr_b64 = generate_key_and_csr()
239
- _cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
250
+ cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
251
+
252
+ mappings = provision_all_bundles(token, bundle_ids, cert_id)
253
+ print("Profile map:")
254
+ for bid, pname, uuid in mappings:
255
+ print(f" {bid} -> {pname} ({uuid})")
256
+
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.
260
+ map_path = Path(runner_temp) / "signing_map.json"
261
+ map_path.write_text(
262
+ json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
263
+ )
264
+ print(f"Wrote signing map to {map_path}")
240
265
 
241
- # Provisioning profile(s) are created on-demand by xcodebuild via
242
- # `-allowProvisioningUpdates` — we only need the signing identity
243
- # (cert + private key) to be present in a keychain Xcode can see.
244
266
  p12_pass = "ci"
245
267
  p12_path = Path(runner_temp) / "cert.p12"
246
268
  write_p12(private_key, cert_der, p12_path, p12_pass)
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Provisioning profile + bundleId helpers for multi-target iOS projects.
4
+
5
+ Handles:
6
+
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).
10
+ * Ensuring each bundle ID is registered on App Store Connect.
11
+ * Creating one IOS_APP_STORE provisioning profile per bundle ID, named
12
+ ``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
13
+ profile with that name already exists, it is deleted first so we always
14
+ end up with a profile that references the fresh cert.
15
+ * Installing every profile into the standard Xcode profile directory.
16
+
17
+ Kept separate from ``prepare_signing.py`` so both files stay well under the
18
+ 400-line cap.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import json
25
+ import plistlib
26
+ import subprocess
27
+ from pathlib import Path
28
+
29
+ from asc_common import get_json, request
30
+
31
+
32
+ PROFILE_PREFIX = "CI-"
33
+ PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
34
+
35
+
36
+ # --------------------------------------------------------------------------- #
37
+ # Bundle ID discovery #
38
+ # --------------------------------------------------------------------------- #
39
+
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).
52
+ """
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:
73
+ raise SystemExit(
74
+ "discover_bundle_ids: no PRODUCT_BUNDLE_IDENTIFIER values found "
75
+ f"for scheme {scheme!r} (configuration={configuration!r})"
76
+ )
77
+ return sorted(found)
78
+
79
+
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
91
+
92
+
93
+ # --------------------------------------------------------------------------- #
94
+ # ASC bundle ID registration #
95
+ # --------------------------------------------------------------------------- #
96
+
97
+ def ensure_bundle_id(token: str, identifier: str) -> str:
98
+ """Return the ASC primary key for ``identifier``; register if missing."""
99
+ data = get_json(
100
+ "/bundleIds",
101
+ token,
102
+ params={"filter[identifier]": identifier, "limit": "5"},
103
+ )
104
+ for item in data.get("data", []):
105
+ if item["attributes"]["identifier"] == identifier:
106
+ return item["id"]
107
+ return _register_bundle_id(token, identifier)
108
+
109
+
110
+ def _register_bundle_id(token: str, identifier: str) -> str:
111
+ body = {
112
+ "data": {
113
+ "type": "bundleIds",
114
+ "attributes": {
115
+ "identifier": identifier,
116
+ "name": identifier.replace(".", "-"),
117
+ "platform": "IOS",
118
+ },
119
+ }
120
+ }
121
+ resp = request("POST", "/bundleIds", token, json_body=body)
122
+ bundle_pk = resp.json()["data"]["id"]
123
+ print(f"Registered new bundle id {identifier!r} (pk={bundle_pk})")
124
+ return bundle_pk
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Profile lifecycle #
129
+ # --------------------------------------------------------------------------- #
130
+
131
+ def profile_name_for(bundle_id: str) -> str:
132
+ """Return the deterministic CI profile name for a bundle id."""
133
+ return f"{PROFILE_PREFIX}{bundle_id}"
134
+
135
+
136
+ def delete_profile_by_name(token: str, name: str) -> None:
137
+ """Delete every existing profile with the given name (paginated scan)."""
138
+ deleted_any = False
139
+ next_path: str | None = "/profiles?limit=200"
140
+ while next_path:
141
+ data = get_json(next_path, token)
142
+ for p in data.get("data", []):
143
+ if (p["attributes"].get("name") or "") == name:
144
+ pid = p["id"]
145
+ print(f"Deleting stale profile {pid} ({name})")
146
+ request("DELETE", f"/profiles/{pid}", token)
147
+ deleted_any = True
148
+ next_link = (data.get("links") or {}).get("next")
149
+ if not next_link:
150
+ break
151
+ # Convert absolute next URL to path component the helper expects.
152
+ idx = next_link.find("/v1")
153
+ next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
154
+ if not deleted_any:
155
+ print(f"No existing profile named {name!r}")
156
+
157
+
158
+ def create_profile(
159
+ token: str, name: str, bundle_pk: str, cert_id: str
160
+ ) -> bytes:
161
+ """Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
162
+ body = {
163
+ "data": {
164
+ "type": "profiles",
165
+ "attributes": {
166
+ "name": name,
167
+ "profileType": "IOS_APP_STORE",
168
+ },
169
+ "relationships": {
170
+ "bundleId": {"data": {"type": "bundleIds", "id": bundle_pk}},
171
+ "certificates": {
172
+ "data": [{"type": "certificates", "id": cert_id}]
173
+ },
174
+ },
175
+ }
176
+ }
177
+ resp = request("POST", "/profiles", token, json_body=body)
178
+ payload = resp.json()["data"]["attributes"]["profileContent"]
179
+ return base64.b64decode(payload)
180
+
181
+
182
+ def install_profile(profile_der: bytes) -> str:
183
+ """Write the .mobileprovision into Xcode's profile dir; return UUID."""
184
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
185
+ tmp_path = PROFILES_DIR / "tmp.mobileprovision"
186
+ tmp_path.write_bytes(profile_der)
187
+ decoded = subprocess.check_output(
188
+ ["security", "cms", "-D", "-i", str(tmp_path)]
189
+ )
190
+ uuid = plistlib.loads(decoded)["UUID"]
191
+ final_path = PROFILES_DIR / f"{uuid}.mobileprovision"
192
+ tmp_path.rename(final_path)
193
+ return uuid
194
+
195
+
196
+ # --------------------------------------------------------------------------- #
197
+ # Orchestration #
198
+ # --------------------------------------------------------------------------- #
199
+
200
+ def provision_all_bundles(
201
+ token: str,
202
+ bundle_ids: list[str],
203
+ cert_id: str,
204
+ ) -> list[tuple[str, str, str]]:
205
+ """Create + install a CI profile for each bundle id.
206
+
207
+ Returns a list of ``(bundle_id, profile_name, uuid)`` tuples in input
208
+ order so callers can log the full mapping.
209
+ """
210
+ results: list[tuple[str, str, str]] = []
211
+ for bid in bundle_ids:
212
+ name = profile_name_for(bid)
213
+ bundle_pk = ensure_bundle_id(token, bid)
214
+ delete_profile_by_name(token, name)
215
+ profile_der = create_profile(token, name, bundle_pk, cert_id)
216
+ uuid = install_profile(profile_der)
217
+ print(f"Installed provisioning profile {name} -> {uuid} ({bid})")
218
+ results.append((bid, name, uuid))
219
+ return results