@daemux/store-automator 0.10.85 → 0.10.87

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.87"
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.87",
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.87",
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.87",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -1,6 +1,8 @@
1
1
  # iOS Native TestFlight CI — Setup Guide
2
2
 
3
- Drop-in TestFlight automation for native Swift/SwiftUI iOS apps. One workflow, one config file, one credential file. All logic lives in the shared composite action `daemux/daemux-plugins/.github/actions/ios-native-testflight`.
3
+ Drop-in TestFlight automation for native Swift/SwiftUI iOS apps. One workflow, one credential file and optionally a config file. All logic lives in the shared composite action `daemux/daemux-plugins/.github/actions/ios-native-testflight`.
4
+
5
+ **As of v0.18.0, `ci.config.yaml` is entirely optional.** With just `creds/AuthKey_*.p8` at the repo root and the 18-line `deploy.yml`, CI auto-detects your project, workspace, scheme, bundle id, team id, and App Store Connect app id. Add a `ci.config.yaml` only when you need to override auto-detection (e.g., multiple `.xcodeproj` at root, a non-application scheme, a pinned configuration).
4
6
 
5
7
  ## Prerequisites
6
8
 
@@ -13,14 +15,16 @@ Drop-in TestFlight automation for native Swift/SwiftUI iOS apps. One workflow, o
13
15
 
14
16
  Copy `.github/workflows/deploy.yml` verbatim from this template. No edits required. It triggers on push to `main` and via `workflow_dispatch`.
15
17
 
16
- ## Step 2 — Copy `ci.config.yaml`
18
+ ## Step 2 — (Optional) Copy `ci.config.yaml`
17
19
 
18
- Copy `ios-native-ci.config.yaml.template` to your repo root as `ci.config.yaml`. Fill in:
20
+ **Skip this step entirely** unless you need to override auto-detection. The CI figures out sane defaults for every field from your Xcode project.
19
21
 
20
- - `app.bundle_id` REQUIRED (must match your App Store Connect app record).
21
- - `xcode.scheme` — REQUIRED (the shared scheme the CI should archive).
22
+ If you want explicit control, copy `ios-native-ci.config.yaml.template` to your repo root as `ci.config.yaml` and set just the fields you want to pin:
22
23
 
23
- Every other field has a sensible default. `xcode.project` auto-detects if there is exactly one `*.xcodeproj` at the repo root. `app.app_store_apple_id` is auto-discovered via the ASC API from the bundle id.
24
+ - `xcode.project` if you have multiple `*.xcodeproj` at the repo root.
25
+ - `xcode.scheme` — if you have multiple application-type schemes.
26
+ - `app.bundle_id` — if auto-detect picks the wrong one (rare).
27
+ - Everything else has a sensible default.
24
28
 
25
29
  ## Step 3 — Drop in the ASC API key
26
30
 
@@ -49,6 +53,16 @@ Leave your Xcode project's `MARKETING_VERSION` at `1.0` initially. CI auto-rolls
49
53
 
50
54
  ## Step 6 — Push to `main`
51
55
 
56
+ Minimal (no config file):
57
+
58
+ ```bash
59
+ git add .github/workflows/deploy.yml creds/AuthKey_*.p8
60
+ git commit -m "ci: add iOS TestFlight pipeline"
61
+ git push origin main
62
+ ```
63
+
64
+ With an optional config file:
65
+
52
66
  ```bash
53
67
  git add .github/workflows/deploy.yml ci.config.yaml creds/AuthKey_*.p8
54
68
  git commit -m "ci: add iOS TestFlight pipeline"
@@ -1,16 +1,29 @@
1
- # iOS TestFlight CI configuration — read by daemux/daemux-plugins/.github/actions/ios-native-testflight
2
- # Place this at the repo root as ci.config.yaml.
3
- # Also place your ASC API key at creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8
1
+ # iOS TestFlight CI configuration — OPTIONAL
2
+ #
3
+ # This file is entirely OPTIONAL. Omit it to let CI auto-detect everything
4
+ # from your Xcode project:
5
+ #
6
+ # - xcode.project -> glob *.xcodeproj / *.xcworkspace at repo root
7
+ # - xcode.scheme -> pick the single application-type scheme
8
+ # - app.bundle_id -> PRODUCT_BUNDLE_IDENTIFIER from xcodebuild
9
+ # - app.team_id -> derived from your ASC API key
10
+ # - app.app_store_apple_id -> looked up via ASC API by bundle_id
11
+ #
12
+ # Set any subset of values below to override auto-detection. All fields are
13
+ # optional; only add what you need to disambiguate or pin.
14
+ #
15
+ # Consumed by daemux/daemux-plugins/.github/actions/ios-native-testflight.
16
+ # Required regardless: creds/AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8
4
17
 
5
18
  app:
6
- bundle_id: "com.example.myapp" # REQUIRED
7
- team_id: "ABCDE12345" # optional (used for manual signing)
8
- app_store_apple_id: "" # optional — auto-discovered via ASC API by bundle_id
19
+ bundle_id: "com.example.myapp" # optional — auto-detected from xcodebuild
20
+ team_id: "ABCDE12345" # optional auto-derived from ASC API key
21
+ app_store_apple_id: "" # optional — auto-discovered via ASC API
9
22
 
10
23
  xcode:
11
- project: "" # auto-detected if exactly one *.xcodeproj at root
12
- workspace: "" # wins over project when set
13
- scheme: "MyApp" # REQUIREDno safe auto-detect
24
+ project: "" # optional — auto-detected (*.xcodeproj at root)
25
+ workspace: "" # optional — wins over project when set
26
+ scheme: "MyApp" # optionalauto-picks application-type scheme
14
27
  configuration: "Release" # default: Release
15
28
  profile_name: "" # default: "<scheme> CI"
16
29
 
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Xcode project / scheme / bundle_id auto-detection.
4
+
5
+ Ported from gowalk-step/fastlane_standalone.sh — keeps us in Python (our
6
+ ecosystem) while still shelling out to ``xcodebuild`` for the two queries
7
+ only it can reliably answer:
8
+
9
+ ``xcodebuild -list -json`` -> enumerate schemes
10
+ ``xcodebuild -showBuildSettings -json`` -> PRODUCT_TYPE + bundle id
11
+
12
+ The detection lets ``ci.config.yaml`` become entirely optional: with just
13
+ ``creds/AuthKey_*.p8`` + the 18-line ``deploy.yml`` the pipeline can still
14
+ figure out what to build.
15
+
16
+ Rules (kept intentionally boring — if auto-detect is ambiguous we log a
17
+ warning and return a deterministic choice, falling back to a clear
18
+ ``::error::`` only when truly nothing works):
19
+
20
+ 1. ``auto_detect_project(workspace)``
21
+ - Prefer a single ``*.xcworkspace`` at the repo root.
22
+ - Else a single ``*.xcodeproj``.
23
+ - When multiple ``.xcodeproj`` exist, prefer one matching the repo
24
+ basename, else alphabetically first (with a warning).
25
+
26
+ 2. ``auto_detect_scheme(workspace, project, workspace_file)``
27
+ - ``xcodebuild -list -json`` to enumerate schemes.
28
+ - For each scheme, ``-showBuildSettings -json`` and keep those whose
29
+ ``PRODUCT_TYPE == com.apple.product-type.application``.
30
+ - Single application scheme -> that one. Multiple: prefer repo-basename
31
+ match, else alphabetical first.
32
+
33
+ 3. ``auto_detect_bundle_id(workspace, project, workspace_file, scheme, configuration)``
34
+ - Read ``PRODUCT_BUNDLE_IDENTIFIER`` from the same ``-showBuildSettings``
35
+ invocation (cached from step 2 when possible).
36
+ - Reject unresolved variable references like ``$(PRODUCT_NAME)``.
37
+
38
+ All xcodebuild invocations are cached per-process so repeated calls in the
39
+ same ``read_config.py`` run don't re-shell.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import subprocess
46
+ from pathlib import Path
47
+ from typing import Optional
48
+
49
+ from cfg_io import log, notice
50
+
51
+
52
+ APP_PRODUCT_TYPE = "com.apple.product-type.application"
53
+ # Timeouts (seconds) — xcodebuild can hang on missing toolchains or
54
+ # package resolution. CI should fail fast rather than spin forever.
55
+ LIST_TIMEOUT = 60
56
+ SETTINGS_TIMEOUT = 120
57
+
58
+ # Process-local caches keyed by (project, workspace_file) or
59
+ # (project, workspace_file, scheme, configuration). Cleared between test
60
+ # cases via ``_clear_caches`` but otherwise persist for the lifetime of
61
+ # the read_config.py process.
62
+ _list_cache: dict[tuple, Optional[list[str]]] = {}
63
+ _settings_cache: dict[tuple, Optional[dict]] = {}
64
+
65
+
66
+ def _clear_caches() -> None:
67
+ """Reset caches — used by tests. Not part of the public API."""
68
+ _list_cache.clear()
69
+ _settings_cache.clear()
70
+
71
+
72
+ def auto_detect_project(workspace: Path) -> tuple[str, str]:
73
+ """Discover the repo-root Xcode project / workspace.
74
+
75
+ Returns ``(project, workspace_file)`` where exactly one is a non-empty
76
+ filename. ``("", "")`` means nothing was found.
77
+ """
78
+ workspaces = sorted(workspace.glob("*.xcworkspace"))
79
+ if workspaces:
80
+ picked = _pick_by_basename(workspaces, workspace, "xcworkspace")
81
+ log(f"auto-detect: workspace={picked.name}")
82
+ return "", picked.name
83
+
84
+ projects = sorted(workspace.glob("*.xcodeproj"))
85
+ if not projects:
86
+ return "", ""
87
+ picked = _pick_by_basename(projects, workspace, "xcodeproj")
88
+ log(f"auto-detect: project={picked.name}")
89
+ return picked.name, ""
90
+
91
+
92
+ def _pick_by_basename(matches: list[Path], workspace: Path, label: str) -> Path:
93
+ """Pick the match whose name stem equals the repo basename, else first."""
94
+ if len(matches) == 1:
95
+ return matches[0]
96
+ repo_base = workspace.resolve().name.lower()
97
+ for match in matches:
98
+ if match.stem.lower() == repo_base:
99
+ return match
100
+ notice(
101
+ f"multiple *.{label} found; picking {matches[0].name} alphabetically. "
102
+ f"Set xcode.project in ci.config.yaml to disambiguate."
103
+ )
104
+ return matches[0]
105
+
106
+
107
+ def _list_schemes(
108
+ workspace: Path, project: str, workspace_file: str
109
+ ) -> Optional[list[str]]:
110
+ """Run ``xcodebuild -list -json`` and return the schemes list (cached)."""
111
+ key = (project, workspace_file)
112
+ if key in _list_cache:
113
+ return _list_cache[key]
114
+
115
+ cmd = ["xcodebuild", "-list", "-json"]
116
+ if workspace_file:
117
+ cmd += ["-workspace", workspace_file]
118
+ elif project:
119
+ cmd += ["-project", project]
120
+ else:
121
+ _list_cache[key] = None
122
+ return None
123
+
124
+ try:
125
+ result = subprocess.run(
126
+ cmd, cwd=str(workspace), capture_output=True, text=True,
127
+ timeout=LIST_TIMEOUT,
128
+ )
129
+ except (OSError, subprocess.TimeoutExpired) as exc:
130
+ log(f"auto-detect: xcodebuild -list failed: {exc!r}")
131
+ _list_cache[key] = None
132
+ return None
133
+
134
+ if result.returncode != 0:
135
+ log(
136
+ f"auto-detect: xcodebuild -list returned {result.returncode}: "
137
+ f"{result.stderr.strip()}"
138
+ )
139
+ _list_cache[key] = None
140
+ return None
141
+
142
+ try:
143
+ data = json.loads(result.stdout)
144
+ except json.JSONDecodeError as exc:
145
+ log(f"auto-detect: xcodebuild -list produced invalid JSON: {exc!r}")
146
+ _list_cache[key] = None
147
+ return None
148
+
149
+ # Workspace output has {"workspace": {"schemes": [...]}}; project has
150
+ # {"project": {"schemes": [...]}}.
151
+ container = data.get("workspace") or data.get("project") or {}
152
+ schemes = container.get("schemes") or []
153
+ _list_cache[key] = list(schemes)
154
+ return _list_cache[key]
155
+
156
+
157
+ def _show_build_settings(
158
+ workspace: Path,
159
+ project: str,
160
+ workspace_file: str,
161
+ scheme: str,
162
+ configuration: str,
163
+ ) -> Optional[dict]:
164
+ """Run ``xcodebuild -showBuildSettings -json`` and return buildSettings (cached)."""
165
+ key = (project, workspace_file, scheme, configuration or "Release")
166
+ if key in _settings_cache:
167
+ return _settings_cache[key]
168
+
169
+ cmd = ["xcodebuild", "-showBuildSettings", "-json", "-scheme", scheme]
170
+ if configuration:
171
+ cmd += ["-configuration", configuration]
172
+ if workspace_file:
173
+ cmd += ["-workspace", workspace_file]
174
+ elif project:
175
+ cmd += ["-project", project]
176
+ else:
177
+ _settings_cache[key] = None
178
+ return None
179
+
180
+ try:
181
+ result = subprocess.run(
182
+ cmd, cwd=str(workspace), capture_output=True, text=True,
183
+ timeout=SETTINGS_TIMEOUT,
184
+ )
185
+ except (OSError, subprocess.TimeoutExpired) as exc:
186
+ log(f"auto-detect: showBuildSettings({scheme!r}) failed: {exc!r}")
187
+ _settings_cache[key] = None
188
+ return None
189
+
190
+ if result.returncode != 0:
191
+ log(
192
+ f"auto-detect: showBuildSettings({scheme!r}) returned "
193
+ f"{result.returncode}: {result.stderr.strip()}"
194
+ )
195
+ _settings_cache[key] = None
196
+ return None
197
+
198
+ try:
199
+ data = json.loads(result.stdout)
200
+ except json.JSONDecodeError as exc:
201
+ log(f"auto-detect: showBuildSettings({scheme!r}) invalid JSON: {exc!r}")
202
+ _settings_cache[key] = None
203
+ return None
204
+
205
+ if not isinstance(data, list) or not data:
206
+ _settings_cache[key] = None
207
+ return None
208
+ settings = data[0].get("buildSettings") or {}
209
+ _settings_cache[key] = settings if isinstance(settings, dict) else None
210
+ return _settings_cache[key]
211
+
212
+
213
+ def auto_detect_scheme(
214
+ workspace: Path, project: str, workspace_file: str
215
+ ) -> Optional[str]:
216
+ """Pick the single ``com.apple.product-type.application`` scheme."""
217
+ schemes = _list_schemes(workspace, project, workspace_file)
218
+ if not schemes:
219
+ return None
220
+
221
+ app_schemes: list[str] = []
222
+ for scheme in schemes:
223
+ settings = _show_build_settings(
224
+ workspace, project, workspace_file, scheme, "Release",
225
+ )
226
+ if not settings:
227
+ continue
228
+ if settings.get("PRODUCT_TYPE") == APP_PRODUCT_TYPE:
229
+ app_schemes.append(scheme)
230
+
231
+ if not app_schemes:
232
+ return None
233
+ if len(app_schemes) == 1:
234
+ log(f"auto-detect: scheme={app_schemes[0]}")
235
+ return app_schemes[0]
236
+
237
+ repo_base = workspace.resolve().name.lower()
238
+ for scheme in app_schemes:
239
+ if scheme.lower() == repo_base:
240
+ log(f"auto-detect: scheme={scheme} (basename match)")
241
+ return scheme
242
+
243
+ picked = sorted(app_schemes)[0]
244
+ notice(
245
+ f"multiple application schemes found ({', '.join(sorted(app_schemes))}); "
246
+ f"picking {picked} alphabetically. Set xcode.scheme in ci.config.yaml "
247
+ f"to disambiguate."
248
+ )
249
+ return picked
250
+
251
+
252
+ def auto_detect_bundle_id(
253
+ workspace: Path,
254
+ project: str,
255
+ workspace_file: str,
256
+ scheme: str,
257
+ configuration: str,
258
+ ) -> Optional[str]:
259
+ """Extract ``PRODUCT_BUNDLE_IDENTIFIER`` for the given scheme/config."""
260
+ settings = _show_build_settings(
261
+ workspace, project, workspace_file, scheme, configuration or "Release",
262
+ )
263
+ if not settings:
264
+ return None
265
+ bundle = settings.get("PRODUCT_BUNDLE_IDENTIFIER")
266
+ if not bundle or not isinstance(bundle, str):
267
+ return None
268
+ # xcodebuild sometimes emits unresolved variable references when build
269
+ # settings depend on xcconfig files that aren't in the default context.
270
+ if "$(" in bundle or bundle.startswith("$") or "=" in bundle:
271
+ log(f"auto-detect: bundle_id {bundle!r} contains unresolved variable — rejecting")
272
+ return None
273
+ log(f"auto-detect: bundle_id={bundle}")
274
+ return bundle
@@ -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} "
@@ -53,10 +53,12 @@ from __future__ import annotations
53
53
  import os
54
54
  from pathlib import Path
55
55
 
56
+ import auto_detect
56
57
  from cfg_io import fail, log
57
58
  from cfg_resolve import (
58
59
  as_str_bool,
59
60
  auto_project_glob,
61
+ derive_team_if_empty,
60
62
  dig,
61
63
  emit,
62
64
  find_p8,
@@ -106,8 +108,49 @@ def emit_credentials(env_file: Path, cfg: dict, workspace: Path) -> dict:
106
108
  return {"key_id": str(asc_key_id), "issuer_id": str(asc_issuer), "key_path": str(p8_path)}
107
109
 
108
110
 
111
+ def _auto_detect_bundle_if_empty(
112
+ bundle: tuple[str, str],
113
+ workspace: Path | None,
114
+ xcode: dict | None,
115
+ ) -> tuple[str, str]:
116
+ """Call auto_detect_bundle_id only when bundle is unresolved."""
117
+ if bundle[0] or workspace is None or xcode is None or not xcode.get("scheme"):
118
+ return bundle
119
+ detected = auto_detect.auto_detect_bundle_id(
120
+ workspace,
121
+ xcode.get("project", ""),
122
+ xcode.get("workspace", ""),
123
+ xcode["scheme"],
124
+ xcode.get("configuration", "Release"),
125
+ )
126
+ if detected:
127
+ return detected, "auto-detect"
128
+ return bundle
129
+
130
+
131
+ def _auto_detect_project_workspace(
132
+ workspace: Path,
133
+ project: tuple[str, str],
134
+ ws_val: tuple[str, str],
135
+ ) -> tuple[tuple[str, str], tuple[str, str]]:
136
+ """Run auto_detect.auto_detect_project when both are unset; return the pair."""
137
+ if project[0] or ws_val[0]:
138
+ return project, ws_val
139
+ auto_proj, auto_ws = auto_detect.auto_detect_project(workspace)
140
+ if auto_ws:
141
+ return project, (auto_ws, "auto-detect")
142
+ if auto_proj:
143
+ return (auto_proj, "auto-detect"), ws_val
144
+ return project, ws_val
145
+
146
+
109
147
  def resolve_xcode(cfg: dict, workspace: Path, inp: dict) -> dict:
110
- """Resolve project / workspace / scheme / configuration / profile_name."""
148
+ """Resolve project / workspace / scheme / configuration / profile_name.
149
+
150
+ Falls back to ``auto_detect`` (glob + xcodebuild -list + showBuildSettings)
151
+ when neither input nor config nor the older single-suffix glob finds a
152
+ value. This is what lets ``ci.config.yaml`` be omitted entirely.
153
+ """
111
154
  proj_cfg, proj_src = pick_with_source(cfg, ("xcode", "project"), ("ios", "native", "project"))
112
155
  ws_cfg, ws_src = pick_with_source(cfg, ("xcode", "workspace"), ("ios", "native", "workspace"))
113
156
  scheme_cfg, scheme_src = pick_with_source(cfg, ("xcode", "scheme"), ("ios", "native", "scheme"))
@@ -126,10 +169,18 @@ def resolve_xcode(cfg: dict, workspace: Path, inp: dict) -> dict:
126
169
  inp["WORKSPACE"], ws_cfg, cfg_source=ws_src,
127
170
  auto_val=auto_project_glob(workspace, ".xcworkspace"),
128
171
  )
129
- scheme = resolve(inp["SCHEME"], scheme_cfg, cfg_source=scheme_src)
172
+ project, ws_val = _auto_detect_project_workspace(workspace, project, ws_val)
173
+
130
174
  configuration = resolve(
131
175
  inp["CONFIGURATION"], config_cfg, cfg_source=config_src, default="Release",
132
176
  )
177
+
178
+ scheme = resolve(inp["SCHEME"], scheme_cfg, cfg_source=scheme_src)
179
+ if not scheme[0] and (project[0] or ws_val[0]):
180
+ detected = auto_detect.auto_detect_scheme(workspace, project[0], ws_val[0])
181
+ if detected:
182
+ scheme = (detected, "auto-detect")
183
+
133
184
  default_profile = f"{scheme[0]} CI" if scheme[0] else ""
134
185
  profile_name = resolve(
135
186
  inp["PROFILE_NAME"], profile_cfg, cfg_source=profile_src, default=default_profile,
@@ -143,8 +194,21 @@ def resolve_xcode(cfg: dict, workspace: Path, inp: dict) -> dict:
143
194
  }
144
195
 
145
196
 
146
- def resolve_app(cfg: dict, inp: dict, creds: dict, scripts_dir: Path) -> dict:
147
- """Resolve bundle_id / team_id / app_store_apple_id (with ASC-API fallback)."""
197
+ def resolve_app(
198
+ cfg: dict,
199
+ inp: dict,
200
+ creds: dict,
201
+ scripts_dir: Path,
202
+ *,
203
+ workspace: Path | None = None,
204
+ xcode: dict | None = None,
205
+ ) -> dict:
206
+ """Resolve bundle_id / team_id / app_store_apple_id (with ASC-API fallback).
207
+
208
+ When ``workspace`` and ``xcode`` are passed and bundle_id is unset from
209
+ both input and config, we call ``auto_detect_bundle_id`` to ask
210
+ xcodebuild for ``PRODUCT_BUNDLE_IDENTIFIER``.
211
+ """
148
212
  bundle_cfg, bundle_src = pick_with_source(cfg, ("app", "bundle_id"), ("ios", "native", "bundle_id"))
149
213
  team_cfg, team_src = pick_with_source(cfg, ("app", "team_id"), ("ios", "native", "team_id"))
150
214
  apple_cfg, apple_src = pick_with_source(
@@ -152,8 +216,21 @@ def resolve_app(cfg: dict, inp: dict, creds: dict, scripts_dir: Path) -> dict:
152
216
  )
153
217
 
154
218
  bundle = resolve(inp["BUNDLE_ID"], bundle_cfg, cfg_source=bundle_src)
219
+ bundle = _auto_detect_bundle_if_empty(bundle, workspace, xcode)
155
220
  team = resolve(inp["TEAM_ID"], team_cfg, cfg_source=team_src)
156
221
 
222
+ # team_id is documented as optional because the ASC API key is bound to
223
+ # exactly one developer team. If input + config both empty, derive it
224
+ # via GET-only ASC endpoints (cert OU, then profile plist).
225
+ if not team[0]:
226
+ derived_val, derived_src = derive_team_if_empty(team[0], team[1], creds)
227
+ if derived_val:
228
+ log(
229
+ f"derived team_id={derived_val} from ASC key "
230
+ f"{creds['key_id']}"
231
+ )
232
+ team = (derived_val, derived_src)
233
+
157
234
  if inp["APP_STORE_APPLE_ID"]:
158
235
  apple = (inp["APP_STORE_APPLE_ID"], "input")
159
236
  elif apple_cfg not in (None, ""):
@@ -264,7 +341,11 @@ def main() -> None:
264
341
 
265
342
  creds = emit_credentials(env_file, cfg, workspace)
266
343
  xc = resolve_xcode(cfg, workspace, inputs)
267
- app = resolve_app(cfg, inputs, creds, scripts_dir)
344
+ xc_values = {k: v[0] for k, v in xc.items()}
345
+ app = resolve_app(
346
+ cfg, inputs, creds, scripts_dir,
347
+ workspace=workspace, xcode=xc_values,
348
+ )
268
349
  tf = resolve_testflight(cfg, inputs)
269
350
  ios = resolve_ios(cfg, inputs)
270
351
  tests = resolve_tests(cfg, inputs)
@@ -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)