appstore-api-mcp 1.12.0 → 1.15.0
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/CHANGELOG.md +85 -0
- package/README.md +1 -0
- package/docs/SETUP.md +55 -0
- package/docs/TOOLS.md +124 -1
- package/package.json +1 -1
- package/src/cicd.js +481 -0
- package/src/client.js +19 -0
- package/src/guardrails.js +22 -1
- package/src/index.js +2122 -59
- package/src/ppp-countries.csv +176 -0
- package/src/ppp.js +183 -0
- package/src/subscriptions.js +45 -0
package/src/cicd.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// iOS CI/CD → TestFlight bootstrap: template rendering + project auto-detection.
|
|
2
|
+
//
|
|
3
|
+
// Pure logic only (string templating + filesystem reads). The git/gh/ASC side
|
|
4
|
+
// effects live in index.js, which has the shell-out (`runCmd`) and ASC client.
|
|
5
|
+
//
|
|
6
|
+
// The pipeline is the same one shipped by hand in fil-technology/okcircle:
|
|
7
|
+
// two GitHub Actions workflows driving fastlane, Xcode automatic ("cloud")
|
|
8
|
+
// signing via -allowProvisioningUpdates (no `match` repo), and a build number
|
|
9
|
+
// computed as latest-TestFlight-build + 1.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
12
|
+
import { join, basename, relative, dirname, sep } from "node:path";
|
|
13
|
+
|
|
14
|
+
// Pinned toolchain — matches the okcircle reference. Bump here in one place.
|
|
15
|
+
const RUNNER = "macos-15";
|
|
16
|
+
const XCODE_PATH = "/Applications/Xcode_16.app";
|
|
17
|
+
const RUBY_VERSION = "3.3";
|
|
18
|
+
|
|
19
|
+
/** Path globs / working-dir derived from where the app lives in the repo. */
|
|
20
|
+
function paths(appDir) {
|
|
21
|
+
const root = appDir === "." || appDir === "";
|
|
22
|
+
return {
|
|
23
|
+
workDir: root ? "." : appDir,
|
|
24
|
+
glob: root ? "**" : `${appDir}/**`,
|
|
25
|
+
gemfile: root ? "Gemfile" : `${appDir}/Gemfile`,
|
|
26
|
+
fastlaneDir: root ? "fastlane" : `${appDir}/fastlane`,
|
|
27
|
+
reportXml: root ? "fastlane/report.xml" : `${appDir}/fastlane/report.xml`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the seven pipeline files. Returns { "<repo-relative path>": contents }.
|
|
33
|
+
* `vars`: { appDir, projectName, scheme, target, bundleId, teamId }.
|
|
34
|
+
*/
|
|
35
|
+
export function renderTemplates(vars) {
|
|
36
|
+
const { appDir, projectName, scheme, target, bundleId, teamId } = vars;
|
|
37
|
+
const p = paths(appDir);
|
|
38
|
+
const project = `${projectName}.xcodeproj`;
|
|
39
|
+
const files = {};
|
|
40
|
+
|
|
41
|
+
files[".github/workflows/ios-ci.yml"] = `name: iOS CI
|
|
42
|
+
|
|
43
|
+
# Fast compile check on every PR / push that touches the app. No secrets and no
|
|
44
|
+
# code signing — it just proves the project still builds.
|
|
45
|
+
on:
|
|
46
|
+
push:
|
|
47
|
+
branches: [main]
|
|
48
|
+
paths:
|
|
49
|
+
- "${p.glob}"
|
|
50
|
+
- ".github/workflows/ios-ci.yml"
|
|
51
|
+
pull_request:
|
|
52
|
+
paths:
|
|
53
|
+
- "${p.glob}"
|
|
54
|
+
- ".github/workflows/ios-ci.yml"
|
|
55
|
+
|
|
56
|
+
concurrency:
|
|
57
|
+
group: ios-ci-\${{ github.ref }}
|
|
58
|
+
cancel-in-progress: true
|
|
59
|
+
|
|
60
|
+
jobs:
|
|
61
|
+
build:
|
|
62
|
+
runs-on: ${RUNNER}
|
|
63
|
+
timeout-minutes: 30
|
|
64
|
+
defaults:
|
|
65
|
+
run:
|
|
66
|
+
working-directory: ${p.workDir}
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v4
|
|
69
|
+
|
|
70
|
+
- name: Select Xcode
|
|
71
|
+
run: sudo xcode-select -s ${XCODE_PATH}
|
|
72
|
+
|
|
73
|
+
- name: Set up Ruby
|
|
74
|
+
uses: ruby/setup-ruby@v1
|
|
75
|
+
with:
|
|
76
|
+
ruby-version: "${RUBY_VERSION}"
|
|
77
|
+
bundler-cache: true
|
|
78
|
+
working-directory: ${p.workDir}
|
|
79
|
+
|
|
80
|
+
- name: Build for simulator
|
|
81
|
+
run: bundle exec fastlane ci_build
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
files[".github/workflows/ios-testflight.yml"] = `name: iOS TestFlight
|
|
85
|
+
|
|
86
|
+
# Ships a signed build to TestFlight. Runs on demand (Actions → Run workflow) and
|
|
87
|
+
# automatically when app code lands on main. Requires the ASC_* secrets below.
|
|
88
|
+
on:
|
|
89
|
+
workflow_dispatch:
|
|
90
|
+
push:
|
|
91
|
+
branches: [main]
|
|
92
|
+
paths:
|
|
93
|
+
- "${p.glob}"
|
|
94
|
+
- ".github/workflows/ios-testflight.yml"
|
|
95
|
+
|
|
96
|
+
# Never run two TestFlight uploads at once (build-number collisions).
|
|
97
|
+
concurrency:
|
|
98
|
+
group: ios-testflight
|
|
99
|
+
cancel-in-progress: false
|
|
100
|
+
|
|
101
|
+
jobs:
|
|
102
|
+
testflight:
|
|
103
|
+
runs-on: ${RUNNER}
|
|
104
|
+
timeout-minutes: 45
|
|
105
|
+
defaults:
|
|
106
|
+
run:
|
|
107
|
+
working-directory: ${p.workDir}
|
|
108
|
+
steps:
|
|
109
|
+
- uses: actions/checkout@v4
|
|
110
|
+
|
|
111
|
+
- name: Select Xcode
|
|
112
|
+
run: sudo xcode-select -s ${XCODE_PATH}
|
|
113
|
+
|
|
114
|
+
- name: Set up Ruby
|
|
115
|
+
uses: ruby/setup-ruby@v1
|
|
116
|
+
with:
|
|
117
|
+
ruby-version: "${RUBY_VERSION}"
|
|
118
|
+
bundler-cache: true
|
|
119
|
+
working-directory: ${p.workDir}
|
|
120
|
+
|
|
121
|
+
- name: Build & upload to TestFlight
|
|
122
|
+
run: bundle exec fastlane beta
|
|
123
|
+
env:
|
|
124
|
+
ASC_KEY_ID: \${{ secrets.ASC_KEY_ID }}
|
|
125
|
+
ASC_ISSUER_ID: \${{ secrets.ASC_ISSUER_ID }}
|
|
126
|
+
ASC_KEY_P8: \${{ secrets.ASC_KEY_P8 }}
|
|
127
|
+
# Give xcodebuild's settings probe extra time on cold CI runners.
|
|
128
|
+
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: "120"
|
|
129
|
+
|
|
130
|
+
- name: Upload build logs on failure
|
|
131
|
+
if: failure()
|
|
132
|
+
uses: actions/upload-artifact@v4
|
|
133
|
+
with:
|
|
134
|
+
name: build-logs
|
|
135
|
+
path: |
|
|
136
|
+
~/Library/Logs/gym
|
|
137
|
+
${p.reportXml}
|
|
138
|
+
if-no-files-found: ignore
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
files[p.gemfile] = `source "https://rubygems.org"
|
|
142
|
+
|
|
143
|
+
gem "fastlane"
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
files[`${p.fastlaneDir}/Appfile`] = `app_identifier("${bundleId}")
|
|
147
|
+
team_id("${teamId}") # Apple Developer portal team
|
|
148
|
+
|
|
149
|
+
# Authentication is via an App Store Connect API key (see Fastfile / CI secrets),
|
|
150
|
+
# so no apple_id / itc_team is required here.
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
files[`${p.fastlaneDir}/Fastfile`] = `default_platform(:ios)
|
|
154
|
+
|
|
155
|
+
PROJECT = "${project}"
|
|
156
|
+
SCHEME = "${scheme}"
|
|
157
|
+
TARGET = "${target}"
|
|
158
|
+
BUNDLE_ID = "${bundleId}"
|
|
159
|
+
TEAM_ID = "${teamId}"
|
|
160
|
+
|
|
161
|
+
platform :ios do
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# PR / push sanity check: does the app still compile? Builds for the simulator,
|
|
164
|
+
# which needs no code signing and therefore no secrets.
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
desc "Compile the app for the simulator (no signing / no secrets)"
|
|
167
|
+
lane :ci_build do
|
|
168
|
+
build_app(
|
|
169
|
+
project: PROJECT,
|
|
170
|
+
scheme: SCHEME,
|
|
171
|
+
configuration: "Debug",
|
|
172
|
+
destination: "generic/platform=iOS Simulator",
|
|
173
|
+
skip_archive: true,
|
|
174
|
+
skip_codesigning: true,
|
|
175
|
+
skip_package_ipa: true
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Build a signed Release archive and ship it to TestFlight.
|
|
181
|
+
#
|
|
182
|
+
# Signing uses Xcode automatic ("cloud") signing driven by the App Store
|
|
183
|
+
# Connect API key (-allowProvisioningUpdates), so there is no certificate or
|
|
184
|
+
# provisioning-profile repo to manage. The same API key authenticates the
|
|
185
|
+
# TestFlight upload.
|
|
186
|
+
#
|
|
187
|
+
# The build number is computed as (latest TestFlight build for this marketing
|
|
188
|
+
# version) + 1, so uploads never collide and no manual pbxproj bump is needed.
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
desc "Build and upload a new build to TestFlight"
|
|
191
|
+
lane :beta do
|
|
192
|
+
setup_ci # ephemeral keychain when running on CI; no-op locally
|
|
193
|
+
|
|
194
|
+
api_key = app_store_connect_api_key(
|
|
195
|
+
key_id: ENV.fetch("ASC_KEY_ID"),
|
|
196
|
+
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
|
197
|
+
key_content: ENV.fetch("ASC_KEY_P8"),
|
|
198
|
+
is_key_content_base64: true,
|
|
199
|
+
in_house: false
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
version = get_version_number(xcodeproj: PROJECT, target: TARGET)
|
|
203
|
+
next_build = latest_testflight_build_number(
|
|
204
|
+
api_key: api_key,
|
|
205
|
+
app_identifier: BUNDLE_ID,
|
|
206
|
+
version: version,
|
|
207
|
+
initial_build_number: 0
|
|
208
|
+
) + 1
|
|
209
|
+
increment_build_number(build_number: next_build, xcodeproj: PROJECT)
|
|
210
|
+
UI.message("Building #{version} (#{next_build}) for TestFlight")
|
|
211
|
+
|
|
212
|
+
build_app(
|
|
213
|
+
project: PROJECT,
|
|
214
|
+
scheme: SCHEME,
|
|
215
|
+
configuration: "Release",
|
|
216
|
+
export_method: "app-store",
|
|
217
|
+
xcargs: "-allowProvisioningUpdates",
|
|
218
|
+
export_options: {
|
|
219
|
+
signingStyle: "automatic",
|
|
220
|
+
teamID: TEAM_ID
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
upload_to_testflight(
|
|
225
|
+
api_key: api_key,
|
|
226
|
+
app_identifier: BUNDLE_ID,
|
|
227
|
+
skip_waiting_for_build_processing: true,
|
|
228
|
+
distribute_external: false
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
files[`${p.fastlaneDir}/.gitignore`] = `README.md
|
|
235
|
+
report.xml
|
|
236
|
+
Preview.html
|
|
237
|
+
*.mobileprovision
|
|
238
|
+
*.cer
|
|
239
|
+
*.p12
|
|
240
|
+
*.p8
|
|
241
|
+
test_output/
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
files[`${p.fastlaneDir}/SETUP.md`] = `# iOS CI / TestFlight
|
|
245
|
+
|
|
246
|
+
Two GitHub Actions workflows live in \`.github/workflows/\`:
|
|
247
|
+
|
|
248
|
+
| Workflow | Trigger | Signing | Secrets |
|
|
249
|
+
|----------------------|------------------------------------------|---------|---------|
|
|
250
|
+
| \`ios-ci.yml\` | PRs + pushes to \`main\` touching the app | none (simulator build) | none |
|
|
251
|
+
| \`ios-testflight.yml\` | manual (**Run workflow**) + push to \`main\` | Xcode automatic / cloud | \`ASC_*\` |
|
|
252
|
+
|
|
253
|
+
Both run on \`${RUNNER}\` and drive [fastlane](https://fastlane.tools) (\`${p.fastlaneDir}/Fastfile\`).
|
|
254
|
+
|
|
255
|
+
## One-time setup
|
|
256
|
+
|
|
257
|
+
### 1. App Store Connect API key — create it once at the TEAM level
|
|
258
|
+
|
|
259
|
+
App Store Connect → Users and Access → **Integrations** → App Store Connect API.
|
|
260
|
+
Create a **team** key (not an individual key) with the **App Manager** role, so the
|
|
261
|
+
same three secret values work for *every* app in the account. Download the \`.p8\`
|
|
262
|
+
(you only get one chance) and note the **Key ID** and the team-level **Issuer ID**.
|
|
263
|
+
|
|
264
|
+
### 2. Add three repository secrets
|
|
265
|
+
|
|
266
|
+
Settings → Secrets and variables → Actions → **New repository secret** (or let
|
|
267
|
+
the \`set_repo_ci_secrets\` MCP tool push them for you):
|
|
268
|
+
|
|
269
|
+
| Secret | Value |
|
|
270
|
+
|-----------------|----------------------------------------------------------|
|
|
271
|
+
| \`ASC_KEY_ID\` | the Key ID (e.g. \`ABC123XYZ\`) |
|
|
272
|
+
| \`ASC_ISSUER_ID\` | the Issuer ID (a UUID) |
|
|
273
|
+
| \`ASC_KEY_P8\` | the \`.p8\` contents, **base64-encoded** (see below) |
|
|
274
|
+
|
|
275
|
+
\`\`\`bash
|
|
276
|
+
base64 -i AuthKey_ABC123XYZ.p8 | pbcopy # paste as ASC_KEY_P8
|
|
277
|
+
\`\`\`
|
|
278
|
+
|
|
279
|
+
### 3. Prerequisite on the Apple side
|
|
280
|
+
|
|
281
|
+
The app record for \`${bundleId}\` must already exist in App Store Connect. The
|
|
282
|
+
public API cannot create it — create it once in the App Store Connect web UI
|
|
283
|
+
(the bundle ID can be registered with the \`register_bundle_id\` tool first).
|
|
284
|
+
|
|
285
|
+
## How it works
|
|
286
|
+
|
|
287
|
+
- **Signing:** automatic ("cloud") signing via \`-allowProvisioningUpdates\`,
|
|
288
|
+
authenticated by the API key — Apple manages the distribution certificate and
|
|
289
|
+
provisioning profile, so there is no \`match\` repo or \`.p12\`/profile to maintain.
|
|
290
|
+
- **Build number:** computed as the latest TestFlight build for the current
|
|
291
|
+
marketing version **+ 1**. Manual \`CURRENT_PROJECT_VERSION\` bumps in the
|
|
292
|
+
project are no longer needed for shipping — CI sets a unique number per upload.
|
|
293
|
+
Bump \`MARKETING_VERSION\` (e.g. 1.0 → 1.1) when you want a new version train.
|
|
294
|
+
|
|
295
|
+
## Run it locally
|
|
296
|
+
|
|
297
|
+
\`\`\`bash
|
|
298
|
+
cd ${p.workDir}
|
|
299
|
+
bundle install
|
|
300
|
+
export ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_KEY_P8="$(base64 -i AuthKey_*.p8)"
|
|
301
|
+
bundle exec fastlane beta
|
|
302
|
+
\`\`\`
|
|
303
|
+
|
|
304
|
+
## If cloud signing gives you trouble
|
|
305
|
+
|
|
306
|
+
Automatic signing on CI occasionally fails for apps with several capabilities.
|
|
307
|
+
The fallback is [\`fastlane match\`](https://docs.fastlane.tools/actions/match/)
|
|
308
|
+
with a private certificates repo and manual signing — ask and it can be wired in.
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
return files;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- Project auto-detection -------------------------------------------------
|
|
315
|
+
|
|
316
|
+
const SKIP_DIRS = new Set([
|
|
317
|
+
"node_modules", ".git", "Pods", "Carthage", "DerivedData", "build", ".build",
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
/** Recursively find .xcodeproj bundles under root (shallow-ish, skips junk). */
|
|
321
|
+
function findXcodeprojs(root, depth = 0, acc = []) {
|
|
322
|
+
if (depth > 4) return acc;
|
|
323
|
+
let entries;
|
|
324
|
+
try {
|
|
325
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
326
|
+
} catch {
|
|
327
|
+
return acc;
|
|
328
|
+
}
|
|
329
|
+
for (const e of entries) {
|
|
330
|
+
if (!e.isDirectory()) continue;
|
|
331
|
+
if (e.name.endsWith(".xcodeproj")) {
|
|
332
|
+
acc.push(join(root, e.name));
|
|
333
|
+
continue; // don't descend into the project bundle
|
|
334
|
+
}
|
|
335
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
|
|
336
|
+
findXcodeprojs(join(root, e.name), depth + 1, acc);
|
|
337
|
+
}
|
|
338
|
+
return acc;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Most frequent value in a list (ties → first seen). */
|
|
342
|
+
function mostCommon(values) {
|
|
343
|
+
const counts = new Map();
|
|
344
|
+
for (const v of values) counts.set(v, (counts.get(v) || 0) + 1);
|
|
345
|
+
let best = null;
|
|
346
|
+
let bestN = 0;
|
|
347
|
+
for (const [v, n] of counts) {
|
|
348
|
+
if (n > bestN) {
|
|
349
|
+
best = v;
|
|
350
|
+
bestN = n;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return best;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function unquote(s) {
|
|
357
|
+
return s.trim().replace(/^["']|["']$/g, "").trim();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Scan project.pbxproj for bundle ids and the development team.
|
|
362
|
+
* Test targets and build-variable placeholders are filtered out.
|
|
363
|
+
*/
|
|
364
|
+
function scanPbxproj(xcodeprojPath) {
|
|
365
|
+
const pbx = join(xcodeprojPath, "project.pbxproj");
|
|
366
|
+
let text = "";
|
|
367
|
+
try {
|
|
368
|
+
text = readFileSync(pbx, "utf8");
|
|
369
|
+
} catch {
|
|
370
|
+
return { bundleId: null, teamId: null, bundleCandidates: [] };
|
|
371
|
+
}
|
|
372
|
+
const bundles = [];
|
|
373
|
+
for (const m of text.matchAll(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([^;]+);/g)) {
|
|
374
|
+
const v = unquote(m[1]);
|
|
375
|
+
if (!v || v.includes("$(")) continue; // skip placeholders
|
|
376
|
+
if (/tests?$/i.test(v) || /\.(test|uitest)/i.test(v)) continue; // skip test targets
|
|
377
|
+
bundles.push(v);
|
|
378
|
+
}
|
|
379
|
+
const teams = [];
|
|
380
|
+
for (const m of text.matchAll(/DEVELOPMENT_TEAM\s*=\s*([^;]+);/g)) {
|
|
381
|
+
const v = unquote(m[1]);
|
|
382
|
+
if (v && v !== '""' && !v.includes("$(")) teams.push(v);
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
bundleId: mostCommon(bundles),
|
|
386
|
+
teamId: mostCommon(teams),
|
|
387
|
+
bundleCandidates: [...new Set(bundles)],
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Detect appDir / projectName / scheme / target / bundleId / teamId for a repo.
|
|
393
|
+
* Explicit `provided` values always win. Returns { detected, missing, warnings }.
|
|
394
|
+
* detected: the resolved vars (some may be null if undetectable)
|
|
395
|
+
* missing: names of required fields still unresolved (bundleId / teamId)
|
|
396
|
+
* warnings: human-readable notes (multiple projects, workspace present, …)
|
|
397
|
+
*/
|
|
398
|
+
export function detectProject(repoRoot, provided = {}) {
|
|
399
|
+
const warnings = [];
|
|
400
|
+
const projects = findXcodeprojs(repoRoot);
|
|
401
|
+
if (projects.length === 0 && !provided.appDir) {
|
|
402
|
+
return {
|
|
403
|
+
detected: { ...provided },
|
|
404
|
+
missing: ["xcodeproj"],
|
|
405
|
+
warnings: ["No .xcodeproj found under the repo — pass appDir/scheme/bundleId/teamId explicitly."],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Pick a project: prefer the shallowest path (closest to repo root).
|
|
410
|
+
let projectPath = null;
|
|
411
|
+
if (projects.length) {
|
|
412
|
+
projectPath = projects.sort(
|
|
413
|
+
(a, b) => a.split(sep).length - b.split(sep).length,
|
|
414
|
+
)[0];
|
|
415
|
+
if (projects.length > 1)
|
|
416
|
+
warnings.push(
|
|
417
|
+
`Multiple .xcodeproj found; using ${relative(repoRoot, projectPath)}. Override with appDir/scheme if wrong.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let appDir = provided.appDir;
|
|
422
|
+
let projectName = provided.projectName;
|
|
423
|
+
if (projectPath) {
|
|
424
|
+
const rel = relative(repoRoot, dirname(projectPath)) || ".";
|
|
425
|
+
if (!appDir) appDir = rel === "" ? "." : rel;
|
|
426
|
+
if (!projectName) projectName = basename(projectPath, ".xcodeproj");
|
|
427
|
+
// Warn if a workspace sits alongside (CocoaPods/SPM) — we build the project.
|
|
428
|
+
try {
|
|
429
|
+
const sibling = readdirSync(dirname(projectPath)).find((f) =>
|
|
430
|
+
f.endsWith(".xcworkspace"),
|
|
431
|
+
);
|
|
432
|
+
if (sibling)
|
|
433
|
+
warnings.push(
|
|
434
|
+
`A ${sibling} exists; the Fastfile builds the .xcodeproj directly. If the app needs the workspace (CocoaPods), switch build_app to use \`workspace:\`.`,
|
|
435
|
+
);
|
|
436
|
+
} catch {
|
|
437
|
+
/* ignore */
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const scanned = projectPath ? scanPbxproj(projectPath) : {};
|
|
442
|
+
const bundleId = provided.bundleId || scanned.bundleId || null;
|
|
443
|
+
const teamId = provided.teamId || scanned.teamId || null;
|
|
444
|
+
if (
|
|
445
|
+
scanned.bundleCandidates &&
|
|
446
|
+
scanned.bundleCandidates.length > 1 &&
|
|
447
|
+
!provided.bundleId
|
|
448
|
+
)
|
|
449
|
+
warnings.push(
|
|
450
|
+
`Multiple bundle ids in the project (${scanned.bundleCandidates.join(", ")}); picked ${bundleId}. Override bundleId if wrong.`,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const detected = {
|
|
454
|
+
appDir: appDir || ".",
|
|
455
|
+
projectName: projectName || null,
|
|
456
|
+
scheme: provided.scheme || projectName || null,
|
|
457
|
+
target: provided.target || provided.scheme || projectName || null,
|
|
458
|
+
bundleId,
|
|
459
|
+
teamId,
|
|
460
|
+
};
|
|
461
|
+
const missing = [];
|
|
462
|
+
if (!detected.projectName) missing.push("scheme");
|
|
463
|
+
if (!detected.bundleId) missing.push("bundleId");
|
|
464
|
+
if (!detected.teamId) missing.push("teamId");
|
|
465
|
+
return { detected, missing, warnings };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** True if any pipeline file already exists in the repo (for safe overwrite UX). */
|
|
469
|
+
export function existingPipelineFiles(repoRoot, files) {
|
|
470
|
+
return Object.keys(files).filter((rel) => existsSync(join(repoRoot, rel)));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Parse owner/name from a git remote URL (ssh or https forms). */
|
|
474
|
+
export function parseRepoSlug(remoteUrl) {
|
|
475
|
+
if (!remoteUrl) return null;
|
|
476
|
+
const u = remoteUrl.trim();
|
|
477
|
+
// git@github.com:owner/name.git or ssh://git@github.com/owner/name.git
|
|
478
|
+
let m = u.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
|
|
479
|
+
if (m) return `${m[1]}/${m[2]}`;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
package/src/client.js
CHANGED
|
@@ -141,6 +141,25 @@ export class AppStoreConnectClient {
|
|
|
141
141
|
return this.request("DELETE", path);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Like getAll, but also concatenates the `included` side-loaded resources.
|
|
146
|
+
* Returns { data, included }. Needed for endpoints queried with `include=`
|
|
147
|
+
* (e.g. price schedules that side-load their price points).
|
|
148
|
+
*/
|
|
149
|
+
async getAllPages(path, query, maxPages = 40) {
|
|
150
|
+
let page = await this.get(path, query);
|
|
151
|
+
const data = Array.isArray(page.data) ? [...page.data] : [];
|
|
152
|
+
const included = Array.isArray(page.included) ? [...page.included] : [];
|
|
153
|
+
let pages = 1;
|
|
154
|
+
while (page.links && page.links.next && pages < maxPages) {
|
|
155
|
+
page = await this.request("GET", page.links.next);
|
|
156
|
+
if (Array.isArray(page.data)) data.push(...page.data);
|
|
157
|
+
if (Array.isArray(page.included)) included.push(...page.included);
|
|
158
|
+
pages++;
|
|
159
|
+
}
|
|
160
|
+
return { data, included };
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
/** Follow `links.next` and concatenate `data` arrays up to `maxPages`. */
|
|
145
164
|
async getAll(path, query, maxPages = 20) {
|
|
146
165
|
let page = await this.get(path, query);
|
package/src/guardrails.js
CHANGED
|
@@ -28,6 +28,7 @@ export const WRITE_TOOLS = new Set([
|
|
|
28
28
|
// catalog / pricing
|
|
29
29
|
"update_in_app_purchase",
|
|
30
30
|
"set_app_price",
|
|
31
|
+
"apply_ppp_prices",
|
|
31
32
|
// provisioning
|
|
32
33
|
"register_bundle_id",
|
|
33
34
|
"register_device",
|
|
@@ -39,9 +40,29 @@ export const WRITE_TOOLS = new Set([
|
|
|
39
40
|
"submit_for_review",
|
|
40
41
|
"release_version",
|
|
41
42
|
"set_phased_release",
|
|
43
|
+
// submission flow: build ↔ version ↔ review
|
|
44
|
+
"attach_build_to_version",
|
|
45
|
+
"update_app_store_version",
|
|
46
|
+
"update_build",
|
|
47
|
+
"expire_build",
|
|
48
|
+
"add_review_submission_item",
|
|
49
|
+
"cancel_review_submission",
|
|
50
|
+
"set_beta_build_notes",
|
|
51
|
+
"reorder_screenshots",
|
|
52
|
+
"replace_screenshots",
|
|
53
|
+
"create_subscription_group",
|
|
54
|
+
"create_subscription",
|
|
55
|
+
"create_in_app_purchase",
|
|
56
|
+
"swap_build",
|
|
57
|
+
"release_pipeline",
|
|
58
|
+
"bulk_upsert_localizations",
|
|
42
59
|
// build & ship (modify project / upload)
|
|
43
60
|
"bump_build_number",
|
|
44
61
|
"upload_build",
|
|
62
|
+
// CI/CD bootstrap (writes files / git / GitHub secrets)
|
|
63
|
+
"bootstrap_ios_cicd",
|
|
64
|
+
"set_repo_ci_secrets",
|
|
65
|
+
"bootstrap_testflight",
|
|
45
66
|
// snapshots
|
|
46
67
|
"restore_app_metadata",
|
|
47
68
|
"restore_screenshots",
|
|
@@ -50,7 +71,7 @@ export const WRITE_TOOLS = new Set([
|
|
|
50
71
|
// High-impact categories with their own opt-out env flags.
|
|
51
72
|
export const CATEGORY_TOOLS = {
|
|
52
73
|
RELEASE: new Set(["release_version", "set_phased_release"]),
|
|
53
|
-
PRICE_CHANGES: new Set(["set_app_price"]),
|
|
74
|
+
PRICE_CHANGES: new Set(["set_app_price", "apply_ppp_prices"]),
|
|
54
75
|
REVIEW_REPLIES: new Set(["reply_to_customer_review"]),
|
|
55
76
|
EXTERNAL_TESTFLIGHT: new Set(["submit_beta_review"]),
|
|
56
77
|
};
|