@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/github/IOS_NATIVE_CI_SETUP.md +20 -6
- package/templates/ios-native-ci.config.yaml.template +22 -9
- package/templates/scripts/ci/ios-native/auto_detect.py +274 -0
- 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 +86 -5
- 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.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.
|
|
15
|
+
"version": "0.10.87",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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 —
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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" #
|
|
7
|
-
team_id: "ABCDE12345" # optional
|
|
8
|
-
app_store_apple_id: "" # optional — auto-discovered via ASC API
|
|
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
|
|
12
|
-
workspace: "" # wins over project when set
|
|
13
|
-
scheme: "MyApp" #
|
|
24
|
+
project: "" # optional — auto-detected (*.xcodeproj at root)
|
|
25
|
+
workspace: "" # optional — wins over project when set
|
|
26
|
+
scheme: "MyApp" # optional — auto-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
|
-
|
|
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} "
|
|
@@ -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
|
-
|
|
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(
|
|
147
|
-
|
|
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
|
-
|
|
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)
|