@daemux/store-automator 0.10.76 → 0.10.78

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.76"
8
+ "version": "0.10.78"
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.76",
15
+ "version": "0.10.78",
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.76",
3
+ "version": "0.10.78",
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.76",
3
+ "version": "0.10.78",
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
  """
@@ -38,6 +45,7 @@ from cryptography.hazmat.primitives import hashes, serialization
38
45
  from cryptography.hazmat.primitives.asymmetric import rsa
39
46
  from cryptography.hazmat.primitives.serialization import pkcs12
40
47
  from cryptography.x509.oid import NameOID
48
+ from profile_manager import discover_bundle_ids, provision_all_bundles
41
49
 
42
50
 
43
51
  def env(name: str) -> str:
@@ -47,6 +55,10 @@ def env(name: str) -> str:
47
55
  return val
48
56
 
49
57
 
58
+ def optional_env(name: str) -> str:
59
+ return os.environ.get(name, "") or ""
60
+
61
+
50
62
  def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
51
63
  """Return (private_key, csr_payload_b64_bytes)."""
52
64
  private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
@@ -55,14 +67,13 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
55
67
  .subject_name(
56
68
  x509.Name(
57
69
  [
58
- x509.NameAttribute(NameOID.COMMON_NAME, "ScudoVPN CI"),
70
+ x509.NameAttribute(NameOID.COMMON_NAME, "Daemux CI"),
59
71
  x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
60
72
  ]
61
73
  )
62
74
  )
63
75
  .sign(private_key, hashes.SHA256())
64
76
  )
65
- # Apple wants the CSR PEM base64 (without headers)
66
77
  csr_pem = csr.public_bytes(serialization.Encoding.PEM)
67
78
  csr_payload = b"".join(
68
79
  line for line in csr_pem.splitlines() if not line.startswith(b"-----")
@@ -85,8 +96,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
85
96
  newest_exp = ""
86
97
  for cert in data.get("data", []):
87
98
  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
99
  exp = attrs.get("expirationDate") or ""
91
100
  if exp > newest_exp:
92
101
  newest_exp = exp
@@ -94,17 +103,11 @@ def newest_distribution_cert_id(token: str) -> str | None:
94
103
  return newest_id
95
104
 
96
105
 
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
106
  def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
103
107
  """Return (certificate_id, DER-encoded certificate bytes).
104
108
 
105
109
  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.
110
+ comes back 409, revoke the newest existing one and retry once.
108
111
  """
109
112
  body = {
110
113
  "data": {
@@ -126,12 +129,11 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
126
129
  "409 from cert create but no existing DISTRIBUTION cert "
127
130
  "found to revoke"
128
131
  )
129
- revoke_cert(token, target)
132
+ request("DELETE", f"/certificates/{target}", token)
130
133
  resp = request("POST", "/certificates", token, json_body=body)
131
134
  data = resp.json()["data"]
132
135
  cert_id = data["id"]
133
- cert_content_b64 = data["attributes"]["certificateContent"]
134
- cert_der = base64.b64decode(cert_content_b64)
136
+ cert_der = base64.b64decode(data["attributes"]["certificateContent"])
135
137
  print(f"Created DISTRIBUTION cert {cert_id}")
136
138
  return cert_id, cert_der
137
139
 
@@ -141,7 +143,7 @@ def write_p12(
141
143
  ) -> None:
142
144
  cert = x509.load_der_x509_certificate(cert_der)
143
145
  p12 = pkcs12.serialize_key_and_certificates(
144
- name=b"ScudoVPN CI",
146
+ name=b"Daemux CI",
145
147
  key=private_key,
146
148
  cert=cert,
147
149
  cas=None,
@@ -153,7 +155,6 @@ def write_p12(
153
155
 
154
156
 
155
157
  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
158
  subprocess.run(
158
159
  ["security", "delete-keychain", keychain_path],
159
160
  check=False,
@@ -173,18 +174,16 @@ def _create_keychain(keychain_path: str, keychain_pass: str) -> None:
173
174
  def _import_p12(
174
175
  keychain_path: str, keychain_pass: str, p12_path: Path, p12_pass: str
175
176
  ) -> None:
176
- """Import p12 into the keychain and authorize codesign to use the key."""
177
177
  subprocess.check_call(
178
178
  [
179
179
  "security", "import", str(p12_path),
180
180
  "-P", p12_pass,
181
- "-A", # allow any app to read (simplest for CI)
181
+ "-A",
182
182
  "-t", "cert",
183
183
  "-f", "pkcs12",
184
184
  "-k", keychain_path,
185
185
  ]
186
186
  )
187
- # Allow codesign to use the key without prompts (modern macOS)
188
187
  subprocess.check_call(
189
188
  [
190
189
  "security", "set-key-partition-list",
@@ -197,7 +196,6 @@ def _import_p12(
197
196
 
198
197
 
199
198
  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
199
  existing = subprocess.check_output(
202
200
  ["security", "list-keychains", "-d", "user"]
203
201
  ).decode()
@@ -233,14 +231,28 @@ def main() -> None:
233
231
  asc_key_path = env("ASC_KEY_PATH")
234
232
  runner_temp = env("RUNNER_TEMP")
235
233
 
234
+ project = optional_env("PROJECT")
235
+ workspace = optional_env("WORKSPACE")
236
+ scheme = env("SCHEME")
237
+ configuration = env("CONFIGURATION")
238
+ if not project and not workspace:
239
+ raise SystemExit(
240
+ "prepare_signing: either PROJECT or WORKSPACE env var must be set"
241
+ )
242
+
236
243
  token = make_jwt(key_id, issuer_id, asc_key_path)
237
244
 
245
+ bundle_ids = discover_bundle_ids(project, workspace, scheme, configuration)
246
+ print(f"Bundle ids to provision ({len(bundle_ids)}): {bundle_ids}")
247
+
238
248
  private_key, csr_b64 = generate_key_and_csr()
239
- _cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
249
+ cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
250
+
251
+ mappings = provision_all_bundles(token, bundle_ids, cert_id)
252
+ print("Profile map:")
253
+ for bid, pname, uuid in mappings:
254
+ print(f" {bid} -> {pname} ({uuid})")
240
255
 
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
256
  p12_pass = "ci"
245
257
  p12_path = Path(runner_temp) / "cert.p12"
246
258
  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