@imagina-build/ias-cli 0.2.1 → 0.3.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.
Files changed (53) hide show
  1. package/README.md +184 -21
  2. package/package.json +1 -1
  3. package/schema/ias.schema.json +46 -1
  4. package/shared/vault/index.mjs +25 -0
  5. package/shared/vault/policy-vectors.mjs +237 -0
  6. package/shared/vault/policy.mjs +427 -0
  7. package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
  8. package/shared/vault/vectors.mjs +2 -0
  9. package/src/apple/capabilities.mjs +96 -0
  10. package/src/apple/inventory.mjs +75 -0
  11. package/src/apple/signing.mjs +134 -0
  12. package/src/applications.mjs +114 -0
  13. package/src/cli.mjs +28 -10
  14. package/src/commands/apps/index.mjs +61 -11
  15. package/src/commands/apps/store.mjs +367 -0
  16. package/src/commands/auth/index.mjs +3 -3
  17. package/src/commands/build/index.mjs +2 -0
  18. package/src/commands/builds/index.mjs +20 -0
  19. package/src/commands/credentials/apple.mjs +1118 -0
  20. package/src/commands/credentials/generate.mjs +122 -0
  21. package/src/commands/credentials/index.mjs +114 -44
  22. package/src/commands/credentials/seal.mjs +44 -0
  23. package/src/commands/devices/asc.mjs +50 -8
  24. package/src/commands/devices/index.mjs +2 -1
  25. package/src/commands/devices/sync.mjs +57 -4
  26. package/src/commands/dist/index.mjs +12 -0
  27. package/src/commands/github/index.mjs +13 -3
  28. package/src/commands/policy/index.mjs +583 -0
  29. package/src/commands/policy/policy.mjs +182 -0
  30. package/src/commands/policy/progress.mjs +20 -0
  31. package/src/commands/releases/index.mjs +225 -0
  32. package/src/commands/rollout/index.mjs +186 -0
  33. package/src/commands/runners/index.mjs +19 -0
  34. package/src/commands/sdk/index.mjs +31 -4
  35. package/src/commands/setup/index.mjs +184 -0
  36. package/src/commands/submit/index.mjs +135 -19
  37. package/src/commands/testflight/index.mjs +309 -0
  38. package/src/commands/tokens/index.mjs +99 -0
  39. package/src/commands/update/index.mjs +42 -15
  40. package/src/commands/vault/approvers.mjs +5 -1
  41. package/src/commands/vault/index.mjs +26 -6
  42. package/src/commands/vault/runners.mjs +48 -2
  43. package/src/commands/webhooks/index.mjs +19 -5
  44. package/src/config.mjs +16 -1
  45. package/src/errors.mjs +52 -6
  46. package/src/op.mjs +83 -0
  47. package/src/ota-key.mjs +53 -0
  48. package/src/provisioning.mjs +281 -0
  49. package/src/schema.mjs +3 -0
  50. package/src/setup-state.mjs +76 -0
  51. package/src/setup.mjs +698 -0
  52. package/src/team-vault.mjs +2 -2
  53. package/src/token-store.mjs +1 -1
@@ -11,6 +11,17 @@ import { SDK_PACKAGE, addCommand, addPlugin, packageManager, sdkSource, sdkState
11
11
  /** Runs the install with its output on stderr, so --json keeps stdout whole; at a low priority on macOS. */
12
12
  const systemExec = (command, commandArgs, options) => (process.platform === 'darwin' ? gently : run)(command, commandArgs, { stdio: ['inherit', 2, 'inherit'], ...options });
13
13
 
14
+ /** The Expo modules each service of ias.json needs, for `ias sdk install --modules` and `ias setup`. */
15
+ export function expoModulesFor({ updates = false, push = false } = {}) {
16
+ return [...(updates ? ['expo-updates'] : []), ...(push ? ['expo-notifications', 'expo-task-manager'] : [])];
17
+ }
18
+
19
+ /** The modules of `wanted` that the app does not have yet. */
20
+ export function missingExpoModules(dir, wanted) {
21
+ const { dependencies } = sdkState(dir);
22
+ return wanted.filter((name) => !dependencies[name]);
23
+ }
24
+
14
25
  /** The steps left after the install, from ias.json and the dependencies of the app: [{ step, why }]. */
15
26
  export function nextSteps(dir, { plugin, dependencies }) {
16
27
  const steps = [];
@@ -29,8 +40,11 @@ export function nextSteps(dir, { plugin, dependencies }) {
29
40
  return steps;
30
41
  }
31
42
 
32
- /** Installs the SDK in the app in `dir` and adds its plugin. `exec` runs the package manager (the tests pass their own). */
33
- export function installSdk(dir, { from, dryRun = false, repository } = {}, { output, env = process.env, exec = systemExec } = {}) {
43
+ /**
44
+ * Installs the SDK in the app in `dir` and adds its plugin. With `modules`, it also installs the Expo modules they need
45
+ * with `npx expo install`. `exec` runs the package manager (the tests pass their own).
46
+ */
47
+ export function installSdk(dir, { from, dryRun = false, modules = [], repository } = {}, { output, env = process.env, exec = systemExec } = {}) {
34
48
  if (!existsSync(join(dir, 'package.json'))) throw new IasError(`no package.json in ${dir}`, { fix: 'run it in the folder of the Expo app, or pass --project <app folder>' });
35
49
  if (!existsSync(join(dir, 'app.json')) && !DYNAMIC_CONFIGS.some((name) => existsSync(join(dir, name)))) throw new IasError(`no app.json nor app.config.* in ${dir}: it is not an Expo app`, { fix: 'run it in the folder of the Expo app, or pass --project <app folder>' });
36
50
  const source = sdkSource(dir, { from, env, ...(repository !== undefined ? { repository } : {}) });
@@ -48,11 +62,20 @@ export function installSdk(dir, { from, dryRun = false, repository } = {}, { out
48
62
  else if (plugin.reason === 'present') output.log(`${output.prefix}: app.json already has the plugin of ${SDK_PACKAGE}`);
49
63
  else output.log(`${output.prefix}: ${plugin.file} is code, and ias leaves it alone: add the plugin by hand (below)`);
50
64
 
65
+ const missing = modules.length > 0 ? missingExpoModules(dir, modules) : [];
66
+ if (missing.length > 0) {
67
+ const installCommand = `npx expo install ${missing.join(' ')}`;
68
+ output.progress(`${output.prefix}: ${dryRun ? 'would run' : 'running'} ${installCommand}`);
69
+ if (!dryRun) exec('npx', ['expo', 'install', ...missing], { cwd: dir, label: installCommand });
70
+ } else if (modules.length > 0) {
71
+ output.log(`${output.prefix}: ${modules.join(', ')} already installed`);
72
+ }
73
+
51
74
  const steps = nextSteps(dir, { plugin, dependencies: sdkState(dir).dependencies });
52
75
  output.log('left to do:');
53
76
  for (const [index, { step, why }] of steps.entries()) output.log(` ${index + 1}. ${step}\n ${why}`);
54
77
  output.log('ias doctor checks the SDK; docs/guide/sdk.md explains how to configure it.');
55
- return { package: SDK_PACKAGE, spec: source.spec, source: source.origin, package_manager: manager.name, command, installed: !dryRun, plugin: plugin.reason, next: steps };
78
+ return { package: SDK_PACKAGE, spec: source.spec, source: source.origin, package_manager: manager.name, command, installed: !dryRun, plugin: plugin.reason, modules: missing, next: steps };
56
79
  }
57
80
 
58
81
  export default {
@@ -69,12 +92,16 @@ export default {
69
92
  ].join('\n'),
70
93
  options: {
71
94
  from: { type: 'string', value: '<source>', description: `Where ${SDK_PACKAGE} comes from: a .tgz of a release (a file or its URL), a folder, or a version of the registry.` },
95
+ modules: { type: 'boolean', description: 'Also install the Expo modules the services of ias.json need (expo-updates, expo-notifications and expo-task-manager) with npx expo install.' },
72
96
  'dry-run': { type: 'boolean', description: 'Say what it would run and change, and change nothing.' },
73
97
  },
74
98
  run: ({ values, cwd, env, output }) => {
75
99
  // A file or folder of --from is relative to where ias runs, as the shell completes it.
76
100
  const from = values.from && existsSync(resolve(cwd, values.from)) ? resolve(cwd, values.from) : values.from;
77
- return installSdk(resolve(cwd, values.project ?? '.'), { from, dryRun: values['dry-run'] }, { output, env });
101
+ const dir = resolve(cwd, values.project ?? '.');
102
+ const config = loadProject(dir).config;
103
+ const modules = values.modules ? expoModulesFor({ updates: Boolean(config?.updates), push: Boolean(config?.push) }) : [];
104
+ return installSdk(dir, { from, dryRun: values['dry-run'], modules }, { output, env });
78
105
  },
79
106
  },
80
107
  ],
@@ -0,0 +1,184 @@
1
+ // ias setup: the checklist of a new app, one step at a time. It writes ias.json, installs the SDK and logs in here; the
2
+ // steps that need a person (the application in the panel, the credentials, a runner) say exactly what to do and stay
3
+ // locked until the check passes. Without an action it prints where the app is in the checklist.
4
+ import { resolve } from 'node:path';
5
+ import { UsageError } from '../../errors.mjs';
6
+ import { openUrl as systemOpen } from '../../manage.mjs';
7
+ import { EXIT_CODE } from '../../output.mjs';
8
+ import { createSetupContext, printPlan, runSetupBuild, runSetupPublish, setupNext, setupPlan } from '../../setup.mjs';
9
+ import { STATUS } from '../../setup.mjs';
10
+ import { parseServices, readSetupState, resetSetupState, writeSetupState } from '../../setup-state.mjs';
11
+
12
+ const ACTIONS = ['status', 'next', 'check', 'services', 'reset'];
13
+ const SYMBOL = { [STATUS.DONE]: '[x]', [STATUS.READY]: '[>]', [STATUS.LOCKED]: '[ ]', [STATUS.OPTIONAL]: '[-]' };
14
+
15
+ const resultOf = (ctx, plan, extra = {}) => {
16
+ const server = ctx.config?.server ?? ctx.env.IAS_SERVER ?? 'https://ias.imagina.build';
17
+ let origin = server;
18
+ try {
19
+ origin = new URL(server).origin;
20
+ } catch {
21
+ // The server is not a URL yet: the checklist already says so.
22
+ }
23
+ return {
24
+ app: ctx.config?.app ?? null,
25
+ server: origin,
26
+ services: ctx.services,
27
+ complete: plan.complete,
28
+ counts: plan.counts,
29
+ steps: plan.steps,
30
+ next: plan.next,
31
+ ...extra,
32
+ };
33
+ };
34
+
35
+ function printWaiting(step, output) {
36
+ output.log(`\n${SYMBOL[step.status]} ${step.id}: ${step.detail}`);
37
+ if (step.fix) for (const line of step.fix.split('\n')) output.log(` → ${line}`);
38
+ if (step.command) output.log(` ${step.command}`);
39
+ if (step.url && !step.fix?.includes(step.url)) output.log(` ${step.url}`);
40
+ output.log(' when it is done: ias setup next');
41
+ }
42
+
43
+ function printStep(step, output) {
44
+ output.log(`${SYMBOL[step.status]} ${step.id} ${step.detail}`);
45
+ if (step.fix) for (const line of step.fix.split('\n')) output.log(` → ${line}`);
46
+ if (step.command) output.log(` ${step.command}`);
47
+ if (step.url) output.log(` ${step.url}`);
48
+ }
49
+
50
+ function servicesOf(context, state) {
51
+ if (context.values.services === undefined) return { services: state.services, state };
52
+ const parsed = parseServices(context.values.services);
53
+ if (parsed.error) throw new UsageError(`${parsed.error}: ias setup --services takes ${['updates', 'distribution', 'push'].join(', ')} (or a comma-separated list)`);
54
+ return { services: parsed.services, state, explicit: true };
55
+ }
56
+
57
+ export default {
58
+ name: 'setup',
59
+ summary: 'The checklist of a new app: what is done, what is next and what a person has to do',
60
+ description: [
61
+ 'The steps, in order: environment, project (ias.json), login, application, services, stores, sdk, vault, credentials, signing, runner, build, testflight, updates, distribution, push and ci.',
62
+ 'ias does the ones it can here: ias init, ias sdk install (with the Expo modules of the services), ias login and ias vault init. The rest are checked against the files and the server; a step says what a person has to do and the next step stays locked until it is done.',
63
+ 'Without an action, it prints where the app is. next runs the next step, or says what a person has to do; with --build <profile> it makes the first build and with --publish the first OTA update, both explicit. check re-checks everything and exits with 1 while the checklist is not complete. services reads or sets the services of the app (updates, distribution, push); reset forgets the local state (it holds no secret).',
64
+ 'Optional steps (they never block and --all never runs them): stores, signing, testflight, push and ci; runner is optional with --local-builds, and a step with a service only counts when that service is on.',
65
+ ].join('\n'),
66
+ positionals: [
67
+ { name: 'action', optional: true, description: `One of ${ACTIONS.join(', ')} (status when left out).`, pattern: new RegExp(`^(${ACTIONS.join('|')})$`) },
68
+ { name: 'value', optional: true, description: 'check: the step to check; services: updates, distribution, push or a comma-separated list.' },
69
+ ],
70
+ options: {
71
+ 'create-app': { type: 'boolean', description: 'Create the application in IAS with the code of ias.json and the name, bundle id and package of app.json (an owner with a personal token).' },
72
+ name: { type: 'string', value: '"…"', description: 'The name of the new application (the name of app.json by default).' },
73
+ 'ios-bundle-id': { type: 'string', value: '<id>', description: 'The iOS bundle id of the new application (app.json by default).' },
74
+ 'android-package': { type: 'string', value: '<id>', description: 'The Android package of the new application (app.json by default).' },
75
+ 'apple-team-id': { type: 'string', value: '<id>', description: 'The Apple team id of the new application.' },
76
+ app: { type: 'string', value: '<code>', description: 'The code of the application, when ias init cannot read it from app.json (the slug is only a guess).' },
77
+ services: { type: 'string', value: '<list>', description: 'The services of the app: updates, distribution, push, or a comma-separated list. Saved for the next runs.' },
78
+ team: { type: 'string', value: '<code>', description: 'The team the login is for, when the person is in several.' },
79
+ 'local-builds': { type: 'boolean', description: 'The app is built on this Mac (ias build --local): the runner step is optional.' },
80
+ item: { type: 'string', value: '<op://vault/item>', description: 'next: the 1Password item the vault step uses with ias vault init (IAS_VAULT_ITEM, vault.item of ias.json, or op://IAS/IAS vault <host>).' },
81
+ all: { type: 'boolean', description: 'next: run every automatic step until one needs a person.' },
82
+ build: { type: 'string', value: '<profile>', description: 'next: build the app with this profile (preview, production…) and stop; an explicit opt-in.' },
83
+ publish: { type: 'boolean', description: 'next: publish the first OTA update after the build (or after the last succeeded build).' },
84
+ message: { type: 'string', value: '"…"', description: 'next --build or --publish: the message of the build or the update.' },
85
+ wait: { type: 'boolean', description: 'next --build: download the artifacts when it succeeds (ias build --wait).' },
86
+ open: { type: 'boolean', description: 'next: open the link of the step that needs a person.' },
87
+ 'dry-run': { type: 'boolean', description: 'Do not write ias.json, install anything or change the configuration: say what would be done.' },
88
+ },
89
+ async run(context) {
90
+ const { output, env, cwd, values, positionals } = context;
91
+ const action = positionals[0] ?? 'status';
92
+ const value = positionals[1];
93
+ const dir = resolve(cwd, values.project ?? '.');
94
+ const state = readSetupState(dir, env);
95
+ const chosen = servicesOf(context, state);
96
+ if (chosen.explicit) writeSetupState(dir, { ...state, services: chosen.services }, env);
97
+
98
+ const ctx = createSetupContext({
99
+ dir,
100
+ env,
101
+ output,
102
+ values,
103
+ state,
104
+ services: chosen.services,
105
+ sleep: context.sleep,
106
+ openUrl: context.openUrl,
107
+ });
108
+
109
+ if (action === 'services') {
110
+ if (value !== undefined) {
111
+ const parsed = parseServices(value);
112
+ if (parsed.error) throw new UsageError(`${parsed.error}: ias setup services takes updates, distribution, push or a comma-separated list`);
113
+ writeSetupState(dir, { ...state, services: parsed.services }, env);
114
+ ctx.services = parsed.services;
115
+ output.log(`${output.prefix} setup: services ${parsed.services.join(', ') || '(none)'}`);
116
+ return { services: parsed.services, file: true };
117
+ }
118
+ output.log(ctx.services.length > 0 ? ctx.services.join(', ') : '(none)');
119
+ return { services: ctx.services };
120
+ }
121
+
122
+ if (action === 'reset') {
123
+ resetSetupState(dir, env);
124
+ output.log(`${output.prefix} setup: forgot the local state of ${dir}; the files and the server still say what is done.`);
125
+ return { reset: true, file: true };
126
+ }
127
+
128
+ if (values['create-app'] && action === 'status') {
129
+ const outcome = await setupNext(ctx, { id: 'application' });
130
+ if (outcome.ran) output.log(`${output.prefix} setup: ran ${outcome.ran}`);
131
+ printPlan(outcome.plan, ctx, output);
132
+ return { ...resultOf(ctx, outcome.plan, { ran: outcome.ran ?? null }), [EXIT_CODE]: 0 };
133
+ }
134
+
135
+ if (action === 'next' && (values.build !== undefined || values.publish)) {
136
+ let buildResult;
137
+ let publishResult;
138
+ if (values.build !== undefined) {
139
+ buildResult = await runSetupBuild(ctx);
140
+ if (buildResult?.[EXIT_CODE]) {
141
+ printPlan(await setupPlan(ctx), ctx, output);
142
+ return { ...resultOf(ctx, await setupPlan(ctx), { build: buildResult }), [EXIT_CODE]: 1 };
143
+ }
144
+ }
145
+ if (values.publish) publishResult = await runSetupPublish(ctx);
146
+ const plan = await setupPlan(ctx);
147
+ printPlan(plan, ctx, output);
148
+ return { ...resultOf(ctx, plan, { ...(buildResult ? { build: buildResult } : {}), ...(publishResult ? { published: publishResult } : {}) }), [EXIT_CODE]: 0 };
149
+ }
150
+
151
+ if (action === 'next') {
152
+ const outcome = await setupNext(ctx, { all: values.all });
153
+ const plan = outcome.plan;
154
+ if (outcome.ran) output.log(`${output.prefix} setup: ran ${outcome.ran}`);
155
+ printPlan(plan, ctx, output);
156
+ const waiting = outcome.waiting;
157
+ if (waiting && waiting.kind === 'manual') {
158
+ printWaiting(waiting, output);
159
+ if (values.open && waiting.url) (context.openUrl ?? ((url) => systemOpen(url, env)))(waiting.url);
160
+ }
161
+ const failed = outcome.ran && plan.steps.find((step) => step.id === outcome.ran)?.status !== STATUS.DONE;
162
+ return {
163
+ ...resultOf(ctx, plan, { ran: outcome.ran ?? null, result: outcome.result ?? null, waiting: waiting?.id ?? null }),
164
+ [EXIT_CODE]: failed ? 1 : 0,
165
+ };
166
+ }
167
+
168
+ const plan = await setupPlan(ctx);
169
+
170
+ if (action === 'check') {
171
+ if (value) {
172
+ const step = plan.steps.find((candidate) => candidate.id === value);
173
+ if (!step) throw new UsageError(`there is no step «${value}»: the checklist has ${plan.steps.map((candidate) => candidate.id).join(', ')}`);
174
+ printStep(step, output);
175
+ return { ...resultOf(ctx, plan), step, [EXIT_CODE]: step.status === STATUS.DONE || step.optional ? 0 : 1 };
176
+ }
177
+ printPlan(plan, ctx, output);
178
+ return { ...resultOf(ctx, plan), [EXIT_CODE]: plan.complete ? 0 : 1 };
179
+ }
180
+
181
+ printPlan(plan, ctx, output);
182
+ return resultOf(ctx, plan);
183
+ },
184
+ };
@@ -1,16 +1,25 @@
1
- // ias submit: sends a store build that succeeded to App Store Connect or Google Play (docs/technical/runner-api.md §6, decision
2
- // D7). The runner does it, with the keys sealed in the vault, after an owner approves it with a passkey: always as a
3
- // draft, to send for review in the store. It opens the approval and, with --wait, follows the submission to its end.
1
+ // ias submit: sends a store build that succeeded to App Store Connect or Google Play (docs/technical/runner-api.md §6,
2
+ // decision D7 and docs/plans/gaps/03-google-play-rollout.md). The runner does it, with the keys sealed in the vault, after an
3
+ // owner approves it with a passkey. A Google Play release is a draft by default; --release-status inProgress with
4
+ // --rollout serves a percentage, and --release-status completed serves it to everyone, only when the track's policy allows
5
+ // it (the server, not ias.json, is the permission). --changes-not-sent-for-review makes Play apply the commit without
6
+ // sending the release for review: it stays pending for a person in Play Console, and nobody receives it until then.
7
+ // It opens the approval and, with --wait, follows the submission to its end.
4
8
  import { resolve } from 'node:path';
5
9
  import { loadProject } from '../../config.mjs';
6
- import { IasError } from '../../errors.mjs';
10
+ import { ApiError, IasError } from '../../errors.mjs';
7
11
  import { connect, openUrl, sleep } from '../../manage.mjs';
8
12
  import { EXIT_CODE } from '../../output.mjs';
9
13
  import { buildsClient, isFinished } from '../build/follow.mjs';
14
+ import { sayPolicy } from '../policy/progress.mjs';
15
+ import { PLAY_TRACK } from '../../shared.mjs';
10
16
 
11
17
  const BUILD_ID = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
12
- const TRACK = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
18
+ const TRACK = PLAY_TRACK;
13
19
  const DEFAULT_TRACK = 'internal';
20
+ const RELEASE_STATUSES = Object.freeze(['draft', 'inProgress', 'completed']);
21
+ /** What the server answers when a policy does not allow what ias.json asks: the command falls back to a draft. */
22
+ const POLICY_CODES = Object.freeze(['release_policy_required', 'release_not_allowed', 'release_percent_above_cap', 'release_step_too_big', 'release_too_soon', 'release_percent_invalid']);
14
23
 
15
24
  /** What the store says of a submission that ended: the delivery of App Store Connect, or the edit of Google Play. */
16
25
  function outcome(submission) {
@@ -20,21 +29,108 @@ function outcome(submission) {
20
29
  return parts.length > 0 ? ` (${parts.join(', ')})` : '';
21
30
  }
22
31
 
23
- /** The body of POST /builds/{id}/submit for `build`, from the options and the submit profile of ias.json. */
24
- function targetOf(context, build, config) {
32
+ /** A whole percentage from --rollout, which is 1-99: 100 is completed. */
33
+ function percentOf(value) {
34
+ if (value === undefined || value === null || value === '') return undefined;
35
+ const text = String(value);
36
+ if (!/^[0-9]+$/.test(text)) throw new IasError(`--rollout ${text} is not a whole percentage`, { fix: 'a staged rollout serves 1 to 99: use --rollout 20, or --release-status completed for 100 %' });
37
+ const percent = Number(text);
38
+ if (percent < 1 || percent > 99) throw new IasError(`--rollout ${percent} is outside 1-99`, { fix: 'a staged rollout serves 1 to 99: the 100 % is --release-status completed' });
39
+ return percent;
40
+ }
41
+
42
+ /** The release ias.json proposes for a profile: EAS writes rollout as a fraction, IAS as a whole percentage. */
43
+ function configuredRelease(profile) {
44
+ const status = profile?.android?.releaseStatus;
45
+ if (status === undefined || status === null || status === 'draft') return null;
46
+ const rollout = profile?.android?.rollout;
47
+ const percent = typeof rollout === 'number' && rollout > 0 && rollout < 1 ? Math.max(1, Math.min(99, Math.round(rollout * 100))) : undefined;
48
+ return { status, percent };
49
+ }
50
+
51
+ /** The body of POST /builds/{id}/submit for `build`, from the options, the submit profile of ias.json and the application. */
52
+ function targetOf(context, build, config, application) {
25
53
  const { values, output } = context;
26
54
  const profile = config?.submit?.[build.profile] ?? {};
27
55
  if (build.platform === 'ios') {
56
+ if (values['changes-not-sent-for-review']) throw new IasError('--changes-not-sent-for-review is for Google Play', { fix: 'an iOS build goes to App Store Connect, where Apple reviews every build: there is no commit to hold back for review' });
28
57
  if (values.track) throw new IasError('--track is for Google Play', { fix: 'an iOS build goes to App Store Connect (TestFlight), without a track' });
29
- const ascAppId = values['asc-app-id'] ?? profile.ios?.ascAppId;
30
- if (ascAppId !== undefined && !/^[0-9]{1,20}$/.test(ascAppId)) throw new IasError(`«${ascAppId}» is not the Apple ID of an app`, { fix: 'it is the number App Store Connect shows in App Information' });
31
- return { target: 'app_store_connect', ...(ascAppId ? { asc_app_id: ascAppId } : {}) };
58
+ if (values['release-status'] || values.rollout) throw new IasError('--release-status and --rollout are for Google Play', { fix: 'an iOS build goes to App Store Connect as it is: TestFlight decides when it is distributed' });
59
+ const ascAppId = values['asc-app-id'] ?? profile.ios?.ascAppId ?? application?.ios?.asc_app_id;
60
+ if (ascAppId === undefined || ascAppId === null || ascAppId === '') {
61
+ throw new IasError(`${build.app} has no app record in App Store Connect`, {
62
+ fix: `a person creates it in App Store Connect with the bundle id ${application?.ios?.bundle_id ?? 'of the app'} and the SKU IAS-${build.app.toUpperCase()}; when it exists: ias apps store ${build.app} --check`,
63
+ code: 'asc_app_missing',
64
+ });
65
+ }
66
+ if (!/^[0-9]{1,20}$/.test(ascAppId)) throw new IasError(`«${ascAppId}» is not the Apple ID of an app`, { fix: 'it is the number App Store Connect shows in App Information' });
67
+ return { target: 'app_store_connect', asc_app_id: ascAppId };
32
68
  }
33
69
  if (values['asc-app-id']) throw new IasError('--asc-app-id is for App Store Connect', { fix: 'an Android build goes to Google Play: --track production, beta, alpha or internal' });
34
70
  const track = values.track ?? profile.android?.track ?? DEFAULT_TRACK;
35
- if (!TRACK.test(track)) throw new IasError(`«${track}» is not a track of Google Play`, { fix: 'production, beta, alpha, internal or the name of a closed track' });
36
- if (profile.android?.releaseStatus && profile.android.releaseStatus !== 'draft') output.warn(`submit.${build.profile}.android.releaseStatus is ${profile.android.releaseStatus}, but IAS always sends a draft (D7): send it for review in Play Console`);
37
- return { target: 'google_play', track, release_status: 'draft' };
71
+ if (!TRACK.test(track)) throw new IasError(`«${track}» is not a track of Google Play`, { fix: 'production, beta, alpha, internal, the name of a closed track or a form factor\'s (wear:production)' });
72
+
73
+ // The flag wins over ias.json; without either, Play sends the commit for review as always.
74
+ const notSentFlag = values['changes-not-sent-for-review'];
75
+ const notSent = notSentFlag === true || (notSentFlag === undefined && profile.android?.changesNotSentForReview === true);
76
+ const notSentFrom = notSentFlag !== undefined ? '--changes-not-sent-for-review' : `submit.${build.profile}.android.changesNotSentForReview`;
77
+ const of = (body) => ({ body: notSent ? { ...body, changes_not_sent_for_review: true } : body, notSentFrom });
78
+
79
+ const explicit = values['release-status'] !== undefined || values.rollout !== undefined;
80
+ let status = values['release-status'];
81
+ let percent = percentOf(values.rollout);
82
+
83
+ if (status !== undefined && !RELEASE_STATUSES.includes(status)) throw new IasError(`--release-status ${status} is not a release state`, { fix: 'draft, inProgress or completed' });
84
+
85
+ if (explicit) {
86
+ status ??= percent === undefined ? 'draft' : 'inProgress';
87
+ if (percent !== undefined && status !== 'inProgress') throw new IasError(`--rollout only goes with --release-status inProgress`, { fix: `use --release-status inProgress --rollout ${percent}, or --release-status completed without --rollout` });
88
+ if (status === 'inProgress' && percent === undefined) throw new IasError('--release-status inProgress needs --rollout <1-99>', { fix: `ias submit ${build.id} --track ${track} --release-status inProgress --rollout 20` });
89
+ return { ...of({ target: 'google_play', track, release_status: status, ...(percent === undefined ? {} : { percent }) }), fromConfig: false };
90
+ }
91
+
92
+ const configured = configuredRelease(profile);
93
+ if (configured === null) return { ...of({ target: 'google_play', track, release_status: 'draft' }), fromConfig: false };
94
+
95
+ if (!RELEASE_STATUSES.includes(configured.status)) {
96
+ output.warn(`submit.${build.profile}.android.releaseStatus is ${configured.status}, which IAS does not know: sending a draft`);
97
+ return { ...of({ target: 'google_play', track, release_status: 'draft' }), fromConfig: false };
98
+ }
99
+
100
+ if (configured.status === 'inProgress' && configured.percent === undefined) {
101
+ output.warn(`submit.${build.profile}.android says inProgress without a rollout between 0 and 1: sending a draft (set rollout: 0.1, or use --rollout 10)`);
102
+ return { ...of({ target: 'google_play', track, release_status: 'draft' }), fromConfig: false };
103
+ }
104
+
105
+ if (configured.status === 'completed') return { ...of({ target: 'google_play', track, release_status: 'completed' }), fromConfig: true };
106
+
107
+ return { ...of({ target: 'google_play', track, release_status: configured.status, percent: configured.percent }), fromConfig: true };
108
+ }
109
+
110
+ /**
111
+ * What `changesNotSentForReview` means, said when it is sent: with a draft it changes nothing, and with a release it
112
+ * keeps the change from reaching anyone until a person sends it from Play Console.
113
+ */
114
+ function warnNotSentForReview(output, body, source) {
115
+ if (body.changes_not_sent_for_review !== true) return;
116
+ if (body.release_status === 'draft') {
117
+ output.warn(`${source} is redundant with a draft: a draft is not sent for review anyway. Send it from Play Console when you are ready.`);
118
+ return;
119
+ }
120
+ output.warn(`${source}: Play applies the release but does not send it for review, so testers and users will not receive it until someone sends it in Play Console (Publishing overview › Send for review).`);
121
+ }
122
+
123
+ /** Creates the submission; what ias.json proposed falls back to a draft when the policy does not allow it, with a warning. */
124
+ async function createSubmission(client, build, body, fromConfig, output) {
125
+ try {
126
+ return { submission: (await client.request('POST', `/builds/${build.id}/submit`, { json: body })).data, body };
127
+ } catch (error) {
128
+ if (!fromConfig || !(error instanceof ApiError) || !POLICY_CODES.includes(error.code)) throw error;
129
+ const why = error.data?.fix ?? error.message;
130
+ output.warn(`ias.json asks ${body.release_status}${body.percent ? ` at ${body.percent} %` : ''}, but the policy does not allow it: sending a draft instead. ${why}`);
131
+ const draft = { target: 'google_play', track: body.track, release_status: 'draft' };
132
+ return { submission: (await client.request('POST', `/builds/${build.id}/submit`, { json: draft })).data, body: draft };
133
+ }
38
134
  }
39
135
 
40
136
  async function submit(context) {
@@ -43,10 +139,21 @@ async function submit(context) {
43
139
  const client = buildsClient(connect(context, project));
44
140
  const { data: build } = await client.request('GET', `/builds/${context.positionals[0]}`);
45
141
  if (build.distribution !== 'store') throw new IasError(`${build.id} is an internal build (${build.profile})`, { fix: 'only a store build goes to the stores: ias build --profile production', code: 'build_not_submittable' });
46
- const body = targetOf(context, build, project.config);
47
- const { data: submission } = await client.request('POST', `/builds/${build.id}/submit`, { json: body });
48
- const where = body.target === 'google_play' ? `Google Play (${body.track}, draft)` : 'App Store Connect';
142
+ // The Apple ID goes in the body: the flag, then ias.json, then the one saved in the application.
143
+ const configured = values['asc-app-id'] ?? project.config?.submit?.[build.profile]?.ios?.ascAppId;
144
+ const needsApplication = build.platform === 'ios' && (configured === undefined || configured === null || configured === '');
145
+ const application = needsApplication ? (await client.request('GET', `/apps/${encodeURIComponent(build.app)}`)).data : null;
146
+ const target = targetOf(context, build, project.config, application);
147
+ const requested = target.body ?? target;
148
+ const sent = await createSubmission(client, build, requested, target.fromConfig === true, output);
149
+ const body = sent.body;
150
+ const submission = sent.submission;
151
+ warnNotSentForReview(output, body, target.notSentFrom);
152
+ const where = body.target === 'google_play'
153
+ ? `Google Play (${body.track}, ${body.release_status}${body.percent ? ` ${body.percent} %` : ''}${body.changes_not_sent_for_review ? ', not sent for review' : ''})`
154
+ : 'App Store Connect';
49
155
  output.progress(`${build.platform} ${build.version} (${build.build_number}) to ${where}: submission ${submission.id}, ${submission.status}`);
156
+ sayPolicy(output, submission);
50
157
  if (submission.approval_url) {
51
158
  const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(submission.approval_url);
52
159
  output.progress(`${opened ? 'Opened' : 'Open'} ${submission.approval_url}: an owner approves it with a passkey (the panel shows the hash ${submission.hash}).`);
@@ -68,17 +175,26 @@ async function submit(context) {
68
175
  }
69
176
  }
70
177
  output.log(`submission ${current.id} to ${where}: ${current.status}${outcome(current)}`);
178
+ if (current.status === 'succeeded' && body.target === 'app_store_connect') output.log(`build in TestFlight; ias testflight ${build.id} distributes it`);
179
+ if (current.status === 'succeeded' && body.target === 'google_play') {
180
+ output.log(body.changes_not_sent_for_review
181
+ ? `release state: ias releases view ${body.track} --refresh; it was not sent for review: a person sends it in Play Console › Publishing overview`
182
+ : `release state: ias releases view ${body.track} --refresh; the next rollout: ias rollout ${body.track}`);
183
+ }
71
184
  return current.status === 'succeeded' ? current : { ...current, [EXIT_CODE]: 1 };
72
185
  }
73
186
 
74
187
  export default {
75
188
  name: 'submit',
76
- summary: 'Send a store build to App Store Connect or Google Play, as a draft, after an owner approves it',
77
- description: 'The runner sends it with the keys of the vault (asc_api_key, google_service_account), and always as a draft: the review is asked in the store. The track and the Apple ID of the app come from the options or from submit.<profile> of ias.json; Google Play goes to the internal track by default.',
189
+ summary: 'Send a store build to App Store Connect or Google Play, after an owner approves it (a draft by default)',
190
+ description: 'The runner sends it with the keys of the vault (asc_api_key, google_service_account). Google Play goes to the internal track by default and, like always, as a draft: the review is asked in Play. --release-status inProgress with --rollout serves a percentage and --release-status completed serves it to everyone, only when the track has a policy that allows it (an owner sets it with ias releases policy; without one, a track only takes drafts). --changes-not-sent-for-review applies the commit without sending it for review: a person sends it from Play Console, and until then nobody receives it (redundant with a draft). submit.<profile>.android.releaseStatus, rollout and changesNotSentForReview in ias.json are used the same way, and if the policy does not allow them the command warns and sends a draft instead.',
78
191
  positionals: [{ name: 'build', description: 'The id of a store build that succeeded, from ias builds list.', pattern: BUILD_ID }],
79
192
  options: {
80
- track: { type: 'string', value: '<track>', description: 'Google Play: production, beta, alpha, internal (the default) or a closed track.' },
193
+ track: { type: 'string', value: '<track>', description: 'Google Play: production, beta, alpha, internal (the default), a closed track or a form factor\'s (wear:production).' },
81
194
  'asc-app-id': { type: 'string', value: '<id>', description: 'App Store Connect: the Apple ID of the app (submit.<profile>.ios.ascAppId by default).' },
195
+ 'release-status': { type: 'string', value: '<draft|inProgress|completed>', description: 'Google Play: the state of the release (draft by default).' },
196
+ rollout: { type: 'string', value: '<1-99>', description: 'Google Play: the percentage an inProgress release serves; 100 is completed.' },
197
+ 'changes-not-sent-for-review': { type: 'boolean', description: 'Google Play: apply the release without sending it for review; a person sends it in Play Console (submit.<profile>.android.changesNotSentForReview by default).' },
82
198
  wait: { type: 'boolean', description: 'Wait until the runner has sent it, and say what the store answered.' },
83
199
  },
84
200
  run: submit,