@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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.87"
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.87",
15
+ "version": "0.10.88",
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.87",
3
+ "version": "0.10.88",
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.87",
3
+ "version": "0.10.88",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -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. 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`.
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
- **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).
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 — (Optional) Copy `ci.config.yaml`
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 4 — Register the app in App Store Connect (one-time)
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 5 — MARKETING_VERSION
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 6 — Push to `main`
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
- With an optional config file:
56
+ GitHub Actions triggers the workflow. On success, the build appears in TestFlight within 5–15 minutes (Apple processing delay).
65
57
 
66
- ```bash
67
- git add .github/workflows/deploy.yml ci.config.yaml creds/AuthKey_*.p8
68
- git commit -m "ci: add iOS TestFlight pipeline"
69
- git push origin main
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
- GitHub Actions triggers the workflow. On success, the build appears in TestFlight within 5–15 minutes (Apple processing delay).
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
- | `No ASC app found for bundle <id>` | App not yet registered in ASC | Run Step 4 (create the app record) |
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
- Config loading + value-resolution helpers for ios-native-testflight.
3
+ Shared helpers for ios-native-testflight read_config.py.
4
4
 
5
- Pulled out of read_config.py to keep any single file under the 400-line /
6
- 10-function project limits.
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, notice
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 dig(cfg: dict, *path: str, default: Any = None) -> Any:
77
- """Safe nested .get across dict path. Returns default if any key missing."""
78
- node: Any = cfg
79
- for key in path:
80
- if not isinstance(node, dict):
81
- return default
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
- config_key_id = pick(cfg, ("credentials", "apple", "key_id"), ("asc", "key_id"))
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"credentials.apple.key_id={config_key_id} did not match any .p8 in "
189
- f"creds/ (found key_ids: {', '.join(k for _, k, _ in parsed)})"
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
- Read ci.config.yaml + discover ASC API key in creds/, then emit derived CI
4
- environment variables to $GITHUB_ENV.
5
-
6
- Schema (flat primary, canonical):
7
- app.{bundle_id, team_id, app_store_apple_id}
8
- xcode.{project, workspace, scheme, configuration, profile_name}
9
- ios.{uses_non_exempt_encryption}
10
- testflight.{whats_new, locale}
11
- ci.{run_tests, test_command, test_destination}
12
- credentials.apple.{key_id, issuer_id} OR asc.{key_id, issuer_id}
13
-
14
- Legacy aliases (accepted for back-compat, never preferred):
15
- ios.native.{project, workspace, scheme, configuration, profile_name,
16
- team_id, app_store_apple_id, uses_non_exempt_encryption,
17
- whats_new, locale, run_tests, test_command, test_destination,
18
- tests.{run, command, destination}}
19
- app_store.locale (third-tier fallback only)
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. Explicit action input (INPUT_* env var)
23
- 2. Flat-schema config value
24
- 3. Legacy ios.native.* alias
25
- 4. Auto-detected value (xcodeproj/xcworkspace glob) or built-in default
26
- 5. Empty string (composite action step applies its own default / skips)
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 (emitted when no config/input exists):
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, CFG_PROFILE_NAME="<scheme> CI".
31
- All other CFG_* fields emit empty and rely on action.yml-level fallbacks.
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. Multiple matches require
35
- ``credentials.apple.key_id`` (or ``asc.key_id``) in config to disambiguate.
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 emit_credentials(env_file: Path, cfg: dict, workspace: Path) -> dict:
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, cfg)
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
- cfg_key_id = pick(cfg, ("credentials", "apple", "key_id"), ("asc", "key_id"))
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: neither the filename contains _Issuer_<UUID> "
98
- "nor credentials.apple.issuer_id is set in ci.config.yaml."
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(asc_key_id))
102
- emit(env_file, "ASC_ISSUER_ID", str(asc_issuer))
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={asc_key_id} (source: {'config' if cfg_key_id else 'filename'})")
106
- log(f"ASC_ISSUER_ID={asc_issuer} (source: {'config' if cfg_issuer else 'filename'})")
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 {"key_id": str(asc_key_id), "issuer_id": str(asc_issuer), "key_path": str(p8_path)}
109
-
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
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 _auto_detect_project_workspace(
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; return the pair."""
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(cfg: dict, workspace: Path, inp: dict) -> dict:
122
+ def resolve_xcode(workspace: Path, inp: dict) -> dict:
148
123
  """Resolve project / workspace / scheme / configuration / profile_name.
149
124
 
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.
125
+ Inputs win; otherwise we shell out to xcodebuild via auto_detect.
153
126
  """
154
- proj_cfg, proj_src = pick_with_source(cfg, ("xcode", "project"), ("ios", "native", "project"))
155
- ws_cfg, ws_src = pick_with_source(cfg, ("xcode", "workspace"), ("ios", "native", "workspace"))
156
- scheme_cfg, scheme_src = pick_with_source(cfg, ("xcode", "scheme"), ("ios", "native", "scheme"))
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
- project = resolve(
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
- configuration = resolve(
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 = resolve(
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 | None = None,
204
- xcode: dict | None = None,
187
+ workspace: Path,
188
+ xcode: dict,
205
189
  ) -> dict:
206
- """Resolve bundle_id / team_id / app_store_apple_id (with ASC-API fallback).
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
- 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
- """
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
- if inp["APP_STORE_APPLE_ID"]:
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(cfg: dict, inp: dict) -> dict:
249
- """Resolve whats_new / locale from testflight.* or legacy aliases."""
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": resolve(
262
- inp["WHATS_NEW"], whats_cfg, cfg_source=whats_src, default=DEFAULT_WHATS_NEW,
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(cfg: dict, inp: dict) -> dict:
269
- """Resolve run_tests / test_command / test_destination from ci.* or legacy."""
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": resolve(
293
- inp["RUN_TESTS"], as_str_bool(run_cfg), cfg_source=run_src, default="false",
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(cfg: dict, inp: dict) -> dict:
301
- """Resolve ios.uses_non_exempt_encryption (flat) with legacy fallback."""
302
- val_flat = dig(cfg, "ios", "uses_non_exempt_encryption")
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
- creds = emit_credentials(env_file, cfg, workspace)
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
- cfg, inputs, creds, scripts_dir,
251
+ inputs, creds, scripts_dir,
347
252
  workspace=workspace, xcode=xc_values,
348
253
  )
349
- tf = resolve_testflight(cfg, inputs)
350
- ios = resolve_ios(cfg, inputs)
351
- tests = resolve_tests(cfg, inputs)
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. CFG_WHATS_NEW still carries
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"