From ed54f88785b1f799b865cd5b7c6352074dfd35fa Mon Sep 17 00:00:00 2001 From: bjw9808 Date: Mon, 15 Jun 2026 20:04:29 +0800 Subject: [PATCH 1/2] build: add v2 release asset uploader --- package.json | 1 + scripts/upload_v2_release_assets.js | 264 ++++++++++++++++++++++++++++ test/uploadV2ReleaseAssets.test.js | 133 ++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 scripts/upload_v2_release_assets.js create mode 100644 test/uploadV2ReleaseAssets.test.js diff --git a/package.json b/package.json index e079afc3..8de5d6bc 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "a2a:export": "node scripts/a2a_export.js", "a2a:ingest": "node scripts/a2a_ingest.js", "a2a:promote": "node scripts/a2a_promote.js", + "release:v2-assets": "node scripts/upload_v2_release_assets.js", "test": "node -e \"const fs=require('fs'),cp=require('child_process');const all=fs.readdirSync('test').filter(f=>f.endsWith('.test.js'));const iso=new Set(['solidifyIntegration.test.js']);const others=all.filter(f=>!iso.has(f)).map(f=>'test/'+f);const isoFiles=all.filter(f=>iso.has(f)).map(f=>'test/'+f);if(others.length)cp.execSync('node --test '+others.join(' '),{stdio:'inherit'});if(isoFiles.length)cp.execSync('node --test '+isoFiles.join(' '),{stdio:'inherit'})\"" }, "engines": { diff --git a/scripts/upload_v2_release_assets.js b/scripts/upload_v2_release_assets.js new file mode 100644 index 00000000..44095502 --- /dev/null +++ b/scripts/upload_v2_release_assets.js @@ -0,0 +1,264 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Upload Evolver v2 standalone binaries to the public EvoMap/evolver release. + * + * The v2 binaries are built in the private v2 repo and copied here only as + * release artifacts. This script deliberately does not use shell globs: it + * verifies the exact expected filenames and SHA256 sidecars before invoking + * `gh release upload`. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const PUBLIC_REPO = 'EvoMap/evolver'; +const DEFAULT_ASSET_DIR = 'dist-binaries'; + +const V2_BINARIES = [ + 'evolver-v2-darwin-arm64', + 'evolver-v2-darwin-x64', + 'evolver-v2-linux-x64', + 'evolver-v2-linux-arm64', + 'evolver-v2-windows-x64.exe', +]; + +const V2_MANIFEST = 'SHA256SUMS-v2.txt'; +const TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const SHA256_LINE_RE = /^([a-f0-9]{64}) ([^/\\\s]+)$/; + +function usage() { + return [ + 'Usage:', + ' node scripts/upload_v2_release_assets.js --tag=v1.89.11 --asset-dir=/path/to/v2/dist-binaries', + ' node scripts/upload_v2_release_assets.js --tag=v1.89.11 --asset-dir=/path/to/v2/dist-binaries --yes', + '', + 'Default mode validates and prints the gh command without uploading.', + 'Use --yes to upload to EvoMap/evolver GitHub Release assets.', + '', + 'Options:', + ' --tag= GitHub release tag, for example v1.89.11', + ` --asset-dir= Directory containing v2 assets (default: ${DEFAULT_ASSET_DIR})`, + ' --yes Actually run gh release upload after validation', + ' --dry-run Validate and print the gh command without uploading', + ' --clobber Replace existing release assets if GitHub allows it', + ' --help, -h Show this help', + ].join('\n'); +} + +function parseArgs(argv) { + const opts = { + assetDir: DEFAULT_ASSET_DIR, + tag: null, + yes: false, + clobber: false, + help: false, + }; + + for (const arg of argv) { + if (arg === '--yes') opts.yes = true; + else if (arg === '--dry-run') opts.yes = false; + else if (arg === '--clobber') opts.clobber = true; + else if (arg === '--help' || arg === '-h') opts.help = true; + else if (arg.startsWith('--tag=')) opts.tag = arg.slice('--tag='.length); + else if (arg.startsWith('--asset-dir=')) opts.assetDir = arg.slice('--asset-dir='.length); + else throw new Error(`unknown argument: ${arg}`); + } + + return opts; +} + +function normalizeTag(tag) { + if (!tag || !TAG_RE.test(tag)) { + throw new Error(`invalid --tag value: ${JSON.stringify(tag)}; expected vX.Y.Z`); + } + return tag; +} + +function resolveAssetDir(assetDir, cwd = process.cwd()) { + const resolved = path.resolve(cwd, assetDir); + const linkStat = fs.lstatSync(resolved); + if (linkStat.isSymbolicLink()) { + throw new Error(`asset dir must not be a symlink: ${resolved}`); + } + const stat = fs.statSync(resolved); + if (!stat.isDirectory()) { + throw new Error(`asset dir is not a directory: ${resolved}`); + } + return resolved; +} + +function assertPlainFile(filePath, label) { + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`${label} must not be a symlink: ${filePath}`); + } + if (!stat.isFile()) { + throw new Error(`${label} is not a regular file: ${filePath}`); + } +} + +function sha256File(filePath) { + const content = fs.readFileSync(filePath); + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function parseSha256Line(text, expectedName, label) { + const lines = text.trim().split(/\r?\n/).filter(Boolean); + if (lines.length !== 1) { + throw new Error(`${label} must contain exactly one checksum line`); + } + const match = SHA256_LINE_RE.exec(lines[0]); + if (!match) { + throw new Error(`${label} must use ' ' format`); + } + const [, hash, fileName] = match; + if (fileName !== expectedName) { + throw new Error(`${label} references ${fileName}, expected ${expectedName}`); + } + return hash; +} + +function parseManifest(text) { + const entries = new Map(); + const lines = text.trim().split(/\r?\n/).filter(Boolean); + + for (const line of lines) { + const match = SHA256_LINE_RE.exec(line); + if (!match) { + throw new Error(`${V2_MANIFEST} contains an invalid checksum line`); + } + const [, hash, fileName] = match; + if (entries.has(fileName)) { + throw new Error(`${V2_MANIFEST} contains duplicate entry for ${fileName}`); + } + entries.set(fileName, hash); + } + + return entries; +} + +function validateV2Assets(assetDir) { + const dir = resolveAssetDir(assetDir); + const expectedSet = new Set(V2_BINARIES); + const manifestPath = path.join(dir, V2_MANIFEST); + const uploadFiles = []; + const hashes = new Map(); + + for (const fileName of V2_BINARIES) { + const filePath = path.join(dir, fileName); + const sidecarPath = path.join(dir, `${fileName}.sha256`); + + assertPlainFile(filePath, fileName); + assertPlainFile(sidecarPath, `${fileName}.sha256`); + + const actualHash = sha256File(filePath); + const sidecarHash = parseSha256Line( + fs.readFileSync(sidecarPath, 'utf8'), + fileName, + `${fileName}.sha256`, + ); + if (actualHash !== sidecarHash) { + throw new Error(`${fileName}.sha256 mismatch: expected ${actualHash}, got ${sidecarHash}`); + } + + hashes.set(fileName, actualHash); + uploadFiles.push(filePath, sidecarPath); + } + + assertPlainFile(manifestPath, V2_MANIFEST); + const manifest = parseManifest(fs.readFileSync(manifestPath, 'utf8')); + if (manifest.size !== V2_BINARIES.length) { + throw new Error(`${V2_MANIFEST} must contain exactly ${V2_BINARIES.length} entries`); + } + for (const fileName of V2_BINARIES) { + if (!manifest.has(fileName)) { + throw new Error(`${V2_MANIFEST} is missing ${fileName}`); + } + if (manifest.get(fileName) !== hashes.get(fileName)) { + throw new Error(`${V2_MANIFEST} checksum mismatch for ${fileName}`); + } + } + for (const fileName of manifest.keys()) { + if (!expectedSet.has(fileName)) { + throw new Error(`${V2_MANIFEST} contains unexpected asset ${fileName}`); + } + } + + uploadFiles.push(manifestPath); + return { dir, hashes, uploadFiles }; +} + +function buildGhReleaseUploadArgs(tag, files, opts = {}) { + const args = ['release', 'upload', tag, ...files, '--repo', PUBLIC_REPO]; + if (opts.clobber) args.push('--clobber'); + return args; +} + +function quoteArg(arg) { + if (/^[A-Za-z0-9_./:=@+-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +function runGh(args) { + const result = spawnSync('gh', args, { stdio: 'inherit' }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`gh ${args.slice(0, 2).join(' ')} failed with exit ${result.status}`); + } +} + +function main(argv = process.argv.slice(2)) { + const opts = parseArgs(argv); + if (opts.help) { + console.log(usage()); + return 0; + } + + const tag = normalizeTag(opts.tag); + const validation = validateV2Assets(opts.assetDir); + const uploadArgs = buildGhReleaseUploadArgs(tag, validation.uploadFiles, opts); + + console.log(`[upload-v2-release-assets] validated ${V2_BINARIES.length} binaries in ${validation.dir}`); + for (const fileName of V2_BINARIES) { + console.log(` ${fileName}: ${validation.hashes.get(fileName)}`); + } + console.log(`\n gh ${uploadArgs.map(quoteArg).join(' ')}`); + + if (!opts.yes) { + console.log('\n[upload-v2-release-assets] dry run only; add --yes to upload'); + return 0; + } + + runGh(['release', 'view', tag, '--repo', PUBLIC_REPO]); + runGh(uploadArgs); + return 0; +} + +if (require.main === module) { + try { + process.exit(main()); + } catch (error) { + console.error(`[upload-v2-release-assets] ERROR: ${error.message}`); + process.exit(1); + } +} + +module.exports = { + PUBLIC_REPO, + V2_BINARIES, + V2_MANIFEST, + buildGhReleaseUploadArgs, + main, + normalizeTag, + parseArgs, + parseManifest, + parseSha256Line, + sha256File, + validateV2Assets, +}; diff --git a/test/uploadV2ReleaseAssets.test.js b/test/uploadV2ReleaseAssets.test.js new file mode 100644 index 00000000..14ec3806 --- /dev/null +++ b/test/uploadV2ReleaseAssets.test.js @@ -0,0 +1,133 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + PUBLIC_REPO, + V2_BINARIES, + V2_MANIFEST, + buildGhReleaseUploadArgs, + normalizeTag, + parseArgs, + validateV2Assets, +} = require('../scripts/upload_v2_release_assets'); + +function sha256Text(text) { + return crypto.createHash('sha256').update(text).digest('hex'); +} + +function makeAssetDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-v2-assets-')); + const manifest = []; + + for (const fileName of V2_BINARIES) { + const content = `binary:${fileName}`; + const hash = sha256Text(content); + fs.writeFileSync(path.join(dir, fileName), content); + fs.writeFileSync(path.join(dir, `${fileName}.sha256`), `${hash} ${fileName}\n`); + manifest.push(`${hash} ${fileName}`); + } + + fs.writeFileSync(path.join(dir, V2_MANIFEST), `${manifest.join('\n')}\n`); + return dir; +} + +test('validateV2Assets accepts the expected v2 binary set and checksum manifest', () => { + const dir = makeAssetDir(); + try { + const result = validateV2Assets(dir); + assert.equal(result.dir, dir); + assert.equal(result.hashes.size, V2_BINARIES.length); + assert.equal(result.uploadFiles.length, V2_BINARIES.length * 2 + 1); + assert.deepEqual( + result.uploadFiles.map((file) => path.basename(file)), + V2_BINARIES.flatMap((fileName) => [fileName, `${fileName}.sha256`]).concat(V2_MANIFEST), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validateV2Assets rejects a sidecar that references the wrong filename', () => { + const dir = makeAssetDir(); + try { + const sidecar = path.join(dir, `${V2_BINARIES[0]}.sha256`); + const hash = fs.readFileSync(sidecar, 'utf8').slice(0, 64); + fs.writeFileSync(sidecar, `${hash} other-file\n`); + assert.throws(() => validateV2Assets(dir), /references other-file/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validateV2Assets rejects symlinked release assets', () => { + const dir = makeAssetDir(); + try { + const target = path.join(dir, V2_BINARIES[0]); + fs.unlinkSync(target); + fs.symlinkSync(path.join(dir, V2_BINARIES[1]), target); + assert.throws(() => validateV2Assets(dir), /must not be a symlink/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validateV2Assets rejects a symlinked asset directory', () => { + const dir = makeAssetDir(); + const link = path.join(os.tmpdir(), `evolver-v2-assets-link-${Date.now()}`); + try { + fs.symlinkSync(dir, link); + assert.throws(() => validateV2Assets(link), /asset dir must not be a symlink/); + } finally { + if (fs.existsSync(link)) fs.unlinkSync(link); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validateV2Assets rejects unexpected manifest entries', () => { + const dir = makeAssetDir(); + try { + const manifest = path.join(dir, V2_MANIFEST); + fs.appendFileSync(manifest, `${sha256Text('x')} evolver-v2-extra\n`); + assert.throws(() => validateV2Assets(dir), /must contain exactly/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('buildGhReleaseUploadArgs pins uploads to EvoMap/evolver without shell interpolation', () => { + const files = ['/tmp/a file', '/tmp/b']; + const args = buildGhReleaseUploadArgs('v1.89.11', files, { clobber: true }); + assert.deepEqual(args, [ + 'release', + 'upload', + 'v1.89.11', + '/tmp/a file', + '/tmp/b', + '--repo', + PUBLIC_REPO, + '--clobber', + ]); +}); + +test('normalizeTag requires a GitHub release-style semver tag', () => { + assert.equal(normalizeTag('v1.89.11'), 'v1.89.11'); + assert.equal(normalizeTag('v1.89.11-beta.1'), 'v1.89.11-beta.1'); + assert.throws(() => normalizeTag('1.89.11'), /invalid --tag/); + assert.throws(() => normalizeTag('v1.89;echo bad'), /invalid --tag/); +}); + +test('parseArgs treats --dry-run as explicit validate-only mode', () => { + assert.deepEqual(parseArgs(['--tag=v1.89.11', '--yes', '--dry-run']), { + assetDir: 'dist-binaries', + tag: 'v1.89.11', + yes: false, + clobber: false, + help: false, + }); +}); From 495a76d49d21f645dcb13f7b6aeddff2e43ed5ab Mon Sep 17 00:00:00 2001 From: bjw9808 Date: Mon, 15 Jun 2026 20:27:19 +0800 Subject: [PATCH 2/2] fix: verify v2 release asset versions --- scripts/upload_v2_release_assets.js | 41 +++++++++++++++++++++++ test/uploadV2ReleaseAssets.test.js | 51 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/scripts/upload_v2_release_assets.js b/scripts/upload_v2_release_assets.js index 44095502..1ccb078a 100644 --- a/scripts/upload_v2_release_assets.js +++ b/scripts/upload_v2_release_assets.js @@ -78,6 +78,10 @@ function normalizeTag(tag) { return tag; } +function versionFromTag(tag) { + return normalizeTag(tag).replace(/^v/, ''); +} + function resolveAssetDir(assetDir, cwd = process.cwd()) { const resolved = path.resolve(cwd, assetDir); const linkStat = fs.lstatSync(resolved); @@ -141,6 +145,33 @@ function parseManifest(text) { return entries; } +function hostBinaryName(platform = process.platform, arch = process.arch) { + const normalizedArch = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'x64' : null; + if (!normalizedArch) return null; + if (platform === 'darwin') return `evolver-v2-darwin-${normalizedArch}`; + if (platform === 'linux') return `evolver-v2-linux-${normalizedArch}`; + if (platform === 'win32') return normalizedArch === 'x64' ? 'evolver-v2-windows-x64.exe' : null; + return null; +} + +function validateHostBinaryVersion(assetDir, expectedVersion, run = spawnSync, platform = process.platform, arch = process.arch) { + const fileName = hostBinaryName(platform, arch); + if (!fileName) return { checked: false, reason: `unsupported host ${platform}/${arch}` }; + + const filePath = path.join(assetDir, fileName); + assertPlainFile(filePath, fileName); + + const result = run(filePath, ['--version'], { encoding: 'utf8', timeout: 15000 }); + if (result.error) throw result.error; + + const output = `${result.stdout || ''}${result.stderr || ''}`.trim(); + if (result.status !== 0 || output !== expectedVersion) { + throw new Error(`${fileName} --version mismatch: expected ${expectedVersion}, got ${JSON.stringify(output)} (exit ${result.status})`); + } + + return { checked: true, fileName, version: output }; +} + function validateV2Assets(assetDir) { const dir = resolveAssetDir(assetDir); const expectedSet = new Set(V2_BINARIES); @@ -221,13 +252,20 @@ function main(argv = process.argv.slice(2)) { } const tag = normalizeTag(opts.tag); + const expectedVersion = versionFromTag(tag); const validation = validateV2Assets(opts.assetDir); + const versionCheck = validateHostBinaryVersion(validation.dir, expectedVersion); const uploadArgs = buildGhReleaseUploadArgs(tag, validation.uploadFiles, opts); console.log(`[upload-v2-release-assets] validated ${V2_BINARIES.length} binaries in ${validation.dir}`); for (const fileName of V2_BINARIES) { console.log(` ${fileName}: ${validation.hashes.get(fileName)}`); } + if (versionCheck.checked) { + console.log(` ${versionCheck.fileName} --version: ${versionCheck.version}`); + } else { + console.log(` host binary version check skipped: ${versionCheck.reason}`); + } console.log(`\n gh ${uploadArgs.map(quoteArg).join(' ')}`); if (!opts.yes) { @@ -254,11 +292,14 @@ module.exports = { V2_BINARIES, V2_MANIFEST, buildGhReleaseUploadArgs, + hostBinaryName, main, normalizeTag, parseArgs, parseManifest, parseSha256Line, sha256File, + validateHostBinaryVersion, validateV2Assets, + versionFromTag, }; diff --git a/test/uploadV2ReleaseAssets.test.js b/test/uploadV2ReleaseAssets.test.js index 14ec3806..4f6b573d 100644 --- a/test/uploadV2ReleaseAssets.test.js +++ b/test/uploadV2ReleaseAssets.test.js @@ -12,9 +12,12 @@ const { V2_BINARIES, V2_MANIFEST, buildGhReleaseUploadArgs, + hostBinaryName, normalizeTag, parseArgs, + validateHostBinaryVersion, validateV2Assets, + versionFromTag, } = require('../scripts/upload_v2_release_assets'); function sha256Text(text) { @@ -122,6 +125,54 @@ test('normalizeTag requires a GitHub release-style semver tag', () => { assert.throws(() => normalizeTag('v1.89;echo bad'), /invalid --tag/); }); +test('versionFromTag strips the release tag prefix for binary version checks', () => { + assert.equal(versionFromTag('v1.89.11'), '1.89.11'); + assert.equal(versionFromTag('v1.89.11-beta.1'), '1.89.11-beta.1'); +}); + +test('hostBinaryName maps supported upload hosts to the expected v2 asset', () => { + assert.equal(hostBinaryName('darwin', 'arm64'), 'evolver-v2-darwin-arm64'); + assert.equal(hostBinaryName('darwin', 'x64'), 'evolver-v2-darwin-x64'); + assert.equal(hostBinaryName('linux', 'x64'), 'evolver-v2-linux-x64'); + assert.equal(hostBinaryName('linux', 'arm64'), 'evolver-v2-linux-arm64'); + assert.equal(hostBinaryName('win32', 'x64'), 'evolver-v2-windows-x64.exe'); + assert.equal(hostBinaryName('win32', 'arm64'), null); +}); + +test('validateHostBinaryVersion accepts the host binary only when --version matches the release tag', () => { + const dir = makeAssetDir(); + try { + const result = validateHostBinaryVersion( + dir, + '1.89.11', + () => ({ status: 0, stdout: '1.89.11\n', stderr: '' }), + 'linux', + 'x64', + ); + assert.deepEqual(result, { checked: true, fileName: 'evolver-v2-linux-x64', version: '1.89.11' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('validateHostBinaryVersion rejects stale dev-version binaries', () => { + const dir = makeAssetDir(); + try { + assert.throws( + () => validateHostBinaryVersion( + dir, + '1.89.11', + () => ({ status: 0, stdout: '0.0.0\n', stderr: '' }), + 'linux', + 'x64', + ), + /--version mismatch/, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('parseArgs treats --dry-run as explicit validate-only mode', () => { assert.deepEqual(parseArgs(['--tag=v1.89.11', '--yes', '--dry-run']), { assetDir: 'dist-binaries',