@daemux/store-automator 0.10.74 → 0.10.76

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.74"
8
+ "version": "0.10.76"
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.74",
15
+ "version": "0.10.76",
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.74",
3
+ "version": "0.10.76",
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.74",
3
+ "version": "0.10.76",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tiny shared IO helpers for ios-native-testflight scripts.
4
+
5
+ Kept deliberately minimal so that read_config.py and lookup_app_id.py can
6
+ import without pulling in heavier dependencies (PyYAML, requests, PyJWT).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+
14
+ def log(msg: str) -> None:
15
+ """Debug log to stderr (never contains .p8 contents)."""
16
+ print(msg, file=sys.stderr)
17
+
18
+
19
+ def notice(msg: str) -> None:
20
+ """GitHub Actions workflow-command notice."""
21
+ print(f"::notice::{msg}")
22
+
23
+
24
+ def fail(msg: str) -> str:
25
+ """Emit a GitHub Actions workflow-command error and exit 1."""
26
+ print(f"::error::{msg}", file=sys.stderr)
27
+ raise SystemExit(1)
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Config loading + value-resolution helpers for ios-native-testflight.
4
+
5
+ Pulled out of read_config.py to keep any single file under the 400-line /
6
+ 10-function project limits.
7
+
8
+ Contents:
9
+ load_config YAML loader (returns {} if missing)
10
+ emit $GITHUB_ENV writer (handles multi-line via heredoc)
11
+ dig / pick Safe nested dict access
12
+ pick_with_source Flat-path-first, legacy-alias-second lookup
13
+ resolve Apply input -> config -> auto -> default precedence
14
+ auto_project_glob Repo-root *.xcodeproj / *.xcworkspace autodetect
15
+ as_str_bool YAML bool -> "true"/"false"/None
16
+ find_p8 Locate ASC .p8 key in creds/
17
+ lookup_app_id_via_api Subprocess helper — calls lookup_app_id.py
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import yaml
29
+
30
+ from cfg_io import fail, log, notice
31
+
32
+
33
+ P8_RE = re.compile(
34
+ r"^AuthKey_([A-Z0-9]{8,10})(?:_Issuer_([0-9a-fA-F-]{36}))?\.p8$"
35
+ )
36
+
37
+
38
+ def load_config(workspace: Path, rel_path: str) -> dict:
39
+ """Load a YAML config from ``workspace/rel_path``. Returns {} if missing."""
40
+ cfg_path = workspace / rel_path
41
+ if not cfg_path.is_file():
42
+ notice(
43
+ f"{rel_path} not found at {cfg_path}; proceeding with inputs and defaults only"
44
+ )
45
+ return {}
46
+ try:
47
+ with cfg_path.open("r") as fh:
48
+ data = yaml.safe_load(fh)
49
+ except yaml.YAMLError as exc:
50
+ fail(f"failed to parse {rel_path}: {exc}")
51
+ if data is None:
52
+ return {}
53
+ if not isinstance(data, dict):
54
+ fail(f"{rel_path} root must be a mapping")
55
+ log(f"loaded config from {cfg_path}")
56
+ return data
57
+
58
+
59
+ def emit(env_file: Path, name: str, value: str) -> None:
60
+ """Append NAME=VALUE (or multi-line NAME<<EOF ... EOF) to $GITHUB_ENV."""
61
+ if value is None:
62
+ value = ""
63
+ if "\n" in value:
64
+ delim = "EOF"
65
+ while delim in value:
66
+ delim += "X"
67
+ with env_file.open("a") as fh:
68
+ fh.write(f"{name}<<{delim}\n{value}\n{delim}\n")
69
+ else:
70
+ with env_file.open("a") as fh:
71
+ fh.write(f"{name}={value}\n")
72
+
73
+
74
+ def dig(cfg: dict, *path: str, default: Any = None) -> Any:
75
+ """Safe nested .get across dict path. Returns default if any key missing."""
76
+ node: Any = cfg
77
+ for key in path:
78
+ if not isinstance(node, dict):
79
+ return default
80
+ node = node.get(key)
81
+ if node is None:
82
+ return default
83
+ return node
84
+
85
+
86
+ def pick(cfg: dict, *paths: tuple) -> Any:
87
+ """Return the first non-None/non-empty value among a series of paths."""
88
+ for path in paths:
89
+ val = dig(cfg, *path)
90
+ if val not in (None, ""):
91
+ return val
92
+ return None
93
+
94
+
95
+ def pick_with_source(cfg: dict, flat: tuple, legacy: tuple) -> tuple[Any, str]:
96
+ """Return (value, source) — flat path wins; legacy alias as fallback."""
97
+ flat_val = dig(cfg, *flat)
98
+ if flat_val not in (None, ""):
99
+ return flat_val, "config"
100
+ legacy_val = dig(cfg, *legacy)
101
+ if legacy_val not in (None, ""):
102
+ return legacy_val, "config(legacy)"
103
+ return None, "config"
104
+
105
+
106
+ def resolve(
107
+ input_val: str,
108
+ cfg_val: Any,
109
+ *,
110
+ cfg_source: str = "config",
111
+ auto_val: str = "",
112
+ default: str = "",
113
+ ) -> tuple[str, str]:
114
+ """Apply precedence: input > config > auto > default > empty."""
115
+ if input_val:
116
+ return input_val, "input"
117
+ if cfg_val not in (None, ""):
118
+ return str(cfg_val), cfg_source
119
+ if auto_val:
120
+ return auto_val, "auto-detect"
121
+ if default:
122
+ return default, "default"
123
+ return "", "empty"
124
+
125
+
126
+ def auto_project_glob(workspace: Path, suffix: str) -> str:
127
+ """Return the single matching *suffix file at repo root, else empty."""
128
+ matches = sorted(workspace.glob(f"*{suffix}"))
129
+ if len(matches) == 1:
130
+ return matches[0].name
131
+ return ""
132
+
133
+
134
+ def as_str_bool(val: Any) -> str | None:
135
+ """Normalize YAML bool / string / None to 'true'|'false'|None."""
136
+ if val is None:
137
+ return None
138
+ if isinstance(val, bool):
139
+ return "true" if val else "false"
140
+ s = str(val).strip().lower()
141
+ if s in ("true", "yes", "1"):
142
+ return "true"
143
+ if s in ("false", "no", "0"):
144
+ return "false"
145
+ return s # let downstream validate
146
+
147
+
148
+ def find_p8(workspace: Path, cfg: dict) -> tuple[Path, str, str | None]:
149
+ """Locate the ASC .p8 key. Returns (path, key_id, issuer_from_filename)."""
150
+ creds_dir = workspace / "creds"
151
+ matches = sorted(creds_dir.glob("AuthKey_*.p8"))
152
+ if not matches:
153
+ fail(
154
+ "No ASC key found. Place "
155
+ "AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8 in creds/."
156
+ )
157
+
158
+ parsed: list[tuple[Path, str, str | None]] = []
159
+ for p in matches:
160
+ m = P8_RE.match(p.name)
161
+ if not m:
162
+ log(f"skipping {p.name}: does not match AuthKey_<KEY_ID>[_Issuer_<UUID>].p8")
163
+ continue
164
+ parsed.append((p, m.group(1), m.group(2)))
165
+
166
+ if not parsed:
167
+ fail(
168
+ "Found .p8 file(s) in creds/ but none match the required naming "
169
+ "pattern AuthKey_<KEY_ID>[_Issuer_<UUID>].p8"
170
+ )
171
+
172
+ if len(parsed) == 1:
173
+ return parsed[0]
174
+
175
+ config_key_id = pick(cfg, ("credentials", "apple", "key_id"), ("asc", "key_id"))
176
+ if not config_key_id:
177
+ names = ", ".join(p.name for p, _, _ in parsed)
178
+ fail(
179
+ f"Multiple ASC keys found in creds/ ({names}). "
180
+ "Set credentials.apple.key_id in ci.config.yaml to choose one."
181
+ )
182
+ for entry in parsed:
183
+ if entry[1] == config_key_id:
184
+ return entry
185
+ fail(
186
+ f"credentials.apple.key_id={config_key_id} did not match any .p8 in "
187
+ f"creds/ (found key_ids: {', '.join(k for _, k, _ in parsed)})"
188
+ )
189
+
190
+
191
+ def lookup_app_id_via_api(bundle_id: str, scripts_dir: Path) -> str:
192
+ """Invoke lookup_app_id.py to resolve apple_id via ASC API."""
193
+ import subprocess
194
+
195
+ script = scripts_dir / "lookup_app_id.py"
196
+ if not script.is_file():
197
+ fail(f"lookup_app_id.py not found at {script}")
198
+ env = dict(os.environ)
199
+ env["BUNDLE_ID"] = bundle_id
200
+ result = subprocess.run(
201
+ [sys.executable, str(script)],
202
+ env=env,
203
+ capture_output=True,
204
+ text=True,
205
+ )
206
+ if result.returncode != 0:
207
+ sys.stderr.write(result.stderr)
208
+ fail(
209
+ f"No ASC app found for bundle {bundle_id}. "
210
+ "Run `fastlane create_app_ios` once manually to register the app."
211
+ )
212
+ return result.stdout.strip()
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Resolve the App Store Connect numeric app id for a bundle id.
4
+
5
+ Environment:
6
+ ASC_KEY_ID - API key ID
7
+ ASC_ISSUER_ID - API issuer ID
8
+ ASC_KEY_PATH - Path to the .p8 key file
9
+ BUNDLE_ID - Application bundle identifier
10
+
11
+ Prints the app id to stdout on success. On 0 or >1 matches, exits 1 with a
12
+ guidance message on stderr.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from asc_common import get_json, make_jwt
20
+ from cfg_io import fail
21
+
22
+
23
+ def env(name: str) -> str:
24
+ val = os.environ.get(name)
25
+ if not val:
26
+ fail(f"missing env var: {name}")
27
+ return val
28
+
29
+
30
+ def main() -> None:
31
+ bundle_id = env("BUNDLE_ID")
32
+ token = make_jwt(env("ASC_KEY_ID"), env("ASC_ISSUER_ID"), env("ASC_KEY_PATH"))
33
+ data = get_json("/apps", token, params={"filter[bundleId]": bundle_id, "limit": "10"})
34
+ apps = data.get("data", [])
35
+ if not apps:
36
+ fail(
37
+ f"No ASC app found for bundleId={bundle_id}. "
38
+ "Run `fastlane create_app_ios` once manually to register the app, "
39
+ "or set app.app_store_apple_id in ci.config.yaml."
40
+ )
41
+ if len(apps) > 1:
42
+ ids = ", ".join(a.get("id", "?") for a in apps)
43
+ fail(
44
+ f"Multiple ASC apps match bundleId={bundle_id} (ids: {ids}). "
45
+ "Set app.app_store_apple_id in ci.config.yaml to disambiguate."
46
+ )
47
+ print(apps[0]["id"])
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Prepare iOS code signing on a fresh CI runner.
3
+ Prepare iOS code signing on a fresh CI runner (automatic-signing variant).
4
+
5
+ This script prepares ONLY the credentials Xcode needs to drive
6
+ `-allowProvisioningUpdates` against the App Store Connect API:
4
7
 
5
- Uses the App Store Connect API (auth via P8 key) to:
6
8
  1. Generate an RSA private key + CSR
7
9
  2. Create a new Apple Distribution certificate from the CSR; if the per-team
8
10
  cap is hit (409), revoke the newest existing DISTRIBUTION cert and retry.
9
- 3. Ensure a provisioning profile with a known name exists for the bundle ID,
10
- linked to the new cert. If it exists, delete+recreate to refresh it.
11
- 4. Install the profile into ~/Library/MobileDevice/Provisioning Profiles/
12
- 5. Import cert + private key into a dedicated temporary keychain and put
11
+ 3. Import cert + private key into a dedicated temporary keychain and put
13
12
  that keychain on the search list so codesign / xcodebuild can find it.
14
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.
18
+
15
19
  Environment inputs:
16
20
  ASC_KEY_ID - App Store Connect API key ID
17
21
  ASC_ISSUER_ID - App Store Connect API issuer ID
18
22
  ASC_KEY_PATH - Path to the .p8 private key file
19
- TEAM_ID - Apple developer team ID
20
- BUNDLE_ID - App bundle identifier
21
- PROFILE_NAME - Desired provisioning profile name
22
23
  RUNNER_TEMP - GitHub Actions temp dir (for keychain + intermediates)
23
24
 
24
25
  Writes nothing to stdout that would leak secrets.
@@ -28,7 +29,6 @@ from __future__ import annotations
28
29
 
29
30
  import base64
30
31
  import os
31
- import plistlib
32
32
  import subprocess
33
33
  from pathlib import Path
34
34
 
@@ -136,68 +136,6 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
136
136
  return cert_id, cert_der
137
137
 
138
138
 
139
- def find_bundle_id(token: str, identifier: str) -> str:
140
- data = get_json(
141
- "/bundleIds",
142
- token,
143
- params={"filter[identifier]": identifier, "limit": "5"},
144
- )
145
- for item in data.get("data", []):
146
- if item["attributes"]["identifier"] == identifier:
147
- return item["id"]
148
- raise SystemExit(f"bundle id {identifier!r} not found in ASC")
149
-
150
-
151
- def delete_profile_by_name(token: str, name: str) -> None:
152
- data = get_json("/profiles", token, params={"limit": "200"})
153
- for p in data.get("data", []):
154
- if (p["attributes"].get("name") or "") == name:
155
- pid = p["id"]
156
- print(f"Deleting existing profile {pid} ({name})")
157
- request("DELETE", f"/profiles/{pid}", token)
158
-
159
-
160
- def create_profile(
161
- token: str, name: str, bundle_id_pk: str, cert_id: str
162
- ) -> bytes:
163
- body = {
164
- "data": {
165
- "type": "profiles",
166
- "attributes": {
167
- "name": name,
168
- "profileType": "IOS_APP_STORE",
169
- },
170
- "relationships": {
171
- "bundleId": {"data": {"type": "bundleIds", "id": bundle_id_pk}},
172
- "certificates": {
173
- "data": [{"type": "certificates", "id": cert_id}]
174
- },
175
- },
176
- }
177
- }
178
- resp = request("POST", "/profiles", token, json_body=body)
179
- data = resp.json()["data"]
180
- profile_content_b64 = data["attributes"]["profileContent"]
181
- return base64.b64decode(profile_content_b64)
182
-
183
-
184
- def install_profile(profile_der: bytes) -> str:
185
- """Write the .mobileprovision file and return its uuid."""
186
- # Profile is CMS-signed plist; decode with `security cms` to read UUID.
187
- profiles_dir = Path.home() / "Library/MobileDevice/Provisioning Profiles"
188
- profiles_dir.mkdir(parents=True, exist_ok=True)
189
- tmp_path = profiles_dir / "tmp.mobileprovision"
190
- tmp_path.write_bytes(profile_der)
191
- decoded = subprocess.check_output(
192
- ["security", "cms", "-D", "-i", str(tmp_path)]
193
- )
194
- uuid = plistlib.loads(decoded)["UUID"]
195
- final_path = profiles_dir / f"{uuid}.mobileprovision"
196
- tmp_path.rename(final_path)
197
- print(f"Installed provisioning profile {uuid} at {final_path}")
198
- return uuid
199
-
200
-
201
139
  def write_p12(
202
140
  private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
203
141
  ) -> None:
@@ -293,20 +231,16 @@ def main() -> None:
293
231
  key_id = env("ASC_KEY_ID")
294
232
  issuer_id = env("ASC_ISSUER_ID")
295
233
  asc_key_path = env("ASC_KEY_PATH")
296
- bundle_id = env("BUNDLE_ID")
297
- profile_name = env("PROFILE_NAME")
298
234
  runner_temp = env("RUNNER_TEMP")
299
235
 
300
236
  token = make_jwt(key_id, issuer_id, asc_key_path)
301
237
 
302
238
  private_key, csr_b64 = generate_key_and_csr()
303
- cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
304
-
305
- bundle_pk = find_bundle_id(token, bundle_id)
306
- delete_profile_by_name(token, profile_name)
307
- profile_der = create_profile(token, profile_name, bundle_pk, cert_id)
308
- install_profile(profile_der)
239
+ _cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
309
240
 
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.
310
244
  p12_pass = "ci"
311
245
  p12_path = Path(runner_temp) / "cert.p12"
312
246
  write_p12(private_key, cert_der, p12_path, p12_pass)
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Read ci.config.yaml + discover ASC API key in creds/, then emit derived CI
4
+ environment variables to $GITHUB_ENV.
5
+
6
+ Schema (flat — primary, canonical):
7
+ app.{bundle_id, team_id, app_store_apple_id}
8
+ xcode.{project, workspace, scheme, configuration, profile_name}
9
+ ios.{uses_non_exempt_encryption}
10
+ testflight.{whats_new, locale}
11
+ ci.{run_tests, test_command, test_destination}
12
+ credentials.apple.{key_id, issuer_id} OR asc.{key_id, issuer_id}
13
+
14
+ Legacy aliases (accepted for back-compat, never preferred):
15
+ ios.native.{project, workspace, scheme, configuration, profile_name,
16
+ team_id, app_store_apple_id, uses_non_exempt_encryption,
17
+ whats_new, locale, run_tests, test_command, test_destination,
18
+ tests.{run, command, destination}}
19
+ app_store.locale (third-tier fallback only)
20
+
21
+ Precedence for every CFG_* value:
22
+ 1. Explicit action input (INPUT_* env var)
23
+ 2. Flat-schema config value
24
+ 3. Legacy ios.native.* alias
25
+ 4. Auto-detected value (xcodeproj/xcworkspace glob) or built-in default
26
+ 5. Empty string (composite action step applies its own default / skips)
27
+
28
+ Built-in defaults (emitted when no config/input exists):
29
+ CFG_CONFIGURATION=Release, CFG_USES_NON_EXEMPT=false,
30
+ CFG_RUN_TESTS=false, CFG_LOCALE=en-US, CFG_PROFILE_NAME="<scheme> CI".
31
+ All other CFG_* fields emit empty and rely on action.yml-level fallbacks.
32
+
33
+ The ASC .p8 key is located by globbing ``creds/AuthKey_*.p8`` with filename
34
+ regex AuthKey_<KEY_ID>[_Issuer_<ISSUER_UUID>].p8. Multiple matches require
35
+ ``credentials.apple.key_id`` (or ``asc.key_id``) in config to disambiguate.
36
+
37
+ Writes to $GITHUB_ENV:
38
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH, ASC_KEY_P8,
39
+ CFG_PROJECT, CFG_WORKSPACE, CFG_SCHEME, CFG_CONFIGURATION,
40
+ CFG_BUNDLE_ID, CFG_TEAM_ID, CFG_APP_STORE_APPLE_ID, CFG_PROFILE_NAME,
41
+ CFG_USES_NON_EXEMPT, CFG_WHATS_NEW, CFG_LOCALE,
42
+ CFG_RUN_TESTS, CFG_TEST_COMMAND, CFG_TEST_DESTINATION
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import os
48
+ from pathlib import Path
49
+
50
+ from cfg_io import fail, log
51
+ from cfg_resolve import (
52
+ as_str_bool,
53
+ auto_project_glob,
54
+ dig,
55
+ emit,
56
+ find_p8,
57
+ load_config,
58
+ lookup_app_id_via_api,
59
+ pick,
60
+ pick_with_source,
61
+ resolve,
62
+ )
63
+
64
+
65
+ DEFAULT_WHATS_NEW = (
66
+ "- Improved performance: faster, smoother app experience\n"
67
+ "- Bug fixes: minor issues resolved for seamless use\n"
68
+ "- Enhanced security: updated to protect your data\n"
69
+ )
70
+
71
+
72
+ def emit_credentials(env_file: Path, cfg: dict, workspace: Path) -> dict:
73
+ """Discover .p8, derive ASC_KEY_ID / ISSUER / PATH, emit to env_file.
74
+
75
+ Returns {'key_id', 'issuer_id', 'key_path'} for downstream use.
76
+ """
77
+ p8_path, key_id_from_file, issuer_from_file = find_p8(workspace, cfg)
78
+ try:
79
+ p8_contents = p8_path.read_text()
80
+ except OSError as exc:
81
+ fail(f"cannot read {p8_path}: {exc}")
82
+
83
+ cfg_key_id = pick(cfg, ("credentials", "apple", "key_id"), ("asc", "key_id"))
84
+ cfg_issuer = pick(cfg, ("credentials", "apple", "issuer_id"), ("asc", "issuer_id"))
85
+ asc_key_id = cfg_key_id or key_id_from_file
86
+ asc_issuer = cfg_issuer or issuer_from_file
87
+ if not asc_issuer:
88
+ fail(
89
+ "ASC issuer id unknown: neither the filename contains _Issuer_<UUID> "
90
+ "nor credentials.apple.issuer_id is set in ci.config.yaml."
91
+ )
92
+
93
+ emit(env_file, "ASC_KEY_ID", str(asc_key_id))
94
+ emit(env_file, "ASC_ISSUER_ID", str(asc_issuer))
95
+ emit(env_file, "ASC_KEY_P8_PATH", str(p8_path))
96
+ emit(env_file, "ASC_KEY_P8", p8_contents)
97
+ log(f"ASC_KEY_ID={asc_key_id} (source: {'config' if cfg_key_id else 'filename'})")
98
+ log(f"ASC_ISSUER_ID={asc_issuer} (source: {'config' if cfg_issuer else 'filename'})")
99
+ log(f"ASC_KEY_P8_PATH={p8_path}")
100
+ return {"key_id": str(asc_key_id), "issuer_id": str(asc_issuer), "key_path": str(p8_path)}
101
+
102
+
103
+ def resolve_xcode(cfg: dict, workspace: Path, inp: dict) -> dict:
104
+ """Resolve project / workspace / scheme / configuration / profile_name."""
105
+ proj_cfg, proj_src = pick_with_source(cfg, ("xcode", "project"), ("ios", "native", "project"))
106
+ ws_cfg, ws_src = pick_with_source(cfg, ("xcode", "workspace"), ("ios", "native", "workspace"))
107
+ scheme_cfg, scheme_src = pick_with_source(cfg, ("xcode", "scheme"), ("ios", "native", "scheme"))
108
+ config_cfg, config_src = pick_with_source(
109
+ cfg, ("xcode", "configuration"), ("ios", "native", "configuration"),
110
+ )
111
+ profile_cfg, profile_src = pick_with_source(
112
+ cfg, ("xcode", "profile_name"), ("ios", "native", "profile_name"),
113
+ )
114
+
115
+ project = resolve(
116
+ inp["PROJECT"], proj_cfg, cfg_source=proj_src,
117
+ auto_val=auto_project_glob(workspace, ".xcodeproj"),
118
+ )
119
+ ws_val = resolve(
120
+ inp["WORKSPACE"], ws_cfg, cfg_source=ws_src,
121
+ auto_val=auto_project_glob(workspace, ".xcworkspace"),
122
+ )
123
+ scheme = resolve(inp["SCHEME"], scheme_cfg, cfg_source=scheme_src)
124
+ configuration = resolve(
125
+ inp["CONFIGURATION"], config_cfg, cfg_source=config_src, default="Release",
126
+ )
127
+ default_profile = f"{scheme[0]} CI" if scheme[0] else ""
128
+ profile_name = resolve(
129
+ inp["PROFILE_NAME"], profile_cfg, cfg_source=profile_src, default=default_profile,
130
+ )
131
+ return {
132
+ "project": project,
133
+ "workspace": ws_val,
134
+ "scheme": scheme,
135
+ "configuration": configuration,
136
+ "profile_name": profile_name,
137
+ }
138
+
139
+
140
+ def resolve_app(cfg: dict, inp: dict, creds: dict, scripts_dir: Path) -> dict:
141
+ """Resolve bundle_id / team_id / app_store_apple_id (with ASC-API fallback)."""
142
+ bundle_cfg, bundle_src = pick_with_source(cfg, ("app", "bundle_id"), ("ios", "native", "bundle_id"))
143
+ team_cfg, team_src = pick_with_source(cfg, ("app", "team_id"), ("ios", "native", "team_id"))
144
+ apple_cfg, apple_src = pick_with_source(
145
+ cfg, ("app", "app_store_apple_id"), ("ios", "native", "app_store_apple_id"),
146
+ )
147
+
148
+ bundle = resolve(inp["BUNDLE_ID"], bundle_cfg, cfg_source=bundle_src)
149
+ team = resolve(inp["TEAM_ID"], team_cfg, cfg_source=team_src)
150
+
151
+ if inp["APP_STORE_APPLE_ID"]:
152
+ apple = (inp["APP_STORE_APPLE_ID"], "input")
153
+ elif apple_cfg not in (None, ""):
154
+ apple = (str(apple_cfg), apple_src)
155
+ elif bundle[0]:
156
+ os.environ["ASC_KEY_ID"] = creds["key_id"]
157
+ os.environ["ASC_ISSUER_ID"] = creds["issuer_id"]
158
+ os.environ["ASC_KEY_PATH"] = creds["key_path"]
159
+ apple = (lookup_app_id_via_api(bundle[0], scripts_dir), "api-lookup")
160
+ else:
161
+ apple = ("", "empty")
162
+ return {"bundle_id": bundle, "team_id": team, "app_store_apple_id": apple}
163
+
164
+
165
+ def resolve_testflight(cfg: dict, inp: dict) -> dict:
166
+ """Resolve whats_new / locale from testflight.* or legacy aliases."""
167
+ whats_cfg, whats_src = pick_with_source(
168
+ cfg, ("testflight", "whats_new"), ("ios", "native", "whats_new"),
169
+ )
170
+ locale_cfg, locale_src = pick_with_source(
171
+ cfg, ("testflight", "locale"), ("ios", "native", "locale"),
172
+ )
173
+ if locale_cfg in (None, ""):
174
+ alt = dig(cfg, "app_store", "locale")
175
+ if alt not in (None, ""):
176
+ locale_cfg, locale_src = alt, "config(legacy)"
177
+ return {
178
+ "whats_new": resolve(
179
+ inp["WHATS_NEW"], whats_cfg, cfg_source=whats_src, default=DEFAULT_WHATS_NEW,
180
+ ),
181
+ "locale": resolve(inp["LOCALE"], locale_cfg, cfg_source=locale_src, default="en-US"),
182
+ }
183
+
184
+
185
+ def resolve_tests(cfg: dict, inp: dict) -> dict:
186
+ """Resolve run_tests / test_command / test_destination from ci.* or legacy."""
187
+ run_flat = dig(cfg, "ci", "run_tests")
188
+ run_legacy = dig(cfg, "ios", "native", "run_tests")
189
+ if run_legacy is None:
190
+ run_legacy = dig(cfg, "ios", "native", "tests", "run")
191
+ run_cfg, run_src = (run_flat, "config") if run_flat is not None else (run_legacy, "config(legacy)")
192
+
193
+ cmd_flat = dig(cfg, "ci", "test_command")
194
+ cmd_legacy = dig(cfg, "ios", "native", "test_command") or dig(cfg, "ios", "native", "tests", "command")
195
+ cmd_cfg, cmd_src = (
196
+ (cmd_flat, "config") if cmd_flat not in (None, "") else (cmd_legacy, "config(legacy)")
197
+ )
198
+
199
+ dest_flat = dig(cfg, "ci", "test_destination")
200
+ dest_legacy = (
201
+ dig(cfg, "ios", "native", "test_destination")
202
+ or dig(cfg, "ios", "native", "tests", "destination")
203
+ )
204
+ dest_cfg, dest_src = (
205
+ (dest_flat, "config") if dest_flat not in (None, "") else (dest_legacy, "config(legacy)")
206
+ )
207
+
208
+ return {
209
+ "run_tests": resolve(
210
+ inp["RUN_TESTS"], as_str_bool(run_cfg), cfg_source=run_src, default="false",
211
+ ),
212
+ "test_command": resolve(inp["TEST_COMMAND"], cmd_cfg, cfg_source=cmd_src),
213
+ "test_destination": resolve(inp["TEST_DESTINATION"], dest_cfg, cfg_source=dest_src),
214
+ }
215
+
216
+
217
+ def resolve_ios(cfg: dict, inp: dict) -> dict:
218
+ """Resolve ios.uses_non_exempt_encryption (flat) with legacy fallback."""
219
+ val_flat = dig(cfg, "ios", "uses_non_exempt_encryption")
220
+ # Flat lookup can accidentally return the legacy 'native' sub-dict; ignore.
221
+ if isinstance(val_flat, dict):
222
+ val_flat = None
223
+ val_legacy = dig(cfg, "ios", "native", "uses_non_exempt_encryption")
224
+ if val_flat is not None:
225
+ raw, src = val_flat, "config"
226
+ elif val_legacy is not None:
227
+ raw, src = val_legacy, "config(legacy)"
228
+ else:
229
+ raw, src = None, "config"
230
+ return {
231
+ "uses_non_exempt": resolve(
232
+ inp["USES_NON_EXEMPT"], as_str_bool(raw), cfg_source=src, default="false",
233
+ )
234
+ }
235
+
236
+
237
+ def collect_inputs() -> dict:
238
+ """Read all INPUT_* env vars the composite action passes in."""
239
+ names = (
240
+ "PROJECT", "WORKSPACE", "SCHEME", "CONFIGURATION", "PROFILE_NAME",
241
+ "BUNDLE_ID", "TEAM_ID", "APP_STORE_APPLE_ID",
242
+ "USES_NON_EXEMPT", "WHATS_NEW", "LOCALE",
243
+ "RUN_TESTS", "TEST_COMMAND", "TEST_DESTINATION",
244
+ )
245
+ return {name: os.environ.get(f"INPUT_{name}", "") for name in names}
246
+
247
+
248
+ def main() -> None:
249
+ workspace = Path(os.environ.get("GITHUB_WORKSPACE", "."))
250
+ scripts_dir = Path(__file__).resolve().parent
251
+ env_file_path = os.environ.get("GITHUB_ENV")
252
+ if not env_file_path:
253
+ fail("GITHUB_ENV not set; this script must run inside a GitHub Actions step")
254
+ env_file = Path(env_file_path)
255
+
256
+ cfg = load_config(workspace, os.environ.get("CONFIG_PATH", "ci.config.yaml"))
257
+ inputs = collect_inputs()
258
+
259
+ creds = emit_credentials(env_file, cfg, workspace)
260
+ xc = resolve_xcode(cfg, workspace, inputs)
261
+ app = resolve_app(cfg, inputs, creds, scripts_dir)
262
+ tf = resolve_testflight(cfg, inputs)
263
+ ios = resolve_ios(cfg, inputs)
264
+ tests = resolve_tests(cfg, inputs)
265
+
266
+ derived = [
267
+ ("CFG_PROJECT", xc["project"]),
268
+ ("CFG_WORKSPACE", xc["workspace"]),
269
+ ("CFG_SCHEME", xc["scheme"]),
270
+ ("CFG_CONFIGURATION", xc["configuration"]),
271
+ ("CFG_BUNDLE_ID", app["bundle_id"]),
272
+ ("CFG_TEAM_ID", app["team_id"]),
273
+ ("CFG_APP_STORE_APPLE_ID", app["app_store_apple_id"]),
274
+ ("CFG_PROFILE_NAME", xc["profile_name"]),
275
+ ("CFG_USES_NON_EXEMPT", ios["uses_non_exempt"]),
276
+ ("CFG_WHATS_NEW", tf["whats_new"]),
277
+ ("CFG_LOCALE", tf["locale"]),
278
+ ("CFG_RUN_TESTS", tests["run_tests"]),
279
+ ("CFG_TEST_COMMAND", tests["test_command"]),
280
+ ("CFG_TEST_DESTINATION", tests["test_destination"]),
281
+ ]
282
+ for name, (value, src) in derived:
283
+ emit(env_file, name, value)
284
+ shown = value if name != "CFG_WHATS_NEW" else value.replace("\n", " / ")
285
+ log(f"{name}={shown!r} (source: {src})")
286
+
287
+
288
+ if __name__ == "__main__":
289
+ main()