mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-31 19:52:43 +00:00
* fix(release): cover every spawned vendored binary in the tarball exec-bit gate The gate matched only vendor/ripgrep/*/rg, but the tarball spawns a second vendored executable: vendor/landlock-run/<arch>-linux/qwen-landlock-run. Its own runtime heal shares the ripgrep heal's hard limit — it can only chmod an install the running user owns, so a root-owned sudo install degrades the same silent way. Widen the expected set with a small spec list keyed on the leaf binary's presence (the landlock-run/src source directory falls out structurally), parse every regular-file tar entry instead of a single ripgrep regex, and extend the fixture tests to cover both trees with pass/reject cases. Also correct the stale comment above the landlock heal: it claimed npm archives strip non-bin exec bits, which is the inverse of what #12668 established — pnpm's packer wrote 0644 for every non-bin entry from 0.24.4 to 0.25.3; npm keeps working-tree modes. Refs #12668 * fix(release): keep missing-binary strictness in the widened exec-bit gate Deriving the expected set from the leaf binary's presence meant a platform directory whose binary was dropped during staging silently shrank the expected set: a dist without vendor/ripgrep/x64-linux/rg but with the directory would pass with five entries. Platform membership now comes from the directory name alone — ripgrep skips '-win32', landlock-run skips the 'src' source directory — so a missing binary fails as 'missing from the packed tarball' again, matching the pre-widening strictness. Also update the function doc block, which still claimed the gate covered ripgrep only. Regression test: an empty ripgrep platform directory fails with the missing-entry diagnostic even when every other binary passes.
220 lines
7.4 KiB
JavaScript
220 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Release gate for the exec bits of spawned vendored binaries in the
|
|
* published CLI tarball (#12668).
|
|
*
|
|
* From 0.24.4 on the release published the generated CLI with `pnpm publish`,
|
|
* whose packer writes 0755 for the entries named in the manifest's `bin` map
|
|
* and 0644 for every other entry — so every vendored binary shipped without
|
|
* an exec bit and a fresh install could not spawn it. `npm pack` keeps the
|
|
* working-tree mode. This gate packs the prepared CLI with the same packer
|
|
* the publish step uses and reads the modes recorded in the archive (no
|
|
* extraction, so the umask cannot mask them), failing when any spawned
|
|
* vendored binary lacks all three exec bits: a root-owned
|
|
* `sudo npm install -g` is only executable by others with 0111 set, and the
|
|
* runtime heals cannot chmod such an install.
|
|
*
|
|
* Usage: node scripts/check-release-tarball-exec-bits.mjs [package-dir]
|
|
* Exit 0 = every spawned vendored binary keeps 0111; exit 1 = one does not,
|
|
* or the gate could not measure (a Windows host, an unreadable directory, no
|
|
* tarball, or no spawned vendored binaries). It never passes vacuously.
|
|
*/
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// Vendored binaries the CLI spawns directly, one expected entry per platform
|
|
// directory. Platform membership comes from the directory name alone: a
|
|
// platform directory whose binary is missing is itself a broken package and
|
|
// must fail the gate, never shrink the expected set. `rg.exe` is a 0644
|
|
// Windows binary by design, so `-win32` platforms are excluded;
|
|
// `landlock-run/src` is a source directory, not a platform, and is excluded
|
|
// by name. Any future vendored binary spawned the same way belongs here —
|
|
// its runtime heal shares the ripgrep heal's limit of being unable to chmod
|
|
// a root-owned or read-only install (#12668).
|
|
const SPAWNED_VENDORED_BINARIES = [
|
|
{
|
|
root: ['vendor', 'ripgrep'],
|
|
leaf: 'rg',
|
|
skip: (platform) => platform.endsWith('-win32'),
|
|
},
|
|
{
|
|
root: ['vendor', 'landlock-run'],
|
|
leaf: 'qwen-landlock-run',
|
|
skip: (platform) => platform === 'src',
|
|
},
|
|
];
|
|
|
|
// read/write/exec bit values for owner, group and other, in tar order.
|
|
const MODE_CLASSES = [
|
|
[0o400, 0o200, 0o100],
|
|
[0o40, 0o20, 0o10],
|
|
[0o4, 0o2, 0o1],
|
|
];
|
|
|
|
/**
|
|
* Converts a tar listing's symbolic mode (`-rwxr-xr-x`) to its permission
|
|
* bits, so the assertion reads the record instead of the extracted file.
|
|
*/
|
|
function symbolicModeToOctal(mode) {
|
|
if (!/^[-bcdlps][rwxstST-]{9}$/.test(mode)) {
|
|
throw new Error(`unparsable tar entry mode: ${mode}`);
|
|
}
|
|
let bits = 0;
|
|
for (let index = 0; index < MODE_CLASSES.length; index += 1) {
|
|
const [read, write, exec] = MODE_CLASSES[index];
|
|
const triplet = mode.slice(1 + index * 3, 4 + index * 3);
|
|
if (triplet[0] === 'r') bits |= read;
|
|
if (triplet[1] === 'w') bits |= write;
|
|
// A lowercase `s`/`t` sets a special bit that also carries the class's
|
|
// exec bit; the uppercase `S`/`T` forms leave it unset.
|
|
if ('xst'.includes(triplet[2])) bits |= exec;
|
|
}
|
|
return bits;
|
|
}
|
|
|
|
/** Maps every regular-file entry in the archive to its recorded mode. */
|
|
function listTarEntryModes(tarball) {
|
|
const listing = spawnSync('tar', ['-tvzf', tarball], { encoding: 'utf8' });
|
|
if (listing.status !== 0) {
|
|
throw new Error(
|
|
`tar -tvzf ${tarball} failed: ${String(listing.stderr ?? '').trim()}`,
|
|
);
|
|
}
|
|
const entries = new Map();
|
|
for (const line of listing.stdout.split('\n')) {
|
|
if (line[0] !== '-') continue;
|
|
const name = line.trim().split(/\s+/).pop();
|
|
if (name !== undefined) {
|
|
entries.set(name, symbolicModeToOctal(line.slice(0, 10)));
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Packs `packageDir` the way the release publishes it and asserts every
|
|
* spawned vendored binary entry keeps 0111. Throws with a per-entry report
|
|
* otherwise.
|
|
*/
|
|
export function checkReleaseTarballExecBits(packageDir = 'dist') {
|
|
if (process.platform === 'win32') {
|
|
throw new Error(
|
|
'the release tarball exec-bit gate cannot read POSIX tar entry modes on Windows',
|
|
);
|
|
}
|
|
const expected = expectedSpawnedEntries(packageDir);
|
|
const destination = mkdtempSync(join(tmpdir(), 'release-tarball-exec-bits-'));
|
|
try {
|
|
const tarball = pack(packageDir, destination);
|
|
const actual = listTarEntryModes(tarball);
|
|
const problems = [];
|
|
for (const name of expected) {
|
|
const mode = actual.get(name);
|
|
if (mode === undefined) {
|
|
problems.push(`${name}: missing from the packed tarball`);
|
|
} else if ((mode & 0o111) !== 0o111) {
|
|
problems.push(`${name}: ${octal(mode)} lacks an exec bit`);
|
|
}
|
|
}
|
|
if (problems.length > 0) {
|
|
throw new Error(
|
|
[
|
|
`the CLI tarball ships a spawned vendored binary without all exec bits (${packageDir})`,
|
|
...problems.map((problem) => ` ${problem}`),
|
|
].join('\n'),
|
|
);
|
|
}
|
|
return {
|
|
tarball,
|
|
entries: expected.map((name) => [name, actual.get(name)]),
|
|
};
|
|
} finally {
|
|
rmSync(destination, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function expectedSpawnedEntries(packageDir) {
|
|
const expected = [];
|
|
for (const spec of SPAWNED_VENDORED_BINARIES) {
|
|
const root = join(packageDir, ...spec.root);
|
|
let platforms;
|
|
try {
|
|
platforms = readdirSync(root, { withFileTypes: true });
|
|
} catch (error) {
|
|
throw new Error(
|
|
`cannot read ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
for (const platform of platforms) {
|
|
if (!platform.isDirectory() || spec.skip(platform.name)) continue;
|
|
expected.push(
|
|
`package/${spec.root.join('/')}/${platform.name}/${spec.leaf}`,
|
|
);
|
|
}
|
|
}
|
|
if (expected.length === 0) {
|
|
throw new Error(
|
|
`no spawned vendored executables under ${join(packageDir, 'vendor')}; the gate refuses to pass vacuously`,
|
|
);
|
|
}
|
|
expected.sort();
|
|
return expected;
|
|
}
|
|
|
|
function pack(packageDir, destination) {
|
|
const packed = spawnSync('npm', ['pack', '--pack-destination', destination], {
|
|
cwd: packageDir,
|
|
encoding: 'utf8',
|
|
});
|
|
if (packed.status !== 0) {
|
|
throw new Error(
|
|
`npm pack failed in ${packageDir}: ${String(packed.stderr ?? '').trim()}`,
|
|
);
|
|
}
|
|
const tarballs = readdirSync(destination).filter((name) =>
|
|
name.endsWith('.tgz'),
|
|
);
|
|
if (tarballs.length !== 1) {
|
|
throw new Error(
|
|
`npm pack in ${packageDir} produced ${tarballs.length} tarballs; expected one`,
|
|
);
|
|
}
|
|
return join(destination, tarballs[0]);
|
|
}
|
|
|
|
function octal(bits) {
|
|
return `0${bits.toString(8).padStart(3, '0')}`;
|
|
}
|
|
|
|
function main(argv) {
|
|
const packageDir = argv[2] ?? 'dist';
|
|
try {
|
|
const { tarball, entries } = checkReleaseTarballExecBits(packageDir);
|
|
process.stdout.write(
|
|
`ok: ${entries.length} spawned vendored binaries keep 0111 in ${tarball} (${packageDir})\n`,
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stdout.write(`::error::${message.split('\n')[0]}\n`);
|
|
process.stderr.write(`${message}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (
|
|
process.argv[1] !== undefined &&
|
|
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
main(process.argv);
|
|
}
|