mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-16 17:16:20 -07:00
feat: add subtitle generation and bundle Bun launcher runtime
- Add local subtitle generation and card timing review workflows - Package cross-platform Bun runtimes, launchers, licenses, and source - Consolidate release packaging and refresh v0.19.6 documentation
This commit is contained in:
@@ -620,6 +620,10 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
|
||||
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
|
||||
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
|
||||
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
|
||||
assert.match(prereleaseNotes, /Windows `subminer\.cmd` launcher/);
|
||||
assert.match(prereleaseNotes, /Both launcher downloads use Bun included with the SubMiner app/);
|
||||
assert.match(prereleaseNotes, /Bun corresponding source: `bun-v1\.3\.5-source\.tar\.gz`/);
|
||||
assert.match(prereleaseNotes, /statically links JavaScriptCore \(LGPL 2\.0\)/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -977,9 +977,12 @@ function renderReleaseNotes(
|
||||
'- Linux: `SubMiner.AppImage`',
|
||||
'- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`',
|
||||
'- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`',
|
||||
'- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher',
|
||||
'- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher',
|
||||
'- Bun corresponding source: `bun-v1.3.5-source.tar.gz` and its `.sha256` file',
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
'Both launcher downloads use Bun included with the SubMiner app. Download `subminer` on Linux or macOS and `subminer.cmd` on Windows.',
|
||||
'',
|
||||
'The app bundles an unmodified Bun 1.3.5 runtime. Bun is MIT licensed and statically links JavaScriptCore (LGPL 2.0) and TinyCC (LGPL 2.1). License texts and third-party notices ship inside the app under `resources/bun/licenses`, and the source archive above contains the matching Bun, WebKit, and dependency sources for relinking.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import packageJson from '../package.json';
|
||||
import { posixLauncherBootstrapContent } from '../src/main/runtime/posix-launcher-bootstrap';
|
||||
import { windowsLauncherBootstrapContent } from '../src/main/runtime/windows-launcher-bootstrap';
|
||||
|
||||
const outputDirectory = path.join(process.cwd(), 'dist', 'launcher');
|
||||
|
||||
function bundle(options: {
|
||||
entrypoint: string;
|
||||
outfile: string;
|
||||
target: 'bun' | 'node';
|
||||
format?: 'cjs';
|
||||
banner?: string;
|
||||
}): void {
|
||||
const args = [
|
||||
'build',
|
||||
options.entrypoint,
|
||||
`--outfile=${options.outfile}`,
|
||||
`--target=${options.target}`,
|
||||
'--packages=bundle',
|
||||
];
|
||||
if (options.format) args.push(`--format=${options.format}`);
|
||||
if (options.banner) args.push(`--banner=${options.banner}`);
|
||||
execFileSync(process.execPath, args, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
|
||||
bundle({
|
||||
entrypoint: path.join(process.cwd(), 'launcher', 'main.ts'),
|
||||
outfile: path.join(outputDirectory, 'subminer.js'),
|
||||
target: 'bun',
|
||||
banner: '#!/usr/bin/env bun',
|
||||
});
|
||||
bundle({
|
||||
entrypoint: path.join(process.cwd(), 'src', 'main', 'runtime', 'prepare-launcher-runtime.ts'),
|
||||
outfile: path.join(outputDirectory, 'prepare.cjs'),
|
||||
target: 'node',
|
||||
format: 'cjs',
|
||||
});
|
||||
|
||||
const posixLauncherPath = path.join(outputDirectory, 'subminer');
|
||||
fs.writeFileSync(posixLauncherPath, posixLauncherBootstrapContent(), { mode: 0o755 });
|
||||
fs.chmodSync(posixLauncherPath, 0o755);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDirectory, 'subminer.cmd'),
|
||||
windowsLauncherBootstrapContent(),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(path.join(outputDirectory, 'version'), `${packageJson.version}\n`, 'utf8');
|
||||
|
||||
console.log(`Built launcher runtime artifacts in ${outputDirectory}`);
|
||||
@@ -86,15 +86,33 @@ async function verifyMacOSWindowHelper(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function afterPack(context) {
|
||||
async function stageBundledBunRuntime(context, deps = {}) {
|
||||
const stageBunRuntime =
|
||||
deps.stageBunRuntime ?? (await import('./stage-bun-runtime.mjs')).stageBunRuntime;
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
await stageBunRuntime({
|
||||
appOutDir: context.appOutDir,
|
||||
platform: context.electronPlatformName,
|
||||
arch: context.arch,
|
||||
productFilename:
|
||||
typeof productFilename === 'string' && productFilename.trim()
|
||||
? productFilename.trim()
|
||||
: 'SubMiner',
|
||||
});
|
||||
}
|
||||
|
||||
async function afterPack(context, deps = {}) {
|
||||
await stageLinuxAppImageSharedLibrary(context);
|
||||
await verifyMacOSWindowHelper(context);
|
||||
await stageBundledBunRuntime(context, deps);
|
||||
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
resolveMacOSAppBundlePath,
|
||||
stageBundledBunRuntime,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
default: afterPack,
|
||||
|
||||
@@ -13,7 +13,23 @@ const {
|
||||
} = require('./electron-builder-after-pack.cjs') as {
|
||||
LINUX_FFMPEG_LIBRARY: string;
|
||||
MACOS_WINDOW_HELPER: string;
|
||||
default: (context: { appOutDir: string; electronPlatformName: string }) => Promise<void>;
|
||||
default: (
|
||||
context: {
|
||||
appOutDir: string;
|
||||
arch?: number;
|
||||
electronPlatformName: string;
|
||||
packager?: { appInfo?: { productFilename?: string } };
|
||||
},
|
||||
deps?: {
|
||||
auditPackage?: (context: { appOutDir: string }) => Promise<void>;
|
||||
stageBunRuntime?: (options: {
|
||||
appOutDir: string;
|
||||
platform: string;
|
||||
arch: number | undefined;
|
||||
productFilename: string;
|
||||
}) => Promise<void>;
|
||||
},
|
||||
) => Promise<void>;
|
||||
stageLinuxAppImageSharedLibrary: (context: {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
@@ -156,3 +172,55 @@ test('afterPack propagates Linux staging failures', async () => {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('afterPack stages Linux and Bun runtime assets before auditing the package', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-target');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-linux-arm64');
|
||||
const sourceLibraryPath = path.join(appOutDir, LINUX_FFMPEG_LIBRARY);
|
||||
const targetLibraryPath = path.join(appOutDir, 'usr', 'lib', LINUX_FFMPEG_LIBRARY);
|
||||
const operations: string[] = [];
|
||||
let stagedOptions:
|
||||
| {
|
||||
appOutDir: string;
|
||||
platform: string;
|
||||
arch: number | undefined;
|
||||
productFilename: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
fs.mkdirSync(appOutDir, { recursive: true });
|
||||
fs.writeFileSync(sourceLibraryPath, 'bundled ffmpeg', 'utf8');
|
||||
|
||||
try {
|
||||
await afterPack(
|
||||
{
|
||||
appOutDir,
|
||||
arch: 3,
|
||||
electronPlatformName: 'linux',
|
||||
packager: { appInfo: { productFilename: 'SubMiner Preview' } },
|
||||
},
|
||||
{
|
||||
stageBunRuntime: async (options) => {
|
||||
stagedOptions = options;
|
||||
operations.push('stage-bun');
|
||||
},
|
||||
auditPackage: async (context) => {
|
||||
assert.equal(context.appOutDir, appOutDir);
|
||||
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
|
||||
operations.push('audit');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(operations, ['stage-bun', 'audit']);
|
||||
assert.deepEqual(stagedOptions, {
|
||||
appOutDir,
|
||||
platform: 'linux',
|
||||
arch: 3,
|
||||
productFilename: 'SubMiner Preview',
|
||||
});
|
||||
assert.equal(fs.readFileSync(targetLibraryPath, 'utf8'), 'bundled ffmpeg');
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const asar = require('@electron/asar');
|
||||
const { Arch } = require('builder-util');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const currentReports = new Set();
|
||||
const REQUIRED_APP_FILES = [
|
||||
'package.json',
|
||||
'LICENSE',
|
||||
'config.example.jsonc',
|
||||
'dist/main-entry.js',
|
||||
'dist/main.js',
|
||||
'dist/preload.js',
|
||||
'dist/preload-settings.js',
|
||||
'dist/preload-syncui.js',
|
||||
'dist/preload-stats.js',
|
||||
'dist/preload-jellyfin-setup.js',
|
||||
'dist/fonts/MPLUS1[wght].ttf',
|
||||
'stats/dist/index.html',
|
||||
'vendor/texthooker-ui/docs/index.html',
|
||||
...['renderer', 'settings', 'syncui'].flatMap((ui) => [
|
||||
`dist/${ui}/index.html`,
|
||||
`dist/${ui}/style.css`,
|
||||
`dist/${ui}/${ui}.js`,
|
||||
]),
|
||||
];
|
||||
const REQUIRED_RESOURCES = [
|
||||
'yomitan/manifest.json',
|
||||
'yomitan/data/fonts/kanji-stroke-orders.ttf',
|
||||
'yomitan/fonts/NotoSansJP-Regular.ttf',
|
||||
'yomitan/lib/resvg.wasm',
|
||||
'launcher/subminer',
|
||||
'plugin/subminer/main.lua',
|
||||
'plugin/subminer.conf',
|
||||
'assets/SubMiner.png',
|
||||
'assets/SubMiner-square.png',
|
||||
'assets/themes/subminer.rasi',
|
||||
'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'CHANGELOG.md',
|
||||
];
|
||||
|
||||
// Do not follow framework symlinks or count ASAR unpacked entries twice.
|
||||
function listFiles(root, prefix = '') {
|
||||
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap((entry) => {
|
||||
const name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink()) return [];
|
||||
if (entry.isDirectory()) return listFiles(root, name);
|
||||
return [{ path: name, bytes: fs.statSync(path.join(root, name)).size }];
|
||||
});
|
||||
}
|
||||
|
||||
function listAppFiles(archive) {
|
||||
return asar.listPackage(archive).flatMap((entry) => {
|
||||
const name = entry.replaceAll('\\', '/').replace(/^\//, '');
|
||||
const stat = asar.statFile(archive, name);
|
||||
return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function verifyAppPath(name, platform, arch) {
|
||||
const allowedRoots = new Set([
|
||||
'dist',
|
||||
'node_modules',
|
||||
'stats',
|
||||
'vendor',
|
||||
'package.json',
|
||||
'LICENSE',
|
||||
'config.example.jsonc',
|
||||
]);
|
||||
assert(allowedRoots.has(name.split('/')[0]), `Unexpected app file: ${name}`);
|
||||
assert(!name.endsWith('.map'), `Packaged source map: ${name}`);
|
||||
assert(!/\.(?:[cm]?ts|tsx)$/.test(name), `Packaged TypeScript: ${name}`);
|
||||
assert(!/\.(?:test|spec)\./.test(name), `Packaged test: ${name}`);
|
||||
assert(
|
||||
!/(?:^|\/)(?:tests?|__tests__|fixtures?|__fixtures__)\//.test(name),
|
||||
`Packaged test or fixture directory: ${name}`,
|
||||
);
|
||||
assert(!/^dist\/.*\.test\./.test(name), `Packaged test: ${name}`);
|
||||
assert(!/^dist\/(launcher|scripts)\//.test(name), `Duplicate helper: ${name}`);
|
||||
assert(!/^dist\/(renderer|settings|syncui)\/fonts\//.test(name), `Duplicate font: ${name}`);
|
||||
assert(!name.startsWith('stats/') || name.startsWith('stats/dist/'), `Stats source: ${name}`);
|
||||
assert(
|
||||
!name.startsWith('vendor/') || name.startsWith('vendor/texthooker-ui/docs/'),
|
||||
`Vendor source: ${name}`,
|
||||
);
|
||||
if (name.startsWith('node_modules/koffi/')) {
|
||||
assert.equal(platform, 'win32', `Koffi shipped on ${platform}`);
|
||||
assert(!/^node_modules\/koffi\/(src|vendor|doc)\//.test(name), `Koffi build files: ${name}`);
|
||||
if (name.endsWith('.node')) {
|
||||
assert.equal(name, `node_modules/koffi/build/koffi/win32_${arch}/koffi.node`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyContents(archive, resources, platform, arch) {
|
||||
const entries = listAppFiles(archive);
|
||||
const names = new Set(entries.map((entry) => entry.path));
|
||||
for (const name of REQUIRED_APP_FILES) assert(names.has(name), `Missing app file: ${name}`);
|
||||
for (const name of REQUIRED_RESOURCES) {
|
||||
assert(fs.statSync(path.join(resources, name)).size > 0, `Empty resource: ${name}`);
|
||||
}
|
||||
assert(listFiles(path.join(resources, 'yomitan-jlpt-vocab')).length > 0, 'Missing JLPT data');
|
||||
for (const { path: name } of entries) verifyAppPath(name, platform, arch);
|
||||
const libsqlPlatform = {
|
||||
linux: `linux-${arch}-gnu`,
|
||||
darwin: `darwin-${arch}`,
|
||||
win32: `win32-${arch}-msvc`,
|
||||
}[platform];
|
||||
const libsqlBinary = `node_modules/@libsql/${libsqlPlatform}/index.node`;
|
||||
assert(names.has(libsqlBinary), `Missing SQLite native binary: ${libsqlBinary}`);
|
||||
for (const name of names) {
|
||||
if (name.startsWith('node_modules/@libsql/') && name.endsWith('.node')) {
|
||||
assert.equal(name, libsqlBinary, `Foreign SQLite binary: ${name}`);
|
||||
}
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
for (const name of [
|
||||
'index.js',
|
||||
'package.json',
|
||||
'LICENSE.txt',
|
||||
`build/koffi/win32_${arch}/koffi.node`,
|
||||
]) {
|
||||
assert(names.has(`node_modules/koffi/${name}`), `Missing Windows FFI file: ${name}`);
|
||||
}
|
||||
}
|
||||
for (const name of listFiles(path.join(resources, 'assets'))) {
|
||||
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
|
||||
}
|
||||
for (const ui of ['renderer', 'settings', 'syncui']) {
|
||||
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString();
|
||||
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function auditPackage(context) {
|
||||
const platform = context.electronPlatformName;
|
||||
const arch = Arch[context.arch];
|
||||
const key = `${platform}-${arch}`;
|
||||
const appRoot =
|
||||
platform === 'darwin'
|
||||
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
|
||||
: context.appOutDir;
|
||||
const resources = path.join(appRoot, platform === 'darwin' ? 'Contents/Resources' : 'resources');
|
||||
const appFiles = verifyContents(path.join(resources, 'app.asar'), resources, platform, arch);
|
||||
const files = listFiles(appRoot);
|
||||
const unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
const report = {
|
||||
version: context.packager.appInfo.version,
|
||||
platform,
|
||||
arch,
|
||||
unpackedBytes,
|
||||
appDirectory: path.relative(context.outDir, appRoot),
|
||||
largestFiles: [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
largestAppFiles: [...appFiles].sort((a, b) => b.bytes - a.bytes).slice(0, 25),
|
||||
nativeBinaries: files.filter((entry) => /\.(node|dll|dylib)$|\.so(?:\.|$)/.test(entry.path)),
|
||||
artifacts: [],
|
||||
};
|
||||
const output = path.join(context.outDir, `package-size-${key}.json`);
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
||||
currentReports.add(output);
|
||||
console.log(
|
||||
`Package contents verified: ${key}, ${(unpackedBytes / MIB).toFixed(2)} MiB unpacked`,
|
||||
);
|
||||
}
|
||||
|
||||
function artifactKind(name) {
|
||||
if (name.endsWith('-mac.zip')) return 'mac.zip';
|
||||
if (name.endsWith('-win.zip')) return 'win.zip';
|
||||
const extension = path.extname(name).slice(1);
|
||||
return ['AppImage', 'dmg', 'exe'].includes(extension) ? extension : undefined;
|
||||
}
|
||||
|
||||
function compareSizes(report, previous) {
|
||||
assert.equal(previous.platform, report.platform);
|
||||
assert.equal(previous.arch, report.arch);
|
||||
assert(Number.isFinite(previous.unpackedBytes), 'Invalid previous size report');
|
||||
const previousArtifacts = Array.isArray(previous.artifacts) ? previous.artifacts : [];
|
||||
return {
|
||||
version: previous.version,
|
||||
unpackedDeltaBytes: report.unpackedBytes - previous.unpackedBytes,
|
||||
artifacts: report.artifacts.flatMap((artifact) => {
|
||||
const old = previousArtifacts.find(
|
||||
(entry) => entry && entry.kind === artifact.kind && Number.isFinite(entry.bytes),
|
||||
);
|
||||
return old ? [{ kind: artifact.kind, deltaBytes: artifact.bytes - old.bytes }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Runs after signing and installer creation, before release upload.
|
||||
async function afterAllArtifactBuild(result) {
|
||||
const reports = [];
|
||||
for (const reportPath of currentReports) {
|
||||
const filename = path.basename(reportPath);
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
const key = `${report.platform}-${report.arch}`;
|
||||
const files = listFiles(path.join(result.outDir, report.appDirectory));
|
||||
report.unpackedBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
report.largestFiles = [...files].sort((a, b) => b.bytes - a.bytes).slice(0, 25);
|
||||
report.artifacts = result.artifactPaths.flatMap((file) => {
|
||||
const kind = artifactKind(file);
|
||||
if (!kind) return [];
|
||||
const bytes = fs.statSync(file).size;
|
||||
return [{ name: path.basename(file), kind, bytes }];
|
||||
});
|
||||
const previousPath = path.join(result.outDir, '..', '.tmp', 'package-baseline', filename);
|
||||
if (fs.existsSync(previousPath)) {
|
||||
const previous = JSON.parse(fs.readFileSync(previousPath, 'utf8'));
|
||||
report.comparison = compareSizes(report, previous);
|
||||
}
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
const summary = [
|
||||
`### Package size: ${key}`,
|
||||
'',
|
||||
`Unpacked: ${(report.unpackedBytes / MIB).toFixed(2)} MiB`,
|
||||
...report.artifacts.map((entry) => `${entry.name}: ${(entry.bytes / MIB).toFixed(2)} MiB`),
|
||||
report.comparison
|
||||
? `Change from ${report.comparison.version}: ${(report.comparison.unpackedDeltaBytes / MIB).toFixed(2)} MiB unpacked`
|
||||
: 'No previous size report available.',
|
||||
'',
|
||||
].join('\n');
|
||||
console.log(summary);
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
||||
reports.push(reportPath);
|
||||
}
|
||||
assert(reports.length > 0, 'No package size reports generated by afterPack');
|
||||
return reports;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
auditPackage,
|
||||
verifyContents,
|
||||
verifyAppPath,
|
||||
listFiles,
|
||||
listAppFiles,
|
||||
compareSizes,
|
||||
default: afterAllArtifactBuild,
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync, createReadStream } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createPackageFromStreams } from '@electron/asar';
|
||||
import { FileMatcher, getFileMatchers } from 'app-builder-lib/out/fileMatcher';
|
||||
import config from '../package.json';
|
||||
import { listAppFiles, listFiles, compareSizes, verifyAppPath } from './package-audit.cjs';
|
||||
|
||||
test('platform packaging preserves the runtime allowlist after builder normalizes global filters', () => {
|
||||
const root = process.cwd();
|
||||
const fileStat = statSync('package.json');
|
||||
for (const platform of ['linux', 'mac', 'win'] as const) {
|
||||
const matchers = getFileMatchers(
|
||||
{ files: [{ filter: config.build.files }] },
|
||||
'files',
|
||||
'/tmp/subminer-filter-output',
|
||||
{
|
||||
defaultSrc: root,
|
||||
globalOutDir: path.join(root, 'release'),
|
||||
customBuildOptions: { files: config.build[platform].files },
|
||||
macroExpander: (value) => value.replaceAll('${arch}', 'x64'),
|
||||
},
|
||||
);
|
||||
assert(matchers);
|
||||
// This is builder's default for an exclusion-only platform matcher.
|
||||
for (const matcher of matchers) {
|
||||
if (matcher.containsOnlyIgnore()) matcher.prependPattern('**/*');
|
||||
}
|
||||
const included = (name: string) =>
|
||||
matchers.some((matcher) => matcher.createFilter()(path.join(root, name), fileStat));
|
||||
for (const name of [
|
||||
'dist/main-entry.js',
|
||||
'dist/fonts/MPLUS1[wght].ttf',
|
||||
'stats/dist/index.html',
|
||||
'vendor/texthooker-ui/docs/index.html',
|
||||
'package.json',
|
||||
]) {
|
||||
assert(included(name), `${platform} must ship ${name}`);
|
||||
}
|
||||
for (const name of [
|
||||
'.agents/skills/test.md',
|
||||
'src/main.ts',
|
||||
'scripts/build-yomitan.mjs',
|
||||
'docs-site/index.md',
|
||||
'dist/main.js.map',
|
||||
'dist/main.test.js',
|
||||
'dist/nested/source.ts',
|
||||
'dist/nested/__tests__/helper.js',
|
||||
'stats/dist/nested/fixtures/data.json',
|
||||
'vendor/texthooker-ui/docs/nested/component.tsx',
|
||||
'dist/launcher/subminer',
|
||||
'dist/settings/fonts/MPLUS1[wght].ttf',
|
||||
'vendor/subminer-yomitan/ext/manifest.json',
|
||||
]) {
|
||||
assert(!included(name), `${platform} must exclude ${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('dependency filters keep only the target Windows Koffi binary', () => {
|
||||
const root = process.cwd();
|
||||
for (const arch of ['x64', 'arm64']) {
|
||||
for (const platform of ['linux', 'mac', 'win'] as const) {
|
||||
const patterns = [
|
||||
'**/*',
|
||||
...config.build.files.filter((name) => name.startsWith('!')),
|
||||
...config.build[platform].files.filter((name) => name.startsWith('!')),
|
||||
];
|
||||
const filter = new FileMatcher(
|
||||
root,
|
||||
'/tmp/subminer-filter-output',
|
||||
(value) => value.replaceAll('${arch}', arch),
|
||||
patterns,
|
||||
).createFilter();
|
||||
const included = (name: string) =>
|
||||
filter(path.join(root, 'node_modules', name), statSync('package.json'));
|
||||
assert(included('@libsql/win32-x64-msvc/index.node'));
|
||||
assert(!included('axios/dist/axios.js.map'));
|
||||
assert(!included('koffi/src/koffi/src/ffi.c'));
|
||||
assert(!included('agent-base/src/index.ts'));
|
||||
assert(!included('@discordjs/rest/dist/index.d.mts'));
|
||||
assert(!included('example/lib/tests/helper.js'));
|
||||
for (const target of [
|
||||
'win32_x64',
|
||||
'win32_arm64',
|
||||
'linux_x64',
|
||||
'darwin_arm64',
|
||||
'openbsd_x64',
|
||||
]) {
|
||||
assert.equal(
|
||||
included(`koffi/build/koffi/${target}/koffi.node`),
|
||||
platform === 'win' && target === `win32_${arch}`,
|
||||
`${platform}/${arch}: ${target}`,
|
||||
);
|
||||
}
|
||||
assert.equal(included('koffi/index.js'), platform === 'win');
|
||||
assert.equal(included('koffi/LICENSE.txt'), platform === 'win');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('content audit rejects development files beneath approved roots', () => {
|
||||
for (const root of ['dist', 'stats/dist', 'vendor/texthooker-ui/docs', 'node_modules/example']) {
|
||||
for (const suffix of [
|
||||
'nested/source.ts',
|
||||
'nested/component.tsx',
|
||||
'nested/types.d.mts',
|
||||
'nested/source.cts',
|
||||
'nested/__tests__/helper.js',
|
||||
'nested/tests/helper.js',
|
||||
'nested/test/helper.js',
|
||||
'nested/__fixtures__/data.json',
|
||||
'nested/fixtures/data.json',
|
||||
'nested/fixture/data.json',
|
||||
'nested/component.spec.js',
|
||||
'nested/component.test.cjs',
|
||||
]) {
|
||||
assert.throws(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'), /Packaged/);
|
||||
}
|
||||
for (const suffix of ['nested/runtime.js', 'nested/style.css', 'nested/data.json']) {
|
||||
assert.doesNotThrow(() => verifyAppPath(`${root}/${suffix}`, 'linux', 'x64'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('archive inventory handles native files without counting them twice on disk', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'subminer-audit-'));
|
||||
try {
|
||||
const input = path.join(root, 'input');
|
||||
const output = path.join(root, 'output');
|
||||
mkdirSync(input);
|
||||
mkdirSync(output);
|
||||
writeFileSync(path.join(input, 'main.js'), 'hello');
|
||||
writeFileSync(path.join(input, 'native.node'), 'native');
|
||||
const archive = path.join(output, 'app.asar');
|
||||
await createPackageFromStreams(
|
||||
archive,
|
||||
['main.js', 'native.node'].map((name) => ({
|
||||
path: name,
|
||||
type: 'file',
|
||||
unpacked: name.endsWith('.node'),
|
||||
stat: statSync(path.join(input, name)),
|
||||
streamGenerator: () => createReadStream(path.join(input, name)),
|
||||
})),
|
||||
);
|
||||
assert.deepEqual(listAppFiles(archive), [
|
||||
{ path: 'main.js', bytes: 5 },
|
||||
{ path: 'native.node', bytes: 6 },
|
||||
]);
|
||||
assert.equal(
|
||||
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
|
||||
statSync(archive).size + 6,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('size comparison tolerates older reports without artifact measurements', () => {
|
||||
const previous = { version: '0.19.6', platform: 'linux', arch: 'x64', unpackedBytes: 100 };
|
||||
const current = { ...previous, unpackedBytes: 80, artifacts: [{ kind: 'AppImage', bytes: 40 }] };
|
||||
assert.deepEqual(compareSizes(current, previous), {
|
||||
version: '0.19.6',
|
||||
unpackedDeltaBytes: -20,
|
||||
artifacts: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
compareSizes(current, { ...previous, artifacts: [null, { kind: 'AppImage', bytes: 50 }] })
|
||||
.artifacts,
|
||||
[{ kind: 'AppImage', deltaBytes: -10 }],
|
||||
);
|
||||
assert.throws(
|
||||
() => compareSizes(current, { ...previous, unpackedBytes: 'unknown' }),
|
||||
/Invalid previous size report/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-source-manifest.json');
|
||||
export const DEFAULT_RUNTIME_MANIFEST_PATH = path.join(
|
||||
repoRoot,
|
||||
'build',
|
||||
'bun-runtime-manifest.json',
|
||||
);
|
||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||
export const DEFAULT_OUTPUT_DIR = path.join(repoRoot, 'release');
|
||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-corresponding-source');
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requiredString(record, key, source) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${source} must contain a non-empty ${key} string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertSafeRelativePath(value, source) {
|
||||
if (path.isAbsolute(value) || value.split(/[\\/]/).includes('..')) {
|
||||
throw new Error(`${source} contains an unsafe path: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSourceManifest(value) {
|
||||
if (!isRecord(value) || value.schemaVersion !== 1) {
|
||||
throw new Error('Bun source manifest must use schemaVersion 1.');
|
||||
}
|
||||
const version = requiredString(value, 'version', 'Bun source manifest');
|
||||
const bunRevision = requiredString(value, 'bunRevision', 'Bun source manifest');
|
||||
const archiveName = requiredString(value, 'archiveName', 'Bun source manifest');
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version) || !/^[a-f0-9]{40}$/.test(bunRevision)) {
|
||||
throw new Error('Bun source manifest has an invalid version or bunRevision.');
|
||||
}
|
||||
if (path.basename(archiveName) !== archiveName || !archiveName.endsWith('.tar.gz')) {
|
||||
throw new Error('Bun source manifest archiveName must be a safe .tar.gz filename.');
|
||||
}
|
||||
if (!Array.isArray(value.sources) || value.sources.length === 0) {
|
||||
throw new Error('Bun source manifest must contain sources.');
|
||||
}
|
||||
|
||||
const names = new Set();
|
||||
const destinations = new Set();
|
||||
const sources = value.sources.map((entry, index) => {
|
||||
if (!isRecord(entry)) throw new Error(`Bun source ${index} must be an object.`);
|
||||
const name = requiredString(entry, 'name', `Bun source ${index}`);
|
||||
const repository = requiredString(entry, 'repository', `Bun source ${name}`);
|
||||
const revision = requiredString(entry, 'revision', `Bun source ${name}`);
|
||||
const destination = requiredString(entry, 'destination', `Bun source ${name}`);
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repository) || !/^[a-f0-9]{40}$/.test(revision)) {
|
||||
throw new Error(`Bun source ${name} has an invalid repository or revision.`);
|
||||
}
|
||||
assertSafeRelativePath(destination, `Bun source ${name}`);
|
||||
if (names.has(name) || destinations.has(destination)) {
|
||||
throw new Error(`Bun source manifest repeats ${name} or ${destination}.`);
|
||||
}
|
||||
names.add(name);
|
||||
destinations.add(destination);
|
||||
|
||||
const transport = entry.transport ?? 'archive';
|
||||
if (transport !== 'archive' && transport !== 'git-sparse') {
|
||||
throw new Error(`Bun source ${name} has unsupported transport ${transport}.`);
|
||||
}
|
||||
const sha256 =
|
||||
transport === 'archive' ? requiredString(entry, 'sha256', `Bun source ${name}`) : null;
|
||||
if (sha256 !== null && !/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error(`Bun source ${name} has an invalid SHA-256 digest.`);
|
||||
}
|
||||
if (!Array.isArray(entry.licensePaths) || entry.licensePaths.length === 0) {
|
||||
throw new Error(`Bun source ${name} must declare licensePaths.`);
|
||||
}
|
||||
const licensePaths = entry.licensePaths.map((licensePath) => {
|
||||
if (typeof licensePath !== 'string' || licensePath.length === 0) {
|
||||
throw new Error(`Bun source ${name} has an invalid license path.`);
|
||||
}
|
||||
assertSafeRelativePath(licensePath, `Bun source ${name}`);
|
||||
return licensePath;
|
||||
});
|
||||
const exclude = Array.isArray(entry.exclude) ? entry.exclude : [];
|
||||
for (const excludedPath of exclude) assertSafeRelativePath(excludedPath, `Bun source ${name}`);
|
||||
return {
|
||||
...entry,
|
||||
name,
|
||||
repository,
|
||||
revision,
|
||||
destination,
|
||||
transport,
|
||||
sha256,
|
||||
licensePaths,
|
||||
exclude,
|
||||
};
|
||||
});
|
||||
|
||||
const bun = sources.find((source) => source.name === 'bun');
|
||||
if (!bun || bun.revision !== bunRevision || bun.destination !== 'bun') {
|
||||
throw new Error('Bun source manifest must map bunRevision to the bun source at bun/.');
|
||||
}
|
||||
return { ...value, version, bunRevision, archiveName, sources };
|
||||
}
|
||||
|
||||
export function parseRegisteredRepositories(cmakeText) {
|
||||
const registrations = new Map();
|
||||
const uncommented = cmakeText.replace(/#[^\n]*/g, '');
|
||||
for (const match of uncommented.matchAll(/register_repository\(([\s\S]*?)\)/g)) {
|
||||
const body = match[1];
|
||||
const name = /\bNAME\s+([^\s#)]+)/.exec(body)?.[1];
|
||||
const repository = /\bREPOSITORY\s+([^\s#)]+)/.exec(body)?.[1];
|
||||
const reference = /\b(COMMIT|TAG)\s+(?:#[^\n]*\n\s*)?([^\s#)]+)/.exec(body);
|
||||
if (name && repository && reference) {
|
||||
registrations.set(name, {
|
||||
repository,
|
||||
kind: reference[1].toLowerCase(),
|
||||
reference: reference[2],
|
||||
});
|
||||
}
|
||||
}
|
||||
return registrations;
|
||||
}
|
||||
|
||||
export function validateRuntimeAlignment(manifest, packageJson, runtimeManifest) {
|
||||
if (!isRecord(packageJson) || packageJson.packageManager !== `bun@${manifest.version}`) {
|
||||
throw new Error(
|
||||
`package.json must pin bun@${manifest.version} to match the Bun source manifest.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(runtimeManifest)) throw new Error('Bun runtime manifest must be an object.');
|
||||
for (const key of ['version', 'bunRevision']) {
|
||||
if (runtimeManifest[key] !== manifest[key]) {
|
||||
throw new Error(`Bun runtime manifest ${key} does not match the Bun source manifest.`);
|
||||
}
|
||||
}
|
||||
if (runtimeManifest.correspondingSourceAsset !== manifest.archiveName) {
|
||||
throw new Error(
|
||||
'Bun runtime manifest correspondingSourceAsset does not match the Bun source manifest.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBunPins(manifest, cmakeTexts, setupWebKitText) {
|
||||
const actual = new Map();
|
||||
for (const cmakeText of cmakeTexts) {
|
||||
for (const [name, registration] of parseRegisteredRepositories(cmakeText)) {
|
||||
if (actual.has(name)) throw new Error(`Bun registers ${name} more than once.`);
|
||||
actual.set(name, registration);
|
||||
}
|
||||
}
|
||||
const expected = new Map(
|
||||
manifest.sources
|
||||
.filter((source) => source.destination.startsWith('bun/vendor/') && source.name !== 'WebKit')
|
||||
.map((source) => [source.name, source]),
|
||||
);
|
||||
for (const [name, registration] of actual) {
|
||||
const source = expected.get(name);
|
||||
if (!source) throw new Error(`Source manifest omits Bun dependency ${name}.`);
|
||||
if (source.repository !== registration.repository) {
|
||||
throw new Error(`Source manifest repository mismatch for ${name}.`);
|
||||
}
|
||||
const expectedReference = source.upstreamReference ?? source.revision;
|
||||
if (expectedReference !== registration.reference) {
|
||||
throw new Error(`Source manifest revision mismatch for ${name}.`);
|
||||
}
|
||||
expected.delete(name);
|
||||
}
|
||||
if (expected.size > 0) {
|
||||
throw new Error(
|
||||
`Source manifest has unregistered Bun dependencies: ${[...expected.keys()].join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const webKitPin = /set\(WEBKIT_VERSION\s+([a-f0-9]{40})\)/.exec(setupWebKitText)?.[1];
|
||||
const webKit = manifest.sources.find((source) => source.name === 'WebKit');
|
||||
if (!webKitPin || !webKit || webKit.revision !== webKitPin) {
|
||||
throw new Error('Source manifest WebKit revision does not match SetupWebKit.cmake.');
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(filePath) {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
async function run(command, args, options = {}) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: 'inherit', ...options });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${command} exited with status ${code}.`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadArchive(source, cacheDir, fetchImpl) {
|
||||
const archivePath = path.join(cacheDir, `${source.name}-${source.revision}.tar.gz`);
|
||||
try {
|
||||
if ((await sha256File(archivePath)) === source.sha256) return archivePath;
|
||||
await fs.rm(archivePath, { force: true });
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const url = `https://codeload.github.com/${source.repository}/tar.gz/${source.revision}`;
|
||||
const temporaryPath = `${archivePath}.download-${randomUUID()}`;
|
||||
try {
|
||||
const response = await fetchImpl(url);
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`Unable to download ${url}: HTTP ${response.status}`);
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
createWriteStream(temporaryPath, { flags: 'wx' }),
|
||||
);
|
||||
const actualSha256 = await sha256File(temporaryPath);
|
||||
if (actualSha256 !== source.sha256) {
|
||||
throw new Error(
|
||||
`Source checksum mismatch for ${source.name}: expected ${source.sha256}, received ${actualSha256}.`,
|
||||
);
|
||||
}
|
||||
await fs.rename(temporaryPath, archivePath);
|
||||
return archivePath;
|
||||
} finally {
|
||||
// Cleanup must not replace the original download, validation, or rename error.
|
||||
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeArchive(source, root, cacheDir, fetchImpl) {
|
||||
const archivePath = await downloadArchive(source, cacheDir, fetchImpl);
|
||||
const destination = path.join(root, source.destination);
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
await run('tar', ['-xzf', archivePath, '-C', destination, '--strip-components=1']);
|
||||
}
|
||||
|
||||
async function materializeGitSparse(source, root, cacheDir) {
|
||||
const checkout = path.join(cacheDir, `${source.name}-${source.revision}-git`);
|
||||
await fs.rm(checkout, { recursive: true, force: true });
|
||||
await run('git', [
|
||||
'clone',
|
||||
'--filter=blob:none',
|
||||
'--no-checkout',
|
||||
'--depth=1',
|
||||
`https://github.com/${source.repository}.git`,
|
||||
checkout,
|
||||
]);
|
||||
await run('git', ['-C', checkout, 'fetch', '--depth=1', 'origin', source.revision]);
|
||||
await run('git', ['-C', checkout, 'sparse-checkout', 'init', '--no-cone']);
|
||||
const sparseRules = ['/*', ...source.exclude.map((entry) => `!/${entry}/`), ''];
|
||||
await fs.writeFile(
|
||||
path.join(checkout, '.git', 'info', 'sparse-checkout'),
|
||||
sparseRules.join('\n'),
|
||||
);
|
||||
await run('git', ['-C', checkout, 'checkout', '--detach', source.revision]);
|
||||
const actualRevision = (await fs.readFile(path.join(checkout, '.git', 'HEAD'), 'utf8')).trim();
|
||||
if (actualRevision !== source.revision)
|
||||
throw new Error(`Git checkout mismatch for ${source.name}.`);
|
||||
await fs.rm(path.join(checkout, '.git'), { recursive: true, force: true });
|
||||
await fs.mkdir(path.dirname(path.join(root, source.destination)), { recursive: true });
|
||||
await fs.rename(checkout, path.join(root, source.destination));
|
||||
}
|
||||
|
||||
async function applyBunDependencyPatches(root, manifest) {
|
||||
const bunRoot = path.join(root, 'bun');
|
||||
for (const source of manifest.sources) {
|
||||
if (!source.destination.startsWith('bun/vendor/') || source.name === 'WebKit') continue;
|
||||
const destination = path.join(root, source.destination);
|
||||
const patchDirectory = path.join(bunRoot, 'patches', source.name);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(patchDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const patchPath = path.join(patchDirectory, entry.name);
|
||||
if (entry.isFile() && entry.name.endsWith('.patch')) {
|
||||
await run(
|
||||
'git',
|
||||
['apply', '--ignore-whitespace', '--ignore-space-change', '--no-index', patchPath],
|
||||
{ cwd: destination },
|
||||
);
|
||||
} else if (entry.isFile()) {
|
||||
await fs.copyFile(patchPath, path.join(destination, entry.name));
|
||||
}
|
||||
}
|
||||
const cmakeReference = source.upstreamReference
|
||||
? `refs/tags/${source.upstreamReference}`
|
||||
: source.revision;
|
||||
await fs.writeFile(path.join(destination, '.ref'), `${cmakeReference}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndCollectLicenses(root, manifest) {
|
||||
const licensesRoot = path.join(root, 'THIRD-PARTY-LICENSES');
|
||||
await fs.mkdir(licensesRoot, { recursive: true });
|
||||
for (const source of manifest.sources) {
|
||||
const target = path.join(licensesRoot, source.name);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
for (const licensePath of source.licensePaths) {
|
||||
const sourcePath = path.join(root, source.destination, licensePath);
|
||||
const stat = await fs.stat(sourcePath).catch(() => null);
|
||||
if (!stat?.isFile() || stat.size === 0) {
|
||||
throw new Error(`Missing required license material for ${source.name}: ${licensePath}`);
|
||||
}
|
||||
const safeName = licensePath.replaceAll('/', '__');
|
||||
await fs.copyFile(sourcePath, path.join(target, safeName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildReadme(manifest) {
|
||||
const webKit = manifest.sources.find((source) => source.name === 'WebKit');
|
||||
const tinycc = manifest.sources.find((source) => source.name === 'tinycc');
|
||||
return `# Bun ${manifest.version} corresponding source and rebuild materials
|
||||
|
||||
This archive matches the official Bun ${manifest.version} binaries whose \`bun --revision\` output names commit \`${manifest.bunRevision}\`. The GitHub release tag points to \`${manifest.releaseTagCommit}\`, one later commit, so this package intentionally uses the binary revision.
|
||||
|
||||
The archive includes Bun's complete source tree, Bun's build scripts and dependency patches, every external repository registered by Bun's CMake build at its exact pin, and Oven's WebKit fork at \`${webKit.revision}\`. Large WebKit test-only trees are excluded. The JavaScriptCore, WTF, WebCore, build-tool, configuration, and resource trees used to build the library are included. TinyCC is \`${tinycc.revision}\`.
|
||||
|
||||
## Rebuild with modified JavaScriptCore
|
||||
|
||||
Install the prerequisites recorded in \`bun/.buildkite/Dockerfile\`, \`bun/scripts/bootstrap.sh\`, and the WebKit platform build scripts. Bun ${manifest.version} used LLVM 19.1.7, CMake 3.30.5 in its Linux build image, and Bun 1.1.38 as the bootstrap runtime. Its Rust input was nightly and was not pinned to a dated toolchain in the release source.
|
||||
|
||||
From this archive root on Linux or macOS:
|
||||
|
||||
\`\`\`sh
|
||||
cd bun
|
||||
bun install --frozen-lockfile
|
||||
bun run jsc:build
|
||||
bun run build:release:local -- -DVERSION=${manifest.version} -DREVISION=${manifest.bunRevision}
|
||||
\`\`\`
|
||||
|
||||
The first command uses the bootstrap Bun. \`jsc:build\` builds the included \`vendor/WebKit\` checkout into \`vendor/WebKit/WebKitBuild/Release\`. \`build:release:local\` links Bun against that local JavaScriptCore build. The explicit version and revision replace metadata that Bun normally reads from its Git checkout. The included \`vendor/*/.ref\` files prevent Bun's CMake rules from replacing the packaged dependency sources, and this package has already applied the files under \`bun/patches/<dependency>/\` in the same order as \`bun/cmake/scripts/GitClone.cmake\`.
|
||||
|
||||
The archive vendors the source repositories that Bun's CMake build links into the executable. It preserves Bun's \`bun.lock\` files and lol-html's \`Cargo.lock\`, but it does not vendor npm packages, crates.io packages used to build lol-html, compilers, SDKs, or other build tools. The rebuild therefore needs network access for those pinned package-manager inputs. License notices embedded in those downloaded packages are outside the collected \`THIRD-PARTY-LICENSES\` directory's scope.
|
||||
|
||||
Windows uses the prerequisites in \`bun/docs/project/building-windows.mdx\` and WebKit's \`windows-release.ps1\`. The local-JavaScriptCore path above has not been verified on Windows.
|
||||
|
||||
These instructions describe the source and build entry points. Toolchain and generated-output differences mean a rebuild is not expected to be byte-for-byte identical to Oven's release binary. No claim about legal compliance or reproducible builds is made here.
|
||||
`;
|
||||
}
|
||||
|
||||
async function createDeterministicArchive(stagingParent, rootName, outputPath) {
|
||||
const temporaryTar = `${outputPath}.tar-${randomUUID()}`;
|
||||
const temporaryGzip = `${outputPath}.gzip-${randomUUID()}`;
|
||||
try {
|
||||
await run('tar', [
|
||||
'--sort=name',
|
||||
'--mtime=@0',
|
||||
'--owner=0',
|
||||
'--group=0',
|
||||
'--numeric-owner',
|
||||
'-cf',
|
||||
temporaryTar,
|
||||
'-C',
|
||||
stagingParent,
|
||||
rootName,
|
||||
]);
|
||||
const gzip = spawn('gzip', ['-n', '-9', '-c', temporaryTar], {
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
const completion = new Promise((resolve, reject) => {
|
||||
gzip.once('error', reject);
|
||||
gzip.once('close', resolve);
|
||||
});
|
||||
const [, code] = await Promise.all([
|
||||
pipeline(gzip.stdout, createWriteStream(temporaryGzip, { flags: 'wx' })),
|
||||
completion,
|
||||
]);
|
||||
if (code !== 0) throw new Error(`gzip exited with status ${code}.`);
|
||||
await fs.rm(outputPath, { force: true });
|
||||
await fs.rename(temporaryGzip, outputPath);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.rm(temporaryTar, { force: true }),
|
||||
fs.rm(temporaryGzip, { force: true }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function packageBunSource({
|
||||
manifestPath = DEFAULT_MANIFEST_PATH,
|
||||
runtimeManifestPath = DEFAULT_RUNTIME_MANIFEST_PATH,
|
||||
packageJsonPath = DEFAULT_PACKAGE_JSON_PATH,
|
||||
outputDir = DEFAULT_OUTPUT_DIR,
|
||||
cacheDir = DEFAULT_CACHE_DIR,
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {}) {
|
||||
const [manifestText, runtimeManifestText, packageJsonText] = await Promise.all([
|
||||
fs.readFile(manifestPath, 'utf8'),
|
||||
fs.readFile(runtimeManifestPath, 'utf8'),
|
||||
fs.readFile(packageJsonPath, 'utf8'),
|
||||
]);
|
||||
const manifest = parseSourceManifest(JSON.parse(manifestText));
|
||||
validateRuntimeAlignment(manifest, JSON.parse(packageJsonText), JSON.parse(runtimeManifestText));
|
||||
if (typeof fetchImpl !== 'function') throw new Error('No fetch implementation is available.');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const stagingParent = await fs.mkdtemp(path.join(cacheDir, 'assemble-'));
|
||||
const rootName = path.basename(manifest.archiveName, '.tar.gz');
|
||||
const root = path.join(stagingParent, rootName);
|
||||
await fs.mkdir(root);
|
||||
|
||||
try {
|
||||
const bun = manifest.sources.find((source) => source.name === 'bun');
|
||||
await materializeArchive(bun, root, cacheDir, fetchImpl);
|
||||
const cmakeFiles = (await fs.readdir(path.join(root, 'bun', 'cmake', 'targets')))
|
||||
.filter((name) => name.endsWith('.cmake'))
|
||||
.map((name) => fs.readFile(path.join(root, 'bun', 'cmake', 'targets', name), 'utf8'));
|
||||
validateBunPins(
|
||||
manifest,
|
||||
await Promise.all(cmakeFiles),
|
||||
await fs.readFile(path.join(root, 'bun', 'cmake', 'tools', 'SetupWebKit.cmake'), 'utf8'),
|
||||
);
|
||||
|
||||
for (const source of manifest.sources.filter((entry) => entry.name !== 'bun')) {
|
||||
if (source.transport === 'git-sparse') await materializeGitSparse(source, root, cacheDir);
|
||||
else await materializeArchive(source, root, cacheDir, fetchImpl);
|
||||
}
|
||||
await applyBunDependencyPatches(root, manifest);
|
||||
await validateAndCollectLicenses(root, manifest);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'SOURCE-INVENTORY.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(root, 'README-REBUILD.md'), rebuildReadme(manifest));
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const outputPath = path.join(outputDir, manifest.archiveName);
|
||||
await createDeterministicArchive(stagingParent, rootName, outputPath);
|
||||
const digest = await sha256File(outputPath);
|
||||
await fs.writeFile(`${outputPath}.sha256`, `${digest} ${manifest.archiveName}\n`);
|
||||
return { outputPath, sha256: digest };
|
||||
} finally {
|
||||
await fs.rm(stagingParent, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const result = await packageBunSource();
|
||||
console.log(`${result.sha256} ${path.basename(result.outputPath)}`);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
downloadArchive,
|
||||
parseRegisteredRepositories,
|
||||
parseSourceManifest,
|
||||
validateBunPins,
|
||||
validateRuntimeAlignment,
|
||||
} from './package-bun-source.mjs';
|
||||
|
||||
const projectRoot = path.resolve(import.meta.dir, '..');
|
||||
|
||||
describe('Bun corresponding-source manifest', () => {
|
||||
test('removes partial downloads when placing a valid archive fails', async () => {
|
||||
const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-source-test-'));
|
||||
try {
|
||||
const payload = new Uint8Array([1, 2, 3]);
|
||||
const source = {
|
||||
name: 'fixture',
|
||||
repository: 'example/fixture',
|
||||
revision: 'a'.repeat(40),
|
||||
sha256: createHash('sha256').update(payload).digest('hex'),
|
||||
};
|
||||
const archivePath = path.join(cacheDir, `${source.name}-${source.revision}.tar.gz`);
|
||||
await expect(
|
||||
downloadArchive(source, cacheDir, async () => {
|
||||
await fs.mkdir(archivePath);
|
||||
return { ok: true, status: 200, body: new Response(payload).body };
|
||||
}),
|
||||
).rejects.toMatchObject({ syscall: 'rename' });
|
||||
|
||||
const entries = await fs.readdir(cacheDir);
|
||||
expect(entries.filter((entry) => entry.includes('.download-'))).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('pins the source to the revision reported by the distributed binary', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(manifest.version).toBe('1.3.5');
|
||||
expect(manifest.bunRevision).toBe('1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
||||
expect(manifest.releaseTagCommit).toBe('fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab');
|
||||
expect(manifest.sources.find((source) => source.name === 'WebKit')?.revision).toBe(
|
||||
'6d0f3aac0b817cc01a846b3754b21271adedac12',
|
||||
);
|
||||
expect(manifest.sources.find((source) => source.name === 'tinycc')?.revision).toBe(
|
||||
'29985a3b59898861442fa3b43f663fc1af2591d7',
|
||||
);
|
||||
});
|
||||
|
||||
test('parses commit and tag registrations from Bun CMake', () => {
|
||||
const registrations = parseRegisteredRepositories(`
|
||||
register_repository(
|
||||
NAME tinycc
|
||||
REPOSITORY oven-sh/tinycc
|
||||
COMMIT
|
||||
# A comment between the field and its value is valid CMake.
|
||||
29985a3b59898861442fa3b43f663fc1af2591d7
|
||||
)
|
||||
register_repository(
|
||||
NAME brotli
|
||||
REPOSITORY google/brotli
|
||||
TAG v1.1.0
|
||||
)
|
||||
`);
|
||||
|
||||
expect(registrations.get('tinycc')).toEqual({
|
||||
repository: 'oven-sh/tinycc',
|
||||
kind: 'commit',
|
||||
reference: '29985a3b59898861442fa3b43f663fc1af2591d7',
|
||||
});
|
||||
expect(registrations.get('brotli')).toEqual({
|
||||
repository: 'google/brotli',
|
||||
kind: 'tag',
|
||||
reference: 'v1.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects stale runtime or package-manager pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const runtimeManifest = {
|
||||
version: manifest.version,
|
||||
bunRevision: manifest.bunRevision,
|
||||
correspondingSourceAsset: manifest.archiveName,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(manifest, { packageManager: 'bun@1.3.5' }, runtimeManifest),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(manifest, { packageManager: 'bun@1.3.6' }, runtimeManifest),
|
||||
).toThrow('package.json must pin bun@1.3.5');
|
||||
expect(() =>
|
||||
validateRuntimeAlignment(
|
||||
manifest,
|
||||
{ packageManager: 'bun@1.3.5' },
|
||||
{
|
||||
...runtimeManifest,
|
||||
correspondingSourceAsset: 'stale.tar.gz',
|
||||
},
|
||||
),
|
||||
).toThrow('correspondingSourceAsset does not match');
|
||||
});
|
||||
|
||||
test('rejects drift in CMake dependency and WebKit pins', async () => {
|
||||
const manifest = parseSourceManifest(
|
||||
JSON.parse(
|
||||
await fs.readFile(path.join(projectRoot, 'build/bun-source-manifest.json'), 'utf8'),
|
||||
),
|
||||
);
|
||||
const registrations = manifest.sources
|
||||
.filter((source) => source.destination.startsWith('bun/vendor/') && source.name !== 'WebKit')
|
||||
.map(
|
||||
(source) => `register_repository(
|
||||
NAME ${source.name}
|
||||
REPOSITORY ${source.repository}
|
||||
${source.upstreamReference ? 'TAG' : 'COMMIT'} ${source.upstreamReference ?? source.revision}
|
||||
)`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
expect(() =>
|
||||
validateBunPins(
|
||||
manifest,
|
||||
[registrations],
|
||||
'set(WEBKIT_VERSION 6d0f3aac0b817cc01a846b3754b21271adedac12)',
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateBunPins(
|
||||
manifest,
|
||||
[registrations.replace('29985a3b59898861442fa3b43f663fc1af2591d7', '0'.repeat(40))],
|
||||
'set(WEBKIT_VERSION 6d0f3aac0b817cc01a846b3754b21271adedac12)',
|
||||
),
|
||||
).toThrow('revision mismatch for tinycc');
|
||||
expect(() =>
|
||||
validateBunPins(manifest, [registrations], `set(WEBKIT_VERSION ${'0'.repeat(40)})`),
|
||||
).toThrow('WebKit revision does not match');
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,6 @@ function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
|
||||
for (const stylesheet of stylesheets) {
|
||||
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
|
||||
}
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
|
||||
}
|
||||
|
||||
@@ -116,6 +112,10 @@ function buildMacosHelper() {
|
||||
}
|
||||
|
||||
function main() {
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const resources = process.argv[2];
|
||||
if (!resources) throw new Error('Usage: bun run test:package <resources-directory>');
|
||||
const require = createRequire(import.meta.url);
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-package-smoke-'));
|
||||
const env = { ...process.env, SUBMINER_PACKAGE_SMOKE_DATA: profile };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
const result = spawnSync(
|
||||
require('electron'),
|
||||
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)],
|
||||
{ env, stdio: 'inherit', timeout: 75_000 },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Run with the pinned Electron runtime against a finished app's resources folder.
|
||||
const { app, BrowserWindow, session } = require('electron');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const assert = require('node:assert/strict');
|
||||
const { once } = require('node:events');
|
||||
|
||||
const resources = path.resolve(process.argv[2]);
|
||||
const archive = path.join(resources, 'app.asar');
|
||||
const isolatedData = process.env.SUBMINER_PACKAGE_SMOKE_DATA;
|
||||
assert(
|
||||
isolatedData && fs.existsSync(isolatedData),
|
||||
'Use bun run test:package to create an isolated profile',
|
||||
);
|
||||
app.setPath('userData', isolatedData);
|
||||
app.disableHardwareAcceleration();
|
||||
app.on('window-all-closed', () => {});
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('Package smoke timed out');
|
||||
app.exit(1);
|
||||
}, 60_000);
|
||||
|
||||
async function smoke() {
|
||||
await app.whenReady();
|
||||
const packagedRequire = createRequire(path.join(archive, 'package.json'));
|
||||
const Database = packagedRequire('libsql');
|
||||
const database = new Database(':memory:');
|
||||
assert.equal(database.prepare('select 42 as answer').get().answer, 42);
|
||||
database.close();
|
||||
if (process.platform === 'win32') {
|
||||
const win32 = packagedRequire('./dist/window-trackers/win32.js');
|
||||
assert(Array.isArray(win32.findMpvWindows().matches));
|
||||
}
|
||||
const { Texthooker } = packagedRequire('./dist/core/services/texthooker.js');
|
||||
const texthooker = new Texthooker();
|
||||
const server = texthooker.start(0);
|
||||
assert(server, 'Packaged texthooker assets could not be found');
|
||||
try {
|
||||
await once(server, 'listening');
|
||||
const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
|
||||
assert.equal(response.status, 200);
|
||||
assert((await response.text()).includes('<html'));
|
||||
} finally {
|
||||
texthooker.stop();
|
||||
}
|
||||
const extension = await session.defaultSession.extensions.loadExtension(
|
||||
path.join(resources, 'yomitan'),
|
||||
{ allowFileAccess: true },
|
||||
);
|
||||
assert(extension.id, 'Yomitan extension failed to load');
|
||||
const failedRequests = [];
|
||||
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => {
|
||||
if (details.error !== 'net::ERR_ABORTED')
|
||||
failedRequests.push(`${details.url}: ${details.error}`);
|
||||
});
|
||||
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
|
||||
const win = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
sandbox: false,
|
||||
preload: path.join(archive, 'dist', ui === 'renderer' ? 'preload.js' : `preload-${ui}.js`),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await win.loadFile(
|
||||
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`),
|
||||
);
|
||||
if (ui !== 'stats') {
|
||||
const loaded = await win.webContents.executeJavaScript(
|
||||
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
|
||||
);
|
||||
assert(loaded, `${ui}: shared Japanese font failed to load`);
|
||||
}
|
||||
} finally {
|
||||
win.destroy();
|
||||
}
|
||||
}
|
||||
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
|
||||
console.log(
|
||||
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
|
||||
);
|
||||
}
|
||||
|
||||
smoke()
|
||||
.then(() => {
|
||||
clearTimeout(timeout);
|
||||
app.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearTimeout(timeout);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { promisify } from 'node:util';
|
||||
import { execFile } from 'node:child_process';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
export const DEFAULT_PACKAGE_JSON_PATH = path.join(repoRoot, 'package.json');
|
||||
export const DEFAULT_MANIFEST_PATH = path.join(repoRoot, 'build', 'bun-runtime-manifest.json');
|
||||
export const DEFAULT_CACHE_DIR = path.join(repoRoot, '.tmp', 'bun-runtime');
|
||||
export const DEFAULT_LICENSES_SOURCE_DIR = path.join(repoRoot, 'resources', 'bun', 'licenses');
|
||||
export const STAGED_METADATA_FILE = 'metadata.json';
|
||||
export const REQUIRED_LICENSE_FILES = [
|
||||
'Bun-LICENSE.md',
|
||||
'LGPL-2.0.txt',
|
||||
'LGPL-2.1.txt',
|
||||
'SOURCE.md',
|
||||
'THIRD-PARTY-NOTICES.md',
|
||||
];
|
||||
|
||||
const RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download';
|
||||
const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'win32']);
|
||||
const ARCH_BY_BUILDER_VALUE = new Map([
|
||||
[1, 'x64'],
|
||||
[3, 'arm64'],
|
||||
['x64', 'x64'],
|
||||
['arm64', 'arm64'],
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readRequiredString(record, key, source) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new Error(`${source} must contain a non-empty ${key} string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCommit(record, key, source) {
|
||||
const value = readRequiredString(record, key, source);
|
||||
if (!/^[a-f0-9]{40}$/.test(value)) {
|
||||
throw new Error(`${source} must contain a 40-character ${key} commit.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeTarget(platform, arch) {
|
||||
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
||||
throw new Error(`Unsupported Bun runtime target platform: ${platform}`);
|
||||
}
|
||||
|
||||
const normalizedArch = ARCH_BY_BUILDER_VALUE.get(arch);
|
||||
if (!normalizedArch) {
|
||||
throw new Error(`Unsupported Bun runtime target architecture for ${platform}: ${String(arch)}`);
|
||||
}
|
||||
if (platform === 'win32' && normalizedArch !== 'x64') {
|
||||
throw new Error(`Unsupported Bun runtime target: ${platform}-${normalizedArch}`);
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
arch: normalizedArch,
|
||||
key: `${platform}-${normalizedArch}`,
|
||||
executableName: platform === 'win32' ? 'bun.exe' : 'bun',
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePackageManagerVersion(packageJson) {
|
||||
if (!isRecord(packageJson)) {
|
||||
throw new Error('package.json must contain a JSON object.');
|
||||
}
|
||||
const packageManager = readRequiredString(packageJson, 'packageManager', 'package.json');
|
||||
const match = /^bun@(\d+\.\d+\.\d+)$/.exec(packageManager);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`package.json packageManager must pin Bun exactly, received ${packageManager}.`,
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function parseArtifact(value, key) {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`Bun runtime manifest artifact ${key} must be an object.`);
|
||||
}
|
||||
const file = readRequiredString(value, 'file', `Bun runtime manifest artifact ${key}`);
|
||||
const sha256 = readRequiredString(value, 'sha256', `Bun runtime manifest artifact ${key}`);
|
||||
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
||||
throw new Error(`Bun runtime manifest artifact ${key} has an invalid SHA-256 digest.`);
|
||||
}
|
||||
if (path.basename(file) !== file || !file.endsWith('.zip')) {
|
||||
throw new Error(`Bun runtime manifest artifact ${key} has an unsafe file name.`);
|
||||
}
|
||||
return { file, sha256 };
|
||||
}
|
||||
|
||||
export function parseRuntimeManifest(manifest, version) {
|
||||
if (!isRecord(manifest) || manifest.schemaVersion !== 1) {
|
||||
throw new Error('Bun runtime manifest must use schemaVersion 1.');
|
||||
}
|
||||
const manifestVersion = readRequiredString(manifest, 'version', 'Bun runtime manifest');
|
||||
if (manifestVersion !== version) {
|
||||
throw new Error(
|
||||
`Bun runtime manifest version ${manifestVersion} does not match packageManager bun@${version}.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(manifest.artifacts)) {
|
||||
throw new Error('Bun runtime manifest must contain an artifacts object.');
|
||||
}
|
||||
return {
|
||||
version,
|
||||
bunRevision: readCommit(manifest, 'bunRevision', 'Bun runtime manifest'),
|
||||
releaseTagCommit: readCommit(manifest, 'releaseTagCommit', 'Bun runtime manifest'),
|
||||
artifacts: manifest.artifacts,
|
||||
licenseInventoryStatus: readRequiredString(
|
||||
manifest,
|
||||
'licenseInventoryStatus',
|
||||
'Bun runtime manifest',
|
||||
),
|
||||
sourceManifest: readRequiredString(manifest, 'sourceManifest', 'Bun runtime manifest'),
|
||||
correspondingSourceAsset: readRequiredString(
|
||||
manifest,
|
||||
'correspondingSourceAsset',
|
||||
'Bun runtime manifest',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadRuntimeConfig({
|
||||
packageJsonPath = DEFAULT_PACKAGE_JSON_PATH,
|
||||
manifestPath = DEFAULT_MANIFEST_PATH,
|
||||
} = {}) {
|
||||
const [packageJsonText, manifestText] = await Promise.all([
|
||||
fs.readFile(packageJsonPath, 'utf8'),
|
||||
fs.readFile(manifestPath, 'utf8'),
|
||||
]);
|
||||
const version = parsePackageManagerVersion(JSON.parse(packageJsonText));
|
||||
return parseRuntimeManifest(JSON.parse(manifestText), version);
|
||||
}
|
||||
|
||||
export function resolveArtifact(config, platform, arch) {
|
||||
const target = normalizeTarget(platform, arch);
|
||||
const artifactValue = config.artifacts[target.key];
|
||||
if (artifactValue === undefined) {
|
||||
throw new Error(`Bun runtime manifest has no artifact for ${target.key}.`);
|
||||
}
|
||||
const artifact = parseArtifact(artifactValue, target.key);
|
||||
return {
|
||||
...target,
|
||||
...artifact,
|
||||
version: config.version,
|
||||
url: `${RELEASE_BASE_URL}/bun-v${config.version}/${artifact.file}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sha256File(filePath) {
|
||||
const hash = createHash('sha256');
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
hash.update(chunk);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export async function ensureCachedArchive(
|
||||
artifact,
|
||||
{ cacheDir = DEFAULT_CACHE_DIR, fetchImpl = globalThis.fetch } = {},
|
||||
) {
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new Error('No fetch implementation is available to download Bun.');
|
||||
}
|
||||
const versionCacheDir = path.join(cacheDir, artifact.version);
|
||||
const archivePath = path.join(versionCacheDir, artifact.file);
|
||||
await fs.mkdir(versionCacheDir, { recursive: true });
|
||||
|
||||
try {
|
||||
if ((await sha256File(archivePath)) === artifact.sha256) return archivePath;
|
||||
await fs.unlink(archivePath);
|
||||
} catch (error) {
|
||||
if (!isRecord(error) || error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const temporaryPath = `${archivePath}.download-${randomUUID()}`;
|
||||
try {
|
||||
const response = await fetchImpl(artifact.url);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Unable to download ${artifact.url}: HTTP ${response.status}`);
|
||||
}
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body),
|
||||
createWriteStream(temporaryPath, { flags: 'wx' }),
|
||||
);
|
||||
const actualSha256 = await sha256File(temporaryPath);
|
||||
if (actualSha256 !== artifact.sha256) {
|
||||
throw new Error(
|
||||
`Bun archive checksum mismatch for ${artifact.file}: expected ${artifact.sha256}, received ${actualSha256}.`,
|
||||
);
|
||||
}
|
||||
await fs.rename(temporaryPath, archivePath);
|
||||
return archivePath;
|
||||
} catch (error) {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeZipEntry(entry) {
|
||||
if (entry.startsWith('/') || /^[A-Za-z]:/.test(entry)) return false;
|
||||
return !entry.replaceAll('\\', '/').split('/').includes('..');
|
||||
}
|
||||
|
||||
function waitForProcess(child, description) {
|
||||
let stderr = '';
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${description}: ${stderr.trim() || `process exited ${code}`}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWindowsExtractionCommand(archivePath, member, outputPath) {
|
||||
const script = [
|
||||
'Add-Type -AssemblyName System.IO.Compression.FileSystem',
|
||||
'$archive = [IO.Compression.ZipFile]::OpenRead($env:SUBMINER_BUN_ARCHIVE_PATH)',
|
||||
'try {',
|
||||
' $entry = $archive.Entries | Where-Object { $_.FullName -ceq $env:SUBMINER_BUN_ARCHIVE_MEMBER }',
|
||||
' if ($null -eq $entry) { throw "Archive member not found: $env:SUBMINER_BUN_ARCHIVE_MEMBER" }',
|
||||
' $inputStream = $entry.Open()',
|
||||
' $outputStream = [IO.File]::Create($env:SUBMINER_BUN_OUTPUT_PATH)',
|
||||
' try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() }',
|
||||
'} finally { $archive.Dispose() }',
|
||||
].join('; ');
|
||||
return {
|
||||
command: 'powershell.exe',
|
||||
args: [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-EncodedCommand',
|
||||
Buffer.from(script, 'utf16le').toString('base64'),
|
||||
],
|
||||
environment: {
|
||||
SUBMINER_BUN_ARCHIVE_PATH: archivePath,
|
||||
SUBMINER_BUN_ARCHIVE_MEMBER: member,
|
||||
SUBMINER_BUN_OUTPUT_PATH: outputPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function extractZipMemberOnWindows(archivePath, member, outputPath) {
|
||||
const command = buildWindowsExtractionCommand(archivePath, member, outputPath);
|
||||
const powershell = spawn(command.command, command.args, {
|
||||
env: { ...process.env, ...command.environment },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
await waitForProcess(powershell, `Unable to extract ${member}`);
|
||||
}
|
||||
|
||||
export async function extractZipMember(archivePath, member, outputPath) {
|
||||
if (!isSafeZipEntry(member)) {
|
||||
throw new Error(`Refusing to extract unsafe Bun archive member ${member}.`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const temporaryPath = `${outputPath}.extract-${randomUUID()}`;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
await extractZipMemberOnWindows(archivePath, member, temporaryPath);
|
||||
await fs.chmod(temporaryPath, 0o755);
|
||||
await fs.rename(temporaryPath, outputPath);
|
||||
} catch (error) {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync('unzip', ['-Z1', archivePath], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const entries = stdout.split(/\r?\n/).filter(Boolean);
|
||||
if (entries.some((entry) => !isSafeZipEntry(entry))) {
|
||||
throw new Error(`Bun archive ${archivePath} contains an unsafe path.`);
|
||||
}
|
||||
if (!entries.includes(member)) {
|
||||
throw new Error(`Bun archive ${archivePath} does not contain ${member}.`);
|
||||
}
|
||||
|
||||
const output = createWriteStream(temporaryPath, { flags: 'wx', mode: 0o755 });
|
||||
const unzip = spawn('unzip', ['-p', archivePath, member], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
pipeline(unzip.stdout, output),
|
||||
waitForProcess(unzip, `Unable to extract ${member}`),
|
||||
]);
|
||||
await fs.chmod(temporaryPath, 0o755);
|
||||
await fs.rename(temporaryPath, outputPath);
|
||||
} catch (error) {
|
||||
await fs.rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveResourcesDirectory(appOutDir, platform, productFilename = 'SubMiner') {
|
||||
if (platform !== 'darwin') return path.join(appOutDir, 'resources');
|
||||
const appBundlePath = appOutDir.endsWith('.app')
|
||||
? appOutDir
|
||||
: path.join(appOutDir, `${productFilename}.app`);
|
||||
return path.join(appBundlePath, 'Contents', 'Resources');
|
||||
}
|
||||
|
||||
export async function stageBunLicenses(
|
||||
runtimeDirectory,
|
||||
licensesSourceDir = DEFAULT_LICENSES_SOURCE_DIR,
|
||||
) {
|
||||
const licensesDirectory = path.join(runtimeDirectory, 'licenses');
|
||||
await Promise.all(
|
||||
REQUIRED_LICENSE_FILES.map((fileName) => fs.access(path.join(licensesSourceDir, fileName))),
|
||||
);
|
||||
await fs.cp(licensesSourceDir, licensesDirectory, { recursive: true, force: true });
|
||||
return licensesDirectory;
|
||||
}
|
||||
|
||||
export async function stageBunRuntime(
|
||||
{ appOutDir, platform, arch, productFilename = 'SubMiner' },
|
||||
{
|
||||
configLoader = loadRuntimeConfig,
|
||||
archiveLoader = ensureCachedArchive,
|
||||
extractor = extractZipMember,
|
||||
licenseStager = stageBunLicenses,
|
||||
} = {},
|
||||
) {
|
||||
const config = await configLoader();
|
||||
const artifact = resolveArtifact(config, platform, arch);
|
||||
const archivePath = await archiveLoader(artifact);
|
||||
const archiveDirectory = path.basename(artifact.file, '.zip');
|
||||
const archiveExecutableName = platform === 'win32' ? 'bun.exe' : 'bun';
|
||||
const member = `${archiveDirectory}/${archiveExecutableName}`;
|
||||
const runtimeDirectory = path.join(
|
||||
resolveResourcesDirectory(appOutDir, platform, productFilename),
|
||||
'bun',
|
||||
);
|
||||
const executablePath = path.join(runtimeDirectory, artifact.executableName);
|
||||
await extractor(archivePath, member, executablePath);
|
||||
await licenseStager(runtimeDirectory);
|
||||
|
||||
const metadata = {
|
||||
name: 'Bun',
|
||||
version: config.version,
|
||||
bunRevision: config.bunRevision,
|
||||
releaseTagCommit: config.releaseTagCommit,
|
||||
target: artifact.key,
|
||||
artifact: artifact.file,
|
||||
artifactSha256: artifact.sha256,
|
||||
sourceUrl: artifact.url,
|
||||
licenseInventoryStatus: config.licenseInventoryStatus,
|
||||
correspondingSourceAsset: config.correspondingSourceAsset,
|
||||
sourceInstructions: 'licenses/SOURCE.md',
|
||||
thirdPartyNotices: 'licenses/THIRD-PARTY-NOTICES.md',
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(runtimeDirectory, STAGED_METADATA_FILE),
|
||||
`${JSON.stringify(metadata, null, 2)}\n`,
|
||||
{ mode: 0o644 },
|
||||
);
|
||||
return {
|
||||
executablePath,
|
||||
metadataPath: path.join(runtimeDirectory, STAGED_METADATA_FILE),
|
||||
artifact,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildWindowsExtractionCommand,
|
||||
ensureCachedArchive,
|
||||
extractZipMember,
|
||||
loadRuntimeConfig,
|
||||
normalizeTarget,
|
||||
parsePackageManagerVersion,
|
||||
parseRuntimeManifest,
|
||||
resolveArtifact,
|
||||
stageBunLicenses,
|
||||
stageBunRuntime,
|
||||
} from './stage-bun-runtime.mjs';
|
||||
|
||||
function sha256(content: Uint8Array): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function runtimeConfig() {
|
||||
return {
|
||||
version: '1.3.5',
|
||||
bunRevision: '1e86cebd74a5723e818b5c0555276b646bcf0e4c',
|
||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||
artifacts: {
|
||||
'darwin-arm64': { file: 'bun-darwin-aarch64.zip', sha256: '1'.repeat(64) },
|
||||
'darwin-x64': { file: 'bun-darwin-x64-baseline.zip', sha256: '2'.repeat(64) },
|
||||
'linux-arm64': { file: 'bun-linux-aarch64.zip', sha256: '3'.repeat(64) },
|
||||
'linux-x64': { file: 'bun-linux-x64-baseline.zip', sha256: '4'.repeat(64) },
|
||||
'win32-x64': { file: 'bun-windows-x64-baseline.zip', sha256: '5'.repeat(64) },
|
||||
},
|
||||
licenseInventoryStatus: 'complete-for-bun-1.3.5-declared-linked-libraries',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
};
|
||||
}
|
||||
|
||||
test('normalizeTarget maps each supported electron-builder target without using the host', () => {
|
||||
assert.deepEqual(normalizeTarget('linux', 1), {
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
key: 'linux-x64',
|
||||
executableName: 'bun',
|
||||
});
|
||||
assert.equal(normalizeTarget('linux', 3).key, 'linux-arm64');
|
||||
assert.equal(normalizeTarget('darwin', 'x64').key, 'darwin-x64');
|
||||
assert.equal(normalizeTarget('darwin', 'arm64').key, 'darwin-arm64');
|
||||
assert.deepEqual(normalizeTarget('win32', 1), {
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
key: 'win32-x64',
|
||||
executableName: 'bun.exe',
|
||||
});
|
||||
assert.throws(() => normalizeTarget('freebsd', 'x64'), /Unsupported Bun runtime target platform/);
|
||||
assert.throws(() => normalizeTarget('win32', 'arm64'), /Unsupported Bun runtime target/);
|
||||
assert.throws(() => normalizeTarget('linux', 0), /Unsupported Bun runtime target architecture/);
|
||||
});
|
||||
|
||||
test('resolveArtifact chooses baseline x64 builds and standard arm64 builds', () => {
|
||||
const config = runtimeConfig();
|
||||
assert.equal(resolveArtifact(config, 'linux', 'x64').file, 'bun-linux-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'x64').file, 'bun-darwin-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'win32', 'x64').file, 'bun-windows-x64-baseline.zip');
|
||||
assert.equal(resolveArtifact(config, 'linux', 'arm64').file, 'bun-linux-aarch64.zip');
|
||||
assert.equal(resolveArtifact(config, 'darwin', 'arm64').file, 'bun-darwin-aarch64.zip');
|
||||
});
|
||||
|
||||
test('tracked runtime manifest covers every supported target at the packageManager version', async () => {
|
||||
const config = await loadRuntimeConfig();
|
||||
assert.equal(config.version, '1.3.5');
|
||||
for (const [platform, arch] of [
|
||||
['linux', 'x64'],
|
||||
['linux', 'arm64'],
|
||||
['darwin', 'x64'],
|
||||
['darwin', 'arm64'],
|
||||
['win32', 'x64'],
|
||||
]) {
|
||||
const artifact = resolveArtifact(config, platform, arch);
|
||||
assert.match(artifact.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
artifact.url,
|
||||
`https://github.com/oven-sh/bun/releases/download/bun-v1.3.5/${artifact.file}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('manifest version must match the exact packageManager Bun pin', () => {
|
||||
assert.equal(parsePackageManagerVersion({ packageManager: 'bun@1.3.5' }), '1.3.5');
|
||||
assert.throws(
|
||||
() => parsePackageManagerVersion({ packageManager: 'bun@^1.3.5' }),
|
||||
/must pin Bun exactly/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRuntimeManifest(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
version: '1.3.6',
|
||||
bunRevision: '1e86cebd74a5723e818b5c0555276b646bcf0e4c',
|
||||
releaseTagCommit: 'fa5a5bbe556a4bda5bde77b4013aa6c3bb4ec9ab',
|
||||
artifacts: {},
|
||||
licenseInventoryStatus: 'partial',
|
||||
sourceManifest: 'build/bun-source-manifest.json',
|
||||
correspondingSourceAsset: 'bun-v1.3.5-source.tar.gz',
|
||||
},
|
||||
'1.3.5',
|
||||
),
|
||||
/does not match packageManager bun@1\.3\.5/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureCachedArchive verifies downloads and reuses a verified cache entry offline', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-cache-'));
|
||||
const bytes = new TextEncoder().encode('verified Bun archive fixture');
|
||||
const artifact = {
|
||||
version: '1.3.5',
|
||||
file: 'bun-linux-x64-baseline.zip',
|
||||
sha256: sha256(bytes),
|
||||
url: 'https://example.invalid/bun.zip',
|
||||
};
|
||||
let downloads = 0;
|
||||
const fetchImpl = async () => {
|
||||
downloads += 1;
|
||||
return new Response(bytes);
|
||||
};
|
||||
|
||||
try {
|
||||
const firstPath = await ensureCachedArchive(artifact, { cacheDir: workspace, fetchImpl });
|
||||
const secondPath = await ensureCachedArchive(artifact, {
|
||||
cacheDir: workspace,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('verified cache should not fetch');
|
||||
},
|
||||
});
|
||||
assert.equal(firstPath, secondPath);
|
||||
assert.equal(downloads, 1);
|
||||
assert.deepEqual(await fs.readFile(firstPath), Buffer.from(bytes));
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureCachedArchive rejects a checksum mismatch without caching the download', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-cache-mismatch-'));
|
||||
const artifact = {
|
||||
version: '1.3.5',
|
||||
file: 'bun-linux-x64-baseline.zip',
|
||||
sha256: '0'.repeat(64),
|
||||
url: 'https://example.invalid/bun.zip',
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
ensureCachedArchive(artifact, {
|
||||
cacheDir: workspace,
|
||||
fetchImpl: async () => new Response('tampered'),
|
||||
}),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
const cacheEntries = await fs.readdir(path.join(workspace, '1.3.5'));
|
||||
assert.deepEqual(cacheEntries, []);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('extractZipMember extracts only the requested path and makes the runtime executable', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-extract-'));
|
||||
const archivePath = path.join(workspace, 'fixture.zip');
|
||||
const outputPath = path.join(workspace, 'output', 'bun');
|
||||
const archiveBase64 =
|
||||
'UEsDBAoAAAAAAMxcKl3rs337EgAAABIAAAAaABwAYnVuLWxpbnV4LXg2NC1iYXNlbGluZS9idW5VVAkAAyD5omog+aJqdXgLAAEE6AMAAAToAwAAYnVuIGZpeHR1cmUgYmluYXJ5UEsBAh4DCgAAAAAAzFwqXeuzffsSAAAAEgAAABoAGAAAAAAAAQAAAKSBAAAAAGJ1bi1saW51eC14NjQtYmFzZWxpbmUvYnVuVVQFAAMg+aJqdXgLAAEE6AMAAAToAwAAUEsFBgAAAAABAAEAYAAAAGYAAAAAAA==';
|
||||
|
||||
try {
|
||||
await fs.writeFile(archivePath, Buffer.from(archiveBase64, 'base64'));
|
||||
await extractZipMember(archivePath, 'bun-linux-x64-baseline/bun', outputPath);
|
||||
assert.equal(await fs.readFile(outputPath, 'utf8'), 'bun fixture binary');
|
||||
assert.equal((await fs.stat(outputPath)).mode & 0o777, 0o755);
|
||||
await assert.rejects(
|
||||
extractZipMember(archivePath, '../bun', path.join(workspace, 'unsafe')),
|
||||
/unsafe Bun archive member/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows extraction keeps paths and archive members out of PowerShell command text', () => {
|
||||
const archivePath = String.raw`C:\Release Builds\bun $(archive) '1.3.5'.zip`;
|
||||
const member = 'bun-windows-x64-baseline/bun.exe';
|
||||
const outputPath = String.raw`C:\Staged App & Tools\resources\bun\bun.exe`;
|
||||
const command = buildWindowsExtractionCommand(archivePath, member, outputPath);
|
||||
const encodedCommand = command.args.at(-1);
|
||||
|
||||
assert.equal(command.command, 'powershell.exe');
|
||||
assert.equal(command.args.at(-2), '-EncodedCommand');
|
||||
assert.ok(encodedCommand);
|
||||
const script = Buffer.from(encodedCommand, 'base64').toString('utf16le');
|
||||
assert.match(script, /\$env:SUBMINER_BUN_ARCHIVE_PATH/);
|
||||
assert.match(script, /\$env:SUBMINER_BUN_ARCHIVE_MEMBER/);
|
||||
assert.match(script, /\$env:SUBMINER_BUN_OUTPUT_PATH/);
|
||||
assert.doesNotMatch(script, /Release Builds|Staged App|bun-windows-x64-baseline/);
|
||||
assert.deepEqual(command.environment, {
|
||||
SUBMINER_BUN_ARCHIVE_PATH: archivePath,
|
||||
SUBMINER_BUN_ARCHIVE_MEMBER: member,
|
||||
SUBMINER_BUN_OUTPUT_PATH: outputPath,
|
||||
});
|
||||
});
|
||||
|
||||
test('stageBunLicenses copies the tracked inventory into resources/bun/licenses', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-licenses-'));
|
||||
const sourceDirectory = path.join(workspace, 'tracked-licenses');
|
||||
const runtimeDirectory = path.join(workspace, 'resources', 'bun');
|
||||
|
||||
try {
|
||||
await fs.mkdir(sourceDirectory, { recursive: true });
|
||||
for (const fileName of [
|
||||
'Bun-LICENSE.md',
|
||||
'LGPL-2.0.txt',
|
||||
'LGPL-2.1.txt',
|
||||
'SOURCE.md',
|
||||
'THIRD-PARTY-NOTICES.md',
|
||||
]) {
|
||||
await fs.writeFile(path.join(sourceDirectory, fileName), `${fileName}\n`);
|
||||
}
|
||||
const licensesDirectory = await stageBunLicenses(runtimeDirectory, sourceDirectory);
|
||||
assert.equal(licensesDirectory, path.join(runtimeDirectory, 'licenses'));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(licensesDirectory, 'THIRD-PARTY-NOTICES.md'), 'utf8'),
|
||||
'THIRD-PARTY-NOTICES.md\n',
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stageBunRuntime places target executables and metadata in app resources with safe modes', async () => {
|
||||
const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'subminer-bun-stage-'));
|
||||
const cases = [
|
||||
{
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
appOutDir: path.join(workspace, 'linux'),
|
||||
relativeExecutable: path.join('resources', 'bun', 'bun'),
|
||||
member: 'bun-linux-x64-baseline/bun',
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
appOutDir: path.join(workspace, 'darwin'),
|
||||
relativeExecutable: path.join('SubMiner.app', 'Contents', 'Resources', 'bun', 'bun'),
|
||||
member: 'bun-darwin-aarch64/bun',
|
||||
},
|
||||
{
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
appOutDir: path.join(workspace, 'windows'),
|
||||
relativeExecutable: path.join('resources', 'bun', 'bun.exe'),
|
||||
member: 'bun-windows-x64-baseline/bun.exe',
|
||||
},
|
||||
] as const;
|
||||
|
||||
try {
|
||||
for (const targetCase of cases) {
|
||||
let extractedMember = '';
|
||||
const result = await stageBunRuntime(targetCase, {
|
||||
configLoader: async () => runtimeConfig(),
|
||||
archiveLoader: async () => path.join(workspace, 'fixture.zip'),
|
||||
extractor: async (_archivePath: string, member: string, outputPath: string) => {
|
||||
extractedMember = member;
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, 'bun fixture', { mode: 0o755 });
|
||||
},
|
||||
licenseStager: async (runtimeDirectory: string) => {
|
||||
const licensesDirectory = path.join(runtimeDirectory, 'licenses');
|
||||
await fs.mkdir(licensesDirectory, { recursive: true });
|
||||
await fs.writeFile(path.join(licensesDirectory, 'Bun-LICENSE.md'), 'MIT fixture');
|
||||
return licensesDirectory;
|
||||
},
|
||||
});
|
||||
const expectedExecutable = path.join(targetCase.appOutDir, targetCase.relativeExecutable);
|
||||
assert.equal(result.executablePath, expectedExecutable);
|
||||
assert.equal(extractedMember, targetCase.member);
|
||||
assert.equal((await fs.stat(expectedExecutable)).mode & 0o777, 0o755);
|
||||
assert.equal((await fs.stat(result.metadataPath)).mode & 0o777, 0o644);
|
||||
const metadata = JSON.parse(await fs.readFile(result.metadataPath, 'utf8'));
|
||||
assert.equal(metadata.version, '1.3.5');
|
||||
assert.equal(metadata.bunRevision, '1e86cebd74a5723e818b5c0555276b646bcf0e4c');
|
||||
assert.equal(metadata.artifactSha256, result.artifact.sha256);
|
||||
assert.equal(metadata.target, result.artifact.key);
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
path.join(path.dirname(expectedExecutable), 'licenses', 'Bun-LICENSE.md'),
|
||||
'utf8',
|
||||
),
|
||||
'MIT fixture',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -70,6 +70,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
||||
);
|
||||
|
||||
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
||||
assert.doesNotMatch(pkgbuild, /^\s*'bun'$/m);
|
||||
assert.match(
|
||||
pkgbuild,
|
||||
/^\s*"subminer-\$\{pkgver\}::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v\$\{pkgver\}\/subminer"$/m,
|
||||
@@ -84,6 +85,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
||||
);
|
||||
assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/);
|
||||
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
||||
assert.doesNotMatch(srcinfo, /^\tdepends = bun$/m);
|
||||
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
||||
assert.match(
|
||||
srcinfo,
|
||||
|
||||
@@ -2,23 +2,38 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LAUNCHER_OUT="$REPO_ROOT/dist/launcher/subminer"
|
||||
LAUNCHER_DIR="$REPO_ROOT/dist/launcher"
|
||||
LAUNCHER_OUT="$LAUNCHER_DIR/subminer"
|
||||
EXPECTED_ARTIFACTS=(prepare.cjs subminer subminer.cmd subminer.js version)
|
||||
|
||||
if [[ ! -f "$REPO_ROOT/launcher/main.ts" ]]; then
|
||||
echo "[FAIL] launcher source missing: launcher/main.ts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fn -- "--outfile=\"\$(LAUNCHER_OUT)\"" "$REPO_ROOT/Makefile" >/dev/null; then
|
||||
echo "[FAIL] Makefile build-launcher target is not writing to dist/launcher/subminer"
|
||||
if ! grep -F -- "bun run build:launcher" "$REPO_ROOT/Makefile" >/dev/null; then
|
||||
echo "[FAIL] Makefile build-launcher target does not call the canonical package script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$LAUNCHER_OUT" ]]; then
|
||||
echo "[FAIL] generated launcher not found at dist/launcher/subminer"
|
||||
echo " run: make build-launcher"
|
||||
exit 1
|
||||
fi
|
||||
for artifact in "${EXPECTED_ARTIFACTS[@]}"; do
|
||||
if [[ ! -f "$LAUNCHER_DIR/$artifact" ]]; then
|
||||
echo "[FAIL] generated launcher artifact missing: dist/launcher/$artifact"
|
||||
echo " run: make build-launcher"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for artifact_path in "$LAUNCHER_DIR"/*; do
|
||||
artifact="${artifact_path##*/}"
|
||||
case "$artifact" in
|
||||
prepare.cjs | subminer | subminer.cmd | subminer.js | version) ;;
|
||||
*)
|
||||
echo "[FAIL] dist/launcher contains an unexpected runtime artifact: $artifact"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -x "$LAUNCHER_OUT" ]]; then
|
||||
echo "[FAIL] generated launcher is not executable: dist/launcher/subminer"
|
||||
@@ -32,11 +47,11 @@ if [[ -f "$REPO_ROOT/subminer" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git -C "$REPO_ROOT" ls-files --error-unmatch dist/launcher/subminer >/dev/null 2>&1; then
|
||||
echo "[FAIL] dist/launcher/subminer is tracked by git; generated artifacts must remain untracked"
|
||||
if git -C "$REPO_ROOT" ls-files --error-unmatch dist/launcher >/dev/null 2>&1; then
|
||||
echo "[FAIL] dist/launcher contains tracked files; generated artifacts must remain untracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] launcher workflow verified"
|
||||
echo " source: launcher/*.ts"
|
||||
echo " generated artifact: dist/launcher/subminer"
|
||||
echo " generated artifacts: ${EXPECTED_ARTIFACTS[*]}"
|
||||
|
||||
Reference in New Issue
Block a user