@daemux/store-automator 0.10.79 → 0.10.80
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.
|
|
8
|
+
"version": "0.10.80"
|
|
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.80",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -7,26 +7,24 @@ Steps:
|
|
|
7
7
|
1. Generate an RSA private key + CSR.
|
|
8
8
|
2. Create a new Apple Distribution certificate from the CSR; if the per-team
|
|
9
9
|
cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
|
|
10
|
-
3.
|
|
11
|
-
|
|
10
|
+
3. Discover every signable native target in the Xcode project (main app +
|
|
11
|
+
extensions like Network Extensions, Widgets, WatchKit apps). SwiftPM
|
|
12
|
+
resource bundles are skipped — they can't be signed manually.
|
|
12
13
|
4. Register each bundle ID on App Store Connect if missing.
|
|
13
14
|
5. Create one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
14
15
|
``CI-<bundle_id>``, linked to the new cert (deleting any stale profile
|
|
15
16
|
with the same name so the reference matches the fresh cert).
|
|
16
17
|
6. Install every profile into ``~/Library/MobileDevice/Provisioning Profiles/``.
|
|
17
|
-
7.
|
|
18
|
+
7. Patch the pbxproj in place so each signable target's build settings
|
|
19
|
+
select manual signing + the matching CI profile. SwiftPM targets are
|
|
20
|
+
left alone — they keep their default automatic (no-sign) config.
|
|
21
|
+
8. Import cert + private key into a temporary keychain placed at the head of
|
|
18
22
|
the user search list so codesign / xcodebuild can find it.
|
|
19
23
|
|
|
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.
|
|
25
|
-
|
|
26
24
|
Environment inputs:
|
|
27
25
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH - App Store Connect API key trio
|
|
28
|
-
PROJECT
|
|
29
|
-
|
|
26
|
+
PROJECT - Xcode project path (.xcodeproj)
|
|
27
|
+
TEAM_ID - Apple developer team identifier
|
|
30
28
|
RUNNER_TEMP - GitHub Actions temp dir
|
|
31
29
|
|
|
32
30
|
Writes nothing to stdout that would leak secrets.
|
|
@@ -46,7 +44,11 @@ from cryptography.hazmat.primitives import hashes, serialization
|
|
|
46
44
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
47
45
|
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
48
46
|
from cryptography.x509.oid import NameOID
|
|
49
|
-
from profile_manager import
|
|
47
|
+
from profile_manager import (
|
|
48
|
+
discover_signable_targets,
|
|
49
|
+
patch_project_signing,
|
|
50
|
+
provision_all_bundles,
|
|
51
|
+
)
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
def env(name: str) -> str:
|
|
@@ -61,7 +63,6 @@ def optional_env(name: str) -> str:
|
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
64
|
-
"""Return (private_key, csr_payload_b64_bytes)."""
|
|
65
66
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
66
67
|
csr = (
|
|
67
68
|
x509.CertificateSigningRequestBuilder()
|
|
@@ -83,7 +84,6 @@ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes]:
|
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def newest_distribution_cert_id(token: str) -> str | None:
|
|
86
|
-
"""Return the ID of the most-recently-created DISTRIBUTION cert, or None."""
|
|
87
87
|
data = get_json(
|
|
88
88
|
"/certificates",
|
|
89
89
|
token,
|
|
@@ -105,11 +105,6 @@ def newest_distribution_cert_id(token: str) -> str | None:
|
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
108
|
-
"""Return (certificate_id, DER-encoded certificate bytes).
|
|
109
|
-
|
|
110
|
-
Apple enforces a per-team cap on active Distribution certs. If the POST
|
|
111
|
-
comes back 409, revoke the newest existing one and retry once.
|
|
112
|
-
"""
|
|
113
108
|
body = {
|
|
114
109
|
"data": {
|
|
115
110
|
"type": "certificates",
|
|
@@ -231,20 +226,26 @@ def main() -> None:
|
|
|
231
226
|
issuer_id = env("ASC_ISSUER_ID")
|
|
232
227
|
asc_key_path = env("ASC_KEY_PATH")
|
|
233
228
|
runner_temp = env("RUNNER_TEMP")
|
|
229
|
+
team_id = env("TEAM_ID")
|
|
234
230
|
|
|
235
231
|
project = optional_env("PROJECT")
|
|
236
232
|
workspace = optional_env("WORKSPACE")
|
|
237
|
-
|
|
238
|
-
configuration = env("CONFIGURATION")
|
|
239
|
-
if not project and not workspace:
|
|
233
|
+
if workspace and not project:
|
|
240
234
|
raise SystemExit(
|
|
241
|
-
"prepare_signing:
|
|
235
|
+
"prepare_signing: WORKSPACE-only mode is not supported; the "
|
|
236
|
+
"underlying .xcodeproj must be passed via PROJECT so we can "
|
|
237
|
+
"patch its signing settings."
|
|
242
238
|
)
|
|
239
|
+
if not project:
|
|
240
|
+
raise SystemExit("prepare_signing: PROJECT env var is required")
|
|
243
241
|
|
|
244
242
|
token = make_jwt(key_id, issuer_id, asc_key_path)
|
|
245
243
|
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
targets = discover_signable_targets(project)
|
|
245
|
+
bundle_ids = sorted({t["bundle_id"] for t in targets})
|
|
246
|
+
print(f"Signable targets ({len(targets)}):")
|
|
247
|
+
for t in targets:
|
|
248
|
+
print(f" - {t['name']} -> {t['bundle_id']} ({len(t['config_ids'])} configs)")
|
|
248
249
|
|
|
249
250
|
private_key, csr_b64 = generate_key_and_csr()
|
|
250
251
|
cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
|
|
@@ -254,9 +255,10 @@ def main() -> None:
|
|
|
254
255
|
for bid, pname, uuid in mappings:
|
|
255
256
|
print(f" {bid} -> {pname} ({uuid})")
|
|
256
257
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
#
|
|
258
|
+
patch_project_signing(project, targets, team_id)
|
|
259
|
+
|
|
260
|
+
# Persist mapping for downstream Export IPA step (ExportOptions.plist
|
|
261
|
+
# provisioningProfiles dict).
|
|
260
262
|
map_path = Path(runner_temp) / "signing_map.json"
|
|
261
263
|
map_path.write_text(
|
|
262
264
|
json.dumps({bid: pname for bid, pname, _ in mappings}, indent=2)
|
|
@@ -5,14 +5,20 @@ Provisioning profile + bundleId helpers for multi-target iOS projects.
|
|
|
5
5
|
Handles:
|
|
6
6
|
|
|
7
7
|
* Enumerating every PRODUCT_BUNDLE_IDENTIFIER used by signable targets in an
|
|
8
|
-
Xcode project
|
|
9
|
-
|
|
8
|
+
Xcode project. We parse ``project.pbxproj`` directly (via ``plutil -convert
|
|
9
|
+
json``) rather than rely on ``xcodebuild -showBuildSettings``, which only
|
|
10
|
+
covers what a given scheme builds. Parsing the pbxproj lets us find every
|
|
11
|
+
native app + extension target without tripping over SwiftPM resource
|
|
12
|
+
bundles (whose product types are not signable).
|
|
10
13
|
* Ensuring each bundle ID is registered on App Store Connect.
|
|
11
14
|
* Creating one IOS_APP_STORE provisioning profile per bundle ID, named
|
|
12
15
|
``CI-<bundle_id>``, linked to the just-issued distribution cert. If a
|
|
13
16
|
profile with that name already exists, it is deleted first so we always
|
|
14
17
|
end up with a profile that references the fresh cert.
|
|
15
18
|
* Installing every profile into the standard Xcode profile directory.
|
|
19
|
+
* Patching the pbxproj in place so each signable target's build settings use
|
|
20
|
+
manual signing + the freshly-created profile. SwiftPM / resource bundle
|
|
21
|
+
targets (which cannot carry provisioning profiles) are left untouched.
|
|
16
22
|
|
|
17
23
|
Kept separate from ``prepare_signing.py`` so both files stay well under the
|
|
18
24
|
400-line cap.
|
|
@@ -32,62 +38,142 @@ from asc_common import get_json, request
|
|
|
32
38
|
PROFILE_PREFIX = "CI-"
|
|
33
39
|
PROFILES_DIR = Path.home() / "Library/MobileDevice/Provisioning Profiles"
|
|
34
40
|
|
|
41
|
+
# Product types that must be signed with a provisioning profile. App
|
|
42
|
+
# extensions share the common ``com.apple.product-type.app-extension`` prefix
|
|
43
|
+
# (messages, widgets, NetworkExtension, WatchKit, etc).
|
|
44
|
+
_SIGNABLE_PRODUCT_TYPES = {
|
|
45
|
+
"com.apple.product-type.application",
|
|
46
|
+
"com.apple.product-type.application.on-demand-install-capable",
|
|
47
|
+
"com.apple.product-type.application.watchapp2",
|
|
48
|
+
"com.apple.product-type.watchkit2-extension",
|
|
49
|
+
}
|
|
50
|
+
_SIGNABLE_PRODUCT_PREFIXES = (
|
|
51
|
+
"com.apple.product-type.app-extension",
|
|
52
|
+
)
|
|
53
|
+
|
|
35
54
|
|
|
36
55
|
# --------------------------------------------------------------------------- #
|
|
37
|
-
#
|
|
56
|
+
# pbxproj parsing #
|
|
38
57
|
# --------------------------------------------------------------------------- #
|
|
39
58
|
|
|
40
|
-
def
|
|
41
|
-
project
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
def _load_pbxproj(project_path: str) -> dict:
|
|
60
|
+
pbx = Path(project_path) / "project.pbxproj"
|
|
61
|
+
out = subprocess.check_output(["plutil", "-convert", "json", "-o", "-", str(pbx)])
|
|
62
|
+
return json.loads(out)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_signable_product_type(product_type: str) -> bool:
|
|
66
|
+
if product_type in _SIGNABLE_PRODUCT_TYPES:
|
|
67
|
+
return True
|
|
68
|
+
return any(product_type.startswith(p) for p in _SIGNABLE_PRODUCT_PREFIXES)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def discover_signable_targets(project_path: str) -> list[dict]:
|
|
72
|
+
"""Return one entry per signable native target.
|
|
73
|
+
|
|
74
|
+
Each entry: ``{"name": str, "bundle_id": str, "config_ids": [str,...]}``
|
|
75
|
+
where ``config_ids`` is the list of XCBuildConfiguration UUIDs whose
|
|
76
|
+
``buildSettings`` dict we need to patch (one per build configuration —
|
|
77
|
+
typically Debug + Release).
|
|
52
78
|
"""
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
pbx = _load_pbxproj(project_path)
|
|
80
|
+
objects = pbx["objects"]
|
|
81
|
+
targets: list[dict] = []
|
|
82
|
+
for _obj_id, obj in objects.items():
|
|
83
|
+
if obj.get("isa") != "PBXNativeTarget":
|
|
84
|
+
continue
|
|
85
|
+
product_type = obj.get("productType") or ""
|
|
86
|
+
if not _is_signable_product_type(product_type):
|
|
87
|
+
continue
|
|
88
|
+
name = obj.get("name") or "<unknown>"
|
|
89
|
+
config_list_id = obj.get("buildConfigurationList")
|
|
90
|
+
config_list = objects.get(config_list_id) or {}
|
|
91
|
+
config_ids = list(config_list.get("buildConfigurations") or [])
|
|
92
|
+
bundle_id = _bundle_id_from_configs(objects, config_ids)
|
|
93
|
+
if not bundle_id:
|
|
94
|
+
print(f"skip target {name!r}: no PRODUCT_BUNDLE_IDENTIFIER")
|
|
95
|
+
continue
|
|
96
|
+
targets.append(
|
|
97
|
+
{"name": name, "bundle_id": bundle_id, "config_ids": config_ids}
|
|
98
|
+
)
|
|
99
|
+
if not targets:
|
|
73
100
|
raise SystemExit(
|
|
74
|
-
"
|
|
75
|
-
|
|
101
|
+
f"discover_signable_targets: no signable targets found in {project_path}"
|
|
102
|
+
)
|
|
103
|
+
return targets
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _bundle_id_from_configs(objects: dict, config_ids: list[str]) -> str:
|
|
107
|
+
"""Return the first non-empty bundle id across the given configs."""
|
|
108
|
+
for cid in config_ids:
|
|
109
|
+
cfg = objects.get(cid) or {}
|
|
110
|
+
settings = cfg.get("buildSettings") or {}
|
|
111
|
+
bid = (settings.get("PRODUCT_BUNDLE_IDENTIFIER") or "").strip()
|
|
112
|
+
if bid and "$(" not in bid and "${" not in bid:
|
|
113
|
+
return bid
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- #
|
|
118
|
+
# pbxproj mutation #
|
|
119
|
+
# --------------------------------------------------------------------------- #
|
|
120
|
+
|
|
121
|
+
def patch_project_signing(
|
|
122
|
+
project_path: str,
|
|
123
|
+
targets: list[dict],
|
|
124
|
+
team_id: str,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Set manual signing + per-target profile name for every signable target.
|
|
127
|
+
|
|
128
|
+
Uses ``plutil -replace`` to mutate the pbxproj in place. Scope is limited
|
|
129
|
+
to native app + extension targets so SwiftPM / resource-bundle targets
|
|
130
|
+
(which reject PROVISIONING_PROFILE_SPECIFIER) are untouched.
|
|
131
|
+
"""
|
|
132
|
+
pbx = Path(project_path) / "project.pbxproj"
|
|
133
|
+
for target in targets:
|
|
134
|
+
profile_name = f"{PROFILE_PREFIX}{target['bundle_id']}"
|
|
135
|
+
for cid in target["config_ids"]:
|
|
136
|
+
_set_build_setting(pbx, cid, "CODE_SIGN_STYLE", "Manual")
|
|
137
|
+
_set_build_setting(pbx, cid, "CODE_SIGN_IDENTITY", "Apple Distribution")
|
|
138
|
+
_set_build_setting(pbx, cid, "DEVELOPMENT_TEAM", team_id)
|
|
139
|
+
_set_build_setting(
|
|
140
|
+
pbx, cid, "PROVISIONING_PROFILE_SPECIFIER", profile_name
|
|
141
|
+
)
|
|
142
|
+
# Clear any legacy autogenerated PROVISIONING_PROFILE uuid that
|
|
143
|
+
# would otherwise override the specifier we just set.
|
|
144
|
+
_clear_build_setting(pbx, cid, "PROVISIONING_PROFILE")
|
|
145
|
+
print(
|
|
146
|
+
f"Patched {target['name']!r} -> {profile_name} "
|
|
147
|
+
f"(configs={len(target['config_ids'])})"
|
|
76
148
|
)
|
|
77
|
-
return sorted(found)
|
|
78
149
|
|
|
79
150
|
|
|
80
|
-
def
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
151
|
+
def _keypath(config_id: str, key: str) -> str:
|
|
152
|
+
return f"objects.{config_id}.buildSettings.{key}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _set_build_setting(
|
|
156
|
+
pbx_path: Path, config_id: str, key: str, value: str
|
|
157
|
+
) -> None:
|
|
158
|
+
keypath = _keypath(config_id, key)
|
|
159
|
+
# -replace fails if the key doesn't exist, so try replace first then
|
|
160
|
+
# fall back to -insert for a fresh add.
|
|
161
|
+
rc = subprocess.run(
|
|
162
|
+
["plutil", "-replace", keypath, "-string", value, str(pbx_path)],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
).returncode
|
|
165
|
+
if rc != 0:
|
|
166
|
+
subprocess.check_call(
|
|
167
|
+
["plutil", "-insert", keypath, "-string", value, str(pbx_path)]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _clear_build_setting(pbx_path: Path, config_id: str, key: str) -> None:
|
|
172
|
+
subprocess.run(
|
|
173
|
+
["plutil", "-remove", _keypath(config_id, key), str(pbx_path)],
|
|
174
|
+
capture_output=True,
|
|
175
|
+
check=False,
|
|
176
|
+
)
|
|
91
177
|
|
|
92
178
|
|
|
93
179
|
# --------------------------------------------------------------------------- #
|
|
@@ -95,7 +181,6 @@ def _is_real_bundle_id(value: str) -> bool:
|
|
|
95
181
|
# --------------------------------------------------------------------------- #
|
|
96
182
|
|
|
97
183
|
def ensure_bundle_id(token: str, identifier: str) -> str:
|
|
98
|
-
"""Return the ASC primary key for ``identifier``; register if missing."""
|
|
99
184
|
data = get_json(
|
|
100
185
|
"/bundleIds",
|
|
101
186
|
token,
|
|
@@ -129,12 +214,10 @@ def _register_bundle_id(token: str, identifier: str) -> str:
|
|
|
129
214
|
# --------------------------------------------------------------------------- #
|
|
130
215
|
|
|
131
216
|
def profile_name_for(bundle_id: str) -> str:
|
|
132
|
-
"""Return the deterministic CI profile name for a bundle id."""
|
|
133
217
|
return f"{PROFILE_PREFIX}{bundle_id}"
|
|
134
218
|
|
|
135
219
|
|
|
136
220
|
def delete_profile_by_name(token: str, name: str) -> None:
|
|
137
|
-
"""Delete every existing profile with the given name (paginated scan)."""
|
|
138
221
|
deleted_any = False
|
|
139
222
|
next_path: str | None = "/profiles?limit=200"
|
|
140
223
|
while next_path:
|
|
@@ -148,7 +231,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
|
|
|
148
231
|
next_link = (data.get("links") or {}).get("next")
|
|
149
232
|
if not next_link:
|
|
150
233
|
break
|
|
151
|
-
# Convert absolute next URL to path component the helper expects.
|
|
152
234
|
idx = next_link.find("/v1")
|
|
153
235
|
next_path = next_link[idx + len("/v1"):] if idx >= 0 else None
|
|
154
236
|
if not deleted_any:
|
|
@@ -158,7 +240,6 @@ def delete_profile_by_name(token: str, name: str) -> None:
|
|
|
158
240
|
def create_profile(
|
|
159
241
|
token: str, name: str, bundle_pk: str, cert_id: str
|
|
160
242
|
) -> bytes:
|
|
161
|
-
"""Create an IOS_APP_STORE profile and return its raw (CMS-signed) bytes."""
|
|
162
243
|
body = {
|
|
163
244
|
"data": {
|
|
164
245
|
"type": "profiles",
|
|
@@ -180,7 +261,6 @@ def create_profile(
|
|
|
180
261
|
|
|
181
262
|
|
|
182
263
|
def install_profile(profile_der: bytes) -> str:
|
|
183
|
-
"""Write the .mobileprovision into Xcode's profile dir; return UUID."""
|
|
184
264
|
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
|
185
265
|
tmp_path = PROFILES_DIR / "tmp.mobileprovision"
|
|
186
266
|
tmp_path.write_bytes(profile_der)
|
|
@@ -204,8 +284,7 @@ def provision_all_bundles(
|
|
|
204
284
|
) -> list[tuple[str, str, str]]:
|
|
205
285
|
"""Create + install a CI profile for each bundle id.
|
|
206
286
|
|
|
207
|
-
Returns a list of ``(bundle_id, profile_name, uuid)`` tuples
|
|
208
|
-
order so callers can log the full mapping.
|
|
287
|
+
Returns a list of ``(bundle_id, profile_name, uuid)`` tuples.
|
|
209
288
|
"""
|
|
210
289
|
results: list[tuple[str, str, str]] = []
|
|
211
290
|
for bid in bundle_ids:
|