@daemux/store-automator 0.10.87 → 0.10.88
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 +36 -28
- package/templates/scripts/ci/ios-native/cfg_resolve.py +16 -125
- package/templates/scripts/ci/ios-native/read_config.py +112 -209
- package/templates/ios-native-ci.config.yaml.template +0 -43
|
@@ -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.88"
|
|
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.88",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
# iOS Native TestFlight CI — Setup Guide
|
|
2
2
|
|
|
3
|
-
Drop-in TestFlight automation for native Swift/SwiftUI iOS apps.
|
|
3
|
+
**Zero config.** Drop-in TestFlight automation for native Swift/SwiftUI iOS apps. Two files in your repo are all you need:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
1. `.github/workflows/deploy.yml` (18 lines, copied verbatim)
|
|
6
|
+
2. `creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8` (your ASC API key)
|
|
7
|
+
|
|
8
|
+
There is **no** `ci.config.yaml`. The composite action auto-detects your project, workspace, scheme, bundle id, team id, and App Store Connect numeric app id from the Xcode project + ASC API. To override any auto-detected value, pass it via the action's `with:` block in `deploy.yml`.
|
|
9
|
+
|
|
10
|
+
All logic lives in the shared composite action `daemux/daemux-plugins/.github/actions/ios-native-testflight`.
|
|
6
11
|
|
|
7
12
|
## Prerequisites
|
|
8
13
|
|
|
9
14
|
- **Private GitHub repo** (holds the ASC API key `.p8` file).
|
|
10
15
|
- **Apple Developer Program** membership with an App Store Connect user that can create API keys.
|
|
11
16
|
- **ASC API Key** with `App Manager` role, downloaded as `AuthKey_<KEY_ID>.p8` (Apple lets you download this exactly once).
|
|
12
|
-
- Xcode project builds locally on macOS 15 / Xcode 16
|
|
17
|
+
- Xcode project builds locally on macOS 15 / Xcode 16+.
|
|
13
18
|
|
|
14
19
|
## Step 1 — Copy the workflow
|
|
15
20
|
|
|
16
21
|
Copy `.github/workflows/deploy.yml` verbatim from this template. No edits required. It triggers on push to `main` and via `workflow_dispatch`.
|
|
17
22
|
|
|
18
|
-
## Step 2 —
|
|
19
|
-
|
|
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.
|
|
21
|
-
|
|
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:
|
|
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.
|
|
28
|
-
|
|
29
|
-
## Step 3 — Drop in the ASC API key
|
|
23
|
+
## Step 2 — Drop in the ASC API key
|
|
30
24
|
|
|
31
25
|
Rename the downloaded key to include both the key id and the issuer uuid, then put it under `creds/`:
|
|
32
26
|
|
|
@@ -38,7 +32,7 @@ Example: `creds/AuthKey_5NBDY6YXJ6_Issuer_69a6de77-xxxx-xxxx-xxxx-xxxxxxxxxxxx.p
|
|
|
38
32
|
|
|
39
33
|
The composite action parses the key id and issuer id from the filename — no GitHub secrets to configure.
|
|
40
34
|
|
|
41
|
-
## Step
|
|
35
|
+
## Step 3 — Register the app in App Store Connect (one-time)
|
|
42
36
|
|
|
43
37
|
App Store Connect requires a human-in-the-loop 2FA step the first time. Either:
|
|
44
38
|
|
|
@@ -47,13 +41,11 @@ App Store Connect requires a human-in-the-loop 2FA step the first time. Either:
|
|
|
47
41
|
|
|
48
42
|
Subsequent builds need neither — the ASC API key handles everything.
|
|
49
43
|
|
|
50
|
-
## Step
|
|
44
|
+
## Step 4 — MARKETING_VERSION
|
|
51
45
|
|
|
52
46
|
Leave your Xcode project's `MARKETING_VERSION` at `1.0` initially. CI auto-rolls the marketing version forward (patch bump) whenever the prior version hits `READY_FOR_SALE` in App Store Connect. Until then, the pipeline keeps bumping the build number against the current marketing version.
|
|
53
47
|
|
|
54
|
-
## Step
|
|
55
|
-
|
|
56
|
-
Minimal (no config file):
|
|
48
|
+
## Step 5 — Push to `main`
|
|
57
49
|
|
|
58
50
|
```bash
|
|
59
51
|
git add .github/workflows/deploy.yml creds/AuthKey_*.p8
|
|
@@ -61,25 +53,41 @@ git commit -m "ci: add iOS TestFlight pipeline"
|
|
|
61
53
|
git push origin main
|
|
62
54
|
```
|
|
63
55
|
|
|
64
|
-
|
|
56
|
+
GitHub Actions triggers the workflow. On success, the build appears in TestFlight within 5–15 minutes (Apple processing delay).
|
|
65
57
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
## Overrides (optional)
|
|
59
|
+
|
|
60
|
+
If auto-detection picks the wrong value, pin it in `deploy.yml`:
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
- uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
|
|
64
|
+
with:
|
|
65
|
+
project: MyApp.xcodeproj # if multiple .xcodeproj at root
|
|
66
|
+
workspace: MyApp.xcworkspace # wins over project
|
|
67
|
+
scheme: MyApp # if multiple application schemes
|
|
68
|
+
configuration: Release # default: Release
|
|
69
|
+
bundle-id: com.example.myapp # if PRODUCT_BUNDLE_IDENTIFIER is wrong
|
|
70
|
+
team-id: ABCDE12345 # if your ASC key is multi-team
|
|
71
|
+
app-store-apple-id: '1234567890' # to skip the ASC API lookup
|
|
72
|
+
app-store-whats-new: |
|
|
73
|
+
- New feature X
|
|
74
|
+
- Bug fixes
|
|
75
|
+
app-store-locale: en-US
|
|
76
|
+
uses-non-exempt-encryption: 'false'
|
|
70
77
|
```
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
Every input is optional. Omitted ones are auto-detected.
|
|
73
80
|
|
|
74
81
|
## Troubleshooting
|
|
75
82
|
|
|
76
83
|
| Error | Likely Cause | Fix |
|
|
77
84
|
|-------|--------------|-----|
|
|
78
85
|
| `No ASC key found in creds/` | File missing or misnamed | Filename must match `AuthKey_<KEY_ID>_Issuer_<ISSUER_UUID>.p8` exactly |
|
|
79
|
-
| `
|
|
86
|
+
| `Multiple ASC keys found in creds/` | More than one .p8 | Remove the unused one(s) — only a single key is supported |
|
|
87
|
+
| `No ASC app found for bundle <id>` | App not yet registered in ASC | Run Step 3 (create the app record) |
|
|
80
88
|
| `MARKETING_VERSION not set` | Missing in `.pbxproj` | Set `MARKETING_VERSION = 1.0;` in your target's build settings |
|
|
81
89
|
| Apple refuses new version | Prior version not in `READY_FOR_SALE` | Let the existing version finish review, or manually bump `MARKETING_VERSION` in Xcode |
|
|
82
|
-
| `Scheme not found` | Scheme not shared | In Xcode: Product → Scheme → Manage Schemes → tick "Shared", commit `*.xcscheme` |
|
|
90
|
+
| `Scheme not found` | Scheme not shared, or auto-detect picked wrong one | In Xcode: Product → Scheme → Manage Schemes → tick "Shared", commit `*.xcscheme`. Or pass `scheme:` in the workflow `with:` block. |
|
|
83
91
|
|
|
84
92
|
## Credential rotation
|
|
85
93
|
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
3
|
+
Shared helpers for ios-native-testflight read_config.py.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Now that ci.config.yaml is gone, this module only contains the helpers
|
|
6
|
+
that interact with the filesystem or App Store Connect — no YAML or
|
|
7
|
+
config-precedence logic remains.
|
|
7
8
|
|
|
8
9
|
Contents:
|
|
9
|
-
load_config YAML loader (returns {} if missing)
|
|
10
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
11
|
find_p8 Locate ASC .p8 key in creds/
|
|
12
|
+
derive_team_if_empty ASC API fallback for team_id
|
|
17
13
|
lookup_app_id_via_api Subprocess helper — calls lookup_app_id.py
|
|
18
14
|
"""
|
|
19
15
|
|
|
@@ -21,14 +17,12 @@ from __future__ import annotations
|
|
|
21
17
|
|
|
22
18
|
import os
|
|
23
19
|
import re
|
|
20
|
+
import subprocess
|
|
24
21
|
import sys
|
|
25
22
|
from pathlib import Path
|
|
26
|
-
from typing import Any
|
|
27
|
-
|
|
28
|
-
import yaml
|
|
29
23
|
|
|
30
24
|
from asc_common import make_jwt
|
|
31
|
-
from cfg_io import fail, log
|
|
25
|
+
from cfg_io import fail, log
|
|
32
26
|
from team_resolver import derive_team_id
|
|
33
27
|
|
|
34
28
|
|
|
@@ -37,27 +31,6 @@ P8_RE = re.compile(
|
|
|
37
31
|
)
|
|
38
32
|
|
|
39
33
|
|
|
40
|
-
def load_config(workspace: Path, rel_path: str) -> dict:
|
|
41
|
-
"""Load a YAML config from ``workspace/rel_path``. Returns {} if missing."""
|
|
42
|
-
cfg_path = workspace / rel_path
|
|
43
|
-
if not cfg_path.is_file():
|
|
44
|
-
notice(
|
|
45
|
-
f"{rel_path} not found at {cfg_path}; proceeding with inputs and defaults only"
|
|
46
|
-
)
|
|
47
|
-
return {}
|
|
48
|
-
try:
|
|
49
|
-
with cfg_path.open("r") as fh:
|
|
50
|
-
data = yaml.safe_load(fh)
|
|
51
|
-
except yaml.YAMLError as exc:
|
|
52
|
-
fail(f"failed to parse {rel_path}: {exc}")
|
|
53
|
-
if data is None:
|
|
54
|
-
return {}
|
|
55
|
-
if not isinstance(data, dict):
|
|
56
|
-
fail(f"{rel_path} root must be a mapping")
|
|
57
|
-
log(f"loaded config from {cfg_path}")
|
|
58
|
-
return data
|
|
59
|
-
|
|
60
|
-
|
|
61
34
|
def emit(env_file: Path, name: str, value: str) -> None:
|
|
62
35
|
"""Append NAME=VALUE (or multi-line NAME<<EOF ... EOF) to $GITHUB_ENV."""
|
|
63
36
|
if value is None:
|
|
@@ -73,82 +46,12 @@ def emit(env_file: Path, name: str, value: str) -> None:
|
|
|
73
46
|
fh.write(f"{name}={value}\n")
|
|
74
47
|
|
|
75
48
|
|
|
76
|
-
def
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
node = node.get(key)
|
|
83
|
-
if node is None:
|
|
84
|
-
return default
|
|
85
|
-
return node
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def pick(cfg: dict, *paths: tuple) -> Any:
|
|
89
|
-
"""Return the first non-None/non-empty value among a series of paths."""
|
|
90
|
-
for path in paths:
|
|
91
|
-
val = dig(cfg, *path)
|
|
92
|
-
if val not in (None, ""):
|
|
93
|
-
return val
|
|
94
|
-
return None
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def pick_with_source(cfg: dict, flat: tuple, legacy: tuple) -> tuple[Any, str]:
|
|
98
|
-
"""Return (value, source) — flat path wins; legacy alias as fallback."""
|
|
99
|
-
flat_val = dig(cfg, *flat)
|
|
100
|
-
if flat_val not in (None, ""):
|
|
101
|
-
return flat_val, "config"
|
|
102
|
-
legacy_val = dig(cfg, *legacy)
|
|
103
|
-
if legacy_val not in (None, ""):
|
|
104
|
-
return legacy_val, "config(legacy)"
|
|
105
|
-
return None, "config"
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def resolve(
|
|
109
|
-
input_val: str,
|
|
110
|
-
cfg_val: Any,
|
|
111
|
-
*,
|
|
112
|
-
cfg_source: str = "config",
|
|
113
|
-
auto_val: str = "",
|
|
114
|
-
default: str = "",
|
|
115
|
-
) -> tuple[str, str]:
|
|
116
|
-
"""Apply precedence: input > config > auto > default > empty."""
|
|
117
|
-
if input_val:
|
|
118
|
-
return input_val, "input"
|
|
119
|
-
if cfg_val not in (None, ""):
|
|
120
|
-
return str(cfg_val), cfg_source
|
|
121
|
-
if auto_val:
|
|
122
|
-
return auto_val, "auto-detect"
|
|
123
|
-
if default:
|
|
124
|
-
return default, "default"
|
|
125
|
-
return "", "empty"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def auto_project_glob(workspace: Path, suffix: str) -> str:
|
|
129
|
-
"""Return the single matching *suffix file at repo root, else empty."""
|
|
130
|
-
matches = sorted(workspace.glob(f"*{suffix}"))
|
|
131
|
-
if len(matches) == 1:
|
|
132
|
-
return matches[0].name
|
|
133
|
-
return ""
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def as_str_bool(val: Any) -> str | None:
|
|
137
|
-
"""Normalize YAML bool / string / None to 'true'|'false'|None."""
|
|
138
|
-
if val is None:
|
|
139
|
-
return None
|
|
140
|
-
if isinstance(val, bool):
|
|
141
|
-
return "true" if val else "false"
|
|
142
|
-
s = str(val).strip().lower()
|
|
143
|
-
if s in ("true", "yes", "1"):
|
|
144
|
-
return "true"
|
|
145
|
-
if s in ("false", "no", "0"):
|
|
146
|
-
return "false"
|
|
147
|
-
return s # let downstream validate
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def find_p8(workspace: Path, cfg: dict) -> tuple[Path, str, str | None]:
|
|
151
|
-
"""Locate the ASC .p8 key. Returns (path, key_id, issuer_from_filename)."""
|
|
49
|
+
def find_p8(workspace: Path) -> tuple[Path, str, str | None]:
|
|
50
|
+
"""Locate the ASC .p8 key. Returns (path, key_id, issuer_from_filename).
|
|
51
|
+
|
|
52
|
+
If multiple keys are found we fail with an actionable message — there is
|
|
53
|
+
no longer a config-based disambiguation channel.
|
|
54
|
+
"""
|
|
152
55
|
creds_dir = workspace / "creds"
|
|
153
56
|
matches = sorted(creds_dir.glob("AuthKey_*.p8"))
|
|
154
57
|
if not matches:
|
|
@@ -170,23 +73,13 @@ def find_p8(workspace: Path, cfg: dict) -> tuple[Path, str, str | None]:
|
|
|
170
73
|
"Found .p8 file(s) in creds/ but none match the required naming "
|
|
171
74
|
"pattern AuthKey_<KEY_ID>[_Issuer_<UUID>].p8"
|
|
172
75
|
)
|
|
173
|
-
|
|
174
76
|
if len(parsed) == 1:
|
|
175
77
|
return parsed[0]
|
|
176
78
|
|
|
177
|
-
|
|
178
|
-
if not config_key_id:
|
|
179
|
-
names = ", ".join(p.name for p, _, _ in parsed)
|
|
180
|
-
fail(
|
|
181
|
-
f"Multiple ASC keys found in creds/ ({names}). "
|
|
182
|
-
"Set credentials.apple.key_id in ci.config.yaml to choose one."
|
|
183
|
-
)
|
|
184
|
-
for entry in parsed:
|
|
185
|
-
if entry[1] == config_key_id:
|
|
186
|
-
return entry
|
|
79
|
+
names = ", ".join(p.name for p, _, _ in parsed)
|
|
187
80
|
fail(
|
|
188
|
-
f"
|
|
189
|
-
|
|
81
|
+
f"Multiple ASC keys found in creds/ ({names}). Remove the unused "
|
|
82
|
+
"ones — only a single key is supported."
|
|
190
83
|
)
|
|
191
84
|
|
|
192
85
|
|
|
@@ -217,8 +110,6 @@ def derive_team_if_empty(
|
|
|
217
110
|
|
|
218
111
|
def lookup_app_id_via_api(bundle_id: str, scripts_dir: Path) -> str:
|
|
219
112
|
"""Invoke lookup_app_id.py to resolve apple_id via ASC API."""
|
|
220
|
-
import subprocess
|
|
221
|
-
|
|
222
113
|
script = scripts_dir / "lookup_app_id.py"
|
|
223
114
|
if not script.is_file():
|
|
224
115
|
fail(f"lookup_app_id.py not found at {script}")
|
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
environment variables to $GITHUB_ENV.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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)
|
|
3
|
+
Discover ASC API key in creds/ + auto-detect Xcode project/scheme/bundle,
|
|
4
|
+
then emit derived CI environment variables to $GITHUB_ENV.
|
|
5
|
+
|
|
6
|
+
There is NO config file. This script reads only:
|
|
7
|
+
- INPUT_* env vars (the composite action's `with:` overrides)
|
|
8
|
+
- creds/AuthKey_<KEY_ID>_Issuer_<UUID>.p8 (the only required file)
|
|
9
|
+
- the Xcode project (via auto_detect.py + xcodebuild)
|
|
10
|
+
- the App Store Connect API (for team_id and app_store_apple_id)
|
|
20
11
|
|
|
21
12
|
Precedence for every CFG_* value:
|
|
22
|
-
1.
|
|
23
|
-
2.
|
|
24
|
-
3.
|
|
25
|
-
4.
|
|
26
|
-
5. Empty string (composite action step applies its own default / skips)
|
|
13
|
+
1. INPUT_* env var (composite action input)
|
|
14
|
+
2. Auto-detected value (xcodebuild, ASC API, glob)
|
|
15
|
+
3. Built-in default ("Release", "false", "en-US", whatsNew boilerplate)
|
|
16
|
+
4. Empty string (downstream step skips or applies its own fallback)
|
|
27
17
|
|
|
28
|
-
Built-in defaults
|
|
18
|
+
Built-in defaults emitted when nothing else applies:
|
|
29
19
|
CFG_CONFIGURATION=Release, CFG_USES_NON_EXEMPT=false,
|
|
30
|
-
CFG_RUN_TESTS=false, CFG_LOCALE=en-US,
|
|
31
|
-
|
|
20
|
+
CFG_RUN_TESTS=false, CFG_LOCALE=en-US,
|
|
21
|
+
CFG_PROFILE_NAME="<scheme> CI", CFG_WHATS_NEW=<boilerplate>.
|
|
32
22
|
|
|
33
23
|
The ASC .p8 key is located by globbing ``creds/AuthKey_*.p8`` with filename
|
|
34
|
-
regex AuthKey_<KEY_ID>[_Issuer_<ISSUER_UUID>].p8.
|
|
35
|
-
|
|
24
|
+
regex AuthKey_<KEY_ID>[_Issuer_<ISSUER_UUID>].p8. If multiple match, we fail
|
|
25
|
+
with an instruction to remove the unwanted one(s) — there is no longer a
|
|
26
|
+
config-based disambiguation channel.
|
|
36
27
|
|
|
37
28
|
Writes to $GITHUB_ENV:
|
|
38
29
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8_PATH, ASC_KEY_P8,
|
|
@@ -56,17 +47,10 @@ from pathlib import Path
|
|
|
56
47
|
import auto_detect
|
|
57
48
|
from cfg_io import fail, log
|
|
58
49
|
from cfg_resolve import (
|
|
59
|
-
as_str_bool,
|
|
60
|
-
auto_project_glob,
|
|
61
50
|
derive_team_if_empty,
|
|
62
|
-
dig,
|
|
63
51
|
emit,
|
|
64
52
|
find_p8,
|
|
65
|
-
load_config,
|
|
66
53
|
lookup_app_id_via_api,
|
|
67
|
-
pick,
|
|
68
|
-
pick_with_source,
|
|
69
|
-
resolve,
|
|
70
54
|
)
|
|
71
55
|
|
|
72
56
|
|
|
@@ -77,63 +61,54 @@ DEFAULT_WHATS_NEW = (
|
|
|
77
61
|
)
|
|
78
62
|
|
|
79
63
|
|
|
80
|
-
def
|
|
64
|
+
def _pick(input_val: str, *, auto_val: str = "", default: str = "") -> tuple[str, str]:
|
|
65
|
+
"""Apply precedence: input > auto > default > empty."""
|
|
66
|
+
if input_val:
|
|
67
|
+
return input_val, "input"
|
|
68
|
+
if auto_val:
|
|
69
|
+
return auto_val, "auto-detect"
|
|
70
|
+
if default:
|
|
71
|
+
return default, "default"
|
|
72
|
+
return "", "empty"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def emit_credentials(env_file: Path, workspace: Path) -> dict:
|
|
81
76
|
"""Discover .p8, derive ASC_KEY_ID / ISSUER / PATH, emit to env_file.
|
|
82
77
|
|
|
83
78
|
Returns {'key_id', 'issuer_id', 'key_path'} for downstream use.
|
|
84
79
|
"""
|
|
85
|
-
p8_path, key_id_from_file, issuer_from_file = find_p8(workspace
|
|
80
|
+
p8_path, key_id_from_file, issuer_from_file = find_p8(workspace)
|
|
86
81
|
try:
|
|
87
82
|
p8_contents = p8_path.read_text()
|
|
88
83
|
except OSError as exc:
|
|
89
84
|
fail(f"cannot read {p8_path}: {exc}")
|
|
90
85
|
|
|
91
|
-
|
|
92
|
-
cfg_issuer = pick(cfg, ("credentials", "apple", "issuer_id"), ("asc", "issuer_id"))
|
|
93
|
-
asc_key_id = cfg_key_id or key_id_from_file
|
|
94
|
-
asc_issuer = cfg_issuer or issuer_from_file
|
|
95
|
-
if not asc_issuer:
|
|
86
|
+
if not issuer_from_file:
|
|
96
87
|
fail(
|
|
97
|
-
"ASC issuer id unknown:
|
|
98
|
-
"
|
|
88
|
+
"ASC issuer id unknown: rename the .p8 to "
|
|
89
|
+
"AuthKey_<KEY_ID>_Issuer_<UUID>.p8 so the issuer id is in the filename."
|
|
99
90
|
)
|
|
100
91
|
|
|
101
|
-
emit(env_file, "ASC_KEY_ID", str(
|
|
102
|
-
emit(env_file, "ASC_ISSUER_ID", str(
|
|
92
|
+
emit(env_file, "ASC_KEY_ID", str(key_id_from_file))
|
|
93
|
+
emit(env_file, "ASC_ISSUER_ID", str(issuer_from_file))
|
|
103
94
|
emit(env_file, "ASC_KEY_P8_PATH", str(p8_path))
|
|
104
95
|
emit(env_file, "ASC_KEY_P8", p8_contents)
|
|
105
|
-
log(f"ASC_KEY_ID={
|
|
106
|
-
log(f"ASC_ISSUER_ID={
|
|
96
|
+
log(f"ASC_KEY_ID={key_id_from_file} (source: filename)")
|
|
97
|
+
log(f"ASC_ISSUER_ID={issuer_from_file} (source: filename)")
|
|
107
98
|
log(f"ASC_KEY_P8_PATH={p8_path}")
|
|
108
|
-
return {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
99
|
+
return {
|
|
100
|
+
"key_id": str(key_id_from_file),
|
|
101
|
+
"issuer_id": str(issuer_from_file),
|
|
102
|
+
"key_path": str(p8_path),
|
|
103
|
+
}
|
|
129
104
|
|
|
130
105
|
|
|
131
|
-
def
|
|
106
|
+
def _detect_project_workspace(
|
|
132
107
|
workspace: Path,
|
|
133
108
|
project: tuple[str, str],
|
|
134
109
|
ws_val: tuple[str, str],
|
|
135
110
|
) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
136
|
-
"""Run auto_detect.auto_detect_project when both are unset
|
|
111
|
+
"""Run auto_detect.auto_detect_project when both are unset."""
|
|
137
112
|
if project[0] or ws_val[0]:
|
|
138
113
|
return project, ws_val
|
|
139
114
|
auto_proj, auto_ws = auto_detect.auto_detect_project(workspace)
|
|
@@ -144,47 +119,25 @@ def _auto_detect_project_workspace(
|
|
|
144
119
|
return project, ws_val
|
|
145
120
|
|
|
146
121
|
|
|
147
|
-
def resolve_xcode(
|
|
122
|
+
def resolve_xcode(workspace: Path, inp: dict) -> dict:
|
|
148
123
|
"""Resolve project / workspace / scheme / configuration / profile_name.
|
|
149
124
|
|
|
150
|
-
|
|
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.
|
|
125
|
+
Inputs win; otherwise we shell out to xcodebuild via auto_detect.
|
|
153
126
|
"""
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
config_cfg, config_src = pick_with_source(
|
|
158
|
-
cfg, ("xcode", "configuration"), ("ios", "native", "configuration"),
|
|
159
|
-
)
|
|
160
|
-
profile_cfg, profile_src = pick_with_source(
|
|
161
|
-
cfg, ("xcode", "profile_name"), ("ios", "native", "profile_name"),
|
|
162
|
-
)
|
|
127
|
+
project = _pick(inp["PROJECT"])
|
|
128
|
+
ws_val = _pick(inp["WORKSPACE"])
|
|
129
|
+
project, ws_val = _detect_project_workspace(workspace, project, ws_val)
|
|
163
130
|
|
|
164
|
-
|
|
165
|
-
inp["PROJECT"], proj_cfg, cfg_source=proj_src,
|
|
166
|
-
auto_val=auto_project_glob(workspace, ".xcodeproj"),
|
|
167
|
-
)
|
|
168
|
-
ws_val = resolve(
|
|
169
|
-
inp["WORKSPACE"], ws_cfg, cfg_source=ws_src,
|
|
170
|
-
auto_val=auto_project_glob(workspace, ".xcworkspace"),
|
|
171
|
-
)
|
|
172
|
-
project, ws_val = _auto_detect_project_workspace(workspace, project, ws_val)
|
|
131
|
+
configuration = _pick(inp["CONFIGURATION"], default="Release")
|
|
173
132
|
|
|
174
|
-
|
|
175
|
-
inp["CONFIGURATION"], config_cfg, cfg_source=config_src, default="Release",
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
scheme = resolve(inp["SCHEME"], scheme_cfg, cfg_source=scheme_src)
|
|
133
|
+
scheme = _pick(inp["SCHEME"])
|
|
179
134
|
if not scheme[0] and (project[0] or ws_val[0]):
|
|
180
135
|
detected = auto_detect.auto_detect_scheme(workspace, project[0], ws_val[0])
|
|
181
136
|
if detected:
|
|
182
137
|
scheme = (detected, "auto-detect")
|
|
183
138
|
|
|
184
139
|
default_profile = f"{scheme[0]} CI" if scheme[0] else ""
|
|
185
|
-
profile_name =
|
|
186
|
-
inp["PROFILE_NAME"], profile_cfg, cfg_source=profile_src, default=default_profile,
|
|
187
|
-
)
|
|
140
|
+
profile_name = _pick(inp["PROFILE_NAME"], default=default_profile)
|
|
188
141
|
return {
|
|
189
142
|
"project": project,
|
|
190
143
|
"workspace": ws_val,
|
|
@@ -194,127 +147,81 @@ def resolve_xcode(cfg: dict, workspace: Path, inp: dict) -> dict:
|
|
|
194
147
|
}
|
|
195
148
|
|
|
196
149
|
|
|
150
|
+
def _detect_bundle(
|
|
151
|
+
bundle: tuple[str, str], workspace: Path, xcode: dict,
|
|
152
|
+
) -> tuple[str, str]:
|
|
153
|
+
"""Auto-detect PRODUCT_BUNDLE_IDENTIFIER when bundle is unresolved."""
|
|
154
|
+
if bundle[0] or not xcode.get("scheme"):
|
|
155
|
+
return bundle
|
|
156
|
+
detected = auto_detect.auto_detect_bundle_id(
|
|
157
|
+
workspace,
|
|
158
|
+
xcode.get("project", ""),
|
|
159
|
+
xcode.get("workspace", ""),
|
|
160
|
+
xcode["scheme"],
|
|
161
|
+
xcode.get("configuration", "Release"),
|
|
162
|
+
)
|
|
163
|
+
if detected:
|
|
164
|
+
return detected, "auto-detect"
|
|
165
|
+
return bundle
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_apple_id(
|
|
169
|
+
inp: dict, bundle: tuple[str, str], creds: dict, scripts_dir: Path,
|
|
170
|
+
) -> tuple[str, str]:
|
|
171
|
+
"""Resolve App Store numeric app id: input -> ASC API lookup -> empty."""
|
|
172
|
+
if inp["APP_STORE_APPLE_ID"]:
|
|
173
|
+
return inp["APP_STORE_APPLE_ID"], "input"
|
|
174
|
+
if not bundle[0]:
|
|
175
|
+
return "", "empty"
|
|
176
|
+
os.environ["ASC_KEY_ID"] = creds["key_id"]
|
|
177
|
+
os.environ["ASC_ISSUER_ID"] = creds["issuer_id"]
|
|
178
|
+
os.environ["ASC_KEY_PATH"] = creds["key_path"]
|
|
179
|
+
return lookup_app_id_via_api(bundle[0], scripts_dir), "api-lookup"
|
|
180
|
+
|
|
181
|
+
|
|
197
182
|
def resolve_app(
|
|
198
|
-
cfg: dict,
|
|
199
183
|
inp: dict,
|
|
200
184
|
creds: dict,
|
|
201
185
|
scripts_dir: Path,
|
|
202
186
|
*,
|
|
203
|
-
workspace: Path
|
|
204
|
-
xcode: dict
|
|
187
|
+
workspace: Path,
|
|
188
|
+
xcode: dict,
|
|
205
189
|
) -> dict:
|
|
206
|
-
"""Resolve bundle_id / team_id / app_store_apple_id
|
|
190
|
+
"""Resolve bundle_id / team_id / app_store_apple_id."""
|
|
191
|
+
bundle = _pick(inp["BUNDLE_ID"])
|
|
192
|
+
bundle = _detect_bundle(bundle, workspace, xcode)
|
|
207
193
|
|
|
208
|
-
|
|
209
|
-
both input and config, we call ``auto_detect_bundle_id`` to ask
|
|
210
|
-
xcodebuild for ``PRODUCT_BUNDLE_IDENTIFIER``.
|
|
211
|
-
"""
|
|
212
|
-
bundle_cfg, bundle_src = pick_with_source(cfg, ("app", "bundle_id"), ("ios", "native", "bundle_id"))
|
|
213
|
-
team_cfg, team_src = pick_with_source(cfg, ("app", "team_id"), ("ios", "native", "team_id"))
|
|
214
|
-
apple_cfg, apple_src = pick_with_source(
|
|
215
|
-
cfg, ("app", "app_store_apple_id"), ("ios", "native", "app_store_apple_id"),
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
bundle = resolve(inp["BUNDLE_ID"], bundle_cfg, cfg_source=bundle_src)
|
|
219
|
-
bundle = _auto_detect_bundle_if_empty(bundle, workspace, xcode)
|
|
220
|
-
team = resolve(inp["TEAM_ID"], team_cfg, cfg_source=team_src)
|
|
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).
|
|
194
|
+
team = _pick(inp["TEAM_ID"])
|
|
225
195
|
if not team[0]:
|
|
226
196
|
derived_val, derived_src = derive_team_if_empty(team[0], team[1], creds)
|
|
227
197
|
if derived_val:
|
|
228
|
-
log(
|
|
229
|
-
f"derived team_id={derived_val} from ASC key "
|
|
230
|
-
f"{creds['key_id']}"
|
|
231
|
-
)
|
|
198
|
+
log(f"derived team_id={derived_val} from ASC key {creds['key_id']}")
|
|
232
199
|
team = (derived_val, derived_src)
|
|
233
200
|
|
|
234
|
-
|
|
235
|
-
apple = (inp["APP_STORE_APPLE_ID"], "input")
|
|
236
|
-
elif apple_cfg not in (None, ""):
|
|
237
|
-
apple = (str(apple_cfg), apple_src)
|
|
238
|
-
elif bundle[0]:
|
|
239
|
-
os.environ["ASC_KEY_ID"] = creds["key_id"]
|
|
240
|
-
os.environ["ASC_ISSUER_ID"] = creds["issuer_id"]
|
|
241
|
-
os.environ["ASC_KEY_PATH"] = creds["key_path"]
|
|
242
|
-
apple = (lookup_app_id_via_api(bundle[0], scripts_dir), "api-lookup")
|
|
243
|
-
else:
|
|
244
|
-
apple = ("", "empty")
|
|
201
|
+
apple = _resolve_apple_id(inp, bundle, creds, scripts_dir)
|
|
245
202
|
return {"bundle_id": bundle, "team_id": team, "app_store_apple_id": apple}
|
|
246
203
|
|
|
247
204
|
|
|
248
|
-
def resolve_testflight(
|
|
249
|
-
"""Resolve whats_new / locale from
|
|
250
|
-
whats_cfg, whats_src = pick_with_source(
|
|
251
|
-
cfg, ("testflight", "whats_new"), ("ios", "native", "whats_new"),
|
|
252
|
-
)
|
|
253
|
-
locale_cfg, locale_src = pick_with_source(
|
|
254
|
-
cfg, ("testflight", "locale"), ("ios", "native", "locale"),
|
|
255
|
-
)
|
|
256
|
-
if locale_cfg in (None, ""):
|
|
257
|
-
alt = dig(cfg, "app_store", "locale")
|
|
258
|
-
if alt not in (None, ""):
|
|
259
|
-
locale_cfg, locale_src = alt, "config(legacy)"
|
|
205
|
+
def resolve_testflight(inp: dict) -> dict:
|
|
206
|
+
"""Resolve whats_new / locale from inputs, with built-in defaults."""
|
|
260
207
|
return {
|
|
261
|
-
"whats_new":
|
|
262
|
-
|
|
263
|
-
),
|
|
264
|
-
"locale": resolve(inp["LOCALE"], locale_cfg, cfg_source=locale_src, default="en-US"),
|
|
208
|
+
"whats_new": _pick(inp["WHATS_NEW"], default=DEFAULT_WHATS_NEW),
|
|
209
|
+
"locale": _pick(inp["LOCALE"], default="en-US"),
|
|
265
210
|
}
|
|
266
211
|
|
|
267
212
|
|
|
268
|
-
def resolve_tests(
|
|
269
|
-
"""Resolve run_tests / test_command / test_destination from
|
|
270
|
-
run_flat = dig(cfg, "ci", "run_tests")
|
|
271
|
-
run_legacy = dig(cfg, "ios", "native", "run_tests")
|
|
272
|
-
if run_legacy is None:
|
|
273
|
-
run_legacy = dig(cfg, "ios", "native", "tests", "run")
|
|
274
|
-
run_cfg, run_src = (run_flat, "config") if run_flat is not None else (run_legacy, "config(legacy)")
|
|
275
|
-
|
|
276
|
-
cmd_flat = dig(cfg, "ci", "test_command")
|
|
277
|
-
cmd_legacy = dig(cfg, "ios", "native", "test_command") or dig(cfg, "ios", "native", "tests", "command")
|
|
278
|
-
cmd_cfg, cmd_src = (
|
|
279
|
-
(cmd_flat, "config") if cmd_flat not in (None, "") else (cmd_legacy, "config(legacy)")
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
dest_flat = dig(cfg, "ci", "test_destination")
|
|
283
|
-
dest_legacy = (
|
|
284
|
-
dig(cfg, "ios", "native", "test_destination")
|
|
285
|
-
or dig(cfg, "ios", "native", "tests", "destination")
|
|
286
|
-
)
|
|
287
|
-
dest_cfg, dest_src = (
|
|
288
|
-
(dest_flat, "config") if dest_flat not in (None, "") else (dest_legacy, "config(legacy)")
|
|
289
|
-
)
|
|
290
|
-
|
|
213
|
+
def resolve_tests(inp: dict) -> dict:
|
|
214
|
+
"""Resolve run_tests / test_command / test_destination from inputs."""
|
|
291
215
|
return {
|
|
292
|
-
"run_tests":
|
|
293
|
-
|
|
294
|
-
),
|
|
295
|
-
"test_command": resolve(inp["TEST_COMMAND"], cmd_cfg, cfg_source=cmd_src),
|
|
296
|
-
"test_destination": resolve(inp["TEST_DESTINATION"], dest_cfg, cfg_source=dest_src),
|
|
216
|
+
"run_tests": _pick(inp["RUN_TESTS"], default="false"),
|
|
217
|
+
"test_command": _pick(inp["TEST_COMMAND"]),
|
|
218
|
+
"test_destination": _pick(inp["TEST_DESTINATION"]),
|
|
297
219
|
}
|
|
298
220
|
|
|
299
221
|
|
|
300
|
-
def resolve_ios(
|
|
301
|
-
"""Resolve ios.uses_non_exempt_encryption
|
|
302
|
-
|
|
303
|
-
# Flat lookup can accidentally return the legacy 'native' sub-dict; ignore.
|
|
304
|
-
if isinstance(val_flat, dict):
|
|
305
|
-
val_flat = None
|
|
306
|
-
val_legacy = dig(cfg, "ios", "native", "uses_non_exempt_encryption")
|
|
307
|
-
if val_flat is not None:
|
|
308
|
-
raw, src = val_flat, "config"
|
|
309
|
-
elif val_legacy is not None:
|
|
310
|
-
raw, src = val_legacy, "config(legacy)"
|
|
311
|
-
else:
|
|
312
|
-
raw, src = None, "config"
|
|
313
|
-
return {
|
|
314
|
-
"uses_non_exempt": resolve(
|
|
315
|
-
inp["USES_NON_EXEMPT"], as_str_bool(raw), cfg_source=src, default="false",
|
|
316
|
-
)
|
|
317
|
-
}
|
|
222
|
+
def resolve_ios(inp: dict) -> dict:
|
|
223
|
+
"""Resolve ios.uses_non_exempt_encryption from inputs."""
|
|
224
|
+
return {"uses_non_exempt": _pick(inp["USES_NON_EXEMPT"], default="false")}
|
|
318
225
|
|
|
319
226
|
|
|
320
227
|
def collect_inputs() -> dict:
|
|
@@ -336,19 +243,17 @@ def main() -> None:
|
|
|
336
243
|
fail("GITHUB_ENV not set; this script must run inside a GitHub Actions step")
|
|
337
244
|
env_file = Path(env_file_path)
|
|
338
245
|
|
|
339
|
-
cfg = load_config(workspace, os.environ.get("CONFIG_PATH", "ci.config.yaml"))
|
|
340
246
|
inputs = collect_inputs()
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
xc = resolve_xcode(cfg, workspace, inputs)
|
|
247
|
+
creds = emit_credentials(env_file, workspace)
|
|
248
|
+
xc = resolve_xcode(workspace, inputs)
|
|
344
249
|
xc_values = {k: v[0] for k, v in xc.items()}
|
|
345
250
|
app = resolve_app(
|
|
346
|
-
|
|
251
|
+
inputs, creds, scripts_dir,
|
|
347
252
|
workspace=workspace, xcode=xc_values,
|
|
348
253
|
)
|
|
349
|
-
tf = resolve_testflight(
|
|
350
|
-
ios = resolve_ios(
|
|
351
|
-
tests = resolve_tests(
|
|
254
|
+
tf = resolve_testflight(inputs)
|
|
255
|
+
ios = resolve_ios(inputs)
|
|
256
|
+
tests = resolve_tests(inputs)
|
|
352
257
|
|
|
353
258
|
derived = [
|
|
354
259
|
("CFG_PROJECT", xc["project"]),
|
|
@@ -373,9 +278,7 @@ def main() -> None:
|
|
|
373
278
|
|
|
374
279
|
# Persist whatsNew to a file under $RUNNER_TEMP so downstream steps can
|
|
375
280
|
# read raw multi-line content without any GitHub Actions ${{ }} YAML
|
|
376
|
-
# interpolation mangling embedded newlines.
|
|
377
|
-
# the same content for backward compatibility; the file is the source
|
|
378
|
-
# of truth.
|
|
281
|
+
# interpolation mangling embedded newlines.
|
|
379
282
|
whats_new_value = tf["whats_new"][0]
|
|
380
283
|
runner_temp = os.environ.get("RUNNER_TEMP") or str(workspace)
|
|
381
284
|
whats_new_path = Path(runner_temp) / "whats_new.txt"
|
|
@@ -1,43 +0,0 @@
|
|
|
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
|
|
17
|
-
|
|
18
|
-
app:
|
|
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
|
|
22
|
-
|
|
23
|
-
xcode:
|
|
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
|
|
27
|
-
configuration: "Release" # default: Release
|
|
28
|
-
profile_name: "" # default: "<scheme> CI"
|
|
29
|
-
|
|
30
|
-
ios:
|
|
31
|
-
uses_non_exempt_encryption: false # false | true | "" to skip write
|
|
32
|
-
|
|
33
|
-
testflight:
|
|
34
|
-
whats_new: |
|
|
35
|
-
- Improved performance
|
|
36
|
-
- Bug fixes
|
|
37
|
-
- Enhanced security
|
|
38
|
-
locale: "en-US"
|
|
39
|
-
|
|
40
|
-
ci:
|
|
41
|
-
run_tests: false
|
|
42
|
-
test_command: "" # optional
|
|
43
|
-
test_destination: "platform=iOS Simulator,name=iPhone 15"
|