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.
@@ -0,0 +1,406 @@
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
+
8
+ const ENGINE_REPO_FULL_NAME = 'amalgm-inc/amalgm-engine';
9
+ const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
10
+ const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-npm-release');
11
+ const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'npm-release.env');
12
+
13
+ function cleanString(value) {
14
+ return typeof value === 'string' ? value.trim() : '';
15
+ }
16
+
17
+ function parseEnvFile(text) {
18
+ const env = {};
19
+ for (const line of String(text || '').split(/\r?\n/)) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed || trimmed.startsWith('#')) continue;
22
+ const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
23
+ if (!match) continue;
24
+ let value = match[2] || '';
25
+ const quote = value[0];
26
+ if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
27
+ value = value.slice(1, -1);
28
+ }
29
+ env[match[1]] = value;
30
+ }
31
+ return env;
32
+ }
33
+
34
+ function applyNpmReleaseEnv(env = process.env) {
35
+ const candidates = [
36
+ cleanString(env.AMALGM_NPM_RELEASE_ENV_FILE),
37
+ DEFAULT_ENV_FILE,
38
+ ].filter(Boolean);
39
+
40
+ for (const filePath of candidates) {
41
+ if (!fs.existsSync(filePath)) continue;
42
+ const values = parseEnvFile(fs.readFileSync(filePath, 'utf8'));
43
+ for (const [key, value] of Object.entries(values)) {
44
+ if (!cleanString(env[key])) env[key] = value;
45
+ }
46
+ }
47
+
48
+ return env;
49
+ }
50
+
51
+ function prependPath(env, dir) {
52
+ const cleanDir = cleanString(dir);
53
+ if (!cleanDir) return env.PATH || '';
54
+ const parts = String(env.PATH || '').split(path.delimiter).filter(Boolean);
55
+ return [cleanDir, ...parts.filter((part) => part !== cleanDir)].join(path.delimiter);
56
+ }
57
+
58
+ function npmReleaseToolEnv(baseEnv = process.env) {
59
+ const env = { ...baseEnv };
60
+ applyNpmReleaseEnv(env);
61
+ const nodeBin = cleanString(env.AMALGM_NPM_RELEASE_NODE_BIN);
62
+ if (nodeBin && fs.existsSync(nodeBin)) {
63
+ env.PATH = prependPath(env, path.dirname(nodeBin));
64
+ }
65
+ const pathPrefix = cleanString(env.AMALGM_NPM_RELEASE_PATH_PREFIX);
66
+ if (pathPrefix) env.PATH = prependPath(env, pathPrefix);
67
+ env.npm_config_python = cleanString(env.AMALGM_NPM_RELEASE_PYTHON)
68
+ || cleanString(env.npm_config_python)
69
+ || '/usr/bin/python3';
70
+ env.npm_config_cache = cleanString(env.AMALGM_NPM_RELEASE_NPM_CACHE)
71
+ || cleanString(env.npm_config_cache)
72
+ || path.join(os.tmpdir(), 'amalgm-npm-cache');
73
+ env.npm_config_prefer_online = 'true';
74
+ env.npm_config_audit = 'false';
75
+ env.npm_config_fund = 'false';
76
+ if (!cleanString(env.NODE_AUTH_TOKEN) && cleanString(env.NPM_TOKEN)) {
77
+ env.NODE_AUTH_TOKEN = cleanString(env.NPM_TOKEN);
78
+ }
79
+ if (!cleanString(env.NPM_TOKEN) && cleanString(env.NODE_AUTH_TOKEN)) {
80
+ env.NPM_TOKEN = cleanString(env.NODE_AUTH_TOKEN);
81
+ }
82
+ return env;
83
+ }
84
+
85
+ function isFullSha(value) {
86
+ return /^[a-f0-9]{40}$/i.test(cleanString(value));
87
+ }
88
+
89
+ function laneForBranch(branch) {
90
+ if (branch === 'preview') return 'preview';
91
+ if (branch === 'main') return 'main';
92
+ return null;
93
+ }
94
+
95
+ function parsePayloadFromEnv(env = process.env) {
96
+ const payloadFile = cleanString(env.AMALGM_NPM_RELEASE_PAYLOAD_FILE);
97
+ const payloadJson = payloadFile ? fs.readFileSync(payloadFile, 'utf8') : cleanString(env.AMALGM_NPM_RELEASE_PAYLOAD_JSON);
98
+ if (!payloadJson) return null;
99
+ return JSON.parse(payloadJson);
100
+ }
101
+
102
+ function requestFromEnv(env = process.env) {
103
+ const payload = parsePayloadFromEnv(env);
104
+ const repository = cleanString(
105
+ env.AMALGM_NPM_RELEASE_SOURCE_REPO
106
+ || payload?.repository?.full_name,
107
+ );
108
+ const ref = cleanString(env.AMALGM_NPM_RELEASE_REF || payload?.ref);
109
+ const branch = cleanString(
110
+ env.AMALGM_NPM_RELEASE_BRANCH
111
+ || (ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref),
112
+ );
113
+ const after = cleanString(env.AMALGM_NPM_RELEASE_AFTER || payload?.after);
114
+ const messages = [
115
+ payload?.head_commit?.message,
116
+ ...(Array.isArray(payload?.commits) ? payload.commits.map((commit) => commit?.message) : []),
117
+ ].map((message) => String(message || ''));
118
+
119
+ return {
120
+ payload,
121
+ repository,
122
+ ref: ref || (branch ? `refs/heads/${branch}` : ''),
123
+ branch,
124
+ after,
125
+ lane: laneForBranch(branch),
126
+ delivery: cleanString(env.AMALGM_NPM_RELEASE_DELIVERY),
127
+ messages,
128
+ dryRun: ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_NPM_RELEASE_DRY_RUN).toLowerCase()),
129
+ };
130
+ }
131
+
132
+ function shouldHandleRequest(request) {
133
+ if (request.repository !== ENGINE_REPO_FULL_NAME) {
134
+ return { ok: false, reason: `Ignored repository "${request.repository || '(missing)'}".` };
135
+ }
136
+ if (!request.lane) return { ok: false, reason: `Ignored branch "${request.branch || '(missing)'}".` };
137
+ if (!isFullSha(request.after)) return { ok: false, reason: 'Ignored push without a full after SHA.' };
138
+ if (/^0{40}$/.test(request.after)) return { ok: false, reason: 'Ignored branch deletion push.' };
139
+ if ((request.messages || []).some((message) => /\[(skip npm|skip runtime|skip ci|ci skip)\]/i.test(message))) {
140
+ return { ok: false, reason: 'Ignored push with npm/runtime skip marker.' };
141
+ }
142
+ return { ok: true };
143
+ }
144
+
145
+ function run(command, args, options = {}) {
146
+ const printable = [command, ...args].join(' ');
147
+ console.log(`$ ${printable}`);
148
+ const result = spawnSync(command, args, {
149
+ cwd: options.cwd,
150
+ env: options.env || process.env,
151
+ encoding: 'utf8',
152
+ maxBuffer: Number(options.maxBuffer) || 100 * 1024 * 1024,
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ });
155
+ if (result.stdout) process.stdout.write(result.stdout);
156
+ if (result.stderr) process.stderr.write(result.stderr);
157
+ if (result.error) throw result.error;
158
+ if (result.status !== 0) {
159
+ throw new Error(`${printable} failed with exit code ${result.status ?? result.signal ?? 'unknown'}`);
160
+ }
161
+ return result.stdout || '';
162
+ }
163
+
164
+ function sleepSync(ms) {
165
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
166
+ }
167
+
168
+ function runWithRetry(command, args, options = {}, attempts = 3) {
169
+ let lastError = null;
170
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
171
+ try {
172
+ return run(command, args, options);
173
+ } catch (error) {
174
+ lastError = error;
175
+ if (attempt >= attempts) break;
176
+ const delayMs = Math.min(15_000, attempt * 5_000);
177
+ console.warn(`${command} ${args[0] || ''} failed on attempt ${attempt}; retrying in ${delayMs}ms.`);
178
+ sleepSync(delayMs);
179
+ }
180
+ }
181
+ throw lastError;
182
+ }
183
+
184
+ function readJsonFile(filePath) {
185
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
186
+ }
187
+
188
+ function ensureCheckout({ dir, repoUrl, branch, ref }) {
189
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
190
+ if (!fs.existsSync(path.join(dir, '.git'))) {
191
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
192
+ run('git', ['clone', repoUrl, dir]);
193
+ }
194
+
195
+ run('git', ['remote', 'set-url', 'origin', repoUrl], { cwd: dir });
196
+ run('git', ['fetch', '--tags', 'origin', branch], { cwd: dir });
197
+ if (isFullSha(ref)) {
198
+ run('git', ['checkout', '--detach', ref], { cwd: dir });
199
+ } else {
200
+ run('git', ['checkout', '-B', branch, `origin/${branch}`], { cwd: dir });
201
+ }
202
+ run('git', ['reset', '--hard'], { cwd: dir });
203
+ run('git', ['clean', '-ffd', '-e', 'node_modules/'], { cwd: dir });
204
+ }
205
+
206
+ function timestampForVersion(date = new Date()) {
207
+ const pad = (value) => String(value).padStart(2, '0');
208
+ return [
209
+ date.getUTCFullYear(),
210
+ pad(date.getUTCMonth() + 1),
211
+ pad(date.getUTCDate()),
212
+ pad(date.getUTCHours()),
213
+ pad(date.getUTCMinutes()),
214
+ pad(date.getUTCSeconds()),
215
+ ].join('');
216
+ }
217
+
218
+ function baseVersion(rawVersion) {
219
+ const match = /^(\d+\.\d+\.\d+)(?:-.+)?$/.exec(String(rawVersion || '').trim());
220
+ if (!match) throw new Error(`Unsupported npm package version: ${rawVersion}`);
221
+ return match[1];
222
+ }
223
+
224
+ function npmReleasePlan(sourcePackageVersion, lane, options = {}) {
225
+ const sourceVersion = cleanString(sourcePackageVersion);
226
+ if (!sourceVersion) throw new Error('Missing source package version.');
227
+ if (lane === 'main') {
228
+ if (sourceVersion.includes('-')) {
229
+ throw new Error(`Main npm releases must use a stable package version, received: ${sourceVersion}`);
230
+ }
231
+ return {
232
+ lane,
233
+ version: sourceVersion,
234
+ publishTag: 'latest',
235
+ distTags: ['latest', 'main'],
236
+ sourcePackageVersion: sourceVersion,
237
+ };
238
+ }
239
+ if (lane === 'preview') {
240
+ const stamp = cleanString(options.timestamp) || timestampForVersion(options.date);
241
+ return {
242
+ lane,
243
+ version: `${baseVersion(sourceVersion)}-canary.${stamp}`,
244
+ publishTag: 'canary',
245
+ distTags: ['canary'],
246
+ sourcePackageVersion: sourceVersion,
247
+ };
248
+ }
249
+ throw new Error(`Unsupported npm release lane: ${lane}`);
250
+ }
251
+
252
+ function writePackageVersions(engineDir, version, env) {
253
+ run('npm', ['version', '--no-git-tag-version', '--allow-same-version', version], { cwd: engineDir, env });
254
+ run('npm', ['version', '--no-git-tag-version', '--allow-same-version', version], {
255
+ cwd: path.join(engineDir, 'packages', 'amalgm'),
256
+ env,
257
+ });
258
+ }
259
+
260
+ function npmAuthToken(env) {
261
+ return cleanString(env.NODE_AUTH_TOKEN) || cleanString(env.NPM_TOKEN);
262
+ }
263
+
264
+ function withProjectNpmrc(packageDir, env, fn) {
265
+ const token = npmAuthToken(env);
266
+ if (!token) return fn();
267
+
268
+ const npmrcPath = path.join(packageDir, '.npmrc');
269
+ const hadPrior = fs.existsSync(npmrcPath);
270
+ const prior = hadPrior ? fs.readFileSync(npmrcPath, 'utf8') : '';
271
+ fs.writeFileSync(npmrcPath, `//registry.npmjs.org/:_authToken=\${NODE_AUTH_TOKEN}\n`);
272
+ try {
273
+ return fn();
274
+ } finally {
275
+ if (hadPrior) fs.writeFileSync(npmrcPath, prior);
276
+ else fs.rmSync(npmrcPath, { force: true });
277
+ }
278
+ }
279
+
280
+ function npmPackageExists(packageDir, version, env) {
281
+ const result = spawnSync('npm', ['view', `amalgm@${version}`, 'version', '--json'], {
282
+ cwd: packageDir,
283
+ env,
284
+ encoding: 'utf8',
285
+ maxBuffer: 10 * 1024 * 1024,
286
+ stdio: ['ignore', 'pipe', 'pipe'],
287
+ });
288
+ if (result.status === 0) return true;
289
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
290
+ if (/E404|404 Not Found|is not in this registry/i.test(output)) return false;
291
+ throw new Error(`Could not check amalgm@${version}: ${output.trim() || `exit ${result.status}`}`);
292
+ }
293
+
294
+ function publishPackage(packageDir, plan, env) {
295
+ const exists = npmPackageExists(packageDir, plan.version, env);
296
+ let published = false;
297
+ if (exists) {
298
+ console.log(`amalgm@${plan.version} already exists; skipping npm publish.`);
299
+ } else {
300
+ runWithRetry('npm', ['publish', '--access', 'public', '--tag', plan.publishTag], { cwd: packageDir, env }, 3);
301
+ published = true;
302
+ }
303
+
304
+ for (const tag of plan.distTags) {
305
+ runWithRetry('npm', ['dist-tag', 'add', `amalgm@${plan.version}`, tag], { cwd: packageDir, env }, 3);
306
+ }
307
+
308
+ let currentDistTags = {};
309
+ try {
310
+ currentDistTags = JSON.parse(run('npm', ['view', 'amalgm', 'dist-tags', '--json'], { cwd: packageDir, env }));
311
+ } catch {
312
+ currentDistTags = {};
313
+ }
314
+
315
+ return { published, alreadyExists: exists, currentDistTags };
316
+ }
317
+
318
+ function publishNpmRelease(request, options = {}) {
319
+ applyNpmReleaseEnv(process.env);
320
+ const releaseEnv = npmReleaseToolEnv(process.env);
321
+ const decision = shouldHandleRequest(request);
322
+ if (!decision.ok) {
323
+ console.log(decision.reason);
324
+ return { ok: true, skipped: true, reason: decision.reason };
325
+ }
326
+
327
+ const sourcePackageVersion = cleanString(options.sourcePackageVersion || process.env.AMALGM_NPM_RELEASE_SOURCE_PACKAGE_VERSION);
328
+ if (request.dryRun) {
329
+ const plan = npmReleasePlan(sourcePackageVersion || (request.lane === 'main' ? '0.1.0' : '0.1.0-canary.0'), request.lane, options);
330
+ return {
331
+ ok: true,
332
+ dryRun: true,
333
+ lane: request.lane,
334
+ branch: request.branch,
335
+ repository: request.repository,
336
+ ref: request.after,
337
+ plan,
338
+ };
339
+ }
340
+
341
+ const checkoutRoot = cleanString(options.checkoutRoot || process.env.AMALGM_NPM_RELEASE_CHECKOUT_ROOT)
342
+ || DEFAULT_CHECKOUT_ROOT;
343
+ const laneRoot = path.join(checkoutRoot, request.lane);
344
+ const engineDir = cleanString(process.env.AMALGM_NPM_RELEASE_ENGINE_REPO_PATH)
345
+ || path.join(laneRoot, 'amalgm-engine');
346
+ const engineRepoUrl = cleanString(process.env.AMALGM_ENGINE_REPO_URL) || DEFAULT_ENGINE_REPO_URL;
347
+
348
+ ensureCheckout({ dir: engineDir, repoUrl: engineRepoUrl, branch: request.branch, ref: request.after });
349
+
350
+ const packageDir = path.join(engineDir, 'packages', 'amalgm');
351
+ const checkedOutVersion = readJsonFile(path.join(packageDir, 'package.json')).version;
352
+ const plan = npmReleasePlan(checkedOutVersion, request.lane);
353
+ const sha = cleanString(run('git', ['rev-parse', 'HEAD'], { cwd: engineDir }));
354
+
355
+ run('npm', ['ci'], { cwd: engineDir, env: releaseEnv });
356
+ writePackageVersions(engineDir, plan.version, releaseEnv);
357
+ run('npm', ['run', 'npm:sync'], { cwd: engineDir, env: releaseEnv });
358
+ run('npm', ['run', 'npm:check'], { cwd: engineDir, env: releaseEnv });
359
+
360
+ const publishResult = withProjectNpmrc(packageDir, releaseEnv, () => publishPackage(packageDir, plan, releaseEnv));
361
+
362
+ return {
363
+ ok: true,
364
+ skipped: false,
365
+ lane: request.lane,
366
+ branch: request.branch,
367
+ repository: request.repository,
368
+ ref: request.after,
369
+ sha,
370
+ version: plan.version,
371
+ sourcePackageVersion: checkedOutVersion,
372
+ publishTag: plan.publishTag,
373
+ distTags: plan.distTags,
374
+ published: publishResult.published,
375
+ alreadyExists: publishResult.alreadyExists,
376
+ currentDistTags: publishResult.currentDistTags,
377
+ };
378
+ }
379
+
380
+ function main() {
381
+ applyNpmReleaseEnv(process.env);
382
+ const request = requestFromEnv(process.env);
383
+ const result = publishNpmRelease(request);
384
+ console.log(JSON.stringify(result, null, 2));
385
+ }
386
+
387
+ if (require.main === module) {
388
+ try {
389
+ main();
390
+ } catch (error) {
391
+ console.error(error?.stack || error?.message || String(error));
392
+ process.exit(1);
393
+ }
394
+ }
395
+
396
+ module.exports = {
397
+ applyNpmReleaseEnv,
398
+ baseVersion,
399
+ npmReleasePlan,
400
+ npmReleaseToolEnv,
401
+ parseEnvFile,
402
+ publishNpmRelease,
403
+ requestFromEnv,
404
+ shouldHandleRequest,
405
+ timestampForVersion,
406
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-automation-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+ process.env.AMALGM_EVENTS_PUBLIC_URL = 'https://desktop-release.example/events';
12
+
13
+ const { closeLocalDb } = require('../state/db');
14
+ const { listAutomations, listTriggers } = require('../automations/store');
15
+
16
+ test.after(() => {
17
+ closeLocalDb();
18
+ fs.rmSync(tempRoot, { recursive: true, force: true });
19
+ });
20
+
21
+ test('automations store seeds the internal release webhook automation', () => {
22
+ const automation = listAutomations().find((item) => item.id === 'internal-amalgm-desktop-release');
23
+
24
+ assert.ok(automation);
25
+ assert.equal(automation.status, 'active');
26
+ assert.equal(automation.name, 'Run Amalgm release automation');
27
+ assert.deepEqual(automation.workflows.map((workflow) => workflow.id), [
28
+ 'internal-amalgm-desktop-release-workflow',
29
+ 'internal-amalgm-npm-release-workflow',
30
+ ]);
31
+ assert.equal(automation.workflows[0].status, 'active');
32
+ assert.equal(automation.workflows[1].status, 'active');
33
+ assert.equal(automation.workflows[1].name, 'Publish npm runtime release');
34
+ assert.match(automation.workflows[0].workflowText, /String\(secrets\.CSC_LINK \|\| ""\)\.trim\(\)/);
35
+ assert.doesNotMatch(automation.workflows[0].workflowText, /CSC_LINK: secrets\.CSC_LINK \|\| ""/);
36
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_RELEASE_PROVIDER/);
37
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_RELEASE_FLY_APP/);
38
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_RELEASE_UPLOAD_TOKEN/);
39
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_LEGACY_BRIDGE_REPOSITORY/);
40
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_UPDATE_BASE_URL/);
41
+ assert.match(automation.workflows[0].workflowText, /FLY_ACCESS_TOKEN/);
42
+ assert.match(automation.workflows[0].workflowText, /APPLE_API_KEY/);
43
+ assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_NOTARIZE/);
44
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('AMALGM_DESKTOP_RELEASE_FLY_ROOT'));
45
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('AMALGM_DESKTOP_RELEASE_UPLOAD_CHUNK_BYTES'));
46
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('AMALGM_DESKTOP_LEGACY_BRIDGE_REPOSITORY'));
47
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('AMALGM_DESKTOP_UPDATE_URL'));
48
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('APPLE_API_KEY'));
49
+ assert.ok(automation.workflows[0].allowlist.secrets.includes('AMALGM_DESKTOP_NOTARIZE'));
50
+
51
+ const trigger = listTriggers().find((item) => item.id === 'internal-amalgm-desktop-release-github-push');
52
+ assert.ok(trigger);
53
+ assert.equal(trigger.kind, 'event');
54
+ assert.equal(trigger.source, 'github');
55
+ assert.equal(trigger.event, 'push');
56
+ assert.equal(trigger.webhookUrl, 'https://desktop-release.example/events');
57
+ assert.match(trigger.secret, /^[a-f0-9]{32}$/);
58
+ });