@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/ios-native-ci.config.yaml.template +1 -1
- package/templates/scripts/ci/ios-native/cfg_resolve.py +27 -0
- package/templates/scripts/ci/ios-native/prepare_signing.py +20 -2
- package/templates/scripts/ci/ios-native/read_config.py +13 -0
- package/templates/scripts/ci/ios-native/set_app_store_whats_new.py +50 -27
- package/templates/scripts/ci/ios-native/team_resolver.py +130 -0
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
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.
|
|
15
|
+
"version": "0.10.86",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
app:
|
|
6
6
|
bundle_id: "com.example.myapp" # REQUIRED
|
|
7
|
-
team_id: "ABCDE12345" # optional
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
206
|
+
count = _update_all_localizations(
|
|
207
|
+
token, version_id, version, whats_new, seed_locale
|
|
165
208
|
)
|
|
166
|
-
|
|
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)
|