@daemux/store-automator 0.10.85 → 0.10.86

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.85"
8
+ "version": "0.10.86"
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.85",
15
+ "version": "0.10.86",
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.85",
3
+ "version": "0.10.86",
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.85",
3
+ "version": "0.10.86",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -4,7 +4,7 @@
4
4
 
5
5
  app:
6
6
  bundle_id: "com.example.myapp" # REQUIRED
7
- team_id: "ABCDE12345" # optional (used for manual signing)
7
+ team_id: "ABCDE12345" # optional auto-derived from ASC API key when omitted
8
8
  app_store_apple_id: "" # optional — auto-discovered via ASC API by bundle_id
9
9
 
10
10
  xcode:
@@ -27,7 +27,9 @@ from typing import Any
27
27
 
28
28
  import yaml
29
29
 
30
+ from asc_common import make_jwt
30
31
  from cfg_io import fail, log, notice
32
+ from team_resolver import derive_team_id
31
33
 
32
34
 
33
35
  P8_RE = re.compile(
@@ -188,6 +190,31 @@ def find_p8(workspace: Path, cfg: dict) -> tuple[Path, str, str | None]:
188
190
  )
189
191
 
190
192
 
193
+ def derive_team_if_empty(
194
+ team_val: str,
195
+ team_src: str,
196
+ creds: dict,
197
+ ) -> tuple[str, str]:
198
+ """If team_val is empty, derive from ASC API; return (value, source).
199
+
200
+ Leaves non-empty team_val untouched. When derivation succeeds, source
201
+ becomes ``derived_from_asc_key`` so read_config.py can log it clearly.
202
+ When derivation returns empty, we preserve ("", "empty") so
203
+ prepare_signing.py's post-profile-install fallback kicks in.
204
+ """
205
+ if team_val:
206
+ return team_val, team_src
207
+ try:
208
+ token = make_jwt(creds["key_id"], creds["issuer_id"], creds["key_path"])
209
+ except (OSError, ValueError) as exc:
210
+ log(f"team derivation skipped: cannot sign ASC JWT ({exc!r})")
211
+ return "", "empty"
212
+ derived = derive_team_id(token)
213
+ if not derived:
214
+ return "", "empty"
215
+ return derived, "derived_from_asc_key"
216
+
217
+
191
218
  def lookup_app_id_via_api(bundle_id: str, scripts_dir: Path) -> str:
192
219
  """Invoke lookup_app_id.py to resolve apple_id via ASC API."""
193
220
  import subprocess
@@ -25,6 +25,9 @@ Environment inputs:
25
25
  ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
26
26
  PROJECT - Xcode project path (.xcodeproj)
27
27
  TEAM_ID - Apple developer team identifier
28
+ (optional; derived from the
29
+ installed provisioning profile
30
+ when empty)
28
31
  RUNNER_TEMP - GitHub Actions temp dir
29
32
 
30
33
  Writes nothing to stdout that would leak secrets.
@@ -223,7 +226,14 @@ def main() -> None:
223
226
  issuer_id = env("ASC_ISSUER_ID")
224
227
  asc_key_path = env("ASC_KEY_PATH")
225
228
  runner_temp = env("RUNNER_TEMP")
226
- team_id = env("TEAM_ID")
229
+ # TEAM_ID is optional: the ASC API key is bound to one developer team,
230
+ # and every profile it issues inherits that team. provision_all_bundles
231
+ # returns that ``effective_team`` read straight from the installed
232
+ # profile's plist — which is the authoritative value Xcode will look
233
+ # for in DEVELOPMENT_TEAM. Upstream (read_config.py) also tries to
234
+ # derive team_id via GET-only ASC endpoints and pass it here as a
235
+ # convenience for logging and xcconfig patching.
236
+ team_id = optional_env("TEAM_ID")
227
237
 
228
238
  project = optional_env("PROJECT")
229
239
  workspace = optional_env("WORKSPACE")
@@ -258,7 +268,15 @@ def main() -> None:
258
268
  # target at archive time. If the ci.config.yaml team differs, warn and
259
269
  # use the profile's team as the source of truth.
260
270
  pbx_team = effective_team or team_id
261
- if effective_team and effective_team != team_id:
271
+ if not pbx_team:
272
+ raise SystemExit(
273
+ "Unable to determine Apple developer team. The installed "
274
+ "provisioning profile did not expose a TeamIdentifier and no "
275
+ "TEAM_ID was provided. Set `app.team_id` in ci.config.yaml. "
276
+ "Find your team id at https://developer.apple.com/account -> "
277
+ "Membership (look for 'Team ID')."
278
+ )
279
+ if effective_team and team_id and effective_team != team_id:
262
280
  print(
263
281
  f"::warning::Config team {team_id} differs from ASC API key's "
264
282
  f"team {effective_team}; patching pbxproj with {effective_team} "
@@ -57,6 +57,7 @@ from cfg_io import fail, log
57
57
  from cfg_resolve import (
58
58
  as_str_bool,
59
59
  auto_project_glob,
60
+ derive_team_if_empty,
60
61
  dig,
61
62
  emit,
62
63
  find_p8,
@@ -154,6 +155,18 @@ def resolve_app(cfg: dict, inp: dict, creds: dict, scripts_dir: Path) -> dict:
154
155
  bundle = resolve(inp["BUNDLE_ID"], bundle_cfg, cfg_source=bundle_src)
155
156
  team = resolve(inp["TEAM_ID"], team_cfg, cfg_source=team_src)
156
157
 
158
+ # team_id is documented as optional because the ASC API key is bound to
159
+ # exactly one developer team. If input + config both empty, derive it
160
+ # via GET-only ASC endpoints (cert OU, then profile plist).
161
+ if not team[0]:
162
+ derived_val, derived_src = derive_team_if_empty(team[0], team[1], creds)
163
+ if derived_val:
164
+ log(
165
+ f"derived team_id={derived_val} from ASC key "
166
+ f"{creds['key_id']}"
167
+ )
168
+ team = (derived_val, derived_src)
169
+
157
170
  if inp["APP_STORE_APPLE_ID"]:
158
171
  apple = (inp["APP_STORE_APPLE_ID"], "input")
159
172
  elif apple_cfg not in (None, ""):
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Derive the Apple developer team identifier from an App Store Connect API key.
4
+
5
+ Why:
6
+ ``ci.config.yaml`` lists ``app.team_id`` as optional because the ASC API
7
+ key is always bound to exactly one team — so it's derivable. This module
8
+ implements that derivation via GET-only endpoints so the first CI run on
9
+ a fresh repo works without pre-seeding the team id.
10
+
11
+ Strategy (GET-only, no resource creation):
12
+ 1. ``GET /v1/certificates?limit=1&sort=-id`` — any Distribution /
13
+ Development cert carries the team id in its Subject's Organizational
14
+ Unit (OU) attribute. Most teams already have at least one.
15
+ 2. If no certs exist: ``GET /v1/profiles?limit=1`` — the attached
16
+ ``profileContent`` (base64-encoded ``.mobileprovision``) contains the
17
+ ``TeamIdentifier`` array in its plist payload. Profiles are CMS-signed
18
+ in production, so we fall back to ``security cms -D`` when
19
+ ``plistlib.loads`` refuses the raw bytes. When ``security`` isn't
20
+ available (unit tests), the test substitutes its own plist.
21
+ 3. If neither yields a team, return "" — the caller decides whether to
22
+ fail with an actionable message or defer to ``prepare_signing.py``'s
23
+ post-profile-install fallback.
24
+
25
+ The returned team id is a 10-character alphanumeric string (Apple's format).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import plistlib
32
+ import subprocess
33
+ from typing import Any
34
+
35
+ from asc_common import get_json
36
+ from cryptography import x509
37
+ from cryptography.x509.oid import NameOID
38
+
39
+
40
+ def _team_from_cert_der(cert_der: bytes) -> str:
41
+ """Return OU attribute from the cert's Subject, or '' if absent/malformed."""
42
+ try:
43
+ cert = x509.load_der_x509_certificate(cert_der)
44
+ except (ValueError, TypeError):
45
+ return ""
46
+ ou_attrs = cert.subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME)
47
+ if not ou_attrs:
48
+ return ""
49
+ value = ou_attrs[0].value
50
+ if isinstance(value, bytes):
51
+ value = value.decode(errors="replace")
52
+ return str(value).strip()
53
+
54
+
55
+ def _team_from_asc_certificates(token: str) -> str:
56
+ """Try GET /certificates and parse OU from the first cert's DER."""
57
+ data = get_json(
58
+ "/certificates",
59
+ token,
60
+ params={"limit": "1", "sort": "-id"},
61
+ )
62
+ items = data.get("data", []) if isinstance(data, dict) else []
63
+ if not items:
64
+ return ""
65
+ first = items[0]
66
+ attrs = first.get("attributes") or {}
67
+ b64 = attrs.get("certificateContent")
68
+ if not b64:
69
+ return ""
70
+ try:
71
+ cert_der = base64.b64decode(b64)
72
+ except (ValueError, TypeError):
73
+ return ""
74
+ return _team_from_cert_der(cert_der)
75
+
76
+
77
+ def _team_from_profile_plist(profile_bytes: bytes) -> str:
78
+ """Extract TeamIdentifier[0] from a mobileprovision plist payload."""
79
+ plist: Any
80
+ try:
81
+ plist = plistlib.loads(profile_bytes)
82
+ except (plistlib.InvalidFileException, ValueError, OSError):
83
+ # Real profiles are CMS-signed — strip the envelope via `security cms`.
84
+ try:
85
+ decoded = subprocess.check_output(
86
+ ["security", "cms", "-D", "-i", "/dev/stdin"],
87
+ input=profile_bytes,
88
+ )
89
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
90
+ return ""
91
+ try:
92
+ plist = plistlib.loads(decoded)
93
+ except (plistlib.InvalidFileException, ValueError, OSError):
94
+ return ""
95
+ if not isinstance(plist, dict):
96
+ return ""
97
+ teams = plist.get("TeamIdentifier") or []
98
+ if isinstance(teams, list) and teams:
99
+ return str(teams[0]).strip()
100
+ return ""
101
+
102
+
103
+ def _team_from_asc_profiles(token: str) -> str:
104
+ """Try GET /profiles and parse TeamIdentifier from the first profile's plist."""
105
+ data = get_json("/profiles", token, params={"limit": "1"})
106
+ items = data.get("data", []) if isinstance(data, dict) else []
107
+ if not items:
108
+ return ""
109
+ attrs = items[0].get("attributes") or {}
110
+ b64 = attrs.get("profileContent")
111
+ if not b64:
112
+ return ""
113
+ try:
114
+ profile_bytes = base64.b64decode(b64)
115
+ except (ValueError, TypeError):
116
+ return ""
117
+ return _team_from_profile_plist(profile_bytes)
118
+
119
+
120
+ def derive_team_id(token: str) -> str:
121
+ """Derive the developer team id bound to the ASC API key.
122
+
123
+ Returns the 10-char team identifier, or "" when neither a certificate
124
+ nor a provisioning profile exposes it. Never raises on missing data;
125
+ only propagates network / auth failures from the underlying ASC client.
126
+ """
127
+ team = _team_from_asc_certificates(token)
128
+ if team:
129
+ return team
130
+ return _team_from_asc_profiles(token)