243 lines
14 KiB
JavaScript
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 => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
})[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;
|
|
}
|
|
}
|