Files

243 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { projectRoot, resolveProjectPath } from './project-paths.mjs';
export const targets = [
{ os: 'linux', arch: 'amd64' },
{ os: 'linux', arch: 'arm64' },
{ os: 'windows', arch: 'amd64' },
{ os: 'windows', arch: 'arm64' },
{ os: 'darwin', arch: 'amd64' },
{ os: 'darwin', arch: 'arm64' },
];
const sha256 = filename => createHash('sha256').update(readFileSync(filename)).digest('hex');
const archiveName = (version, target) => `linkport-${version}-${target.os}-${target.arch}.${target.os === 'windows' ? 'zip' : 'tar.gz'}`;
const writeJson = (filename, value) => writeFileSync(filename, `${JSON.stringify(value, null, 2)}\n`);
function validateMetadata(metadata) {
assert.match(metadata.version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
assert.match(metadata.commit, /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/);
assert(Number.isFinite(Date.parse(metadata.buildTime)), 'Invalid build timestamp');
}
export function packageRelease(input, output) {
const metadata = JSON.parse(readFileSync(join(input, 'build-info.json'), 'utf8'));
validateMetadata(metadata);
for (const target of targets) {
const binary = target.os === 'windows' ? 'linkport.exe' : 'linkport';
assert(statSync(join(input, `${target.os}-${target.arch}`, binary)).isFile(), `Missing ${target.os}/${target.arch} binary`);
}
mkdirSync(dirname(output), { recursive: true });
mkdirSync(output);
const temporary = mkdtempSync(join(tmpdir(), 'linkport-ci-package-'));
const manifest = { ...metadata, packages: [] };
try {
for (const target of targets) {
const folder = `linkport-${metadata.version}-${target.os}-${target.arch}`;
const staged = join(temporary, folder);
const binary = target.os === 'windows' ? 'linkport.exe' : 'linkport';
mkdirSync(join(staged, 'docs'), { recursive: true });
copyFileSync(join(input, `${target.os}-${target.arch}`, binary), join(staged, binary));
copyFileSync(join(projectRoot, 'README.md'), join(staged, 'README.md'));
for (const document of ['build-output.md', 'testing.md']) {
copyFileSync(join(projectRoot, 'docs', document), join(staged, 'docs', document));
}
writeFileSync(join(staged, 'VERSION'), `${metadata.version}\n`);
writeJson(join(staged, 'BUILDINFO.json'), { ...metadata, ...target });
const binarySha256 = sha256(join(staged, binary));
writeFileSync(join(staged, 'SHA256SUMS.txt'), `${binarySha256} ${binary}\n`);
const file = archiveName(metadata.version, target);
const options = target.os === 'windows' ? ['--format', 'zip', '-cf'] : ['-czf'];
execFileSync(process.platform === 'win32' ? 'tar.exe' : 'bsdtar', [...options, join(output, file), '-C', temporary, folder], { windowsHide: true });
manifest.packages.push({ ...target, file, sha256: sha256(join(output, file)), binarySha256 });
}
writeJson(join(output, 'release.json'), manifest);
const files = [...manifest.packages.map(entry => entry.file), 'release.json'].sort();
writeFileSync(join(output, 'SHA256SUMS.txt'), files.map(file => `${sha256(join(output, file))} ${file}\n`).join(''));
return manifest;
} finally {
assert.equal(dirname(resolve(temporary)), resolve(tmpdir()));
assert(basename(temporary).startsWith('linkport-ci-package-'));
rmSync(temporary, { recursive: true, force: true });
}
}
export function releaseFiles(directory) {
const manifest = JSON.parse(readFileSync(join(directory, 'release.json'), 'utf8'));
validateMetadata(manifest);
assert.equal(manifest.packages.length, targets.length, 'Expected all configured platform packages');
const expected = targets.map(target => archiveName(manifest.version, target)).sort();
assert.deepEqual(manifest.packages.map(entry => entry.file).sort(), expected, 'Unexpected platform package names');
for (const entry of manifest.packages) {
assert(targets.some(target => target.os === entry.os && target.arch === entry.arch && archiveName(manifest.version, target) === entry.file), 'Platform metadata mismatch');
assert.equal(sha256(join(directory, entry.file)), entry.sha256, `Archive checksum mismatch: ${entry.file}`);
}
const checked = [...expected, 'release.json'].sort();
const sums = checked.map(file => `${sha256(join(directory, file))} ${file}\n`).join('');
assert.equal(readFileSync(join(directory, 'SHA256SUMS.txt'), 'utf8'), sums, 'Release checksum list mismatch');
return [...checked, 'SHA256SUMS.txt'];
}
export function ipfsAddUrl(api, wrapWithDirectory = true) {
assert(api, 'IPFS_API is required');
const url = new URL(api);
assert(['http:', 'https:'].includes(url.protocol), 'IPFS_API must use HTTP or HTTPS');
const path = url.pathname.replace(/\/+$/, '');
url.pathname = `${path.endsWith('/api/v0') ? path : `${path}/api/v0`}/add`;
url.searchParams.set('pin', 'true');
url.searchParams.set('wrap-with-directory', String(wrapWithDirectory));
url.searchParams.set('cid-version', '1');
url.searchParams.set('progress', 'false');
return url;
}
function validFileCid(cid) {
if (typeof cid !== 'string' || !/^b[a-z2-7]{58}$/.test(cid)) return false;
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
const bytes = [];
let buffer = 0;
let bits = 0;
for (const character of cid.slice(1)) {
buffer = (buffer << 5) | alphabet.indexOf(character);
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((buffer >>> bits) & 255);
}
}
return bytes.length === 36 && bytes[0] === 1 && [0x55, 0x70].includes(bytes[1])
&& bytes[2] === 0x12 && bytes[3] === 0x20 && (buffer & ((1 << bits) - 1)) === 0;
}
const escapeHtml = value => String(value).replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]);
export function createIpfsIndex(manifest, archiveEntries) {
validateMetadata(manifest);
assert.equal(manifest.packages.length, targets.length, 'Expected all configured platform packages');
assert.equal(archiveEntries.length, targets.length, 'Expected all configured uploaded archives');
const packages = targets.map(target => {
const file = archiveName(manifest.version, target);
const sources = manifest.packages.filter(entry => entry.file === file);
const uploads = archiveEntries.filter(entry => entry.file === file);
assert(sources.length === 1 && uploads.length === 1, 'Missing or duplicate platform archive');
const source = sources[0];
const upload = uploads[0];
assert(source.os === target.os && source.arch === target.arch, 'Platform metadata mismatch');
assert(typeof source.sha256 === 'string' && /^[a-f0-9]{64}$/.test(source.sha256), 'Invalid archive checksum');
assert(upload.sha256 === source.sha256, 'Uploaded archive checksum mismatch');
assert(validFileCid(upload.cid), 'Invalid IPFS file CID');
return { ...target, file, cid: upload.cid, sha256: source.sha256, uri: `ipfs://${upload.cid}` };
});
const index = { version: manifest.version, commit: manifest.commit, buildTime: manifest.buildTime, packages };
const rows = packages.map(entry => {
const href = `/ipfs/${entry.cid}?filename=${encodeURIComponent(entry.file)}`;
return `<tr><td>${escapeHtml(entry.os)}/${escapeHtml(entry.arch)}</td><td><a href="${escapeHtml(href)}">${escapeHtml(entry.file)}</a></td><td><code>${escapeHtml(entry.sha256)}</code></td></tr>`;
}).join('\n');
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>linkport ${escapeHtml(index.version)}</title></head>
<body><h1>linkport ${escapeHtml(index.version)}</h1><p>Commit: <code>${escapeHtml(index.commit)}</code></p><p>Build: ${escapeHtml(index.buildTime)}</p>
<table><thead><tr><th>Platform</th><th>Download</th><th>SHA256</th></tr></thead><tbody>${rows}</tbody></table>
<p><a href="release.json">release.json</a> | <a href="SHA256SUMS.txt">SHA256SUMS.txt</a> | <a href="ipfs-index.json">ipfs-index.json</a></p></body></html>\n`;
return { index, html };
}
function addFiles(directory, files, api, token, wrapWithDirectory, runCurl) {
const url = ipfsAddUrl(api, wrapWithDirectory);
const args = ['--disable', '--fail', '--silent', '--show-error', '--connect-timeout', '20', '--max-time', '1800', '--user-agent', 'linkport-ci-release/1', '--config', '-', '--request', 'POST'];
const quote = value => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
for (const file of files) {
args.push('--form', `file=@${quote(join(directory, file))};filename=${quote(file)}`);
}
args.push(url.href);
const config = token ? `header = ${quote(`Authorization: Bearer ${token}`)}\n` : '';
const response = runCurl(process.platform === 'win32' ? 'curl.exe' : 'curl', args, {
input: config, encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024, timeout: 1810000,
});
assert(!response.error && response.status === 0, `IPFS upload failed (curl exit ${response.status ?? 'unavailable'}); check API access and authentication`);
let records;
try {
records = response.stdout.trim().split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
} catch {
throw new Error('IPFS returned malformed JSON');
}
assert(records.every(record => record && typeof record === 'object' && !record.Type && !record.Error), 'IPFS returned an error');
assert.equal(records.length, files.length + (wrapWithDirectory ? 1 : 0), 'Unexpected IPFS response record count');
for (const file of files) {
const matches = records.filter(record => record.Name === file);
assert(matches.length === 1 && validFileCid(matches[0].Hash), 'IPFS response has a missing or invalid file Name/CID');
}
if (!wrapWithDirectory) return records[0].Hash;
const roots = records.filter(record => record.Name === '');
assert(roots.length === 1 && validFileCid(roots[0].Hash), 'IPFS response did not include a valid wrapped directory CID');
return roots[0].Hash;
}
export function uploadRelease(directory, api, token = '', { runCurl = spawnSync } = {}) {
ipfsAddUrl(api);
assert(!/[\r\n]/.test(token), 'IPFS_TOKEN must not contain line breaks');
releaseFiles(directory);
const receipt = `${directory}.ipfs.json`;
assert(!existsSync(receipt), 'IPFS receipt already exists; preserve it and use a fresh release directory');
const manifest = JSON.parse(readFileSync(join(directory, 'release.json'), 'utf8'));
const archives = [];
for (const entry of manifest.packages) {
console.log(`Uploading IPFS archive ${archives.length + 1}/${manifest.packages.length}: ${entry.file}`);
const cid = addFiles(directory, [entry.file], api, token, false, runCurl);
archives.push({ file: entry.file, cid, sha256: entry.sha256 });
console.log(`IPFS archive add accepted (pin requested): ${entry.file} ${cid}`);
}
const { index, html } = createIpfsIndex(manifest, archives);
const temporary = mkdtempSync(join(tmpdir(), 'linkport-ci-ipfs-'));
try {
writeJson(join(temporary, 'ipfs-index.json'), index);
writeFileSync(join(temporary, 'index.html'), html);
for (const file of ['release.json', 'SHA256SUMS.txt']) copyFileSync(join(directory, file), join(temporary, file));
const indexFiles = ['index.html', 'ipfs-index.json', 'release.json', 'SHA256SUMS.txt'];
console.log('Uploading IPFS download index (4 files)');
const cid = addFiles(temporary, indexFiles, api, token, true, runCurl);
const result = { cid, files: [...index.packages, ...indexFiles.map(file => ({ file, uri: `ipfs://${cid}/${file}` }))] };
writeFileSync(receipt, `${JSON.stringify(result, null, 2)}\n`, { flag: 'wx' });
return result;
} finally {
assert.equal(dirname(resolve(temporary)), resolve(tmpdir()));
assert(basename(temporary).startsWith('linkport-ci-ipfs-'));
rmSync(temporary, { recursive: true, force: true });
}
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
try {
if (process.argv[2] === 'package') {
const output = resolveProjectPath(process.argv[4] || 'bin/releases/ci');
const manifest = packageRelease(resolveProjectPath(process.argv[3] || 'bin/build-cache/ci-release'), output);
for (const entry of manifest.packages) console.log(`${entry.sha256} ${join(output, entry.file)}`);
console.log(`All ${manifest.packages.length} platform packages retained in ${output}`);
} else if (process.argv[2] === 'upload') {
const result = uploadRelease(resolveProjectPath(process.argv[3] || 'bin/releases/ci'), process.env.IPFS_API, process.env.IPFS_TOKEN);
const gateway = new URL(process.env.IPFS_API).origin;
console.log(`IPFS index directory CID (pin requested): ${result.cid}`);
for (const entry of result.files) console.log(entry.uri);
console.log('\n=== IPFS upload complete: download URLs ===');
console.log(`Download page:\n${gateway}/ipfs/${result.cid}/`);
for (const entry of result.files) {
const download = new URL(`${gateway}/ipfs/${entry.uri.slice('ipfs://'.length)}`);
if (entry.cid) download.searchParams.set('filename', entry.file);
console.log(`\nDownload ${entry.file}:\n${download.href}`);
}
} else {
throw new Error('Usage: node scripts/ci-release.mjs package [binary-directory] [output-directory] | upload [release-directory]');
}
} catch (error) {
console.error(`Release failed: ${error.message}`);
process.exitCode = 1;
}
}