amalgm 0.1.124 → 0.1.125
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 +1 -1
- package/runtime/scripts/amalgm-mcp/automations/internal-workflows.js +482 -0
- package/runtime/scripts/amalgm-mcp/automations/store.js +51 -0
- package/runtime/scripts/amalgm-mcp/events/desktop-release-runner.js +1379 -0
- package/runtime/scripts/amalgm-mcp/events/npm-release-runner.js +406 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +58 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-runner.test.js +313 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +94 -0
- package/runtime/scripts/amalgm-mcp/tests/npm-release-runner.test.js +102 -0
|
@@ -0,0 +1,1379 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
|
|
9
|
+
const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
|
|
10
|
+
const UI_REPO_FULL_NAME = 'amalgm-inc/amalgm-ui';
|
|
11
|
+
const DEFAULT_DESKTOP_RELEASE_REPO_FULL_NAME = 'amalgm-inc/amalgm-desktop-releases';
|
|
12
|
+
const DEFAULT_DESKTOP_LEGACY_BRIDGE_REPO_FULL_NAME = ENGINE_REPO_FULL_NAME;
|
|
13
|
+
const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
|
|
14
|
+
const DEFAULT_UI_REPO_URL = 'https://github.com/amalgm-inc/amalgm-ui.git';
|
|
15
|
+
const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-desktop-release');
|
|
16
|
+
const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'desktop-release.env');
|
|
17
|
+
const DEFAULT_CSC_NAME = 'amalgm, Inc. (C5S6UATV3L)';
|
|
18
|
+
const DEFAULT_DESKTOP_UPDATE_BASE_URL = 'https://amalgm-desktop-releases.fly.dev';
|
|
19
|
+
const DEFAULT_DESKTOP_RELEASE_FLY_APP = 'amalgm-desktop-releases';
|
|
20
|
+
const DEFAULT_DESKTOP_RELEASE_FLY_ROOT = '/data';
|
|
21
|
+
const DEFAULT_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
function cleanString(value) {
|
|
24
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseRepoFullName(value) {
|
|
28
|
+
const raw = cleanString(value);
|
|
29
|
+
const match = /^([^/\s]+)\/([^/\s]+)$/.exec(raw);
|
|
30
|
+
if (!match) throw new Error(`Invalid GitHub repository full name: ${raw || '(missing)'}`);
|
|
31
|
+
return { owner: match[1], repo: match[2], fullName: `${match[1]}/${match[2]}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeReleaseProvider(value) {
|
|
35
|
+
const raw = cleanString(value).toLowerCase();
|
|
36
|
+
if (!raw || raw === 'fly' || raw === 'generic') return 'fly';
|
|
37
|
+
if (raw === 'github' || raw === 'gh') return 'github';
|
|
38
|
+
throw new Error(`Unsupported desktop release provider: ${value}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeUpdateProviderForRelease(provider) {
|
|
42
|
+
return provider === 'github' ? 'github' : 'generic';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeUploadMethod(value) {
|
|
46
|
+
const raw = cleanString(value).toLowerCase();
|
|
47
|
+
if (!raw || raw === 'http' || raw === 'https' || raw === 'chunked' || raw === 'http-chunked') return 'http';
|
|
48
|
+
if (raw === 'sftp' || raw === 'fly-sftp') return 'sftp';
|
|
49
|
+
throw new Error(`Unsupported desktop release upload method: ${value}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeHttpBaseUrl(value, label) {
|
|
53
|
+
const raw = cleanString(value);
|
|
54
|
+
if (!raw) return '';
|
|
55
|
+
const url = new URL(raw);
|
|
56
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
57
|
+
throw new Error(`${label} must be http(s), received: ${raw}`);
|
|
58
|
+
}
|
|
59
|
+
url.search = '';
|
|
60
|
+
url.hash = '';
|
|
61
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
62
|
+
return url.toString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function desktopReleaseProvider(env = process.env) {
|
|
66
|
+
return normalizeReleaseProvider(
|
|
67
|
+
env.AMALGM_DESKTOP_RELEASE_PROVIDER
|
|
68
|
+
|| env.AMALGM_DESKTOP_UPDATE_PROVIDER
|
|
69
|
+
|| 'fly',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function desktopUpdateBaseUrl(env = process.env) {
|
|
74
|
+
return normalizeHttpBaseUrl(
|
|
75
|
+
env.AMALGM_DESKTOP_UPDATE_BASE_URL || DEFAULT_DESKTOP_UPDATE_BASE_URL,
|
|
76
|
+
'Desktop update base URL',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function desktopUpdateUrlForLane(lane, env = process.env) {
|
|
81
|
+
const explicitUrl = normalizeHttpBaseUrl(env.AMALGM_DESKTOP_UPDATE_URL || '', 'Desktop update URL');
|
|
82
|
+
if (explicitUrl) return explicitUrl;
|
|
83
|
+
return new URL(`${lane}/`, desktopUpdateBaseUrl(env)).toString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function desktopReleaseUploadBaseUrl(env = process.env) {
|
|
87
|
+
return normalizeHttpBaseUrl(
|
|
88
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_URL
|
|
89
|
+
|| env.AMALGM_DESKTOP_UPDATE_BASE_URL
|
|
90
|
+
|| DEFAULT_DESKTOP_UPDATE_BASE_URL,
|
|
91
|
+
'Desktop release upload URL',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeRemotePathSegment(value, label) {
|
|
96
|
+
const raw = cleanString(value);
|
|
97
|
+
if (!/^[A-Za-z0-9._-]+$/.test(raw)) {
|
|
98
|
+
throw new Error(`Invalid ${label}: ${raw || '(missing)'}`);
|
|
99
|
+
}
|
|
100
|
+
return raw;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRemoteRoot(value) {
|
|
104
|
+
const raw = cleanString(value) || DEFAULT_DESKTOP_RELEASE_FLY_ROOT;
|
|
105
|
+
if (!raw.startsWith('/')) throw new Error(`Fly release root must be absolute: ${raw}`);
|
|
106
|
+
return raw.replace(/\/+$/, '') || '/';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function joinRemotePath(root, ...segments) {
|
|
110
|
+
const cleanRoot = normalizeRemoteRoot(root);
|
|
111
|
+
const prefix = cleanRoot === '/' ? '' : cleanRoot;
|
|
112
|
+
return [prefix, ...segments.map((segment) => safeRemotePathSegment(segment, 'remote path segment'))]
|
|
113
|
+
.join('/')
|
|
114
|
+
.replace(/\/+/g, '/');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function shellQuote(value) {
|
|
118
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function desktopReleasePublishTarget(env = process.env, lane = 'main') {
|
|
122
|
+
const provider = desktopReleaseProvider(env);
|
|
123
|
+
if (provider === 'github') {
|
|
124
|
+
const releaseRepository = desktopReleaseRepository(env);
|
|
125
|
+
return {
|
|
126
|
+
provider,
|
|
127
|
+
updateProvider: 'github',
|
|
128
|
+
releaseRepository,
|
|
129
|
+
releaseUrlBase: `https://github.com/${releaseRepository.fullName}/releases/tag/`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const flyApp = cleanString(env.AMALGM_DESKTOP_RELEASE_FLY_APP) || DEFAULT_DESKTOP_RELEASE_FLY_APP;
|
|
134
|
+
const flyRoot = normalizeRemoteRoot(env.AMALGM_DESKTOP_RELEASE_FLY_ROOT);
|
|
135
|
+
const remoteDir = joinRemotePath(flyRoot, lane);
|
|
136
|
+
const updateUrl = desktopUpdateUrlForLane(lane, env);
|
|
137
|
+
const channelFile = desktopReleaseChannelFile(lane);
|
|
138
|
+
const uploadMethod = normalizeUploadMethod(env.AMALGM_DESKTOP_RELEASE_UPLOAD_METHOD || '');
|
|
139
|
+
return {
|
|
140
|
+
provider,
|
|
141
|
+
updateProvider: 'generic',
|
|
142
|
+
updateUrl,
|
|
143
|
+
channelFile,
|
|
144
|
+
channelUrl: new URL(channelFile, updateUrl).toString(),
|
|
145
|
+
uploadMethod,
|
|
146
|
+
uploadBaseUrl: desktopReleaseUploadBaseUrl(env),
|
|
147
|
+
flyApp,
|
|
148
|
+
flyRoot,
|
|
149
|
+
remoteDir,
|
|
150
|
+
lane,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function desktopReleaseRepository(env = process.env) {
|
|
155
|
+
const fullName = cleanString(env.AMALGM_DESKTOP_RELEASE_REPOSITORY)
|
|
156
|
+
|| cleanString(env.AMALGM_DESKTOP_RELEASE_REPO_FULL_NAME);
|
|
157
|
+
if (fullName) return parseRepoFullName(fullName);
|
|
158
|
+
|
|
159
|
+
const owner = cleanString(env.AMALGM_DESKTOP_UPDATE_OWNER)
|
|
160
|
+
|| cleanString(env.AMALGM_DESKTOP_RELEASE_OWNER);
|
|
161
|
+
const repo = cleanString(env.AMALGM_DESKTOP_UPDATE_REPO)
|
|
162
|
+
|| cleanString(env.AMALGM_DESKTOP_RELEASE_REPO);
|
|
163
|
+
if (owner || repo) return parseRepoFullName(`${owner || 'amalgm-inc'}/${repo || 'amalgm-desktop-releases'}`);
|
|
164
|
+
|
|
165
|
+
return parseRepoFullName(DEFAULT_DESKTOP_RELEASE_REPO_FULL_NAME);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function desktopLegacyBridgeEnabled(env = process.env) {
|
|
169
|
+
const raw = cleanString(env.AMALGM_DESKTOP_LEGACY_BRIDGE_ENABLED).toLowerCase();
|
|
170
|
+
if (!raw) return true;
|
|
171
|
+
return !['0', 'false', 'no', 'off'].includes(raw);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function desktopLegacyBridgeRepository(env = process.env) {
|
|
175
|
+
const fullName = cleanString(env.AMALGM_DESKTOP_LEGACY_BRIDGE_REPOSITORY)
|
|
176
|
+
|| cleanString(env.AMALGM_DESKTOP_LEGACY_BRIDGE_REPO_FULL_NAME);
|
|
177
|
+
if (fullName) return parseRepoFullName(fullName);
|
|
178
|
+
|
|
179
|
+
const owner = cleanString(env.AMALGM_DESKTOP_LEGACY_BRIDGE_OWNER);
|
|
180
|
+
const repo = cleanString(env.AMALGM_DESKTOP_LEGACY_BRIDGE_REPO);
|
|
181
|
+
if (owner || repo) return parseRepoFullName(`${owner || 'amalgm-inc'}/${repo || 'amalgm-engine'}`);
|
|
182
|
+
|
|
183
|
+
return parseRepoFullName(DEFAULT_DESKTOP_LEGACY_BRIDGE_REPO_FULL_NAME);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseEnvFile(text) {
|
|
187
|
+
const env = {};
|
|
188
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
189
|
+
const trimmed = line.trim();
|
|
190
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
191
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
|
192
|
+
if (!match) continue;
|
|
193
|
+
let value = match[2] || '';
|
|
194
|
+
const quote = value[0];
|
|
195
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
196
|
+
value = value.slice(1, -1);
|
|
197
|
+
}
|
|
198
|
+
env[match[1]] = value;
|
|
199
|
+
}
|
|
200
|
+
return env;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function applyDesktopReleaseEnv(env = process.env) {
|
|
204
|
+
const candidates = [
|
|
205
|
+
cleanString(env.AMALGM_DESKTOP_RELEASE_ENV_FILE),
|
|
206
|
+
DEFAULT_ENV_FILE,
|
|
207
|
+
].filter(Boolean);
|
|
208
|
+
|
|
209
|
+
for (const filePath of candidates) {
|
|
210
|
+
if (!fs.existsSync(filePath)) continue;
|
|
211
|
+
const values = parseEnvFile(fs.readFileSync(filePath, 'utf8'));
|
|
212
|
+
for (const [key, value] of Object.entries(values)) {
|
|
213
|
+
if (!cleanString(env[key])) env[key] = value;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return env;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function prependPath(env, dir) {
|
|
221
|
+
const cleanDir = cleanString(dir);
|
|
222
|
+
if (!cleanDir) return env.PATH || '';
|
|
223
|
+
const parts = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
224
|
+
return [cleanDir, ...parts.filter((part) => part !== cleanDir)].join(path.delimiter);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function desktopReleaseToolEnv(baseEnv = process.env) {
|
|
228
|
+
const env = { ...baseEnv };
|
|
229
|
+
applyDesktopReleaseEnv(env);
|
|
230
|
+
const nodeBin = cleanString(env.AMALGM_DESKTOP_RELEASE_NODE_BIN);
|
|
231
|
+
if (nodeBin && fs.existsSync(nodeBin)) {
|
|
232
|
+
env.PATH = prependPath(env, path.dirname(nodeBin));
|
|
233
|
+
}
|
|
234
|
+
const pathPrefix = cleanString(env.AMALGM_DESKTOP_RELEASE_PATH_PREFIX);
|
|
235
|
+
if (pathPrefix) env.PATH = prependPath(env, pathPrefix);
|
|
236
|
+
env.npm_config_python = cleanString(env.AMALGM_DESKTOP_RELEASE_PYTHON)
|
|
237
|
+
|| cleanString(env.npm_config_python)
|
|
238
|
+
|| '/usr/bin/python3';
|
|
239
|
+
return env;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isFullSha(value) {
|
|
243
|
+
return /^[a-f0-9]{40}$/i.test(cleanString(value));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function laneForBranch(branch) {
|
|
247
|
+
if (branch === 'preview') return 'preview';
|
|
248
|
+
if (branch === 'main') return 'main';
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readJsonFile(filePath) {
|
|
253
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parsePayloadFromEnv(env = process.env) {
|
|
257
|
+
const payloadFile = cleanString(env.AMALGM_DESKTOP_RELEASE_PAYLOAD_FILE);
|
|
258
|
+
const payloadJson = payloadFile ? fs.readFileSync(payloadFile, 'utf8') : cleanString(env.AMALGM_DESKTOP_RELEASE_PAYLOAD_JSON);
|
|
259
|
+
if (!payloadJson) return null;
|
|
260
|
+
return JSON.parse(payloadJson);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function requestFromEnv(env = process.env) {
|
|
264
|
+
const payload = parsePayloadFromEnv(env);
|
|
265
|
+
const repository = cleanString(
|
|
266
|
+
env.AMALGM_DESKTOP_RELEASE_SOURCE_REPO
|
|
267
|
+
|| payload?.repository?.full_name,
|
|
268
|
+
);
|
|
269
|
+
const ref = cleanString(env.AMALGM_DESKTOP_RELEASE_REF || payload?.ref);
|
|
270
|
+
const branch = cleanString(
|
|
271
|
+
env.AMALGM_DESKTOP_RELEASE_BRANCH
|
|
272
|
+
|| (ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref),
|
|
273
|
+
);
|
|
274
|
+
const after = cleanString(env.AMALGM_DESKTOP_RELEASE_AFTER || payload?.after);
|
|
275
|
+
const lane = laneForBranch(branch);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
payload,
|
|
279
|
+
repository,
|
|
280
|
+
ref: ref || (branch ? `refs/heads/${branch}` : ''),
|
|
281
|
+
branch,
|
|
282
|
+
after,
|
|
283
|
+
lane,
|
|
284
|
+
delivery: cleanString(env.AMALGM_DESKTOP_RELEASE_DELIVERY),
|
|
285
|
+
dryRun: ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_DESKTOP_RELEASE_DRY_RUN).toLowerCase()),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function shouldHandleRequest(request) {
|
|
290
|
+
if (!request.lane) return { ok: false, reason: `Ignored branch "${request.branch || '(missing)'}".` };
|
|
291
|
+
if (![ENGINE_REPO_FULL_NAME, UI_REPO_FULL_NAME].includes(request.repository)) {
|
|
292
|
+
return { ok: false, reason: `Ignored repository "${request.repository || '(missing)'}".` };
|
|
293
|
+
}
|
|
294
|
+
if (!isFullSha(request.after)) return { ok: false, reason: `Ignored push without a full after SHA.` };
|
|
295
|
+
if (/^0{40}$/.test(request.after)) return { ok: false, reason: 'Ignored branch deletion push.' };
|
|
296
|
+
return { ok: true };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function escapeRegExp(value) {
|
|
300
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function scrubLogText(value, env = process.env) {
|
|
304
|
+
let text = String(value || '');
|
|
305
|
+
for (const secret of [
|
|
306
|
+
env.GH_TOKEN,
|
|
307
|
+
env.GITHUB_TOKEN,
|
|
308
|
+
env.FLY_ACCESS_TOKEN,
|
|
309
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN,
|
|
310
|
+
env.APPLE_API_KEY,
|
|
311
|
+
env.APPLE_API_KEY_ID,
|
|
312
|
+
env.APPLE_API_ISSUER,
|
|
313
|
+
env.APPLE_ID,
|
|
314
|
+
env.APPLE_APP_SPECIFIC_PASSWORD,
|
|
315
|
+
env.APPLE_TEAM_ID,
|
|
316
|
+
env.APPLE_KEYCHAIN,
|
|
317
|
+
env.APPLE_KEYCHAIN_PROFILE,
|
|
318
|
+
process.env.GH_TOKEN,
|
|
319
|
+
process.env.GITHUB_TOKEN,
|
|
320
|
+
process.env.FLY_ACCESS_TOKEN,
|
|
321
|
+
process.env.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN,
|
|
322
|
+
process.env.APPLE_API_KEY,
|
|
323
|
+
process.env.APPLE_API_KEY_ID,
|
|
324
|
+
process.env.APPLE_API_ISSUER,
|
|
325
|
+
process.env.APPLE_ID,
|
|
326
|
+
process.env.APPLE_APP_SPECIFIC_PASSWORD,
|
|
327
|
+
process.env.APPLE_TEAM_ID,
|
|
328
|
+
process.env.APPLE_KEYCHAIN,
|
|
329
|
+
process.env.APPLE_KEYCHAIN_PROFILE,
|
|
330
|
+
]) {
|
|
331
|
+
const cleanSecret = cleanString(secret);
|
|
332
|
+
if (cleanSecret.length < 8) continue;
|
|
333
|
+
text = text.replace(new RegExp(escapeRegExp(cleanSecret), 'g'), '[redacted]');
|
|
334
|
+
}
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function releaseLogFile(options = {}) {
|
|
339
|
+
return cleanString(options.logFile)
|
|
340
|
+
|| cleanString(options.env?.AMALGM_DESKTOP_RELEASE_LOG_FILE)
|
|
341
|
+
|| cleanString(process.env.AMALGM_DESKTOP_RELEASE_LOG_FILE);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function appendReleaseLog(filePath, text) {
|
|
345
|
+
const logFile = cleanString(filePath);
|
|
346
|
+
if (!logFile) return;
|
|
347
|
+
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
|
348
|
+
fs.appendFileSync(logFile, text);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function run(command, args, options = {}) {
|
|
352
|
+
const printable = [command, ...args].join(' ');
|
|
353
|
+
const logFile = releaseLogFile(options);
|
|
354
|
+
appendReleaseLog(logFile, [
|
|
355
|
+
'',
|
|
356
|
+
`[${new Date().toISOString()}] $ ${scrubLogText(printable, options.env)}`,
|
|
357
|
+
options.cwd ? `cwd: ${options.cwd}` : null,
|
|
358
|
+
].filter(Boolean).join('\n') + '\n');
|
|
359
|
+
console.log(`$ ${printable}`);
|
|
360
|
+
const result = spawnSync(command, args, {
|
|
361
|
+
cwd: options.cwd,
|
|
362
|
+
env: options.env || process.env,
|
|
363
|
+
encoding: 'utf8',
|
|
364
|
+
maxBuffer: Number(options.maxBuffer) || 100 * 1024 * 1024,
|
|
365
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
366
|
+
});
|
|
367
|
+
appendReleaseLog(logFile, [
|
|
368
|
+
`status: ${result.error ? `error: ${result.error.message}` : (result.status ?? result.signal ?? 'unknown')}`,
|
|
369
|
+
result.stdout ? `stdout:\n${scrubLogText(result.stdout, options.env)}` : null,
|
|
370
|
+
result.stderr ? `stderr:\n${scrubLogText(result.stderr, options.env)}` : null,
|
|
371
|
+
].filter(Boolean).join('\n') + '\n');
|
|
372
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
373
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
374
|
+
if (result.error) throw result.error;
|
|
375
|
+
if (result.status !== 0) {
|
|
376
|
+
throw new Error(`${printable} failed with exit code ${result.status ?? result.signal ?? 'unknown'}`);
|
|
377
|
+
}
|
|
378
|
+
return result.stdout || '';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function sleepSync(ms) {
|
|
382
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function runWithRetry(command, args, options = {}, attempts = 3) {
|
|
386
|
+
let lastError = null;
|
|
387
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
388
|
+
try {
|
|
389
|
+
return run(command, args, options);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
lastError = error;
|
|
392
|
+
if (attempt >= attempts) break;
|
|
393
|
+
const delayMs = Math.min(15_000, attempt * 5_000);
|
|
394
|
+
console.warn(`${command} ${args[0] || ''} failed on attempt ${attempt}; retrying in ${delayMs}ms.`);
|
|
395
|
+
sleepSync(delayMs);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
throw lastError;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function curlConfigValue(value) {
|
|
402
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function ghToken(env = process.env) {
|
|
406
|
+
if (cleanString(env.GH_TOKEN)) return cleanString(env.GH_TOKEN);
|
|
407
|
+
if (cleanString(env.GITHUB_TOKEN)) return cleanString(env.GITHUB_TOKEN);
|
|
408
|
+
try {
|
|
409
|
+
const result = spawnSync('gh', ['auth', 'token'], {
|
|
410
|
+
env,
|
|
411
|
+
encoding: 'utf8',
|
|
412
|
+
maxBuffer: 1024 * 1024,
|
|
413
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
414
|
+
});
|
|
415
|
+
if (result.error || result.status !== 0) return '';
|
|
416
|
+
return cleanString(result.stdout);
|
|
417
|
+
} catch {
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function githubAssetContentType(filePath) {
|
|
423
|
+
const name = path.basename(filePath);
|
|
424
|
+
if (name.endsWith('.zip')) return 'application/zip';
|
|
425
|
+
if (name.endsWith('.dmg')) return 'application/x-apple-diskimage';
|
|
426
|
+
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'text/yaml';
|
|
427
|
+
return 'application/octet-stream';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function githubReleaseAssetIdsByName(releaseId, assetName, cwd, env, releaseRepository) {
|
|
431
|
+
const jq = `.[] | select(.name == ${JSON.stringify(assetName)}) | .id`;
|
|
432
|
+
const stdout = run('gh', [
|
|
433
|
+
'api',
|
|
434
|
+
`repos/${releaseRepository.fullName}/releases/${releaseId}/assets`,
|
|
435
|
+
'--paginate',
|
|
436
|
+
'--jq',
|
|
437
|
+
jq,
|
|
438
|
+
], { cwd, env });
|
|
439
|
+
return stdout.split(/\r?\n/)
|
|
440
|
+
.map((line) => cleanString(line))
|
|
441
|
+
.filter(Boolean);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function deleteGithubReleaseAssetsByName(releaseId, assetName, cwd, env, releaseRepository) {
|
|
445
|
+
for (const assetId of githubReleaseAssetIdsByName(releaseId, assetName, cwd, env, releaseRepository)) {
|
|
446
|
+
run('gh', [
|
|
447
|
+
'api',
|
|
448
|
+
'-X',
|
|
449
|
+
'DELETE',
|
|
450
|
+
`repos/${releaseRepository.fullName}/releases/assets/${assetId}`,
|
|
451
|
+
], { cwd, env });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function githubUploadLimitRate(env = process.env) {
|
|
456
|
+
return cleanString(env.AMALGM_DESKTOP_RELEASE_UPLOAD_LIMIT_RATE) || '1m';
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function uploadGithubReleaseAssetWithCurl(releaseId, filePath, cwd, env, releaseRepository) {
|
|
460
|
+
const token = ghToken(env);
|
|
461
|
+
if (!token) throw new Error('Missing GitHub token. Set GH_TOKEN/GITHUB_TOKEN or run gh auth login before uploading desktop release assets.');
|
|
462
|
+
|
|
463
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-release-upload-'));
|
|
464
|
+
const configPath = path.join(tempRoot, 'curl.conf');
|
|
465
|
+
const responsePath = path.join(tempRoot, 'response.json');
|
|
466
|
+
const assetName = path.basename(filePath);
|
|
467
|
+
const uploadUrl = `https://uploads.github.com/repos/${releaseRepository.fullName}/releases/${releaseId}/assets?name=${encodeURIComponent(assetName)}`;
|
|
468
|
+
const limitRate = githubUploadLimitRate(env);
|
|
469
|
+
const configLines = [
|
|
470
|
+
'http1.1',
|
|
471
|
+
'silent',
|
|
472
|
+
'show-error',
|
|
473
|
+
'fail',
|
|
474
|
+
'location',
|
|
475
|
+
'request = "POST"',
|
|
476
|
+
'connect-timeout = "30"',
|
|
477
|
+
limitRate ? `limit-rate = ${curlConfigValue(limitRate)}` : '',
|
|
478
|
+
`header = ${curlConfigValue(`Authorization: Bearer ${token}`)}`,
|
|
479
|
+
'header = "Accept: application/vnd.github+json"',
|
|
480
|
+
`header = ${curlConfigValue(`Content-Type: ${githubAssetContentType(filePath)}`)}`,
|
|
481
|
+
`data-binary = ${curlConfigValue(`@${filePath}`)}`,
|
|
482
|
+
`output = ${curlConfigValue(responsePath)}`,
|
|
483
|
+
'write-out = "%{http_code}\\n"',
|
|
484
|
+
`url = ${curlConfigValue(uploadUrl)}`,
|
|
485
|
+
'',
|
|
486
|
+
].filter(Boolean);
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
fs.writeFileSync(configPath, configLines.join('\n'), { mode: 0o600 });
|
|
490
|
+
const stdout = run('curl', ['--config', configPath], {
|
|
491
|
+
cwd,
|
|
492
|
+
env,
|
|
493
|
+
maxBuffer: 1024 * 1024,
|
|
494
|
+
});
|
|
495
|
+
const statusCode = cleanString(stdout).split(/\s+/).filter(Boolean).pop();
|
|
496
|
+
if (statusCode !== '201') {
|
|
497
|
+
const response = fs.existsSync(responsePath) ? fs.readFileSync(responsePath, 'utf8') : '';
|
|
498
|
+
throw new Error(`GitHub asset upload returned HTTP ${statusCode || '(missing)'} for ${assetName}: ${response}`);
|
|
499
|
+
}
|
|
500
|
+
} finally {
|
|
501
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function ensureCheckout({ dir, repoUrl, branch, ref }) {
|
|
506
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
507
|
+
if (!fs.existsSync(path.join(dir, '.git'))) {
|
|
508
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
509
|
+
run('git', ['clone', repoUrl, dir]);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
run('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: dir });
|
|
513
|
+
run('git', ['fetch', '--tags', 'origin', branch], { cwd: dir });
|
|
514
|
+
if (isFullSha(ref)) {
|
|
515
|
+
run('git', ['checkout', '--detach', ref], { cwd: dir });
|
|
516
|
+
} else {
|
|
517
|
+
run('git', ['checkout', '-B', branch, `origin/${branch}`], { cwd: dir });
|
|
518
|
+
}
|
|
519
|
+
run('git', ['reset', '--hard'], { cwd: dir });
|
|
520
|
+
run('git', ['clean', '-ffd', '-e', 'node_modules/'], { cwd: dir });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function resolveBuildRefs(request) {
|
|
524
|
+
const branch = request.branch;
|
|
525
|
+
return {
|
|
526
|
+
engineRef: request.repository === ENGINE_REPO_FULL_NAME ? request.after : branch,
|
|
527
|
+
uiRef: request.repository === UI_REPO_FULL_NAME ? request.after : branch,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function desktopVersion(packageVersion, lane, buildNumber) {
|
|
532
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/.exec(String(packageVersion || ''));
|
|
533
|
+
if (!match) throw new Error(`Unsupported package version for desktop build: ${packageVersion}`);
|
|
534
|
+
const major = Number(match[1]);
|
|
535
|
+
const minor = Number(match[2]);
|
|
536
|
+
const patch = Number(match[3]);
|
|
537
|
+
const runNumber = Number(buildNumber);
|
|
538
|
+
if (![major, minor, patch, runNumber].every(Number.isSafeInteger)) {
|
|
539
|
+
throw new Error(`Invalid desktop version inputs: ${packageVersion}, build ${buildNumber}`);
|
|
540
|
+
}
|
|
541
|
+
const desktopPatch = (patch * 1_000_000) + runNumber;
|
|
542
|
+
const base = `${major}.${minor}.${desktopPatch}`;
|
|
543
|
+
return lane === 'preview' ? `${base}-preview.1` : base;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildNumberFromNow(date = new Date()) {
|
|
547
|
+
const epoch = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
548
|
+
return Math.max(1, Math.floor((date.getTime() - epoch) / 1000));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function copyUiIntoEngine(uiDir, engineDir) {
|
|
552
|
+
const webDir = path.join(engineDir, 'web');
|
|
553
|
+
fs.rmSync(webDir, { recursive: true, force: true });
|
|
554
|
+
fs.mkdirSync(webDir, { recursive: true });
|
|
555
|
+
run('rsync', [
|
|
556
|
+
'-a',
|
|
557
|
+
'--delete',
|
|
558
|
+
'--exclude', '.git',
|
|
559
|
+
'--exclude', 'node_modules',
|
|
560
|
+
'--exclude', '.next',
|
|
561
|
+
`${uiDir}/`,
|
|
562
|
+
`${webDir}/`,
|
|
563
|
+
]);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function envForBuild(
|
|
567
|
+
baseEnv,
|
|
568
|
+
request,
|
|
569
|
+
sourcePackageVersion,
|
|
570
|
+
version,
|
|
571
|
+
refs,
|
|
572
|
+
shas,
|
|
573
|
+
appOrigin,
|
|
574
|
+
releaseRepository = desktopReleaseRepository(baseEnv),
|
|
575
|
+
publishTarget = desktopReleasePublishTarget(baseEnv, request.lane),
|
|
576
|
+
) {
|
|
577
|
+
const token = ghToken(baseEnv);
|
|
578
|
+
if (publishTarget.provider === 'github' && !token) {
|
|
579
|
+
throw new Error('Missing GitHub token. Set GH_TOKEN/GITHUB_TOKEN or run gh auth login on the builder Mac.');
|
|
580
|
+
}
|
|
581
|
+
const env = {
|
|
582
|
+
...baseEnv,
|
|
583
|
+
DESKTOP_RELEASE_LANE: request.lane,
|
|
584
|
+
DESKTOP_RELEASE_CHANNEL: request.lane === 'preview' ? 'preview' : 'latest',
|
|
585
|
+
AMALGM_RUNTIME_LABEL: request.lane === 'preview' ? 'preview' : 'main',
|
|
586
|
+
AMALGM_BRANCH: request.lane,
|
|
587
|
+
ELECTRON_VERSION: version,
|
|
588
|
+
AMALGM_DESKTOP_SOURCE_PACKAGE_VERSION: sourcePackageVersion,
|
|
589
|
+
AMALGM_DESKTOP_ENGINE_REF: refs.engineRef,
|
|
590
|
+
AMALGM_DESKTOP_UI_REF: refs.uiRef,
|
|
591
|
+
AMALGM_DESKTOP_ENGINE_SHA: shas.engineSha,
|
|
592
|
+
AMALGM_DESKTOP_UI_SHA: shas.uiSha,
|
|
593
|
+
AMALGM_DESKTOP_SOURCE_RUN_ID: request.delivery || `amalgm-automation-${Date.now()}`,
|
|
594
|
+
AMALGM_DESKTOP_APP_ORIGIN: appOrigin,
|
|
595
|
+
AMALGM_DESKTOP_RELEASE_PROVIDER: publishTarget.provider,
|
|
596
|
+
AMALGM_DESKTOP_UPDATE_PROVIDER: publishTarget.updateProvider || normalizeUpdateProviderForRelease(publishTarget.provider),
|
|
597
|
+
CSC_NAME: baseEnv.CSC_NAME || DEFAULT_CSC_NAME,
|
|
598
|
+
};
|
|
599
|
+
if (token) {
|
|
600
|
+
env.GH_TOKEN = token;
|
|
601
|
+
env.GITHUB_TOKEN = token;
|
|
602
|
+
}
|
|
603
|
+
if (publishTarget.provider === 'github') {
|
|
604
|
+
env.AMALGM_DESKTOP_UPDATE_OWNER = releaseRepository.owner;
|
|
605
|
+
env.AMALGM_DESKTOP_UPDATE_REPO = releaseRepository.repo;
|
|
606
|
+
env.AMALGM_DESKTOP_RELEASE_REPOSITORY = releaseRepository.fullName;
|
|
607
|
+
} else {
|
|
608
|
+
env.AMALGM_DESKTOP_UPDATE_BASE_URL = desktopUpdateBaseUrl(baseEnv);
|
|
609
|
+
env.AMALGM_DESKTOP_UPDATE_URL = publishTarget.updateUrl || desktopUpdateUrlForLane(request.lane, baseEnv);
|
|
610
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_METHOD = publishTarget.uploadMethod || normalizeUploadMethod(baseEnv.AMALGM_DESKTOP_RELEASE_UPLOAD_METHOD || '');
|
|
611
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_URL = publishTarget.uploadBaseUrl || desktopReleaseUploadBaseUrl(baseEnv);
|
|
612
|
+
if (cleanString(baseEnv.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN)) {
|
|
613
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN = cleanString(baseEnv.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN);
|
|
614
|
+
}
|
|
615
|
+
if (cleanString(baseEnv.AMALGM_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES)) {
|
|
616
|
+
env.AMALGM_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES = cleanString(baseEnv.AMALGM_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES);
|
|
617
|
+
}
|
|
618
|
+
env.AMALGM_DESKTOP_RELEASE_FLY_APP = publishTarget.flyApp || DEFAULT_DESKTOP_RELEASE_FLY_APP;
|
|
619
|
+
env.AMALGM_DESKTOP_RELEASE_FLY_ROOT = publishTarget.flyRoot || DEFAULT_DESKTOP_RELEASE_FLY_ROOT;
|
|
620
|
+
}
|
|
621
|
+
for (const key of ['CSC_LINK', 'CSC_KEY_PASSWORD']) {
|
|
622
|
+
if (!cleanString(env[key])) delete env[key];
|
|
623
|
+
}
|
|
624
|
+
return env;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function appOriginForLane(lane, env = process.env) {
|
|
628
|
+
if (lane === 'preview') {
|
|
629
|
+
return cleanString(env.AMALGM_DESKTOP_PREVIEW_APP_ORIGIN)
|
|
630
|
+
|| cleanString(env.NEXT_PUBLIC_PREVIEW_SITE_URL)
|
|
631
|
+
|| 'https://preview.amalgm.ai';
|
|
632
|
+
}
|
|
633
|
+
return cleanString(env.AMALGM_DESKTOP_MAIN_APP_ORIGIN)
|
|
634
|
+
|| cleanString(env.NEXT_PUBLIC_SITE_URL)
|
|
635
|
+
|| 'https://amalgm.ai';
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function writeBundledWebEnv(engineDir, lane, env = process.env) {
|
|
639
|
+
const supabaseUrl = cleanString(env.NEXT_PUBLIC_SUPABASE_URL);
|
|
640
|
+
const supabaseAnonKey = cleanString(env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
|
|
641
|
+
if (!supabaseUrl) throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL for bundled desktop web app.');
|
|
642
|
+
if (!supabaseAnonKey) throw new Error('Missing NEXT_PUBLIC_SUPABASE_ANON_KEY for bundled desktop web app.');
|
|
643
|
+
const appOrigin = appOriginForLane(lane, env);
|
|
644
|
+
fs.writeFileSync(path.join(engineDir, 'web', '.env'), [
|
|
645
|
+
`NEXT_PUBLIC_SUPABASE_URL=${supabaseUrl}`,
|
|
646
|
+
`NEXT_PUBLIC_SUPABASE_ANON_KEY=${supabaseAnonKey}`,
|
|
647
|
+
`NEXT_PUBLIC_SITE_URL=${appOrigin}`,
|
|
648
|
+
`NEXT_PUBLIC_APP_URL=${appOrigin}`,
|
|
649
|
+
`NEXT_PUBLIC_AMALGM_RUNTIME_LABEL=${lane === 'preview' ? 'preview' : 'main'}`,
|
|
650
|
+
'',
|
|
651
|
+
].join('\n'));
|
|
652
|
+
return appOrigin;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function latestReleaseBody(request, version, refs, shas, sourcePackageVersion) {
|
|
656
|
+
return [
|
|
657
|
+
`amalgm-desktop-lane: ${request.lane}`,
|
|
658
|
+
`amalgm-desktop-channel: ${request.lane === 'preview' ? 'preview' : 'latest'}`,
|
|
659
|
+
`amalgm-desktop-engine-ref: ${refs.engineRef}`,
|
|
660
|
+
`amalgm-desktop-engine-sha: ${shas.engineSha}`,
|
|
661
|
+
`amalgm-desktop-ui-ref: ${refs.uiRef}`,
|
|
662
|
+
`amalgm-desktop-ui-sha: ${shas.uiSha}`,
|
|
663
|
+
`amalgm-desktop-source-package-version: ${sourcePackageVersion}`,
|
|
664
|
+
`amalgm-desktop-source-run-id: ${request.delivery || `amalgm-automation-${version}`}`,
|
|
665
|
+
].join('\n');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function desktopReleaseChannelFile(lane) {
|
|
669
|
+
return `${lane === 'preview' ? 'preview' : 'latest'}-mac.yml`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function desktopReleaseArtifactPaths(buildDir, lane, version) {
|
|
673
|
+
const names = fs.readdirSync(buildDir);
|
|
674
|
+
const versionedArtifacts = names
|
|
675
|
+
.filter((name) => name.includes(version))
|
|
676
|
+
.filter((name) => (
|
|
677
|
+
name.endsWith('.dmg')
|
|
678
|
+
|| name.endsWith('.dmg.blockmap')
|
|
679
|
+
|| name.endsWith('.zip')
|
|
680
|
+
|| name.endsWith('.zip.blockmap')
|
|
681
|
+
))
|
|
682
|
+
.sort();
|
|
683
|
+
const channelFile = desktopReleaseChannelFile(lane);
|
|
684
|
+
const required = [
|
|
685
|
+
{ label: 'DMG', ok: versionedArtifacts.some((name) => name.endsWith('.dmg')) },
|
|
686
|
+
{ label: 'DMG blockmap', ok: versionedArtifacts.some((name) => name.endsWith('.dmg.blockmap')) },
|
|
687
|
+
{ label: 'zip', ok: versionedArtifacts.some((name) => name.endsWith('.zip')) },
|
|
688
|
+
{ label: 'zip blockmap', ok: versionedArtifacts.some((name) => name.endsWith('.zip.blockmap')) },
|
|
689
|
+
{ label: channelFile, ok: fs.existsSync(path.join(buildDir, channelFile)) },
|
|
690
|
+
];
|
|
691
|
+
const missing = required.filter((item) => !item.ok).map((item) => item.label);
|
|
692
|
+
if (missing.length > 0) {
|
|
693
|
+
throw new Error(`Missing desktop release artifact(s): ${missing.join(', ')}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return [
|
|
697
|
+
...versionedArtifacts.map((name) => path.join(buildDir, name)),
|
|
698
|
+
path.join(buildDir, channelFile),
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function githubReleaseId(tag, cwd, env, releaseRepository = desktopReleaseRepository(env)) {
|
|
703
|
+
try {
|
|
704
|
+
const publishedReleaseId = cleanString(run('gh', [
|
|
705
|
+
'api',
|
|
706
|
+
`repos/${releaseRepository.fullName}/releases/tags/${tag}`,
|
|
707
|
+
'--jq',
|
|
708
|
+
'.id',
|
|
709
|
+
], { cwd, env }));
|
|
710
|
+
if (publishedReleaseId) return publishedReleaseId;
|
|
711
|
+
} catch {
|
|
712
|
+
// Draft releases are not returned by the releases/tags endpoint.
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
return cleanString(run('gh', [
|
|
717
|
+
'api',
|
|
718
|
+
`repos/${releaseRepository.fullName}/releases`,
|
|
719
|
+
'--paginate',
|
|
720
|
+
'--jq',
|
|
721
|
+
`.[] | select(.tag_name == ${JSON.stringify(tag)}) | .id`,
|
|
722
|
+
], { cwd, env })).split(/\r?\n/).find(Boolean) || '';
|
|
723
|
+
} catch {
|
|
724
|
+
return '';
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function waitForGithubReleaseId(tag, cwd, env, releaseRepository = desktopReleaseRepository(env)) {
|
|
729
|
+
let releaseId = '';
|
|
730
|
+
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
731
|
+
releaseId = githubReleaseId(tag, cwd, env, releaseRepository);
|
|
732
|
+
if (releaseId) return releaseId;
|
|
733
|
+
sleepSync(Math.min(10_000, attempt * 2_000));
|
|
734
|
+
}
|
|
735
|
+
return releaseId;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function githubReleaseTargetCommitish(refs, releaseRepository) {
|
|
739
|
+
if (releaseRepository.fullName === ENGINE_REPO_FULL_NAME) return refs.engineRef;
|
|
740
|
+
return 'main';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function createGithubRelease({ tag, version, body, request, refs, cwd, env, releaseRepository }) {
|
|
744
|
+
const releaseId = cleanString(runWithRetry('gh', [
|
|
745
|
+
'api',
|
|
746
|
+
'-X',
|
|
747
|
+
'POST',
|
|
748
|
+
`repos/${releaseRepository.fullName}/releases`,
|
|
749
|
+
'-f',
|
|
750
|
+
`tag_name=${tag}`,
|
|
751
|
+
'-f',
|
|
752
|
+
`target_commitish=${githubReleaseTargetCommitish(refs, releaseRepository)}`,
|
|
753
|
+
'-f',
|
|
754
|
+
`name=${version}`,
|
|
755
|
+
'-f',
|
|
756
|
+
`body=${body}`,
|
|
757
|
+
'-F',
|
|
758
|
+
'draft=true',
|
|
759
|
+
'-F',
|
|
760
|
+
`prerelease=${request.lane === 'preview' ? 'true' : 'false'}`,
|
|
761
|
+
'-f',
|
|
762
|
+
'make_latest=false',
|
|
763
|
+
'--jq',
|
|
764
|
+
'.id',
|
|
765
|
+
], { cwd, env }));
|
|
766
|
+
if (releaseId) return releaseId;
|
|
767
|
+
return waitForGithubReleaseId(tag, cwd, env, releaseRepository);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function ensureGithubRelease({ tag, version, request, refs, shas, sourcePackageVersion, cwd, env, releaseRepository = desktopReleaseRepository(env), body: bodyOverride = '' }) {
|
|
771
|
+
const body = cleanString(bodyOverride) || latestReleaseBody(request, version, refs, shas, sourcePackageVersion);
|
|
772
|
+
let releaseId = githubReleaseId(tag, cwd, env, releaseRepository);
|
|
773
|
+
if (!releaseId) {
|
|
774
|
+
releaseId = createGithubRelease({ tag, version, body, request, refs, cwd, env, releaseRepository });
|
|
775
|
+
}
|
|
776
|
+
if (!releaseId) throw new Error(`Failed to resolve GitHub release id for ${tag}`);
|
|
777
|
+
|
|
778
|
+
const patchArgs = [
|
|
779
|
+
'api',
|
|
780
|
+
'-X',
|
|
781
|
+
'PATCH',
|
|
782
|
+
`repos/${releaseRepository.fullName}/releases/${releaseId}`,
|
|
783
|
+
'-f',
|
|
784
|
+
`tag_name=${tag}`,
|
|
785
|
+
'-f',
|
|
786
|
+
`target_commitish=${githubReleaseTargetCommitish(refs, releaseRepository)}`,
|
|
787
|
+
'-f',
|
|
788
|
+
`name=${version}`,
|
|
789
|
+
'-f',
|
|
790
|
+
`body=${body}`,
|
|
791
|
+
'-F',
|
|
792
|
+
`prerelease=${request.lane === 'preview' ? 'true' : 'false'}`,
|
|
793
|
+
'-F',
|
|
794
|
+
'draft=true',
|
|
795
|
+
];
|
|
796
|
+
run('gh', patchArgs, { cwd, env });
|
|
797
|
+
|
|
798
|
+
return releaseId;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function publishGithubRelease({ tag, request, cwd, env, releaseRepository = desktopReleaseRepository(env), releaseId: releaseIdOverride = '' }) {
|
|
802
|
+
const releaseId = cleanString(releaseIdOverride) || githubReleaseId(tag, cwd, env, releaseRepository);
|
|
803
|
+
if (!releaseId) throw new Error(`Failed to resolve GitHub release id for ${tag}`);
|
|
804
|
+
run('gh', [
|
|
805
|
+
'api',
|
|
806
|
+
'-X',
|
|
807
|
+
'PATCH',
|
|
808
|
+
`repos/${releaseRepository.fullName}/releases/${releaseId}`,
|
|
809
|
+
'-f',
|
|
810
|
+
`tag_name=${tag}`,
|
|
811
|
+
'-F',
|
|
812
|
+
'draft=false',
|
|
813
|
+
'-F',
|
|
814
|
+
`prerelease=${request.lane === 'preview' ? 'true' : 'false'}`,
|
|
815
|
+
'-f',
|
|
816
|
+
`make_latest=${request.lane === 'main' ? 'true' : 'false'}`,
|
|
817
|
+
], { cwd, env });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function uploadGithubReleaseAssets(tag, artifactPaths, cwd, env, releaseRepository = desktopReleaseRepository(env), releaseIdOverride = '') {
|
|
821
|
+
const releaseId = cleanString(releaseIdOverride) || githubReleaseId(tag, cwd, env, releaseRepository);
|
|
822
|
+
if (!releaseId) throw new Error(`Failed to resolve GitHub release id for ${tag}`);
|
|
823
|
+
const channelPath = artifactPaths.find((filePath) => filePath.endsWith('-mac.yml'));
|
|
824
|
+
const uploadPaths = [
|
|
825
|
+
...artifactPaths.filter((filePath) => filePath !== channelPath),
|
|
826
|
+
channelPath,
|
|
827
|
+
].filter(Boolean);
|
|
828
|
+
|
|
829
|
+
for (const filePath of uploadPaths) {
|
|
830
|
+
const assetName = path.basename(filePath);
|
|
831
|
+
let lastError = null;
|
|
832
|
+
const attempts = Number(env.AMALGM_DESKTOP_RELEASE_UPLOAD_ATTEMPTS) || 4;
|
|
833
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
834
|
+
try {
|
|
835
|
+
deleteGithubReleaseAssetsByName(releaseId, assetName, cwd, env, releaseRepository);
|
|
836
|
+
uploadGithubReleaseAssetWithCurl(releaseId, filePath, cwd, env, releaseRepository);
|
|
837
|
+
lastError = null;
|
|
838
|
+
break;
|
|
839
|
+
} catch (error) {
|
|
840
|
+
lastError = error;
|
|
841
|
+
if (attempt >= attempts) break;
|
|
842
|
+
const delayMs = Math.min(30_000, attempt * 10_000);
|
|
843
|
+
console.warn(`asset upload failed for ${assetName} on attempt ${attempt}; retrying in ${delayMs}ms.`);
|
|
844
|
+
sleepSync(delayMs);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (lastError) throw lastError;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function absoluteAssetUrl(updateBaseUrl, assetUrl) {
|
|
852
|
+
const raw = cleanString(assetUrl);
|
|
853
|
+
if (!raw) return raw;
|
|
854
|
+
const url = new URL(raw, normalizeHttpBaseUrl(updateBaseUrl, 'Desktop update URL'));
|
|
855
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
856
|
+
throw new Error(`Desktop release asset URL must be http(s), received: ${raw}`);
|
|
857
|
+
}
|
|
858
|
+
return url.toString();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function rewritePackageAssetReferences(packages, updateBaseUrl) {
|
|
862
|
+
if (!packages || typeof packages !== 'object' || Array.isArray(packages)) return;
|
|
863
|
+
for (const packageInfo of Object.values(packages)) {
|
|
864
|
+
if (!packageInfo || typeof packageInfo !== 'object' || Array.isArray(packageInfo)) continue;
|
|
865
|
+
for (const key of ['path', 'file', 'url']) {
|
|
866
|
+
if (typeof packageInfo[key] === 'string') {
|
|
867
|
+
packageInfo[key] = absoluteAssetUrl(updateBaseUrl, packageInfo[key]);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function bridgeChannelInfoForBase(channelText, updateBaseUrl) {
|
|
874
|
+
const channelInfo = yaml.load(String(channelText || ''));
|
|
875
|
+
if (!channelInfo || typeof channelInfo !== 'object' || Array.isArray(channelInfo)) {
|
|
876
|
+
throw new Error('Desktop release channel file is not a YAML object.');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (Array.isArray(channelInfo.files)) {
|
|
880
|
+
for (const file of channelInfo.files) {
|
|
881
|
+
if (!file || typeof file !== 'object' || Array.isArray(file)) continue;
|
|
882
|
+
if (typeof file.url === 'string') file.url = absoluteAssetUrl(updateBaseUrl, file.url);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (typeof channelInfo.path === 'string') {
|
|
886
|
+
channelInfo.path = absoluteAssetUrl(updateBaseUrl, channelInfo.path);
|
|
887
|
+
}
|
|
888
|
+
rewritePackageAssetReferences(channelInfo.packages, updateBaseUrl);
|
|
889
|
+
|
|
890
|
+
return yaml.dump(channelInfo, {
|
|
891
|
+
lineWidth: -1,
|
|
892
|
+
noRefs: true,
|
|
893
|
+
sortKeys: false,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function legacyGithubBridgeReleaseBody(request, version, refs, shas, sourcePackageVersion, publishTarget) {
|
|
898
|
+
return [
|
|
899
|
+
'amalgm-desktop-legacy-bridge: true',
|
|
900
|
+
`amalgm-desktop-lane: ${request.lane}`,
|
|
901
|
+
`amalgm-desktop-channel: ${request.lane === 'preview' ? 'preview' : 'latest'}`,
|
|
902
|
+
`amalgm-desktop-engine-ref: ${refs.engineRef}`,
|
|
903
|
+
`amalgm-desktop-engine-sha: ${shas.engineSha}`,
|
|
904
|
+
`amalgm-desktop-ui-ref: ${refs.uiRef}`,
|
|
905
|
+
`amalgm-desktop-ui-sha: ${shas.uiSha}`,
|
|
906
|
+
`amalgm-desktop-source-package-version: ${sourcePackageVersion}`,
|
|
907
|
+
`amalgm-desktop-source-run-id: ${request.delivery || `amalgm-automation-${version}`}`,
|
|
908
|
+
`amalgm-desktop-update-url: ${publishTarget.updateUrl || ''}`,
|
|
909
|
+
'',
|
|
910
|
+
'This release exists only for old macOS clients whose embedded updater still checks GitHub.',
|
|
911
|
+
'The channel YAML points those clients at the Fly-hosted desktop update feed.',
|
|
912
|
+
].join('\n');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function desktopLegacyBridgePlan(env, publishTarget) {
|
|
916
|
+
if (!publishTarget || publishTarget.provider === 'github') {
|
|
917
|
+
return { enabled: false, skipped: true, reason: 'Desktop release provider is GitHub.' };
|
|
918
|
+
}
|
|
919
|
+
if (!desktopLegacyBridgeEnabled(env)) {
|
|
920
|
+
return { enabled: false, skipped: true, reason: 'Legacy GitHub updater bridge disabled.' };
|
|
921
|
+
}
|
|
922
|
+
return {
|
|
923
|
+
enabled: true,
|
|
924
|
+
repository: desktopLegacyBridgeRepository(env).fullName,
|
|
925
|
+
channelFile: publishTarget.channelFile || '',
|
|
926
|
+
updateUrl: publishTarget.updateUrl || '',
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function publishLegacyGithubBridgeRelease({
|
|
931
|
+
tag,
|
|
932
|
+
version,
|
|
933
|
+
request,
|
|
934
|
+
refs,
|
|
935
|
+
shas,
|
|
936
|
+
sourcePackageVersion,
|
|
937
|
+
cwd,
|
|
938
|
+
env,
|
|
939
|
+
publishTarget,
|
|
940
|
+
channelPath,
|
|
941
|
+
}) {
|
|
942
|
+
const plan = desktopLegacyBridgePlan(env, publishTarget);
|
|
943
|
+
if (!plan.enabled) return plan;
|
|
944
|
+
if (!channelPath) throw new Error('Missing desktop release channel file for legacy GitHub updater bridge.');
|
|
945
|
+
|
|
946
|
+
const bridgeRepository = desktopLegacyBridgeRepository(env);
|
|
947
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-legacy-bridge-'));
|
|
948
|
+
const bridgeChannelPath = path.join(tempRoot, path.basename(channelPath));
|
|
949
|
+
try {
|
|
950
|
+
fs.writeFileSync(
|
|
951
|
+
bridgeChannelPath,
|
|
952
|
+
bridgeChannelInfoForBase(fs.readFileSync(channelPath, 'utf8'), publishTarget.updateUrl),
|
|
953
|
+
);
|
|
954
|
+
const body = legacyGithubBridgeReleaseBody(request, version, refs, shas, sourcePackageVersion, publishTarget);
|
|
955
|
+
const releaseId = ensureGithubRelease({
|
|
956
|
+
tag,
|
|
957
|
+
version,
|
|
958
|
+
request,
|
|
959
|
+
refs,
|
|
960
|
+
shas,
|
|
961
|
+
sourcePackageVersion,
|
|
962
|
+
cwd,
|
|
963
|
+
env,
|
|
964
|
+
releaseRepository: bridgeRepository,
|
|
965
|
+
body,
|
|
966
|
+
});
|
|
967
|
+
uploadGithubReleaseAssets(tag, [bridgeChannelPath], cwd, env, bridgeRepository, releaseId);
|
|
968
|
+
publishGithubRelease({ tag, request, cwd, env, releaseRepository: bridgeRepository, releaseId });
|
|
969
|
+
return {
|
|
970
|
+
enabled: true,
|
|
971
|
+
skipped: false,
|
|
972
|
+
repository: bridgeRepository.fullName,
|
|
973
|
+
releaseUrl: `https://github.com/${bridgeRepository.fullName}/releases/tag/${tag}`,
|
|
974
|
+
channelFile: path.basename(channelPath),
|
|
975
|
+
updateUrl: publishTarget.updateUrl || '',
|
|
976
|
+
};
|
|
977
|
+
} finally {
|
|
978
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function desktopReleaseMetadata({ tag, version, request, refs, shas, sourcePackageVersion, publishTarget, artifacts }) {
|
|
983
|
+
return {
|
|
984
|
+
provider: publishTarget.provider,
|
|
985
|
+
updateProvider: publishTarget.updateProvider,
|
|
986
|
+
updateUrl: publishTarget.updateUrl || '',
|
|
987
|
+
channelFile: desktopReleaseChannelFile(request.lane),
|
|
988
|
+
releaseUrl: publishTarget.channelUrl || '',
|
|
989
|
+
tag,
|
|
990
|
+
version,
|
|
991
|
+
lane: request.lane,
|
|
992
|
+
branch: request.branch,
|
|
993
|
+
repository: request.repository,
|
|
994
|
+
refs,
|
|
995
|
+
shas,
|
|
996
|
+
sourcePackageVersion,
|
|
997
|
+
sourceRunId: request.delivery || `amalgm-automation-${version}`,
|
|
998
|
+
artifacts: artifacts.map((filePath) => path.basename(filePath)),
|
|
999
|
+
publishedAt: new Date().toISOString(),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function desktopReleaseUploadChunkBytes(env = process.env) {
|
|
1004
|
+
const value = Number(env.AMALGM_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES) || DEFAULT_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES;
|
|
1005
|
+
return Math.max(64 * 1024, Math.min(value, 8 * 1024 * 1024));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function orderedReleaseUploadPaths(artifactPaths) {
|
|
1009
|
+
const channelPath = artifactPaths.find((filePath) => filePath.endsWith('-mac.yml'));
|
|
1010
|
+
return [
|
|
1011
|
+
...artifactPaths.filter((filePath) => filePath !== channelPath),
|
|
1012
|
+
channelPath,
|
|
1013
|
+
].filter(Boolean);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function uploadRequestWithCurl({ method, url, token, cwd, env, dataFile = '', dataText = '', expectedStatuses = [200] }) {
|
|
1017
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-release-http-upload-'));
|
|
1018
|
+
const configPath = path.join(tempRoot, 'curl.conf');
|
|
1019
|
+
const responsePath = path.join(tempRoot, 'response.json');
|
|
1020
|
+
const bodyPath = dataText ? path.join(tempRoot, 'body.json') : dataFile;
|
|
1021
|
+
const configLines = [
|
|
1022
|
+
'silent',
|
|
1023
|
+
'show-error',
|
|
1024
|
+
'location',
|
|
1025
|
+
`request = ${curlConfigValue(method)}`,
|
|
1026
|
+
'connect-timeout = "30"',
|
|
1027
|
+
`header = ${curlConfigValue(`x-upload-token: ${token}`)}`,
|
|
1028
|
+
dataText ? 'header = "content-type: application/json"' : '',
|
|
1029
|
+
dataFile ? 'header = "content-type: application/octet-stream"' : '',
|
|
1030
|
+
bodyPath ? `data-binary = ${curlConfigValue(`@${bodyPath}`)}` : '',
|
|
1031
|
+
`output = ${curlConfigValue(responsePath)}`,
|
|
1032
|
+
'write-out = "%{http_code}\\n"',
|
|
1033
|
+
`url = ${curlConfigValue(url)}`,
|
|
1034
|
+
'',
|
|
1035
|
+
].filter(Boolean);
|
|
1036
|
+
|
|
1037
|
+
try {
|
|
1038
|
+
if (dataText) fs.writeFileSync(bodyPath, dataText);
|
|
1039
|
+
fs.writeFileSync(configPath, configLines.join('\n'), { mode: 0o600 });
|
|
1040
|
+
const stdout = run('curl', ['--config', configPath], {
|
|
1041
|
+
cwd,
|
|
1042
|
+
env,
|
|
1043
|
+
maxBuffer: 1024 * 1024,
|
|
1044
|
+
});
|
|
1045
|
+
const statusCode = cleanString(stdout).split(/\s+/).filter(Boolean).pop();
|
|
1046
|
+
if (!expectedStatuses.map(String).includes(statusCode)) {
|
|
1047
|
+
const response = fs.existsSync(responsePath) ? fs.readFileSync(responsePath, 'utf8') : '';
|
|
1048
|
+
throw new Error(`Desktop release upload ${method} returned HTTP ${statusCode || '(missing)'} for ${url}: ${response.slice(0, 1000)}`);
|
|
1049
|
+
}
|
|
1050
|
+
return fs.existsSync(responsePath) ? fs.readFileSync(responsePath, 'utf8') : '';
|
|
1051
|
+
} finally {
|
|
1052
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function uploadRequestWithRetry(params, attempts = Number(params.env?.AMALGM_DESKTOP_RELEASE_UPLOAD_ATTEMPTS) || 4) {
|
|
1057
|
+
let lastError = null;
|
|
1058
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1059
|
+
try {
|
|
1060
|
+
return uploadRequestWithCurl(params);
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
lastError = error;
|
|
1063
|
+
if (attempt >= attempts) break;
|
|
1064
|
+
const delayMs = Math.min(15_000, attempt * 3_000);
|
|
1065
|
+
console.warn(`desktop release upload ${params.method} failed on attempt ${attempt}; retrying in ${delayMs}ms.`);
|
|
1066
|
+
sleepSync(delayMs);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
throw lastError;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function uploadHttpReleaseFile({ filePath, cwd, env, publishTarget }) {
|
|
1073
|
+
const token = cleanString(env.AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN);
|
|
1074
|
+
if (!token) throw new Error('Missing AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN for desktop release HTTP upload.');
|
|
1075
|
+
const lane = publishTarget.lane || 'main';
|
|
1076
|
+
const baseUrl = publishTarget.uploadBaseUrl || desktopReleaseUploadBaseUrl(env);
|
|
1077
|
+
const assetName = path.basename(filePath);
|
|
1078
|
+
const uploadUrl = new URL(`/__upload/${lane}/${encodeURIComponent(assetName)}`, baseUrl).toString();
|
|
1079
|
+
const finishUrl = new URL(`/__upload/${lane}/${encodeURIComponent(assetName)}/finish`, baseUrl).toString();
|
|
1080
|
+
const size = fs.statSync(filePath).size;
|
|
1081
|
+
const chunkBytes = desktopReleaseUploadChunkBytes(env);
|
|
1082
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-release-chunk-'));
|
|
1083
|
+
const chunkPath = path.join(tempRoot, 'chunk.bin');
|
|
1084
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
uploadRequestWithRetry({
|
|
1088
|
+
method: 'DELETE',
|
|
1089
|
+
url: uploadUrl,
|
|
1090
|
+
token,
|
|
1091
|
+
cwd,
|
|
1092
|
+
env,
|
|
1093
|
+
expectedStatuses: [200, 404],
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
let offset = 0;
|
|
1097
|
+
while (offset < size) {
|
|
1098
|
+
const length = Math.min(chunkBytes, size - offset);
|
|
1099
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
1100
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
1101
|
+
if (bytesRead <= 0) throw new Error(`Failed to read upload chunk for ${assetName} at offset ${offset}`);
|
|
1102
|
+
fs.writeFileSync(chunkPath, buffer.subarray(0, bytesRead));
|
|
1103
|
+
const chunkUrl = new URL(uploadUrl);
|
|
1104
|
+
chunkUrl.searchParams.set('offset', String(offset));
|
|
1105
|
+
uploadRequestWithRetry({
|
|
1106
|
+
method: 'PUT',
|
|
1107
|
+
url: chunkUrl.toString(),
|
|
1108
|
+
token,
|
|
1109
|
+
cwd,
|
|
1110
|
+
env,
|
|
1111
|
+
dataFile: chunkPath,
|
|
1112
|
+
});
|
|
1113
|
+
offset += bytesRead;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
uploadRequestWithRetry({
|
|
1117
|
+
method: 'POST',
|
|
1118
|
+
url: finishUrl,
|
|
1119
|
+
token,
|
|
1120
|
+
cwd,
|
|
1121
|
+
env,
|
|
1122
|
+
dataText: `${JSON.stringify({ size })}\n`,
|
|
1123
|
+
});
|
|
1124
|
+
} finally {
|
|
1125
|
+
fs.closeSync(fd);
|
|
1126
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function uploadHttpReleaseAssets({ artifactPaths, cwd, env, publishTarget, metadata }) {
|
|
1131
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-metadata-'));
|
|
1132
|
+
const metadataPath = path.join(tempRoot, 'desktop-release.json');
|
|
1133
|
+
try {
|
|
1134
|
+
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
1135
|
+
const ordered = orderedReleaseUploadPaths(artifactPaths);
|
|
1136
|
+
const uploadPaths = [
|
|
1137
|
+
...ordered.filter((filePath) => !filePath.endsWith('-mac.yml')),
|
|
1138
|
+
metadataPath,
|
|
1139
|
+
...ordered.filter((filePath) => filePath.endsWith('-mac.yml')),
|
|
1140
|
+
];
|
|
1141
|
+
for (const filePath of uploadPaths) {
|
|
1142
|
+
uploadHttpReleaseFile({ filePath, cwd, env, publishTarget });
|
|
1143
|
+
}
|
|
1144
|
+
} finally {
|
|
1145
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function uploadFlySftpReleaseAssets({ artifactPaths, cwd, env, publishTarget, metadata }) {
|
|
1150
|
+
if (!publishTarget.flyApp) throw new Error('Missing Fly app for desktop release upload.');
|
|
1151
|
+
if (!publishTarget.remoteDir) throw new Error('Missing Fly release remote directory.');
|
|
1152
|
+
const attempts = Number(env.AMALGM_DESKTOP_RELEASE_UPLOAD_ATTEMPTS) || 4;
|
|
1153
|
+
runWithRetry('fly', [
|
|
1154
|
+
'ssh',
|
|
1155
|
+
'console',
|
|
1156
|
+
'-a',
|
|
1157
|
+
publishTarget.flyApp,
|
|
1158
|
+
'-C',
|
|
1159
|
+
`mkdir -p ${shellQuote(publishTarget.remoteDir)}`,
|
|
1160
|
+
], { cwd, env }, attempts);
|
|
1161
|
+
|
|
1162
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-metadata-'));
|
|
1163
|
+
const metadataPath = path.join(tempRoot, 'desktop-release.json');
|
|
1164
|
+
const ordered = orderedReleaseUploadPaths(artifactPaths);
|
|
1165
|
+
const uploadPaths = [
|
|
1166
|
+
...ordered.filter((filePath) => !filePath.endsWith('-mac.yml')),
|
|
1167
|
+
metadataPath,
|
|
1168
|
+
...ordered.filter((filePath) => filePath.endsWith('-mac.yml')),
|
|
1169
|
+
];
|
|
1170
|
+
|
|
1171
|
+
try {
|
|
1172
|
+
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
1173
|
+
for (const filePath of uploadPaths) {
|
|
1174
|
+
const remotePath = joinRemotePath(publishTarget.remoteDir, path.basename(filePath));
|
|
1175
|
+
runWithRetry('fly', [
|
|
1176
|
+
'sftp',
|
|
1177
|
+
'put',
|
|
1178
|
+
'-a',
|
|
1179
|
+
publishTarget.flyApp,
|
|
1180
|
+
filePath,
|
|
1181
|
+
remotePath,
|
|
1182
|
+
], { cwd, env }, attempts);
|
|
1183
|
+
}
|
|
1184
|
+
} finally {
|
|
1185
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function uploadFlyReleaseAssets({ artifactPaths, cwd, env, publishTarget, metadata }) {
|
|
1190
|
+
if ((publishTarget.uploadMethod || 'http') === 'sftp') {
|
|
1191
|
+
return uploadFlySftpReleaseAssets({ artifactPaths, cwd, env, publishTarget, metadata });
|
|
1192
|
+
}
|
|
1193
|
+
return uploadHttpReleaseAssets({ artifactPaths, cwd, env, publishTarget, metadata });
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function publishDesktopRelease(request, options = {}) {
|
|
1197
|
+
applyDesktopReleaseEnv(process.env);
|
|
1198
|
+
const releaseEnv = desktopReleaseToolEnv(process.env);
|
|
1199
|
+
const decision = shouldHandleRequest(request);
|
|
1200
|
+
if (!decision.ok) {
|
|
1201
|
+
console.log(decision.reason);
|
|
1202
|
+
return { ok: true, skipped: true, reason: decision.reason };
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const checkoutRoot = cleanString(options.checkoutRoot || process.env.AMALGM_DESKTOP_RELEASE_CHECKOUT_ROOT)
|
|
1206
|
+
|| DEFAULT_CHECKOUT_ROOT;
|
|
1207
|
+
const laneRoot = path.join(checkoutRoot, request.lane);
|
|
1208
|
+
const engineDir = cleanString(process.env.AMALGM_DESKTOP_RELEASE_ENGINE_REPO_PATH)
|
|
1209
|
+
|| path.join(laneRoot, 'amalgm-engine');
|
|
1210
|
+
const uiDir = cleanString(process.env.AMALGM_DESKTOP_RELEASE_UI_REPO_PATH)
|
|
1211
|
+
|| path.join(laneRoot, 'amalgm-ui');
|
|
1212
|
+
const engineRepoUrl = cleanString(process.env.AMALGM_ENGINE_REPO_URL) || DEFAULT_ENGINE_REPO_URL;
|
|
1213
|
+
const uiRepoUrl = cleanString(process.env.AMALGM_UI_REPO_URL) || DEFAULT_UI_REPO_URL;
|
|
1214
|
+
const refs = resolveBuildRefs(request);
|
|
1215
|
+
const releaseRepository = desktopReleaseRepository(releaseEnv);
|
|
1216
|
+
const publishTarget = desktopReleasePublishTarget(releaseEnv, request.lane);
|
|
1217
|
+
|
|
1218
|
+
if (request.dryRun) {
|
|
1219
|
+
return {
|
|
1220
|
+
ok: true,
|
|
1221
|
+
dryRun: true,
|
|
1222
|
+
lane: request.lane,
|
|
1223
|
+
branch: request.branch,
|
|
1224
|
+
repository: request.repository,
|
|
1225
|
+
refs,
|
|
1226
|
+
engineDir,
|
|
1227
|
+
uiDir,
|
|
1228
|
+
releaseProvider: publishTarget.provider,
|
|
1229
|
+
updateProvider: publishTarget.updateProvider,
|
|
1230
|
+
updateUrl: publishTarget.updateUrl || '',
|
|
1231
|
+
releaseUrl: publishTarget.channelUrl || '',
|
|
1232
|
+
flyApp: publishTarget.flyApp || '',
|
|
1233
|
+
flyRemoteDir: publishTarget.remoteDir || '',
|
|
1234
|
+
releaseRepository: releaseRepository.fullName,
|
|
1235
|
+
legacyBridge: desktopLegacyBridgePlan(releaseEnv, publishTarget),
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
ensureCheckout({ dir: engineDir, repoUrl: engineRepoUrl, branch: request.branch, ref: refs.engineRef });
|
|
1240
|
+
ensureCheckout({ dir: uiDir, repoUrl: uiRepoUrl, branch: request.branch, ref: refs.uiRef });
|
|
1241
|
+
releaseEnv.AMALGM_DESKTOP_RELEASE_LOG_FILE = cleanString(releaseEnv.AMALGM_DESKTOP_RELEASE_LOG_FILE)
|
|
1242
|
+
|| path.join(engineDir, '.desktop-build', 'desktop-release-run.log');
|
|
1243
|
+
fs.mkdirSync(path.dirname(releaseEnv.AMALGM_DESKTOP_RELEASE_LOG_FILE), { recursive: true });
|
|
1244
|
+
fs.writeFileSync(releaseEnv.AMALGM_DESKTOP_RELEASE_LOG_FILE, [
|
|
1245
|
+
`amalgm desktop release log`,
|
|
1246
|
+
`startedAt=${new Date().toISOString()}`,
|
|
1247
|
+
`lane=${request.lane}`,
|
|
1248
|
+
`repository=${request.repository}`,
|
|
1249
|
+
`branch=${request.branch}`,
|
|
1250
|
+
`delivery=${request.delivery || ''}`,
|
|
1251
|
+
'',
|
|
1252
|
+
].join('\n'), { mode: 0o600 });
|
|
1253
|
+
|
|
1254
|
+
const sourcePackageVersion = readJsonFile(path.join(engineDir, 'package.json')).version;
|
|
1255
|
+
if (request.lane === 'preview' && !sourcePackageVersion.includes('-')) {
|
|
1256
|
+
throw new Error(`Preview desktop builds must come from a prerelease package version, received: ${sourcePackageVersion}`);
|
|
1257
|
+
}
|
|
1258
|
+
if (request.lane === 'main' && sourcePackageVersion.includes('-')) {
|
|
1259
|
+
throw new Error(`Main desktop builds must come from a stable package version, received: ${sourcePackageVersion}`);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const buildNumber = Number(process.env.AMALGM_DESKTOP_BUILD_NUMBER) || buildNumberFromNow();
|
|
1263
|
+
const version = desktopVersion(sourcePackageVersion, request.lane, buildNumber);
|
|
1264
|
+
const engineSha = cleanString(run('git', ['rev-parse', 'HEAD'], { cwd: engineDir }));
|
|
1265
|
+
const uiSha = cleanString(run('git', ['rev-parse', 'HEAD'], { cwd: uiDir }));
|
|
1266
|
+
const shas = { engineSha, uiSha };
|
|
1267
|
+
|
|
1268
|
+
run('npm', ['ci'], { cwd: engineDir, env: releaseEnv });
|
|
1269
|
+
run('git', ['tag', '-f', `v${version}`, engineSha], { cwd: engineDir });
|
|
1270
|
+
run('git', ['push', 'origin', `refs/tags/v${version}`, '--force'], { cwd: engineDir });
|
|
1271
|
+
run('npm', ['version', '--no-git-tag-version', '--allow-same-version', version], { cwd: engineDir, env: releaseEnv });
|
|
1272
|
+
run('npm', ['run', 'compile'], { cwd: engineDir, env: releaseEnv });
|
|
1273
|
+
run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
|
|
1274
|
+
copyUiIntoEngine(uiDir, engineDir);
|
|
1275
|
+
const appOrigin = writeBundledWebEnv(engineDir, request.lane, releaseEnv);
|
|
1276
|
+
run('npm', ['run', 'build:next'], { cwd: engineDir, env: releaseEnv });
|
|
1277
|
+
if (request.lane === 'preview') run('npm', ['run', 'assets:preview-icon'], { cwd: engineDir, env: releaseEnv });
|
|
1278
|
+
|
|
1279
|
+
const buildEnv = envForBuild(releaseEnv, request, sourcePackageVersion, version, refs, shas, appOrigin, releaseRepository, publishTarget);
|
|
1280
|
+
run('npm', ['run', 'electron-builder:config', '--', request.lane], { cwd: engineDir, env: buildEnv });
|
|
1281
|
+
fs.rmSync(path.join(engineDir, 'build'), { recursive: true, force: true });
|
|
1282
|
+
run(path.join(engineDir, 'node_modules', '.bin', 'electron-builder'), [
|
|
1283
|
+
'--mac',
|
|
1284
|
+
'--config',
|
|
1285
|
+
`.desktop-build/electron-builder.${request.lane}.json`,
|
|
1286
|
+
'--publish',
|
|
1287
|
+
'never',
|
|
1288
|
+
], { cwd: engineDir, env: buildEnv });
|
|
1289
|
+
|
|
1290
|
+
const tag = `v${version}`;
|
|
1291
|
+
const artifacts = desktopReleaseArtifactPaths(path.join(engineDir, 'build'), request.lane, version);
|
|
1292
|
+
const channelPath = artifacts.find((filePath) => filePath.endsWith('-mac.yml'));
|
|
1293
|
+
let releaseUrl = '';
|
|
1294
|
+
let legacyBridge = null;
|
|
1295
|
+
if (publishTarget.provider === 'github') {
|
|
1296
|
+
const releaseId = ensureGithubRelease({ tag, version, request, refs, shas, sourcePackageVersion, cwd: engineDir, env: buildEnv, releaseRepository });
|
|
1297
|
+
uploadGithubReleaseAssets(tag, artifacts, engineDir, buildEnv, releaseRepository, releaseId);
|
|
1298
|
+
publishGithubRelease({ tag, request, cwd: engineDir, env: buildEnv, releaseRepository, releaseId });
|
|
1299
|
+
releaseUrl = `https://github.com/${releaseRepository.fullName}/releases/tag/${tag}`;
|
|
1300
|
+
legacyBridge = desktopLegacyBridgePlan(buildEnv, publishTarget);
|
|
1301
|
+
} else {
|
|
1302
|
+
const metadata = desktopReleaseMetadata({ tag, version, request, refs, shas, sourcePackageVersion, publishTarget, artifacts });
|
|
1303
|
+
uploadFlyReleaseAssets({ artifactPaths: artifacts, cwd: engineDir, env: buildEnv, publishTarget, metadata });
|
|
1304
|
+
legacyBridge = publishLegacyGithubBridgeRelease({
|
|
1305
|
+
tag,
|
|
1306
|
+
version,
|
|
1307
|
+
request,
|
|
1308
|
+
refs,
|
|
1309
|
+
shas,
|
|
1310
|
+
sourcePackageVersion,
|
|
1311
|
+
cwd: engineDir,
|
|
1312
|
+
env: buildEnv,
|
|
1313
|
+
publishTarget,
|
|
1314
|
+
channelPath,
|
|
1315
|
+
});
|
|
1316
|
+
releaseUrl = publishTarget.channelUrl;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
return {
|
|
1320
|
+
ok: true,
|
|
1321
|
+
skipped: false,
|
|
1322
|
+
lane: request.lane,
|
|
1323
|
+
version,
|
|
1324
|
+
tag,
|
|
1325
|
+
releaseUrl,
|
|
1326
|
+
releaseProvider: publishTarget.provider,
|
|
1327
|
+
updateProvider: publishTarget.updateProvider,
|
|
1328
|
+
updateUrl: publishTarget.updateUrl || '',
|
|
1329
|
+
flyApp: publishTarget.flyApp || '',
|
|
1330
|
+
flyRemoteDir: publishTarget.remoteDir || '',
|
|
1331
|
+
releaseRepository: releaseRepository.fullName,
|
|
1332
|
+
legacyBridge,
|
|
1333
|
+
artifacts: artifacts.map((filePath) => path.basename(filePath)),
|
|
1334
|
+
repository: request.repository,
|
|
1335
|
+
branch: request.branch,
|
|
1336
|
+
refs,
|
|
1337
|
+
shas,
|
|
1338
|
+
sourcePackageVersion,
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function main() {
|
|
1343
|
+
applyDesktopReleaseEnv(process.env);
|
|
1344
|
+
const request = requestFromEnv(process.env);
|
|
1345
|
+
const result = publishDesktopRelease(request);
|
|
1346
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (require.main === module) {
|
|
1350
|
+
try {
|
|
1351
|
+
main();
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
console.error(error?.stack || error?.message || String(error));
|
|
1354
|
+
process.exit(1);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
module.exports = {
|
|
1359
|
+
absoluteAssetUrl,
|
|
1360
|
+
applyDesktopReleaseEnv,
|
|
1361
|
+
bridgeChannelInfoForBase,
|
|
1362
|
+
buildNumberFromNow,
|
|
1363
|
+
desktopLegacyBridgeEnabled,
|
|
1364
|
+
desktopLegacyBridgePlan,
|
|
1365
|
+
desktopLegacyBridgeRepository,
|
|
1366
|
+
desktopReleaseRepository,
|
|
1367
|
+
desktopReleaseProvider,
|
|
1368
|
+
desktopReleasePublishTarget,
|
|
1369
|
+
desktopReleaseToolEnv,
|
|
1370
|
+
desktopUpdateUrlForLane,
|
|
1371
|
+
desktopVersion,
|
|
1372
|
+
desktopReleaseArtifactPaths,
|
|
1373
|
+
envForBuild,
|
|
1374
|
+
parseEnvFile,
|
|
1375
|
+
publishLegacyGithubBridgeRelease,
|
|
1376
|
+
publishDesktopRelease,
|
|
1377
|
+
requestFromEnv,
|
|
1378
|
+
shouldHandleRequest,
|
|
1379
|
+
};
|