@daemux/store-automator 0.6.0 → 0.7.1

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.6.0"
8
+ "version": "0.7.1"
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.6.0",
15
+ "version": "0.7.1",
16
16
  "keywords": ["flutter", "app-store", "google-play", "fastlane", "codemagic"]
17
17
  }
18
18
  ]
package/README.md CHANGED
@@ -27,7 +27,7 @@ npm install @daemux/store-automator
27
27
 
28
28
  The postinstall script will:
29
29
 
30
- 1. Prompt for MCP server tokens (Stitch, Cloudflare, Codemagic)
30
+ 1. Prompt for bundle ID and MCP server tokens (Stitch, Cloudflare, Codemagic)
31
31
  2. Configure `.mcp.json` with MCP servers (Playwright, mobile-mcp, Stitch, Cloudflare, Codemagic)
32
32
  3. Install the plugin marketplace and register agents
33
33
  4. Copy `CLAUDE.md` template to `.claude/CLAUDE.md`
@@ -122,13 +122,14 @@ For CI/CD environments or scripted setups, pass tokens as CLI flags to skip inte
122
122
 
123
123
  ```bash
124
124
  npx @daemux/store-automator \
125
+ --bundle-id=com.company.app \
125
126
  --codemagic-token=YOUR_CM_TOKEN \
126
127
  --stitch-key=YOUR_STITCH_KEY \
127
128
  --cloudflare-token=YOUR_CF_TOKEN \
128
129
  --cloudflare-account-id=YOUR_CF_ACCOUNT_ID
129
130
  ```
130
131
 
131
- Any tokens provided via flags will skip the corresponding interactive prompt. If all four tokens are provided, the entire interactive session is skipped.
132
+ Any tokens provided via flags will skip the corresponding interactive prompt. If all four tokens are provided, the entire interactive session is skipped. The bundle ID, if provided, is automatically written to `bundle_id` and `package_name` in `ci.config.yaml`.
132
133
 
133
134
  ## CLI Options
134
135
 
@@ -142,6 +143,9 @@ Options:
142
143
  -v, --version Show version number
143
144
  -h, --help Show help
144
145
 
146
+ App Configuration:
147
+ --bundle-id=ID Bundle ID / Package Name (e.g., com.company.app)
148
+
145
149
  MCP Token Flags (skip interactive prompts):
146
150
  --codemagic-token=TOKEN Codemagic API token
147
151
  --stitch-key=KEY Stitch MCP API key
package/bin/cli.mjs CHANGED
@@ -27,11 +27,13 @@ function flagValue(arg, prefix) {
27
27
  return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
28
28
  }
29
29
 
30
- const tokenFlags = {
30
+ const valueFlags = {
31
31
  '--codemagic-token=': 'codemagicToken',
32
+ '--codemagic-team-id=': 'codemagicTeamId',
32
33
  '--stitch-key=': 'stitchApiKey',
33
34
  '--cloudflare-token=': 'cloudflareToken',
34
35
  '--cloudflare-account-id=': 'cloudflareAccountId',
36
+ '--bundle-id=': 'bundleId',
35
37
  };
36
38
 
37
39
  for (const arg of args) {
@@ -41,7 +43,7 @@ for (const arg of args) {
41
43
  if ((v = flagValue(arg, '--workflow=')) !== undefined) { cmWorkflowId = v; continue; }
42
44
 
43
45
  let matched = false;
44
- for (const [prefix, key] of Object.entries(tokenFlags)) {
46
+ for (const [prefix, key] of Object.entries(valueFlags)) {
45
47
  if ((v = flagValue(arg, prefix)) !== undefined) {
46
48
  cliTokens[key] = v;
47
49
  matched = true;
@@ -85,8 +87,12 @@ Options:
85
87
  -v, --version Show version number
86
88
  -h, --help Show help
87
89
 
90
+ App Configuration:
91
+ --bundle-id=ID Bundle ID / Package Name (e.g., com.company.app)
92
+
88
93
  MCP Token Flags (skip interactive prompts):
89
94
  --codemagic-token=TOKEN Codemagic API token
95
+ --codemagic-team-id=ID Codemagic Team ID (from Teams page)
90
96
  --stitch-key=KEY Stitch MCP API key
91
97
  --cloudflare-token=TOKEN Cloudflare API token
92
98
  --cloudflare-account-id=ID Cloudflare account ID
@@ -113,7 +119,7 @@ Examples:
113
119
  npx @daemux/store-automator --github-setup Configure GitHub Actions
114
120
 
115
121
  Non-interactive install (CI/CD):
116
- npx @daemux/store-automator --codemagic-token=TOKEN --stitch-key=KEY
122
+ npx @daemux/store-automator --bundle-id=com.company.app --codemagic-token=TOKEN --codemagic-team-id=ID --stitch-key=KEY
117
123
  npx @daemux/store-automator --cloudflare-token=TOKEN --cloudflare-account-id=ID`);
118
124
  process.exit(0);
119
125
  break; // eslint: no-fallthrough
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -44,7 +44,7 @@ The Codemagic pipeline runs two parallel workflows on push to `main`:
44
44
 
45
45
  ### Token Configuration
46
46
 
47
- The Codemagic API token is auto-configured via the codemagic MCP server in `.mcp.json` (set during install). You do not need to resolve the token manually -- all MCP tool calls authenticate automatically. If the codemagic MCP server is missing from `.mcp.json`, instruct the user to re-run `npx @daemux/store-automator` or add `--codemagic-token=TOKEN` to configure it.
47
+ The Codemagic API token and Team ID are auto-configured via the codemagic MCP server in `.mcp.json` (set during install). You do not need to resolve the token manually -- all MCP tool calls authenticate automatically. The `CODEMAGIC_TEAM_ID` env var enables default team resolution for team-scoped tools (`list_builds`, `get_team`, `list_team_members`, `create_variable_group`, `setup_asc_credentials`, `setup_code_signing`). If the codemagic MCP server is missing from `.mcp.json`, instruct the user to re-run `npx @daemux/store-automator` or add `--codemagic-token=TOKEN --codemagic-team-id=ID` to configure it.
48
48
 
49
49
  ### Triggering Builds
50
50
 
@@ -76,20 +76,20 @@ Build states: `queued` -> `preparing` -> `building` -> `testing` -> `publishing`
76
76
  | `start_build` | Start a new build |
77
77
  | `get_build` | Get details of a specific build |
78
78
  | `cancel_build` | Cancel a running build |
79
- | `list_builds` | List builds for a team (V3 API) |
79
+ | `list_builds` | List builds for a team (V3 API, uses default team if omitted) |
80
80
  | `get_artifact_url` | Get the download URL for a build artifact |
81
81
  | `create_public_artifact_url` | Create a time-limited public URL for an artifact |
82
82
  | `list_caches` | List build caches for an application |
83
83
  | `delete_caches` | Delete build caches for an application |
84
- | `setup_asc_credentials` | Create variable group with App Store Connect credentials |
85
- | `setup_code_signing` | Create variable group with iOS code signing cert and profile |
84
+ | `setup_asc_credentials` | Create variable group with ASC credentials (uses default team if omitted) |
85
+ | `setup_code_signing` | Create variable group with iOS signing (uses default team if omitted) |
86
86
  | `get_user` | Get the current authenticated user info |
87
87
  | `list_teams` | List all teams the user belongs to |
88
- | `get_team` | Get details of a specific team |
89
- | `list_team_members` | List members of a specific team |
88
+ | `get_team` | Get details of a specific team (uses default team if omitted) |
89
+ | `list_team_members` | List members of a team (uses default team if omitted) |
90
90
  | `list_variable_groups` | List variable groups for a team or application |
91
91
  | `get_variable_group` | Get details of a specific variable group |
92
- | `create_variable_group` | Create a new variable group |
92
+ | `create_variable_group` | Create a new variable group (uses default team if omitted) |
93
93
  | `update_variable_group` | Update a variable group name or security setting |
94
94
  | `delete_variable_group` | Delete a variable group |
95
95
  | `list_variables` | List variables in a variable group |
@@ -366,7 +366,7 @@ Analyze Firestore usage patterns and optimize queries, indexes, and security rul
366
366
 
367
367
  | File | Purpose |
368
368
  |------|---------|
369
- | `ci.config.yaml` | Single source of truth for all CI/CD config |
369
+ | `ci.config.yaml` | Single source of truth for all CI/CD config (includes team_id, app_id) |
370
370
  | `codemagic.yaml` | Generated from template -- do not edit directly |
371
371
  | `templates/codemagic.template.yaml` | Codemagic workflow template |
372
372
  | `scripts/generate.sh` | Generates codemagic.yaml from ci.config.yaml |
package/src/ci-config.mjs CHANGED
@@ -2,17 +2,24 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  const CI_CONFIG_FILE = 'ci.config.yaml';
5
- const APP_ID_PATTERN = /^(\s*app_id:\s*)"[^"]*"/m;
5
+ const FIELD_PATTERNS = {
6
+ app_id: /^(\s*app_id:\s*)"[^"]*"/m,
7
+ team_id: /^(\s*team_id:\s*)"[^"]*"/m,
8
+ bundle_id: /^(\s*bundle_id:\s*)"[^"]*"/m,
9
+ package_name: /^(\s*package_name:\s*)"[^"]*"/m,
10
+ };
6
11
 
7
- export function writeCiAppId(projectDir, appId) {
12
+ function writeCiField(projectDir, field, value) {
8
13
  const configPath = join(projectDir, CI_CONFIG_FILE);
9
14
  if (!existsSync(configPath)) return false;
10
15
 
11
16
  try {
17
+ const pattern = FIELD_PATTERNS[field];
12
18
  const content = readFileSync(configPath, 'utf8');
13
- if (!APP_ID_PATTERN.test(content)) return false;
19
+ if (!pattern.test(content)) return false;
14
20
 
15
- const updated = content.replace(APP_ID_PATTERN, `$1"${appId}"`);
21
+ const safeValue = value.replace(/\$/g, '$$$$');
22
+ const updated = content.replace(pattern, `$1"${safeValue}"`);
16
23
  if (updated === content) return false;
17
24
 
18
25
  writeFileSync(configPath, updated, 'utf8');
@@ -21,3 +28,19 @@ export function writeCiAppId(projectDir, appId) {
21
28
  return false;
22
29
  }
23
30
  }
31
+
32
+ export function writeCiAppId(projectDir, appId) {
33
+ return writeCiField(projectDir, 'app_id', appId);
34
+ }
35
+
36
+ export function writeCiTeamId(projectDir, teamId) {
37
+ return writeCiField(projectDir, 'team_id', teamId);
38
+ }
39
+
40
+ export function writeCiBundleId(projectDir, bundleId) {
41
+ return writeCiField(projectDir, 'bundle_id', bundleId);
42
+ }
43
+
44
+ export function writeCiPackageName(projectDir, packageName) {
45
+ return writeCiField(projectDir, 'package_name', packageName);
46
+ }
@@ -50,8 +50,10 @@ export async function findAppByRepo(token, repoUrl) {
50
50
  }) || null;
51
51
  }
52
52
 
53
- export async function addApp(token, repoUrl) {
54
- return cmFetch(token, 'POST', '/apps', { repositoryUrl: repoUrl });
53
+ export async function addApp(token, repoUrl, teamId) {
54
+ const body = { repositoryUrl: repoUrl };
55
+ if (teamId) body.teamId = teamId;
56
+ return cmFetch(token, 'POST', '/apps', body);
55
57
  }
56
58
 
57
59
  export async function startBuild(token, appId, workflowId, branch) {
@@ -1,8 +1,10 @@
1
1
  import { findAppByRepo, addApp, startBuild, getBuildStatus, normalizeRepoUrl } from './codemagic-api.mjs';
2
2
  import { exec, resolveToken } from './utils.mjs';
3
3
  import { execFileSync } from 'child_process';
4
- import { writeCiAppId } from './ci-config.mjs';
5
- import { updateMcpAppId } from './mcp-setup.mjs';
4
+ import { readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { writeCiAppId, writeCiTeamId } from './ci-config.mjs';
7
+ import { updateMcpAppId, updateMcpTeamId } from './mcp-setup.mjs';
6
8
 
7
9
  const POLL_INTERVAL_MS = 30_000;
8
10
  const POLL_TIMEOUT_MS = 15 * 60 * 1000;
@@ -94,6 +96,24 @@ async function pollBuildStatus(token, buildId) {
94
96
  return 'timeout';
95
97
  }
96
98
 
99
+ function resolveCliTeamId() {
100
+ const prefix = '--codemagic-team-id=';
101
+ for (const arg of process.argv) {
102
+ if (arg.startsWith(prefix)) return arg.slice(prefix.length);
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function readCiTeamId() {
108
+ try {
109
+ const content = readFileSync(join(process.cwd(), 'ci.config.yaml'), 'utf8');
110
+ const match = content.match(/^\s*team_id:\s*"([^"]+)"/m);
111
+ return match ? match[1] : undefined;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
97
117
  export async function runCodemagicSetup(options) {
98
118
  const {
99
119
  tokenArg = '',
@@ -105,8 +125,10 @@ export async function runCodemagicSetup(options) {
105
125
 
106
126
  const token = resolveToken(tokenArg);
107
127
  const repoUrl = resolveRepoUrl();
128
+ const teamId = resolveCliTeamId() || readCiTeamId();
108
129
 
109
130
  console.log(`Repository: ${repoUrl}`);
131
+ if (teamId) console.log(`Team ID: ${teamId}`);
110
132
  console.log('Checking Codemagic for existing app...');
111
133
 
112
134
  let app = await findAppByRepo(token, repoUrl);
@@ -115,7 +137,7 @@ export async function runCodemagicSetup(options) {
115
137
  console.log(`App already registered: ${app.appName || app._id}`);
116
138
  } else {
117
139
  console.log('App not found. Adding to Codemagic...');
118
- app = await addApp(token, repoUrl);
140
+ app = await addApp(token, repoUrl, teamId);
119
141
  console.log(`App added: ${app.appName || app._id}`);
120
142
 
121
143
  console.log('Setting up GitHub webhook...');
@@ -126,6 +148,11 @@ export async function runCodemagicSetup(options) {
126
148
  const appIdWritten = writeCiAppId(process.cwd(), appId);
127
149
  updateMcpAppId(process.cwd(), appId);
128
150
 
151
+ if (teamId) {
152
+ writeCiTeamId(process.cwd(), teamId);
153
+ updateMcpTeamId(process.cwd(), teamId);
154
+ }
155
+
129
156
  if (!trigger) {
130
157
  console.log('\nSetup complete. Use --trigger to start a build.\n');
131
158
  if (appIdWritten) {
package/src/install.mjs CHANGED
@@ -9,10 +9,10 @@ import {
9
9
  } from './utils.mjs';
10
10
  import { injectEnvVars, injectStatusLine } from './settings.mjs';
11
11
  import { promptForTokens } from './prompt.mjs';
12
- import { getMcpServers, writeMcpJson, updateMcpAppId } from './mcp-setup.mjs';
12
+ import { getMcpServers, writeMcpJson, updateMcpAppId, updateMcpTeamId } from './mcp-setup.mjs';
13
13
  import { installClaudeMd, installCiTemplates, installFirebaseTemplates } from './templates.mjs';
14
14
  import { findAppByRepo, addApp, normalizeRepoUrl } from './codemagic-api.mjs';
15
- import { writeCiAppId } from './ci-config.mjs';
15
+ import { writeCiAppId, writeCiTeamId, writeCiBundleId, writeCiPackageName } from './ci-config.mjs';
16
16
 
17
17
  function checkClaudeCli() {
18
18
  const result = exec('command -v claude') || exec('which claude');
@@ -112,7 +112,7 @@ function setupGitHubActions(codemagicToken) {
112
112
  }
113
113
  }
114
114
 
115
- async function setupCodemagicApp(projectDir, codemagicToken) {
115
+ async function setupCodemagicApp(projectDir, codemagicToken, codemagicTeamId) {
116
116
  if (!codemagicToken) return;
117
117
 
118
118
  let repoUrl;
@@ -130,7 +130,7 @@ async function setupCodemagicApp(projectDir, codemagicToken) {
130
130
  try {
131
131
  let app = await findAppByRepo(codemagicToken, repoUrl);
132
132
  if (!app) {
133
- app = await addApp(codemagicToken, repoUrl);
133
+ app = await addApp(codemagicToken, repoUrl, codemagicTeamId);
134
134
  console.log(`Codemagic app created: ${app.appName || app._id}`);
135
135
  } else {
136
136
  console.log(`Codemagic app found: ${app.appName || app._id}`);
@@ -142,6 +142,11 @@ async function setupCodemagicApp(projectDir, codemagicToken) {
142
142
  }
143
143
 
144
144
  updateMcpAppId(projectDir, app._id);
145
+
146
+ if (codemagicTeamId) {
147
+ writeCiTeamId(projectDir, codemagicTeamId);
148
+ updateMcpTeamId(projectDir, codemagicTeamId);
149
+ }
145
150
  } catch (err) {
146
151
  console.log(`Codemagic auto-setup skipped: ${err.message || err}`);
147
152
  }
@@ -178,7 +183,13 @@ export async function runInstall(scope, isPostinstall = false, cliTokens = {}) {
178
183
  installCiTemplates(projectDir, packageDir);
179
184
  installFirebaseTemplates(projectDir, packageDir);
180
185
 
181
- await setupCodemagicApp(projectDir, tokens.codemagicToken);
186
+ if (tokens.bundleId) {
187
+ const written = writeCiBundleId(projectDir, tokens.bundleId);
188
+ if (written) console.log(`Bundle ID set in ci.config.yaml: ${tokens.bundleId}`);
189
+ writeCiPackageName(projectDir, tokens.bundleId);
190
+ }
191
+
192
+ await setupCodemagicApp(projectDir, tokens.codemagicToken, tokens.codemagicTeamId);
182
193
 
183
194
  const scopeLabel = scope === 'user' ? 'global' : 'project';
184
195
  console.log(`Configuring ${scopeLabel} settings...`);
package/src/mcp-setup.mjs CHANGED
@@ -34,6 +34,7 @@ export function getMcpServers(tokens) {
34
34
 
35
35
  if (tokens.codemagicToken) {
36
36
  const codemagicEnv = { CODEMAGIC_API_TOKEN: tokens.codemagicToken };
37
+ if (tokens.codemagicTeamId) codemagicEnv.CODEMAGIC_TEAM_ID = tokens.codemagicTeamId;
37
38
  if (tokens.codemagicAppId) codemagicEnv.CODEMAGIC_APP_ID = tokens.codemagicAppId;
38
39
  servers.codemagic = {
39
40
  command: 'npx',
@@ -77,14 +78,14 @@ export function writeMcpJson(projectDir, servers) {
77
78
  }
78
79
  }
79
80
 
80
- export function updateMcpAppId(projectDir, appId) {
81
+ function updateMcpEnvVar(projectDir, envKey, value) {
81
82
  const mcpPath = join(projectDir, '.mcp.json');
82
83
  if (!existsSync(mcpPath)) return false;
83
84
 
84
85
  try {
85
86
  const data = readJson(mcpPath);
86
87
  if (!data.mcpServers?.codemagic?.env) return false;
87
- data.mcpServers.codemagic.env.CODEMAGIC_APP_ID = appId;
88
+ data.mcpServers.codemagic.env[envKey] = value;
88
89
  writeJson(mcpPath, data);
89
90
  return true;
90
91
  } catch {
@@ -92,6 +93,14 @@ export function updateMcpAppId(projectDir, appId) {
92
93
  }
93
94
  }
94
95
 
96
+ export function updateMcpAppId(projectDir, appId) {
97
+ return updateMcpEnvVar(projectDir, 'CODEMAGIC_APP_ID', appId);
98
+ }
99
+
100
+ export function updateMcpTeamId(projectDir, teamId) {
101
+ return updateMcpEnvVar(projectDir, 'CODEMAGIC_TEAM_ID', teamId);
102
+ }
103
+
95
104
  export function removeMcpServers(projectDir) {
96
105
  const mcpPath = join(projectDir, '.mcp.json');
97
106
  if (!existsSync(mcpPath)) return;
package/src/prompt.mjs CHANGED
@@ -17,26 +17,36 @@ function allTokensProvided(cliTokens) {
17
17
  cliTokens.stitchApiKey !== undefined &&
18
18
  cliTokens.cloudflareToken !== undefined &&
19
19
  cliTokens.cloudflareAccountId !== undefined &&
20
- cliTokens.codemagicToken !== undefined
20
+ cliTokens.codemagicToken !== undefined &&
21
+ cliTokens.codemagicTeamId !== undefined
22
+ );
23
+ }
24
+
25
+ function allPromptsProvided(cliTokens) {
26
+ return (
27
+ cliTokens.bundleId !== undefined &&
28
+ allTokensProvided(cliTokens)
21
29
  );
22
30
  }
23
31
 
24
32
  export async function promptForTokens(cliTokens = {}) {
25
33
  const result = {
34
+ bundleId: cliTokens.bundleId ?? '',
26
35
  stitchApiKey: cliTokens.stitchApiKey ?? '',
27
36
  cloudflareToken: cliTokens.cloudflareToken ?? '',
28
37
  cloudflareAccountId: cliTokens.cloudflareAccountId ?? '',
29
38
  codemagicToken: cliTokens.codemagicToken ?? '',
39
+ codemagicTeamId: cliTokens.codemagicTeamId ?? '',
30
40
  };
31
41
 
32
- if (allTokensProvided(cliTokens)) {
33
- console.log('All MCP tokens provided via CLI flags, skipping prompts.');
42
+ if (allPromptsProvided(cliTokens)) {
43
+ console.log('All configuration provided via CLI flags, skipping prompts.');
34
44
  return result;
35
45
  }
36
46
 
37
47
  if (!isInteractive()) {
38
- console.log('Non-interactive terminal detected, skipping token prompts.');
39
- console.log('Run "npx store-automator" manually to configure MCP tokens.');
48
+ console.log('Non-interactive terminal detected, skipping prompts.');
49
+ console.log('Run "npx store-automator" manually to configure.');
40
50
  return result;
41
51
  }
42
52
 
@@ -45,12 +55,28 @@ export async function promptForTokens(cliTokens = {}) {
45
55
  output: process.stdout,
46
56
  });
47
57
 
48
- console.log('');
49
- console.log('MCP Server Configuration');
50
- console.log('Press Enter to skip any token you do not have yet.');
51
58
  console.log('');
52
59
 
53
60
  try {
61
+ if (cliTokens.bundleId === undefined) {
62
+ console.log('App Configuration');
63
+ console.log('');
64
+ result.bundleId = await ask(
65
+ rl,
66
+ 'Bundle ID / Package Name (e.g., com.company.app): '
67
+ );
68
+ console.log('');
69
+ }
70
+
71
+ if (allTokensProvided(cliTokens)) {
72
+ console.log('All MCP tokens provided via CLI flags.');
73
+ return result;
74
+ }
75
+
76
+ console.log('MCP Server Configuration');
77
+ console.log('Press Enter to skip any token you do not have yet.');
78
+ console.log('');
79
+
54
80
  if (cliTokens.stitchApiKey === undefined) {
55
81
  result.stitchApiKey = await ask(
56
82
  rl,
@@ -79,6 +105,13 @@ export async function promptForTokens(cliTokens = {}) {
79
105
  );
80
106
  }
81
107
 
108
+ if (result.codemagicToken && cliTokens.codemagicTeamId === undefined) {
109
+ result.codemagicTeamId = await ask(
110
+ rl,
111
+ 'Codemagic Team ID (optional, from Teams page): '
112
+ );
113
+ }
114
+
82
115
  return result;
83
116
  } finally {
84
117
  rl.close();
@@ -59,6 +59,7 @@ web:
59
59
  # Find app_id in your Codemagic dashboard URL: codemagic.io/app/{app_id}
60
60
  # API token is stored in .mcp.json (codemagic MCP server, set during install)
61
61
  codemagic:
62
+ team_id: "" # Team ID from Codemagic Teams page
62
63
  app_id: ""
63
64
  workflows:
64
65
  - ios-release
@@ -19,14 +19,12 @@ workflows:
19
19
  PRICE_TIER: "${PRICE_TIER}"
20
20
  SUBMIT_FOR_REVIEW: "${SUBMIT_FOR_REVIEW}"
21
21
  AUTOMATIC_RELEASE: "${AUTOMATIC_RELEASE}"
22
- ios_signing:
23
- distribution_type: app_store
24
- bundle_identifier: "${BUNDLE_ID}"
22
+ FASTLANE_ENABLE_BETA_DELIVER_SYNC_SCREENSHOTS: "1"
23
+ APP_ROOT: "${APP_ROOT}"
25
24
  cache:
26
25
  cache_paths:
27
- - $HOME/.codemagic_keys
28
26
  - $HOME/.gem
29
- - ios/vendor/bundle
27
+ - ${APP_ROOT}/ios/vendor/bundle
30
28
  triggering:
31
29
  events:
32
30
  - push
@@ -34,15 +32,20 @@ workflows:
34
32
  - pattern: main
35
33
  include: true
36
34
  scripts:
35
+ - name: Link Fastlane directories
36
+ script: |
37
+ ln -sfn "$CM_BUILD_DIR/fastlane/ios" "$CM_BUILD_DIR/$APP_ROOT/ios/fastlane"
38
+ ln -sfn "$CM_BUILD_DIR/fastlane" "$CM_BUILD_DIR/$APP_ROOT/fastlane"
39
+
37
40
  - name: Ensure CERTIFICATE_PRIVATE_KEY
38
41
  script: |
39
- KEY_FILE="$HOME/.codemagic_keys/ios_dist_private_key"
40
- mkdir -p "$HOME/.codemagic_keys"
42
+ KEY_FILE="$CM_BUILD_DIR/creds/ios_dist_private_key"
41
43
  if [ -f "$KEY_FILE" ]; then
42
- echo "Reusing cached CERTIFICATE_PRIVATE_KEY"
44
+ echo "Using CERTIFICATE_PRIVATE_KEY from repo creds/"
43
45
  else
44
- echo "Generating new CERTIFICATE_PRIVATE_KEY"
45
- ssh-keygen -t rsa -b 2048 -m PEM -f "$KEY_FILE" -q -N ""
46
+ echo "ERROR: creds/ios_dist_private_key not found in repo."
47
+ echo "Generate it with: ssh-keygen -t rsa -b 2048 -m PEM -f creds/ios_dist_private_key -q -N ''"
48
+ exit 1
46
49
  fi
47
50
  echo "CERTIFICATE_PRIVATE_KEY<<DELIMITER" >> $CM_ENV
48
51
  cat "$KEY_FILE" >> $CM_ENV
@@ -57,17 +60,141 @@ workflows:
57
60
  cat "$CM_BUILD_DIR/$P8_KEY_PATH" >> $CM_ENV
58
61
  echo "" >> $CM_ENV
59
62
  echo "KEYDELIMITER" >> $CM_ENV
63
+ # Write P8 key to temp file once for all Fastlane steps
64
+ P8_TMP="/tmp/fastlane_api_key.p8"
65
+ cat "$CM_BUILD_DIR/$P8_KEY_PATH" > "$P8_TMP"
66
+ echo "FASTLANE_API_KEY_PATH=$P8_TMP" >> $CM_ENV
67
+
68
+ - name: Install Fastlane
69
+ script: |
70
+ cd $CM_BUILD_DIR/$APP_ROOT/ios
71
+ gem install bundler
72
+ bundle install
73
+
74
+ - name: Ensure app record exists
75
+ script: |
76
+ echo "=== Checking if $BUNDLE_ID exists in App Store Connect ==="
77
+
78
+ # First try: list apps filtering by bundle ID
79
+ echo "--- Running: app-store-connect apps list --bundle-id-identifier $BUNDLE_ID ---"
80
+ APP_JSON=$(app-store-connect apps list \
81
+ --bundle-id-identifier "$BUNDLE_ID" \
82
+ --strict-match-identifier \
83
+ --json 2>/tmp/asc_apps_err.log || true)
84
+
85
+ echo "Apps list stderr:"
86
+ cat /tmp/asc_apps_err.log 2>/dev/null || true
87
+ echo "Apps list stdout (first 500 chars):"
88
+ echo "$APP_JSON" | head -c 500
89
+
90
+ EXISTING=$(echo "$APP_JSON" | python3 -c "import sys,json; data=json.load(sys.stdin); apps=data if isinstance(data,list) else []; print('yes' if len(apps)>0 else 'no')" 2>/dev/null || echo "no")
91
+
92
+ echo "App exists result: $EXISTING"
93
+
94
+ if [ "$EXISTING" = "yes" ]; then
95
+ echo "App record found for $BUNDLE_ID"
96
+ else
97
+ echo ""
98
+ echo "=========================================="
99
+ echo "BUILD STOPPED - APP RECORD NOT DETECTED"
100
+ echo "=========================================="
101
+ echo ""
102
+ echo "The app-store-connect CLI could not find $BUNDLE_ID."
103
+ echo "If you already created the app in App Store Connect,"
104
+ echo "this may be an API propagation delay or permission issue."
105
+ echo ""
106
+ echo "Retrying with verbose output..."
107
+ app-store-connect apps list --json 2>&1 | python3 -c "import sys,json;data=json.load(sys.stdin);apps=data if isinstance(data,list) else [];print(f'Total apps: {len(apps)}');[print(f' App: {a.get(\"attributes\",{}).get(\"name\",\"?\")} | Bundle: {a.get(\"attributes\",{}).get(\"bundleId\",\"?\")} | ID: {a.get(\"id\",\"?\")}') for a in apps[:10]]" 2>&1 || true
108
+ exit 1
109
+ fi
110
+
111
+ - name: Upload iOS metadata and screenshots
112
+ script: |
113
+ cd $CM_BUILD_DIR/$APP_ROOT/ios
114
+ bundle exec fastlane upload_metadata_ios
115
+
116
+ - name: Sync IAP and subscriptions
117
+ script: |
118
+ FORCE_FLAG=""
119
+ if [ ! -f "$CM_BUILD_DIR/.codemagic/ios_iap_synced" ]; then
120
+ FORCE_FLAG="--force"
121
+ echo "First IAP sync detected - forcing upload"
122
+ fi
123
+ if ./scripts/check_changed.sh $FORCE_FLAG fastlane/iap_config.json; then
124
+ cd $CM_BUILD_DIR/$APP_ROOT/ios
125
+ bundle exec fastlane sync_iap
126
+ # Create marker after successful sync
127
+ if [ -n "$FORCE_FLAG" ]; then
128
+ mkdir -p "$CM_BUILD_DIR/.codemagic"
129
+ touch "$CM_BUILD_DIR/.codemagic/ios_iap_synced"
130
+ cd "$CM_BUILD_DIR"
131
+ git add .codemagic/ios_iap_synced
132
+ git commit -m "chore: mark iOS IAP sync complete [skip ci]" || true
133
+ git push origin HEAD || true
134
+ echo "iOS IAP sync marker committed"
135
+ fi
136
+ else
137
+ echo "IAP config unchanged - skipping"
138
+ fi
60
139
 
61
140
  - name: Set up iOS code signing
62
141
  script: |
63
- app-store-connect fetch-signing-files "$BUNDLE_ID" \
64
- --type IOS_APP_STORE --create
65
142
  keychain initialize
66
- app-store-connect certificates list \
67
- --type IOS_DISTRIBUTION \
68
- --certificate-key=@env:CERTIFICATE_PRIVATE_KEY --save
143
+
144
+ # Attempt to fetch or create signing files.
145
+ if app-store-connect fetch-signing-files "$BUNDLE_ID" \
146
+ --type IOS_APP_STORE \
147
+ --certificate-key=@env:CERTIFICATE_PRIVATE_KEY \
148
+ --create 2>/tmp/signing_err.log; then
149
+ echo "Signing files fetched successfully"
150
+ else
151
+ cat /tmp/signing_err.log
152
+ echo ""
153
+ echo "Signing failed. Deleting ALL distribution certs..."
154
+
155
+ for CERT_TYPE in IOS_DISTRIBUTION DISTRIBUTION; do
156
+ CERT_IDS=$(app-store-connect certificates list \
157
+ --type "$CERT_TYPE" --json 2>/dev/null \
158
+ | python3 -c "
159
+ import sys, json
160
+ certs = json.load(sys.stdin)
161
+ for c in certs:
162
+ print(c.get('id', ''))
163
+ " 2>/dev/null || true)
164
+
165
+ for CID in $CERT_IDS; do
166
+ if [ -n "$CID" ]; then
167
+ echo "Deleting $CERT_TYPE cert: $CID"
168
+ app-store-connect certificates delete "$CID" || true
169
+ fi
170
+ done
171
+ done
172
+
173
+ echo "Waiting 15s for Apple API propagation..."
174
+ sleep 15
175
+
176
+ # Verify certs are gone
177
+ REMAINING=$(app-store-connect certificates list \
178
+ --type DISTRIBUTION --json 2>/dev/null \
179
+ | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null || echo "?")
180
+ echo "Remaining DISTRIBUTION certs: $REMAINING"
181
+
182
+ echo "Retrying fetch-signing-files..."
183
+ app-store-connect fetch-signing-files "$BUNDLE_ID" \
184
+ --type IOS_APP_STORE \
185
+ --certificate-key=@env:CERTIFICATE_PRIVATE_KEY \
186
+ --create
187
+ fi
188
+
189
+ # Verify signing artifacts exist
190
+ P12_COUNT=$(ls /Users/builder/Library/MobileDevice/Certificates/*.p12 2>/dev/null | wc -l)
191
+ if [ "$P12_COUNT" -eq 0 ]; then
192
+ echo "ERROR: No .p12 certificates found after signing setup"
193
+ exit 1
194
+ fi
195
+
69
196
  keychain add-certificates
70
- xcode-project use-profiles
197
+ xcode-project use-profiles --project $APP_ROOT/ios/Runner.xcodeproj
71
198
 
72
199
  - name: Manage iOS version
73
200
  script: |
@@ -84,50 +211,29 @@ workflows:
84
211
  - name: Set Flutter version
85
212
  script: |
86
213
  BUILD_NUMBER=$(($(app-store-connect get-latest-app-store-build-number "$BUNDLE_ID" 2>/dev/null || echo "0") + 1))
87
- if [ -z "$APP_VERSION" ] || [ "$APP_VERSION" = "" ]; then
214
+ if [ -z "$APP_VERSION" ]; then
88
215
  APP_VERSION="1.0.0"
89
216
  fi
90
- sed -i '' "s/^version:.*/version: ${APP_VERSION}+${BUILD_NUMBER}/" pubspec.yaml
217
+ # Normalize to semver (1.0 -> 1.0.0, 1 -> 1.0.0)
218
+ PARTS=$(echo "$APP_VERSION" | tr '.' '\n' | wc -l | tr -d ' ')
219
+ if [ "$PARTS" -eq 1 ]; then APP_VERSION="${APP_VERSION}.0.0"; fi
220
+ if [ "$PARTS" -eq 2 ]; then APP_VERSION="${APP_VERSION}.0"; fi
221
+ sed -i '' "s/^version:.*/version: ${APP_VERSION}+${BUILD_NUMBER}/" $APP_ROOT/pubspec.yaml
91
222
  echo "Building: $APP_VERSION+$BUILD_NUMBER"
92
223
 
93
224
  - name: Flutter packages
94
- script: flutter pub get
225
+ script: cd $CM_BUILD_DIR/$APP_ROOT && flutter pub get
95
226
 
96
227
  - name: Build iOS
97
- script: flutter build ipa --release --export-options-plist=/tmp/export.plist
98
-
99
- - name: Install Fastlane
100
- script: |
101
- cd ios
102
- gem install bundler
103
- bundle install
104
-
105
- - name: Create app record (idempotent)
106
- script: |
107
- cd ios
108
- bundle exec fastlane produce create \
109
- -u "$APPLE_ID" \
110
- -a "$BUNDLE_ID" \
111
- --app_name "$APP_NAME" \
112
- --sku "$SKU" \
113
- || true
114
-
115
- - name: Deploy to App Store
116
- script: |
117
- cd ios
118
- bundle exec fastlane deploy_ios
228
+ script: cd $CM_BUILD_DIR/$APP_ROOT && flutter build ipa --release --export-options-plist=/Users/builder/export_options.plist
119
229
 
120
- - name: Sync IAP and subscriptions
230
+ - name: Upload IPA to App Store
121
231
  script: |
122
- if ./scripts/check_changed.sh fastlane/iap_config.json; then
123
- cd ios
124
- bundle exec fastlane sync_iap
125
- else
126
- echo "IAP config unchanged - skipping"
127
- fi
232
+ cd $CM_BUILD_DIR/$APP_ROOT/ios
233
+ bundle exec fastlane upload_binary_ios
128
234
 
129
235
  artifacts:
130
- - build/ios/ipa/*.ipa
236
+ - ${APP_ROOT}/build/ios/ipa/*.ipa
131
237
  - /tmp/xcodebuild_logs/*.log
132
238
 
133
239
  android-release:
@@ -136,6 +242,9 @@ workflows:
136
242
  instance_type: mac_mini_m4
137
243
  environment:
138
244
  flutter: stable
245
+ groups:
246
+ - google_play_credentials
247
+ - android_keystore
139
248
  vars:
140
249
  PACKAGE_NAME: "${PACKAGE_NAME}"
141
250
  APP_NAME: "${APP_NAME}"
@@ -148,11 +257,12 @@ workflows:
148
257
  APPLE_KEY_ID: "${APPLE_KEY_ID}"
149
258
  APPLE_ISSUER_ID: "${APPLE_ISSUER_ID}"
150
259
  P8_KEY_PATH: "${P8_KEY_PATH}"
260
+ APP_ROOT: "${APP_ROOT}"
151
261
  cache:
152
262
  cache_paths:
153
263
  - $HOME/.gem
154
264
  - $HOME/.gradle/caches
155
- - android/vendor/bundle
265
+ - ${APP_ROOT}/android/vendor/bundle
156
266
  triggering:
157
267
  events:
158
268
  - push
@@ -160,11 +270,21 @@ workflows:
160
270
  - pattern: main
161
271
  include: true
162
272
  scripts:
273
+ - name: Link Fastlane directories
274
+ script: |
275
+ ln -sfn "$CM_BUILD_DIR/fastlane/android" "$CM_BUILD_DIR/$APP_ROOT/android/fastlane"
276
+ ln -sfn "$CM_BUILD_DIR/fastlane" "$CM_BUILD_DIR/$APP_ROOT/fastlane"
277
+
163
278
  - name: Ensure Android upload keystore
164
279
  script: |
165
- KEYSTORE_PATH="$CM_BUILD_DIR/android/upload.keystore"
280
+ KEYSTORE_PATH="$CM_BUILD_DIR/$APP_ROOT/android/upload.keystore"
281
+ CREDS_PATH="$CM_BUILD_DIR/creds/android_upload.keystore"
282
+ GENERATED=false
166
283
  if [ -f "$KEYSTORE_PATH" ]; then
167
284
  echo "Using existing upload keystore from repo"
285
+ elif [ -f "$CREDS_PATH" ]; then
286
+ echo "Restoring upload keystore from creds/"
287
+ cp "$CREDS_PATH" "$KEYSTORE_PATH"
168
288
  else
169
289
  echo "Generating new upload keystore..."
170
290
  keytool -genkey -v \
@@ -177,8 +297,18 @@ workflows:
177
297
  -storepass "$KEYSTORE_PASSWORD" \
178
298
  -keypass "$KEYSTORE_PASSWORD" \
179
299
  -dname "CN=Upload Key, O=Developer, C=US"
300
+ GENERATED=true
301
+ fi
302
+ # Always ensure creds/ backup exists
303
+ mkdir -p "$CM_BUILD_DIR/creds"
304
+ if [ ! -f "$CREDS_PATH" ] || ! cmp -s "$KEYSTORE_PATH" "$CREDS_PATH"; then
305
+ cp "$KEYSTORE_PATH" "$CREDS_PATH"
306
+ echo "Keystore backed up to creds/android_upload.keystore"
307
+ fi
308
+ # Commit keystore files if newly generated
309
+ if [ "$GENERATED" = "true" ]; then
180
310
  cd "$CM_BUILD_DIR"
181
- git add android/upload.keystore
311
+ git add --force $APP_ROOT/android/upload.keystore creds/android_upload.keystore
182
312
  git commit -m "chore: add Android upload keystore [skip ci]"
183
313
  git push origin HEAD
184
314
  echo "Upload keystore generated and committed to repo"
@@ -194,27 +324,124 @@ workflows:
194
324
  pip3 install PyJWT cryptography requests
195
325
  export SA_JSON="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
196
326
  export PACKAGE_NAME="$PACKAGE_NAME"
197
- RESULT=$(python3 scripts/check_google_play.py)
198
- READY=$(echo "$RESULT" | python3 -c "import sys,json; print(str(json.load(sys.stdin)['ready']).lower())")
327
+ RESULT=$(python3 scripts/check_google_play.py 2>/dev/null || echo '{"ready":false}')
328
+ READY=$(echo "$RESULT" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('ready',False)).lower())" 2>/dev/null || echo "false")
199
329
  if [ "$READY" != "true" ]; then
200
330
  echo "GOOGLE_PLAY_READY=false" >> $CM_ENV
201
- MISSING_STEPS=$(echo "$RESULT" | python3 -c "import sys,json; print('\n'.join(json.load(sys.stdin)['missing_steps']))")
202
- cat > $CM_BUILD_DIR/HOW_TO_GOOGLE_PLAY.md << GUIDE
203
- # Google Play Setup - Remaining Manual Steps
204
-
205
- Your Android AAB is in the build artifacts. Complete these steps, then push again:
206
-
207
- $MISSING_STEPS
208
-
209
- ## After completing all steps:
210
- Just \`git push\` again - Codemagic will publish automatically.
211
- GUIDE
331
+ printf '%s\n' \
332
+ "# Google Play Setup - Manual Steps Required" \
333
+ "" \
334
+ "Your Android AAB is available in the build artifacts above." \
335
+ "" \
336
+ "## Steps to complete" \
337
+ "" \
338
+ "1. **Go to Google Play Console** - https://play.google.com/console" \
339
+ "2. **Create your app** (if not already created) with the correct package name" \
340
+ "3. **Upload the AAB** from build artifacts to an internal testing track" \
341
+ "4. **Complete the Store Listing** - Add title, descriptions, screenshots, and app icon" \
342
+ "5. **Complete the Content Rating** questionnaire" \
343
+ "6. **Set up Pricing and Distribution**" \
344
+ "7. **Complete the Data Safety** form" \
345
+ "8. **Review and roll out** the internal testing release" \
346
+ "" \
347
+ "## After completing all steps" \
348
+ "Just git push again - Codemagic will publish automatically on subsequent builds." \
349
+ > "$CM_BUILD_DIR/HOW_TO_GOOGLE_PLAY.md"
212
350
  echo "Google Play not ready - see HOW_TO_GOOGLE_PLAY.md in artifacts"
213
351
  else
214
352
  echo "GOOGLE_PLAY_READY=true" >> $CM_ENV
353
+ # Detect first store sync: if marker file doesn't exist, force IAP/data safety upload
354
+ if [ ! -f "$CM_BUILD_DIR/.codemagic/android_store_synced" ]; then
355
+ echo "FIRST_STORE_SYNC=true" >> $CM_ENV
356
+ echo "First store sync detected - IAP and data safety will be force-uploaded"
357
+ else
358
+ echo "FIRST_STORE_SYNC=false" >> $CM_ENV
359
+ echo "Marker found - using change detection for IAP/data safety"
360
+ fi
215
361
  echo "Google Play ready for automated publishing"
216
362
  fi
217
363
 
364
+ - name: Install Fastlane
365
+ script: |
366
+ cd $CM_BUILD_DIR/$APP_ROOT/android
367
+ gem install bundler
368
+ bundle install
369
+
370
+ - name: Upload Android metadata and screenshots
371
+ script: |
372
+ if [ "$GOOGLE_PLAY_READY" != "true" ]; then
373
+ echo "WARNING: Google Play not ready - skipping metadata upload."
374
+ echo "First AAB must be uploaded manually before metadata can sync."
375
+ exit 0
376
+ fi
377
+ export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
378
+ cd $CM_BUILD_DIR/$APP_ROOT/android
379
+ if bundle exec fastlane upload_metadata_android; then
380
+ echo "Metadata uploaded successfully"
381
+ else
382
+ echo "WARNING: Metadata upload failed (app may still be in draft state)."
383
+ echo "Metadata will sync on next build after app leaves draft."
384
+ echo "Continuing build..."
385
+ fi
386
+
387
+ - name: Sync subscriptions and IAP
388
+ script: |
389
+ if [ "$GOOGLE_PLAY_READY" != "true" ]; then
390
+ echo "WARNING: Google Play not ready - skipping IAP sync."
391
+ exit 0
392
+ fi
393
+ FORCE_FLAG=""
394
+ if [ "${FIRST_STORE_SYNC:-false}" = "true" ]; then
395
+ FORCE_FLAG="--force"
396
+ fi
397
+ if ./scripts/check_changed.sh $FORCE_FLAG fastlane/iap_config.json; then
398
+ export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
399
+ cd $CM_BUILD_DIR/$APP_ROOT/android
400
+ if bundle exec fastlane sync_google_iap 2>&1; then
401
+ echo "IAP sync completed successfully"
402
+ else
403
+ echo "WARNING: IAP sync failed (fastlane-plugin-iap may not be available yet)."
404
+ echo "IAP products can be configured manually in Google Play Console."
405
+ echo "Continuing build..."
406
+ fi
407
+ else
408
+ echo "IAP config unchanged - skipping"
409
+ fi
410
+
411
+ - name: Update data safety form
412
+ script: |
413
+ if [ "$GOOGLE_PLAY_READY" != "true" ]; then
414
+ echo "WARNING: Google Play not ready - skipping data safety update."
415
+ exit 0
416
+ fi
417
+ FORCE_FLAG=""
418
+ if [ "${FIRST_STORE_SYNC:-false}" = "true" ]; then
419
+ FORCE_FLAG="--force"
420
+ fi
421
+ if ./scripts/check_changed.sh $FORCE_FLAG fastlane/data_safety.csv; then
422
+ export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
423
+ cd $CM_BUILD_DIR/$APP_ROOT/android
424
+ if bundle exec fastlane update_data_safety 2>&1; then
425
+ echo "Data safety form updated successfully"
426
+ else
427
+ echo "WARNING: Data safety update failed."
428
+ echo "Data safety can be configured manually in Google Play Console."
429
+ echo "Continuing build..."
430
+ fi
431
+ else
432
+ echo "Data safety unchanged - skipping"
433
+ fi
434
+ # After successful sync, create marker so future builds use change detection
435
+ if [ "${FIRST_STORE_SYNC:-false}" = "true" ]; then
436
+ mkdir -p "$CM_BUILD_DIR/.codemagic"
437
+ touch "$CM_BUILD_DIR/.codemagic/android_store_synced"
438
+ cd "$CM_BUILD_DIR"
439
+ git add .codemagic/android_store_synced
440
+ git commit -m "chore: mark Android store sync complete [skip ci]" || true
441
+ git push origin HEAD || true
442
+ echo "Store sync marker committed"
443
+ fi
444
+
218
445
  - name: Manage Android version
219
446
  script: |
220
447
  # Read iOS version for consistency (if iOS workflow ran)
@@ -231,68 +458,59 @@ workflows:
231
458
  if [ "$GOOGLE_PLAY_READY" != "true" ]; then
232
459
  LATEST_BUILD=0
233
460
  else
234
- LATEST_BUILD=$(google-play get-latest-build-number \
461
+ export GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS="$(cat $CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH)"
462
+ LATEST_BUILD_OUTPUT=$(google-play get-latest-build-number \
235
463
  --package-name "$PACKAGE_NAME" \
236
- --tracks=production,beta,alpha,internal 2>/dev/null || echo "0")
464
+ --tracks=production,beta,alpha,internal 2>/dev/null || true)
465
+ # Extract just the number from output
466
+ LATEST_BUILD=$(echo "$LATEST_BUILD_OUTPUT" | grep -oE '^[0-9]+$' | tail -1)
467
+ if [ -z "$LATEST_BUILD" ]; then
468
+ echo "WARNING: Could not get latest build number from Google Play."
469
+ echo "Raw output: $LATEST_BUILD_OUTPUT"
470
+ echo "Falling back to build number 1"
471
+ LATEST_BUILD=1
472
+ fi
473
+ echo "Latest build number from Google Play: $LATEST_BUILD"
237
474
  fi
238
475
  NEW_BUILD=$(($LATEST_BUILD + 1))
239
- if [ -z "$APP_VERSION" ] || [ "$APP_VERSION" = "" ]; then
476
+ if [ -z "$APP_VERSION" ]; then
240
477
  APP_VERSION="1.0.0"
241
478
  fi
242
- sed -i '' "s/^version:.*/version: ${APP_VERSION}+${NEW_BUILD}/" pubspec.yaml
479
+ # Normalize to semver (1.0 -> 1.0.0, 1 -> 1.0.0)
480
+ PARTS=$(echo "$APP_VERSION" | tr '.' '\n' | wc -l | tr -d ' ')
481
+ if [ "$PARTS" -eq 1 ]; then APP_VERSION="${APP_VERSION}.0.0"; fi
482
+ if [ "$PARTS" -eq 2 ]; then APP_VERSION="${APP_VERSION}.0"; fi
483
+ sed -i '' "s/^version:.*/version: ${APP_VERSION}+${NEW_BUILD}/" $APP_ROOT/pubspec.yaml
243
484
  echo "ANDROID_VERSION_CODE=$NEW_BUILD" >> $CM_ENV
244
485
  echo "Android versionCode: $NEW_BUILD, versionName: $APP_VERSION"
245
486
 
246
487
  - name: Flutter packages
247
- script: flutter pub get
488
+ script: cd $CM_BUILD_DIR/$APP_ROOT && flutter pub get
248
489
 
249
490
  - name: Build Android
250
- script: flutter build appbundle --release
491
+ script: cd $CM_BUILD_DIR/$APP_ROOT && flutter build appbundle --release
251
492
 
252
- - name: Install Fastlane
253
- script: |
254
- cd android
255
- gem install bundler
256
- bundle install
257
-
258
- - name: Deploy to Google Play
493
+ - name: Upload AAB to Google Play
259
494
  script: |
260
495
  if [ "$GOOGLE_PLAY_READY" != "true" ]; then
261
- echo "Skipping - Google Play setup incomplete"
262
- exit 0
496
+ echo "============================================"
497
+ echo "FIRST RUN: AAB built but NOT uploaded."
498
+ echo "============================================"
499
+ echo ""
500
+ echo "Download the AAB from build artifacts and"
501
+ echo "upload it manually to Google Play Console."
502
+ echo "See HOW_TO_GOOGLE_PLAY.md in artifacts for"
503
+ echo "step-by-step instructions."
504
+ echo ""
505
+ echo "After completing manual setup, push again"
506
+ echo "for fully automated publishing."
507
+ echo "============================================"
508
+ exit 1
263
509
  fi
264
510
  export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
265
- cd android
266
- bundle exec fastlane deploy_android
267
-
268
- - name: Sync subscriptions and IAP
269
- script: |
270
- if [ "$GOOGLE_PLAY_READY" != "true" ]; then
271
- echo "Skipping - Google Play setup incomplete"
272
- exit 0
273
- fi
274
- if ./scripts/check_changed.sh fastlane/iap_config.json; then
275
- export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
276
- cd android
277
- bundle exec fastlane sync_google_iap
278
- else
279
- echo "IAP config unchanged - skipping"
280
- fi
281
-
282
- - name: Update data safety form
283
- script: |
284
- if [ "$GOOGLE_PLAY_READY" != "true" ]; then
285
- echo "Skipping - Google Play setup incomplete"
286
- exit 0
287
- fi
288
- if ./scripts/check_changed.sh fastlane/data_safety.csv; then
289
- export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$CM_BUILD_DIR/$GOOGLE_SA_JSON_PATH"
290
- cd android
291
- bundle exec fastlane update_data_safety
292
- else
293
- echo "Data safety unchanged - skipping"
294
- fi
511
+ cd $CM_BUILD_DIR/$APP_ROOT/android
512
+ bundle exec fastlane upload_binary_android
295
513
 
296
514
  artifacts:
297
- - build/app/outputs/**/*.aab
515
+ - ${APP_ROOT}/build/app/outputs/**/*.aab
298
516
  - $CM_BUILD_DIR/HOW_TO_GOOGLE_PLAY.md
@@ -5,26 +5,74 @@ default_platform(:android)
5
5
  ROOT_DIR = ENV.fetch("CM_BUILD_DIR", File.expand_path("../..", __FILE__))
6
6
  APP_ROOT = ENV.fetch("APP_ROOT", "app")
7
7
 
8
+ AAB_PATH = "#{ROOT_DIR}/#{APP_ROOT}/build/app/outputs/bundle/release/app-release.aab"
9
+
8
10
  def metadata_changed?(path)
9
11
  !sh("git diff --name-only HEAD~1 -- #{path}").strip.empty?
10
12
  rescue StandardError
11
13
  true
12
14
  end
13
15
 
16
+ # Shared upload options reused across Android lanes.
17
+ def base_play_store_options
18
+ {
19
+ package_name: ENV["PACKAGE_NAME"],
20
+ track: ENV.fetch("TRACK", "internal"),
21
+ json_key: ENV["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"]
22
+ }
23
+ end
24
+
25
+ def rollout_options
26
+ {
27
+ rollout: ENV.fetch("ROLLOUT_FRACTION", "").empty? ? nil : ENV["ROLLOUT_FRACTION"],
28
+ in_app_update_priority: ENV.fetch("IN_APP_UPDATE_PRIORITY", "3").to_i
29
+ }
30
+ end
31
+
14
32
  platform :android do
15
33
  lane :deploy_android do
16
- upload_to_play_store(
17
- aab: "#{ROOT_DIR}/#{APP_ROOT}/build/app/outputs/bundle/release/app-release.aab",
18
- track: ENV.fetch("TRACK", "internal"),
19
- json_key: ENV["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"],
34
+ status = ENV.fetch("RELEASE_STATUS", "draft")
35
+ opts = base_play_store_options.merge(
36
+ aab: AAB_PATH,
37
+ release_status: status,
20
38
  skip_upload_metadata: !metadata_changed?("fastlane/metadata/android/"),
21
39
  skip_upload_screenshots: !metadata_changed?("fastlane/screenshots/android/"),
22
40
  skip_upload_images: !metadata_changed?("fastlane/screenshots/android/"),
23
41
  skip_upload_changelogs: false,
24
- metadata_path: "#{ROOT_DIR}/fastlane/metadata/android",
25
- rollout: ENV.fetch("ROLLOUT_FRACTION", "").empty? ? nil : ENV["ROLLOUT_FRACTION"].to_f,
26
- in_app_update_priority: ENV.fetch("IN_APP_UPDATE_PRIORITY", "3").to_i
42
+ metadata_path: "#{ROOT_DIR}/fastlane/metadata/android"
43
+ )
44
+ opts.merge!(rollout_options) unless status == "draft"
45
+ upload_to_play_store(opts)
46
+ end
47
+
48
+ lane :upload_metadata_android do
49
+ upload_to_play_store(
50
+ base_play_store_options.merge(
51
+ skip_upload_aab: true,
52
+ skip_upload_apk: true,
53
+ skip_upload_metadata: false,
54
+ skip_upload_screenshots: false,
55
+ skip_upload_images: false,
56
+ skip_upload_changelogs: true,
57
+ release_status: "draft",
58
+ metadata_path: "#{ROOT_DIR}/fastlane/metadata/android"
59
+ )
60
+ )
61
+ end
62
+
63
+ lane :upload_binary_android do
64
+ status = ENV.fetch("RELEASE_STATUS", "draft")
65
+ opts = base_play_store_options.merge(
66
+ aab: AAB_PATH,
67
+ release_status: status,
68
+ skip_upload_metadata: true,
69
+ skip_upload_screenshots: true,
70
+ skip_upload_images: true,
71
+ skip_upload_changelogs: true
27
72
  )
73
+ # Rollout percentage is only valid for non-draft releases
74
+ opts.merge!(rollout_options) unless status == "draft"
75
+ upload_to_play_store(opts)
28
76
  end
29
77
 
30
78
  lane :sync_google_iap do
@@ -1 +1,4 @@
1
- gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"
1
+ # fastlane-plugin-iap is not yet published.
2
+ # IAP sync is handled gracefully in codemagic.yaml (non-blocking).
3
+ # Uncomment when the plugin is available:
4
+ # gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"
@@ -5,6 +5,20 @@ default_platform(:ios)
5
5
  ROOT_DIR = ENV.fetch("CM_BUILD_DIR", File.expand_path("../..", __FILE__))
6
6
  APP_ROOT = ENV.fetch("APP_ROOT", "app")
7
7
 
8
+ # Monkey-patch: Fastlane deliver crashes with "No data" when fetching
9
+ # app_store_review_detail on the first version (fastlane/fastlane#20538).
10
+ # Wrap the method to rescue the RuntimeError gracefully.
11
+ # Require deliver explicitly so the constant is available at parse time.
12
+ require "deliver"
13
+ ::Deliver::UploadMetadata.prepend(Module.new do
14
+ def review_attachment_file(version)
15
+ super
16
+ rescue RuntimeError => e
17
+ raise unless e.message.include?("No data")
18
+ Fastlane::UI.important("Skipping review attachment: #{e.message} (first version)")
19
+ end
20
+ end)
21
+
8
22
  def metadata_changed?(path)
9
23
  !sh("git diff --name-only HEAD~1 -- #{path}").strip.empty?
10
24
  rescue StandardError
@@ -30,26 +44,67 @@ def asc_api_key
30
44
  end
31
45
  end
32
46
 
47
+ # Shared deliver options reused across iOS lanes.
48
+ def base_deliver_options
49
+ {
50
+ api_key: asc_api_key,
51
+ app_identifier: ENV["BUNDLE_ID"],
52
+ force: true
53
+ }
54
+ end
55
+
56
+ def metadata_deliver_options
57
+ base_deliver_options.merge(
58
+ metadata_path: "#{ROOT_DIR}/fastlane/metadata",
59
+ screenshots_path: "#{ROOT_DIR}/fastlane/screenshots/ios",
60
+ sync_screenshots: true,
61
+ primary_category: ENV.fetch("PRIMARY_CATEGORY", "UTILITIES"),
62
+ secondary_category: ENV.fetch("SECONDARY_CATEGORY", "PRODUCTIVITY")
63
+ )
64
+ end
65
+
66
+ def submission_options
67
+ {
68
+ submit_for_review: ENV.fetch("SUBMIT_FOR_REVIEW", "true") == "true",
69
+ automatic_release: ENV.fetch("AUTOMATIC_RELEASE", "true") == "true"
70
+ }
71
+ end
72
+
33
73
  platform :ios do
34
74
  lane :deploy_ios do
35
75
  deliver(
36
- api_key: asc_api_key,
37
- app_identifier: ENV["BUNDLE_ID"],
38
- ipa: Dir.glob("#{ROOT_DIR}/#{APP_ROOT}/build/ios/ipa/*.ipa").first,
39
- skip_metadata: !metadata_changed?("fastlane/metadata/ios/"),
40
- skip_screenshots: !metadata_changed?("fastlane/screenshots/ios/"),
41
- sync_screenshots: true,
42
- metadata_path: "#{ROOT_DIR}/fastlane/metadata/ios",
43
- screenshots_path: "#{ROOT_DIR}/fastlane/screenshots/ios",
44
- submit_for_review: ENV.fetch("SUBMIT_FOR_REVIEW", "true") == "true",
45
- automatic_release: ENV.fetch("AUTOMATIC_RELEASE", "true") == "true",
46
- force: true,
47
- app_rating_config_path: "#{ROOT_DIR}/fastlane/app_rating_config.json",
48
- price_tier: ENV.fetch("PRICE_TIER", "0").to_i,
49
- run_precheck_before_submit: true,
50
- precheck_include_in_app_purchases: false,
51
- primary_category: ENV.fetch("PRIMARY_CATEGORY", "UTILITIES"),
52
- secondary_category: ENV.fetch("SECONDARY_CATEGORY", "PRODUCTIVITY")
76
+ metadata_deliver_options.merge(submission_options).merge(
77
+ ipa: Dir.glob("#{ROOT_DIR}/#{APP_ROOT}/build/ios/ipa/*.ipa").first,
78
+ app_rating_config_path: "#{ROOT_DIR}/fastlane/app_rating_config.json",
79
+ skip_metadata: !metadata_changed?("fastlane/metadata/ios/"),
80
+ skip_screenshots: !metadata_changed?("fastlane/screenshots/ios/"),
81
+ run_precheck_before_submit: true,
82
+ precheck_include_in_app_purchases: false
83
+ )
84
+ )
85
+ end
86
+
87
+ lane :upload_metadata_ios do
88
+ deliver(
89
+ metadata_deliver_options.merge(
90
+ skip_binary_upload: true,
91
+ skip_metadata: false,
92
+ skip_screenshots: false,
93
+ run_precheck_before_submit: false,
94
+ submit_for_review: false
95
+ )
96
+ )
97
+ end
98
+
99
+ lane :upload_binary_ios do
100
+ deliver(
101
+ base_deliver_options.merge(submission_options).merge(
102
+ ipa: Dir.glob("#{ROOT_DIR}/#{APP_ROOT}/build/ios/ipa/*.ipa").first,
103
+ skip_metadata: true,
104
+ skip_screenshots: true,
105
+ run_precheck_before_submit: true,
106
+ precheck_include_in_app_purchases: false
107
+ )
53
108
  )
54
109
  end
55
110
 
@@ -1 +1,4 @@
1
- gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"
1
+ # fastlane-plugin-iap is not yet published.
2
+ # IAP sync is handled gracefully in codemagic.yaml (non-blocking).
3
+ # Uncomment when the plugin is available:
4
+ # gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"