amalgm 0.1.125 → 0.1.126
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 +2 -2
- package/runtime/scripts/amalgm-mcp/agent-bundles/bundles.js +30 -0
- package/runtime/scripts/amalgm-mcp/automations/internal-workflows.js +148 -1
- package/runtime/scripts/amalgm-mcp/events/pr-check-runner.js +344 -0
- package/runtime/scripts/amalgm-mcp/tests/agent-bundles.test.js +30 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-automation-seed.test.js +11 -0
- package/runtime/scripts/amalgm-mcp/tests/desktop-release-server.test.js +64 -0
- package/runtime/scripts/amalgm-mcp/tests/pr-check-runner.test.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amalgm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.126",
|
|
4
4
|
"description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
18
18
|
"prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
19
19
|
"pack:dry": "npm pack --dry-run",
|
|
20
|
-
"check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
|
|
20
|
+
"check": "node --check bin/amalgm.js && node --check lib/app-chat.js && node --check lib/react.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/amalgm-mcp/events/pr-check-runner.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20"
|
|
@@ -291,6 +291,35 @@ function externalMcpToolIdsForLoadout(toolIds, tools) {
|
|
|
291
291
|
.map((tool) => tool.id);
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
function agentEntryId(entry) {
|
|
295
|
+
if (!isObject(entry)) return '';
|
|
296
|
+
const sourceAgentId = typeof entry.sourceAgentId === 'string' ? entry.sourceAgentId.trim() : '';
|
|
297
|
+
if (sourceAgentId) return sourceAgentId;
|
|
298
|
+
return typeof entry.agent?.sourceAgentId === 'string' ? entry.agent.sourceAgentId.trim() : '';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function referencedSubagentIds(entry) {
|
|
302
|
+
if (!isObject(entry)) return [];
|
|
303
|
+
const subagents = Array.isArray(entry.config?.subagents) ? entry.config.subagents : [];
|
|
304
|
+
return subagents
|
|
305
|
+
.map((subagent) => (typeof subagent?.agentId === 'string' ? subagent.agentId.trim() : ''))
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function assertClosedAgentBundleGraph(bundle) {
|
|
310
|
+
const agentIds = new Set(bundle.agents.map(agentEntryId).filter(Boolean));
|
|
311
|
+
const missing = new Set();
|
|
312
|
+
for (const entry of bundle.agents) {
|
|
313
|
+
for (const agentId of referencedSubagentIds(entry)) {
|
|
314
|
+
if (!agentIds.has(agentId)) missing.add(agentId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (missing.size > 0) {
|
|
319
|
+
throw new Error(`Bundle is missing shared subagents: ${Array.from(missing).join(', ')}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
294
323
|
function validateBundle(bundle) {
|
|
295
324
|
if (!isObject(bundle)) throw new Error('bundle must be an object');
|
|
296
325
|
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Unsupported bundle kind: ${bundle.kind || 'unknown'}`);
|
|
@@ -300,6 +329,7 @@ function validateBundle(bundle) {
|
|
|
300
329
|
if (!Array.isArray(bundle.agents) || bundle.agents.length === 0) {
|
|
301
330
|
throw new Error('bundle must include at least one agent');
|
|
302
331
|
}
|
|
332
|
+
assertClosedAgentBundleGraph(bundle);
|
|
303
333
|
return bundle;
|
|
304
334
|
}
|
|
305
335
|
|
|
@@ -4,8 +4,10 @@ const path = require('path');
|
|
|
4
4
|
|
|
5
5
|
const DESKTOP_RELEASE_AUTOMATION_ID = 'internal-amalgm-desktop-release';
|
|
6
6
|
const DESKTOP_RELEASE_TRIGGER_ID = 'internal-amalgm-desktop-release-github-push';
|
|
7
|
+
const PR_CHECK_TRIGGER_ID = 'internal-amalgm-pr-check-github-pull-request';
|
|
7
8
|
const DESKTOP_RELEASE_WORKFLOW_ID = 'internal-amalgm-desktop-release-workflow';
|
|
8
9
|
const NPM_RELEASE_WORKFLOW_ID = 'internal-amalgm-npm-release-workflow';
|
|
10
|
+
const PR_CHECK_WORKFLOW_ID = 'internal-amalgm-pr-check-workflow';
|
|
9
11
|
|
|
10
12
|
function desktopReleaseRunnerPath() {
|
|
11
13
|
return path.resolve(__dirname, '../events/desktop-release-runner.js');
|
|
@@ -15,6 +17,10 @@ function npmReleaseRunnerPath() {
|
|
|
15
17
|
return path.resolve(__dirname, '../events/npm-release-runner.js');
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
function prCheckRunnerPath() {
|
|
21
|
+
return path.resolve(__dirname, '../events/pr-check-runner.js');
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
function shellQuote(value) {
|
|
19
25
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
20
26
|
}
|
|
@@ -339,11 +345,108 @@ function npmReleaseWorkflow(runnerPath = npmReleaseRunnerPath()) {
|
|
|
339
345
|
})`;
|
|
340
346
|
}
|
|
341
347
|
|
|
348
|
+
function prCheckWorkflow(runnerPath = prCheckRunnerPath()) {
|
|
349
|
+
const escapedRunnerCommand = JSON.stringify(`node ${shellQuote(runnerPath)}`);
|
|
350
|
+
const escapedRunnerDir = JSON.stringify(path.dirname(runnerPath));
|
|
351
|
+
return `export default workflow({
|
|
352
|
+
trigger: event("github.pull_request"),
|
|
353
|
+
|
|
354
|
+
allowlist: {
|
|
355
|
+
localCompute: true,
|
|
356
|
+
secrets: [
|
|
357
|
+
"GH_TOKEN",
|
|
358
|
+
"GITHUB_TOKEN",
|
|
359
|
+
"AMALGM_PR_CHECK_CHECKOUT_ROOT",
|
|
360
|
+
"AMALGM_PR_CHECK_ENV_FILE",
|
|
361
|
+
"AMALGM_PR_CHECK_NODE_BIN",
|
|
362
|
+
"AMALGM_PR_CHECK_PATH_PREFIX",
|
|
363
|
+
"AMALGM_PR_CHECK_PYTHON",
|
|
364
|
+
"AMALGM_PR_CHECK_NPM_CACHE",
|
|
365
|
+
"AMALGM_PR_CHECK_ENGINE_COMMAND",
|
|
366
|
+
"AMALGM_PR_CHECK_UI_COMMAND",
|
|
367
|
+
"AMALGM_PR_CHECK_STATUS_CONTEXT",
|
|
368
|
+
"AMALGM_ENGINE_REPO_URL",
|
|
369
|
+
"AMALGM_UI_REPO_URL"
|
|
370
|
+
]
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
limits: {
|
|
374
|
+
maxConcurrentRuns: 2,
|
|
375
|
+
queueLimit: 20,
|
|
376
|
+
cellTimeoutMs: 1800000,
|
|
377
|
+
codeTimeoutMs: 15000
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
cells: [
|
|
381
|
+
code("filter_pull_request", async ({ payload, headers, stop }) => {
|
|
382
|
+
const repository = String(payload?.repository?.full_name || "").trim()
|
|
383
|
+
const action = String(payload?.action || "").trim()
|
|
384
|
+
const pull = payload?.pull_request || {}
|
|
385
|
+
const baseBranch = String(pull?.base?.ref || "").trim()
|
|
386
|
+
const headSha = String(pull?.head?.sha || "").trim()
|
|
387
|
+
const headRef = String(pull?.head?.ref || "").trim()
|
|
388
|
+
const headRepository = String(pull?.head?.repo?.full_name || "").trim()
|
|
389
|
+
const number = Number(payload?.number || pull?.number || 0)
|
|
390
|
+
const allowedRepositories = ["amalgm-inc/amalgm-engine", "amalgm-inc/amalgm-ui"]
|
|
391
|
+
const allowedActions = ["opened", "synchronize", "reopened", "ready_for_review"]
|
|
392
|
+
|
|
393
|
+
if (!allowedRepositories.includes(repository)) stop()
|
|
394
|
+
if (!allowedActions.includes(action)) stop()
|
|
395
|
+
if (!["main", "preview"].includes(baseBranch)) stop()
|
|
396
|
+
if (!Number.isSafeInteger(number) || number <= 0) stop()
|
|
397
|
+
if (!/^[a-f0-9]{40}$/i.test(headSha)) stop()
|
|
398
|
+
if (pull?.draft) stop()
|
|
399
|
+
if (headRepository && headRepository !== repository) stop()
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
repository,
|
|
403
|
+
action,
|
|
404
|
+
number,
|
|
405
|
+
baseBranch,
|
|
406
|
+
headRef,
|
|
407
|
+
headRepository,
|
|
408
|
+
headSha,
|
|
409
|
+
delivery: String(headers?.["x-github-delivery"] || "")
|
|
410
|
+
}
|
|
411
|
+
}),
|
|
412
|
+
|
|
413
|
+
cli("run_pr_check", {
|
|
414
|
+
cwd: ${escapedRunnerDir},
|
|
415
|
+
command: ${escapedRunnerCommand},
|
|
416
|
+
env: ({ cells, secrets }) => ({
|
|
417
|
+
GH_TOKEN: secrets.GH_TOKEN || secrets.GITHUB_TOKEN || "",
|
|
418
|
+
GITHUB_TOKEN: secrets.GITHUB_TOKEN || secrets.GH_TOKEN || "",
|
|
419
|
+
AMALGM_PR_CHECK_CHECKOUT_ROOT: secrets.AMALGM_PR_CHECK_CHECKOUT_ROOT || "",
|
|
420
|
+
AMALGM_PR_CHECK_ENV_FILE: secrets.AMALGM_PR_CHECK_ENV_FILE || "",
|
|
421
|
+
AMALGM_PR_CHECK_NODE_BIN: secrets.AMALGM_PR_CHECK_NODE_BIN || "",
|
|
422
|
+
AMALGM_PR_CHECK_PATH_PREFIX: secrets.AMALGM_PR_CHECK_PATH_PREFIX || "",
|
|
423
|
+
AMALGM_PR_CHECK_PYTHON: secrets.AMALGM_PR_CHECK_PYTHON || "",
|
|
424
|
+
AMALGM_PR_CHECK_NPM_CACHE: secrets.AMALGM_PR_CHECK_NPM_CACHE || "",
|
|
425
|
+
AMALGM_PR_CHECK_ENGINE_COMMAND: secrets.AMALGM_PR_CHECK_ENGINE_COMMAND || "",
|
|
426
|
+
AMALGM_PR_CHECK_UI_COMMAND: secrets.AMALGM_PR_CHECK_UI_COMMAND || "",
|
|
427
|
+
AMALGM_PR_CHECK_STATUS_CONTEXT: secrets.AMALGM_PR_CHECK_STATUS_CONTEXT || "",
|
|
428
|
+
AMALGM_ENGINE_REPO_URL: secrets.AMALGM_ENGINE_REPO_URL || "",
|
|
429
|
+
AMALGM_UI_REPO_URL: secrets.AMALGM_UI_REPO_URL || "",
|
|
430
|
+
AMALGM_PR_CHECK_SOURCE_REPO: cells.filter_pull_request.output.repository,
|
|
431
|
+
AMALGM_PR_CHECK_ACTION: cells.filter_pull_request.output.action,
|
|
432
|
+
AMALGM_PR_CHECK_NUMBER: String(cells.filter_pull_request.output.number),
|
|
433
|
+
AMALGM_PR_CHECK_BASE_BRANCH: cells.filter_pull_request.output.baseBranch,
|
|
434
|
+
AMALGM_PR_CHECK_HEAD_REF: cells.filter_pull_request.output.headRef,
|
|
435
|
+
AMALGM_PR_CHECK_HEAD_REPO: cells.filter_pull_request.output.headRepository,
|
|
436
|
+
AMALGM_PR_CHECK_HEAD_SHA: cells.filter_pull_request.output.headSha,
|
|
437
|
+
AMALGM_PR_CHECK_DELIVERY: cells.filter_pull_request.output.delivery
|
|
438
|
+
}),
|
|
439
|
+
timeoutMs: 1800000
|
|
440
|
+
})
|
|
441
|
+
]
|
|
442
|
+
})`;
|
|
443
|
+
}
|
|
444
|
+
|
|
342
445
|
function desktopReleaseAutomationSeed() {
|
|
343
446
|
return {
|
|
344
447
|
id: DESKTOP_RELEASE_AUTOMATION_ID,
|
|
345
448
|
name: 'Run Amalgm release automation',
|
|
346
|
-
description: 'Internal workflow:
|
|
449
|
+
description: 'Internal workflow: run PR checks and publish npm/runtime releases from GitHub webhooks.',
|
|
347
450
|
enabled: true,
|
|
348
451
|
internal: true,
|
|
349
452
|
triggers: [{
|
|
@@ -355,6 +458,15 @@ function desktopReleaseAutomationSeed() {
|
|
|
355
458
|
event: 'push',
|
|
356
459
|
sourceUrl: 'https://github.com/amalgm-inc',
|
|
357
460
|
enabled: true,
|
|
461
|
+
}, {
|
|
462
|
+
id: PR_CHECK_TRIGGER_ID,
|
|
463
|
+
kind: 'event',
|
|
464
|
+
name: 'GitHub pull request',
|
|
465
|
+
description: 'GitHub pull_request webhook for amalgm-engine and amalgm-ui PR checks.',
|
|
466
|
+
source: 'github',
|
|
467
|
+
event: 'pull_request',
|
|
468
|
+
sourceUrl: 'https://github.com/amalgm-inc',
|
|
469
|
+
enabled: true,
|
|
358
470
|
}],
|
|
359
471
|
workflows: [{
|
|
360
472
|
id: DESKTOP_RELEASE_WORKFLOW_ID,
|
|
@@ -457,6 +569,37 @@ function desktopReleaseAutomationSeed() {
|
|
|
457
569
|
cellTimeoutMs: 1800000,
|
|
458
570
|
codeTimeoutMs: 15000,
|
|
459
571
|
},
|
|
572
|
+
}, {
|
|
573
|
+
id: PR_CHECK_WORKFLOW_ID,
|
|
574
|
+
name: 'Run PR checks',
|
|
575
|
+
description: 'Runs deterministic pre-merge checks for pull requests into main and preview.',
|
|
576
|
+
enabled: true,
|
|
577
|
+
internal: true,
|
|
578
|
+
workflowText: prCheckWorkflow(),
|
|
579
|
+
allowlist: {
|
|
580
|
+
localCompute: true,
|
|
581
|
+
secrets: [
|
|
582
|
+
'GH_TOKEN',
|
|
583
|
+
'GITHUB_TOKEN',
|
|
584
|
+
'AMALGM_PR_CHECK_CHECKOUT_ROOT',
|
|
585
|
+
'AMALGM_PR_CHECK_ENV_FILE',
|
|
586
|
+
'AMALGM_PR_CHECK_NODE_BIN',
|
|
587
|
+
'AMALGM_PR_CHECK_PATH_PREFIX',
|
|
588
|
+
'AMALGM_PR_CHECK_PYTHON',
|
|
589
|
+
'AMALGM_PR_CHECK_NPM_CACHE',
|
|
590
|
+
'AMALGM_PR_CHECK_ENGINE_COMMAND',
|
|
591
|
+
'AMALGM_PR_CHECK_UI_COMMAND',
|
|
592
|
+
'AMALGM_PR_CHECK_STATUS_CONTEXT',
|
|
593
|
+
'AMALGM_ENGINE_REPO_URL',
|
|
594
|
+
'AMALGM_UI_REPO_URL',
|
|
595
|
+
],
|
|
596
|
+
},
|
|
597
|
+
limits: {
|
|
598
|
+
maxConcurrentRuns: 2,
|
|
599
|
+
queueLimit: 20,
|
|
600
|
+
cellTimeoutMs: 1800000,
|
|
601
|
+
codeTimeoutMs: 15000,
|
|
602
|
+
},
|
|
460
603
|
}],
|
|
461
604
|
};
|
|
462
605
|
}
|
|
@@ -471,12 +614,16 @@ function internalAutomationSeeds() {
|
|
|
471
614
|
module.exports = {
|
|
472
615
|
DESKTOP_RELEASE_AUTOMATION_ID,
|
|
473
616
|
DESKTOP_RELEASE_TRIGGER_ID,
|
|
617
|
+
PR_CHECK_TRIGGER_ID,
|
|
474
618
|
DESKTOP_RELEASE_WORKFLOW_ID,
|
|
475
619
|
NPM_RELEASE_WORKFLOW_ID,
|
|
620
|
+
PR_CHECK_WORKFLOW_ID,
|
|
476
621
|
desktopReleaseAutomationSeed,
|
|
477
622
|
desktopReleaseRunnerPath,
|
|
478
623
|
desktopReleaseWorkflow,
|
|
479
624
|
internalAutomationSeeds,
|
|
480
625
|
npmReleaseRunnerPath,
|
|
481
626
|
npmReleaseWorkflow,
|
|
627
|
+
prCheckRunnerPath,
|
|
628
|
+
prCheckWorkflow,
|
|
482
629
|
};
|
|
@@ -0,0 +1,344 @@
|
|
|
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 UI_REPO_FULL_NAME = 'amalgm-inc/amalgm-ui';
|
|
10
|
+
const DEFAULT_ENGINE_REPO_URL = 'https://github.com/amalgm-inc/amalgm-engine.git';
|
|
11
|
+
const DEFAULT_UI_REPO_URL = 'https://github.com/amalgm-inc/amalgm-ui.git';
|
|
12
|
+
const DEFAULT_CHECKOUT_ROOT = path.join(os.homedir(), '.amalgm', 'workflow-checkouts', 'amalgm-pr-check');
|
|
13
|
+
const DEFAULT_ENV_FILE = path.join(os.homedir(), '.amalgm', 'pr-check.env');
|
|
14
|
+
const DEFAULT_STATUS_CONTEXT = 'amalgm/pr-check';
|
|
15
|
+
|
|
16
|
+
function cleanString(value) {
|
|
17
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseEnvFile(text) {
|
|
21
|
+
const env = {};
|
|
22
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
25
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
|
26
|
+
if (!match) continue;
|
|
27
|
+
let value = match[2] || '';
|
|
28
|
+
const quote = value[0];
|
|
29
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
|
30
|
+
value = value.slice(1, -1);
|
|
31
|
+
}
|
|
32
|
+
env[match[1]] = value;
|
|
33
|
+
}
|
|
34
|
+
return env;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applyPrCheckEnv(env = process.env) {
|
|
38
|
+
const candidates = [
|
|
39
|
+
cleanString(env.AMALGM_PR_CHECK_ENV_FILE),
|
|
40
|
+
DEFAULT_ENV_FILE,
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
|
|
43
|
+
for (const filePath of candidates) {
|
|
44
|
+
if (!fs.existsSync(filePath)) continue;
|
|
45
|
+
const values = parseEnvFile(fs.readFileSync(filePath, 'utf8'));
|
|
46
|
+
for (const [key, value] of Object.entries(values)) {
|
|
47
|
+
if (!cleanString(env[key])) env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return env;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function prependPath(env, dir) {
|
|
55
|
+
const cleanDir = cleanString(dir);
|
|
56
|
+
if (!cleanDir) return env.PATH || '';
|
|
57
|
+
const parts = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
58
|
+
return [cleanDir, ...parts.filter((part) => part !== cleanDir)].join(path.delimiter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prCheckToolEnv(baseEnv = process.env) {
|
|
62
|
+
const env = { ...baseEnv };
|
|
63
|
+
applyPrCheckEnv(env);
|
|
64
|
+
const nodeBin = cleanString(env.AMALGM_PR_CHECK_NODE_BIN);
|
|
65
|
+
if (nodeBin && fs.existsSync(nodeBin)) {
|
|
66
|
+
env.PATH = prependPath(env, path.dirname(nodeBin));
|
|
67
|
+
}
|
|
68
|
+
const pathPrefix = cleanString(env.AMALGM_PR_CHECK_PATH_PREFIX);
|
|
69
|
+
if (pathPrefix) env.PATH = prependPath(env, pathPrefix);
|
|
70
|
+
env.npm_config_python = cleanString(env.AMALGM_PR_CHECK_PYTHON)
|
|
71
|
+
|| cleanString(env.npm_config_python)
|
|
72
|
+
|| '/usr/bin/python3';
|
|
73
|
+
env.npm_config_cache = cleanString(env.AMALGM_PR_CHECK_NPM_CACHE)
|
|
74
|
+
|| cleanString(env.npm_config_cache)
|
|
75
|
+
|| path.join(os.tmpdir(), 'amalgm-pr-check-npm-cache');
|
|
76
|
+
env.npm_config_prefer_online = 'true';
|
|
77
|
+
env.npm_config_audit = 'false';
|
|
78
|
+
env.npm_config_fund = 'false';
|
|
79
|
+
if (!cleanString(env.GH_TOKEN) && cleanString(env.GITHUB_TOKEN)) {
|
|
80
|
+
env.GH_TOKEN = cleanString(env.GITHUB_TOKEN);
|
|
81
|
+
}
|
|
82
|
+
if (!cleanString(env.GITHUB_TOKEN) && cleanString(env.GH_TOKEN)) {
|
|
83
|
+
env.GITHUB_TOKEN = cleanString(env.GH_TOKEN);
|
|
84
|
+
}
|
|
85
|
+
return env;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parsePayloadFromEnv(env = process.env) {
|
|
89
|
+
const payloadFile = cleanString(env.AMALGM_PR_CHECK_PAYLOAD_FILE);
|
|
90
|
+
const payloadJson = payloadFile ? fs.readFileSync(payloadFile, 'utf8') : cleanString(env.AMALGM_PR_CHECK_PAYLOAD_JSON);
|
|
91
|
+
if (!payloadJson) return null;
|
|
92
|
+
return JSON.parse(payloadJson);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requestFromPayload(payload = {}, env = process.env) {
|
|
96
|
+
const pull = payload.pull_request || {};
|
|
97
|
+
const base = pull.base || {};
|
|
98
|
+
const head = pull.head || {};
|
|
99
|
+
const repository = cleanString(env.AMALGM_PR_CHECK_SOURCE_REPO || payload.repository?.full_name);
|
|
100
|
+
const baseBranch = cleanString(env.AMALGM_PR_CHECK_BASE_BRANCH || base.ref);
|
|
101
|
+
const headSha = cleanString(env.AMALGM_PR_CHECK_HEAD_SHA || head.sha);
|
|
102
|
+
const headRef = cleanString(env.AMALGM_PR_CHECK_HEAD_REF || head.ref);
|
|
103
|
+
const headRepository = cleanString(env.AMALGM_PR_CHECK_HEAD_REPO || head.repo?.full_name);
|
|
104
|
+
const number = Number(env.AMALGM_PR_CHECK_NUMBER || payload.number || pull.number || 0);
|
|
105
|
+
return {
|
|
106
|
+
payload,
|
|
107
|
+
action: cleanString(env.AMALGM_PR_CHECK_ACTION || payload.action),
|
|
108
|
+
repository,
|
|
109
|
+
number,
|
|
110
|
+
baseBranch,
|
|
111
|
+
headRef,
|
|
112
|
+
headSha,
|
|
113
|
+
headRepository,
|
|
114
|
+
draft: env.AMALGM_PR_CHECK_DRAFT
|
|
115
|
+
? ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_PR_CHECK_DRAFT).toLowerCase())
|
|
116
|
+
: Boolean(pull.draft),
|
|
117
|
+
delivery: cleanString(env.AMALGM_PR_CHECK_DELIVERY),
|
|
118
|
+
dryRun: ['1', 'true', 'yes', 'on'].includes(cleanString(env.AMALGM_PR_CHECK_DRY_RUN).toLowerCase()),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function requestFromEnv(env = process.env) {
|
|
123
|
+
return requestFromPayload(parsePayloadFromEnv(env) || {}, env);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isFullSha(value) {
|
|
127
|
+
return /^[a-f0-9]{40}$/i.test(cleanString(value));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function repoShortName(fullName) {
|
|
131
|
+
return cleanString(fullName).split('/').pop() || 'repo';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function repoUrlForRequest(request, env = process.env) {
|
|
135
|
+
if (request.repository === ENGINE_REPO_FULL_NAME) {
|
|
136
|
+
return cleanString(env.AMALGM_ENGINE_REPO_URL) || DEFAULT_ENGINE_REPO_URL;
|
|
137
|
+
}
|
|
138
|
+
if (request.repository === UI_REPO_FULL_NAME) {
|
|
139
|
+
return cleanString(env.AMALGM_UI_REPO_URL) || DEFAULT_UI_REPO_URL;
|
|
140
|
+
}
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shouldHandleRequest(request) {
|
|
145
|
+
const allowedActions = new Set(['opened', 'synchronize', 'reopened', 'ready_for_review']);
|
|
146
|
+
if (![ENGINE_REPO_FULL_NAME, UI_REPO_FULL_NAME].includes(request.repository)) {
|
|
147
|
+
return { ok: false, reason: `Ignored repository "${request.repository || '(missing)'}".` };
|
|
148
|
+
}
|
|
149
|
+
if (!allowedActions.has(request.action)) {
|
|
150
|
+
return { ok: false, reason: `Ignored pull_request action "${request.action || '(missing)'}".` };
|
|
151
|
+
}
|
|
152
|
+
if (!['main', 'preview'].includes(request.baseBranch)) {
|
|
153
|
+
return { ok: false, reason: `Ignored base branch "${request.baseBranch || '(missing)'}".` };
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isSafeInteger(request.number) || request.number <= 0) {
|
|
156
|
+
return { ok: false, reason: 'Ignored pull request without a valid number.' };
|
|
157
|
+
}
|
|
158
|
+
if (!isFullSha(request.headSha)) {
|
|
159
|
+
return { ok: false, reason: 'Ignored pull request without a full head SHA.' };
|
|
160
|
+
}
|
|
161
|
+
if (request.draft) {
|
|
162
|
+
return { ok: false, reason: 'Ignored draft pull request.' };
|
|
163
|
+
}
|
|
164
|
+
if (request.headRepository && request.headRepository !== request.repository) {
|
|
165
|
+
return { ok: false, reason: `Ignored forked pull request from "${request.headRepository}".` };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function commandForRequest(request, env = process.env) {
|
|
171
|
+
if (request.repository === ENGINE_REPO_FULL_NAME) {
|
|
172
|
+
return cleanString(env.AMALGM_PR_CHECK_ENGINE_COMMAND) || 'npm ci && npm run ci:pr';
|
|
173
|
+
}
|
|
174
|
+
if (request.repository === UI_REPO_FULL_NAME) {
|
|
175
|
+
return cleanString(env.AMALGM_PR_CHECK_UI_COMMAND) || 'npm ci && npm run ci:pr';
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`Unsupported PR check repository: ${request.repository}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function run(command, args, options = {}) {
|
|
181
|
+
const printable = [command, ...args].join(' ');
|
|
182
|
+
console.log(`$ ${printable}`);
|
|
183
|
+
const result = spawnSync(command, args, {
|
|
184
|
+
cwd: options.cwd,
|
|
185
|
+
env: options.env || process.env,
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
maxBuffer: Number(options.maxBuffer) || 200 * 1024 * 1024,
|
|
188
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
189
|
+
});
|
|
190
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
191
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
192
|
+
if (result.error) throw result.error;
|
|
193
|
+
if (result.status !== 0) {
|
|
194
|
+
const error = new Error(`${printable} failed with exit code ${result.status ?? result.signal ?? 'unknown'}`);
|
|
195
|
+
error.stdout = result.stdout || '';
|
|
196
|
+
error.stderr = result.stderr || '';
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
return result.stdout || '';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function runShell(command, options = {}) {
|
|
203
|
+
return run('/bin/sh', ['-lc', command], options);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function checkoutPullRequest(request, env = process.env) {
|
|
207
|
+
const checkoutRoot = cleanString(env.AMALGM_PR_CHECK_CHECKOUT_ROOT) || DEFAULT_CHECKOUT_ROOT;
|
|
208
|
+
const checkoutDir = path.join(checkoutRoot, repoShortName(request.repository), String(request.number));
|
|
209
|
+
const repoUrl = repoUrlForRequest(request, env);
|
|
210
|
+
if (!repoUrl) throw new Error(`No clone URL configured for ${request.repository}`);
|
|
211
|
+
|
|
212
|
+
fs.rmSync(checkoutDir, { recursive: true, force: true });
|
|
213
|
+
fs.mkdirSync(path.dirname(checkoutDir), { recursive: true });
|
|
214
|
+
run('git', ['clone', '--no-checkout', repoUrl, checkoutDir], { env });
|
|
215
|
+
run('git', [
|
|
216
|
+
'fetch',
|
|
217
|
+
'--no-tags',
|
|
218
|
+
'origin',
|
|
219
|
+
`+refs/pull/${request.number}/head:refs/remotes/origin/pr-${request.number}`,
|
|
220
|
+
], { cwd: checkoutDir, env });
|
|
221
|
+
const fetchedSha = cleanString(run('git', ['rev-parse', `refs/remotes/origin/pr-${request.number}`], { cwd: checkoutDir, env }));
|
|
222
|
+
if (fetchedSha !== request.headSha) {
|
|
223
|
+
throw new Error(`Fetched PR head ${fetchedSha} did not match webhook head ${request.headSha}`);
|
|
224
|
+
}
|
|
225
|
+
run('git', ['checkout', '--detach', request.headSha], { cwd: checkoutDir, env });
|
|
226
|
+
return checkoutDir;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function ghToken(env = process.env) {
|
|
230
|
+
return cleanString(env.GH_TOKEN) || cleanString(env.GITHUB_TOKEN);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function truncateDescription(value) {
|
|
234
|
+
const text = cleanString(value).replace(/\s+/g, ' ');
|
|
235
|
+
return text.length > 140 ? `${text.slice(0, 137)}...` : text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function statusPayload(request, state, description, env = process.env) {
|
|
239
|
+
const context = cleanString(env.AMALGM_PR_CHECK_STATUS_CONTEXT) || DEFAULT_STATUS_CONTEXT;
|
|
240
|
+
const targetUrl = cleanString(env.AMALGM_PR_CHECK_TARGET_URL)
|
|
241
|
+
|| `https://github.com/${request.repository}/pull/${request.number}`;
|
|
242
|
+
return {
|
|
243
|
+
state,
|
|
244
|
+
context,
|
|
245
|
+
description: truncateDescription(description),
|
|
246
|
+
targetUrl,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function postGithubStatus(request, state, description, env = process.env) {
|
|
251
|
+
const token = ghToken(env);
|
|
252
|
+
if (!token) {
|
|
253
|
+
console.warn('Missing GH_TOKEN/GITHUB_TOKEN; skipping GitHub status update.');
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
const payload = statusPayload(request, state, description, env);
|
|
257
|
+
const args = [
|
|
258
|
+
'api',
|
|
259
|
+
'-X',
|
|
260
|
+
'POST',
|
|
261
|
+
`repos/${request.repository}/statuses/${request.headSha}`,
|
|
262
|
+
'-f',
|
|
263
|
+
`state=${payload.state}`,
|
|
264
|
+
'-f',
|
|
265
|
+
`context=${payload.context}`,
|
|
266
|
+
'-f',
|
|
267
|
+
`description=${payload.description}`,
|
|
268
|
+
'-f',
|
|
269
|
+
`target_url=${payload.targetUrl}`,
|
|
270
|
+
];
|
|
271
|
+
run('gh', args, { env: { ...env, GH_TOKEN: token, GITHUB_TOKEN: token } });
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function runPrCheck(request, options = {}) {
|
|
276
|
+
applyPrCheckEnv(process.env);
|
|
277
|
+
const checkEnv = prCheckToolEnv(process.env);
|
|
278
|
+
const decision = shouldHandleRequest(request);
|
|
279
|
+
if (!decision.ok) {
|
|
280
|
+
console.log(decision.reason);
|
|
281
|
+
return { ok: true, skipped: true, reason: decision.reason };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const command = commandForRequest(request, checkEnv);
|
|
285
|
+
if (request.dryRun || options.dryRun) {
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
dryRun: true,
|
|
289
|
+
repository: request.repository,
|
|
290
|
+
number: request.number,
|
|
291
|
+
baseBranch: request.baseBranch,
|
|
292
|
+
headSha: request.headSha,
|
|
293
|
+
command,
|
|
294
|
+
status: statusPayload(request, 'pending', 'PR check would run.', checkEnv),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
postGithubStatus(request, 'pending', 'Amalgm PR check is running.', checkEnv);
|
|
299
|
+
const checkoutDir = checkoutPullRequest(request, checkEnv);
|
|
300
|
+
try {
|
|
301
|
+
runShell(command, { cwd: checkoutDir, env: checkEnv });
|
|
302
|
+
postGithubStatus(request, 'success', 'Amalgm PR check passed.', checkEnv);
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
skipped: false,
|
|
306
|
+
repository: request.repository,
|
|
307
|
+
number: request.number,
|
|
308
|
+
baseBranch: request.baseBranch,
|
|
309
|
+
headSha: request.headSha,
|
|
310
|
+
checkoutDir,
|
|
311
|
+
command,
|
|
312
|
+
};
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const detail = cleanString(error.stderr) || cleanString(error.stdout) || error.message;
|
|
315
|
+
postGithubStatus(request, 'failure', `Amalgm PR check failed: ${detail}`, checkEnv);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function main() {
|
|
321
|
+
const request = requestFromEnv(process.env);
|
|
322
|
+
const result = runPrCheck(request);
|
|
323
|
+
console.log(JSON.stringify(result, null, 2));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (require.main === module) {
|
|
327
|
+
try {
|
|
328
|
+
main();
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error(error.stack || error.message || error);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
module.exports = {
|
|
336
|
+
ENGINE_REPO_FULL_NAME,
|
|
337
|
+
UI_REPO_FULL_NAME,
|
|
338
|
+
commandForRequest,
|
|
339
|
+
requestFromEnv,
|
|
340
|
+
requestFromPayload,
|
|
341
|
+
runPrCheck,
|
|
342
|
+
shouldHandleRequest,
|
|
343
|
+
statusPayload,
|
|
344
|
+
};
|
|
@@ -135,3 +135,33 @@ test('installing an agent bundle creates local copies and rewrites subagent refs
|
|
|
135
135
|
assert.notEqual(installedRootConfig.subagents[0].agentId, child.id);
|
|
136
136
|
assert.equal(Boolean(resolveAgent(installedRootConfig.subagents[0].agentId)), true);
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
test('installing an agent bundle rejects missing shared subagents', () => {
|
|
140
|
+
assert.throws(
|
|
141
|
+
() => installAgentBundle({
|
|
142
|
+
kind: 'amalgm.agent.bundle',
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
rootAgentId: 'missing-child-root',
|
|
145
|
+
title: 'Missing child root',
|
|
146
|
+
agents: [{
|
|
147
|
+
sourceAgentId: 'missing-child-root',
|
|
148
|
+
agent: {
|
|
149
|
+
name: 'Missing child root',
|
|
150
|
+
baseHarnessId: 'codex',
|
|
151
|
+
baseModelId: 'openai/gpt-5.5',
|
|
152
|
+
},
|
|
153
|
+
config: {
|
|
154
|
+
instructions: 'Root',
|
|
155
|
+
loadout: { toolIds: [] },
|
|
156
|
+
subagents: [{
|
|
157
|
+
id: 'missing-child-ref',
|
|
158
|
+
agentId: 'missing-child',
|
|
159
|
+
name: 'Missing child',
|
|
160
|
+
enabled: true,
|
|
161
|
+
}],
|
|
162
|
+
},
|
|
163
|
+
}],
|
|
164
|
+
}),
|
|
165
|
+
/missing shared subagents: missing-child/,
|
|
166
|
+
);
|
|
167
|
+
});
|
|
@@ -27,10 +27,13 @@ test('automations store seeds the internal release webhook automation', () => {
|
|
|
27
27
|
assert.deepEqual(automation.workflows.map((workflow) => workflow.id), [
|
|
28
28
|
'internal-amalgm-desktop-release-workflow',
|
|
29
29
|
'internal-amalgm-npm-release-workflow',
|
|
30
|
+
'internal-amalgm-pr-check-workflow',
|
|
30
31
|
]);
|
|
31
32
|
assert.equal(automation.workflows[0].status, 'active');
|
|
32
33
|
assert.equal(automation.workflows[1].status, 'active');
|
|
34
|
+
assert.equal(automation.workflows[2].status, 'active');
|
|
33
35
|
assert.equal(automation.workflows[1].name, 'Publish npm runtime release');
|
|
36
|
+
assert.equal(automation.workflows[2].name, 'Run PR checks');
|
|
34
37
|
assert.match(automation.workflows[0].workflowText, /String\(secrets\.CSC_LINK \|\| ""\)\.trim\(\)/);
|
|
35
38
|
assert.doesNotMatch(automation.workflows[0].workflowText, /CSC_LINK: secrets\.CSC_LINK \|\| ""/);
|
|
36
39
|
assert.match(automation.workflows[0].workflowText, /AMALGM_DESKTOP_RELEASE_PROVIDER/);
|
|
@@ -55,4 +58,12 @@ test('automations store seeds the internal release webhook automation', () => {
|
|
|
55
58
|
assert.equal(trigger.event, 'push');
|
|
56
59
|
assert.equal(trigger.webhookUrl, 'https://desktop-release.example/events');
|
|
57
60
|
assert.match(trigger.secret, /^[a-f0-9]{32}$/);
|
|
61
|
+
|
|
62
|
+
const prTrigger = listTriggers().find((item) => item.id === 'internal-amalgm-pr-check-github-pull-request');
|
|
63
|
+
assert.ok(prTrigger);
|
|
64
|
+
assert.equal(prTrigger.kind, 'event');
|
|
65
|
+
assert.equal(prTrigger.source, 'github');
|
|
66
|
+
assert.equal(prTrigger.event, 'pull_request');
|
|
67
|
+
assert.equal(prTrigger.webhookUrl, 'https://desktop-release.example/events');
|
|
68
|
+
assert.match(prTrigger.secret, /^[a-f0-9]{32}$/);
|
|
58
69
|
});
|
|
@@ -10,10 +10,12 @@ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-desktop-release-s
|
|
|
10
10
|
process.env.DATA_ROOT = tempRoot;
|
|
11
11
|
|
|
12
12
|
const {
|
|
13
|
+
cleanupReleaseLane,
|
|
13
14
|
createServer,
|
|
14
15
|
latestArtifactName,
|
|
15
16
|
latestDownloadTarget,
|
|
16
17
|
parseDownloadPath,
|
|
18
|
+
releaseKeyForArtifact,
|
|
17
19
|
} = require(path.resolve(__dirname, '../../../../infra/desktop-release-server/server.js'));
|
|
18
20
|
|
|
19
21
|
test.after(() => {
|
|
@@ -26,6 +28,29 @@ function writeReleaseMetadata(lane, artifacts) {
|
|
|
26
28
|
fs.writeFileSync(path.join(laneDir, 'desktop-release.json'), `${JSON.stringify({ lane, artifacts }, null, 2)}\n`);
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
function writeLaneFile(lane, fileName, mtimeOffsetMs = 0) {
|
|
32
|
+
const laneDir = path.join(tempRoot, lane);
|
|
33
|
+
fs.mkdirSync(laneDir, { recursive: true });
|
|
34
|
+
const filePath = path.join(laneDir, fileName);
|
|
35
|
+
fs.writeFileSync(filePath, fileName);
|
|
36
|
+
const when = new Date(Date.now() + mtimeOffsetMs);
|
|
37
|
+
fs.utimesSync(filePath, when, when);
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeReleaseFiles(lane, version, mtimeOffsetMs = 0) {
|
|
42
|
+
const prefix = lane === 'preview' ? 'amalgm-preview' : 'amalgm';
|
|
43
|
+
const zipSuffix = lane === 'preview' ? 'arm64.zip' : 'arm64-mac.zip';
|
|
44
|
+
const files = [
|
|
45
|
+
`${prefix}-${version}-${zipSuffix}`,
|
|
46
|
+
`${prefix}-${version}-${zipSuffix}.blockmap`,
|
|
47
|
+
`${prefix}-${version}-arm64.dmg`,
|
|
48
|
+
`${prefix}-${version}-arm64.dmg.blockmap`,
|
|
49
|
+
];
|
|
50
|
+
for (const fileName of files) writeLaneFile(lane, fileName, mtimeOffsetMs);
|
|
51
|
+
return files;
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
test('desktop release server resolves stable bridge download targets from metadata', () => {
|
|
30
55
|
const artifacts = [
|
|
31
56
|
'amalgm-0.1.1-arm64-mac.zip',
|
|
@@ -47,6 +72,45 @@ test('desktop release server resolves stable bridge download targets from metada
|
|
|
47
72
|
});
|
|
48
73
|
});
|
|
49
74
|
|
|
75
|
+
test('desktop release server groups versioned release artifacts', () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
releaseKeyForArtifact('amalgm-0.1.139881837-arm64-mac.zip.blockmap'),
|
|
78
|
+
'amalgm@0.1.139881837',
|
|
79
|
+
);
|
|
80
|
+
assert.equal(
|
|
81
|
+
releaseKeyForArtifact('amalgm-preview-0.1.139882282-preview.1-arm64.dmg'),
|
|
82
|
+
'amalgm-preview@0.1.139882282-preview.1',
|
|
83
|
+
);
|
|
84
|
+
assert.equal(releaseKeyForArtifact('latest-mac.yml'), '');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('desktop release server prunes stale lane artifacts after publish', () => {
|
|
88
|
+
const oldFiles = writeReleaseFiles('main', '0.1.100', -40_000);
|
|
89
|
+
const previousFiles = writeReleaseFiles('main', '0.1.101', -30_000);
|
|
90
|
+
const newestFiles = writeReleaseFiles('main', '0.1.102', -10_000);
|
|
91
|
+
const currentFiles = writeReleaseFiles('main', '0.1.099', -50_000);
|
|
92
|
+
writeLaneFile('main', 'latest-mac.yml', -5_000);
|
|
93
|
+
writeLaneFile('main', 'notes.txt', -5_000);
|
|
94
|
+
writeReleaseMetadata('main', [...currentFiles, 'latest-mac.yml']);
|
|
95
|
+
|
|
96
|
+
const uploadDir = path.join(tempRoot, '.uploads', 'main');
|
|
97
|
+
fs.mkdirSync(uploadDir, { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(uploadDir, 'amalgm-0.1.103-arm64-mac.zip.part'), 'partial');
|
|
99
|
+
|
|
100
|
+
const result = cleanupReleaseLane('main', { keepReleaseCount: 1 });
|
|
101
|
+
|
|
102
|
+
assert.equal(result.ok, true);
|
|
103
|
+
assert.equal(result.uploadsDeleted, 1);
|
|
104
|
+
for (const fileName of oldFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), false);
|
|
105
|
+
for (const fileName of previousFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), false);
|
|
106
|
+
for (const fileName of newestFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), true);
|
|
107
|
+
for (const fileName of currentFiles) assert.equal(fs.existsSync(path.join(tempRoot, 'main', fileName)), true);
|
|
108
|
+
assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'latest-mac.yml')), true);
|
|
109
|
+
assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'desktop-release.json')), true);
|
|
110
|
+
assert.equal(fs.existsSync(path.join(tempRoot, 'main', 'notes.txt')), true);
|
|
111
|
+
assert.equal(fs.readdirSync(uploadDir).length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
50
114
|
test('desktop release server redirects public bridge download URLs to current assets', async () => {
|
|
51
115
|
writeReleaseMetadata('preview', [
|
|
52
116
|
'amalgm-preview-0.1.1-preview.1-arm64.zip',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
ENGINE_REPO_FULL_NAME,
|
|
8
|
+
UI_REPO_FULL_NAME,
|
|
9
|
+
commandForRequest,
|
|
10
|
+
requestFromPayload,
|
|
11
|
+
shouldHandleRequest,
|
|
12
|
+
statusPayload,
|
|
13
|
+
} = require('../events/pr-check-runner');
|
|
14
|
+
|
|
15
|
+
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678';
|
|
16
|
+
|
|
17
|
+
function payload(overrides = {}) {
|
|
18
|
+
const repository = overrides.repository || ENGINE_REPO_FULL_NAME;
|
|
19
|
+
return {
|
|
20
|
+
action: overrides.action || 'synchronize',
|
|
21
|
+
number: overrides.number || 42,
|
|
22
|
+
repository: { full_name: repository },
|
|
23
|
+
pull_request: {
|
|
24
|
+
number: overrides.number || 42,
|
|
25
|
+
draft: Boolean(overrides.draft),
|
|
26
|
+
base: { ref: overrides.baseBranch || 'main' },
|
|
27
|
+
head: {
|
|
28
|
+
ref: overrides.headRef || 'feature/pr-check',
|
|
29
|
+
sha: overrides.headSha || HEAD_SHA,
|
|
30
|
+
repo: { full_name: overrides.headRepository || repository },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('requestFromPayload normalizes a GitHub pull request payload', () => {
|
|
37
|
+
const request = requestFromPayload(payload({ repository: UI_REPO_FULL_NAME, baseBranch: 'preview' }));
|
|
38
|
+
|
|
39
|
+
assert.equal(request.repository, UI_REPO_FULL_NAME);
|
|
40
|
+
assert.equal(request.action, 'synchronize');
|
|
41
|
+
assert.equal(request.number, 42);
|
|
42
|
+
assert.equal(request.baseBranch, 'preview');
|
|
43
|
+
assert.equal(request.headSha, HEAD_SHA);
|
|
44
|
+
assert.equal(request.headRepository, UI_REPO_FULL_NAME);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('shouldHandleRequest accepts same-repo PRs into main or preview', () => {
|
|
48
|
+
const request = requestFromPayload(payload({ repository: ENGINE_REPO_FULL_NAME, baseBranch: 'main' }));
|
|
49
|
+
assert.deepEqual(shouldHandleRequest(request), { ok: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('shouldHandleRequest rejects unsupported PR shapes', () => {
|
|
53
|
+
assert.match(
|
|
54
|
+
shouldHandleRequest(requestFromPayload(payload({ baseBranch: 'feature' }))).reason,
|
|
55
|
+
/base branch/,
|
|
56
|
+
);
|
|
57
|
+
assert.match(
|
|
58
|
+
shouldHandleRequest(requestFromPayload(payload({ draft: true }))).reason,
|
|
59
|
+
/draft/,
|
|
60
|
+
);
|
|
61
|
+
assert.match(
|
|
62
|
+
shouldHandleRequest(requestFromPayload(payload({ headRepository: 'someone/fork' }))).reason,
|
|
63
|
+
/forked/,
|
|
64
|
+
);
|
|
65
|
+
assert.match(
|
|
66
|
+
shouldHandleRequest(requestFromPayload(payload({ action: 'closed' }))).reason,
|
|
67
|
+
/action/,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('commandForRequest returns the repo-specific default command', () => {
|
|
72
|
+
assert.equal(
|
|
73
|
+
commandForRequest(requestFromPayload(payload({ repository: ENGINE_REPO_FULL_NAME })), {}),
|
|
74
|
+
'npm ci && npm run ci:pr',
|
|
75
|
+
);
|
|
76
|
+
assert.equal(
|
|
77
|
+
commandForRequest(requestFromPayload(payload({ repository: UI_REPO_FULL_NAME })), {}),
|
|
78
|
+
'npm ci && npm run ci:pr',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('statusPayload uses the stable Amalgm PR check context', () => {
|
|
83
|
+
const request = requestFromPayload(payload({ repository: UI_REPO_FULL_NAME }));
|
|
84
|
+
const status = statusPayload(request, 'pending', 'Running the Amalgm PR check.', {});
|
|
85
|
+
|
|
86
|
+
assert.equal(status.state, 'pending');
|
|
87
|
+
assert.equal(status.context, 'amalgm/pr-check');
|
|
88
|
+
assert.equal(status.targetUrl, 'https://github.com/amalgm-inc/amalgm-ui/pull/42');
|
|
89
|
+
});
|