@daemux/store-automator 0.10.84 → 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.84"
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.84",
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.84",
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.84",
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, ""):
@@ -11,8 +11,12 @@ REST API (no fastlane dependency here):
11
11
  * Skips the first release (1.0 / 1.0.0 / 0.0 / 0.0.0) -- there are no prior
12
12
  release notes to announce.
13
13
  * Skips when the slot is not in a state that permits editing whatsNew.
14
- * Either PATCHes the existing localization for the requested locale or POSTs
15
- a new one pointing at the appStoreVersion.
14
+ * PATCHes whatsNew on EVERY appStoreVersionLocalization entry with the
15
+ same text. gowalk-step's set_changelog iterates every localization
16
+ (not just the default one) so users in every language see the release
17
+ notes rather than an empty "What's New" section when their locale
18
+ isn't en-US. If no localizations exist yet (brand-new version slot),
19
+ POST a single one for APP_STORE_LOCALE (default en-US) as a seed.
16
20
 
17
21
  Non-fatal by design: any failure is reported as a ::warning:: and the action
18
22
  continues. The TestFlight upload itself has already succeeded by this point.
@@ -27,7 +31,11 @@ Environment:
27
31
  multi-line content)
28
32
  APP_STORE_WHATS_NEW -- release notes text (fallback when
29
33
  _FILE is unset / unreadable)
30
- APP_STORE_LOCALE -- locale code, default "en-US"
34
+ APP_STORE_LOCALE -- locale to seed when no
35
+ localizations exist; default
36
+ "en-US". Ignored when
37
+ localizations already exist --
38
+ every existing locale is updated.
31
39
  """
32
40
 
33
41
  from __future__ import annotations
@@ -127,6 +135,41 @@ def _read_whats_new() -> tuple[str, str]:
127
135
  return "", "<empty>"
128
136
 
129
137
 
138
+ def _update_all_localizations(
139
+ token: str, version_id: str, version: str, whats_new: str, seed_locale: str
140
+ ) -> int:
141
+ """PATCH whatsNew on every existing localization. If none exist, POST a
142
+ single seed localization in `seed_locale`. Returns the count of
143
+ localizations written."""
144
+ locs = get_json(
145
+ f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
146
+ )
147
+ entries = locs.get("data") or []
148
+
149
+ if not entries:
150
+ new_id = _create_localization(token, version_id, seed_locale, whats_new)
151
+ _log(
152
+ f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
153
+ f"{version} ({seed_locale}) -- no prior localizations"
154
+ )
155
+ return 1
156
+
157
+ count = 0
158
+ for item in entries:
159
+ loc_id = item.get("id") or ""
160
+ loc = (item.get("attributes") or {}).get("locale") or "?"
161
+ if not loc_id:
162
+ _warn(f"skipping localization without id: {item!r}")
163
+ continue
164
+ _patch_localization(token, loc_id, whats_new)
165
+ _log(
166
+ f"PATCHed appStoreVersionLocalization {loc_id} whatsNew "
167
+ f"for {version} ({loc})"
168
+ )
169
+ count += 1
170
+ return count
171
+
172
+
130
173
  def main() -> int:
131
174
  whats_new, source = _read_whats_new()
132
175
  _log(
@@ -139,7 +182,7 @@ def main() -> int:
139
182
 
140
183
  version = _require_env("MARKETING_VERSION")
141
184
  version_id = _require_env("APP_STORE_VERSION_ID")
142
- locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
185
+ seed_locale = os.environ.get("APP_STORE_LOCALE", "").strip() or "en-US"
143
186
 
144
187
  if version in SKIP_VERSIONS:
145
188
  _log(f"Skipping whatsNew for first release {version}.")
@@ -160,30 +203,10 @@ def main() -> int:
160
203
  )
161
204
  return 0
162
205
 
163
- locs = get_json(
164
- f"/appStoreVersions/{version_id}/appStoreVersionLocalizations", token
206
+ count = _update_all_localizations(
207
+ token, version_id, version, whats_new, seed_locale
165
208
  )
166
- existing = next(
167
- (
168
- item
169
- for item in (locs.get("data") or [])
170
- if (item.get("attributes") or {}).get("locale") == locale
171
- ),
172
- None,
173
- )
174
-
175
- if existing:
176
- _patch_localization(token, existing["id"], whats_new)
177
- _log(
178
- f"PATCHed appStoreVersionLocalization {existing['id']} whatsNew "
179
- f"for {version} ({locale})"
180
- )
181
- else:
182
- new_id = _create_localization(token, version_id, locale, whats_new)
183
- _log(
184
- f"CREATEd appStoreVersionLocalization {new_id} whatsNew for "
185
- f"{version} ({locale})"
186
- )
209
+ _log(f"whatsNew set for {count} localizations on {version}")
187
210
  return 0
188
211
 
189
212
 
@@ -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)