@daemux/store-automator 0.10.61 → 0.10.63

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.61"
8
+ "version": "0.10.63"
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.61",
15
+ "version": "0.10.63",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/README.md CHANGED
@@ -20,30 +20,46 @@ Plus CI/CD templates for GitHub Actions, Fastlane, web pages, and scripts.
20
20
 
21
21
  ## Installation
22
22
 
23
+ **Project (default):** Full install into your Flutter project -- agents, CI/CD templates, MCP servers, and interactive setup:
24
+
23
25
  ```bash
24
26
  cd your-flutter-project
25
- npm install @daemux/store-automator@latest
27
+ npx --yes @daemux/store-automator
26
28
  ```
27
29
 
28
- The postinstall script runs an interactive setup with five sections:
30
+ The installer runs an interactive setup with five sections:
29
31
 
30
32
  1. **App Identity** -- App name, bundle ID, package name, SKU, Apple ID
31
- 2. **Credentials** -- Guided steps for App Store Connect API key, Google Play service account, Android keystore, and Match code signing
33
+ 2. **Credentials** -- App Store Connect API key, Google Play service account, Android keystore, Match code signing
32
34
  3. **Store Settings** -- iOS categories/pricing, Android track/rollout, metadata languages
33
35
  4. **Web Settings** -- Domain, colors, company info, legal jurisdiction
34
- 5. **MCP Tokens** -- Stitch and Cloudflare API keys (optional, press Enter to skip)
35
-
36
- All values are written to `ci.config.yaml`. The installer also:
36
+ 5. **MCP Tokens** -- Stitch and Cloudflare API keys (optional)
37
37
 
38
- - Configures `.mcp.json` with MCP servers (Playwright, mobile-mcp, Stitch, Cloudflare)
39
- - Installs `.claude/CLAUDE.md` with your app name and agent configurations
40
- - Copies CI/CD templates (Fastlane, scripts, web pages, GitHub Actions)
41
- - Configures `.claude/settings.json` with required env vars
42
- - Runs post-install guides for GitHub repo setup, secrets, and Firebase
38
+ All values are written to `ci.config.yaml`. The installer also configures `.mcp.json`, installs `.claude/CLAUDE.md`, copies CI/CD templates, and configures `.claude/settings.json`.
43
39
 
44
40
  Re-running the installer reads existing `ci.config.yaml` values as defaults, so you can update individual fields without re-entering everything.
45
41
 
46
- ## After Installation
42
+ **Global (agents only):** Install the plugin into `~/.claude` for use across all projects, without touching the current directory:
43
+
44
+ ```bash
45
+ npx --yes @daemux/store-automator --global
46
+ ```
47
+
48
+ Global install:
49
+
50
+ - Registers the marketplace and installs the plugin at user scope
51
+ - Writes `~/.claude/CLAUDE.md` with agent configurations
52
+ - Adds required env vars and statusLine to `~/.claude/settings.json`
53
+
54
+ Global install does NOT:
55
+
56
+ - Create `ci.config.yaml`, `fastlane/`, `scripts/`, `web/`, `Gemfile`, `.github/workflows/`, or any other project files
57
+ - Write or modify `.mcp.json` in the current directory
58
+ - Run any interactive prompts or post-install guides
59
+
60
+ Use project install for any Flutter app you are actually publishing. Global install is for agent-only access from arbitrary directories.
61
+
62
+ ## After Installation _(project scope only)_
47
63
 
48
64
  1. Add any credential files not configured during the guided setup:
49
65
  - `creds/AuthKey.p8` -- Apple App Store Connect API key
@@ -52,7 +68,7 @@ Re-running the installer reads existing `ci.config.yaml` values as defaults, so
52
68
  2. Verify `ci.config.yaml` has all required values filled in
53
69
  3. Start Claude Code and use the agents
54
70
 
55
- ## Manual Setup
71
+ ## Manual Setup _(project scope only)_
56
72
 
57
73
  If postinstall was skipped (CI environment), run manually:
58
74
 
@@ -240,19 +256,18 @@ web:
240
256
  google_play_url: "" # Filled after first Android publish
241
257
  ```
242
258
 
243
- ## Usage
259
+ ## Uninstall
244
260
 
245
- ### Global Install
261
+ **Project uninstall:** Removes CI templates, `ci.config.yaml`, `.mcp.json` entries, `.claude/CLAUDE.md`, and settings from the current project. Marketplace files remain in `~/.claude/plugins/`.
246
262
 
247
263
  ```bash
248
- npx @daemux/store-automator -g
264
+ npx --yes @daemux/store-automator --uninstall
249
265
  ```
250
266
 
251
- ### Uninstall
267
+ **Global uninstall:** Removes the plugin from `~/.claude` and clears the marketplace. Does NOT touch the current project's `ci.config.yaml`, CI templates, or `.mcp.json`.
252
268
 
253
269
  ```bash
254
- npx @daemux/store-automator -u # project scope
255
- npx @daemux/store-automator -g -u # global scope
270
+ npx --yes @daemux/store-automator --global --uninstall
256
271
  ```
257
272
 
258
273
  ## Agents
@@ -269,7 +284,7 @@ Designs complete app UI screens, creates ASO-optimized store screenshots for all
269
284
 
270
285
  Reviews all metadata, screenshots, privacy policy, and IAP configuration against Apple and Google guidelines. Returns APPROVED or REJECTED with specific issues.
271
286
 
272
- ## CI/CD Templates
287
+ ## CI/CD Templates _(project scope only)_
273
288
 
274
289
  The package installs these templates to your project:
275
290
 
@@ -282,7 +297,7 @@ The package installs these templates to your project:
282
297
  | `web/` | Marketing, privacy, terms, and support page templates |
283
298
  | `Gemfile` | Ruby gems for Fastlane |
284
299
 
285
- ## Workflow
300
+ ## Workflow _(project scope only)_
286
301
 
287
302
  1. Install the package (interactive setup fills `ci.config.yaml`)
288
303
  2. Add any remaining credential files
@@ -291,7 +306,7 @@ The package installs these templates to your project:
291
306
  5. Use `appstore-reviewer` to verify compliance
292
307
  6. Push to GitHub -- GitHub Actions builds and publishes automatically
293
308
 
294
- ## MCP Servers
309
+ ## MCP Servers _(project scope only)_
295
310
 
296
311
  The package configures these MCP servers in `.mcp.json`:
297
312
 
@@ -302,7 +317,7 @@ The package configures these MCP servers in `.mcp.json`:
302
317
  | stitch | AI design tool for screenshot generation | `STITCH_API_KEY` |
303
318
  | cloudflare | Cloudflare Pages deployment | `CLOUDFLARE_API_TOKEN` + Account ID |
304
319
 
305
- ## Idempotency
320
+ ## Idempotency _(project scope only)_
306
321
 
307
322
  The installer is idempotent. Re-running it reads existing values from `ci.config.yaml` as defaults for each prompt. This means you can:
308
323
 
package/bin/cli.mjs CHANGED
@@ -9,8 +9,6 @@ const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = dirname(__filename);
10
10
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
11
11
 
12
- const notifier = updateNotifier({ pkg });
13
-
14
12
  const args = process.argv.slice(2);
15
13
  let scope = 'project';
16
14
  let action = 'install';
@@ -146,6 +144,13 @@ Examples:
146
144
  }
147
145
  }
148
146
 
147
+ if (scope === 'user' && cliTokens.githubActions) {
148
+ console.error('Error: --github-actions and --global are mutually exclusive.');
149
+ console.error(' --global targets ~/.claude (no CI templates).');
150
+ console.error(' --github-actions targets a project repo. Pick one.');
151
+ process.exit(1);
152
+ }
153
+
149
154
  if (cliTokens.githubActions) {
150
155
  const missing = [];
151
156
  if (!cliTokens.matchDeployKey) missing.push('--match-deploy-key');
@@ -163,7 +168,9 @@ if (cliTokens.githubActions) {
163
168
  }
164
169
  }
165
170
 
166
- notifier.notify();
171
+ if (!isPostinstall) {
172
+ updateNotifier({ pkg }).notify();
173
+ }
167
174
 
168
175
  try {
169
176
  if (action === 'uninstall') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.61",
3
+ "version": "0.10.63",
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.61",
3
+ "version": "0.10.63",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
package/src/install.mjs CHANGED
@@ -152,7 +152,39 @@ function printNextSteps(prompted) {
152
152
  }
153
153
  }
154
154
 
155
+ async function withReadline(fn) {
156
+ const { createInterface } = await import('node:readline');
157
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
158
+ try {
159
+ return await fn(rl);
160
+ } finally {
161
+ rl.close();
162
+ }
163
+ }
164
+
165
+ function configureScopedSettings(baseDir, packageDir, appName, scopeLabel) {
166
+ ensureDir(baseDir);
167
+ installClaudeMd(join(baseDir, 'CLAUDE.md'), packageDir, appName);
168
+ console.log(`Configuring ${scopeLabel} settings...`);
169
+ const settingsPath = join(baseDir, 'settings.json');
170
+ injectEnvVars(settingsPath);
171
+ injectStatusLine(settingsPath);
172
+ }
173
+
174
+ function printGlobalNote() {
175
+ console.log('');
176
+ console.log('Note: Global install registers the marketplace and installs ~/.claude/CLAUDE.md only.');
177
+ console.log('To generate CI/CD templates, MCP config, and ci.config.yaml, run `npx @daemux/store-automator` inside a project directory.');
178
+ }
179
+
155
180
  export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
181
+ const isGlobal = scope === 'user';
182
+
183
+ if (isPostinstall && isGlobal) {
184
+ console.log('Skipping postinstall in global scope.');
185
+ return;
186
+ }
187
+
156
188
  checkClaudeCli();
157
189
 
158
190
  console.log('Installing/updating Daemux Store Automator...');
@@ -160,102 +192,71 @@ export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
160
192
  const isGitHubActions = Boolean(cliTokens.githubActions);
161
193
  const nonInteractive = Boolean(process.env.npm_config_yes) || process.argv.includes('--postinstall');
162
194
  const projectDir = process.cwd();
163
- const oldVersion = readMarketplaceVersion();
164
195
  const packageDir = getPackageDir();
196
+ const oldVersion = readMarketplaceVersion();
165
197
 
166
- // 1. Copy plugin files + register marketplace
167
198
  copyPluginFiles(packageDir);
168
199
  clearCache();
169
200
  registerMarketplace();
170
201
  runClaudeInstall(scope);
171
-
172
202
  const newVersion = readMarketplaceVersion('unknown');
173
203
 
174
- // 2. Install CI templates (creates ci.config.yaml if missing)
175
- installCiTemplates(projectDir, packageDir);
176
- installFirebaseTemplates(projectDir, packageDir);
177
-
178
- // 3. Read current ci.config.yaml values
179
- const currentConfig = readCiConfig(projectDir);
204
+ let prompted = {};
205
+ if (!isGlobal) {
206
+ installCiTemplates(projectDir, packageDir);
207
+ installFirebaseTemplates(projectDir, packageDir);
208
+ const currentConfig = readCiConfig(projectDir);
180
209
 
181
- // 4. Run interactive prompts (or use CLI flags / skip in non-interactive)
182
- let prompted;
183
- if (isGitHubActions) {
184
- prompted = {
185
- bundleId: cliTokens.bundleId ?? '',
186
- matchDeployKeyPath: cliTokens.matchDeployKey,
187
- matchGitUrl: cliTokens.matchGitUrl,
188
- };
189
- } else if (nonInteractive) {
190
- prompted = { ...cliTokens };
191
- } else {
192
- const { createInterface } = await import('node:readline');
193
- const rl = createInterface({ input: process.stdin, output: process.stdout });
194
- try {
195
- prompted = await promptAll(rl, cliTokens, currentConfig, projectDir);
196
- } finally {
197
- rl.close();
210
+ if (isGitHubActions) {
211
+ prompted = {
212
+ bundleId: cliTokens.bundleId ?? '',
213
+ matchDeployKeyPath: cliTokens.matchDeployKey,
214
+ matchGitUrl: cliTokens.matchGitUrl,
215
+ };
216
+ } else if (nonInteractive) {
217
+ prompted = { ...cliTokens };
218
+ } else {
219
+ prompted = await withReadline((rl) => promptAll(rl, cliTokens, currentConfig, projectDir));
198
220
  }
199
- }
200
-
201
- // 5. Write all prompted values to ci.config.yaml
202
- const ciFields = mapPromptsToCiFields(prompted);
203
- const wrote = writeCiFields(projectDir, ciFields);
204
- if (wrote) console.log('Configuration written to ci.config.yaml');
205
-
206
- if (prompted.matchDeployKeyPath || prompted.matchGitUrl) {
207
- const wroteMatch = writeMatchConfig(projectDir, {
208
- deployKeyPath: prompted.matchDeployKeyPath,
209
- gitUrl: prompted.matchGitUrl,
210
- });
211
- if (wroteMatch) console.log('Match credentials written to ci.config.yaml');
212
- }
213
221
 
214
- // 6. Handle languages separately
215
- if (prompted.languages) {
216
- const langStr = Array.isArray(prompted.languages)
217
- ? prompted.languages.join(',')
218
- : prompted.languages;
219
- if (writeCiLanguages(projectDir, langStr)) {
220
- console.log('Languages updated in ci.config.yaml');
222
+ if (writeCiFields(projectDir, mapPromptsToCiFields(prompted))) {
223
+ console.log('Configuration written to ci.config.yaml');
221
224
  }
222
- }
223
-
224
- // 7. Configure MCP, CLAUDE.md, settings
225
- if (!isGitHubActions) {
226
- const servers = getMcpServers(prompted);
227
- writeMcpJson(projectDir, servers);
228
- }
229
225
 
230
- const baseDir = scope === 'user'
231
- ? join(homedir(), '.claude')
232
- : join(process.cwd(), '.claude');
233
-
234
- ensureDir(baseDir);
235
-
236
- installClaudeMd(join(baseDir, 'CLAUDE.md'), packageDir, prompted.appName);
226
+ if (prompted.matchDeployKeyPath || prompted.matchGitUrl) {
227
+ const wroteMatch = writeMatchConfig(projectDir, {
228
+ deployKeyPath: prompted.matchDeployKeyPath,
229
+ gitUrl: prompted.matchGitUrl,
230
+ });
231
+ if (wroteMatch) console.log('Match credentials written to ci.config.yaml');
232
+ }
237
233
 
238
- installGitHubActionsPath(projectDir, packageDir, prompted);
234
+ if (prompted.languages) {
235
+ const langStr = Array.isArray(prompted.languages) ? prompted.languages.join(',') : prompted.languages;
236
+ if (writeCiLanguages(projectDir, langStr)) {
237
+ console.log('Languages updated in ci.config.yaml');
238
+ }
239
+ }
239
240
 
240
- const scopeLabel = scope === 'user' ? 'global' : 'project';
241
- console.log(`Configuring ${scopeLabel} settings...`);
242
- const settingsPath = join(baseDir, 'settings.json');
243
- injectEnvVars(settingsPath);
244
- injectStatusLine(settingsPath);
241
+ if (!isGitHubActions) writeMcpJson(projectDir, getMcpServers(prompted));
242
+ installGitHubActionsPath(projectDir, packageDir, prompted);
245
243
 
246
- // 8. Run post-install guides (interactive only)
247
- if (!isGitHubActions && !nonInteractive) {
248
- const { createInterface } = await import('node:readline');
249
- const guideRl = createInterface({ input: process.stdin, output: process.stdout });
250
- try {
251
- const { runPostInstallGuides } = await import('./prompts/store-settings.mjs');
252
- await runPostInstallGuides(guideRl, currentConfig);
253
- } finally {
254
- guideRl.close();
244
+ if (!isGitHubActions && !nonInteractive) {
245
+ await withReadline(async (rl) => {
246
+ const { runPostInstallGuides } = await import('./prompts/store-settings.mjs');
247
+ await runPostInstallGuides(rl, currentConfig);
248
+ });
255
249
  }
256
250
  }
257
251
 
258
- // 9. Summary + dynamic next steps
252
+ const baseDir = isGlobal ? join(homedir(), '.claude') : join(projectDir, '.claude');
253
+ const appName = prompted.appName || (isGlobal ? 'your app' : undefined);
254
+ configureScopedSettings(baseDir, packageDir, appName, isGlobal ? 'global' : 'project');
255
+
259
256
  printSummary(scope, oldVersion, newVersion);
260
- printNextSteps(prompted);
257
+ if (isGlobal) {
258
+ printGlobalNote();
259
+ } else {
260
+ printNextSteps(prompted);
261
+ }
261
262
  }
package/src/templates.mjs CHANGED
@@ -55,7 +55,7 @@ export function installClaudeMd(targetPath, packageDir, appName) {
55
55
 
56
56
  let content = readFileSync(template, 'utf8');
57
57
  if (appName) {
58
- content = content.replace(/\{APP_NAME\}/g, appName);
58
+ content = content.split('{APP_NAME}').join(appName);
59
59
  }
60
60
  writeFileSync(targetPath, content, 'utf8');
61
61
  }
package/src/uninstall.mjs CHANGED
@@ -86,11 +86,15 @@ export async function runUninstall(scope) {
86
86
 
87
87
  runClaudeUninstall(scope);
88
88
 
89
+ let doneMessage;
89
90
  if (scope === 'user') {
90
91
  console.log('Removing marketplace...');
91
92
  rmSync(MARKETPLACE_DIR, { recursive: true, force: true });
92
93
  rmSync(CACHE_DIR, { recursive: true, force: true });
93
94
  unregisterMarketplace();
95
+ doneMessage = '\nDone! store-automator uninstalled globally.';
96
+ } else {
97
+ doneMessage = '\nDone! store-automator uninstalled from this project.\n\nNote: Marketplace files remain under ~/.claude/plugins/marketplaces.\nRun with --global --uninstall to remove marketplace completely.';
94
98
  }
95
99
 
96
100
  const isGlobal = scope === 'user';
@@ -98,15 +102,15 @@ export async function runUninstall(scope) {
98
102
  const scopeLabel = isGlobal ? 'global' : 'project';
99
103
 
100
104
  removeFileIfExists(join(baseDir, 'CLAUDE.md'), `${scopeLabel} CLAUDE.md`);
101
- removeCiTemplates(process.cwd());
102
- removeMcpServers(process.cwd());
105
+
106
+ if (!isGlobal) {
107
+ removeCiTemplates(process.cwd());
108
+ removeMcpServers(process.cwd());
109
+ }
103
110
 
104
111
  console.log(`Cleaning ${scopeLabel} settings...`);
105
112
  removeEnvVars(join(baseDir, 'settings.json'));
106
113
  removeStatusLine(join(baseDir, 'settings.json'));
107
114
 
108
- console.log(isGlobal
109
- ? '\nDone! store-automator uninstalled globally.'
110
- : `\nDone! store-automator uninstalled from this project.\n\nNote: Marketplace files remain in ${MARKETPLACE_DIR}\nRun with --global --uninstall to remove marketplace completely.`
111
- );
115
+ console.log(doneMessage);
112
116
  }
@@ -0,0 +1,39 @@
1
+ name: iOS Deploy
2
+
3
+ # Native-Swift iOS release pipeline. Archives the app and uploads it to
4
+ # TestFlight via the App Store Connect API.
5
+ #
6
+ # Required repository secrets:
7
+ # ASC_KEY_ID App Store Connect API key id (e.g. "5NBDY6YXJ6")
8
+ # ASC_ISSUER_ID App Store Connect API issuer id (uuid)
9
+ # ASC_KEY_P8 Full contents of the AuthKey_*.p8 file
10
+ #
11
+ # Edit the `with:` block below to match your project.
12
+
13
+ on:
14
+ push:
15
+ branches: [main]
16
+ workflow_dispatch:
17
+
18
+ concurrency:
19
+ group: ios-deploy-${{ github.ref }}
20
+ cancel-in-progress: false
21
+
22
+ jobs:
23
+ deploy:
24
+ runs-on: macos-latest
25
+ timeout-minutes: 60
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: daemux/daemux-plugins/.github/actions/ios-native-testflight@main
30
+ with:
31
+ project: MyApp.xcodeproj
32
+ scheme: MyApp
33
+ bundle-id: com.example.myapp
34
+ team-id: ABCDE12345
35
+ app-store-apple-id: "1234567890"
36
+ env:
37
+ ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
38
+ ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
39
+ ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Query App Store Connect for the highest-numbered build (across all states,
4
+ all platforms) for this app and print `<highest + 1>` to stdout.
5
+
6
+ If no builds exist yet, print 1.
7
+
8
+ Environment:
9
+ ASC_KEY_ID - API key ID
10
+ ASC_ISSUER_ID - API issuer ID
11
+ ASC_KEY_PATH - Path to the .p8 key file
12
+ APP_STORE_APPLE_ID - App Store numeric app id
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ import time
20
+
21
+ import jwt
22
+ import requests
23
+
24
+
25
+ def env(name: str) -> str:
26
+ val = os.environ.get(name)
27
+ if not val:
28
+ raise SystemExit(f"missing env var: {name}")
29
+ return val
30
+
31
+
32
+ def main() -> None:
33
+ key_id = env("ASC_KEY_ID")
34
+ issuer_id = env("ASC_ISSUER_ID")
35
+ key_path = env("ASC_KEY_PATH")
36
+ app_id = env("APP_STORE_APPLE_ID")
37
+
38
+ with open(key_path) as f:
39
+ key = f.read()
40
+
41
+ now = int(time.time())
42
+ token = jwt.encode(
43
+ {"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"},
44
+ key,
45
+ algorithm="ES256",
46
+ headers={"kid": key_id, "typ": "JWT"},
47
+ )
48
+
49
+ # Sort by -version requests highest version first; limit 200 is plenty.
50
+ url = (
51
+ "https://api.appstoreconnect.apple.com/v1/builds"
52
+ f"?filter[app]={app_id}&sort=-version&limit=200"
53
+ )
54
+ resp = requests.get(url, headers={"Authorization": f"Bearer {token}"})
55
+ if resp.status_code >= 400:
56
+ print(f"ASC builds list failed: {resp.status_code}\n{resp.text[:500]}", file=sys.stderr)
57
+ raise SystemExit(1)
58
+
59
+ highest = 0
60
+ for b in resp.json().get("data", []):
61
+ ver = b.get("attributes", {}).get("version")
62
+ try:
63
+ n = int(ver)
64
+ except (TypeError, ValueError):
65
+ continue
66
+ if n > highest:
67
+ highest = n
68
+
69
+ print(highest + 1)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Compute the next CFBundleShortVersionString for this app.
4
+
5
+ Strategy:
6
+ * Look at all existing pre-release (TestFlight) and App Store versions.
7
+ * Take the highest semantic-version triple and bump its patch component.
8
+ * If no versions exist yet, return 1.0.0.
9
+
10
+ Prints the chosen version to stdout.
11
+
12
+ Environment:
13
+ ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH, APP_STORE_APPLE_ID
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import sys
21
+ import time
22
+
23
+ import jwt
24
+ import requests
25
+
26
+
27
+ def env(name: str) -> str:
28
+ v = os.environ.get(name)
29
+ if not v:
30
+ raise SystemExit(f"missing env var: {name}")
31
+ return v
32
+
33
+
34
+ SEM_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$")
35
+
36
+
37
+ def parse_sem(v: str) -> tuple[int, int, int] | None:
38
+ m = SEM_RE.match(v or "")
39
+ if not m:
40
+ return None
41
+ major = int(m.group(1))
42
+ minor = int(m.group(2))
43
+ patch = int(m.group(3) or 0)
44
+ return major, minor, patch
45
+
46
+
47
+ def main() -> None:
48
+ key_id = env("ASC_KEY_ID")
49
+ issuer_id = env("ASC_ISSUER_ID")
50
+ key_path = env("ASC_KEY_PATH")
51
+ app_id = env("APP_STORE_APPLE_ID")
52
+
53
+ with open(key_path) as f:
54
+ key = f.read()
55
+ now = int(time.time())
56
+ token = jwt.encode(
57
+ {"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"},
58
+ key,
59
+ algorithm="ES256",
60
+ headers={"kid": key_id, "typ": "JWT"},
61
+ )
62
+ headers = {"Authorization": f"Bearer {token}"}
63
+
64
+ highest: tuple[int, int, int] = (0, 0, 0)
65
+
66
+ for endpoint, field in (
67
+ (f"/v1/apps/{app_id}/preReleaseVersions?limit=200", "version"),
68
+ (f"/v1/apps/{app_id}/appStoreVersions?limit=200", "versionString"),
69
+ ):
70
+ url = f"https://api.appstoreconnect.apple.com{endpoint}"
71
+ resp = requests.get(url, headers=headers)
72
+ if resp.status_code >= 400:
73
+ print(
74
+ f"ASC GET {endpoint} failed: {resp.status_code}\n{resp.text[:500]}",
75
+ file=sys.stderr,
76
+ )
77
+ raise SystemExit(1)
78
+ for item in resp.json().get("data", []):
79
+ v = item.get("attributes", {}).get(field)
80
+ parsed = parse_sem(v)
81
+ if parsed and parsed > highest:
82
+ highest = parsed
83
+
84
+ if highest == (0, 0, 0):
85
+ print("1.0.0")
86
+ return
87
+
88
+ major, minor, patch = highest
89
+ print(f"{major}.{minor}.{patch + 1}")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Prepare iOS code signing on a fresh CI runner.
4
+
5
+ Uses the App Store Connect API (auth via P8 key) to:
6
+ 1. Generate an RSA private key + CSR
7
+ 2. Revoke any previously-created CI Apple Distribution certs (marker match)
8
+ 3. Create a new Apple Distribution certificate from the CSR
9
+ 4. Ensure a provisioning profile with a known name exists for the bundle ID,
10
+ linked to the new cert. If it exists, delete+recreate to refresh it.
11
+ 5. Install the profile into ~/Library/MobileDevice/Provisioning Profiles/
12
+ 6. Import cert + private key into a dedicated temporary keychain and put
13
+ that keychain on the search list so codesign / xcodebuild can find it.
14
+
15
+ Environment inputs:
16
+ ASC_KEY_ID - App Store Connect API key ID
17
+ ASC_ISSUER_ID - App Store Connect API issuer ID
18
+ ASC_KEY_PATH - Path to the .p8 private key file
19
+ TEAM_ID - Apple developer team ID
20
+ BUNDLE_ID - App bundle identifier
21
+ PROFILE_NAME - Desired provisioning profile name
22
+ RUNNER_TEMP - GitHub Actions temp dir (for keychain + intermediates)
23
+
24
+ Writes nothing to stdout that would leak secrets.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import base64
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+
37
+ import jwt
38
+ import requests
39
+ from cryptography import x509
40
+ from cryptography.hazmat.primitives import hashes, serialization
41
+ from cryptography.hazmat.primitives.asymmetric import rsa
42
+ from cryptography.x509.oid import NameOID
43
+
44
+
45
+ ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
46
+ CERT_MARKER = "ScudoVPN-CI"
47
+
48
+
49
+ def env(name: str) -> str:
50
+ val = os.environ.get(name)
51
+ if not val:
52
+ raise SystemExit(f"missing env var: {name}")
53
+ return val
54
+
55
+
56
+ def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
57
+ with open(key_path, "r") as f:
58
+ key = f.read()
59
+ now = int(time.time())
60
+ payload = {
61
+ "iss": issuer_id,
62
+ "iat": now,
63
+ "exp": now + 600,
64
+ "aud": "appstoreconnect-v1",
65
+ }
66
+ return jwt.encode(
67
+ payload, key, algorithm="ES256", headers={"kid": key_id, "typ": "JWT"}
68
+ )
69
+
70
+
71
+ def api(method: str, path: str, token: str, **kwargs) -> requests.Response:
72
+ url = path if path.startswith("http") else f"{ASC_BASE}{path}"
73
+ headers = kwargs.pop("headers", {})
74
+ headers["Authorization"] = f"Bearer {token}"
75
+ if "json" in kwargs:
76
+ headers["Content-Type"] = "application/json"
77
+ resp = requests.request(method, url, headers=headers, **kwargs)
78
+ if resp.status_code >= 400:
79
+ raise SystemExit(
80
+ f"ASC API {method} {path} failed: {resp.status_code}\n{resp.text[:2000]}"
81
+ )
82
+ return resp
83
+
84
+
85
+ def generate_key_and_csr() -> tuple[rsa.RSAPrivateKey, bytes, bytes]:
86
+ """Return (private_key, private_key_pem, csr_der)."""
87
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
88
+ csr = (
89
+ x509.CertificateSigningRequestBuilder()
90
+ .subject_name(
91
+ x509.Name(
92
+ [
93
+ x509.NameAttribute(NameOID.COMMON_NAME, "ScudoVPN CI"),
94
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
95
+ ]
96
+ )
97
+ )
98
+ .sign(private_key, hashes.SHA256())
99
+ )
100
+ priv_pem = private_key.private_bytes(
101
+ encoding=serialization.Encoding.PEM,
102
+ format=serialization.PrivateFormat.PKCS8,
103
+ encryption_algorithm=serialization.NoEncryption(),
104
+ )
105
+ # Apple wants the CSR PEM base64 (without headers)
106
+ csr_pem = csr.public_bytes(serialization.Encoding.PEM)
107
+ csr_payload = b"".join(
108
+ line for line in csr_pem.splitlines() if not line.startswith(b"-----")
109
+ )
110
+ return private_key, priv_pem, csr_payload
111
+
112
+
113
+ def newest_distribution_cert_id(token: str) -> str | None:
114
+ """Return the ID of the most-recently-created DISTRIBUTION cert, or None."""
115
+ resp = api(
116
+ "GET",
117
+ "/certificates?limit=200&sort=-id&filter[certificateType]=DISTRIBUTION",
118
+ token,
119
+ )
120
+ newest_id = None
121
+ newest_exp = ""
122
+ for cert in resp.json().get("data", []):
123
+ attrs = cert.get("attributes") or {}
124
+ # Use expirationDate as a proxy for creation time (cert expiration
125
+ # is exactly creation + 1 year for Apple Distribution certs).
126
+ exp = attrs.get("expirationDate") or ""
127
+ if exp > newest_exp:
128
+ newest_exp = exp
129
+ newest_id = cert["id"]
130
+ return newest_id
131
+
132
+
133
+ def revoke_cert(token: str, cert_id: str) -> None:
134
+ print(f"Revoking distribution cert {cert_id}")
135
+ api("DELETE", f"/certificates/{cert_id}", token)
136
+
137
+
138
+ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
139
+ """Return (certificate_id, DER-encoded certificate bytes).
140
+
141
+ Apple enforces a per-team cap on active Distribution certs. If the POST
142
+ comes back 409 (already have one or pending), revoke the newest existing
143
+ one and retry once. This is the CI-owned cert from a prior run.
144
+ """
145
+ body = {
146
+ "data": {
147
+ "type": "certificates",
148
+ "attributes": {
149
+ "csrContent": csr_b64,
150
+ "certificateType": "DISTRIBUTION",
151
+ },
152
+ }
153
+ }
154
+ url = f"{ASC_BASE}/certificates"
155
+ headers = {
156
+ "Authorization": f"Bearer {token}",
157
+ "Content-Type": "application/json",
158
+ }
159
+ resp = requests.post(url, headers=headers, json=body)
160
+ if resp.status_code == 409:
161
+ print("Distribution cert cap hit; revoking newest existing cert")
162
+ target = newest_distribution_cert_id(token)
163
+ if not target:
164
+ raise SystemExit(
165
+ "409 from cert create but no existing DISTRIBUTION cert "
166
+ "found to revoke"
167
+ )
168
+ revoke_cert(token, target)
169
+ resp = requests.post(url, headers=headers, json=body)
170
+ if resp.status_code >= 400:
171
+ raise SystemExit(
172
+ f"ASC API POST /certificates failed: {resp.status_code}\n"
173
+ f"{resp.text[:2000]}"
174
+ )
175
+ data = resp.json()["data"]
176
+ cert_id = data["id"]
177
+ cert_content_b64 = data["attributes"]["certificateContent"]
178
+ cert_der = base64.b64decode(cert_content_b64)
179
+ print(f"Created DISTRIBUTION cert {cert_id}")
180
+ return cert_id, cert_der
181
+
182
+
183
+ def find_bundle_id(token: str, identifier: str) -> str:
184
+ resp = api(
185
+ "GET", f"/bundleIds?filter[identifier]={identifier}&limit=5", token
186
+ )
187
+ for item in resp.json().get("data", []):
188
+ if item["attributes"]["identifier"] == identifier:
189
+ return item["id"]
190
+ raise SystemExit(f"bundle id {identifier!r} not found in ASC")
191
+
192
+
193
+ def delete_profile_by_name(token: str, name: str) -> None:
194
+ resp = api("GET", "/profiles?limit=200", token)
195
+ for p in resp.json().get("data", []):
196
+ if (p["attributes"].get("name") or "") == name:
197
+ pid = p["id"]
198
+ print(f"Deleting existing profile {pid} ({name})")
199
+ api("DELETE", f"/profiles/{pid}", token)
200
+
201
+
202
+ def create_profile(
203
+ token: str, name: str, bundle_id_pk: str, cert_id: str
204
+ ) -> bytes:
205
+ body = {
206
+ "data": {
207
+ "type": "profiles",
208
+ "attributes": {
209
+ "name": name,
210
+ "profileType": "IOS_APP_STORE",
211
+ },
212
+ "relationships": {
213
+ "bundleId": {"data": {"type": "bundleIds", "id": bundle_id_pk}},
214
+ "certificates": {
215
+ "data": [{"type": "certificates", "id": cert_id}]
216
+ },
217
+ },
218
+ }
219
+ }
220
+ resp = api("POST", "/profiles", token, json=body)
221
+ data = resp.json()["data"]
222
+ profile_content_b64 = data["attributes"]["profileContent"]
223
+ return base64.b64decode(profile_content_b64)
224
+
225
+
226
+ def install_profile(profile_der: bytes) -> str:
227
+ """Write the .mobileprovision file and return its uuid."""
228
+ # Extract UUID from profile (it's CMS-signed plist)
229
+ # Use `security cms -D -i <path>` to decode.
230
+ profiles_dir = Path.home() / "Library/MobileDevice/Provisioning Profiles"
231
+ profiles_dir.mkdir(parents=True, exist_ok=True)
232
+ tmp_path = profiles_dir / "tmp.mobileprovision"
233
+ tmp_path.write_bytes(profile_der)
234
+ decoded = subprocess.check_output(
235
+ ["security", "cms", "-D", "-i", str(tmp_path)]
236
+ )
237
+ import plistlib
238
+
239
+ plist = plistlib.loads(decoded)
240
+ uuid = plist["UUID"]
241
+ final_path = profiles_dir / f"{uuid}.mobileprovision"
242
+ tmp_path.rename(final_path)
243
+ print(f"Installed provisioning profile {uuid} at {final_path}")
244
+ return uuid
245
+
246
+
247
+ def write_p12(
248
+ private_key: rsa.RSAPrivateKey, cert_der: bytes, out_path: Path, passwd: str
249
+ ) -> None:
250
+ cert = x509.load_der_x509_certificate(cert_der)
251
+ from cryptography.hazmat.primitives.serialization import pkcs12
252
+
253
+ p12 = pkcs12.serialize_key_and_certificates(
254
+ name=b"ScudoVPN CI",
255
+ key=private_key,
256
+ cert=cert,
257
+ cas=None,
258
+ encryption_algorithm=serialization.BestAvailableEncryption(
259
+ passwd.encode()
260
+ ),
261
+ )
262
+ out_path.write_bytes(p12)
263
+
264
+
265
+ def setup_keychain(p12_path: Path, p12_pass: str) -> str:
266
+ runner_temp = env("RUNNER_TEMP")
267
+ keychain_path = os.path.join(runner_temp, "ci.keychain-db")
268
+ keychain_pass = "ci"
269
+ # Remove stale
270
+ subprocess.run(
271
+ ["security", "delete-keychain", keychain_path],
272
+ check=False,
273
+ capture_output=True,
274
+ )
275
+ subprocess.check_call(
276
+ ["security", "create-keychain", "-p", keychain_pass, keychain_path]
277
+ )
278
+ subprocess.check_call(
279
+ ["security", "set-keychain-settings", "-lut", "21600", keychain_path]
280
+ )
281
+ subprocess.check_call(
282
+ ["security", "unlock-keychain", "-p", keychain_pass, keychain_path]
283
+ )
284
+ # Import the p12
285
+ subprocess.check_call(
286
+ [
287
+ "security",
288
+ "import",
289
+ str(p12_path),
290
+ "-P",
291
+ p12_pass,
292
+ "-A", # allow any app to read (simplest for CI)
293
+ "-t",
294
+ "cert",
295
+ "-f",
296
+ "pkcs12",
297
+ "-k",
298
+ keychain_path,
299
+ ]
300
+ )
301
+ # Allow codesign to use the key without prompts (modern macOS)
302
+ subprocess.check_call(
303
+ [
304
+ "security",
305
+ "set-key-partition-list",
306
+ "-S",
307
+ "apple-tool:,apple:,codesign:",
308
+ "-s",
309
+ "-k",
310
+ keychain_pass,
311
+ keychain_path,
312
+ ]
313
+ )
314
+ # Add to default search list (in addition to login + System)
315
+ existing = subprocess.check_output(
316
+ ["security", "list-keychains", "-d", "user"]
317
+ ).decode()
318
+ existing_list = [
319
+ line.strip().strip('"')
320
+ for line in existing.splitlines()
321
+ if line.strip()
322
+ ]
323
+ new_list = [keychain_path] + [
324
+ k for k in existing_list if k != keychain_path
325
+ ]
326
+ subprocess.check_call(
327
+ ["security", "list-keychains", "-d", "user", "-s", *new_list]
328
+ )
329
+ subprocess.check_call(
330
+ ["security", "default-keychain", "-s", keychain_path]
331
+ )
332
+ print(f"Keychain ready: {keychain_path}")
333
+ return keychain_path
334
+
335
+
336
+ def main() -> None:
337
+ key_id = env("ASC_KEY_ID")
338
+ issuer_id = env("ASC_ISSUER_ID")
339
+ asc_key_path = env("ASC_KEY_PATH")
340
+ bundle_id = env("BUNDLE_ID")
341
+ profile_name = env("PROFILE_NAME")
342
+ runner_temp = env("RUNNER_TEMP")
343
+
344
+ token = make_jwt(key_id, issuer_id, asc_key_path)
345
+
346
+ private_key, priv_pem, csr_b64 = generate_key_and_csr()
347
+ cert_id, cert_der = create_distribution_cert(token, csr_b64.decode())
348
+
349
+ bundle_pk = find_bundle_id(token, bundle_id)
350
+ delete_profile_by_name(token, profile_name)
351
+ profile_der = create_profile(token, profile_name, bundle_pk, cert_id)
352
+ install_profile(profile_der)
353
+
354
+ p12_pass = "ci"
355
+ p12_path = Path(runner_temp) / "cert.p12"
356
+ write_p12(private_key, cert_der, p12_path, p12_pass)
357
+ setup_keychain(p12_path, p12_pass)
358
+
359
+
360
+ if __name__ == "__main__":
361
+ main()