@imagina-build/ias-cli 0.2.2 → 0.3.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.
- package/README.md +188 -22
- package/package.json +1 -1
- package/schema/ias.schema.json +46 -1
- package/shared/vault/index.mjs +25 -0
- package/shared/vault/policy-vectors.mjs +237 -0
- package/shared/vault/policy.mjs +427 -0
- package/shared/vault/test-vectors/ias-policy-1/policy.json +4016 -0
- package/shared/vault/vectors.mjs +2 -0
- package/src/apple/capabilities.mjs +96 -0
- package/src/apple/inventory.mjs +75 -0
- package/src/apple/signing.mjs +134 -0
- package/src/applications.mjs +114 -0
- package/src/cli.mjs +28 -10
- package/src/commands/apps/index.mjs +61 -11
- package/src/commands/apps/store.mjs +367 -0
- package/src/commands/auth/index.mjs +3 -3
- package/src/commands/build/index.mjs +2 -0
- package/src/commands/builds/index.mjs +20 -0
- package/src/commands/credentials/apple.mjs +1118 -0
- package/src/commands/credentials/generate.mjs +122 -0
- package/src/commands/credentials/index.mjs +114 -44
- package/src/commands/credentials/seal.mjs +44 -0
- package/src/commands/devices/asc.mjs +50 -8
- package/src/commands/devices/index.mjs +2 -1
- package/src/commands/devices/sync.mjs +57 -4
- package/src/commands/dist/index.mjs +12 -0
- package/src/commands/doctor/index.mjs +3 -0
- package/src/commands/github/index.mjs +13 -3
- package/src/commands/init/index.mjs +6 -3
- package/src/commands/policy/index.mjs +583 -0
- package/src/commands/policy/policy.mjs +182 -0
- package/src/commands/policy/progress.mjs +20 -0
- package/src/commands/releases/index.mjs +225 -0
- package/src/commands/rollout/index.mjs +186 -0
- package/src/commands/runners/index.mjs +19 -0
- package/src/commands/sdk/index.mjs +31 -4
- package/src/commands/setup/index.mjs +184 -0
- package/src/commands/skill/index.mjs +43 -0
- package/src/commands/submit/index.mjs +135 -19
- package/src/commands/testflight/index.mjs +309 -0
- package/src/commands/tokens/index.mjs +99 -0
- package/src/commands/update/index.mjs +26 -5
- package/src/commands/vault/approvers.mjs +5 -1
- package/src/commands/vault/index.mjs +26 -6
- package/src/commands/vault/runners.mjs +48 -2
- package/src/commands/webhooks/index.mjs +19 -5
- package/src/config.mjs +16 -1
- package/src/errors.mjs +52 -6
- package/src/op.mjs +83 -0
- package/src/ota-key.mjs +53 -0
- package/src/provisioning.mjs +281 -0
- package/src/schema.mjs +3 -0
- package/src/setup-state.mjs +76 -0
- package/src/setup.mjs +701 -0
- package/src/skill-files.generated.mjs +10 -0
- package/src/skill.mjs +256 -0
- package/src/team-vault.mjs +2 -2
- package/src/token-store.mjs +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What ias policy init, check, sign, show, status, diff and simulate share: the source file ias.policy.json (the
|
|
2
|
+
// editable policy, unsigned or with the signature of an earlier attempt), its canonical hash, the team's signing key
|
|
3
|
+
// (from the server, compared with 1Password when it is there), the policy the server serves, its verified form and the
|
|
4
|
+
// diff between two of them. The private key never leaves 1Password: only ias policy sign reads it, in memory.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import { IasError } from '../../errors.mjs';
|
|
9
|
+
import { POLICY_LIMITS, isNotAfter, policyHash, validatePolicy, validatePolicyDraft, verifyPolicy } from '../../shared.mjs';
|
|
10
|
+
import { publicKeysOf, vaultItem } from '../../team-vault.mjs';
|
|
11
|
+
|
|
12
|
+
export const POLICY_FILE = 'ias.policy.json';
|
|
13
|
+
|
|
14
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/** The limits the file shares with the server (docs/technical/vault.md §2.5). */
|
|
17
|
+
export const MAX_VERSION = 2147483647;
|
|
18
|
+
|
|
19
|
+
/** Where the source file of a command is: --out / --file, or ias.policy.json in the project. */
|
|
20
|
+
export const policyPath = (context, file) => resolve(context.cwd, file ?? POLICY_FILE);
|
|
21
|
+
|
|
22
|
+
/** The document without its signature: what the file holds and what the team signs. */
|
|
23
|
+
export function unsignedOf(document) {
|
|
24
|
+
const value = structuredClone(document);
|
|
25
|
+
delete value.signature;
|
|
26
|
+
if (value.format === undefined) value.format = 'ias-policy-1';
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The policy of a file, parsed: { path, document }. */
|
|
31
|
+
export function readPolicyFile(path) {
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
throw new IasError(`there is no ${path}`, { fix: 'ias policy init writes it with a rule equivalent to today', code: 'policy_file_missing' });
|
|
34
|
+
}
|
|
35
|
+
const text = readFileSync(path, 'utf8');
|
|
36
|
+
|
|
37
|
+
if (Buffer.byteLength(text, 'utf8') > POLICY_LIMITS.bytes) {
|
|
38
|
+
throw new IasError(`${path} is larger than ${POLICY_LIMITS.bytes} bytes`, { fix: 'a policy has at most 64 rules of 64 members: split the rules or remove members', code: 'policy.too_large' });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let document;
|
|
42
|
+
try {
|
|
43
|
+
document = JSON.parse(text);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new IasError(`${path} is not valid JSON (${error.message})`, { fix: `fix ${path}, or write it again with ias policy init --force`, code: 'policy_file_invalid' });
|
|
46
|
+
}
|
|
47
|
+
if (typeof document !== 'object' || document === null || Array.isArray(document)) {
|
|
48
|
+
throw new IasError(`${path} is not a policy`, { fix: 'it must be a JSON object with format, version, rules and, optionally, not_after; ias policy init --force writes one', code: 'policy_invalid' });
|
|
49
|
+
}
|
|
50
|
+
return { path, document };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The first problem of a policy file, as the code and message of validatePolicy, or null. */
|
|
54
|
+
export function problemOf(document) {
|
|
55
|
+
const result = document.signature === undefined ? validatePolicyDraft(unsignedOf(document)) : validatePolicy(document);
|
|
56
|
+
return result.valid ? null : result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A timestamp of the format from a date the person writes: 2027-04-28 or 2027-04-28T00:00:00Z. */
|
|
60
|
+
export function notAfterOf(value) {
|
|
61
|
+
const text = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value;
|
|
62
|
+
if (!isNotAfter(text)) {
|
|
63
|
+
throw new IasError(`«${value}» is not a date the policy takes`, { fix: 'use 2027-04-28, or the whole UTC timestamp 2027-04-28T00:00:00Z', code: 'policy.not_after' });
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The default not_after: 180 days from today, at midnight UTC, as the plan recommends. */
|
|
69
|
+
export function defaultNotAfter(now = new Date()) {
|
|
70
|
+
return `${new Date(now.getTime() + 180 * DAY_MS).toISOString().slice(0, 10)}T00:00:00Z`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The days from now to a not_after, rounded up; negative when it has passed. */
|
|
74
|
+
export const daysUntil = (notAfter, now = new Date()) => Math.ceil((Date.parse(notAfter) - now.getTime()) / DAY_MS);
|
|
75
|
+
|
|
76
|
+
/** «any app · iOS · production · build · Google Play · track internal». */
|
|
77
|
+
export function scopeOf(rule) {
|
|
78
|
+
const scope = [
|
|
79
|
+
rule.app === '*' ? 'any app' : rule.app,
|
|
80
|
+
rule.platform === '*' ? 'any platform' : rule.platform === 'ios' ? 'iOS' : 'Android',
|
|
81
|
+
rule.profile === '*' ? 'any profile' : rule.profile,
|
|
82
|
+
rule.type === '*' ? 'any job' : rule.type === 'build' ? 'build' : 'submission',
|
|
83
|
+
];
|
|
84
|
+
if (rule.target !== '*') scope.push(rule.target === 'google_play' ? 'Google Play' : 'App Store Connect');
|
|
85
|
+
if (rule.track !== '*') scope.push(`track ${rule.track}`);
|
|
86
|
+
return scope.join(' · ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The members of a policy, in order, without repeating. */
|
|
90
|
+
export function membersOf(document) {
|
|
91
|
+
const members = [];
|
|
92
|
+
for (const rule of document.rules ?? []) for (const member of rule.require?.members ?? []) if (!members.includes(member)) members.push(member);
|
|
93
|
+
return members;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The ids of the members with a passkey in ias-approvers-1, from GET /team/approvers. */
|
|
97
|
+
export function holdersOf(listed) {
|
|
98
|
+
return [...new Set((listed.data ?? []).map((entry) => entry.member).filter(Boolean))];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The names of the members with a passkey, by id. */
|
|
102
|
+
export function namesOf(listed) {
|
|
103
|
+
return Object.fromEntries((listed.data ?? []).map((entry) => [entry.member, entry.name]).filter(([id]) => id));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** «2 of 2 (Ana, Efraín)». */
|
|
107
|
+
export function requirementOf(rule, names = {}) {
|
|
108
|
+
const people = rule.require.members.map((member) => names[member] ?? member);
|
|
109
|
+
return `${rule.require.count} of ${rule.require.members.length}${people.length > 0 ? ` (${people.join(', ')})` : ''}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The signing key of the team: the server's, compared with the one in 1Password when op can read it. */
|
|
113
|
+
export async function signingKeyOfTeam(client, context, project) {
|
|
114
|
+
const { data: vault } = await client.request('GET', '/vault');
|
|
115
|
+
if (!vault.signing_key) {
|
|
116
|
+
throw new IasError('the vault has no keys yet', { fix: 'an owner of the team runs ias vault init', code: 'vault_not_ready' });
|
|
117
|
+
}
|
|
118
|
+
const server = { kid: vault.signing_key.kid, publicKey: vault.signing_key.public_key };
|
|
119
|
+
const item = vaultItem(context, project, client.server);
|
|
120
|
+
let local;
|
|
121
|
+
try {
|
|
122
|
+
local = publicKeysOf(item, context.env);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { key: server, compared: false, warning: `${error.what ?? error.message}: the signature is checked with the key the server gives` };
|
|
125
|
+
}
|
|
126
|
+
if (!local) return { key: server, compared: false, warning: `there is no ${item.reference} in 1Password: the signature is checked with the key the server gives` };
|
|
127
|
+
if (local.signing.kid !== server.kid || local.signing.publicKey !== server.publicKey) {
|
|
128
|
+
throw new IasError(`the signing key of the server is not the one in ${item.reference}`, { fix: 'do not trust the policy: ias vault status shows both, and someone may be trying to slip in a key of their own', code: 'vault_keys_mismatch' });
|
|
129
|
+
}
|
|
130
|
+
return { key: local.signing, compared: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** GET /team/policy as the server answers it. */
|
|
134
|
+
export async function serverPolicy(client) {
|
|
135
|
+
return (await client.request('GET', '/team/policy')).data;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The policy the server serves, verified with the team's key: null when there is none. */
|
|
139
|
+
export function verifiedPolicy(current, key) {
|
|
140
|
+
if (!current?.document) return null;
|
|
141
|
+
try {
|
|
142
|
+
return verifyPolicy(current.document, key, { checkExpiry: false });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new IasError(`the policy of the server does not verify with the team's key (${error.code})`, {
|
|
145
|
+
fix: 'do not rely on it: someone may have changed it on the server; a person should check Sistema › Política, and ias policy sign stores a new one only through an owner\'s passkey',
|
|
146
|
+
code: 'policy_invalid',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The rules that appear, change or go, and the members that enter or leave, between two policies. */
|
|
152
|
+
export function diffPolicies(before, after) {
|
|
153
|
+
const previous = new Map((before?.rules ?? []).map((rule) => [rule.id, rule]));
|
|
154
|
+
const next = new Map((after?.rules ?? []).map((rule) => [rule.id, rule]));
|
|
155
|
+
const added = [...next.keys()].filter((id) => !previous.has(id));
|
|
156
|
+
const removed = [...previous.keys()].filter((id) => !next.has(id));
|
|
157
|
+
const changed = [...next.keys()].filter((id) => previous.has(id) && JSON.stringify(previous.get(id)) !== JSON.stringify(next.get(id)));
|
|
158
|
+
const beforeMembers = new Set(membersOf(before ?? { rules: [] }));
|
|
159
|
+
const afterMembers = new Set(membersOf(after));
|
|
160
|
+
return {
|
|
161
|
+
added: added.map((id) => next.get(id)),
|
|
162
|
+
changed: changed.map((id) => ({ before: previous.get(id), after: next.get(id) })),
|
|
163
|
+
removed: removed.map((id) => previous.get(id)),
|
|
164
|
+
members_added: [...afterMembers].filter((member) => !beforeMembers.has(member)),
|
|
165
|
+
members_removed: [...beforeMembers].filter((member) => !afterMembers.has(member)),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One line for the rules a diff found: +ids, ~ids, −ids and the members that enter or leave. */
|
|
170
|
+
export function diffLine(diff) {
|
|
171
|
+
const parts = [
|
|
172
|
+
diff.added.length > 0 ? `+ ${diff.added.map((rule) => rule.id).join(', ')}` : null,
|
|
173
|
+
diff.changed.length > 0 ? `~ ${diff.changed.map((entry) => entry.after.id).join(', ')}` : null,
|
|
174
|
+
diff.removed.length > 0 ? `− ${diff.removed.map((rule) => rule.id).join(', ')}` : null,
|
|
175
|
+
diff.members_added.length > 0 ? `+${diff.members_added.length} member${diff.members_added.length === 1 ? '' : 's'}` : null,
|
|
176
|
+
diff.members_removed.length > 0 ? `−${diff.members_removed.length} member${diff.members_removed.length === 1 ? '' : 's'}` : null,
|
|
177
|
+
];
|
|
178
|
+
return parts.filter(Boolean).join(' · ') || 'no change in the rules';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The hash the CLI shows of a policy: the SHA-256 of its canonical text. */
|
|
182
|
+
export { policyHash };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The approval policy of a job (docs/plans/gaps/06-politica-aprobacion.md §3.8): what ias build, ias submit,
|
|
2
|
+
// ias testflight and ias rollout print when the job carries a rule, so nobody waits for an approval that is
|
|
3
|
+
// already there or misses the second one. A job of a team without a policy has none and nothing is said: the
|
|
4
|
+
// common path stays as it was.
|
|
5
|
+
|
|
6
|
+
/** The line of a job: «policy ios-production · 1 of 2», or null without a rule. */
|
|
7
|
+
export function policyLine(job) {
|
|
8
|
+
const policy = job?.policy;
|
|
9
|
+
if (!policy || typeof policy.rule !== 'string' || policy.rule === '') return null;
|
|
10
|
+
const count = Number.isInteger(policy.count) ? policy.count : 1;
|
|
11
|
+
const approved = Number.isInteger(policy.approved) ? policy.approved : 0;
|
|
12
|
+
return `policy ${policy.rule} · ${approved} of ${count}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Says the rule and the approvals of a job when it has one, and returns that line (or null). */
|
|
16
|
+
export function sayPolicy(output, job) {
|
|
17
|
+
const line = policyLine(job);
|
|
18
|
+
if (line !== null) output.progress(` ${line}`);
|
|
19
|
+
return line;
|
|
20
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// ias releases: the releases of Google Play and the policy of each track (docs/plans/gaps/03-google-play-rollout.md
|
|
2
|
+
// §4.3 and §4.10). `list` and `view` only read the state IAS keeps: no flags needed, no job, no passkey. `--refresh`
|
|
3
|
+
// queues the read-only release_status job and waits for it. `policy` shows a track's policy, and an owner sets it with
|
|
4
|
+
// flags (the same permission the panel edits): without a row, a track only takes drafts.
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { loadProject } from '../../config.mjs';
|
|
7
|
+
import { IasError } from '../../errors.mjs';
|
|
8
|
+
import { applicationOf, connect, sleep } from '../../manage.mjs';
|
|
9
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
10
|
+
import { buildsClient, isFinished } from '../build/follow.mjs';
|
|
11
|
+
import { PLAY_TRACK } from '../../shared.mjs';
|
|
12
|
+
|
|
13
|
+
const TRACK = PLAY_TRACK;
|
|
14
|
+
const MODES = Object.freeze(['draft', 'staged', 'full']);
|
|
15
|
+
const MODE_LABELS = Object.freeze({ draft: 'drafts only', staged: 'staged', full: 'full' });
|
|
16
|
+
|
|
17
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
18
|
+
const clientOf = (context, project = projectOf(context)) => buildsClient(connect(context, project));
|
|
19
|
+
const trackOf = (context) => {
|
|
20
|
+
const track = context.positionals[0];
|
|
21
|
+
if (!TRACK.test(track ?? '')) throw new IasError(`«${track ?? ''}» is not a track of Google Play`, { fix: 'production, beta, alpha, internal, a closed track or a form factor\'s (wear:production)' });
|
|
22
|
+
return track;
|
|
23
|
+
};
|
|
24
|
+
const date = (value) => (value ? value.slice(0, 16).replace('T', ' ') : 'never');
|
|
25
|
+
const percentText = (release) => (release?.percent === null || release?.percent === undefined ? '' : ` · ${release.percent} %`);
|
|
26
|
+
const stateLine = (release) => `${release.track} · ${release.version ?? '?'} (${release.version_code ?? '?'}) · ${release.status ?? 'unknown'}${percentText(release)}${release.lifecycle ? ` · ${String(release.lifecycle).toLowerCase().replace(/_/g, ' ')}` : ''} · read ${date(release.observed_at)}`;
|
|
27
|
+
|
|
28
|
+
/** The whole number of a flag, or undefined. */
|
|
29
|
+
function number(value, flag, { min, max }) {
|
|
30
|
+
if (value === undefined) return undefined;
|
|
31
|
+
if (!/^[0-9]+$/.test(String(value))) throw new IasError(`--${flag} ${value} is not a whole number`, { fix: `a whole number from ${min} to ${max}` });
|
|
32
|
+
const parsed = Number(value);
|
|
33
|
+
if (parsed < min || parsed > max) throw new IasError(`--${flag} ${parsed} is outside ${min}-${max}`, { fix: `use a whole number from ${min} to ${max}` });
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The current state of a track; a 404 names the command that creates it. */
|
|
38
|
+
async function stateOf(client, app, track, { allowMissing = false } = {}) {
|
|
39
|
+
try {
|
|
40
|
+
return (await client.request('GET', `/apps/${encodeURIComponent(app)}/releases/${encodeURIComponent(track)}`)).data;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (allowMissing && (error.code === 'release_not_found' || error.status === 404)) return null;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Waits for a release/release_status job, polling the state of the track; returns the state it left. */
|
|
48
|
+
async function waitForJob(context, client, app, track, job) {
|
|
49
|
+
const { output } = context;
|
|
50
|
+
const wait = context.sleep ?? sleep;
|
|
51
|
+
let said = job.status;
|
|
52
|
+
for (let attempt = 0; attempt < 240; attempt += 1) {
|
|
53
|
+
await wait(5);
|
|
54
|
+
let state = null;
|
|
55
|
+
try {
|
|
56
|
+
state = await stateOf(client, app, track);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// The first read of a track IAS did not know: the answer carries the job, not a release yet.
|
|
59
|
+
const seen = error.code === 'release_not_found' ? error.data?.job : null;
|
|
60
|
+
if (seen === null || seen === undefined) throw error;
|
|
61
|
+
state = { track, job: seen };
|
|
62
|
+
}
|
|
63
|
+
const current = state?.job?.id === job.id ? state.job : null;
|
|
64
|
+
if (current && current.status !== said) {
|
|
65
|
+
said = current.status;
|
|
66
|
+
output.progress(` ${current.status}${current.step ? ` (${current.step})` : ''}${current.runner?.name ? ` on ${current.runner.name}` : ''}`);
|
|
67
|
+
}
|
|
68
|
+
if (current && isFinished(current.status)) return { ...state, job: current };
|
|
69
|
+
}
|
|
70
|
+
throw new IasError(`the job ${job.id} of ${track} did not end in time`, { fix: `ias releases view ${track} shows the state IAS has; if nothing changed, run it again` });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads the releases of every track IAS knows of, one job per track. */
|
|
74
|
+
async function refreshAll(context, client, app) {
|
|
75
|
+
const { data } = await client.request('GET', `/apps/${encodeURIComponent(app)}/releases`);
|
|
76
|
+
const tracks = (data.data ?? []).map((release) => release.track);
|
|
77
|
+
for (const track of tracks) {
|
|
78
|
+
const { data: job } = await client.request('POST', `/apps/${encodeURIComponent(app)}/releases/${encodeURIComponent(track)}/refresh`, { json: {} });
|
|
79
|
+
context.output.progress(`${track}: reading Play (job ${job.id})`);
|
|
80
|
+
await waitForJob(context, client, app, track, job);
|
|
81
|
+
}
|
|
82
|
+
return tracks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function list(context) {
|
|
86
|
+
const { output, values } = context;
|
|
87
|
+
const project = projectOf(context);
|
|
88
|
+
const app = applicationOf(context, project);
|
|
89
|
+
const client = clientOf(context, project);
|
|
90
|
+
if (values.refresh) await refreshAll(context, client, app);
|
|
91
|
+
const { data } = await client.request('GET', `/apps/${encodeURIComponent(app)}/releases`);
|
|
92
|
+
const releases = data.data ?? [];
|
|
93
|
+
|
|
94
|
+
output.log(`releases of ${app}:`);
|
|
95
|
+
if (releases.length === 0) output.log(' none yet: ias submit <build> sends a build to a track (a draft by default)');
|
|
96
|
+
for (const release of releases) {
|
|
97
|
+
output.log(` ${stateLine(release)}`);
|
|
98
|
+
output.log(` next: ${release.next}`);
|
|
99
|
+
}
|
|
100
|
+
if (releases.length > 0) output.log(`read one track again: ias releases view <track> --refresh; move one: ias rollout <track>`);
|
|
101
|
+
|
|
102
|
+
return { app, releases };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function view(context) {
|
|
106
|
+
const { output, values } = context;
|
|
107
|
+
const project = projectOf(context);
|
|
108
|
+
const app = applicationOf(context, project);
|
|
109
|
+
const client = clientOf(context, project);
|
|
110
|
+
const track = trackOf(context);
|
|
111
|
+
let state = await stateOf(client, app, track, { allowMissing: true });
|
|
112
|
+
|
|
113
|
+
if (values.refresh) {
|
|
114
|
+
const { data: job } = await client.request('POST', `/apps/${encodeURIComponent(app)}/releases/${encodeURIComponent(track)}/refresh`, { json: {} });
|
|
115
|
+
output.progress(`${track}: reading Play (job ${job.id}, no passkey: it changes nothing)`);
|
|
116
|
+
state = await waitForJob(context, client, app, track, job);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (state === null) {
|
|
120
|
+
throw new IasError(`${app} has no release in ${track} that IAS knows of`,
|
|
121
|
+
{ fix: `send a store Android build first: ias submit <build> --track ${track}; ias releases list shows the tracks IAS knows` });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
output.log(`release of ${app} in ${track}: ${state.status ?? 'unknown'}${percentText(state)}${state.lifecycle ? ` (${String(state.lifecycle).toLowerCase().replace(/_/g, ' ')})` : ''}`);
|
|
125
|
+
output.log(` version ${state.version ?? '?'} (version code ${state.version_code ?? '?'})${state.release_name ? `, ${state.release_name}` : ''}`);
|
|
126
|
+
output.log(` read ${date(state.observed_at)}${state.edit_id ? ` · edit ${state.edit_id}` : ''}`);
|
|
127
|
+
output.log(` policy ${state.policy ? `${MODE_LABELS[state.policy.mode]} · ceiling ${state.policy.max_percent} % · step ${state.policy.step_limit} % · wait ${state.policy.min_minutes} min` : 'none: only drafts. An owner creates it with ias releases policy ' + track}`);
|
|
128
|
+
if (state.job) output.log(` last job ${state.job.action}: ${state.job.status}${state.job.error ? ` — ${state.job.error.code}` : ''}`);
|
|
129
|
+
output.log(` next ${state.next}`);
|
|
130
|
+
|
|
131
|
+
return { app, track, ...state };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function policy(context) {
|
|
135
|
+
const { output, values } = context;
|
|
136
|
+
const project = projectOf(context);
|
|
137
|
+
const app = applicationOf(context, project);
|
|
138
|
+
const client = clientOf(context, project);
|
|
139
|
+
const track = trackOf(context);
|
|
140
|
+
const { data } = await client.request('GET', `/apps/${encodeURIComponent(app)}/releases/policies`);
|
|
141
|
+
const current = (data.data ?? []).find((candidate) => candidate.track === track) ?? null;
|
|
142
|
+
const flags = values.mode !== undefined || values['max-percent'] !== undefined || values.step !== undefined || values['min-minutes'] !== undefined;
|
|
143
|
+
|
|
144
|
+
if (!flags) {
|
|
145
|
+
if (current === null) {
|
|
146
|
+
output.log(`${track}: no policy: IAS only sends drafts there (as always). An owner can allow a staged rollout with:`);
|
|
147
|
+
output.log(` ias releases policy ${track} --mode staged --max-percent 50 --step 20`);
|
|
148
|
+
return { app, track, policy: null, next: `ias releases policy ${track} --mode staged --max-percent 50 --step 20` };
|
|
149
|
+
}
|
|
150
|
+
output.log(`policy of ${app} in ${track}: ${MODE_LABELS[current.mode]} · ceiling ${current.max_percent} % · step ${current.step_limit} % · wait ${current.min_minutes} min`);
|
|
151
|
+
output.log(` next ias rollout ${track} --percent ${Math.min(current.max_percent, current.step_limit)}`);
|
|
152
|
+
return { app, track, policy: current };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (values.mode !== undefined && !MODES.includes(values.mode)) throw new IasError(`--mode ${values.mode} is not a release mode`, { fix: 'draft, staged or full' });
|
|
156
|
+
const update = {
|
|
157
|
+
mode: values.mode ?? current?.mode ?? 'staged',
|
|
158
|
+
max_percent: number(values['max-percent'], 'max-percent', { min: 1, max: 100 }) ?? current?.max_percent ?? 100,
|
|
159
|
+
step_limit: number(values.step, 'step', { min: 1, max: 100 }) ?? current?.step_limit ?? 20,
|
|
160
|
+
min_minutes: number(values['min-minutes'], 'min-minutes', { min: 0, max: 10080 }) ?? current?.min_minutes ?? 0,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
output.log(`policy of ${app} in ${track}: ${MODE_LABELS[update.mode]} · ceiling ${update.max_percent} % · step ${update.step_limit} % · wait ${update.min_minutes} min (an owner)`);
|
|
164
|
+
if (values['dry-run']) {
|
|
165
|
+
output.progress('Nothing was saved: run it again without --dry-run.');
|
|
166
|
+
return { app, track, policy: update, dry_run: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { data: saved } = await client.request('PUT', `/apps/${encodeURIComponent(app)}/releases/policies/${encodeURIComponent(track)}`, { json: update });
|
|
170
|
+
output.log(`saved: the track now allows a release up to ${saved.max_percent} %, ${saved.step_limit} points per change, ${saved.min_minutes} minutes apart.`);
|
|
171
|
+
output.log(`next ias submit <build> --track ${track} --release-status inProgress --rollout ${Math.min(saved.max_percent, saved.step_limit)}, or ias rollout ${track} --percent ${Math.min(saved.max_percent, saved.step_limit)}`);
|
|
172
|
+
return { app, track, policy: saved };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default {
|
|
176
|
+
name: 'releases',
|
|
177
|
+
summary: 'The releases of Google Play: what each track serves, its history and its policy',
|
|
178
|
+
description: [
|
|
179
|
+
'Without a subcommand, ias releases lists the tracks IAS knows of the application. list and view only read the state IAS keeps (no job, no passkey, no flags); --refresh queues the read-only release_status job, lets the runner read Play and waits for it. A track without a policy only takes drafts, as always; an owner allows staged rollouts there with ias releases policy <track> (the panel edits the same policy).',
|
|
180
|
+
'To move a release that is already in a track, use ias rollout.',
|
|
181
|
+
].join('\n'),
|
|
182
|
+
options: {
|
|
183
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
184
|
+
refresh: { type: 'boolean', description: 'Read Play again with the read-only job, and wait for it.' },
|
|
185
|
+
},
|
|
186
|
+
subcommands: [
|
|
187
|
+
{
|
|
188
|
+
name: 'list',
|
|
189
|
+
summary: 'The releases of every track, with their next step; --refresh reads Play again',
|
|
190
|
+
description: 'Only reads: one line per track with the version, version code, status, percentage, review lifecycle and when IAS last read it. It never creates a job unless --refresh is given.',
|
|
191
|
+
options: {
|
|
192
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
193
|
+
refresh: { type: 'boolean', description: 'Read every known track again with the read-only job, and wait for it.' },
|
|
194
|
+
},
|
|
195
|
+
run: list,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'view',
|
|
199
|
+
summary: 'What one track serves, its policy, its last job and the next step',
|
|
200
|
+
description: 'Only reads the state IAS keeps. With --refresh it queues the read-only release_status job (no approval), waits for the runner and shows what Play has.',
|
|
201
|
+
positionals: [{ name: 'track', description: 'The track of Google Play, as ias submit --track names it.' }],
|
|
202
|
+
options: {
|
|
203
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
204
|
+
refresh: { type: 'boolean', description: 'Read Play again with the read-only job, and wait for it.' },
|
|
205
|
+
},
|
|
206
|
+
run: view,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'policy',
|
|
210
|
+
summary: 'Show the release policy of a track, or set it (an owner)',
|
|
211
|
+
description: 'Without flags it shows the policy; without a row, it says that the track only takes drafts and gives the command. With flags an owner sets it: it is the permission of every rollout, so production needs one explicitly. The panel (application › Ajustes › Google Play) edits the same policy.',
|
|
212
|
+
positionals: [{ name: 'track', description: 'The track of Google Play, as ias submit --track names it.' }],
|
|
213
|
+
options: {
|
|
214
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
215
|
+
mode: { type: 'string', value: '<draft|staged|full>', description: 'What the track allows: drafts only, a gradual rollout or completing it to everyone.' },
|
|
216
|
+
'max-percent': { type: 'string', value: '<1-100>', description: 'The ceiling of the rollout (staged and full).' },
|
|
217
|
+
step: { type: 'string', value: '<1-100>', description: 'How many points one change may raise the rollout.' },
|
|
218
|
+
'min-minutes': { type: 'string', value: '<minutes>', description: 'The minimum wait between changes, to protect Play\'s review queue.' },
|
|
219
|
+
'dry-run': { type: 'boolean', description: 'Show the policy that would be saved, without saving it.' },
|
|
220
|
+
},
|
|
221
|
+
run: policy,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
run: list,
|
|
225
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ias rollout: moves the release of a Google Play track that is already there (docs/plans/gaps/03-google-play-rollout.md
|
|
2
|
+
// §4.3 and §4.7). It sends no artifact: the binary was approved by its submit. Without an action flag it only reads the
|
|
3
|
+
// state and says the next step; --percent, --halt, --resume or --complete create one job, which an owner approves with a
|
|
4
|
+
// passkey. The server checks the policy of the track (ceiling, step, wait) and the state the caller expected.
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { loadProject } from '../../config.mjs';
|
|
7
|
+
import { IasError } from '../../errors.mjs';
|
|
8
|
+
import { applicationOf, connect, openUrl, sleep } from '../../manage.mjs';
|
|
9
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
10
|
+
import { buildsClient, isFinished } from '../build/follow.mjs';
|
|
11
|
+
import { sayPolicy } from '../policy/progress.mjs';
|
|
12
|
+
import { PLAY_TRACK } from '../../shared.mjs';
|
|
13
|
+
|
|
14
|
+
const TRACK = PLAY_TRACK;
|
|
15
|
+
const MODE_LABELS = Object.freeze({ draft: 'drafts only', staged: 'staged', full: 'full' });
|
|
16
|
+
|
|
17
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
18
|
+
const clientOf = (context, project = projectOf(context)) => buildsClient(connect(context, project));
|
|
19
|
+
const date = (value) => (value ? value.slice(0, 16).replace('T', ' ') : 'never');
|
|
20
|
+
|
|
21
|
+
function trackOf(context) {
|
|
22
|
+
const track = context.positionals[0];
|
|
23
|
+
if (!TRACK.test(track ?? '')) throw new IasError(`«${track ?? ''}» is not a track of Google Play`, { fix: 'production, beta, alpha, internal, a closed track or a form factor\'s (wear:production)' });
|
|
24
|
+
return track;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The state of the track, from the cache; a 404 names the command that sends a release. */
|
|
28
|
+
async function stateOf(client, app, track) {
|
|
29
|
+
try {
|
|
30
|
+
return (await client.request('GET', `/apps/${encodeURIComponent(app)}/releases/${encodeURIComponent(track)}`)).data;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === 'release_not_found' || error.status === 404) {
|
|
33
|
+
throw new IasError(`${app} has no release in ${track} that IAS knows of`, {
|
|
34
|
+
fix: `send a store Android build first: ias submit <build> --track ${track}; ias releases view ${track} --refresh reads what Play has`,
|
|
35
|
+
code: 'release_not_found',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The action and percentage the flags ask for. */
|
|
43
|
+
function planOf(context, release) {
|
|
44
|
+
const { values } = context;
|
|
45
|
+
const actions = [values.halt === true, values.resume === true, values.complete === true].filter(Boolean).length;
|
|
46
|
+
if (actions > 1) throw new IasError('ias rollout takes one action at a time', { fix: 'one of --percent <n>, --halt, --resume or --complete' });
|
|
47
|
+
|
|
48
|
+
let percent;
|
|
49
|
+
if (values.percent !== undefined) {
|
|
50
|
+
if (!/^[0-9]+$/.test(String(values.percent))) throw new IasError(`--percent ${values.percent} is not a whole number`, { fix: 'a rollout serves a whole percentage from 1 to 99' });
|
|
51
|
+
percent = Number(values.percent);
|
|
52
|
+
if (percent < 1 || percent > 99) throw new IasError(`--percent ${percent} is outside 1-99`, { fix: 'the 100 % is --complete; to stop new users use --halt' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (values.halt) {
|
|
56
|
+
if (percent !== undefined) throw new IasError('--halt takes no --percent', { fix: `ias rollout ${context.positionals[0]} --halt pauses the rollout where it is` });
|
|
57
|
+
return { action: 'halt', percent: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (values.complete) {
|
|
61
|
+
if (percent !== undefined) throw new IasError('--complete takes no --percent', { fix: `ias rollout ${context.positionals[0]} --complete serves it to everyone (100 %)` });
|
|
62
|
+
return { action: 'complete', percent: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (values.resume) return { action: 'resume', percent: percent ?? null };
|
|
66
|
+
|
|
67
|
+
if (percent !== undefined) {
|
|
68
|
+
if (release.status === 'halted') {
|
|
69
|
+
throw new IasError(`${context.positionals[0]} is halted: ias does not raise it by stealth`, { fix: `ias rollout ${context.positionals[0]} --resume${percent === undefined ? '' : ` --percent ${percent}`} resumes it` });
|
|
70
|
+
}
|
|
71
|
+
if (release.status === 'completed') throw new IasError(`${context.positionals[0]} is completed: there is nothing to raise`, { fix: 'send the next version with ias submit --release-status inProgress --rollout <1-99>' });
|
|
72
|
+
return { action: 'raise', percent };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printState(output, app, release) {
|
|
79
|
+
const percent = release.percent === null || release.percent === undefined ? '' : ` · ${release.percent} %`;
|
|
80
|
+
output.log(`${app} · ${release.track}: ${release.status ?? 'unknown'}${percent}${release.lifecycle ? ` (${String(release.lifecycle).toLowerCase().replace(/_/g, ' ')})` : ''}`);
|
|
81
|
+
output.log(` version ${release.version ?? '?'} (version code ${release.version_code ?? '?'})${release.release_name ? `, ${release.release_name}` : ''}`);
|
|
82
|
+
output.log(` policy ${release.policy ? `${MODE_LABELS[release.policy.mode]} · ceiling ${release.policy.max_percent} % · step ${release.policy.step_limit} % · wait ${release.policy.min_minutes} min` : 'none: only drafts. ias releases policy ' + release.track}`);
|
|
83
|
+
output.log(` read ${date(release.observed_at)}`);
|
|
84
|
+
output.log(` next ${release.next}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Waits for the job of the rollout, polling the state of the track. */
|
|
88
|
+
async function waitForJob(context, client, app, track, job) {
|
|
89
|
+
const { output } = context;
|
|
90
|
+
const wait = context.sleep ?? sleep;
|
|
91
|
+
let said = job.status;
|
|
92
|
+
for (let attempt = 0; attempt < 240; attempt += 1) {
|
|
93
|
+
await wait(5);
|
|
94
|
+
const state = await stateOf(client, app, track);
|
|
95
|
+
const current = state.job?.id === job.id ? state.job : null;
|
|
96
|
+
if (current && current.status !== said) {
|
|
97
|
+
said = current.status;
|
|
98
|
+
output.progress(` ${current.status}${current.step ? ` (${current.step})` : ''}${current.runner?.name ? ` on ${current.runner.name}` : ''}`);
|
|
99
|
+
}
|
|
100
|
+
if (current && isFinished(current.status)) return { ...state, job: current };
|
|
101
|
+
}
|
|
102
|
+
throw new IasError(`the rollout of ${track} did not end in time`, { fix: `ias releases view ${track} shows the state IAS has; if it did not change, read it with --refresh` });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function outcome(output, state) {
|
|
106
|
+
const job = state.job;
|
|
107
|
+
if (job.status !== 'succeeded') {
|
|
108
|
+
const error = job.error ? `${job.error.code}${job.error.step ? ` at ${job.error.step}` : ''}${job.error.message ? `: ${job.error.message}` : ''}` : '';
|
|
109
|
+
output.log(`rollout of ${state.track}: ${job.status}${error ? ` — ${error}` : ''}`);
|
|
110
|
+
return { ...state, [EXIT_CODE]: 1 };
|
|
111
|
+
}
|
|
112
|
+
const result = job.result ?? {};
|
|
113
|
+
const before = result.before ?? {};
|
|
114
|
+
const after = result.after ?? {};
|
|
115
|
+
const describe = (state) => `${state.status ?? '?'}${state.percent === null || state.percent === undefined ? '' : ` ${state.percent} %`}`;
|
|
116
|
+
output.log(`rollout of ${state.track}: ${describe(before)} → ${describe(after)}${result.editId ? ` (edit ${result.editId})` : ''}`);
|
|
117
|
+
output.log(`next ${state.next}`);
|
|
118
|
+
return state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function rollout(context) {
|
|
122
|
+
const { output, values } = context;
|
|
123
|
+
const project = projectOf(context);
|
|
124
|
+
const app = applicationOf(context, project);
|
|
125
|
+
const client = clientOf(context, project);
|
|
126
|
+
const track = trackOf(context);
|
|
127
|
+
const release = await stateOf(client, app, track);
|
|
128
|
+
const plan = planOf(context, release);
|
|
129
|
+
|
|
130
|
+
if (plan === null) {
|
|
131
|
+
printState(output, app, release);
|
|
132
|
+
return { app, track, release, next: release.next, changed: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const versionCode = values['version-code'] === undefined ? undefined : Number(values['version-code']);
|
|
136
|
+
if (versionCode !== undefined && (!Number.isInteger(versionCode) || versionCode < 1)) throw new IasError(`--version-code ${values['version-code']} is not a version code`, { fix: 'the whole number Play assigned to the build: ias releases view <track> shows it' });
|
|
137
|
+
|
|
138
|
+
const body = { action: plan.action, ...(plan.percent === null ? {} : { percent: plan.percent }), ...(versionCode === undefined ? {} : { versionCode }) };
|
|
139
|
+
output.log(`${app} · ${track}: ${plan.action}${plan.percent === null ? '' : ` to ${plan.percent} %`} (version code ${versionCode ?? release.version_code ?? '?'})`);
|
|
140
|
+
|
|
141
|
+
if (values['dry-run']) {
|
|
142
|
+
output.progress('Nothing was created: an owner approves the real one with a passkey.');
|
|
143
|
+
return { app, track, release, ...body, dry_run: true, job: null };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { data } = await client.request('POST', `/apps/${encodeURIComponent(app)}/releases/${encodeURIComponent(track)}/rollout`, { json: body });
|
|
147
|
+
|
|
148
|
+
if (data.changed === false) {
|
|
149
|
+
output.log(`nothing to do: ${track} already is what you asked for.`);
|
|
150
|
+
return data;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const job = data.job;
|
|
154
|
+
output.progress(`${track}: job ${job.id}, ${job.status} (version code ${job.version_code ?? '?'})`);
|
|
155
|
+
sayPolicy(output, job);
|
|
156
|
+
if (job.approval_url) {
|
|
157
|
+
const opened = (context.openUrl ?? ((url) => openUrl(url, context.env)))(job.approval_url);
|
|
158
|
+
output.progress(`${opened ? 'Opened' : 'Open'} ${job.approval_url}: an owner approves it with a passkey (the panel shows the hash ${job.hash}).`);
|
|
159
|
+
}
|
|
160
|
+
if (!values.wait) {
|
|
161
|
+
output.log(`ias releases view ${track} shows how it goes; the runner moves it once it is approved.`);
|
|
162
|
+
return data;
|
|
163
|
+
}
|
|
164
|
+
return outcome(output, await waitForJob(context, client, app, track, job));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default {
|
|
168
|
+
name: 'rollout',
|
|
169
|
+
summary: 'Move the release of a Google Play track: raise its percentage, halt, resume or complete it',
|
|
170
|
+
description: [
|
|
171
|
+
'The release has to be in the track already (ias submit put it there, draft, inProgress or completed). Without an action flag this is read-only: it shows the state, the policy and the next step, and creates nothing. With --percent (raise), --halt, --resume [--percent] or --complete it creates one job that an owner approves with a passkey; the runner checks Play and writes the change without sending an artifact again.',
|
|
172
|
+
'The policy of the track is the permission: an owner sets it with ias releases policy <track>. A rollout never goes down (who has the release keeps it); use --halt to stop new users. Play reviews from the last submission, so the policy can also set a minimum wait.',
|
|
173
|
+
].join('\n'),
|
|
174
|
+
positionals: [{ name: 'track', description: 'The track of Google Play, as ias submit --track names it.' }],
|
|
175
|
+
options: {
|
|
176
|
+
app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
|
|
177
|
+
percent: { type: 'string', value: '<1-99>', description: 'Raise the rollout to this whole percentage (never down).' },
|
|
178
|
+
halt: { type: 'boolean', description: 'Stop serving it to new users; who has it keeps it.' },
|
|
179
|
+
resume: { type: 'boolean', description: 'Resume a halted rollout, at its percentage or at --percent.' },
|
|
180
|
+
complete: { type: 'boolean', description: 'Serve it to everyone (100 %); needs a full policy.' },
|
|
181
|
+
'version-code': { type: 'string', value: '<n>', description: 'The release to move (the track\'s current one by default).' },
|
|
182
|
+
wait: { type: 'boolean', description: 'Wait until the runner moved it, and say the state before and after.' },
|
|
183
|
+
'dry-run': { type: 'boolean', description: 'Show what would be approved, without creating anything.' },
|
|
184
|
+
},
|
|
185
|
+
run: rollout,
|
|
186
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ias runners: the machines that build. code makes a one-time code to register one with ias-runner, valid for 30
|
|
2
|
+
// minutes and once; the runner stays pending until a member authorizes it with ias vault grant. It needs an owner's
|
|
3
|
+
// personal token.
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { createRunnerCode } from '../../provisioning.mjs';
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
name: 'runners',
|
|
9
|
+
summary: 'The runners of the team: a one-time code to register one',
|
|
10
|
+
description: 'A runner registers with ias-runner register and the code this prints, and a member authorizes it with ias vault grant. The code is valid for 30 minutes and once.',
|
|
11
|
+
subcommands: [
|
|
12
|
+
{
|
|
13
|
+
name: 'code',
|
|
14
|
+
summary: 'Make a one-time code to register a runner, and print the command for its Mac',
|
|
15
|
+
options: { 'dry-run': { type: 'boolean', description: 'Say what it would make and make nothing.' } },
|
|
16
|
+
run: (context) => createRunnerCode({ dir: resolve(context.cwd, context.values.project ?? '.'), env: context.env, output: context.output, sleep: context.sleep, dryRun: context.values['dry-run'] }),
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
};
|