@htgabriel/release-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.js +2900 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +957 -0
- package/dist/index.js +1881 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -0
- package/schema.json +308 -0
- package/templates/release-kit.config.example.json +42 -0
|
@@ -0,0 +1,2900 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/exec/run.ts
|
|
13
|
+
var run_exports = {};
|
|
14
|
+
__export(run_exports, {
|
|
15
|
+
ReleaseKitError: () => ReleaseKitError,
|
|
16
|
+
defaultRun: () => defaultRun,
|
|
17
|
+
fail: () => fail,
|
|
18
|
+
formatCommand: () => formatCommand,
|
|
19
|
+
requireSuccess: () => requireSuccess
|
|
20
|
+
});
|
|
21
|
+
import crossSpawn from "cross-spawn";
|
|
22
|
+
function fail(message, code = 1) {
|
|
23
|
+
throw new ReleaseKitError(message, { code });
|
|
24
|
+
}
|
|
25
|
+
function formatCommand(command, args, env = {}) {
|
|
26
|
+
const exported = Object.entries(env).filter(([key, value]) => value !== void 0 && (key === "APP_VARIANT" || key.startsWith("EAS_"))).map(([key, value]) => `${key}=${value}`).join(" ");
|
|
27
|
+
const body = [command, ...args].join(" ");
|
|
28
|
+
return exported ? `${exported} ${body}` : body;
|
|
29
|
+
}
|
|
30
|
+
function requireSuccess(result, label) {
|
|
31
|
+
if (result.dryRun) return result;
|
|
32
|
+
if (result.status === 0) return result;
|
|
33
|
+
const how = result.signal ? `sinal ${result.signal}` : `exit ${result.status}`;
|
|
34
|
+
const detail = (result.stderr || result.stdout || result.error?.message || "").trim();
|
|
35
|
+
fail(`${label} falhou (${how}).${detail ? `
|
|
36
|
+
${detail}` : ""}`);
|
|
37
|
+
}
|
|
38
|
+
var ReleaseKitError, defaultRun;
|
|
39
|
+
var init_run = __esm({
|
|
40
|
+
"src/exec/run.ts"() {
|
|
41
|
+
"use strict";
|
|
42
|
+
ReleaseKitError = class extends Error {
|
|
43
|
+
code;
|
|
44
|
+
constructor(message, { code = 1 } = {}) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "ReleaseKitError";
|
|
47
|
+
this.code = code;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
defaultRun = (command, args, { cwd, env, inherit = false, dryRun = false } = {}) => {
|
|
51
|
+
const extraEnv = env ?? {};
|
|
52
|
+
if (dryRun) {
|
|
53
|
+
return {
|
|
54
|
+
status: 0,
|
|
55
|
+
signal: null,
|
|
56
|
+
stdout: "",
|
|
57
|
+
stderr: "",
|
|
58
|
+
dryRun: true,
|
|
59
|
+
printed: formatCommand(command, args, extraEnv)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const result = crossSpawn.sync(command, args, {
|
|
63
|
+
cwd,
|
|
64
|
+
env: { ...process.env, ...extraEnv },
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
stdio: inherit ? "inherit" : "pipe",
|
|
67
|
+
maxBuffer: 32 * 1024 * 1024
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
status: result.status ?? 1,
|
|
71
|
+
signal: result.signal ?? null,
|
|
72
|
+
stdout: result.stdout ?? "",
|
|
73
|
+
stderr: result.stderr ?? "",
|
|
74
|
+
error: result.error
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// src/env/store.ts
|
|
81
|
+
var store_exports = {};
|
|
82
|
+
__export(store_exports, {
|
|
83
|
+
ENV_FILENAME: () => ENV_FILENAME,
|
|
84
|
+
credentialsDir: () => credentialsDir,
|
|
85
|
+
envPath: () => envPath,
|
|
86
|
+
materializeSecretContent: () => materializeSecretContent,
|
|
87
|
+
readEnvFile: () => readEnvFile,
|
|
88
|
+
resolveFileCredential: () => resolveFileCredential,
|
|
89
|
+
storeCredentialFile: () => storeCredentialFile,
|
|
90
|
+
writeEnvFile: () => writeEnvFile
|
|
91
|
+
});
|
|
92
|
+
import { chmodSync, copyFileSync, existsSync as existsSync3, mkdirSync, mkdtempSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
93
|
+
import { homedir, tmpdir } from "os";
|
|
94
|
+
import { basename, join as join3 } from "path";
|
|
95
|
+
import dotenv from "dotenv";
|
|
96
|
+
function envPath(repoRoot) {
|
|
97
|
+
return join3(repoRoot, ENV_FILENAME);
|
|
98
|
+
}
|
|
99
|
+
function readEnvFile(repoRoot) {
|
|
100
|
+
const path = envPath(repoRoot);
|
|
101
|
+
if (!existsSync3(path)) return {};
|
|
102
|
+
return dotenv.parse(readFileSync3(path, "utf8"));
|
|
103
|
+
}
|
|
104
|
+
function writeEnvFile(repoRoot, updates) {
|
|
105
|
+
const current = readEnvFile(repoRoot);
|
|
106
|
+
const merged = { ...current };
|
|
107
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
108
|
+
if (value === void 0) continue;
|
|
109
|
+
merged[key] = value;
|
|
110
|
+
}
|
|
111
|
+
const body = Object.entries(merged).map(([key, value]) => `${key}=${escapeEnvValue(value)}`).join("\n");
|
|
112
|
+
const path = envPath(repoRoot);
|
|
113
|
+
writeFileSync2(path, `${body}
|
|
114
|
+
`);
|
|
115
|
+
try {
|
|
116
|
+
chmodSync(path, 384);
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
return path;
|
|
120
|
+
}
|
|
121
|
+
function escapeEnvValue(value) {
|
|
122
|
+
if (/[\s#"'$]/.test(value)) return `"${value.replace(/"/g, '\\"')}"`;
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function credentialsDir(slug) {
|
|
126
|
+
return join3(homedir(), ".release-kit", slug);
|
|
127
|
+
}
|
|
128
|
+
function storeCredentialFile(slug, sourcePath, filename) {
|
|
129
|
+
const dir = credentialsDir(slug);
|
|
130
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
131
|
+
const destName = filename ?? basename(sourcePath);
|
|
132
|
+
const dest = join3(dir, destName);
|
|
133
|
+
copyFileSync(sourcePath, dest);
|
|
134
|
+
try {
|
|
135
|
+
chmodSync(dest, 384);
|
|
136
|
+
} catch {
|
|
137
|
+
}
|
|
138
|
+
return dest;
|
|
139
|
+
}
|
|
140
|
+
function materializeSecretContent(content, filename) {
|
|
141
|
+
const dir = mkdtempSync(join3(tmpdir(), "release-kit-"));
|
|
142
|
+
const path = join3(dir, filename);
|
|
143
|
+
writeFileSync2(path, content);
|
|
144
|
+
try {
|
|
145
|
+
chmodSync(path, 384);
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
path,
|
|
150
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true })
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function resolveFileCredential(env, pathKey, contentKey, filename) {
|
|
154
|
+
const directPath = env[pathKey];
|
|
155
|
+
if (directPath) return { path: directPath, cleanup: () => {
|
|
156
|
+
} };
|
|
157
|
+
const content = env[contentKey];
|
|
158
|
+
if (content) return materializeSecretContent(content, filename);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
var ENV_FILENAME;
|
|
162
|
+
var init_store = __esm({
|
|
163
|
+
"src/env/store.ts"() {
|
|
164
|
+
"use strict";
|
|
165
|
+
ENV_FILENAME = ".env.release";
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// src/cli/parse.ts
|
|
170
|
+
import { parseArgs } from "util";
|
|
171
|
+
function parseCliArgs(argv) {
|
|
172
|
+
const { values, positionals } = parseArgs({
|
|
173
|
+
args: argv,
|
|
174
|
+
allowPositionals: true,
|
|
175
|
+
strict: true,
|
|
176
|
+
options: {
|
|
177
|
+
channel: { type: "string" },
|
|
178
|
+
platform: { type: "string" },
|
|
179
|
+
version: { type: "string" },
|
|
180
|
+
push: { type: "boolean" },
|
|
181
|
+
edit: { type: "boolean" },
|
|
182
|
+
"skip-editor": { type: "boolean" },
|
|
183
|
+
cloud: { type: "boolean" },
|
|
184
|
+
output: { type: "string" },
|
|
185
|
+
latest: { type: "boolean" },
|
|
186
|
+
path: { type: "string" },
|
|
187
|
+
to: { type: "string" },
|
|
188
|
+
message: { type: "string" },
|
|
189
|
+
deep: { type: "boolean" },
|
|
190
|
+
"build-id": { type: "string" },
|
|
191
|
+
"skip-doctor": { type: "boolean" },
|
|
192
|
+
"allow-toolchain-drift": { type: "boolean" },
|
|
193
|
+
"i-know-this-is-production": { type: "boolean" },
|
|
194
|
+
"confirm-version": { type: "string" },
|
|
195
|
+
"dry-run": { type: "boolean" },
|
|
196
|
+
json: { type: "boolean" },
|
|
197
|
+
verbose: { type: "boolean" },
|
|
198
|
+
yes: { type: "boolean", short: "y" },
|
|
199
|
+
"non-interactive": { type: "boolean" },
|
|
200
|
+
help: { type: "boolean", short: "h" }
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const [command, ...rest] = positionals;
|
|
204
|
+
return {
|
|
205
|
+
command,
|
|
206
|
+
positionals: rest,
|
|
207
|
+
flags: {
|
|
208
|
+
channel: values.channel,
|
|
209
|
+
platform: values.platform,
|
|
210
|
+
version: values.version,
|
|
211
|
+
push: values.push,
|
|
212
|
+
edit: values.edit,
|
|
213
|
+
skipEditor: values["skip-editor"],
|
|
214
|
+
cloud: values.cloud,
|
|
215
|
+
output: values.output,
|
|
216
|
+
latest: values.latest,
|
|
217
|
+
path: values.path,
|
|
218
|
+
to: values.to,
|
|
219
|
+
message: values.message,
|
|
220
|
+
deep: values.deep,
|
|
221
|
+
buildId: values["build-id"],
|
|
222
|
+
skipDoctor: values["skip-doctor"],
|
|
223
|
+
allowToolchainDrift: values["allow-toolchain-drift"],
|
|
224
|
+
iKnowThisIsProduction: values["i-know-this-is-production"],
|
|
225
|
+
confirmVersion: values["confirm-version"],
|
|
226
|
+
dryRun: values["dry-run"],
|
|
227
|
+
json: values.json,
|
|
228
|
+
verbose: values.verbose,
|
|
229
|
+
yes: values.yes,
|
|
230
|
+
nonInteractive: values["non-interactive"],
|
|
231
|
+
help: values.help
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/cli/init.ts
|
|
237
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
238
|
+
import { join as join6, resolve as resolve2 } from "path";
|
|
239
|
+
import * as p from "@clack/prompts";
|
|
240
|
+
import pc2 from "picocolors";
|
|
241
|
+
|
|
242
|
+
// src/project/detect.ts
|
|
243
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
244
|
+
import { join } from "path";
|
|
245
|
+
var LOCKFILES = {
|
|
246
|
+
yarn: "yarn.lock",
|
|
247
|
+
pnpm: "pnpm-lock.yaml",
|
|
248
|
+
npm: "package-lock.json"
|
|
249
|
+
};
|
|
250
|
+
function detectPackageManager(repoRoot) {
|
|
251
|
+
for (const [pm, lockfile] of Object.entries(LOCKFILES)) {
|
|
252
|
+
if (existsSync(join(repoRoot, lockfile))) return pm;
|
|
253
|
+
}
|
|
254
|
+
return "npm";
|
|
255
|
+
}
|
|
256
|
+
function detectAppDir(repoRoot) {
|
|
257
|
+
if (hasExpoAppJson(repoRoot)) return ".";
|
|
258
|
+
const candidateDirs = ["apps", "packages"];
|
|
259
|
+
for (const dir of candidateDirs) {
|
|
260
|
+
const base = join(repoRoot, dir);
|
|
261
|
+
if (!existsSync(base)) continue;
|
|
262
|
+
const entries = safeReadDir(base);
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
const candidate = join(dir, entry);
|
|
265
|
+
if (hasExpoAppJson(join(repoRoot, candidate))) return candidate;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
function hasExpoAppJson(dir) {
|
|
271
|
+
const path = join(dir, "app.json");
|
|
272
|
+
if (!existsSync(path)) return false;
|
|
273
|
+
try {
|
|
274
|
+
const json = JSON.parse(readFileSync(path, "utf8"));
|
|
275
|
+
return typeof json.expo === "object" && json.expo !== null;
|
|
276
|
+
} catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function safeReadDir(dir) {
|
|
281
|
+
try {
|
|
282
|
+
return readdirSync(dir);
|
|
283
|
+
} catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function readEasJson(appDir) {
|
|
288
|
+
const path = join(appDir, "eas.json");
|
|
289
|
+
if (!existsSync(path)) return null;
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/project/expo.ts
|
|
298
|
+
init_run();
|
|
299
|
+
function resolveExpoConfig(ctx, env = {}) {
|
|
300
|
+
const result = ctx.run("npx", ["expo", "config", "--json"], {
|
|
301
|
+
cwd: ctx.appDir,
|
|
302
|
+
env: { ...ctx.env, ...env },
|
|
303
|
+
inherit: false
|
|
304
|
+
});
|
|
305
|
+
if (result.status !== 0) {
|
|
306
|
+
fail(`expo config --json falhou.
|
|
307
|
+
${result.stderr || result.stdout}`);
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(result.stdout);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
fail(`expo config --json n\xE3o devolveu JSON v\xE1lido: ${error.message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/cli/resolve.ts
|
|
317
|
+
import { resolve } from "path";
|
|
318
|
+
import crossSpawn2 from "cross-spawn";
|
|
319
|
+
|
|
320
|
+
// src/context.ts
|
|
321
|
+
init_run();
|
|
322
|
+
|
|
323
|
+
// src/ui/log.ts
|
|
324
|
+
import pc from "picocolors";
|
|
325
|
+
function createLogger(options = {}) {
|
|
326
|
+
const write = options.write ?? ((line) => console.error(line));
|
|
327
|
+
const silent = Boolean(options.json);
|
|
328
|
+
const emit = (line) => {
|
|
329
|
+
if (silent) return;
|
|
330
|
+
write(line);
|
|
331
|
+
};
|
|
332
|
+
return {
|
|
333
|
+
info: (message) => emit(message),
|
|
334
|
+
warn: (message) => emit(pc.yellow(`! ${message}`)),
|
|
335
|
+
error: (message) => emit(pc.red(`\u2717 ${message}`)),
|
|
336
|
+
success: (message) => emit(pc.green(`\u2713 ${message}`)),
|
|
337
|
+
step: (message) => emit(pc.cyan(`\u2192 ${message}`)),
|
|
338
|
+
debug: (message) => {
|
|
339
|
+
if (!options.verbose) return;
|
|
340
|
+
emit(pc.dim(message));
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/context.ts
|
|
346
|
+
function createContext(overrides) {
|
|
347
|
+
const env = overrides.env ?? process.env;
|
|
348
|
+
const json = Boolean(overrides.json);
|
|
349
|
+
return {
|
|
350
|
+
repoRoot: overrides.repoRoot,
|
|
351
|
+
appDir: overrides.appDir ?? overrides.repoRoot,
|
|
352
|
+
releaseDir: overrides.releaseDir ?? `${overrides.repoRoot}/.release`,
|
|
353
|
+
env,
|
|
354
|
+
dryRun: Boolean(overrides.dryRun),
|
|
355
|
+
json,
|
|
356
|
+
verbose: Boolean(overrides.verbose),
|
|
357
|
+
nonInteractive: overrides.nonInteractive ?? !process.stdin.isTTY,
|
|
358
|
+
run: overrides.run ?? defaultRun,
|
|
359
|
+
log: overrides.log ?? createLogger({ json, verbose: overrides.verbose }),
|
|
360
|
+
confirm: overrides.confirm
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/config/load.ts
|
|
365
|
+
init_run();
|
|
366
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
367
|
+
import { join as join2 } from "path";
|
|
368
|
+
import { z as z2 } from "zod";
|
|
369
|
+
|
|
370
|
+
// src/config/schema.ts
|
|
371
|
+
import { z } from "zod";
|
|
372
|
+
var platformSchema = z.enum(["android", "ios"]);
|
|
373
|
+
var versionSourceSchema = z.enum(["app.json", "package.json"]);
|
|
374
|
+
var gitPolicySchema = z.object({
|
|
375
|
+
releaseBranch: z.string().default("main"),
|
|
376
|
+
tagFormat: z.string().default("v{version}"),
|
|
377
|
+
requireClean: z.boolean().default(true),
|
|
378
|
+
requireSyncedWithRemote: z.boolean().default(true),
|
|
379
|
+
remote: z.string().default("origin")
|
|
380
|
+
});
|
|
381
|
+
var iosChannelSchema = z.object({
|
|
382
|
+
bundleId: z.string(),
|
|
383
|
+
ascAppId: z.string().optional(),
|
|
384
|
+
appleTeamId: z.string().optional(),
|
|
385
|
+
phasedRelease: z.boolean().default(false),
|
|
386
|
+
automaticRelease: z.boolean().default(false),
|
|
387
|
+
copyright: z.string().optional()
|
|
388
|
+
});
|
|
389
|
+
var androidChannelSchema = z.object({
|
|
390
|
+
package: z.string(),
|
|
391
|
+
testTrack: z.string().default("internal"),
|
|
392
|
+
productionTrack: z.string().default("production"),
|
|
393
|
+
initialRollout: z.number().min(0).max(1).default(1)
|
|
394
|
+
});
|
|
395
|
+
var otaSchema = z.object({
|
|
396
|
+
channel: z.string()
|
|
397
|
+
});
|
|
398
|
+
var channelSchema = z.object({
|
|
399
|
+
/** Canal protegido (produção): exige --i-know-this-is-production ou confirmação digitando a versão. */
|
|
400
|
+
protected: z.boolean().default(false),
|
|
401
|
+
/** Se false, o canal nunca envia à loja (ex: build local de sideload). */
|
|
402
|
+
store: z.boolean().default(true),
|
|
403
|
+
buildProfile: z.string(),
|
|
404
|
+
submitProfile: z.string().optional(),
|
|
405
|
+
/** Nome do "environment" do EAS (eas.json build.<profile>.environment) — usado nos checks de fingerprint/Sentry. */
|
|
406
|
+
easEnvironment: z.string().optional(),
|
|
407
|
+
/** Se o build profile já reusa o build number/versionCode (eas.json autoIncrement). */
|
|
408
|
+
autoIncrement: z.boolean().default(true),
|
|
409
|
+
/** Profiles com upload de sourcemap desligado (ex: SENTRY_DISABLE_AUTO_UPLOAD) não exigem token no doctor. */
|
|
410
|
+
sentryUploadDisabled: z.boolean().default(false),
|
|
411
|
+
env: z.record(z.string(), z.string()).default({}),
|
|
412
|
+
ios: iosChannelSchema.optional(),
|
|
413
|
+
android: androidChannelSchema.optional(),
|
|
414
|
+
ota: otaSchema.optional()
|
|
415
|
+
});
|
|
416
|
+
var toolchainBaselineSchema = z.object({
|
|
417
|
+
easCli: z.object({ min: z.string(), tested: z.string() }).optional(),
|
|
418
|
+
node: z.object({ min: z.string(), tested: z.string() }).optional(),
|
|
419
|
+
jdk: z.object({ min: z.string(), tested: z.string() }).optional(),
|
|
420
|
+
xcode: z.object({ min: z.string(), tested: z.string() }).optional(),
|
|
421
|
+
cocoapods: z.object({ min: z.string(), tested: z.string() }).optional()
|
|
422
|
+
});
|
|
423
|
+
var doctorConfigSchema = z.object({
|
|
424
|
+
sentry: z.boolean().default(false),
|
|
425
|
+
expoDoctor: z.boolean().default(true)
|
|
426
|
+
});
|
|
427
|
+
var releaseKitConfigSchema = z.object({
|
|
428
|
+
$schema: z.string().optional(),
|
|
429
|
+
appDir: z.string().default("."),
|
|
430
|
+
/** Conta/organização esperada no `eas whoami` (opcional; reforça o doctor). */
|
|
431
|
+
easOwner: z.string().optional(),
|
|
432
|
+
platforms: z.array(platformSchema).min(1),
|
|
433
|
+
locales: z.array(z.string()).min(1).default(["pt-BR"]),
|
|
434
|
+
notesDir: z.string().default("release-notes"),
|
|
435
|
+
version: z.object({ source: versionSourceSchema.default("app.json") }).default({ source: "app.json" }),
|
|
436
|
+
git: gitPolicySchema.default({}),
|
|
437
|
+
channels: z.record(z.string(), channelSchema).refine((channels) => Object.keys(channels).length > 0, {
|
|
438
|
+
message: 'defina ao menos um canal em "channels"'
|
|
439
|
+
}),
|
|
440
|
+
toolchain: toolchainBaselineSchema.default({}),
|
|
441
|
+
doctor: doctorConfigSchema.default({})
|
|
442
|
+
});
|
|
443
|
+
function parseConfig(input) {
|
|
444
|
+
return releaseKitConfigSchema.parse(input);
|
|
445
|
+
}
|
|
446
|
+
function channelOf(config, name) {
|
|
447
|
+
const channel = config.channels[name];
|
|
448
|
+
if (!channel) {
|
|
449
|
+
const available = Object.keys(config.channels).join(", ");
|
|
450
|
+
throw new Error(`canal desconhecido: "${name}". Canais dispon\xEDveis: ${available}`);
|
|
451
|
+
}
|
|
452
|
+
return channel;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/config/load.ts
|
|
456
|
+
var CONFIG_FILENAME = "release-kit.config.json";
|
|
457
|
+
function configPath(repoRoot) {
|
|
458
|
+
return join2(repoRoot, CONFIG_FILENAME);
|
|
459
|
+
}
|
|
460
|
+
function loadConfig(repoRoot) {
|
|
461
|
+
const path = configPath(repoRoot);
|
|
462
|
+
if (!existsSync2(path)) {
|
|
463
|
+
fail(
|
|
464
|
+
`${CONFIG_FILENAME} n\xE3o encontrado em ${repoRoot}. Rode \`release-kit init\` para criar a configura\xE7\xE3o.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
let raw;
|
|
468
|
+
try {
|
|
469
|
+
raw = JSON.parse(readFileSync2(path, "utf8"));
|
|
470
|
+
} catch (error) {
|
|
471
|
+
fail(`${CONFIG_FILENAME} n\xE3o \xE9 um JSON v\xE1lido: ${error.message}`);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
return parseConfig(raw);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
if (error instanceof z2.ZodError) {
|
|
477
|
+
const issues = error.issues.map((issue) => ` - ${issue.path.join(".") || "(raiz)"}: ${issue.message}`);
|
|
478
|
+
fail(`${CONFIG_FILENAME} inv\xE1lido:
|
|
479
|
+
${issues.join("\n")}`);
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function writeConfig(repoRoot, config) {
|
|
485
|
+
const path = configPath(repoRoot);
|
|
486
|
+
const withSchema = { $schema: "./node_modules/@htgabriel/release-kit/schema.json", ...config };
|
|
487
|
+
writeFileSync(path, `${JSON.stringify(withSchema, null, 2)}
|
|
488
|
+
`);
|
|
489
|
+
return path;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/cli/resolve.ts
|
|
493
|
+
function findRepoRoot(cwd = process.cwd()) {
|
|
494
|
+
const result = crossSpawn2.sync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
|
|
495
|
+
if (result.status === 0 && result.stdout) return result.stdout.trim();
|
|
496
|
+
return cwd;
|
|
497
|
+
}
|
|
498
|
+
function buildContext(flags = {}) {
|
|
499
|
+
const repoRoot = findRepoRoot();
|
|
500
|
+
const json = Boolean(flags.json);
|
|
501
|
+
return createContext({
|
|
502
|
+
repoRoot,
|
|
503
|
+
dryRun: Boolean(flags.dryRun),
|
|
504
|
+
json,
|
|
505
|
+
verbose: Boolean(flags.verbose),
|
|
506
|
+
nonInteractive: flags.nonInteractive ?? !process.stdin.isTTY,
|
|
507
|
+
log: createLogger({ json, verbose: flags.verbose })
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
function withAppDir(ctx, config) {
|
|
511
|
+
return { ...ctx, appDir: resolve(ctx.repoRoot, config.appDir), releaseDir: resolve(ctx.repoRoot, config.appDir, ".release") };
|
|
512
|
+
}
|
|
513
|
+
function loadContextAndConfig(flags = {}) {
|
|
514
|
+
const base = buildContext(flags);
|
|
515
|
+
const config = loadConfig(base.repoRoot);
|
|
516
|
+
return { ctx: withAppDir(base, config), config };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/cli/init.ts
|
|
520
|
+
init_store();
|
|
521
|
+
|
|
522
|
+
// src/eas/cli.ts
|
|
523
|
+
init_run();
|
|
524
|
+
function eas(ctx, args, { inherit = false, env = {}, cwd } = {}) {
|
|
525
|
+
return ctx.run("eas", args, {
|
|
526
|
+
cwd: cwd ?? ctx.appDir,
|
|
527
|
+
env,
|
|
528
|
+
inherit,
|
|
529
|
+
dryRun: ctx.dryRun
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function parseRemoteVersion(stdout) {
|
|
533
|
+
const text2 = String(stdout || "").trim();
|
|
534
|
+
const jsonStart = text2.indexOf("{");
|
|
535
|
+
if (jsonStart === -1) return { versionCode: null, buildNumber: null, raw: text2 };
|
|
536
|
+
try {
|
|
537
|
+
const parsed = JSON.parse(text2.slice(jsonStart));
|
|
538
|
+
return {
|
|
539
|
+
versionCode: parsed.versionCode != null ? String(parsed.versionCode) : null,
|
|
540
|
+
buildNumber: parsed.buildNumber != null ? String(parsed.buildNumber) : null,
|
|
541
|
+
raw: parsed
|
|
542
|
+
};
|
|
543
|
+
} catch {
|
|
544
|
+
return { versionCode: null, buildNumber: null, raw: text2 };
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function projectRemoteVersion(ctx, { platform, buildProfile }) {
|
|
548
|
+
const args = ["build:version:get", "--non-interactive", "--json", "-e", buildProfile, "-p", platform];
|
|
549
|
+
const result = eas(ctx, args);
|
|
550
|
+
if (ctx.dryRun) return { versionCode: null, buildNumber: null, raw: null, dryRun: true };
|
|
551
|
+
requireSuccess(result, "eas build:version:get");
|
|
552
|
+
return parseRemoteVersion(result.stdout);
|
|
553
|
+
}
|
|
554
|
+
function expectedBuildNumber(current, { autoIncrement, platform }) {
|
|
555
|
+
const key = platform === "ios" ? "buildNumber" : "versionCode";
|
|
556
|
+
const currentValue = current[key];
|
|
557
|
+
if (!currentValue) return { key, current: null, expected: null };
|
|
558
|
+
if (!autoIncrement) return { key, current: currentValue, expected: currentValue };
|
|
559
|
+
const numeric = Number(currentValue);
|
|
560
|
+
if (!Number.isFinite(numeric)) return { key, current: currentValue, expected: null };
|
|
561
|
+
return { key, current: currentValue, expected: String(numeric + 1) };
|
|
562
|
+
}
|
|
563
|
+
function easWhoami(ctx) {
|
|
564
|
+
const result = eas(ctx, ["whoami"]);
|
|
565
|
+
if (ctx.dryRun) return { ok: true, account: null, raw: "[dry-run]" };
|
|
566
|
+
if (result.status !== 0) return { ok: false, account: null, raw: result.stderr || result.stdout };
|
|
567
|
+
const account = result.stdout.trim().split("\n").pop() ?? null;
|
|
568
|
+
return { ok: true, account, raw: result.stdout };
|
|
569
|
+
}
|
|
570
|
+
function easEnvList(ctx, environment) {
|
|
571
|
+
return eas(ctx, ["env:list", environment, "--format", "short"]);
|
|
572
|
+
}
|
|
573
|
+
function easEnvListHasKey(ctx, environment, key) {
|
|
574
|
+
const result = easEnvList(ctx, environment);
|
|
575
|
+
if (ctx.dryRun) return true;
|
|
576
|
+
if (result.status !== 0) return false;
|
|
577
|
+
const pattern = new RegExp(`(^|\\n)${key}(\\n|$)`);
|
|
578
|
+
return pattern.test(`${result.stdout}
|
|
579
|
+
${result.stderr}`);
|
|
580
|
+
}
|
|
581
|
+
function easFingerprintGenerate(ctx, environment) {
|
|
582
|
+
return eas(ctx, ["fingerprint:generate", "--non-interactive", "--json", "--environment", environment]);
|
|
583
|
+
}
|
|
584
|
+
function easFingerprintCompare(ctx, environment, buildId) {
|
|
585
|
+
return eas(ctx, [
|
|
586
|
+
"fingerprint:compare",
|
|
587
|
+
"--non-interactive",
|
|
588
|
+
"--json",
|
|
589
|
+
"--build-id",
|
|
590
|
+
buildId,
|
|
591
|
+
"--environment",
|
|
592
|
+
environment
|
|
593
|
+
]);
|
|
594
|
+
}
|
|
595
|
+
function easBuild(ctx, opts) {
|
|
596
|
+
const args = ["build", "-p", opts.platform, "--profile", opts.buildProfile, "--non-interactive"];
|
|
597
|
+
if (opts.local) args.push("--local");
|
|
598
|
+
if (opts.output) args.push("--output", opts.output);
|
|
599
|
+
return eas(ctx, args, { inherit: true, env: opts.env });
|
|
600
|
+
}
|
|
601
|
+
function easSubmit(ctx, opts) {
|
|
602
|
+
const args = ["submit", "-p", opts.platform, "--profile", opts.submitProfile, "--path", opts.path, "--non-interactive"];
|
|
603
|
+
return eas(ctx, args, { inherit: true, env: opts.env });
|
|
604
|
+
}
|
|
605
|
+
function easUpdate(ctx, opts) {
|
|
606
|
+
const args = ["update", "--channel", opts.channel, "--non-interactive"];
|
|
607
|
+
if (opts.message) args.push("--message", opts.message);
|
|
608
|
+
if (opts.platform) args.push("--platform", opts.platform);
|
|
609
|
+
return eas(ctx, args, { inherit: true, env: opts.env });
|
|
610
|
+
}
|
|
611
|
+
function assertEasCliAvailable(ctx) {
|
|
612
|
+
const result = ctx.run("eas", ["--version"], { cwd: ctx.appDir });
|
|
613
|
+
if (result.status !== 0) {
|
|
614
|
+
fail("eas-cli n\xE3o encontrado no PATH. Instale com `npm install -g eas-cli` e rode `eas login`.");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/stores/apple/client.ts
|
|
619
|
+
init_run();
|
|
620
|
+
|
|
621
|
+
// src/stores/apple/auth.ts
|
|
622
|
+
import { createPrivateKey, createSign } from "crypto";
|
|
623
|
+
function base64url(input) {
|
|
624
|
+
const buffer = typeof input === "string" ? Buffer.from(input) : input;
|
|
625
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
626
|
+
}
|
|
627
|
+
function createAppStoreConnectToken(credentials, now = Date.now()) {
|
|
628
|
+
const iat = Math.floor(now / 1e3);
|
|
629
|
+
const exp = iat + 19 * 60;
|
|
630
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: credentials.keyId, typ: "JWT" }));
|
|
631
|
+
const payload = base64url(
|
|
632
|
+
JSON.stringify({
|
|
633
|
+
iss: credentials.issuerId,
|
|
634
|
+
iat,
|
|
635
|
+
exp,
|
|
636
|
+
aud: "appstoreconnect-v1"
|
|
637
|
+
})
|
|
638
|
+
);
|
|
639
|
+
const signingInput = `${header}.${payload}`;
|
|
640
|
+
const key = createPrivateKey({ key: credentials.privateKeyPem, format: "pem" });
|
|
641
|
+
const signer = createSign("SHA256");
|
|
642
|
+
signer.update(signingInput);
|
|
643
|
+
signer.end();
|
|
644
|
+
const signature = signer.sign({ key, dsaEncoding: "ieee-p1363" });
|
|
645
|
+
return `${signingInput}.${base64url(signature)}`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/stores/apple/client.ts
|
|
649
|
+
var APP_STORE_CONNECT_BASE = "https://api.appstoreconnect.apple.com/v1";
|
|
650
|
+
var AppStoreConnectClient = class {
|
|
651
|
+
credentials;
|
|
652
|
+
baseUrl;
|
|
653
|
+
fetchImpl;
|
|
654
|
+
constructor(options) {
|
|
655
|
+
this.credentials = options.credentials;
|
|
656
|
+
this.baseUrl = options.baseUrl ?? APP_STORE_CONNECT_BASE;
|
|
657
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
658
|
+
}
|
|
659
|
+
token() {
|
|
660
|
+
return createAppStoreConnectToken(this.credentials);
|
|
661
|
+
}
|
|
662
|
+
async request(method, path, body) {
|
|
663
|
+
const url = path.startsWith("http") ? path : `${this.baseUrl}${path}`;
|
|
664
|
+
const response = await this.fetchImpl(url, {
|
|
665
|
+
method,
|
|
666
|
+
headers: {
|
|
667
|
+
Authorization: `Bearer ${this.token()}`,
|
|
668
|
+
"Content-Type": "application/json"
|
|
669
|
+
},
|
|
670
|
+
body: body ? JSON.stringify(body) : void 0
|
|
671
|
+
});
|
|
672
|
+
const text2 = await response.text();
|
|
673
|
+
if (!response.ok) {
|
|
674
|
+
fail(`App Store Connect API ${method} ${path} \u2192 ${response.status}
|
|
675
|
+
${text2}`);
|
|
676
|
+
}
|
|
677
|
+
if (!text2) return void 0;
|
|
678
|
+
return JSON.parse(text2);
|
|
679
|
+
}
|
|
680
|
+
get(path) {
|
|
681
|
+
return this.request("GET", path);
|
|
682
|
+
}
|
|
683
|
+
post(path, body) {
|
|
684
|
+
return this.request("POST", path, body);
|
|
685
|
+
}
|
|
686
|
+
patch(path, body) {
|
|
687
|
+
return this.request("PATCH", path, body);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
// src/stores/apple/apps.ts
|
|
692
|
+
init_run();
|
|
693
|
+
async function findAppByBundleId(client, bundleId) {
|
|
694
|
+
const result = await client.get(`/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
|
|
695
|
+
const [app] = result.data;
|
|
696
|
+
if (!app) return null;
|
|
697
|
+
return { id: app.id, name: app.attributes.name };
|
|
698
|
+
}
|
|
699
|
+
async function requireAppByBundleId(client, bundleId) {
|
|
700
|
+
const app = await findAppByBundleId(client, bundleId);
|
|
701
|
+
if (!app) fail(`App com bundleId ${bundleId} n\xE3o encontrado no App Store Connect (chave sem acesso ao app, ou app ainda n\xE3o criado).`);
|
|
702
|
+
return app;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/stores/google/client.ts
|
|
706
|
+
init_run();
|
|
707
|
+
|
|
708
|
+
// src/stores/google/auth.ts
|
|
709
|
+
init_run();
|
|
710
|
+
import { createSign as createSign2 } from "crypto";
|
|
711
|
+
function parseServiceAccountJson(raw) {
|
|
712
|
+
let parsed;
|
|
713
|
+
try {
|
|
714
|
+
parsed = JSON.parse(raw);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
fail(`JSON da service account inv\xE1lido: ${error.message}`);
|
|
717
|
+
}
|
|
718
|
+
const account = parsed;
|
|
719
|
+
if (!account.client_email || !account.private_key) {
|
|
720
|
+
fail("JSON da service account sem client_email/private_key.");
|
|
721
|
+
}
|
|
722
|
+
return account;
|
|
723
|
+
}
|
|
724
|
+
function base64url2(input) {
|
|
725
|
+
const buffer = typeof input === "string" ? Buffer.from(input) : input;
|
|
726
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
727
|
+
}
|
|
728
|
+
var TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
729
|
+
var ANDROID_PUBLISHER_SCOPE = "https://www.googleapis.com/auth/androidpublisher";
|
|
730
|
+
async function fetchAccessToken(account, fetchImpl = fetch, now = Date.now()) {
|
|
731
|
+
const iat = Math.floor(now / 1e3);
|
|
732
|
+
const exp = iat + 60 * 60;
|
|
733
|
+
const header = base64url2(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
734
|
+
const payload = base64url2(
|
|
735
|
+
JSON.stringify({
|
|
736
|
+
iss: account.client_email,
|
|
737
|
+
scope: ANDROID_PUBLISHER_SCOPE,
|
|
738
|
+
aud: TOKEN_URL,
|
|
739
|
+
iat,
|
|
740
|
+
exp
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
const signingInput = `${header}.${payload}`;
|
|
744
|
+
const signer = createSign2("RSA-SHA256");
|
|
745
|
+
signer.update(signingInput);
|
|
746
|
+
signer.end();
|
|
747
|
+
const signature = signer.sign(account.private_key);
|
|
748
|
+
const assertion = `${signingInput}.${base64url2(signature)}`;
|
|
749
|
+
const response = await fetchImpl(TOKEN_URL, {
|
|
750
|
+
method: "POST",
|
|
751
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
752
|
+
body: new URLSearchParams({
|
|
753
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
754
|
+
assertion
|
|
755
|
+
}).toString()
|
|
756
|
+
});
|
|
757
|
+
const text2 = await response.text();
|
|
758
|
+
if (!response.ok) fail(`Falha ao obter access_token do Google: ${response.status}
|
|
759
|
+
${text2}`);
|
|
760
|
+
const json = JSON.parse(text2);
|
|
761
|
+
if (!json.access_token) fail("Resposta do Google sem access_token.");
|
|
762
|
+
return json.access_token;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/stores/google/client.ts
|
|
766
|
+
var ANDROID_PUBLISHER_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3";
|
|
767
|
+
var AndroidPublisherClient = class {
|
|
768
|
+
account;
|
|
769
|
+
baseUrl;
|
|
770
|
+
fetchImpl;
|
|
771
|
+
cachedToken = null;
|
|
772
|
+
constructor(options) {
|
|
773
|
+
this.account = options.account;
|
|
774
|
+
this.baseUrl = options.baseUrl ?? ANDROID_PUBLISHER_BASE;
|
|
775
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
776
|
+
}
|
|
777
|
+
async token() {
|
|
778
|
+
if (!this.cachedToken) {
|
|
779
|
+
this.cachedToken = await fetchAccessToken(this.account, this.fetchImpl);
|
|
780
|
+
}
|
|
781
|
+
return this.cachedToken;
|
|
782
|
+
}
|
|
783
|
+
async request(method, path, body) {
|
|
784
|
+
const token = await this.token();
|
|
785
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
786
|
+
method,
|
|
787
|
+
headers: {
|
|
788
|
+
Authorization: `Bearer ${token}`,
|
|
789
|
+
"Content-Type": "application/json"
|
|
790
|
+
},
|
|
791
|
+
body: body ? JSON.stringify(body) : void 0
|
|
792
|
+
});
|
|
793
|
+
const text2 = await response.text();
|
|
794
|
+
if (!response.ok) fail(`Android Publisher API ${method} ${path} \u2192 ${response.status}
|
|
795
|
+
${text2}`);
|
|
796
|
+
if (!text2) return void 0;
|
|
797
|
+
return JSON.parse(text2);
|
|
798
|
+
}
|
|
799
|
+
get(path) {
|
|
800
|
+
return this.request("GET", path);
|
|
801
|
+
}
|
|
802
|
+
put(path, body) {
|
|
803
|
+
return this.request("PUT", path, body);
|
|
804
|
+
}
|
|
805
|
+
post(path, body) {
|
|
806
|
+
return this.request("POST", path, body);
|
|
807
|
+
}
|
|
808
|
+
delete(path) {
|
|
809
|
+
return this.request("DELETE", path);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
// src/stores/google/tracks.ts
|
|
814
|
+
init_run();
|
|
815
|
+
|
|
816
|
+
// src/notes/limits.ts
|
|
817
|
+
init_run();
|
|
818
|
+
var PLAY_NOTES_MAX = 500;
|
|
819
|
+
var APPLE_WHATS_NEW_MAX = 4e3;
|
|
820
|
+
function notesMaxFor(target) {
|
|
821
|
+
return target === "play" ? PLAY_NOTES_MAX : APPLE_WHATS_NEW_MAX;
|
|
822
|
+
}
|
|
823
|
+
function assertNotesWithinLimit(text2, target, locale) {
|
|
824
|
+
const max = notesMaxFor(target);
|
|
825
|
+
if (text2.length > max) {
|
|
826
|
+
fail(`Notas ${target} (${locale}) t\xEAm ${text2.length} caracteres (m\xE1ximo ${max}).`);
|
|
827
|
+
}
|
|
828
|
+
if (!text2.trim()) {
|
|
829
|
+
fail(`Notas ${target} (${locale}) est\xE3o vazias.`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// src/stores/google/tracks.ts
|
|
834
|
+
async function withEdit(client, packageName, fn) {
|
|
835
|
+
const edit = await client.post(`/applications/${packageName}/edits`);
|
|
836
|
+
try {
|
|
837
|
+
const result = await fn(edit.id);
|
|
838
|
+
await client.post(`/applications/${packageName}/edits/${edit.id}:commit`);
|
|
839
|
+
return result;
|
|
840
|
+
} catch (error) {
|
|
841
|
+
await client.delete(`/applications/${packageName}/edits/${edit.id}`).catch(() => {
|
|
842
|
+
});
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function withReadOnlyEdit(client, packageName, fn) {
|
|
847
|
+
const edit = await client.post(`/applications/${packageName}/edits`);
|
|
848
|
+
try {
|
|
849
|
+
return await fn(edit.id);
|
|
850
|
+
} finally {
|
|
851
|
+
await client.delete(`/applications/${packageName}/edits/${edit.id}`).catch(() => {
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function findTargetRelease(releases, versionCode) {
|
|
856
|
+
if (versionCode) {
|
|
857
|
+
return releases.find((release) => (release.versionCodes ?? []).includes(String(versionCode)));
|
|
858
|
+
}
|
|
859
|
+
return releases.find((release) => release.status === "completed") ?? releases[0];
|
|
860
|
+
}
|
|
861
|
+
async function validatePlayAccess(client, packageName) {
|
|
862
|
+
await withReadOnlyEdit(client, packageName, async () => void 0);
|
|
863
|
+
}
|
|
864
|
+
async function promoteRelease(client, input) {
|
|
865
|
+
return withEdit(client, input.packageName, async (editId) => {
|
|
866
|
+
const source = await client.get(`/applications/${input.packageName}/edits/${editId}/tracks/${input.fromTrack}`);
|
|
867
|
+
const releases = source.releases ?? [];
|
|
868
|
+
const target = findTargetRelease(releases);
|
|
869
|
+
if (!target || !target.versionCodes?.length) {
|
|
870
|
+
fail(`faixa ${input.fromTrack} sem release para promover.`);
|
|
871
|
+
}
|
|
872
|
+
const fraction = input.initialRollout ?? 1;
|
|
873
|
+
const status = fraction < 1 ? "inProgress" : "completed";
|
|
874
|
+
const releaseNotes = input.notesByLocale ? Object.entries(input.notesByLocale).map(([language, text2]) => ({ language, text: text2 })) : target.releaseNotes;
|
|
875
|
+
const newRelease = {
|
|
876
|
+
versionCodes: target.versionCodes,
|
|
877
|
+
status,
|
|
878
|
+
releaseNotes,
|
|
879
|
+
...status === "inProgress" ? { userFraction: fraction } : {}
|
|
880
|
+
};
|
|
881
|
+
await client.put(`/applications/${input.packageName}/edits/${editId}/tracks/${input.toTrack}`, {
|
|
882
|
+
track: input.toTrack,
|
|
883
|
+
releases: [newRelease]
|
|
884
|
+
});
|
|
885
|
+
return newRelease;
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
async function setRollout(client, packageName, track, target) {
|
|
889
|
+
return withEdit(client, packageName, async (editId) => {
|
|
890
|
+
const current = await client.get(`/applications/${packageName}/edits/${editId}/tracks/${track}`);
|
|
891
|
+
const releases = current.releases ?? [];
|
|
892
|
+
const active = releases.find((release) => release.status === "inProgress") ?? releases[releases.length - 1];
|
|
893
|
+
if (!active) fail(`faixa ${track} sem release em rollout.`);
|
|
894
|
+
if (target === "complete") {
|
|
895
|
+
active.status = "completed";
|
|
896
|
+
delete active.userFraction;
|
|
897
|
+
} else if (target === "halt") {
|
|
898
|
+
active.status = "halted";
|
|
899
|
+
} else {
|
|
900
|
+
if (target <= 0 || target > 1) fail(`--to precisa ser um valor entre 0 e 1 (ou complete/halt).`);
|
|
901
|
+
active.status = "inProgress";
|
|
902
|
+
active.userFraction = target;
|
|
903
|
+
}
|
|
904
|
+
await client.put(`/applications/${packageName}/edits/${editId}/tracks/${track}`, { track, releases });
|
|
905
|
+
return active;
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
async function getTrackStatus(client, packageName, track) {
|
|
909
|
+
return withReadOnlyEdit(
|
|
910
|
+
client,
|
|
911
|
+
packageName,
|
|
912
|
+
(editId) => client.get(`/applications/${packageName}/edits/${editId}/tracks/${track}`)
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/doctor/run.ts
|
|
917
|
+
init_run();
|
|
918
|
+
|
|
919
|
+
// src/git/policy.ts
|
|
920
|
+
init_run();
|
|
921
|
+
function git(ctx, args, { allowFail = false } = {}) {
|
|
922
|
+
const result = ctx.run("git", args, { cwd: ctx.repoRoot, inherit: false });
|
|
923
|
+
if (!allowFail) requireSuccess(result, `git ${args.join(" ")}`);
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
function gitPorcelain(ctx) {
|
|
927
|
+
return git(ctx, ["status", "--porcelain"]).stdout;
|
|
928
|
+
}
|
|
929
|
+
function isWorkingTreeClean(ctx, ignoreDirs = [".release/"]) {
|
|
930
|
+
const dirty = gitPorcelain(ctx).split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0).filter((line) => !ignoreDirs.some((dir) => line.endsWith(` ${dir}`) || line.includes(` ${dir}`)));
|
|
931
|
+
return dirty.length === 0;
|
|
932
|
+
}
|
|
933
|
+
function dirtyFiles(ctx, ignoreDirs = [".release/"]) {
|
|
934
|
+
return gitPorcelain(ctx).split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0).filter((line) => !ignoreDirs.some((dir) => line.endsWith(` ${dir}`) || line.includes(` ${dir}`)));
|
|
935
|
+
}
|
|
936
|
+
function currentBranch(ctx) {
|
|
937
|
+
return git(ctx, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.trim();
|
|
938
|
+
}
|
|
939
|
+
function headSha(ctx) {
|
|
940
|
+
return git(ctx, ["rev-parse", "HEAD"]).stdout.trim();
|
|
941
|
+
}
|
|
942
|
+
function remoteSha(ctx, remote, branch) {
|
|
943
|
+
const result = git(ctx, ["rev-parse", `${remote}/${branch}`], { allowFail: true });
|
|
944
|
+
if (result.status !== 0) return null;
|
|
945
|
+
return result.stdout.trim();
|
|
946
|
+
}
|
|
947
|
+
function tagShaFormatted(ctx, tagFormat, version) {
|
|
948
|
+
const tag = renderTag(tagFormat, version);
|
|
949
|
+
const result = git(ctx, ["rev-parse", `${tag}^{commit}`], { allowFail: true });
|
|
950
|
+
if (result.status !== 0) return null;
|
|
951
|
+
return result.stdout.trim();
|
|
952
|
+
}
|
|
953
|
+
function lastTagMatching(ctx, tagFormat) {
|
|
954
|
+
const pattern = tagGlob(tagFormat);
|
|
955
|
+
const result = git(ctx, ["tag", "--list", pattern, "--sort=-creatordate"], { allowFail: true });
|
|
956
|
+
const [first] = (result.stdout || "").split("\n").map((line) => line.trim()).filter(Boolean);
|
|
957
|
+
return first ?? null;
|
|
958
|
+
}
|
|
959
|
+
function aheadBehind(ctx, remote, branch) {
|
|
960
|
+
const upstream = git(ctx, ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], { allowFail: true });
|
|
961
|
+
if (upstream.status !== 0) return null;
|
|
962
|
+
const counts = git(ctx, ["rev-list", "--left-right", "--count", `HEAD...${remote}/${branch}`], {
|
|
963
|
+
allowFail: true
|
|
964
|
+
});
|
|
965
|
+
if (counts.status !== 0) return null;
|
|
966
|
+
const [ahead = "0", behind = "0"] = counts.stdout.trim().split(/\s+/);
|
|
967
|
+
return { ahead: Number(ahead), behind: Number(behind) };
|
|
968
|
+
}
|
|
969
|
+
function renderTag(tagFormat, version) {
|
|
970
|
+
return tagFormat.replace("{version}", version);
|
|
971
|
+
}
|
|
972
|
+
function tagGlob(tagFormat) {
|
|
973
|
+
return tagFormat.replace("{version}", "*");
|
|
974
|
+
}
|
|
975
|
+
function checkGitPolicy(ctx, policy, { version, isProtectedChannel }) {
|
|
976
|
+
const checks = [];
|
|
977
|
+
const branch = currentBranch(ctx);
|
|
978
|
+
if (policy.requireClean) {
|
|
979
|
+
const dirty = dirtyFiles(ctx);
|
|
980
|
+
if (dirty.length) {
|
|
981
|
+
checks.push({ name: "git-clean", status: "fail", message: `working tree suja:
|
|
982
|
+
${dirty.join("\n")}` });
|
|
983
|
+
} else {
|
|
984
|
+
checks.push({ name: "git-clean", status: "pass", message: "working tree limpa" });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (isProtectedChannel) {
|
|
988
|
+
if (branch !== policy.releaseBranch) {
|
|
989
|
+
checks.push({
|
|
990
|
+
name: "git-branch",
|
|
991
|
+
status: "fail",
|
|
992
|
+
message: `canal protegido exige branch ${policy.releaseBranch} (atual: ${branch}).`
|
|
993
|
+
});
|
|
994
|
+
} else {
|
|
995
|
+
checks.push({ name: "git-branch", status: "pass", message: `branch ${branch}` });
|
|
996
|
+
}
|
|
997
|
+
const head = headSha(ctx);
|
|
998
|
+
const expectedTag = renderTag(policy.tagFormat, version);
|
|
999
|
+
const tagSha = tagShaFormatted(ctx, policy.tagFormat, version);
|
|
1000
|
+
if (!tagSha) {
|
|
1001
|
+
checks.push({ name: "git-tag", status: "fail", message: `tag ${expectedTag} n\xE3o existe (vers\xE3o atual: ${version}).` });
|
|
1002
|
+
} else if (tagSha !== head) {
|
|
1003
|
+
checks.push({
|
|
1004
|
+
name: "git-tag",
|
|
1005
|
+
status: "fail",
|
|
1006
|
+
message: `tag ${expectedTag} (${tagSha.slice(0, 8)}) n\xE3o aponta para HEAD (${head.slice(0, 8)}).`
|
|
1007
|
+
});
|
|
1008
|
+
} else {
|
|
1009
|
+
checks.push({ name: "git-tag", status: "pass", message: `${expectedTag} == HEAD` });
|
|
1010
|
+
}
|
|
1011
|
+
if (policy.requireSyncedWithRemote) {
|
|
1012
|
+
const remote = remoteSha(ctx, policy.remote, policy.releaseBranch);
|
|
1013
|
+
if (!remote) {
|
|
1014
|
+
checks.push({
|
|
1015
|
+
name: "git-sync",
|
|
1016
|
+
status: "fail",
|
|
1017
|
+
message: `${policy.remote}/${policy.releaseBranch} ausente. Rode git fetch ${policy.remote} ${policy.releaseBranch}.`
|
|
1018
|
+
});
|
|
1019
|
+
} else if (remote !== head) {
|
|
1020
|
+
checks.push({
|
|
1021
|
+
name: "git-sync",
|
|
1022
|
+
status: "fail",
|
|
1023
|
+
message: `HEAD (${head.slice(0, 8)}) \u2260 ${policy.remote}/${policy.releaseBranch} (${remote.slice(0, 8)}).`
|
|
1024
|
+
});
|
|
1025
|
+
} else {
|
|
1026
|
+
checks.push({ name: "git-sync", status: "pass", message: `HEAD == ${policy.remote}/${policy.releaseBranch}` });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return checks;
|
|
1030
|
+
}
|
|
1031
|
+
if (policy.requireSyncedWithRemote) {
|
|
1032
|
+
const status = aheadBehind(ctx, policy.remote, branch);
|
|
1033
|
+
if (!status) {
|
|
1034
|
+
checks.push({ name: "git-sync", status: "fail", message: `branch ${branch} sem upstream. Rode git push -u.` });
|
|
1035
|
+
} else if (status.behind > 0) {
|
|
1036
|
+
checks.push({ name: "git-sync", status: "fail", message: `branch atrasada ${status.behind} commit(s) em rela\xE7\xE3o ao upstream.` });
|
|
1037
|
+
} else if (status.ahead > 0) {
|
|
1038
|
+
checks.push({ name: "git-sync", status: "fail", message: `branch adiantada ${status.ahead} commit(s) \u2014 fa\xE7a push antes de buildar.` });
|
|
1039
|
+
} else {
|
|
1040
|
+
checks.push({ name: "git-sync", status: "pass", message: "sincronizada com o upstream" });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return checks;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/project/version.ts
|
|
1047
|
+
init_run();
|
|
1048
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1049
|
+
import { join as join4 } from "path";
|
|
1050
|
+
import semver from "semver";
|
|
1051
|
+
function loadVersionFile(appDir, source) {
|
|
1052
|
+
const path = join4(appDir, source);
|
|
1053
|
+
if (!existsSync4(path)) fail(`${source} n\xE3o encontrado em ${appDir}.`);
|
|
1054
|
+
const raw = readFileSync4(path, "utf8");
|
|
1055
|
+
const json = JSON.parse(raw);
|
|
1056
|
+
if (source === "app.json") {
|
|
1057
|
+
return {
|
|
1058
|
+
path,
|
|
1059
|
+
json,
|
|
1060
|
+
get: () => {
|
|
1061
|
+
const expo = json.expo;
|
|
1062
|
+
const version = expo?.version;
|
|
1063
|
+
if (typeof version !== "string") fail(`app.json n\xE3o tem expo.version.`);
|
|
1064
|
+
return version;
|
|
1065
|
+
},
|
|
1066
|
+
set: (next) => {
|
|
1067
|
+
const expo = json.expo ?? {};
|
|
1068
|
+
expo.version = next;
|
|
1069
|
+
json.expo = expo;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
return {
|
|
1074
|
+
path,
|
|
1075
|
+
json,
|
|
1076
|
+
get: () => {
|
|
1077
|
+
const version = json.version;
|
|
1078
|
+
if (typeof version !== "string") fail(`package.json n\xE3o tem "version".`);
|
|
1079
|
+
return version;
|
|
1080
|
+
},
|
|
1081
|
+
set: (next) => {
|
|
1082
|
+
json.version = next;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function readAppVersion(appDir, config) {
|
|
1087
|
+
return loadVersionFile(appDir, config.version.source).get();
|
|
1088
|
+
}
|
|
1089
|
+
function nextVersion(current, bump) {
|
|
1090
|
+
if (bump === "patch" || bump === "minor" || bump === "major") {
|
|
1091
|
+
const next = semver.inc(current, bump);
|
|
1092
|
+
if (!next) fail(`n\xE3o foi poss\xEDvel incrementar "${bump}" a partir de ${current}.`);
|
|
1093
|
+
return next;
|
|
1094
|
+
}
|
|
1095
|
+
if (!semver.valid(bump)) fail(`vers\xE3o inv\xE1lida: "${bump}". Use patch | minor | major | x.y.z.`);
|
|
1096
|
+
if (semver.lte(bump, current)) {
|
|
1097
|
+
fail(`${bump} n\xE3o \xE9 maior que a vers\xE3o atual (${current}).`);
|
|
1098
|
+
}
|
|
1099
|
+
return bump;
|
|
1100
|
+
}
|
|
1101
|
+
function writeAppVersion(appDir, config, next) {
|
|
1102
|
+
const file = loadVersionFile(appDir, config.version.source);
|
|
1103
|
+
file.set(next);
|
|
1104
|
+
writeFileSync3(file.path, `${JSON.stringify(file.json, null, " ")}
|
|
1105
|
+
`);
|
|
1106
|
+
return file.path;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/doctor/checks.ts
|
|
1110
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1111
|
+
import { join as join5 } from "path";
|
|
1112
|
+
|
|
1113
|
+
// src/doctor/toolchain.ts
|
|
1114
|
+
function parseVersion(input) {
|
|
1115
|
+
const match = String(input ?? "").replace(/^v/i, "").match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
1116
|
+
if (!match) return null;
|
|
1117
|
+
return { major: Number(match[1]), minor: Number(match[2] || 0), patch: Number(match[3] || 0) };
|
|
1118
|
+
}
|
|
1119
|
+
function cmpVersion(a, b) {
|
|
1120
|
+
const pa = parseVersion(a);
|
|
1121
|
+
const pb = parseVersion(b);
|
|
1122
|
+
if (!pa || !pb) return 0;
|
|
1123
|
+
if (pa.major !== pb.major) return pa.major - pb.major;
|
|
1124
|
+
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
|
|
1125
|
+
return pa.patch - pb.patch;
|
|
1126
|
+
}
|
|
1127
|
+
function collectToolchain(ctx) {
|
|
1128
|
+
const capture = (command, args, pick) => {
|
|
1129
|
+
const result = ctx.run(command, args, { inherit: false });
|
|
1130
|
+
if (result.status !== 0) return null;
|
|
1131
|
+
return pick(`${result.stdout}
|
|
1132
|
+
${result.stderr}`);
|
|
1133
|
+
};
|
|
1134
|
+
const firstMatch = (text2, re) => text2.match(re)?.[1] ?? null;
|
|
1135
|
+
return {
|
|
1136
|
+
easCli: capture("eas", ["--version"], (t) => firstMatch(t, /eas-cli\/(\d+\.\d+\.\d+)/)),
|
|
1137
|
+
node: capture("node", ["-v"], (t) => firstMatch(t, /v?(\d+\.\d+\.\d+)/)),
|
|
1138
|
+
yarn: capture("yarn", ["-v"], (t) => firstMatch(t, /(\d+\.\d+\.\d+)/)),
|
|
1139
|
+
jdk: capture("java", ["-version"], (t) => firstMatch(t, /version "(\d+(?:\.\d+\.\d+)?)/)),
|
|
1140
|
+
xcode: capture("xcodebuild", ["-version"], (t) => firstMatch(t, /Xcode (\d+\.\d+)/)),
|
|
1141
|
+
xcodeBuild: capture("xcodebuild", ["-version"], (t) => firstMatch(t, /Build version (\S+)/)),
|
|
1142
|
+
cocoapods: capture("pod", ["--version"], (t) => firstMatch(t, /(\d+\.\d+\.\d+)/)),
|
|
1143
|
+
ndkHome: ctx.env.ANDROID_NDK_HOME || null,
|
|
1144
|
+
host: `${process.platform}-${process.arch}`
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/doctor/checks.ts
|
|
1149
|
+
var EXPO_PUBLIC_PREFIX = "EXPO_PUBLIC_";
|
|
1150
|
+
function check(name, status, message) {
|
|
1151
|
+
return { name, status, message };
|
|
1152
|
+
}
|
|
1153
|
+
function assertShellClean(ctx, checks) {
|
|
1154
|
+
const leaked = Object.keys(ctx.env).filter((key) => key.startsWith(EXPO_PUBLIC_PREFIX) && ctx.env[key]);
|
|
1155
|
+
if (leaked.length) {
|
|
1156
|
+
checks.push(
|
|
1157
|
+
check(
|
|
1158
|
+
"shell-env",
|
|
1159
|
+
"fail",
|
|
1160
|
+
`shell tem ${leaked.join(", ")} exportado. Em build local isso vence as env vars do EAS. Unset antes de continuar.`
|
|
1161
|
+
)
|
|
1162
|
+
);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
checks.push(check("shell-env", "pass", "nenhum EXPO_PUBLIC_* no shell"));
|
|
1166
|
+
}
|
|
1167
|
+
function assertSentry(ctx, checks, channel) {
|
|
1168
|
+
if (channel?.sentryUploadDisabled) {
|
|
1169
|
+
checks.push(check("sentry", "pass", "canal desliga o upload de sourcemap; token n\xE3o \xE9 exigido."));
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
const inShell = Boolean(ctx.env.SENTRY_AUTH_TOKEN);
|
|
1173
|
+
if (!channel && !inShell) {
|
|
1174
|
+
checks.push(check("sentry", "skip", "passe --channel para verificar SENTRY_AUTH_TOKEN no environment EAS."));
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (inShell) {
|
|
1178
|
+
checks.push(
|
|
1179
|
+
check(
|
|
1180
|
+
"sentry",
|
|
1181
|
+
"warn",
|
|
1182
|
+
"SENTRY_AUTH_TOKEN est\xE1 no shell e, em build local, vence o token do EAS. Prefira unset e deixe o environment EAS (Sensitive) injetar."
|
|
1183
|
+
)
|
|
1184
|
+
);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const environment = channel?.easEnvironment;
|
|
1188
|
+
if (environment && easEnvListHasKey(ctx, environment, "SENTRY_AUTH_TOKEN")) {
|
|
1189
|
+
checks.push(check("sentry", "pass", `SENTRY_AUTH_TOKEN no environment EAS ${environment}.`));
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
checks.push(
|
|
1193
|
+
check(
|
|
1194
|
+
"sentry",
|
|
1195
|
+
"fail",
|
|
1196
|
+
`SENTRY_AUTH_TOKEN ausente no shell e no environment EAS ${environment || "(defina easEnvironment no canal)"}.`
|
|
1197
|
+
)
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
function assertEasAuth(ctx, checks, easOwner) {
|
|
1201
|
+
const result = easWhoami(ctx);
|
|
1202
|
+
if (ctx.dryRun) {
|
|
1203
|
+
checks.push(check("eas-auth", "pass", "[dry-run] eas whoami"));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (!result.ok) {
|
|
1207
|
+
checks.push(check("eas-auth", "fail", `eas whoami falhou. Rode \`eas login\` ou defina EXPO_TOKEN.
|
|
1208
|
+
${result.raw}`));
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
if (easOwner && !result.raw.includes(easOwner)) {
|
|
1212
|
+
checks.push(check("eas-auth", "fail", `eas whoami n\xE3o listou a conta ${easOwner}.`));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
checks.push(check("eas-auth", "pass", `autenticado${result.account ? ` como ${result.account}` : ""}`));
|
|
1216
|
+
}
|
|
1217
|
+
function assertToolchain(ctx, checks, config, { platform, allowToolchainDrift }) {
|
|
1218
|
+
const baseline = config.toolchain;
|
|
1219
|
+
const actual = collectToolchain(ctx);
|
|
1220
|
+
const drift = [];
|
|
1221
|
+
const compare = (name, spec, value, { platformOnly } = {}) => {
|
|
1222
|
+
if (!spec) {
|
|
1223
|
+
checks.push(check(`toolchain-${name}`, "skip", "sem baseline configurado"));
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (platformOnly && platform && platform !== platformOnly) {
|
|
1227
|
+
checks.push(check(`toolchain-${name}`, "skip", `n\xE3o exigido para --platform ${platform}`));
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (!value) {
|
|
1231
|
+
checks.push(check(`toolchain-${name}`, "fail", `${name} n\xE3o encontrado no PATH.`));
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (spec.min && cmpVersion(value, spec.min) < 0) {
|
|
1235
|
+
checks.push(check(`toolchain-${name}`, "fail", `${name} ${value} < m\xEDnimo ${spec.min}.`));
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (spec.tested && cmpVersion(value, spec.tested) > 0) {
|
|
1239
|
+
drift.push(`${name} ${value} > testado ${spec.tested}`);
|
|
1240
|
+
checks.push(
|
|
1241
|
+
check(
|
|
1242
|
+
`toolchain-${name}`,
|
|
1243
|
+
allowToolchainDrift ? "warn" : "fail",
|
|
1244
|
+
`${name} ${value} est\xE1 acima do testado (${spec.tested}). Passe --allow-toolchain-drift para continuar.`
|
|
1245
|
+
)
|
|
1246
|
+
);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
checks.push(check(`toolchain-${name}`, "pass", `${name} ${value}`));
|
|
1250
|
+
};
|
|
1251
|
+
compare("eas-cli", baseline.easCli, actual.easCli);
|
|
1252
|
+
compare("node", baseline.node, actual.node);
|
|
1253
|
+
compare("jdk", baseline.jdk, actual.jdk, { platformOnly: "android" });
|
|
1254
|
+
compare("xcode", baseline.xcode, actual.xcode, { platformOnly: "ios" });
|
|
1255
|
+
compare("cocoapods", baseline.cocoapods, actual.cocoapods, { platformOnly: "ios" });
|
|
1256
|
+
if (!platform || platform === "android") {
|
|
1257
|
+
if (!ctx.env.JAVA_HOME) {
|
|
1258
|
+
checks.push(check("java-home", "fail", "JAVA_HOME n\xE3o est\xE1 definido."));
|
|
1259
|
+
} else {
|
|
1260
|
+
checks.push(check("java-home", "pass", ctx.env.JAVA_HOME));
|
|
1261
|
+
}
|
|
1262
|
+
if (!ctx.env.ANDROID_HOME && !ctx.env.ANDROID_SDK_ROOT) {
|
|
1263
|
+
checks.push(check("android-sdk", "fail", "ANDROID_HOME / ANDROID_SDK_ROOT ausente."));
|
|
1264
|
+
} else {
|
|
1265
|
+
checks.push(check("android-sdk", "pass", ctx.env.ANDROID_HOME || ctx.env.ANDROID_SDK_ROOT || ""));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (platform === "ios" && process.platform !== "darwin") {
|
|
1269
|
+
checks.push(check("platform-ios", "fail", "build local de iOS s\xF3 funciona no macOS. Use --cloud ou remova --platform ios."));
|
|
1270
|
+
} else if (platform === "android" && process.platform === "win32") {
|
|
1271
|
+
checks.push(
|
|
1272
|
+
check(
|
|
1273
|
+
"platform-android",
|
|
1274
|
+
"warn",
|
|
1275
|
+
"build local de Android no Windows n\xE3o \xE9 suportado pelo EAS fora do WSL2. Use --cloud ou rode dentro do WSL2."
|
|
1276
|
+
)
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
return { actual, drift, toolchainDrift: drift.length > 0 };
|
|
1280
|
+
}
|
|
1281
|
+
function assertLockfileClean(ctx, checks) {
|
|
1282
|
+
const pm = detectPackageManager(ctx.repoRoot);
|
|
1283
|
+
const lockfile = { yarn: "yarn.lock", pnpm: "pnpm-lock.yaml", npm: "package-lock.json" }[pm];
|
|
1284
|
+
const diff = ctx.run("git", ["diff", "--quiet", "HEAD", "--", lockfile], { cwd: ctx.repoRoot, inherit: false });
|
|
1285
|
+
if (diff.status !== 0) {
|
|
1286
|
+
checks.push(check("lockfile", "fail", `${lockfile} diverge do HEAD. Commit ou restaure antes do build (lockfile congelado).`));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
checks.push(check("lockfile", "pass", `${lockfile} igual ao HEAD`));
|
|
1290
|
+
}
|
|
1291
|
+
function assertRemoteVersion(ctx, checks, { platform, channel }) {
|
|
1292
|
+
const remote = projectRemoteVersion(ctx, { platform, buildProfile: channel.buildProfile });
|
|
1293
|
+
if (remote.dryRun) {
|
|
1294
|
+
checks.push(check("remote-version", "pass", "[dry-run] eas build:version:get"));
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const projection = expectedBuildNumber(remote, { autoIncrement: channel.autoIncrement, platform });
|
|
1298
|
+
if (!channel.autoIncrement) {
|
|
1299
|
+
checks.push(
|
|
1300
|
+
check(
|
|
1301
|
+
"remote-version",
|
|
1302
|
+
"pass",
|
|
1303
|
+
`${projection.key} remoto atual: ${projection.current} (autoIncrement desligado; este build reutiliza o n\xFAmero).`
|
|
1304
|
+
)
|
|
1305
|
+
);
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
checks.push(
|
|
1309
|
+
check(
|
|
1310
|
+
"remote-version",
|
|
1311
|
+
"pass",
|
|
1312
|
+
`${projection.key} remoto atual: ${projection.current} \u2192 esperado neste build: ${projection.expected} (s\xF3 \xE9 reservado quando o EAS iniciar o build).`
|
|
1313
|
+
)
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
function assertFingerprint(ctx, checks, { channel, deep, buildId }) {
|
|
1317
|
+
const environment = channel.easEnvironment;
|
|
1318
|
+
if (!environment) {
|
|
1319
|
+
checks.push(check("fingerprint", "skip", "canal sem easEnvironment configurado."));
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (!deep) {
|
|
1323
|
+
checks.push(check("fingerprint", "pass", `compara\xE7\xE3o contra build da nuvem s\xF3 em --deep (environment ${environment}).`));
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (ctx.dryRun) {
|
|
1327
|
+
checks.push(check("fingerprint", "pass", `[dry-run] eas fingerprint:generate --environment ${environment}`));
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
const generated = easFingerprintGenerate(ctx, environment);
|
|
1331
|
+
if (generated.status !== 0) {
|
|
1332
|
+
checks.push(check("fingerprint", "fail", `eas fingerprint:generate falhou.
|
|
1333
|
+
${generated.stderr || generated.stdout}`));
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (!buildId) {
|
|
1337
|
+
checks.push(
|
|
1338
|
+
check(
|
|
1339
|
+
"fingerprint",
|
|
1340
|
+
"pass",
|
|
1341
|
+
`fingerprint local gerado. Para comparar com a nuvem: eas fingerprint:compare --build-id <id> --environment ${environment}`
|
|
1342
|
+
)
|
|
1343
|
+
);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const compared = easFingerprintCompare(ctx, environment, buildId);
|
|
1347
|
+
if (compared.status !== 0) {
|
|
1348
|
+
checks.push(check("fingerprint", "fail", `eas fingerprint:compare falhou.
|
|
1349
|
+
${compared.stderr || compared.stdout}`));
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
checks.push(check("fingerprint", "pass", `fingerprint local vs build ${buildId}`));
|
|
1353
|
+
}
|
|
1354
|
+
function assertExpoDoctor(ctx, checks, config) {
|
|
1355
|
+
if (!config.doctor.expoDoctor) {
|
|
1356
|
+
checks.push(check("expo-doctor", "skip", "desligado em doctor.expoDoctor"));
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (ctx.dryRun) {
|
|
1360
|
+
checks.push(check("expo-doctor", "pass", "[dry-run] npx expo-doctor"));
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const result = ctx.run("npx", ["expo-doctor"], { cwd: ctx.appDir, inherit: false });
|
|
1364
|
+
if (result.status !== 0) {
|
|
1365
|
+
const detail = (result.stdout || result.stderr || "").trim();
|
|
1366
|
+
checks.push(
|
|
1367
|
+
check("expo-doctor", "fail", `expo-doctor saiu com c\xF3digo ${result.status}. Atualize as depend\xEAncias com \`npx expo install\`.${detail ? `
|
|
1368
|
+
${detail}` : ""}`)
|
|
1369
|
+
);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
checks.push(check("expo-doctor", "pass", "expo-doctor ok"));
|
|
1373
|
+
}
|
|
1374
|
+
function assertEasJsonExists(ctx) {
|
|
1375
|
+
if (!existsSync5(join5(ctx.appDir, "eas.json"))) {
|
|
1376
|
+
throw new Error(`eas.json n\xE3o encontrado em ${ctx.appDir}. Rode \`eas init\` ou \`release-kit init\`.`);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/doctor/run.ts
|
|
1381
|
+
function runDoctor(ctx, config, opts) {
|
|
1382
|
+
assertEasJsonExists(ctx);
|
|
1383
|
+
const channel = opts.channel ? channelOf(config, opts.channel) : null;
|
|
1384
|
+
if (opts.platform && !config.platforms.includes(opts.platform)) {
|
|
1385
|
+
fail(`--platform ${opts.platform} n\xE3o est\xE1 em config.platforms (${config.platforms.join(", ")}).`);
|
|
1386
|
+
}
|
|
1387
|
+
const checks = [];
|
|
1388
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
1389
|
+
checks.push(...checkGitPolicy(ctx, config.git, { version, isProtectedChannel: Boolean(channel?.protected) }));
|
|
1390
|
+
assertShellClean(ctx, checks);
|
|
1391
|
+
if (config.doctor.sentry) {
|
|
1392
|
+
assertSentry(ctx, checks, channel);
|
|
1393
|
+
} else {
|
|
1394
|
+
checks.push({ name: "sentry", status: "skip", message: "desligado em doctor.sentry" });
|
|
1395
|
+
}
|
|
1396
|
+
assertEasAuth(ctx, checks, config.easOwner);
|
|
1397
|
+
const toolchain = assertToolchain(ctx, checks, config, {
|
|
1398
|
+
platform: opts.platform,
|
|
1399
|
+
allowToolchainDrift: opts.allowToolchainDrift
|
|
1400
|
+
});
|
|
1401
|
+
assertLockfileClean(ctx, checks);
|
|
1402
|
+
if (channel && opts.platform) {
|
|
1403
|
+
assertRemoteVersion(ctx, checks, { platform: opts.platform, channel });
|
|
1404
|
+
assertFingerprint(ctx, checks, { channel, deep: opts.deep, buildId: opts.buildId });
|
|
1405
|
+
} else {
|
|
1406
|
+
checks.push({ name: "remote-version", status: "skip", message: "passe --channel e --platform para consultar versionCode/buildNumber" });
|
|
1407
|
+
checks.push({ name: "fingerprint", status: "skip", message: "passe --channel para avaliar runtimeVersion" });
|
|
1408
|
+
}
|
|
1409
|
+
assertExpoDoctor(ctx, checks, config);
|
|
1410
|
+
const failed = checks.filter((item) => item.status === "fail");
|
|
1411
|
+
return {
|
|
1412
|
+
ok: failed.length === 0,
|
|
1413
|
+
checks,
|
|
1414
|
+
toolchainDrift: toolchain.toolchainDrift
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
function printDoctorReport(report, ctx) {
|
|
1418
|
+
for (const item of report.checks) {
|
|
1419
|
+
const mark = item.status === "pass" ? "\u2713" : item.status === "skip" ? "\xB7" : item.status === "warn" ? "!" : "\u2717";
|
|
1420
|
+
ctx.log.info(`${mark} ${item.name}: ${item.message}`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
function requireDoctorPass(report) {
|
|
1424
|
+
if (report.ok) return;
|
|
1425
|
+
const failed = report.checks.filter((item) => item.status === "fail");
|
|
1426
|
+
const summary = failed.map((item) => `- ${item.name}: ${item.message}`).join("\n");
|
|
1427
|
+
fail(`doctor falhou:
|
|
1428
|
+
${summary}`);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/cli/init.ts
|
|
1432
|
+
function cancel2(message = "Setup cancelado.") {
|
|
1433
|
+
p.cancel(message);
|
|
1434
|
+
process.exit(1);
|
|
1435
|
+
}
|
|
1436
|
+
async function runInitWizard() {
|
|
1437
|
+
p.intro(pc2.bold("release-kit init"));
|
|
1438
|
+
const repoRoot = process.cwd().includes(".git") ? process.cwd() : findGitRootOrCwd();
|
|
1439
|
+
const ctxBase = buildContext({});
|
|
1440
|
+
const slug = basenameSlug(ctxBase.repoRoot);
|
|
1441
|
+
const packageManager = detectPackageManager(ctxBase.repoRoot);
|
|
1442
|
+
const detectedAppDir = detectAppDir(ctxBase.repoRoot);
|
|
1443
|
+
const appDirRelative = detectedAppDir ?? await promptText("Diret\xF3rio do app Expo (relativo \xE0 raiz do repo)", ".");
|
|
1444
|
+
const appDir = resolve2(ctxBase.repoRoot, appDirRelative);
|
|
1445
|
+
if (!existsSync6(join6(appDir, "app.json"))) {
|
|
1446
|
+
cancel2(`app.json n\xE3o encontrado em ${appDir}. Rode este comando na raiz de um projeto Expo, ou informe o diret\xF3rio certo.`);
|
|
1447
|
+
}
|
|
1448
|
+
p.log.success(`projeto: ${packageManager}, appDir=${appDirRelative}`);
|
|
1449
|
+
const ctx = { ...ctxBase, appDir };
|
|
1450
|
+
assertEasCliAvailable(ctx);
|
|
1451
|
+
p.log.success("eas-cli encontrado no PATH");
|
|
1452
|
+
warnPlatformLimits();
|
|
1453
|
+
await ensureEasLogin(ctx);
|
|
1454
|
+
const easJson = readEasJson(appDir);
|
|
1455
|
+
if (!easJson) cancel2(`eas.json n\xE3o encontrado em ${appDirRelative}. Rode \`eas init\` primeiro ou aponte para o appDir certo.`);
|
|
1456
|
+
const platforms = await promptPlatforms();
|
|
1457
|
+
const channels = await defineChannels(ctx, easJson, platforms);
|
|
1458
|
+
const envUpdates = {};
|
|
1459
|
+
if (platforms.includes("ios")) {
|
|
1460
|
+
await runAppleCredentialsStep(ctx, slug, channels, envUpdates);
|
|
1461
|
+
}
|
|
1462
|
+
if (platforms.includes("android")) {
|
|
1463
|
+
await runGoogleCredentialsStep(slug, channels, envUpdates);
|
|
1464
|
+
}
|
|
1465
|
+
if (Object.keys(envUpdates).length > 0) {
|
|
1466
|
+
const sync = await p.confirm({
|
|
1467
|
+
message: "Configurar essas credenciais tamb\xE9m no EAS (env vars/credentials), pra build/submit na nuvem e CI funcionarem sem prompt?"
|
|
1468
|
+
});
|
|
1469
|
+
if (sync === true) {
|
|
1470
|
+
p.log.info("Rode manualmente por enquanto: `eas credentials` (ASC key / Play SA) e `eas env:create` para as demais env vars. Automa\xE7\xE3o completa fica para uma vers\xE3o futura do release-kit.");
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
const locales = await promptLocales();
|
|
1474
|
+
const config = releaseKitConfigSchema.parse({
|
|
1475
|
+
appDir: appDirRelative,
|
|
1476
|
+
platforms,
|
|
1477
|
+
locales,
|
|
1478
|
+
channels
|
|
1479
|
+
});
|
|
1480
|
+
writeConfig(ctx.repoRoot, config);
|
|
1481
|
+
if (Object.keys(envUpdates).length > 0) writeEnvFile(ctx.repoRoot, envUpdates);
|
|
1482
|
+
ensureGitignoreEntries(ctx.repoRoot);
|
|
1483
|
+
mkdirSync2(join6(appDir, config.notesDir), { recursive: true });
|
|
1484
|
+
addReleaseScript(ctx.repoRoot, packageManager);
|
|
1485
|
+
p.log.success(`escrito: release-kit.config.json, .env.release, ${config.notesDir}/`);
|
|
1486
|
+
const finalCtx = { ...ctx, releaseDir: join6(appDir, ".release"), env: { ...process.env, ...readEnvFile(ctx.repoRoot) } };
|
|
1487
|
+
try {
|
|
1488
|
+
const report = runDoctor(finalCtx, config, {});
|
|
1489
|
+
printDoctorReport(report, finalCtx);
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
p.log.warn(`doctor encontrou pend\xEAncias: ${error.message}`);
|
|
1492
|
+
}
|
|
1493
|
+
p.outro("Setup conclu\xEDdo. Pr\xF3ximo passo: `release-kit doctor --channel <canal> --platform <plataforma>`.");
|
|
1494
|
+
}
|
|
1495
|
+
function findGitRootOrCwd() {
|
|
1496
|
+
return process.cwd();
|
|
1497
|
+
}
|
|
1498
|
+
function basenameSlug(repoRoot) {
|
|
1499
|
+
return repoRoot.split("/").filter(Boolean).pop() ?? "release-kit-project";
|
|
1500
|
+
}
|
|
1501
|
+
async function promptText(message, initialValue) {
|
|
1502
|
+
const value = await p.text({ message, initialValue });
|
|
1503
|
+
if (p.isCancel(value)) cancel2();
|
|
1504
|
+
return value;
|
|
1505
|
+
}
|
|
1506
|
+
function warnPlatformLimits() {
|
|
1507
|
+
if (process.platform === "win32") {
|
|
1508
|
+
p.log.warn("Windows: build local de iOS n\xE3o \xE9 suportado (precisa de macOS) e build local de Android exige WSL2. Use --cloud nesses casos.");
|
|
1509
|
+
} else if (process.platform === "linux") {
|
|
1510
|
+
p.log.warn("Linux: build local de iOS n\xE3o \xE9 suportado (precisa de macOS). Android funciona normalmente.");
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
async function ensureEasLogin(ctx) {
|
|
1514
|
+
const whoami = easWhoami(ctx);
|
|
1515
|
+
if (whoami.ok) {
|
|
1516
|
+
p.log.success(`eas: autenticado${whoami.account ? ` como ${whoami.account}` : ""}`);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const choice = await p.select({
|
|
1520
|
+
message: "N\xE3o autenticado no EAS. Como prefere entrar?",
|
|
1521
|
+
options: [
|
|
1522
|
+
{ value: "login", label: "eas login (abre o fluxo interativo do eas-cli)" },
|
|
1523
|
+
{ value: "token", label: "Colar um EXPO_TOKEN" }
|
|
1524
|
+
]
|
|
1525
|
+
});
|
|
1526
|
+
if (p.isCancel(choice)) cancel2();
|
|
1527
|
+
if (choice === "login") {
|
|
1528
|
+
ctx.run("eas", ["login"], { inherit: true });
|
|
1529
|
+
} else {
|
|
1530
|
+
const token = await promptText("EXPO_TOKEN");
|
|
1531
|
+
process.env.EXPO_TOKEN = token;
|
|
1532
|
+
}
|
|
1533
|
+
const retry = easWhoami(ctx);
|
|
1534
|
+
if (!retry.ok) cancel2("Ainda n\xE3o autenticado no EAS. Rode `release-kit init` de novo depois de `eas login`.");
|
|
1535
|
+
p.log.success(`eas: autenticado${retry.account ? ` como ${retry.account}` : ""}`);
|
|
1536
|
+
const easJson = readEasJson(ctx.appDir);
|
|
1537
|
+
if (!easJson) {
|
|
1538
|
+
const doInit = await p.confirm({ message: "eas.json n\xE3o encontrado. Rodar `eas init` agora?" });
|
|
1539
|
+
if (doInit === true) {
|
|
1540
|
+
ctx.run("eas", ["init", "--non-interactive"], { cwd: ctx.appDir, inherit: true });
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
async function promptPlatforms() {
|
|
1545
|
+
const selected = await p.multiselect({
|
|
1546
|
+
message: "Quais plataformas este projeto publica?",
|
|
1547
|
+
options: [
|
|
1548
|
+
{ value: "android", label: "Android (Google Play)" },
|
|
1549
|
+
{ value: "ios", label: "iOS (App Store)" }
|
|
1550
|
+
],
|
|
1551
|
+
initialValues: ["android", "ios"]
|
|
1552
|
+
});
|
|
1553
|
+
if (p.isCancel(selected)) cancel2();
|
|
1554
|
+
return selected;
|
|
1555
|
+
}
|
|
1556
|
+
async function promptLocales() {
|
|
1557
|
+
const raw = await p.text({ message: "Locales das notas de loja, separados por v\xEDrgula", initialValue: "pt-BR" });
|
|
1558
|
+
if (p.isCancel(raw)) cancel2();
|
|
1559
|
+
return raw.split(",").map((l) => l.trim()).filter(Boolean);
|
|
1560
|
+
}
|
|
1561
|
+
async function defineChannels(ctx, easJson, platforms) {
|
|
1562
|
+
const build = easJson.build ?? {};
|
|
1563
|
+
const profileNames = Object.keys(build).filter((name) => name !== "development");
|
|
1564
|
+
p.log.info(`build profiles encontrados no eas.json: ${profileNames.join(", ") || "(nenhum)"}`);
|
|
1565
|
+
const channels = {};
|
|
1566
|
+
for (const name of profileNames) {
|
|
1567
|
+
const use = await p.confirm({ message: `Criar o canal "${name}" a partir deste build profile?`, initialValue: true });
|
|
1568
|
+
if (p.isCancel(use) || !use) continue;
|
|
1569
|
+
const isProtected = await p.confirm({ message: `"${name}" \xE9 um canal protegido (produ\xE7\xE3o \u2014 gate fail-closed)?`, initialValue: name === "production" });
|
|
1570
|
+
if (p.isCancel(isProtected)) cancel2();
|
|
1571
|
+
const goesToStore = await p.confirm({ message: `"${name}" envia \xE0 loja (submit)?`, initialValue: true });
|
|
1572
|
+
if (p.isCancel(goesToStore)) cancel2();
|
|
1573
|
+
const channel = {
|
|
1574
|
+
protected: isProtected,
|
|
1575
|
+
store: goesToStore,
|
|
1576
|
+
buildProfile: name,
|
|
1577
|
+
submitProfile: name,
|
|
1578
|
+
env: {}
|
|
1579
|
+
};
|
|
1580
|
+
if (goesToStore) {
|
|
1581
|
+
for (const platform of platforms) {
|
|
1582
|
+
const envForConfig = await tryResolveEnv(ctx, platform, name);
|
|
1583
|
+
if (platform === "ios") {
|
|
1584
|
+
const bundleId = await promptText(`bundleId iOS para "${name}"`, envForConfig?.ios?.bundleIdentifier);
|
|
1585
|
+
channel.ios = { bundleId, phasedRelease: isProtected, automaticRelease: false };
|
|
1586
|
+
}
|
|
1587
|
+
if (platform === "android") {
|
|
1588
|
+
const packageName = await promptText(`package Android para "${name}"`, envForConfig?.android?.package);
|
|
1589
|
+
channel.android = { package: packageName, testTrack: "internal", productionTrack: isProtected ? "production" : "internal", initialRollout: isProtected ? 0.1 : 1 };
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
channels[name] = channelSchema.parse(channel);
|
|
1594
|
+
}
|
|
1595
|
+
if (Object.keys(channels).length === 0) cancel2("Nenhum canal configurado \u2014 o kit precisa de ao menos um.");
|
|
1596
|
+
return channels;
|
|
1597
|
+
}
|
|
1598
|
+
async function tryResolveEnv(ctx, platform, profileName) {
|
|
1599
|
+
try {
|
|
1600
|
+
return resolveExpoConfig(ctx, { APP_VARIANT: profileName });
|
|
1601
|
+
} catch {
|
|
1602
|
+
return null;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
async function runAppleCredentialsStep(ctx, slug, channels, envUpdates) {
|
|
1606
|
+
p.log.step("App Store Connect API Key");
|
|
1607
|
+
p.log.info('Crie em https://appstoreconnect.apple.com/access/integrations/api \u2014 role "App Manager" (ou "Admin").');
|
|
1608
|
+
const keyId = await promptText("ASC_KEY_ID (Key ID)");
|
|
1609
|
+
const issuerId = await promptText("ASC_ISSUER_ID (Issuer ID)");
|
|
1610
|
+
const keyPathRaw = await promptText("Caminho do arquivo .p8 baixado");
|
|
1611
|
+
const keyPath = resolve2(keyPathRaw);
|
|
1612
|
+
if (!existsSync6(keyPath)) cancel2(`Arquivo n\xE3o encontrado: ${keyPath}`);
|
|
1613
|
+
const privateKeyPem = readFileSync5(keyPath, "utf8");
|
|
1614
|
+
const client = new AppStoreConnectClient({ credentials: { keyId, issuerId, privateKeyPem } });
|
|
1615
|
+
const spinner2 = p.spinner();
|
|
1616
|
+
spinner2.start("Validando chave contra a App Store Connect API");
|
|
1617
|
+
try {
|
|
1618
|
+
for (const [name, channel] of Object.entries(channels)) {
|
|
1619
|
+
if (!channel.ios) continue;
|
|
1620
|
+
const app = await findAppByBundleId(client, channel.ios.bundleId);
|
|
1621
|
+
if (!app) {
|
|
1622
|
+
spinner2.stop(`Chave v\xE1lida, mas nenhum app com bundleId ${channel.ios.bundleId} encontrado (canal "${name}").`, 1);
|
|
1623
|
+
const proceed = await p.confirm({ message: "Continuar assim mesmo? (o app pode ainda n\xE3o existir no App Store Connect)" });
|
|
1624
|
+
if (p.isCancel(proceed) || !proceed) cancel2();
|
|
1625
|
+
spinner2.start("Continuando");
|
|
1626
|
+
} else {
|
|
1627
|
+
channel.ios.ascAppId = app.id;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
spinner2.stop("App Store Connect: chave validada.");
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
spinner2.stop(`Falha ao validar a chave: ${error.message}`, 1);
|
|
1633
|
+
cancel2();
|
|
1634
|
+
}
|
|
1635
|
+
const dest = storeCredentialFile(slug, keyPath, `AuthKey_${keyId}.p8`);
|
|
1636
|
+
envUpdates.ASC_KEY_ID = keyId;
|
|
1637
|
+
envUpdates.ASC_ISSUER_ID = issuerId;
|
|
1638
|
+
envUpdates.ASC_KEY_PATH = dest;
|
|
1639
|
+
p.log.success(`.p8 copiada para ${dest} (fora do reposit\xF3rio).`);
|
|
1640
|
+
}
|
|
1641
|
+
async function runGoogleCredentialsStep(slug, channels, envUpdates) {
|
|
1642
|
+
p.log.step("Google Play service account");
|
|
1643
|
+
p.log.info("Crie em Google Cloud Console \u2192 IAM \u2192 Service Accounts, e conceda acesso em Play Console \u2192 Utilizadores e permiss\xF5es.");
|
|
1644
|
+
const jsonPathRaw = await promptText("Caminho do JSON da service account");
|
|
1645
|
+
const jsonPath = resolve2(jsonPathRaw);
|
|
1646
|
+
if (!existsSync6(jsonPath)) cancel2(`Arquivo n\xE3o encontrado: ${jsonPath}`);
|
|
1647
|
+
const account = parseServiceAccountJson(readFileSync5(jsonPath, "utf8"));
|
|
1648
|
+
const client = new AndroidPublisherClient({ account });
|
|
1649
|
+
const spinner2 = p.spinner();
|
|
1650
|
+
spinner2.start("Validando acesso \xE0 Android Publisher API");
|
|
1651
|
+
try {
|
|
1652
|
+
for (const [name, channel] of Object.entries(channels)) {
|
|
1653
|
+
if (!channel.android) continue;
|
|
1654
|
+
try {
|
|
1655
|
+
await validatePlayAccess(client, channel.android.package);
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
spinner2.stop(`Falha ao acessar o package ${channel.android.package} (canal "${name}"): ${error.message}`, 1);
|
|
1658
|
+
p.log.warn("Causas comuns: app ainda n\xE3o criado na Play, primeiro AAB nunca enviado manualmente, ou a service account sem permiss\xE3o no Play Console.");
|
|
1659
|
+
const proceed = await p.confirm({ message: "Continuar assim mesmo?" });
|
|
1660
|
+
if (p.isCancel(proceed) || !proceed) cancel2();
|
|
1661
|
+
spinner2.start("Continuando");
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
spinner2.stop("Google Play: acesso validado.");
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
spinner2.stop(`Falha: ${error.message}`, 1);
|
|
1667
|
+
cancel2();
|
|
1668
|
+
}
|
|
1669
|
+
const dest = storeCredentialFile(slug, jsonPath, "play-service-account.json");
|
|
1670
|
+
envUpdates.GOOGLE_PLAY_JSON_KEY = dest;
|
|
1671
|
+
p.log.success(`JSON copiado para ${dest} (fora do reposit\xF3rio).`);
|
|
1672
|
+
}
|
|
1673
|
+
function ensureGitignoreEntries(repoRoot) {
|
|
1674
|
+
const path = join6(repoRoot, ".gitignore");
|
|
1675
|
+
const entries = [".env.release", ".release/"];
|
|
1676
|
+
const current = existsSync6(path) ? readFileSync5(path, "utf8") : "";
|
|
1677
|
+
const missing = entries.filter((entry) => !current.includes(entry));
|
|
1678
|
+
if (missing.length === 0) return;
|
|
1679
|
+
writeFileSync4(path, `${current}${current.endsWith("\n") || current === "" ? "" : "\n"}${missing.join("\n")}
|
|
1680
|
+
`);
|
|
1681
|
+
}
|
|
1682
|
+
function addReleaseScript(repoRoot, packageManager) {
|
|
1683
|
+
const path = join6(repoRoot, "package.json");
|
|
1684
|
+
if (!existsSync6(path)) return;
|
|
1685
|
+
const json = JSON.parse(readFileSync5(path, "utf8"));
|
|
1686
|
+
json.scripts = json.scripts ?? {};
|
|
1687
|
+
if (!json.scripts.release) {
|
|
1688
|
+
json.scripts.release = "release-kit";
|
|
1689
|
+
writeFileSync4(path, `${JSON.stringify(json, null, " ")}
|
|
1690
|
+
`);
|
|
1691
|
+
}
|
|
1692
|
+
void packageManager;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/cli/credentials.ts
|
|
1696
|
+
import * as p2 from "@clack/prompts";
|
|
1697
|
+
import pc3 from "picocolors";
|
|
1698
|
+
init_store();
|
|
1699
|
+
async function runCredentialsCommand(scope) {
|
|
1700
|
+
p2.intro(pc3.bold(`release-kit credentials ${scope ?? ""}`));
|
|
1701
|
+
const base = buildContext({});
|
|
1702
|
+
const config = loadConfig(base.repoRoot);
|
|
1703
|
+
const ctx = withAppDir(base, config);
|
|
1704
|
+
const slug = ctx.repoRoot.split("/").filter(Boolean).pop() ?? "release-kit-project";
|
|
1705
|
+
const envUpdates = {};
|
|
1706
|
+
if (!scope || scope === "expo") {
|
|
1707
|
+
await ensureEasLogin(ctx);
|
|
1708
|
+
}
|
|
1709
|
+
if (!scope || scope === "apple") {
|
|
1710
|
+
if (config.platforms.includes("ios")) await runAppleCredentialsStep(ctx, slug, config.channels, envUpdates);
|
|
1711
|
+
else p2.log.warn("config.platforms n\xE3o inclui ios \u2014 nada a validar.");
|
|
1712
|
+
}
|
|
1713
|
+
if (!scope || scope === "google") {
|
|
1714
|
+
if (config.platforms.includes("android")) await runGoogleCredentialsStep(slug, config.channels, envUpdates);
|
|
1715
|
+
else p2.log.warn("config.platforms n\xE3o inclui android \u2014 nada a validar.");
|
|
1716
|
+
}
|
|
1717
|
+
if (scope === "sync") {
|
|
1718
|
+
p2.log.info("Rode `eas credentials` (ASC key / Play SA) e `eas env:create` para sincronizar com builds na nuvem.");
|
|
1719
|
+
}
|
|
1720
|
+
if (Object.keys(envUpdates).length > 0) {
|
|
1721
|
+
writeEnvFile(ctx.repoRoot, envUpdates);
|
|
1722
|
+
writeConfig(ctx.repoRoot, config);
|
|
1723
|
+
p2.log.success(".env.release e release-kit.config.json atualizados.");
|
|
1724
|
+
}
|
|
1725
|
+
p2.outro("Credenciais atualizadas.");
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// src/cli/menu.ts
|
|
1729
|
+
import * as p3 from "@clack/prompts";
|
|
1730
|
+
import pc4 from "picocolors";
|
|
1731
|
+
|
|
1732
|
+
// src/release/version.ts
|
|
1733
|
+
init_run();
|
|
1734
|
+
import { relative } from "path";
|
|
1735
|
+
function bumpVersion(ctx, config, opts) {
|
|
1736
|
+
if (!isWorkingTreeClean(ctx)) {
|
|
1737
|
+
fail("working tree suja. Commit ou descarte as mudan\xE7as antes de rodar `release-kit version`.");
|
|
1738
|
+
}
|
|
1739
|
+
const current = readAppVersion(ctx.appDir, config);
|
|
1740
|
+
const next = nextVersion(current, opts.bump);
|
|
1741
|
+
const tag = renderTag(config.git.tagFormat, next);
|
|
1742
|
+
if (ctx.dryRun) {
|
|
1743
|
+
ctx.log.info(`[dry-run] ${current} \u2192 ${next} (tag ${tag})`);
|
|
1744
|
+
return { from: current, to: next, tag, dryRun: true };
|
|
1745
|
+
}
|
|
1746
|
+
const path = writeAppVersion(ctx.appDir, config, next);
|
|
1747
|
+
const relPath = relative(ctx.repoRoot, path);
|
|
1748
|
+
git(ctx, ["add", relPath]);
|
|
1749
|
+
git(ctx, ["commit", "-m", `chore(release): ${tag}`]);
|
|
1750
|
+
git(ctx, ["tag", tag]);
|
|
1751
|
+
ctx.log.success(`${current} \u2192 ${next} (commit + tag ${tag})`);
|
|
1752
|
+
if (opts.push) {
|
|
1753
|
+
requireSuccess(git(ctx, ["push", config.git.remote, config.git.releaseBranch]), "git push");
|
|
1754
|
+
requireSuccess(git(ctx, ["push", config.git.remote, tag]), "git push tag");
|
|
1755
|
+
ctx.log.success(`push ${config.git.remote} ${config.git.releaseBranch} + tag ${tag}`);
|
|
1756
|
+
}
|
|
1757
|
+
return { from: current, to: next, tag };
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/release/notes.ts
|
|
1761
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1762
|
+
|
|
1763
|
+
// src/git/commits.ts
|
|
1764
|
+
var CONVENTIONAL_RE = /^(\w+)(?:\(([^)]+)\))?!?:\s*(.+)$/;
|
|
1765
|
+
function parseSubject(subject) {
|
|
1766
|
+
const match = subject.match(CONVENTIONAL_RE);
|
|
1767
|
+
if (!match) return { type: null, scope: null, description: subject };
|
|
1768
|
+
const [, type, scope, description] = match;
|
|
1769
|
+
return { type: type ?? null, scope: scope ?? null, description: description ?? subject };
|
|
1770
|
+
}
|
|
1771
|
+
function commitsSinceLastTag(ctx, tagFormat) {
|
|
1772
|
+
const lastTag = lastTagMatching(ctx, tagFormat);
|
|
1773
|
+
const range = lastTag ? `${lastTag}..HEAD` : "HEAD";
|
|
1774
|
+
const result = git(ctx, ["log", range, "--pretty=format:%h %s"], { allowFail: true });
|
|
1775
|
+
if (result.status !== 0) return [];
|
|
1776
|
+
return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean).map((line) => {
|
|
1777
|
+
const [sha, ...rest] = line.split(" ");
|
|
1778
|
+
const subject = rest.join(" ");
|
|
1779
|
+
const parsed = parseSubject(subject);
|
|
1780
|
+
return { sha: sha ?? "", subject, ...parsed };
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
var USER_FACING_TYPES = /* @__PURE__ */ new Set(["feat", "fix"]);
|
|
1784
|
+
function userFacingCommits(commits) {
|
|
1785
|
+
return commits.filter((commit) => commit.type && USER_FACING_TYPES.has(commit.type));
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// src/notes/draft.ts
|
|
1789
|
+
function draftNotes(commits, version) {
|
|
1790
|
+
const facing = userFacingCommits(commits);
|
|
1791
|
+
if (facing.length === 0) {
|
|
1792
|
+
return `Novidades da vers\xE3o ${version}
|
|
1793
|
+
|
|
1794
|
+
(sem commits feat/fix desde a \xFAltima tag \u2014 escreva as notas manualmente)
|
|
1795
|
+
`;
|
|
1796
|
+
}
|
|
1797
|
+
const features = facing.filter((c) => c.type === "feat");
|
|
1798
|
+
const fixes = facing.filter((c) => c.type === "fix");
|
|
1799
|
+
const lines = [`Novidades da vers\xE3o ${version}`, ""];
|
|
1800
|
+
if (features.length) {
|
|
1801
|
+
lines.push("Novidades:");
|
|
1802
|
+
for (const commit of features) lines.push(`- ${capitalize(commit.description)}`);
|
|
1803
|
+
lines.push("");
|
|
1804
|
+
}
|
|
1805
|
+
if (fixes.length) {
|
|
1806
|
+
lines.push("Corre\xE7\xF5es:");
|
|
1807
|
+
for (const commit of fixes) lines.push(`- ${capitalize(commit.description)}`);
|
|
1808
|
+
lines.push("");
|
|
1809
|
+
}
|
|
1810
|
+
lines.push("# Revise este rascunho antes de publicar \u2014 ele vem direto dos commits (feat/fix) e n\xE3o foi reescrito em linguagem de usu\xE1rio.");
|
|
1811
|
+
return lines.join("\n");
|
|
1812
|
+
}
|
|
1813
|
+
function capitalize(text2) {
|
|
1814
|
+
if (!text2) return text2;
|
|
1815
|
+
return text2[0].toUpperCase() + text2.slice(1);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
// src/notes/editor.ts
|
|
1819
|
+
init_run();
|
|
1820
|
+
function defaultEditor() {
|
|
1821
|
+
if (process.platform === "win32") return "notepad";
|
|
1822
|
+
return "nano";
|
|
1823
|
+
}
|
|
1824
|
+
function openInEditor(ctx, path) {
|
|
1825
|
+
const editor = ctx.env.EDITOR || ctx.env.VISUAL || defaultEditor();
|
|
1826
|
+
const [command, ...args] = editor.split(" ");
|
|
1827
|
+
if (!command) return;
|
|
1828
|
+
requireSuccess(ctx.run(command, [...args, path], { inherit: true }), `${editor} ${path}`);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// src/notes/paths.ts
|
|
1832
|
+
init_run();
|
|
1833
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
1834
|
+
import { join as join7 } from "path";
|
|
1835
|
+
function notesDirFor(appDir, notesDir, version) {
|
|
1836
|
+
return join7(appDir, notesDir, version);
|
|
1837
|
+
}
|
|
1838
|
+
function notesFilePath(appDir, notesDir, version, locale, target) {
|
|
1839
|
+
return join7(notesDirFor(appDir, notesDir, version), `${locale}.${target}.txt`);
|
|
1840
|
+
}
|
|
1841
|
+
function readNotesFile(path) {
|
|
1842
|
+
if (!existsSync7(path)) fail(`Notas n\xE3o encontradas: ${path}. Rode \`release-kit notes\` primeiro.`);
|
|
1843
|
+
const text2 = readFileSync6(path, "utf8").trim();
|
|
1844
|
+
if (!text2) fail(`Notas vazias em ${path}.`);
|
|
1845
|
+
return text2;
|
|
1846
|
+
}
|
|
1847
|
+
function writeNotesFile(path, text2) {
|
|
1848
|
+
mkdirSync3(join7(path, ".."), { recursive: true });
|
|
1849
|
+
writeFileSync5(path, `${text2.trim()}
|
|
1850
|
+
`);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/release/notes.ts
|
|
1854
|
+
function targetsFor(config) {
|
|
1855
|
+
const targets = [];
|
|
1856
|
+
if (config.platforms.includes("ios")) targets.push("ios");
|
|
1857
|
+
if (config.platforms.includes("android")) targets.push("play");
|
|
1858
|
+
return targets;
|
|
1859
|
+
}
|
|
1860
|
+
function draftAndEditNotes(ctx, config, opts) {
|
|
1861
|
+
const version = opts.version ?? readAppVersion(ctx.appDir, config);
|
|
1862
|
+
const commits = commitsSinceLastTag(ctx, config.git.tagFormat);
|
|
1863
|
+
const targets = targetsFor(config);
|
|
1864
|
+
const files = [];
|
|
1865
|
+
for (const locale of config.locales) {
|
|
1866
|
+
for (const target of targets) {
|
|
1867
|
+
const path = notesFilePath(ctx.appDir, config.notesDir, version, locale, target);
|
|
1868
|
+
const isNew = !existsSync8(path);
|
|
1869
|
+
if (isNew) {
|
|
1870
|
+
writeNotesFile(path, draftNotes(commits, version));
|
|
1871
|
+
ctx.log.step(`rascunho criado: ${path}`);
|
|
1872
|
+
}
|
|
1873
|
+
if (!opts.skipEditor && (isNew || opts.edit)) {
|
|
1874
|
+
openInEditor(ctx, path);
|
|
1875
|
+
}
|
|
1876
|
+
const text2 = readNotesFile(path);
|
|
1877
|
+
assertNotesWithinLimit(text2, target, locale);
|
|
1878
|
+
files.push({ path, target, locale });
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
return { version, files };
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// src/release/build.ts
|
|
1885
|
+
init_run();
|
|
1886
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync10, mkdirSync as mkdirSync5, unlinkSync } from "fs";
|
|
1887
|
+
import { dirname, join as join9, resolve as resolve3 } from "path";
|
|
1888
|
+
|
|
1889
|
+
// src/eas/profiles.ts
|
|
1890
|
+
init_run();
|
|
1891
|
+
function resolveBuildProfileChain(easJson, profileName) {
|
|
1892
|
+
const build = easJson.build ?? {};
|
|
1893
|
+
const profile = build[profileName];
|
|
1894
|
+
if (!profile) fail(`build profile "${profileName}" n\xE3o existe no eas.json.`);
|
|
1895
|
+
const chain = [profile];
|
|
1896
|
+
let cursor = profile;
|
|
1897
|
+
const seen = /* @__PURE__ */ new Set([profileName]);
|
|
1898
|
+
while (cursor?.extends) {
|
|
1899
|
+
const parentName = cursor.extends;
|
|
1900
|
+
if (seen.has(parentName)) break;
|
|
1901
|
+
seen.add(parentName);
|
|
1902
|
+
const parent = build[parentName];
|
|
1903
|
+
if (!parent) break;
|
|
1904
|
+
chain.push(parent);
|
|
1905
|
+
cursor = parent;
|
|
1906
|
+
}
|
|
1907
|
+
const merged = {};
|
|
1908
|
+
for (const step of chain.slice().reverse()) {
|
|
1909
|
+
Object.assign(merged, step);
|
|
1910
|
+
if (step.android) merged.android = { ...merged.android, ...step.android };
|
|
1911
|
+
if (step.ios) merged.ios = { ...merged.ios, ...step.ios };
|
|
1912
|
+
}
|
|
1913
|
+
return merged;
|
|
1914
|
+
}
|
|
1915
|
+
function androidBuildTypeExt(easJson, profileName) {
|
|
1916
|
+
const resolved = resolveBuildProfileChain(easJson, profileName);
|
|
1917
|
+
return resolved.android?.buildType === "apk" ? "apk" : "aab";
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// src/release/gate.ts
|
|
1921
|
+
init_run();
|
|
1922
|
+
function assertChannelOptIn(ctx, config, channel, opts) {
|
|
1923
|
+
if (!channel.protected) return;
|
|
1924
|
+
if (opts.iKnowThisIsProduction) return;
|
|
1925
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
1926
|
+
if (opts.confirmVersion && opts.confirmVersion.trim() === version) return;
|
|
1927
|
+
fail(
|
|
1928
|
+
`canal protegido: passe --i-know-this-is-production (n\xE3o-interativo) ou confirme digitando a vers\xE3o exata (${version}). \`release-kit\` sem argumentos nunca resolve para um canal protegido.`
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
async function confirmProtectedChannelVersion(ctx, config, channelName) {
|
|
1932
|
+
const channel = channelOf(config, channelName);
|
|
1933
|
+
if (!channel.protected) return "";
|
|
1934
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
1935
|
+
if (!ctx.confirm) return "";
|
|
1936
|
+
ctx.log.warn(`canal "${channelName}" \xE9 protegido \u2014 gera bin\xE1rio/OTA de produ\xE7\xE3o.`);
|
|
1937
|
+
const answer = await ctx.confirm(`Digite a vers\xE3o exatamente (${version}) para continuar: `);
|
|
1938
|
+
return String(answer || "").trim();
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// src/release/metadata.ts
|
|
1942
|
+
init_run();
|
|
1943
|
+
import { createHash } from "crypto";
|
|
1944
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync, writeFileSync as writeFileSync6 } from "fs";
|
|
1945
|
+
import { join as join8 } from "path";
|
|
1946
|
+
function sha256File(path) {
|
|
1947
|
+
return createHash("sha256").update(readFileSync7(path)).digest("hex");
|
|
1948
|
+
}
|
|
1949
|
+
function gitInfo(ctx) {
|
|
1950
|
+
const sha = git(ctx, ["rev-parse", "HEAD"]).stdout.trim();
|
|
1951
|
+
const branch = git(ctx, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.trim();
|
|
1952
|
+
const tagResult = git(ctx, ["tag", "--points-at", "HEAD"], { allowFail: true });
|
|
1953
|
+
const tag = (tagResult.stdout || "").split("\n").map((line) => line.trim()).find((line) => /^v\d/.test(line)) || "";
|
|
1954
|
+
return { sha, branch, tag };
|
|
1955
|
+
}
|
|
1956
|
+
function writeMetadata(dir, payload) {
|
|
1957
|
+
mkdirSync4(dir, { recursive: true });
|
|
1958
|
+
writeFileSync6(join8(dir, "metadata.json"), `${JSON.stringify(payload, null, 2)}
|
|
1959
|
+
`);
|
|
1960
|
+
return payload;
|
|
1961
|
+
}
|
|
1962
|
+
function findLatestMetadata(releaseDir, { channel, platform } = {}) {
|
|
1963
|
+
if (!existsSync9(releaseDir)) fail(`Nenhum artefato em ${releaseDir}. Rode \`release-kit build\` primeiro.`);
|
|
1964
|
+
const entries = readdirSync2(releaseDir).map((name) => {
|
|
1965
|
+
const dir = join8(releaseDir, name);
|
|
1966
|
+
const metaPath = join8(dir, "metadata.json");
|
|
1967
|
+
if (!statSync(dir).isDirectory() || !existsSync9(metaPath)) return null;
|
|
1968
|
+
const metadata = JSON.parse(readFileSync7(metaPath, "utf8"));
|
|
1969
|
+
if (platform && metadata.platform !== platform) return null;
|
|
1970
|
+
if (channel && metadata.channel !== channel) return null;
|
|
1971
|
+
return { dir, metaPath, metadata, createdAt: metadata.createdAt || "" };
|
|
1972
|
+
}).filter((entry) => entry !== null).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1973
|
+
const [first] = entries;
|
|
1974
|
+
if (!first) {
|
|
1975
|
+
fail(`Nenhum metadata.json${platform ? ` para ${platform}` : ""}${channel ? ` (canal ${channel})` : ""} em ${releaseDir}.`);
|
|
1976
|
+
}
|
|
1977
|
+
return first;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// src/release/build.ts
|
|
1981
|
+
function artifactExt(ctx, platform, buildProfile) {
|
|
1982
|
+
if (platform === "ios") return "ipa";
|
|
1983
|
+
const easJson = readEasJson(ctx.appDir);
|
|
1984
|
+
if (!easJson) fail(`eas.json n\xE3o encontrado em ${ctx.appDir}.`);
|
|
1985
|
+
return androidBuildTypeExt(easJson, buildProfile);
|
|
1986
|
+
}
|
|
1987
|
+
function buildRelease(ctx, config, opts) {
|
|
1988
|
+
const channel = channelOf(config, opts.channel);
|
|
1989
|
+
assertChannelOptIn(ctx, config, channel, opts);
|
|
1990
|
+
if (!config.platforms.includes(opts.platform)) {
|
|
1991
|
+
fail(`--platform ${opts.platform} n\xE3o est\xE1 em config.platforms.`);
|
|
1992
|
+
}
|
|
1993
|
+
const ext = artifactExt(ctx, opts.platform, channel.buildProfile);
|
|
1994
|
+
const staging = opts.output ? resolve3(ctx.repoRoot, opts.output) : join9(ctx.releaseDir, `_building-${opts.platform}-${opts.channel}.${ext}`);
|
|
1995
|
+
const local = !opts.cloud;
|
|
1996
|
+
if (ctx.dryRun) {
|
|
1997
|
+
const env = channel.env;
|
|
1998
|
+
const command = formatCommand("eas", ["build", "-p", opts.platform, "--profile", channel.buildProfile, ...local ? ["--local"] : [], "--output", staging], env);
|
|
1999
|
+
ctx.log.info(`[dry-run] cd ${ctx.appDir} && ${command}`);
|
|
2000
|
+
return { dryRun: true, command, staging };
|
|
2001
|
+
}
|
|
2002
|
+
mkdirSync5(dirname(staging), { recursive: true });
|
|
2003
|
+
ctx.log.step(`eas build ${local ? "--local" : "(cloud)"} (${opts.platform}/${opts.channel})`);
|
|
2004
|
+
requireSuccess(
|
|
2005
|
+
easBuild(ctx, { platform: opts.platform, buildProfile: channel.buildProfile, local, output: local ? staging : void 0, env: channel.env }),
|
|
2006
|
+
"eas build"
|
|
2007
|
+
);
|
|
2008
|
+
if (local && !existsSync10(staging)) fail(`Build concluiu sem artefato em ${staging}.`);
|
|
2009
|
+
const remote = projectRemoteVersion(ctx, { platform: opts.platform, buildProfile: channel.buildProfile });
|
|
2010
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
2011
|
+
const versionCode = remote.versionCode;
|
|
2012
|
+
const buildNumber = opts.platform === "ios" ? remote.buildNumber : remote.versionCode;
|
|
2013
|
+
const git2 = gitInfo(ctx);
|
|
2014
|
+
const dirName = `${opts.platform}-${opts.channel}-${version}-${buildNumber || "unknown"}`;
|
|
2015
|
+
const finalDir = join9(ctx.releaseDir, dirName);
|
|
2016
|
+
mkdirSync5(finalDir, { recursive: true });
|
|
2017
|
+
const finalPath = local ? join9(finalDir, `app.${ext}`) : "";
|
|
2018
|
+
if (local && resolve3(staging) !== resolve3(finalPath)) {
|
|
2019
|
+
copyFileSync2(staging, finalPath);
|
|
2020
|
+
if (staging.includes("_building-")) {
|
|
2021
|
+
try {
|
|
2022
|
+
unlinkSync(staging);
|
|
2023
|
+
} catch {
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
const metadata = writeMetadata(finalDir, {
|
|
2028
|
+
platform: opts.platform,
|
|
2029
|
+
channel: opts.channel,
|
|
2030
|
+
buildProfile: channel.buildProfile,
|
|
2031
|
+
version,
|
|
2032
|
+
buildNumber,
|
|
2033
|
+
versionCode,
|
|
2034
|
+
artifactPath: finalPath,
|
|
2035
|
+
sha256: local && existsSync10(finalPath) ? sha256File(finalPath) : "",
|
|
2036
|
+
commitSha: git2.sha,
|
|
2037
|
+
branch: git2.branch,
|
|
2038
|
+
tag: git2.tag,
|
|
2039
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2040
|
+
toolchain: collectToolchain(ctx),
|
|
2041
|
+
toolchainDrift: Boolean(opts.toolchainDrift)
|
|
2042
|
+
});
|
|
2043
|
+
if (local) {
|
|
2044
|
+
ctx.log.success(`artefato ${finalPath}`);
|
|
2045
|
+
} else {
|
|
2046
|
+
ctx.log.success("build enviado ao EAS Build Cloud \u2014 use `eas build:list` para acompanhar e baixar o artefato.");
|
|
2047
|
+
}
|
|
2048
|
+
ctx.log.success(`metadata ${join9(finalDir, "metadata.json")}`);
|
|
2049
|
+
return { artifactPath: finalPath, metadata };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// src/release/submit.ts
|
|
2053
|
+
init_run();
|
|
2054
|
+
import { existsSync as existsSync11 } from "fs";
|
|
2055
|
+
import { resolve as resolve4 } from "path";
|
|
2056
|
+
function inferPlatformFromArtifact(artifactPath) {
|
|
2057
|
+
const lower = artifactPath.toLowerCase();
|
|
2058
|
+
if (lower.endsWith(".apk")) {
|
|
2059
|
+
fail("Submit Android exige AAB. O profile de sideload n\xE3o vai \xE0 loja; gere o bin\xE1rio com um profile de release/production.");
|
|
2060
|
+
}
|
|
2061
|
+
if (lower.endsWith(".aab")) return "android";
|
|
2062
|
+
if (lower.endsWith(".ipa")) return "ios";
|
|
2063
|
+
fail("submit --path exige um arquivo .aab ou .ipa.");
|
|
2064
|
+
}
|
|
2065
|
+
function submitRelease(ctx, config, opts) {
|
|
2066
|
+
const channel = channelOf(config, opts.channel);
|
|
2067
|
+
assertChannelOptIn(ctx, config, channel, opts);
|
|
2068
|
+
if (!channel.store) {
|
|
2069
|
+
fail(`canal "${opts.channel}" n\xE3o envia \xE0 loja (store: false).`);
|
|
2070
|
+
}
|
|
2071
|
+
const submitProfile = channel.submitProfile ?? channel.buildProfile;
|
|
2072
|
+
let artifactPath;
|
|
2073
|
+
let platform;
|
|
2074
|
+
if (opts.path) {
|
|
2075
|
+
artifactPath = resolve4(ctx.repoRoot, opts.path);
|
|
2076
|
+
if (!existsSync11(artifactPath)) fail(`Artefato n\xE3o encontrado: ${artifactPath}.`);
|
|
2077
|
+
platform = inferPlatformFromArtifact(artifactPath);
|
|
2078
|
+
if (opts.platform && opts.platform !== platform) {
|
|
2079
|
+
fail(`--platform ${opts.platform} n\xE3o corresponde ao arquivo (${platform}).`);
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
const entry = findLatestMetadata(ctx.releaseDir, { channel: opts.channel, platform: opts.platform });
|
|
2083
|
+
const metadata = entry.metadata;
|
|
2084
|
+
artifactPath = metadata.artifactPath;
|
|
2085
|
+
platform = metadata.platform;
|
|
2086
|
+
if (!artifactPath || !existsSync11(artifactPath)) {
|
|
2087
|
+
fail(`Artefato n\xE3o encontrado: ${artifactPath || "(ausente no metadata)"}. Builds na nuvem n\xE3o t\xEAm artefato local \u2014 use --path ap\xF3s baixar.`);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
if (platform === "android" && !artifactPath.toLowerCase().endsWith(".aab")) {
|
|
2091
|
+
fail("Submit Android exige AAB.");
|
|
2092
|
+
}
|
|
2093
|
+
if (ctx.dryRun) {
|
|
2094
|
+
const command = formatCommand("eas", ["submit", "-p", platform, "--profile", submitProfile, "--path", artifactPath], channel.env);
|
|
2095
|
+
ctx.log.info(`[dry-run] cd ${ctx.appDir} && ${command}`);
|
|
2096
|
+
return { dryRun: true, command, submitProfile, artifactPath };
|
|
2097
|
+
}
|
|
2098
|
+
ctx.log.step(`eas submit --path (${platform}/${submitProfile})`);
|
|
2099
|
+
requireSuccess(easSubmit(ctx, { platform, submitProfile, path: artifactPath, env: channel.env }), "eas submit");
|
|
2100
|
+
return { submitProfile, artifactPath };
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// src/release/ship.ts
|
|
2104
|
+
init_run();
|
|
2105
|
+
function shipRelease(ctx, config, opts) {
|
|
2106
|
+
const channel = channelOf(config, opts.channel);
|
|
2107
|
+
if (!channel.store) {
|
|
2108
|
+
fail(`canal "${opts.channel}" n\xE3o envia \xE0 loja (store: false). Use \`release-kit build\` direto para gerar o artefato de sideload.`);
|
|
2109
|
+
}
|
|
2110
|
+
let toolchainDrift = opts.toolchainDrift;
|
|
2111
|
+
if (!opts.skipDoctor) {
|
|
2112
|
+
const report = runDoctor(ctx, config, {
|
|
2113
|
+
channel: opts.channel,
|
|
2114
|
+
platform: opts.platform,
|
|
2115
|
+
allowToolchainDrift: opts.allowToolchainDrift
|
|
2116
|
+
});
|
|
2117
|
+
requireDoctorPass(report);
|
|
2118
|
+
toolchainDrift = report.toolchainDrift;
|
|
2119
|
+
}
|
|
2120
|
+
const built = buildRelease(ctx, config, { ...opts, toolchainDrift });
|
|
2121
|
+
if (ctx.dryRun) return { dryRun: true, artifactPath: built.artifactPath };
|
|
2122
|
+
const submitted = submitRelease(ctx, config, {
|
|
2123
|
+
channel: opts.channel,
|
|
2124
|
+
platform: opts.platform,
|
|
2125
|
+
latest: true,
|
|
2126
|
+
iKnowThisIsProduction: opts.iKnowThisIsProduction,
|
|
2127
|
+
confirmVersion: opts.confirmVersion
|
|
2128
|
+
});
|
|
2129
|
+
return { artifactPath: built.artifactPath, submit: submitted };
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// src/release/promote.ts
|
|
2133
|
+
init_run();
|
|
2134
|
+
|
|
2135
|
+
// src/stores/credentials.ts
|
|
2136
|
+
init_run();
|
|
2137
|
+
init_store();
|
|
2138
|
+
import { existsSync as existsSync12, readFileSync as readFileSync8 } from "fs";
|
|
2139
|
+
function createAppleClient(env) {
|
|
2140
|
+
const keyId = env.ASC_KEY_ID;
|
|
2141
|
+
const issuerId = env.ASC_ISSUER_ID;
|
|
2142
|
+
if (!keyId || !issuerId) {
|
|
2143
|
+
fail("Credenciais App Store Connect ausentes: ASC_KEY_ID, ASC_ISSUER_ID. Rode `release-kit credentials apple`.");
|
|
2144
|
+
}
|
|
2145
|
+
const resolved = resolveFileCredential(env, "ASC_KEY_PATH", "ASC_KEY_P8_CONTENT", "AuthKey.p8");
|
|
2146
|
+
if (!resolved) {
|
|
2147
|
+
fail("Credencial ASC_KEY_PATH (ou ASC_KEY_P8_CONTENT em CI) ausente. Rode `release-kit credentials apple`.");
|
|
2148
|
+
}
|
|
2149
|
+
if (!existsSync12(resolved.path)) fail(`ASC_KEY_PATH n\xE3o existe: ${resolved.path}`);
|
|
2150
|
+
const privateKeyPem = readFileSync8(resolved.path, "utf8");
|
|
2151
|
+
const client = new AppStoreConnectClient({ credentials: { keyId, issuerId, privateKeyPem } });
|
|
2152
|
+
return Object.assign(client, { cleanup: resolved.cleanup });
|
|
2153
|
+
}
|
|
2154
|
+
function createGoogleClient(env) {
|
|
2155
|
+
const resolved = resolveFileCredential(env, "GOOGLE_PLAY_JSON_KEY", "GOOGLE_PLAY_JSON_CONTENT", "play-service-account.json");
|
|
2156
|
+
if (!resolved) {
|
|
2157
|
+
fail(
|
|
2158
|
+
"Credencial GOOGLE_PLAY_JSON_KEY (ou GOOGLE_PLAY_JSON_CONTENT em CI) ausente. Rode `release-kit credentials google`."
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
if (!existsSync12(resolved.path)) fail(`GOOGLE_PLAY_JSON_KEY n\xE3o existe: ${resolved.path}`);
|
|
2162
|
+
const account = parseServiceAccountJson(readFileSync8(resolved.path, "utf8"));
|
|
2163
|
+
const client = new AndroidPublisherClient({ account });
|
|
2164
|
+
return Object.assign(client, { cleanup: resolved.cleanup });
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// src/stores/apple/review.ts
|
|
2168
|
+
init_run();
|
|
2169
|
+
var IN_PROGRESS_STATES = /* @__PURE__ */ new Set(["READY_FOR_REVIEW", "WAITING_FOR_REVIEW", "IN_REVIEW", "PROCESSING"]);
|
|
2170
|
+
var TERMINAL_STATES = /* @__PURE__ */ new Set(["COMPLETE", "CANCELED"]);
|
|
2171
|
+
async function submitForReview(client, input) {
|
|
2172
|
+
const app = await requireAppByBundleId(client, input.bundleId);
|
|
2173
|
+
const inProgress = await findInProgressReviewSubmission(client, app.id);
|
|
2174
|
+
if (inProgress) {
|
|
2175
|
+
return {
|
|
2176
|
+
appId: app.id,
|
|
2177
|
+
appStoreVersionId: "",
|
|
2178
|
+
buildId: "",
|
|
2179
|
+
submissionId: inProgress.id,
|
|
2180
|
+
state: inProgress.attributes.state,
|
|
2181
|
+
alreadyInProgress: true
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const version = await ensureAppStoreVersion(client, app.id, input.versionString);
|
|
2185
|
+
if (input.copyright && !version.attributes.copyright) {
|
|
2186
|
+
await client.patch(`/appStoreVersions/${version.id}`, {
|
|
2187
|
+
data: { id: version.id, type: "appStoreVersions", attributes: { copyright: input.copyright } }
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
await client.patch(`/appStoreVersions/${version.id}`, {
|
|
2191
|
+
data: {
|
|
2192
|
+
id: version.id,
|
|
2193
|
+
type: "appStoreVersions",
|
|
2194
|
+
attributes: { releaseType: input.automaticRelease ? "AFTER_APPROVAL" : "MANUAL" }
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
const build = await findValidBuild(client, app.id, input.versionString);
|
|
2198
|
+
if (!build) fail(`Nenhum build VALID no TestFlight para a vers\xE3o ${input.versionString}.`);
|
|
2199
|
+
await client.request("POST", `/appStoreVersions/${version.id}/relationships/build`, {
|
|
2200
|
+
data: { id: build.id, type: "builds" }
|
|
2201
|
+
});
|
|
2202
|
+
if (input.phasedRelease) {
|
|
2203
|
+
await client.post("/appStoreVersionPhasedReleases", {
|
|
2204
|
+
data: {
|
|
2205
|
+
type: "appStoreVersionPhasedReleases",
|
|
2206
|
+
relationships: { appStoreVersion: { data: { id: version.id, type: "appStoreVersions" } } }
|
|
2207
|
+
}
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
for (const [locale, whatsNew] of Object.entries(input.whatsNewByLocale)) {
|
|
2211
|
+
const localization = await findLocalization(client, version.id, locale);
|
|
2212
|
+
if (!localization) fail(`Localization ${locale} ausente na vers\xE3o ${input.versionString} do App Store Connect.`);
|
|
2213
|
+
await client.patch(`/appStoreVersionLocalizations/${localization.id}`, {
|
|
2214
|
+
data: { id: localization.id, type: "appStoreVersionLocalizations", attributes: { whatsNew } }
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
const submission = await createReviewSubmission(client, app.id);
|
|
2218
|
+
await client.post("/reviewSubmissionItems", {
|
|
2219
|
+
data: {
|
|
2220
|
+
type: "reviewSubmissionItems",
|
|
2221
|
+
relationships: {
|
|
2222
|
+
reviewSubmission: { data: { id: submission.id, type: "reviewSubmissions" } },
|
|
2223
|
+
appStoreVersion: { data: { id: version.id, type: "appStoreVersions" } }
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
const submitted = await client.patch(`/reviewSubmissions/${submission.id}`, {
|
|
2228
|
+
data: { id: submission.id, type: "reviewSubmissions", attributes: { submitted: true } }
|
|
2229
|
+
});
|
|
2230
|
+
return {
|
|
2231
|
+
appId: app.id,
|
|
2232
|
+
appStoreVersionId: version.id,
|
|
2233
|
+
buildId: build.id,
|
|
2234
|
+
submissionId: submission.id,
|
|
2235
|
+
state: submitted.data.attributes.state,
|
|
2236
|
+
alreadyInProgress: false
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
async function getIosStatus(client, bundleId, versionString) {
|
|
2240
|
+
const app = await requireAppByBundleId(client, bundleId);
|
|
2241
|
+
const version = await findAppStoreVersion(client, app.id, versionString);
|
|
2242
|
+
const inProgress = await findInProgressReviewSubmission(client, app.id);
|
|
2243
|
+
return {
|
|
2244
|
+
appId: app.id,
|
|
2245
|
+
versionString,
|
|
2246
|
+
appStoreState: version?.attributes.appStoreState ?? null,
|
|
2247
|
+
reviewSubmissionState: inProgress?.attributes.state ?? null
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
async function findInProgressReviewSubmission(client, appId) {
|
|
2251
|
+
const result = await client.get(
|
|
2252
|
+
`/apps/${appId}/reviewSubmissions?filter[platform]=IOS`
|
|
2253
|
+
);
|
|
2254
|
+
const active = result.data.find(
|
|
2255
|
+
(item) => IN_PROGRESS_STATES.has(item.attributes.state) && !TERMINAL_STATES.has(item.attributes.state)
|
|
2256
|
+
);
|
|
2257
|
+
return active ?? null;
|
|
2258
|
+
}
|
|
2259
|
+
async function ensureAppStoreVersion(client, appId, versionString) {
|
|
2260
|
+
const existing = await findAppStoreVersion(client, appId, versionString);
|
|
2261
|
+
if (existing) return existing;
|
|
2262
|
+
const created = await client.post("/appStoreVersions", {
|
|
2263
|
+
data: {
|
|
2264
|
+
type: "appStoreVersions",
|
|
2265
|
+
attributes: { platform: "IOS", versionString },
|
|
2266
|
+
relationships: { app: { data: { id: appId, type: "apps" } } }
|
|
2267
|
+
}
|
|
2268
|
+
});
|
|
2269
|
+
return { id: created.data.id, attributes: created.data.attributes };
|
|
2270
|
+
}
|
|
2271
|
+
async function findAppStoreVersion(client, appId, versionString) {
|
|
2272
|
+
const result = await client.get(
|
|
2273
|
+
`/apps/${appId}/appStoreVersions?filter[versionString]=${encodeURIComponent(versionString)}&filter[platform]=IOS`
|
|
2274
|
+
);
|
|
2275
|
+
const [version] = result.data;
|
|
2276
|
+
return version ? { id: version.id, attributes: version.attributes } : null;
|
|
2277
|
+
}
|
|
2278
|
+
async function findValidBuild(client, appId, versionString) {
|
|
2279
|
+
const result = await client.get(
|
|
2280
|
+
`/builds?filter[app]=${appId}&filter[preReleaseVersion.version]=${encodeURIComponent(versionString)}&sort=-uploadedDate&limit=20`
|
|
2281
|
+
);
|
|
2282
|
+
const valid = result.data.find((item) => item.attributes.processingState === "VALID");
|
|
2283
|
+
const build = valid ?? result.data[0];
|
|
2284
|
+
return build ? { id: build.id, attributes: build.attributes } : null;
|
|
2285
|
+
}
|
|
2286
|
+
async function findLocalization(client, versionId, locale) {
|
|
2287
|
+
const result = await client.get(
|
|
2288
|
+
`/appStoreVersions/${versionId}/appStoreVersionLocalizations`
|
|
2289
|
+
);
|
|
2290
|
+
const match = result.data.find((item) => item.attributes.locale === locale);
|
|
2291
|
+
return match ? { id: match.id, attributes: match.attributes } : null;
|
|
2292
|
+
}
|
|
2293
|
+
async function createReviewSubmission(client, appId) {
|
|
2294
|
+
const created = await client.post("/reviewSubmissions", {
|
|
2295
|
+
data: {
|
|
2296
|
+
type: "reviewSubmissions",
|
|
2297
|
+
attributes: { platform: "IOS" },
|
|
2298
|
+
relationships: { app: { data: { id: appId, type: "apps" } } }
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
return { id: created.data.id, attributes: created.data.attributes };
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// src/release/promote.ts
|
|
2305
|
+
function notesByLocale(ctx, config, version, target) {
|
|
2306
|
+
const result = {};
|
|
2307
|
+
for (const locale of config.locales) {
|
|
2308
|
+
const path = notesFilePath(ctx.appDir, config.notesDir, version, locale, target);
|
|
2309
|
+
result[locale] = readNotesFile(path);
|
|
2310
|
+
}
|
|
2311
|
+
return result;
|
|
2312
|
+
}
|
|
2313
|
+
async function promoteRelease2(ctx, config, opts) {
|
|
2314
|
+
const channel = channelOf(config, opts.channel);
|
|
2315
|
+
assertChannelOptIn(ctx, config, channel, opts);
|
|
2316
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
2317
|
+
const result = {};
|
|
2318
|
+
const wantsAndroid = (!opts.platform || opts.platform === "android") && channel.android;
|
|
2319
|
+
const wantsIos = (!opts.platform || opts.platform === "ios") && channel.ios;
|
|
2320
|
+
if (!wantsAndroid && !wantsIos) fail("nenhuma plataforma configurada no canal para promover.");
|
|
2321
|
+
if (ctx.dryRun) {
|
|
2322
|
+
if (wantsAndroid) ctx.log.info(`[dry-run] Play: promover ${channel.android.testTrack} \u2192 ${channel.android.productionTrack} (${channel.android.package})`);
|
|
2323
|
+
if (wantsIos) ctx.log.info(`[dry-run] Apple: criar Review Submission para ${channel.ios.bundleId} v${version}`);
|
|
2324
|
+
return result;
|
|
2325
|
+
}
|
|
2326
|
+
if (wantsAndroid) {
|
|
2327
|
+
const android = channel.android;
|
|
2328
|
+
const google = createGoogleClient(ctx.env);
|
|
2329
|
+
try {
|
|
2330
|
+
const notes = notesByLocale(ctx, config, version, "play");
|
|
2331
|
+
ctx.log.step(`Play: promovendo ${android.testTrack} \u2192 ${android.productionTrack}`);
|
|
2332
|
+
const release = await promoteRelease(google, {
|
|
2333
|
+
packageName: android.package,
|
|
2334
|
+
fromTrack: android.testTrack,
|
|
2335
|
+
toTrack: android.productionTrack,
|
|
2336
|
+
notesByLocale: notes,
|
|
2337
|
+
initialRollout: android.initialRollout
|
|
2338
|
+
});
|
|
2339
|
+
ctx.log.success(`Play: ${android.productionTrack} status=${release.status} versionCodes=${(release.versionCodes ?? []).join(",")}`);
|
|
2340
|
+
result.android = { versionCodes: release.versionCodes, status: release.status };
|
|
2341
|
+
} finally {
|
|
2342
|
+
google.cleanup();
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (wantsIos) {
|
|
2346
|
+
const ios = channel.ios;
|
|
2347
|
+
const apple = createAppleClient(ctx.env);
|
|
2348
|
+
try {
|
|
2349
|
+
const notes = notesByLocale(ctx, config, version, "ios");
|
|
2350
|
+
ctx.log.step(`Apple: enviando Review Submission para ${ios.bundleId} v${version}`);
|
|
2351
|
+
const submission = await submitForReview(apple, {
|
|
2352
|
+
bundleId: ios.bundleId,
|
|
2353
|
+
versionString: version,
|
|
2354
|
+
whatsNewByLocale: notes,
|
|
2355
|
+
automaticRelease: ios.automaticRelease,
|
|
2356
|
+
phasedRelease: ios.phasedRelease,
|
|
2357
|
+
copyright: ios.copyright
|
|
2358
|
+
});
|
|
2359
|
+
if (submission.alreadyInProgress) {
|
|
2360
|
+
ctx.log.warn(`Apple: j\xE1 existe submission em an\xE1lise (${submission.submissionId}, state=${submission.state}).`);
|
|
2361
|
+
} else {
|
|
2362
|
+
ctx.log.success(`Apple: submission ${submission.submissionId} enviada (state=${submission.state}).`);
|
|
2363
|
+
}
|
|
2364
|
+
result.ios = { submissionId: submission.submissionId, state: submission.state, alreadyInProgress: submission.alreadyInProgress };
|
|
2365
|
+
} finally {
|
|
2366
|
+
apple.cleanup();
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
return result;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// src/release/rollout.ts
|
|
2373
|
+
init_run();
|
|
2374
|
+
async function rolloutRelease(ctx, config, opts) {
|
|
2375
|
+
if (opts.platform !== "android") {
|
|
2376
|
+
fail("rollout percentual s\xF3 \xE9 suportado para Android nesta vers\xE3o. iOS: acompanhe o phased release no App Store Connect.");
|
|
2377
|
+
}
|
|
2378
|
+
const channel = channelOf(config, opts.channel);
|
|
2379
|
+
assertChannelOptIn(ctx, config, channel, opts);
|
|
2380
|
+
if (!channel.android) fail(`canal "${opts.channel}" n\xE3o tem configura\xE7\xE3o android.`);
|
|
2381
|
+
if (ctx.dryRun) {
|
|
2382
|
+
ctx.log.info(`[dry-run] Play: ${channel.android.productionTrack} \u2192 ${opts.to}`);
|
|
2383
|
+
return { dryRun: true };
|
|
2384
|
+
}
|
|
2385
|
+
const google = createGoogleClient(ctx.env);
|
|
2386
|
+
try {
|
|
2387
|
+
const release = await setRollout(google, channel.android.package, channel.android.productionTrack, opts.to);
|
|
2388
|
+
ctx.log.success(`Play: ${channel.android.productionTrack} agora ${release.status}${release.userFraction ? ` (${Math.round(release.userFraction * 100)}%)` : ""}`);
|
|
2389
|
+
return release;
|
|
2390
|
+
} finally {
|
|
2391
|
+
google.cleanup();
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// src/release/status.ts
|
|
2396
|
+
async function statusOf(ctx, config, opts) {
|
|
2397
|
+
const channel = channelOf(config, opts.channel);
|
|
2398
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
2399
|
+
const result = { version };
|
|
2400
|
+
if (channel.android) {
|
|
2401
|
+
const google = createGoogleClient(ctx.env);
|
|
2402
|
+
try {
|
|
2403
|
+
const track = await getTrackStatus(google, channel.android.package, channel.android.productionTrack);
|
|
2404
|
+
result.android = {
|
|
2405
|
+
track: channel.android.productionTrack,
|
|
2406
|
+
releases: (track.releases ?? []).map((release) => ({
|
|
2407
|
+
status: release.status,
|
|
2408
|
+
versionCodes: release.versionCodes,
|
|
2409
|
+
userFraction: release.userFraction
|
|
2410
|
+
}))
|
|
2411
|
+
};
|
|
2412
|
+
} finally {
|
|
2413
|
+
google.cleanup();
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (channel.ios) {
|
|
2417
|
+
const apple = createAppleClient(ctx.env);
|
|
2418
|
+
try {
|
|
2419
|
+
const status = await getIosStatus(apple, channel.ios.bundleId, version);
|
|
2420
|
+
result.ios = { appStoreState: status.appStoreState, reviewSubmissionState: status.reviewSubmissionState };
|
|
2421
|
+
} finally {
|
|
2422
|
+
apple.cleanup();
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
return result;
|
|
2426
|
+
}
|
|
2427
|
+
function printStatus(ctx, status) {
|
|
2428
|
+
ctx.log.info(`vers\xE3o: ${status.version}`);
|
|
2429
|
+
if (status.android) {
|
|
2430
|
+
ctx.log.info(`Play (${status.android.track}):`);
|
|
2431
|
+
for (const release of status.android.releases) {
|
|
2432
|
+
const fraction = release.userFraction ? ` (${Math.round(release.userFraction * 100)}%)` : "";
|
|
2433
|
+
ctx.log.info(` - ${release.status}${fraction} \u2014 versionCodes: ${(release.versionCodes ?? []).join(", ")}`);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
if (status.ios) {
|
|
2437
|
+
ctx.log.info(`App Store: appStoreState=${status.ios.appStoreState ?? "(sem vers\xE3o edit\xE1vel)"} reviewSubmission=${status.ios.reviewSubmissionState ?? "(nenhuma em andamento)"}`);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/release/update.ts
|
|
2442
|
+
init_run();
|
|
2443
|
+
function updateRelease(ctx, config, opts) {
|
|
2444
|
+
const channel = channelOf(config, opts.channel);
|
|
2445
|
+
assertChannelOptIn(ctx, config, channel, opts);
|
|
2446
|
+
if (!channel.ota) {
|
|
2447
|
+
fail(`canal "${opts.channel}" n\xE3o tem "ota.channel" configurado.`);
|
|
2448
|
+
}
|
|
2449
|
+
if (ctx.dryRun) {
|
|
2450
|
+
const command = formatCommand("eas", ["update", "--channel", channel.ota.channel, "--message", opts.message ?? "(sem mensagem)"], channel.env);
|
|
2451
|
+
ctx.log.info(`[dry-run] cd ${ctx.appDir} && ${command}`);
|
|
2452
|
+
return { dryRun: true, command, channel: channel.ota.channel };
|
|
2453
|
+
}
|
|
2454
|
+
ctx.log.step(`eas update (channel ${channel.ota.channel})`);
|
|
2455
|
+
requireSuccess(
|
|
2456
|
+
easUpdate(ctx, { channel: channel.ota.channel, message: opts.message, platform: opts.platform, env: channel.env }),
|
|
2457
|
+
"eas update"
|
|
2458
|
+
);
|
|
2459
|
+
return { channel: channel.ota.channel };
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// src/cli/commands.ts
|
|
2463
|
+
init_run();
|
|
2464
|
+
function requirePlatform(flags) {
|
|
2465
|
+
if (flags.platform !== "android" && flags.platform !== "ios") {
|
|
2466
|
+
fail("--platform \xE9 obrigat\xF3rio (android|ios).");
|
|
2467
|
+
}
|
|
2468
|
+
return flags.platform;
|
|
2469
|
+
}
|
|
2470
|
+
function requireChannel(flags) {
|
|
2471
|
+
if (!flags.channel) fail("--channel \xE9 obrigat\xF3rio.");
|
|
2472
|
+
return flags.channel;
|
|
2473
|
+
}
|
|
2474
|
+
function runDoctorCommand(flags) {
|
|
2475
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2476
|
+
const report = runDoctor(ctx, config, {
|
|
2477
|
+
channel: flags.channel,
|
|
2478
|
+
platform: flags.platform,
|
|
2479
|
+
deep: flags.deep,
|
|
2480
|
+
buildId: flags.buildId,
|
|
2481
|
+
allowToolchainDrift: flags.allowToolchainDrift
|
|
2482
|
+
});
|
|
2483
|
+
if (!ctx.json) printDoctorReport(report, ctx);
|
|
2484
|
+
if (!flags.dryRun) requireDoctorPass(report);
|
|
2485
|
+
return report;
|
|
2486
|
+
}
|
|
2487
|
+
function runVersionCommand(flags, bump) {
|
|
2488
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2489
|
+
return bumpVersion(ctx, config, { bump, push: flags.push });
|
|
2490
|
+
}
|
|
2491
|
+
function runNotesCommand(flags) {
|
|
2492
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2493
|
+
return draftAndEditNotes(ctx, config, { version: flags.version, edit: flags.edit, skipEditor: flags.skipEditor });
|
|
2494
|
+
}
|
|
2495
|
+
function runBuildCommand(flags) {
|
|
2496
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2497
|
+
const channel = requireChannel(flags);
|
|
2498
|
+
const platform = requirePlatform(flags);
|
|
2499
|
+
return buildRelease(ctx, config, {
|
|
2500
|
+
channel,
|
|
2501
|
+
platform,
|
|
2502
|
+
cloud: flags.cloud,
|
|
2503
|
+
output: flags.output,
|
|
2504
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2505
|
+
confirmVersion: flags.confirmVersion
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
function runSubmitCommand(flags) {
|
|
2509
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2510
|
+
const channel = requireChannel(flags);
|
|
2511
|
+
if (!flags.latest && !flags.path) fail("`submit` exige --latest ou --path <aab|ipa>.");
|
|
2512
|
+
return submitRelease(ctx, config, {
|
|
2513
|
+
channel,
|
|
2514
|
+
platform: flags.platform,
|
|
2515
|
+
latest: flags.latest,
|
|
2516
|
+
path: flags.path,
|
|
2517
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2518
|
+
confirmVersion: flags.confirmVersion
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
function runShipCommand(flags) {
|
|
2522
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2523
|
+
const channel = requireChannel(flags);
|
|
2524
|
+
const platform = requirePlatform(flags);
|
|
2525
|
+
return shipRelease(ctx, config, {
|
|
2526
|
+
channel,
|
|
2527
|
+
platform,
|
|
2528
|
+
cloud: flags.cloud,
|
|
2529
|
+
output: flags.output,
|
|
2530
|
+
skipDoctor: flags.skipDoctor,
|
|
2531
|
+
allowToolchainDrift: flags.allowToolchainDrift,
|
|
2532
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2533
|
+
confirmVersion: flags.confirmVersion
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
function runPromoteCommand(flags) {
|
|
2537
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2538
|
+
const channel = requireChannel(flags);
|
|
2539
|
+
return promoteRelease2(ctx, config, {
|
|
2540
|
+
channel,
|
|
2541
|
+
platform: flags.platform,
|
|
2542
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2543
|
+
confirmVersion: flags.confirmVersion
|
|
2544
|
+
});
|
|
2545
|
+
}
|
|
2546
|
+
function runRolloutCommand(flags) {
|
|
2547
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2548
|
+
const channel = requireChannel(flags);
|
|
2549
|
+
const platform = requirePlatform(flags);
|
|
2550
|
+
if (!flags.to) fail("`rollout` exige --to <0-1|complete|halt>.");
|
|
2551
|
+
const to = flags.to === "complete" || flags.to === "halt" ? flags.to : Number(flags.to);
|
|
2552
|
+
return rolloutRelease(ctx, config, {
|
|
2553
|
+
channel,
|
|
2554
|
+
platform,
|
|
2555
|
+
to,
|
|
2556
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2557
|
+
confirmVersion: flags.confirmVersion
|
|
2558
|
+
});
|
|
2559
|
+
}
|
|
2560
|
+
async function runStatusCommand(flags) {
|
|
2561
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2562
|
+
const channel = requireChannel(flags);
|
|
2563
|
+
const status = await statusOf(ctx, config, { channel });
|
|
2564
|
+
if (!ctx.json) printStatus(ctx, status);
|
|
2565
|
+
return status;
|
|
2566
|
+
}
|
|
2567
|
+
function runUpdateCommand(flags) {
|
|
2568
|
+
const { ctx, config } = loadContextAndConfig(flags);
|
|
2569
|
+
const channel = requireChannel(flags);
|
|
2570
|
+
return updateRelease(ctx, config, {
|
|
2571
|
+
channel,
|
|
2572
|
+
message: flags.message,
|
|
2573
|
+
platform: flags.platform ?? "all",
|
|
2574
|
+
iKnowThisIsProduction: flags.iKnowThisIsProduction,
|
|
2575
|
+
confirmVersion: flags.confirmVersion
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
// src/cli/menu.ts
|
|
2580
|
+
async function runInteractiveMenu() {
|
|
2581
|
+
p3.intro(pc4.bold("release-kit"));
|
|
2582
|
+
const { ctx, config } = loadContextAndConfig({});
|
|
2583
|
+
const version = readAppVersion(ctx.appDir, config);
|
|
2584
|
+
p3.log.info(`vers\xE3o atual: ${version} \xB7 canais: ${Object.keys(config.channels).join(", ")}`);
|
|
2585
|
+
const action = await p3.select({
|
|
2586
|
+
message: "O que voc\xEA quer fazer?",
|
|
2587
|
+
options: [
|
|
2588
|
+
{ value: "doctor", label: "doctor \u2014 preflight" },
|
|
2589
|
+
{ value: "version", label: "version \u2014 bump + tag" },
|
|
2590
|
+
{ value: "notes", label: "notes \u2014 rascunhar/editar notas de vers\xE3o" },
|
|
2591
|
+
{ value: "build", label: "build \u2014 gerar o bin\xE1rio" },
|
|
2592
|
+
{ value: "submit", label: "submit \u2014 enviar \xE0 faixa de teste" },
|
|
2593
|
+
{ value: "ship", label: "ship \u2014 doctor + build + submit" },
|
|
2594
|
+
{ value: "status", label: "status \u2014 ver review/rollout" },
|
|
2595
|
+
{ value: "update", label: "update \u2014 OTA" },
|
|
2596
|
+
{ value: "promote", label: "promote \u2014 produ\xE7\xE3o (App Review / Play production)" },
|
|
2597
|
+
{ value: "exit", label: "sa\xEDda" }
|
|
2598
|
+
]
|
|
2599
|
+
});
|
|
2600
|
+
if (p3.isCancel(action) || action === "exit") {
|
|
2601
|
+
p3.outro("Nada feito.");
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
const channelNames = Object.keys(config.channels);
|
|
2605
|
+
const channel = await p3.select({
|
|
2606
|
+
message: "Canal",
|
|
2607
|
+
options: channelNames.map((name) => ({ value: name, label: `${name}${config.channels[name].protected ? " (protegido)" : ""}` }))
|
|
2608
|
+
});
|
|
2609
|
+
if (p3.isCancel(channel)) return;
|
|
2610
|
+
const flags = { channel };
|
|
2611
|
+
if (["build", "submit", "ship", "update"].includes(action)) {
|
|
2612
|
+
if (config.platforms.length > 1) {
|
|
2613
|
+
const platform = await p3.select({
|
|
2614
|
+
message: "Plataforma",
|
|
2615
|
+
options: config.platforms.map((pl) => ({ value: pl, label: pl }))
|
|
2616
|
+
});
|
|
2617
|
+
if (p3.isCancel(platform)) return;
|
|
2618
|
+
flags.platform = platform;
|
|
2619
|
+
} else {
|
|
2620
|
+
flags.platform = config.platforms[0];
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
const selectedChannel = channelOf(config, channel);
|
|
2624
|
+
if (selectedChannel.protected) {
|
|
2625
|
+
const typed = await confirmProtectedChannelVersion(ctx, config, channel);
|
|
2626
|
+
flags.confirmVersion = typed;
|
|
2627
|
+
}
|
|
2628
|
+
try {
|
|
2629
|
+
switch (action) {
|
|
2630
|
+
case "doctor":
|
|
2631
|
+
runDoctorCommand(flags);
|
|
2632
|
+
break;
|
|
2633
|
+
case "version": {
|
|
2634
|
+
const bump = await p3.select({ message: "Bump", options: [{ value: "patch", label: "patch" }, { value: "minor", label: "minor" }, { value: "major", label: "major" }] });
|
|
2635
|
+
if (p3.isCancel(bump)) return;
|
|
2636
|
+
runVersionCommand(flags, bump);
|
|
2637
|
+
break;
|
|
2638
|
+
}
|
|
2639
|
+
case "notes":
|
|
2640
|
+
runNotesCommand(flags);
|
|
2641
|
+
break;
|
|
2642
|
+
case "build":
|
|
2643
|
+
runBuildCommand(flags);
|
|
2644
|
+
break;
|
|
2645
|
+
case "submit":
|
|
2646
|
+
runSubmitCommand({ ...flags, latest: true });
|
|
2647
|
+
break;
|
|
2648
|
+
case "ship":
|
|
2649
|
+
runShipCommand(flags);
|
|
2650
|
+
break;
|
|
2651
|
+
case "status":
|
|
2652
|
+
await runStatusCommand(flags);
|
|
2653
|
+
break;
|
|
2654
|
+
case "update":
|
|
2655
|
+
runUpdateCommand(flags);
|
|
2656
|
+
break;
|
|
2657
|
+
case "promote":
|
|
2658
|
+
await runPromoteCommand(flags);
|
|
2659
|
+
break;
|
|
2660
|
+
}
|
|
2661
|
+
p3.outro("Feito.");
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
p3.log.error(error.message);
|
|
2664
|
+
p3.outro("Falhou.");
|
|
2665
|
+
process.exitCode = 1;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// src/cli/ci.ts
|
|
2670
|
+
import { existsSync as existsSync13, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
2671
|
+
import { join as join10 } from "path";
|
|
2672
|
+
import * as p4 from "@clack/prompts";
|
|
2673
|
+
import pc5 from "picocolors";
|
|
2674
|
+
var WORKFLOW_TEMPLATE = `name: Release kit
|
|
2675
|
+
|
|
2676
|
+
on:
|
|
2677
|
+
workflow_dispatch:
|
|
2678
|
+
inputs:
|
|
2679
|
+
channel:
|
|
2680
|
+
description: "Canal (ver release-kit.config.json)"
|
|
2681
|
+
required: true
|
|
2682
|
+
default: "release"
|
|
2683
|
+
platform:
|
|
2684
|
+
description: "android | ios"
|
|
2685
|
+
required: true
|
|
2686
|
+
default: "android"
|
|
2687
|
+
|
|
2688
|
+
jobs:
|
|
2689
|
+
ship:
|
|
2690
|
+
name: doctor + build (cloud) + submit (faixa de teste)
|
|
2691
|
+
runs-on: ubuntu-latest
|
|
2692
|
+
steps:
|
|
2693
|
+
- uses: actions/checkout@v4
|
|
2694
|
+
- uses: actions/setup-node@v4
|
|
2695
|
+
with:
|
|
2696
|
+
node-version: 20
|
|
2697
|
+
- run: npm install -g eas-cli @htgabriel/release-kit
|
|
2698
|
+
- name: doctor
|
|
2699
|
+
run: release-kit doctor --channel \${{ inputs.channel }} --platform \${{ inputs.platform }} --json
|
|
2700
|
+
env:
|
|
2701
|
+
EXPO_TOKEN: \${{ secrets.EXPO_TOKEN }}
|
|
2702
|
+
- name: ship (build cloud + submit)
|
|
2703
|
+
run: release-kit ship --channel \${{ inputs.channel }} --platform \${{ inputs.platform }} --cloud --json
|
|
2704
|
+
env:
|
|
2705
|
+
EXPO_TOKEN: \${{ secrets.EXPO_TOKEN }}
|
|
2706
|
+
ASC_KEY_ID: \${{ secrets.ASC_KEY_ID }}
|
|
2707
|
+
ASC_ISSUER_ID: \${{ secrets.ASC_ISSUER_ID }}
|
|
2708
|
+
ASC_KEY_P8_CONTENT: \${{ secrets.ASC_KEY_P8_CONTENT }}
|
|
2709
|
+
GOOGLE_PLAY_JSON_CONTENT: \${{ secrets.GOOGLE_PLAY_JSON_CONTENT }}
|
|
2710
|
+
|
|
2711
|
+
# Canal protegido nunca roda autom\xE1tico \u2014 precisa de workflow_dispatch
|
|
2712
|
+
# manual + Environment com reviewers no reposit\xF3rio (Settings \u2192 Environments).
|
|
2713
|
+
promote:
|
|
2714
|
+
name: promote (produ\xE7\xE3o \u2014 requer approval do Environment)
|
|
2715
|
+
runs-on: ubuntu-latest
|
|
2716
|
+
environment: production
|
|
2717
|
+
if: inputs.channel == 'production'
|
|
2718
|
+
needs: [ship]
|
|
2719
|
+
steps:
|
|
2720
|
+
- uses: actions/checkout@v4
|
|
2721
|
+
- uses: actions/setup-node@v4
|
|
2722
|
+
with:
|
|
2723
|
+
node-version: 20
|
|
2724
|
+
- run: npm install -g eas-cli @htgabriel/release-kit
|
|
2725
|
+
- run: release-kit promote --channel \${{ inputs.channel }} --i-know-this-is-production --json
|
|
2726
|
+
env:
|
|
2727
|
+
EXPO_TOKEN: \${{ secrets.EXPO_TOKEN }}
|
|
2728
|
+
ASC_KEY_ID: \${{ secrets.ASC_KEY_ID }}
|
|
2729
|
+
ASC_ISSUER_ID: \${{ secrets.ASC_ISSUER_ID }}
|
|
2730
|
+
ASC_KEY_P8_CONTENT: \${{ secrets.ASC_KEY_P8_CONTENT }}
|
|
2731
|
+
GOOGLE_PLAY_JSON_CONTENT: \${{ secrets.GOOGLE_PLAY_JSON_CONTENT }}
|
|
2732
|
+
`;
|
|
2733
|
+
var REQUIRED_SECRETS = ["EXPO_TOKEN", "ASC_KEY_ID", "ASC_ISSUER_ID", "ASC_KEY_P8_CONTENT", "GOOGLE_PLAY_JSON_CONTENT"];
|
|
2734
|
+
async function runCiInitCommand(target) {
|
|
2735
|
+
if (target && target !== "github") {
|
|
2736
|
+
p4.log.error(`alvo de CI desconhecido: "${target}". S\xF3 "github" \xE9 suportado nesta vers\xE3o.`);
|
|
2737
|
+
process.exitCode = 1;
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
p4.intro(pc5.bold("release-kit ci init github"));
|
|
2741
|
+
const ctx = buildContext({});
|
|
2742
|
+
loadConfig(ctx.repoRoot);
|
|
2743
|
+
const dir = join10(ctx.repoRoot, ".github", "workflows");
|
|
2744
|
+
mkdirSync6(dir, { recursive: true });
|
|
2745
|
+
const path = join10(dir, "release-kit.yml");
|
|
2746
|
+
if (existsSync13(path)) {
|
|
2747
|
+
const overwrite = await p4.confirm({ message: `${path} j\xE1 existe. Sobrescrever?` });
|
|
2748
|
+
if (p4.isCancel(overwrite) || !overwrite) {
|
|
2749
|
+
p4.outro("Nada feito.");
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
writeFileSync7(path, WORKFLOW_TEMPLATE);
|
|
2754
|
+
p4.log.success(`escrito: .github/workflows/release-kit.yml`);
|
|
2755
|
+
p4.log.info("Cadastre os secrets do reposit\xF3rio (Settings \u2192 Secrets and variables \u2192 Actions):");
|
|
2756
|
+
for (const secret of REQUIRED_SECRETS) p4.log.info(` - ${secret}`);
|
|
2757
|
+
p4.log.info('E crie o Environment "production" com reviewers obrigat\xF3rios (Settings \u2192 Environments) \u2014 \xE9 o que impede o job `promote` de rodar sem aprova\xE7\xE3o humana.');
|
|
2758
|
+
const setNow = await p4.confirm({ message: "Tentar cadastrar os secrets agora via `gh secret set`? (l\xEA de .env.release, ignora o que n\xE3o tiver)" });
|
|
2759
|
+
if (setNow === true) {
|
|
2760
|
+
await setSecretsFromEnv(ctx.repoRoot);
|
|
2761
|
+
}
|
|
2762
|
+
p4.outro("CI configurado.");
|
|
2763
|
+
}
|
|
2764
|
+
async function setSecretsFromEnv(repoRoot) {
|
|
2765
|
+
const { readEnvFile: readEnvFile2 } = await Promise.resolve().then(() => (init_store(), store_exports));
|
|
2766
|
+
const env = readEnvFile2(repoRoot);
|
|
2767
|
+
const { defaultRun: defaultRun2 } = await Promise.resolve().then(() => (init_run(), run_exports));
|
|
2768
|
+
const mapping = {
|
|
2769
|
+
ASC_KEY_ID: env.ASC_KEY_ID,
|
|
2770
|
+
ASC_ISSUER_ID: env.ASC_ISSUER_ID
|
|
2771
|
+
};
|
|
2772
|
+
for (const [key, value] of Object.entries(mapping)) {
|
|
2773
|
+
if (!value) continue;
|
|
2774
|
+
defaultRun2("gh", ["secret", "set", key, "--body", value], { cwd: repoRoot });
|
|
2775
|
+
p4.log.success(`gh secret set ${key}`);
|
|
2776
|
+
}
|
|
2777
|
+
if (env.ASC_KEY_PATH) {
|
|
2778
|
+
const { readFileSync: readFileSync9 } = await import("fs");
|
|
2779
|
+
defaultRun2("gh", ["secret", "set", "ASC_KEY_P8_CONTENT", "--body", readFileSync9(env.ASC_KEY_PATH, "utf8")], { cwd: repoRoot });
|
|
2780
|
+
p4.log.success("gh secret set ASC_KEY_P8_CONTENT");
|
|
2781
|
+
}
|
|
2782
|
+
if (env.GOOGLE_PLAY_JSON_KEY) {
|
|
2783
|
+
const { readFileSync: readFileSync9 } = await import("fs");
|
|
2784
|
+
defaultRun2("gh", ["secret", "set", "GOOGLE_PLAY_JSON_CONTENT", "--body", readFileSync9(env.GOOGLE_PLAY_JSON_KEY, "utf8")], { cwd: repoRoot });
|
|
2785
|
+
p4.log.success("gh secret set GOOGLE_PLAY_JSON_CONTENT");
|
|
2786
|
+
}
|
|
2787
|
+
p4.log.warn("EXPO_TOKEN n\xE3o \xE9 lido do .env.release \u2014 cadastre manualmente (`gh secret set EXPO_TOKEN`).");
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// src/cli/index.ts
|
|
2791
|
+
init_run();
|
|
2792
|
+
var HELP = `release-kit \u2014 orquestra\xE7\xE3o de release para apps Expo/EAS
|
|
2793
|
+
|
|
2794
|
+
Uso:
|
|
2795
|
+
release-kit menu interativo (s\xF3 com TTY)
|
|
2796
|
+
release-kit init wizard de setup (credenciais, canais, config)
|
|
2797
|
+
release-kit credentials [expo|apple|google|sync]
|
|
2798
|
+
release-kit doctor [--channel] [--platform] [--deep]
|
|
2799
|
+
release-kit version <patch|minor|major|x.y.z> [--push]
|
|
2800
|
+
release-kit notes [--version] [--edit]
|
|
2801
|
+
release-kit build --channel <c> --platform <android|ios> [--cloud]
|
|
2802
|
+
release-kit submit --channel <c> [--latest|--path <arquivo>]
|
|
2803
|
+
release-kit ship --channel <c> --platform <android|ios>
|
|
2804
|
+
release-kit promote --channel <c> [--platform]
|
|
2805
|
+
release-kit rollout --channel <c> --platform android --to <0-1|complete|halt>
|
|
2806
|
+
release-kit status --channel <c>
|
|
2807
|
+
release-kit update --channel <c> [--message]
|
|
2808
|
+
release-kit ci init github
|
|
2809
|
+
|
|
2810
|
+
Canais com "protected: true" (produ\xE7\xE3o) exigem --i-know-this-is-production
|
|
2811
|
+
(n\xE3o-interativo) ou --confirm-version <vers\xE3o exata>. Sem isso, nenhum
|
|
2812
|
+
comando toca um canal protegido.
|
|
2813
|
+
|
|
2814
|
+
Flags globais: --dry-run --json --verbose --non-interactive
|
|
2815
|
+
`;
|
|
2816
|
+
async function main(argv = process.argv.slice(2)) {
|
|
2817
|
+
const { command, positionals, flags } = parseCliArgs(argv);
|
|
2818
|
+
if (flags.help && command) {
|
|
2819
|
+
console.log(HELP);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
if (!command) {
|
|
2823
|
+
if (flags.help) {
|
|
2824
|
+
console.log(HELP);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
if (process.stdin.isTTY && !flags.nonInteractive) {
|
|
2828
|
+
await runInteractiveMenu();
|
|
2829
|
+
return;
|
|
2830
|
+
}
|
|
2831
|
+
console.log(HELP);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
let result;
|
|
2835
|
+
switch (command) {
|
|
2836
|
+
case "init":
|
|
2837
|
+
await runInitWizard();
|
|
2838
|
+
return;
|
|
2839
|
+
case "credentials":
|
|
2840
|
+
await runCredentialsCommand(positionals[0]);
|
|
2841
|
+
return;
|
|
2842
|
+
case "ci":
|
|
2843
|
+
if (positionals[0] !== "init") throw new ReleaseKitError("uso: release-kit ci init github");
|
|
2844
|
+
await runCiInitCommand(positionals[1]);
|
|
2845
|
+
return;
|
|
2846
|
+
case "doctor":
|
|
2847
|
+
result = runDoctorCommand(flags);
|
|
2848
|
+
break;
|
|
2849
|
+
case "version": {
|
|
2850
|
+
const bump = positionals[0];
|
|
2851
|
+
if (!bump) throw new ReleaseKitError("uso: release-kit version <patch|minor|major|x.y.z>");
|
|
2852
|
+
result = runVersionCommand(flags, bump);
|
|
2853
|
+
break;
|
|
2854
|
+
}
|
|
2855
|
+
case "notes":
|
|
2856
|
+
result = runNotesCommand(flags);
|
|
2857
|
+
break;
|
|
2858
|
+
case "build":
|
|
2859
|
+
result = runBuildCommand(flags);
|
|
2860
|
+
break;
|
|
2861
|
+
case "submit":
|
|
2862
|
+
result = runSubmitCommand(flags);
|
|
2863
|
+
break;
|
|
2864
|
+
case "ship":
|
|
2865
|
+
result = runShipCommand(flags);
|
|
2866
|
+
break;
|
|
2867
|
+
case "promote":
|
|
2868
|
+
result = await runPromoteCommand(flags);
|
|
2869
|
+
break;
|
|
2870
|
+
case "rollout":
|
|
2871
|
+
result = await runRolloutCommand(flags);
|
|
2872
|
+
break;
|
|
2873
|
+
case "status":
|
|
2874
|
+
result = await runStatusCommand(flags);
|
|
2875
|
+
break;
|
|
2876
|
+
case "update":
|
|
2877
|
+
result = runUpdateCommand(flags);
|
|
2878
|
+
break;
|
|
2879
|
+
default:
|
|
2880
|
+
throw new ReleaseKitError(`comando desconhecido: ${command}. Rode \`release-kit --help\`.`);
|
|
2881
|
+
}
|
|
2882
|
+
if (flags.json && result !== void 0) {
|
|
2883
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
var invokedDirectly = process.argv[1]?.endsWith("cli/index.js") || process.argv[1]?.endsWith("release-kit");
|
|
2887
|
+
if (invokedDirectly) {
|
|
2888
|
+
main().catch((error) => {
|
|
2889
|
+
if (error instanceof ReleaseKitError) {
|
|
2890
|
+
console.error(error.message);
|
|
2891
|
+
process.exit(error.code);
|
|
2892
|
+
}
|
|
2893
|
+
console.error(error);
|
|
2894
|
+
process.exit(1);
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
export {
|
|
2898
|
+
main
|
|
2899
|
+
};
|
|
2900
|
+
//# sourceMappingURL=index.js.map
|