blob: 0bcd09946e02fdf58ee369ec4ecb09d0aba2621f [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 assert from 'node:assert/strict';
import childProcess, { execFileSync } from 'node:child_process';
import { syncBuiltinESMExports } from 'node:module';
import { createHash } from 'node:crypto';
import { copyFile, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { previewVersion, releaseNativeCli } from './release-cli.mjs';
import {
previewTargets,
publishPreviewRelease,
readNativePreviewRelease,
readPreviewRelease,
} from './publish-cli.mjs';
import { packLauncher } from './pack-launcher.mjs';
test('prepared release binds its source and recovers partial publication and missing tags', async (t) => {
const stage = await mkdtemp(join(tmpdir(), 'maka launcher release '));
try {
const version = '0.2.0-rust-preview.1';
const root = 'apache-maka-0.2.0-incubating';
const sourceRoot = join(stage, root);
await mkdir(join(sourceRoot, 'packages/cli/bin'), { recursive: true });
for (const file of ['package.json', 'README.md', 'README.zh-CN.md', 'bin/maka.mjs']) {
await copyFile(
join(import.meta.dirname, '../../packages/cli', file),
join(sourceRoot, 'packages/cli', file),
);
}
for (const [file, content] of Object.entries({
'package.json': JSON.stringify({ name: 'maka', version: '0.2.0', license: 'Apache-2.0' }),
'package-lock.json': JSON.stringify({
version: '0.2.0',
lockfileVersion: 3,
packages: { '': { version: '0.2.0' } },
}),
LICENSE: 'Apache License, Version 2.0\n',
NOTICE: 'Apache Maka\n',
'DISCLAIMER-WIP': 'Apache Maka is undergoing incubation.\n',
}))
await writeFile(join(sourceRoot, file), content);
const archive = root + '-src.tar.gz';
execFileSync('tar', ['-czf', join(stage, archive), '-C', stage, root]);
const sha512 = createHash('sha512')
.update(await readFile(join(stage, archive)))
.digest('hex');
await writeFile(join(stage, archive + '.sha512'), `${sha512} ${archive}\n`);
const source = { archive, version: '0.2.0', sha512 };
await mkdir(join(stage, 'package'));
for (const target of previewTargets) {
const name = '@maka-agent/cli-' + target;
const archive = 'maka-agent-cli-' + target + '-' + version + '.tgz';
await writeFile(
join(stage, 'package/package.json'),
JSON.stringify({
name,
version,
makaSource: source,
publishConfig: { tag: 'rust-preview' },
}),
);
execFileSync('tar', ['-czf', join(stage, archive), '-C', stage, 'package']);
const integrity =
'sha512-' +
createHash('sha512')
.update(await readFile(join(stage, archive)))
.digest('base64');
await writeFile(
join(stage, target + '.json'),
JSON.stringify({ name, version, target, archive, integrity }),
);
}
await assert.rejects(readPreviewRelease(stage), /ENOENT/);
const receipt = await packLauncher(stage);
const packages = await readPreviewRelease(stage);
assert.deepEqual(
packages.map(({ name }) => name),
[...previewTargets.map((target) => '@maka-agent/cli-' + target), '@maka-agent/cli'],
);
const packed = join(stage, receipt.archive);
const files = execFileSync('tar', ['-tzf', packed], { encoding: 'utf8' })
.trim()
.split('\n')
.sort();
assert.deepEqual(
files,
[
'package/LICENSE',
'package/NOTICE',
'package/README.md',
'package/README.zh-CN.md',
'package/bin/maka.mjs',
'package/package.json',
].sort(),
);
assert.equal(
execFileSync('tar', ['-xOf', packed, 'package/bin/maka.mjs'], { encoding: 'utf8' }),
await readFile(join(sourceRoot, 'packages/cli/bin/maka.mjs'), 'utf8'),
);
const manifest = JSON.parse(
execFileSync('tar', ['-xOf', packed, 'package/package.json'], { encoding: 'utf8' }),
);
assert.deepEqual(
manifest.optionalDependencies,
Object.fromEntries(previewTargets.map((target) => ['@maka-agent/cli-' + target, version])),
);
assert.match(manifest.description, /Unofficial experimental.*not an ASF release/);
const readme = execFileSync('tar', ['-xOf', packed, 'package/README.md'], { encoding: 'utf8' });
assert.match(
readme,
/not an official Apache Software Foundation release and is not approved, endorsed or supported by the ASF/,
);
assert.match(
readme,
/The `latest` tags remain empty placeholders, including the separate `maka-agent` package\. They contain no CLI, runtime, dependencies or install scripts\./,
);
assert.deepEqual(manifest.makaSource, source);
await publicationRecovery(t, stage, packages);
await writeFile(packed, 'tampered');
await assert.rejects(readPreviewRelease(stage), /integrity differs/);
await writeFile(join(stage, archive), 'tampered source');
await assert.rejects(packLauncher(stage), /SHA-512 mismatch/);
} finally {
await rm(stage, { recursive: true, force: true });
}
});
async function publicationRecovery(t, directory, packages) {
const versions = new Map();
const tags = new Map();
const uploads = [];
const originalExec = childProcess.execFileSync;
let fail = true;
let raced = false;
let tagReads = 0;
let pendingVisibility = 6;
// Advance confirmation waits without making this registry simulation sleep.
const delay = t.mock.method(globalThis, 'setTimeout', (resolveDelay) => {
queueMicrotask(resolveDelay);
return 0;
});
const fetch = t.mock.method(globalThis, 'fetch', async (url) => {
const path = new URL(url).pathname;
if (path.startsWith('/-/package/')) {
const name = decodeURIComponent(path.slice('/-/package/'.length, -'/dist-tags'.length));
if (raced && ++tagReads > packages.length) tags.set(name, '0.2.0-rust-preview.999');
return new Response(JSON.stringify(tags.has(name) ? { 'rust-preview': tags.get(name) } : {}));
}
const pkg = packages.find((pkg) => path === '/' + pkg.name + '/' + pkg.version);
assert.ok(pkg, 'only fixture registry paths may be read');
if (versions.has(pkg.name) && pkg.name === packages[0].name && pendingVisibility > 0) {
pendingVisibility -= 1;
return new Response('', { status: 404 });
}
return versions.has(pkg.name)
? new Response(JSON.stringify({ dist: { integrity: versions.get(pkg.name) } }))
: new Response('', { status: 404 });
});
const exec = t.mock.method(childProcess, 'execFileSync', (command, args, options) => {
if (command === 'tar') return originalExec(command, args, options);
assert.equal(command, 'npm');
if (args[0] === 'publish') {
const pkg = packages.find((pkg) => pkg.archive === join(options.cwd, args[1]));
assert.ok(pkg, 'publish uses a cwd and basename even when the directory has spaces');
assert.ok(!versions.has(pkg.name), 'accepted immutable versions are never uploaded again');
versions.set(pkg.name, pkg.integrity);
uploads.push(pkg.name);
if (fail && uploads.length === 2) {
fail = false;
throw new Error('lost publication response');
}
tags.set(pkg.name, pkg.version);
} else {
assert.deepEqual(args.slice(0, 2), ['dist-tag', 'add']);
const pkg = packages.find((pkg) => pkg.name + '@' + pkg.version === args[2]);
assert.ok(pkg);
assert.equal(versions.get(pkg.name), pkg.integrity);
tags.set(pkg.name, pkg.version);
}
return '';
});
syncBuiltinESMExports();
try {
await assert.rejects(publishPreviewRelease(directory), /lost publication response/);
await publishPreviewRelease(directory);
assert.deepEqual(
uploads,
packages.map((pkg) => pkg.name),
);
assert.deepEqual([...tags].sort(), packages.map((pkg) => [pkg.name, pkg.version]).sort());
tags.delete('@maka-agent/cli');
await publishPreviewRelease(directory);
assert.equal(tags.get('@maka-agent/cli'), packages[0].version);
tags.set('@maka-agent/cli', '0.2.0-rust-preview.999');
await assert.rejects(publishPreviewRelease(directory), /backwards/);
assert.equal(tags.get('@maka-agent/cli'), '0.2.0-rust-preview.999');
assert.equal(uploads.length, packages.length);
versions.clear();
tags.clear();
raced = true;
await assert.rejects(publishPreviewRelease(directory), /backwards/);
assert.equal(
uploads.length,
packages.length,
'a tag advanced after preflight prevents the first publication',
);
} finally {
fetch.mock.restore();
exec.mock.restore();
delay.mock.restore();
syncBuiltinESMExports();
}
}
test('publication rejects incomplete, mixed-source, or modified platform sets before writing npm', async () => {
const stage = await mkdtemp(join(tmpdir(), 'maka-publication-'));
try {
const version = '0.2.0-rust-preview.1';
const source = {
archive: 'apache-maka-0.2.0-incubating-src.tar.gz',
version: '0.2.0',
sha512: 'a'.repeat(128),
};
await mkdir(join(stage, 'package'));
async function pack(target, origin = source) {
const name = '@maka-agent/cli-' + target;
const archive = 'maka-agent-cli-' + target + '-' + version + '.tgz';
await writeFile(
join(stage, 'package/package.json'),
JSON.stringify({
name,
version,
makaSource: origin,
publishConfig: { tag: 'rust-preview' },
}),
);
execFileSync('tar', ['-czf', join(stage, archive), '-C', stage, 'package']);
const integrity =
'sha512-' +
createHash('sha512')
.update(await readFile(join(stage, archive)))
.digest('base64');
await writeFile(
join(stage, target + '.json'),
JSON.stringify({ name, version, target, archive, integrity }),
);
}
await pack(previewTargets[0]);
await assert.rejects(readNativePreviewRelease(stage), /ENOENT/);
for (const target of previewTargets.slice(1)) await pack(target);
assert.equal((await readNativePreviewRelease(stage)).length, 3);
await pack(previewTargets[2], { ...source, sha512: 'b'.repeat(128) });
await assert.rejects(readNativePreviewRelease(stage), /same source archive/);
await pack(previewTargets[2]);
await writeFile(
join(stage, 'maka-agent-cli-' + previewTargets[0] + '-' + version + '.tgz'),
'changed',
);
await assert.rejects(readNativePreviewRelease(stage), /integrity differs/);
} finally {
await rm(stage, { recursive: true, force: true });
}
});
test('preview builds have distinct package versions without changing the source version', () => {
assert.equal(previewVersion('0.2.0', '20260916.1'), '0.2.0-rust-preview.20260916.1');
assert.equal(previewVersion('0.2.0', '20260916.2'), '0.2.0-rust-preview.20260916.2');
assert.equal(previewVersion('0.2.0', '123.2.gabc123'), '0.2.0-rust-preview.123.2.gabc123');
for (const buildId of [undefined, '', '01', '1..2', '1+sha', 'a/b', 'a'.repeat(256)]) {
assert.throws(
() => previewVersion('0.2.0', buildId),
/build-id|release version|256 characters/,
);
}
assert.throws(() => previewVersion('0.2.0-beta', '1'), /stable source version/);
});
test('source identity failures cannot reach dependency installation or compilation', async () => {
const stage = await mkdtemp(join(tmpdir(), 'maka-source-release-test-'));
try {
const name = 'apache-maka-1.2.3-incubating';
const sourceRoot = join(stage, name);
await mkdir(join(sourceRoot, 'src'), { recursive: true });
const files = {
LICENSE: 'Apache-2.0\n',
NOTICE: 'Apache Maka\n',
'DISCLAIMER-WIP': 'Apache Maka is undergoing incubation.\n',
'package.json': JSON.stringify({ name: 'maka', version: '1.2.3', license: 'Apache-2.0' }),
'package-lock.json': JSON.stringify({
version: '1.2.3',
lockfileVersion: 3,
packages: { '': { version: '1.2.3' } },
}),
'Cargo.toml': '[package]\nname = "maka-cli"\nversion = "0.0.0"\nedition = "2024"\n',
'src/main.rs': 'fn main() {}\n',
};
for (const [path, content] of Object.entries(files)) {
await writeFile(join(sourceRoot, path), content);
}
const source = join(stage, name + '-src.tar.gz');
execFileSync('tar', ['-czf', source, '-C', stage, name]);
const digest = createHash('sha512')
.update(await readFile(source))
.digest('hex');
await writeFile(source + '.sha512', digest + ' ' + name + '-src.tar.gz\n');
const args = {
buildId: '1',
source,
target: 'linux-x64-gnu',
// Deliberately absent: source identity must fail before any of these is used.
notices: join(stage, 'notices'),
validator: join(stage, 'validator'),
output: join(stage, 'output'),
};
await assert.rejects(releaseNativeCli(args), /Native CLI version does not match/);
await writeFile(source, 'changed since the checksum was created');
await assert.rejects(releaseNativeCli(args), /SHA-512 mismatch/);
await assert.rejects(releaseNativeCli({ ...args, target: 'toString' }), /supported target/);
} finally {
await rm(stage, { recursive: true, force: true });
}
});