blob: acf387c15268132328c68904e0240b4231d3988a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { execFile, spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { parseArgs, promisify } from 'node:util';
import {
controlledProcessEnvironment,
execTar,
verifySourceCandidate,
} from '../asf-source-release.mjs';
import { npmSpawnOptions } from '../npm-spawn.mjs';
import { parseProductReleaseVersion } from '../release-version.mjs';
import { buildCli, desktopShell, rustTarget } from './build-cli.mjs';
import { nativeCliTargets, packNativeCli } from './pack-cli.mjs';
import { verifyNotices } from './notices.mjs';
import { buildV8 } from './build-v8.mjs';
const run = promisify(execFile);
export function previewVersion(sourceVersion, buildId) {
if (
parseProductReleaseVersion(sourceVersion).prerelease.length > 0 ||
typeof buildId !== 'string' ||
!buildId
) {
throw new Error('A stable source version and a SemVer build-id are required');
}
const version = `${sourceVersion}-rust-preview.${buildId}`;
if (version.length > 256) throw new Error('Native preview version exceeds 256 characters');
parseProductReleaseVersion(version);
return version;
}
/** Build only the verified archive, never caller-supplied executable bytes. Does not publish. */
export async function releaseNativeCli({ source, keys, target, validator, output, buildId }) {
const platform = Object.hasOwn(nativeCliTargets, target) ? nativeCliTargets[target] : undefined;
if (!platform || !source || !output || !buildId) {
throw new Error('source, supported target, output, and build-id are required');
}
const native = platform.os === process.platform && platform.cpu === process.arch;
if (!native && !validator) throw new Error('Cross-target packaging requires a local validator');
const stage = await mkdtemp(join(tmpdir(), 'maka-native-source-'));
try {
// Snapshot before validation so later changes to the input cannot change the build.
const archivePath = join(stage, basename(source));
for (const suffix of ['', '.sha512', ...(keys ? ['.asc'] : [])]) {
await copyFile(resolve(source) + suffix, archivePath + suffix, constants.COPYFILE_EXCL);
}
const candidate = await verifySourceCandidate({
archivePath,
...(keys ? { keysPath: resolve(keys) } : {}),
});
const version = previewVersion(candidate.version, buildId);
const extraction = join(stage, 'source');
await mkdir(extraction);
execTar(archivePath, ['-xzf', '-C', extraction], {
timeout: 180_000,
windowsHide: true,
});
const repositoryRoot = join(extraction, candidate.rootDirectory);
// JS must come from this install, not an ambient MAKA_JS_DEPS checkout.
const env = controlledProcessEnvironment({
excludedPrefixes: ['BINDGEN_EXTRA_CLANG_ARGS'],
excludedNames: [
'MAKA_JS_DEPS',
'CARGO_BUILD_TARGET',
'MAKA_NATIVE_PACKAGE_VERSION',
'RUSTY_V8_ARCHIVE',
'RUSTY_V8_SRC_BINDING_PATH',
'RUSTY_V8_MIRROR',
'V8_FROM_SOURCE',
'V8_FORCE_DEBUG',
'GN_ARGS',
'EXTRA_GN_ARGS',
'DISABLE_CLANG',
'CLANG_BASE_PATH',
],
overrides: {
MAKA_JS_DEPS: repositoryRoot,
MAKA_NATIVE_PACKAGE_VERSION: version,
// cargo-config2 (used by zigbuild) treats an empty config wrapper as an
// executable, but honors Cargo's empty environment override correctly.
RUSTC_WRAPPER: '',
RUSTC_WORKSPACE_WRAPPER: '',
},
});
const metadata = JSON.parse(
(
await run('cargo', ['metadata', '--locked', '--no-deps', '--format-version', '1'], {
cwd: repositoryRoot,
env,
encoding: 'utf8',
maxBuffer: 4 * 1024 * 1024,
timeout: 180_000,
windowsHide: true,
})
).stdout,
);
const cli = metadata.packages.find((pkg) => pkg.name === 'maka-cli');
if (cli?.version !== candidate.version) {
throw new Error('Native CLI version does not match the source archive');
}
const reviewedNotices = await verifyNotices(repositoryRoot);
if (platform.os === 'darwin') {
const sdk = (
await run('xcrun', ['--sdk', 'macosx', '--show-sdk-version'], { env })
).stdout.trim();
if (!(Number.parseInt(sdk, 10) >= 26))
throw new Error('Native macOS packaging requires Xcode 26 or newer; selected SDK: ' + sdk);
}
// Apply build-time dependency patches without running package lifecycle hooks.
await execute(
'npm',
['ci', '--include=dev', '--include=optional', '--ignore-scripts', '--no-audit', '--no-fund'],
npmSpawnOptions({ cwd: repositoryRoot, env }),
);
await execute(process.execPath, ['scripts/apply-dependency-patches.mjs'], {
cwd: repositoryRoot,
env,
});
const v8 = await buildV8({
root: repositoryRoot,
directory: join(stage, 'v8'),
cache: resolve(env.CARGO_TARGET_DIR || 'target', 'v8'),
target: rustTarget(platform.os, platform.cpu),
env,
metadata: JSON.parse(
(
await run(
'cargo',
[
'metadata',
'--locked',
'--format-version',
'1',
'--filter-platform',
rustTarget(platform.os, platform.cpu),
],
{
cwd: repositoryRoot,
env,
maxBuffer: 16 * 1024 * 1024,
timeout: 600_000,
},
)
).stdout,
),
});
const notices = join(stage, 'THIRD_PARTY_NOTICES.txt');
const sysroot = (
await run('rustc', ['--print', 'sysroot'], { cwd: repositoryRoot, env })
).stdout.trim();
const rustNotices = await readFile(
join(sysroot, 'share/doc/rust/COPYRIGHT-library.html'),
'utf8',
);
await writeFile(
notices,
[await readFile(reviewedNotices, 'utf8'), v8.notices, rustNotices].join('\n\n'),
);
const binary = await buildCli({
release: true,
target: rustTarget(platform.os, platform.cpu),
repositoryRoot,
env: { ...env, ...v8.environment },
});
if (native) {
const { stdout } = await run(
join(dirname(binary), desktopShell(platform.os)),
['--version'],
{
timeout: 15_000,
windowsHide: true,
},
);
if (stdout.trim() !== 'maka-desktop-shell ' + candidate.version)
throw new Error('Built desktop shell reports another source version');
const terminal = await run(binary, ['terminal-shell', '--version'], {
timeout: 15_000,
windowsHide: true,
});
if (!terminal.stdout.trim().endsWith(' ' + candidate.version))
throw new Error('Built terminal shell reports another source version');
const worker = join(
dirname(binary),
platform.os === 'win32' ? 'maka-python-worker.exe' : 'maka-python-worker',
);
const workerVersion = await run(worker, ['--version'], {
timeout: 15_000,
windowsHide: true,
});
if (workerVersion.stdout.trim() !== 'maka-python-worker ' + candidate.version)
throw new Error('Built Python worker reports another source version');
const executables = [
binary,
...(platform.os === 'win32' ? [join(dirname(binary), 'maka-service.exe')] : []),
];
for (const [index, executable] of executables.entries()) {
const { stdout } = await run(executable, ['--version'], {
timeout: 15_000,
windowsHide: true,
});
if (stdout.trim() !== 'maka ' + candidate.version)
throw new Error('Built CLI reports another source version');
await verifyCode(executable, join(stage, 'smoke-' + index + '.sqlite'));
}
}
return await packNativeCli({
target,
version,
binary,
notices,
validator: validator ?? binary,
output,
repositoryRoot,
source: { archive: basename(source), version: candidate.version, sha512: candidate.digest },
});
} finally {
await rm(stage, { recursive: true, force: true });
}
}
async function verifyCode(executable, log) {
const stdout = await new Promise((resolveOutput, reject) => {
const child = execFile(
executable,
['code', '--log', log],
{ encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024, windowsHide: true },
(error, stdout) => (error ? reject(error) : resolveOutput(stdout)),
);
child.stdin.end(
'if (Math.abs(Math.sin(1) - 0.8414709848078965) > 1e-15 || Math.cos(0) !== 1 || !new Intl.DateTimeFormat("en").format(new Date(0)) || Temporal.PlainDate.from("2026-09-28").year !== 2026) throw new Error("V8 smoke failed");',
);
child.stdin.on('error', reject);
});
if (JSON.parse(stdout).output?.ok !== true) throw new Error('Built CLI failed its real V8 smoke');
}
async function execute(command, args, options) {
await new Promise((resolveCommand, reject) => {
const child = spawn(command, args, { ...options, stdio: 'inherit', windowsHide: true });
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolveCommand();
else reject(new Error('Native source build failed: ' + command + ' ' + (signal ?? code)));
});
});
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const { values } = parseArgs({
options: Object.fromEntries(
['source', 'keys', 'target', 'validator', 'output', 'build-id'].map((name) => [
name,
{ type: 'string' },
]),
),
});
console.log(JSON.stringify(await releaseNativeCli({ ...values, buildId: values['build-id'] })));
}