@expo-up/cli 0.1.3-next.2 → 0.1.4
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/package.json +5 -1
- package/CHANGELOG.md +0 -41
- package/src/auth.ts +0 -135
- package/src/channels.tsx +0 -123
- package/src/cli-utils.test.ts +0 -25
- package/src/cli-utils.ts +0 -19
- package/src/codesigning.test.ts +0 -165
- package/src/codesigning.ts +0 -265
- package/src/history-utils.test.ts +0 -23
- package/src/history-utils.ts +0 -24
- package/src/history.tsx +0 -559
- package/src/index.tsx +0 -368
- package/src/release-utils.test.ts +0 -221
- package/src/release-utils.ts +0 -146
- package/src/release.tsx +0 -511
- package/src/rollback-utils.test.ts +0 -74
- package/src/rollback-utils.ts +0 -53
- package/src/rollback.tsx +0 -267
- package/src/ui.tsx +0 -99
- package/tsconfig.json +0 -12
package/src/release-utils.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { PlatformOption } from "./cli-utils";
|
|
3
|
-
|
|
4
|
-
interface ContentDirectoryItem {
|
|
5
|
-
name: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
interface MetadataAssetEntry {
|
|
9
|
-
hash?: string;
|
|
10
|
-
path?: string;
|
|
11
|
-
ext?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface MetadataPlatformEntry {
|
|
15
|
-
bundle?: string;
|
|
16
|
-
assets?: MetadataAssetEntry[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface ExportMetadata {
|
|
20
|
-
fileMetadata?: Record<string, MetadataPlatformEntry>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function stableDeepSort(value: unknown): unknown {
|
|
24
|
-
if (value === null || typeof value !== "object") {
|
|
25
|
-
return value;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (Array.isArray(value)) {
|
|
29
|
-
return [...value]
|
|
30
|
-
.map((item) => stableDeepSort(item))
|
|
31
|
-
.sort((left, right) =>
|
|
32
|
-
stableStringify(left).localeCompare(stableStringify(right)),
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const objectValue = value as Record<string, unknown>;
|
|
37
|
-
return Object.keys(objectValue)
|
|
38
|
-
.sort()
|
|
39
|
-
.reduce<Record<string, unknown>>((acc, key) => {
|
|
40
|
-
acc[key] = stableDeepSort(objectValue[key]);
|
|
41
|
-
return acc;
|
|
42
|
-
}, {});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function stableStringify(value: unknown): string {
|
|
46
|
-
if (value === null || typeof value !== "object") {
|
|
47
|
-
return JSON.stringify(value);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (Array.isArray(value)) {
|
|
51
|
-
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const objectValue = value as Record<string, unknown>;
|
|
55
|
-
const keys = Object.keys(objectValue).sort();
|
|
56
|
-
return `{${keys.map((key) => `"${key}":${stableStringify(objectValue[key])}`).join(",")}}`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function getExpoExportArgs(platform: PlatformOption): string[] {
|
|
60
|
-
if (platform === "ios")
|
|
61
|
-
return ["expo", "export", "--platform", "ios", "--clear"];
|
|
62
|
-
if (platform === "android")
|
|
63
|
-
return ["expo", "export", "--platform", "android", "--clear"];
|
|
64
|
-
return [
|
|
65
|
-
"expo",
|
|
66
|
-
"export",
|
|
67
|
-
"--platform",
|
|
68
|
-
"ios",
|
|
69
|
-
"--platform",
|
|
70
|
-
"android",
|
|
71
|
-
"--clear",
|
|
72
|
-
];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function parseNumericBuilds(items: ContentDirectoryItem[]): number[] {
|
|
76
|
-
return items
|
|
77
|
-
.map((item) => Number.parseInt(item.name, 10))
|
|
78
|
-
.filter((value) => Number.isFinite(value))
|
|
79
|
-
.sort((a, b) => b - a);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function createMetadataFingerprint(metadata: unknown): string {
|
|
83
|
-
const typed = metadata as ExportMetadata;
|
|
84
|
-
const fileMetadata = typed.fileMetadata ?? {};
|
|
85
|
-
const normalized = Object.keys(fileMetadata)
|
|
86
|
-
.sort()
|
|
87
|
-
.reduce<Record<string, { bundle: string; assets: MetadataAssetEntry[] }>>(
|
|
88
|
-
(acc, platform) => {
|
|
89
|
-
const entry = fileMetadata[platform] ?? {};
|
|
90
|
-
const assets = (entry.assets ?? [])
|
|
91
|
-
.map((asset) => ({
|
|
92
|
-
hash: asset.hash ?? "",
|
|
93
|
-
path: asset.path ?? "",
|
|
94
|
-
ext: asset.ext ?? "",
|
|
95
|
-
}))
|
|
96
|
-
.sort((left, right) => {
|
|
97
|
-
const leftKey = `${left.hash}:${left.path}:${left.ext}`;
|
|
98
|
-
const rightKey = `${right.hash}:${right.path}:${right.ext}`;
|
|
99
|
-
return leftKey.localeCompare(rightKey);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
acc[platform] = {
|
|
103
|
-
bundle: entry.bundle ?? "",
|
|
104
|
-
assets,
|
|
105
|
-
};
|
|
106
|
-
return acc;
|
|
107
|
-
},
|
|
108
|
-
{},
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
return crypto
|
|
112
|
-
.createHash("sha256")
|
|
113
|
-
.update(stableStringify(normalized))
|
|
114
|
-
.digest("hex");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function createSortedMetadataHash(metadata: unknown): string {
|
|
118
|
-
return crypto
|
|
119
|
-
.createHash("sha256")
|
|
120
|
-
.update(stableStringify(stableDeepSort(metadata)))
|
|
121
|
-
.digest("hex");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function getErrorStatus(error: unknown): number | undefined {
|
|
125
|
-
if (typeof error === "object" && error !== null && "status" in error) {
|
|
126
|
-
return Number((error as { status: unknown }).status);
|
|
127
|
-
}
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function getErrorMessageText(error: unknown): string {
|
|
132
|
-
if (typeof error === "object" && error !== null && "message" in error) {
|
|
133
|
-
return String((error as { message: unknown }).message ?? "");
|
|
134
|
-
}
|
|
135
|
-
return "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function isEmptyRepositoryError(error: unknown): boolean {
|
|
139
|
-
const status = getErrorStatus(error);
|
|
140
|
-
const message = getErrorMessageText(error).toLowerCase();
|
|
141
|
-
return (
|
|
142
|
-
status === 409 ||
|
|
143
|
-
message.includes("git repository is empty") ||
|
|
144
|
-
message.includes("repository is empty")
|
|
145
|
-
);
|
|
146
|
-
}
|
package/src/release.tsx
DELETED
|
@@ -1,511 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
2
|
-
import { Box, Text } from "ink";
|
|
3
|
-
import Spinner from "ink-spinner";
|
|
4
|
-
import { Octokit } from "@octokit/core";
|
|
5
|
-
import { getConfig } from "@expo/config";
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
import pc from "picocolors";
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { getAutoConfig, getStoredToken } from "./auth";
|
|
11
|
-
import { INIT_CHANNEL, parseProjectDescriptor } from "../../core/src/index";
|
|
12
|
-
import { Badge, BrandHeader, CliCard, KV } from "./ui";
|
|
13
|
-
import { PlatformOption } from "./cli-utils";
|
|
14
|
-
import {
|
|
15
|
-
createSortedMetadataHash,
|
|
16
|
-
getErrorMessageText,
|
|
17
|
-
getErrorStatus,
|
|
18
|
-
getExpoExportArgs,
|
|
19
|
-
isEmptyRepositoryError,
|
|
20
|
-
parseNumericBuilds,
|
|
21
|
-
} from "./release-utils";
|
|
22
|
-
|
|
23
|
-
interface ReleaseProps {
|
|
24
|
-
channel?: string;
|
|
25
|
-
platform: PlatformOption;
|
|
26
|
-
debug?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface GitRefResponse {
|
|
30
|
-
object: { sha: string };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface GitTreeResponse {
|
|
34
|
-
sha: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface GitCommitResponse {
|
|
38
|
-
sha: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface ContentFileResponse {
|
|
42
|
-
content: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface RepositoryResponse {
|
|
46
|
-
default_branch: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
type ReleaseStatus = "idle" | "exporting" | "uploading" | "success" | "error";
|
|
50
|
-
|
|
51
|
-
function readJsonFile<T>(filePath: string): T {
|
|
52
|
-
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function toErrorMessage(error: unknown): string {
|
|
56
|
-
return error instanceof Error ? error.message : "Unknown error";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export const Release: React.FC<ReleaseProps> = ({
|
|
60
|
-
channel = "main",
|
|
61
|
-
platform,
|
|
62
|
-
debug = false,
|
|
63
|
-
}) => {
|
|
64
|
-
const [status, setStatus] = useState<ReleaseStatus>("idle");
|
|
65
|
-
const [logs, setLogs] = useState<string[]>([]);
|
|
66
|
-
const [debugLogs, setDebugLogs] = useState<string[]>([]);
|
|
67
|
-
const [error, setError] = useState<string | null>(null);
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
const appendLog = (message: string): void =>
|
|
71
|
-
setLogs((prev) => [...prev, message]);
|
|
72
|
-
const appendDebug = (message: string): void =>
|
|
73
|
-
setDebugLogs((prev) => [...prev, message]);
|
|
74
|
-
|
|
75
|
-
const runExpoExport = (): void => {
|
|
76
|
-
const args = getExpoExportArgs(platform);
|
|
77
|
-
const result = spawnSync("npx", args, {
|
|
78
|
-
env: { ...process.env, NODE_ENV: "production" },
|
|
79
|
-
encoding: "utf-8",
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (debug) {
|
|
83
|
-
appendDebug(`$ npx ${args.join(" ")}`);
|
|
84
|
-
const stdoutLines = (result.stdout ?? "")
|
|
85
|
-
.split("\n")
|
|
86
|
-
.map((line) => line.trim())
|
|
87
|
-
.filter(Boolean);
|
|
88
|
-
const stderrLines = (result.stderr ?? "")
|
|
89
|
-
.split("\n")
|
|
90
|
-
.map((line) => line.trim())
|
|
91
|
-
.filter(Boolean);
|
|
92
|
-
stdoutLines.forEach((line) => appendDebug(`[expo:stdout] ${line}`));
|
|
93
|
-
stderrLines.forEach((line) => appendDebug(`[expo:stderr] ${line}`));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (result.status !== 0) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`Expo export failed with exit code ${result.status ?? 1}.`,
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const run = async () => {
|
|
104
|
-
try {
|
|
105
|
-
const config = getAutoConfig();
|
|
106
|
-
const token = getStoredToken();
|
|
107
|
-
if (!token) throw new Error('Not logged in. Run "login" first.');
|
|
108
|
-
if (!config.serverUrl || !config.projectId || !config.runtimeVersion) {
|
|
109
|
-
throw new Error(
|
|
110
|
-
"Missing Expo updates configuration. Check Expo config updates.url and version.",
|
|
111
|
-
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const projectRes = await fetch(
|
|
115
|
-
`${config.serverUrl}/projects/${config.projectId}`,
|
|
116
|
-
);
|
|
117
|
-
if (!projectRes.ok)
|
|
118
|
-
throw new Error(`Project "${config.projectId}" not found on server.`);
|
|
119
|
-
const { owner, repo } = parseProjectDescriptor(await projectRes.json());
|
|
120
|
-
|
|
121
|
-
const octokit = new Octokit({ auth: token });
|
|
122
|
-
|
|
123
|
-
setStatus("exporting");
|
|
124
|
-
appendLog(
|
|
125
|
-
`${pc.blue("ℹ")} Exporting project with Metro (${platform})...`,
|
|
126
|
-
);
|
|
127
|
-
if (debug)
|
|
128
|
-
appendDebug(
|
|
129
|
-
`Resolved config: server=${config.serverUrl}, project=${config.projectId}, runtime=${config.runtimeVersion}`,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const distDir = path.join(process.cwd(), "dist");
|
|
133
|
-
if (fs.existsSync(distDir)) {
|
|
134
|
-
fs.rmSync(distDir, { recursive: true, force: true });
|
|
135
|
-
if (debug) appendDebug(`Deleted existing dist directory: ${distDir}`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
runExpoExport();
|
|
139
|
-
|
|
140
|
-
const metadataPath = path.join(distDir, "metadata.json");
|
|
141
|
-
if (!fs.existsSync(metadataPath)) {
|
|
142
|
-
throw new Error(
|
|
143
|
-
"Export completed but dist/metadata.json is missing.",
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const metadata = readJsonFile<unknown>(metadataPath);
|
|
148
|
-
const sortedMetadataHash = createSortedMetadataHash(metadata);
|
|
149
|
-
if (debug)
|
|
150
|
-
appendDebug(`Local sorted metadata hash: ${sortedMetadataHash}`);
|
|
151
|
-
|
|
152
|
-
const appConfig = getConfig(process.cwd());
|
|
153
|
-
fs.writeFileSync(
|
|
154
|
-
path.join(distDir, "expoConfig.json"),
|
|
155
|
-
JSON.stringify(appConfig.exp ?? {}, null, 2),
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
const getRefSha = async (refName: string): Promise<string | null> => {
|
|
159
|
-
try {
|
|
160
|
-
const { data: refData } = (await octokit.request(
|
|
161
|
-
"GET /repos/{owner}/{repo}/git/ref/{ref}",
|
|
162
|
-
{
|
|
163
|
-
owner,
|
|
164
|
-
repo,
|
|
165
|
-
ref: `heads/${refName}`,
|
|
166
|
-
},
|
|
167
|
-
)) as { data: GitRefResponse };
|
|
168
|
-
return refData.object.sha;
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (
|
|
171
|
-
getErrorStatus(error) === 404 ||
|
|
172
|
-
isEmptyRepositoryError(error)
|
|
173
|
-
) {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
throw error;
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const createInitBranch = async (): Promise<string> => {
|
|
181
|
-
appendLog(
|
|
182
|
-
`${pc.yellow("ℹ")} Repository is empty. Initializing ${pc.bold(INIT_CHANNEL)}...`,
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
const initFileContent = `expo-up init\ncreatedAt=${new Date().toISOString()}\n`;
|
|
186
|
-
const { data: repoData } = (await octokit.request(
|
|
187
|
-
"GET /repos/{owner}/{repo}",
|
|
188
|
-
{
|
|
189
|
-
owner,
|
|
190
|
-
repo,
|
|
191
|
-
},
|
|
192
|
-
)) as { data: RepositoryResponse };
|
|
193
|
-
const defaultBranch = repoData.default_branch || "main";
|
|
194
|
-
|
|
195
|
-
await octokit.request("PUT /repos/{owner}/{repo}/contents/{path}", {
|
|
196
|
-
owner,
|
|
197
|
-
repo,
|
|
198
|
-
path: "INIT",
|
|
199
|
-
message: `chore: initialize ${INIT_CHANNEL} branch [cli]`,
|
|
200
|
-
content: Buffer.from(initFileContent, "utf-8").toString("base64"),
|
|
201
|
-
branch: defaultBranch,
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
const { data: defaultRef } = (await octokit.request(
|
|
205
|
-
"GET /repos/{owner}/{repo}/git/ref/{ref}",
|
|
206
|
-
{
|
|
207
|
-
owner,
|
|
208
|
-
repo,
|
|
209
|
-
ref: `heads/${defaultBranch}`,
|
|
210
|
-
},
|
|
211
|
-
)) as { data: GitRefResponse };
|
|
212
|
-
const initSha = defaultRef.object.sha;
|
|
213
|
-
|
|
214
|
-
if (defaultBranch !== INIT_CHANNEL) {
|
|
215
|
-
try {
|
|
216
|
-
await octokit.request("POST /repos/{owner}/{repo}/git/refs", {
|
|
217
|
-
owner,
|
|
218
|
-
repo,
|
|
219
|
-
ref: `refs/heads/${INIT_CHANNEL}`,
|
|
220
|
-
sha: initSha,
|
|
221
|
-
});
|
|
222
|
-
} catch (error) {
|
|
223
|
-
if (getErrorStatus(error) !== 422) throw error;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Make __INIT__ the repository default branch for new repositories.
|
|
227
|
-
await octokit.request("PATCH /repos/{owner}/{repo}", {
|
|
228
|
-
owner,
|
|
229
|
-
repo,
|
|
230
|
-
default_branch: INIT_CHANNEL,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
// Best effort: remove the temporary bootstrap branch (usually "main").
|
|
234
|
-
try {
|
|
235
|
-
await octokit.request(
|
|
236
|
-
"DELETE /repos/{owner}/{repo}/git/refs/{ref}",
|
|
237
|
-
{
|
|
238
|
-
owner,
|
|
239
|
-
repo,
|
|
240
|
-
ref: `heads/${defaultBranch}`,
|
|
241
|
-
},
|
|
242
|
-
);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
// Ignore if branch is protected or already removed.
|
|
245
|
-
if (debug) {
|
|
246
|
-
appendDebug(
|
|
247
|
-
`Could not delete bootstrap branch ${defaultBranch}: ${getErrorMessageText(error)}`,
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (debug) {
|
|
254
|
-
appendDebug(
|
|
255
|
-
`Initialized repository with default branch ${INIT_CHANNEL}, commit SHA: ${initSha}`,
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
return initSha;
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
let parentSha = await getRefSha(channel);
|
|
262
|
-
if (parentSha && debug) {
|
|
263
|
-
appendDebug(`Found existing channel ref SHA: ${parentSha}`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (!parentSha) {
|
|
267
|
-
appendLog(
|
|
268
|
-
`${pc.yellow("ℹ")} Creating channel ${pc.cyan(channel)} from ${pc.bold(INIT_CHANNEL)}...`,
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
let initSha: string | null = null;
|
|
272
|
-
initSha = await getRefSha(INIT_CHANNEL);
|
|
273
|
-
|
|
274
|
-
if (!initSha) {
|
|
275
|
-
initSha = await createInitBranch();
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (channel === INIT_CHANNEL) {
|
|
279
|
-
parentSha = initSha;
|
|
280
|
-
} else {
|
|
281
|
-
try {
|
|
282
|
-
const { data: newRef } = (await octokit.request(
|
|
283
|
-
"POST /repos/{owner}/{repo}/git/refs",
|
|
284
|
-
{
|
|
285
|
-
owner,
|
|
286
|
-
repo,
|
|
287
|
-
ref: `refs/heads/${channel}`,
|
|
288
|
-
sha: initSha,
|
|
289
|
-
},
|
|
290
|
-
)) as { data: GitRefResponse };
|
|
291
|
-
parentSha = newRef.object.sha;
|
|
292
|
-
} catch (error) {
|
|
293
|
-
if (getErrorStatus(error) !== 422) throw error;
|
|
294
|
-
parentSha = await getRefSha(channel);
|
|
295
|
-
if (!parentSha) throw error;
|
|
296
|
-
}
|
|
297
|
-
if (debug) {
|
|
298
|
-
appendDebug(`Created channel ${channel} with SHA ${parentSha}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!parentSha) {
|
|
304
|
-
throw new Error(
|
|
305
|
-
`Unable to resolve channel reference for "${channel}".`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
let nextBuild = 1;
|
|
310
|
-
try {
|
|
311
|
-
const { data: contents } = (await octokit.request(
|
|
312
|
-
"GET /repos/{owner}/{repo}/contents/{path}",
|
|
313
|
-
{
|
|
314
|
-
owner,
|
|
315
|
-
repo,
|
|
316
|
-
path: config.runtimeVersion,
|
|
317
|
-
ref: channel,
|
|
318
|
-
},
|
|
319
|
-
)) as { data: Array<{ name: string }> };
|
|
320
|
-
|
|
321
|
-
const builds = Array.isArray(contents)
|
|
322
|
-
? parseNumericBuilds(contents)
|
|
323
|
-
: [];
|
|
324
|
-
if (debug)
|
|
325
|
-
appendDebug(
|
|
326
|
-
`Detected build folders: ${builds.join(", ") || "(none)"}`,
|
|
327
|
-
);
|
|
328
|
-
if (builds.length > 0) {
|
|
329
|
-
const latestBuild = builds[0];
|
|
330
|
-
nextBuild = latestBuild + 1;
|
|
331
|
-
if (debug)
|
|
332
|
-
appendDebug(
|
|
333
|
-
`Latest build=${latestBuild}, next build=${nextBuild}`,
|
|
334
|
-
);
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const { data: latestMeta } = (await octokit.request(
|
|
338
|
-
"GET /repos/{owner}/{repo}/contents/{path}",
|
|
339
|
-
{
|
|
340
|
-
owner,
|
|
341
|
-
repo,
|
|
342
|
-
path: `${config.runtimeVersion}/${latestBuild}/metadata.json`,
|
|
343
|
-
ref: channel,
|
|
344
|
-
},
|
|
345
|
-
)) as { data: ContentFileResponse };
|
|
346
|
-
|
|
347
|
-
const remoteMetadata = JSON.parse(
|
|
348
|
-
Buffer.from(latestMeta.content, "base64").toString(),
|
|
349
|
-
) as unknown;
|
|
350
|
-
const remoteSortedMetadataHash =
|
|
351
|
-
createSortedMetadataHash(remoteMetadata);
|
|
352
|
-
if (debug) {
|
|
353
|
-
appendDebug(
|
|
354
|
-
`Remote sorted metadata hash: ${remoteSortedMetadataHash}`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
if (remoteSortedMetadataHash === sortedMetadataHash) {
|
|
358
|
-
appendLog(pc.yellow("⚠ No changes detected. Build skipped."));
|
|
359
|
-
setStatus("success");
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
} catch {
|
|
363
|
-
// If latest build metadata is unreadable we continue with release.
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
} catch {
|
|
367
|
-
// Runtime root path may not exist yet on new channels. Proceed with build #1.
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
setStatus("uploading");
|
|
371
|
-
appendLog(
|
|
372
|
-
`${pc.blue("ℹ")} Uploading build ${pc.bold(nextBuild)} to ${pc.cyan(channel)}...`,
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
const treeItems: Array<{ local: string; remote: string }> = [];
|
|
376
|
-
const walk = (dir: string, base = ""): void => {
|
|
377
|
-
for (const fileName of fs.readdirSync(dir)) {
|
|
378
|
-
const fullPath = path.join(dir, fileName);
|
|
379
|
-
const relativePath = base ? path.join(base, fileName) : fileName;
|
|
380
|
-
if (fs.statSync(fullPath).isDirectory()) {
|
|
381
|
-
walk(fullPath, relativePath);
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
treeItems.push({
|
|
385
|
-
local: fullPath,
|
|
386
|
-
remote: `${config.runtimeVersion}/${nextBuild}/${relativePath.replaceAll(path.sep, "/")}`,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
walk(distDir);
|
|
391
|
-
if (debug)
|
|
392
|
-
appendDebug(`Prepared ${treeItems.length} files for commit tree.`);
|
|
393
|
-
|
|
394
|
-
const newTree: Array<{
|
|
395
|
-
path: string;
|
|
396
|
-
mode: "100644";
|
|
397
|
-
type: "blob";
|
|
398
|
-
sha: string;
|
|
399
|
-
}> = [];
|
|
400
|
-
for (const item of treeItems) {
|
|
401
|
-
const content = fs.readFileSync(item.local);
|
|
402
|
-
const { data: blob } = (await octokit.request(
|
|
403
|
-
"POST /repos/{owner}/{repo}/git/blobs",
|
|
404
|
-
{
|
|
405
|
-
owner,
|
|
406
|
-
repo,
|
|
407
|
-
content: content.toString("base64"),
|
|
408
|
-
encoding: "base64",
|
|
409
|
-
},
|
|
410
|
-
)) as { data: { sha: string } };
|
|
411
|
-
newTree.push({
|
|
412
|
-
path: item.remote,
|
|
413
|
-
mode: "100644",
|
|
414
|
-
type: "blob",
|
|
415
|
-
sha: blob.sha,
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const { data: tree } = (await octokit.request(
|
|
420
|
-
"POST /repos/{owner}/{repo}/git/trees",
|
|
421
|
-
{
|
|
422
|
-
owner,
|
|
423
|
-
repo,
|
|
424
|
-
base_tree: parentSha,
|
|
425
|
-
tree: newTree,
|
|
426
|
-
},
|
|
427
|
-
)) as { data: GitTreeResponse };
|
|
428
|
-
if (debug) appendDebug(`Created git tree: ${tree.sha}`);
|
|
429
|
-
|
|
430
|
-
const { data: commit } = (await octokit.request(
|
|
431
|
-
"POST /repos/{owner}/{repo}/git/commits",
|
|
432
|
-
{
|
|
433
|
-
owner,
|
|
434
|
-
repo,
|
|
435
|
-
message: `release: build ${nextBuild} for ${config.runtimeVersion} [cli]`,
|
|
436
|
-
tree: tree.sha,
|
|
437
|
-
parents: [parentSha],
|
|
438
|
-
},
|
|
439
|
-
)) as { data: GitCommitResponse };
|
|
440
|
-
if (debug) appendDebug(`Created commit: ${commit.sha}`);
|
|
441
|
-
|
|
442
|
-
await octokit.request("PATCH /repos/{owner}/{repo}/git/refs/{ref}", {
|
|
443
|
-
owner,
|
|
444
|
-
repo,
|
|
445
|
-
ref: `heads/${channel}`,
|
|
446
|
-
sha: commit.sha,
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
appendLog(pc.green("✔ Release successful!"));
|
|
450
|
-
setStatus("success");
|
|
451
|
-
} catch (runError) {
|
|
452
|
-
setError(toErrorMessage(runError));
|
|
453
|
-
if (debug) appendDebug(`Failure: ${toErrorMessage(runError)}`);
|
|
454
|
-
setStatus("error");
|
|
455
|
-
}
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
run();
|
|
459
|
-
}, [channel, platform]);
|
|
460
|
-
|
|
461
|
-
return (
|
|
462
|
-
<Box flexDirection="column" padding={1}>
|
|
463
|
-
<BrandHeader subtitle="Over-the-air updates" />
|
|
464
|
-
<CliCard title="expo-up release" subtitle="Build and publish OTA update">
|
|
465
|
-
<KV keyName="Channel" value={channel} valueColor="cyan" />
|
|
466
|
-
<KV keyName="Platform" value={platform} valueColor="blue" />
|
|
467
|
-
</CliCard>
|
|
468
|
-
|
|
469
|
-
<CliCard title="Progress">
|
|
470
|
-
{logs.length === 0 ? (
|
|
471
|
-
<Text color="gray">Waiting to start...</Text>
|
|
472
|
-
) : null}
|
|
473
|
-
{logs.map((log, index) => (
|
|
474
|
-
<Text key={index}>{`• ${log}`}</Text>
|
|
475
|
-
))}
|
|
476
|
-
{status !== "success" && status !== "error" && (
|
|
477
|
-
<Box marginTop={1}>
|
|
478
|
-
<Badge label={status.toUpperCase()} tone="yellow" />
|
|
479
|
-
<Text>
|
|
480
|
-
<Spinner type="dots" /> Working...
|
|
481
|
-
</Text>
|
|
482
|
-
</Box>
|
|
483
|
-
)}
|
|
484
|
-
{error ? (
|
|
485
|
-
<Box marginTop={1}>
|
|
486
|
-
<Badge label="FAILED" tone="red" />
|
|
487
|
-
<Text color="red">{error}</Text>
|
|
488
|
-
</Box>
|
|
489
|
-
) : null}
|
|
490
|
-
{status === "success" ? (
|
|
491
|
-
<Box marginTop={1}>
|
|
492
|
-
<Badge label="SUCCESS" tone="green" />
|
|
493
|
-
<Text color="green">Release completed.</Text>
|
|
494
|
-
</Box>
|
|
495
|
-
) : null}
|
|
496
|
-
</CliCard>
|
|
497
|
-
{debug && (
|
|
498
|
-
<CliCard title="Debug Logs" subtitle="Verbose diagnostics">
|
|
499
|
-
{debugLogs.length === 0 ? (
|
|
500
|
-
<Text color="gray">No debug logs yet.</Text>
|
|
501
|
-
) : null}
|
|
502
|
-
{debugLogs.map((log, index) => (
|
|
503
|
-
<Text key={index} color="gray">
|
|
504
|
-
{log}
|
|
505
|
-
</Text>
|
|
506
|
-
))}
|
|
507
|
-
</CliCard>
|
|
508
|
-
)}
|
|
509
|
-
</Box>
|
|
510
|
-
);
|
|
511
|
-
};
|