Files
larksuite-cli/scripts/release-candidate.js
2026-07-28 14:53:35 +08:00

674 lines
21 KiB
JavaScript

#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { isDeepStrictEqual } = require("node:util");
const MANIFEST_NAME = "candidate-manifest.json";
const RELEASE_VERSION_PATTERN =
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?$/;
const SHA_PATTERN = /^[0-9a-fA-F]{40}$/;
const SHA256_PATTERN = /^[0-9a-fA-F]{64}$/;
function fail(message) {
throw new Error(message);
}
function hasOwn(value, key) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function assertObject(value, label) {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
fail(`${label} must be an object`);
}
}
function assertExactKeys(value, expectedKeys, label) {
assertObject(value, label);
const expected = new Set(expectedKeys);
const unexpected = Object.keys(value).filter((key) => !expected.has(key)).sort();
const missing = expectedKeys.filter((key) => !hasOwn(value, key));
if (unexpected.length > 0) {
fail(`${label} has unexpected field: ${unexpected.join(", ")}`);
}
if (missing.length > 0) {
fail(`${label} is missing required field: ${missing.join(", ")}`);
}
}
function normalizeSourceSha(sourceSha, label = "sourceSha") {
if (typeof sourceSha !== "string" || !SHA_PATTERN.test(sourceSha)) {
fail(`${label} must be exactly 40 hexadecimal characters`);
}
return sourceSha.toLowerCase();
}
function parseReleaseVersion(version) {
const match = typeof version === "string" ? RELEASE_VERSION_PATTERN.exec(version) : null;
if (!match) {
fail(
"release version must use Stable X.Y.Z or Beta X.Y.Z-beta.N; "
+ "other prerelease labels, build metadata, and leading zeros are not allowed",
);
}
return {
version,
channel: match[4] === undefined ? "stable" : "beta",
major: match[1],
minor: match[2],
patch: match[3],
beta: match[4] === undefined ? null : match[4],
};
}
function validateChannel(version, channel, label = "metadata") {
const parsed = parseReleaseVersion(version);
if (channel !== "stable" && channel !== "beta") {
fail(`${label}.channel must be stable or beta`);
}
if (parsed.channel !== channel) {
fail(`${label}.version requires channel ${parsed.channel}, received ${channel}`);
}
return parsed;
}
function assertSafeFilename(name, label) {
if (
typeof name !== "string"
|| name.length === 0
|| name === "."
|| name === ".."
|| name.includes("/")
|| name.includes("\\")
|| path.basename(name) !== name
) {
fail(`${label} must be a safe basename without path separators`);
}
}
function filePath(directory, name, label) {
assertSafeFilename(name, label);
return path.join(directory, name);
}
function assertRegularFile(directory, name, label) {
const target = filePath(directory, name, label);
let stat;
try {
stat = fs.lstatSync(target);
} catch (error) {
fail(`${label} ${name} could not be inspected: ${error.message}`);
}
if (stat.isSymbolicLink() || !stat.isFile()) {
fail(`${label} ${name} must be a regular file, not a symlink or other file type`);
}
return target;
}
function listRegularFiles(directory) {
let stat;
try {
stat = fs.lstatSync(directory);
} catch (error) {
fail(`candidate directory could not be inspected: ${error.message}`);
}
if (stat.isSymbolicLink() || !stat.isDirectory()) {
fail("candidate directory must be a directory and must not be a symlink");
}
let names;
try {
names = fs.readdirSync(directory);
} catch (error) {
fail(`candidate directory could not be read: ${error.message}`);
}
names.sort();
for (const name of names) {
assertRegularFile(directory, name, "candidate entry");
}
return names;
}
function hashFile(target, algorithms) {
const hashes = algorithms.map((algorithm) => crypto.createHash(algorithm));
const buffer = Buffer.allocUnsafe(64 * 1024);
let descriptor;
try {
descriptor = fs.openSync(target, "r");
for (;;) {
const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (length === 0) break;
const chunk = buffer.subarray(0, length);
for (const hash of hashes) hash.update(chunk);
}
} catch (error) {
fail(`could not hash ${path.basename(target)}: ${error.message}`);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
return hashes.map((hash) => hash.digest());
}
function sha256File(target) {
return hashFile(target, ["sha256"])[0].toString("hex");
}
function npmDigests(target) {
const [sha256, sha512] = hashFile(target, ["sha256", "sha512"]);
return {
sha256: sha256.toString("hex"),
integrity: `sha512-${sha512.toString("base64")}`,
};
}
function validateCreateMetadata(metadata) {
assertObject(metadata, "metadata");
const sourceSha = normalizeSourceSha(metadata.sourceSha, "metadata.sourceSha");
if (typeof metadata.version !== "string") {
fail("metadata.version must be a string");
}
validateChannel(metadata.version, metadata.channel);
assertSafeFilename(metadata.npmPackage, "metadata.npmPackage");
if (!metadata.npmPackage.endsWith(".tgz")) {
fail("metadata.npmPackage must designate an npm .tgz file");
}
return {
sourceSha,
version: metadata.version,
channel: metadata.channel,
npmPackage: metadata.npmPackage,
};
}
function createCandidateManifest(directory, metadata) {
const normalized = validateCreateMetadata(metadata);
const names = listRegularFiles(directory);
if (!names.includes(normalized.npmPackage)) {
fail(`designated npm package is missing: ${normalized.npmPackage}`);
}
const releaseAssetNames = names.filter(
(name) => name !== MANIFEST_NAME && name !== normalized.npmPackage,
);
const releaseAssets = releaseAssetNames.map((name) => ({
name,
sha256: sha256File(assertRegularFile(directory, name, "release asset")),
}));
const npmTarget = assertRegularFile(
directory,
normalized.npmPackage,
"designated npm package",
);
return {
schemaVersion: 1,
sourceSha: normalized.sourceSha,
version: normalized.version,
channel: normalized.channel,
releaseAssets,
npmPackage: {
name: normalized.npmPackage,
...npmDigests(npmTarget),
},
};
}
function validateSha256(value, label) {
if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
fail(`${label} must be exactly 64 hexadecimal characters`);
}
return value.toLowerCase();
}
function validateIntegrity(value, label) {
if (typeof value !== "string" || !value.startsWith("sha512-") || value.length === 7) {
fail(`${label} must contain one canonical SHA-512 digest`);
}
const encoded = value.slice(7);
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
fail(`${label} must contain one canonical SHA-512 digest`);
}
const decoded = Buffer.from(encoded, "base64");
if (decoded.length !== 64 || decoded.toString("base64") !== encoded) {
fail(`${label} must contain one canonical SHA-512 digest`);
}
return value;
}
function validateManifest(manifest) {
assertExactKeys(
manifest,
[
"schemaVersion",
"sourceSha",
"version",
"channel",
"releaseAssets",
"npmPackage",
],
"manifest",
);
if (manifest.schemaVersion !== 1) {
fail("manifest.schemaVersion must be 1");
}
const sourceSha = normalizeSourceSha(manifest.sourceSha, "manifest.sourceSha");
if (typeof manifest.version !== "string") {
fail("manifest.version must be a string");
}
validateChannel(manifest.version, manifest.channel, "manifest");
if (!Array.isArray(manifest.releaseAssets)) {
fail("manifest.releaseAssets must be an array");
}
const seen = new Set();
let previousName = null;
const releaseAssets = manifest.releaseAssets.map((asset, index) => {
const label = `manifest.releaseAssets[${index}]`;
assertExactKeys(asset, ["name", "sha256"], label);
assertSafeFilename(asset.name, `${label}.name`);
if (asset.name === MANIFEST_NAME) {
fail(`${MANIFEST_NAME} is reserved and must not appear in releaseAssets`);
}
if (seen.has(asset.name)) {
fail(`manifest contains duplicate release asset: ${asset.name}`);
}
if (previousName !== null && previousName >= asset.name) {
fail("manifest.releaseAssets must be sorted by name");
}
seen.add(asset.name);
previousName = asset.name;
return {
name: asset.name,
sha256: validateSha256(asset.sha256, `${label}.sha256`),
};
});
assertExactKeys(manifest.npmPackage, ["name", "sha256", "integrity"], "manifest.npmPackage");
assertSafeFilename(manifest.npmPackage.name, "manifest.npmPackage.name");
if (seen.has(manifest.npmPackage.name)) {
fail(`release asset ${manifest.npmPackage.name} duplicates npm package`);
}
if (!manifest.npmPackage.name.endsWith(".tgz")) {
fail("manifest.npmPackage.name must designate an npm .tgz file");
}
return {
sourceSha,
version: manifest.version,
channel: manifest.channel,
releaseAssets,
npmPackage: {
name: manifest.npmPackage.name,
sha256: validateSha256(
manifest.npmPackage.sha256,
"manifest.npmPackage.sha256",
),
integrity: validateIntegrity(
manifest.npmPackage.integrity,
"manifest.npmPackage.integrity",
),
},
};
}
function validateExpectedMetadata(expectedMetadata) {
assertObject(expectedMetadata, "expected metadata");
const sourceSha = normalizeSourceSha(
expectedMetadata.sourceSha,
"expected metadata.sourceSha",
);
if (typeof expectedMetadata.version !== "string") {
fail("expected metadata.version must be a string");
}
validateChannel(expectedMetadata.version, expectedMetadata.channel, "expected metadata");
return {
sourceSha,
version: expectedMetadata.version,
channel: expectedMetadata.channel,
};
}
function describeSetMismatch(label, expected, actual) {
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
const missing = expected.filter((name) => !actualSet.has(name));
const unexpected = actual.filter((name) => !expectedSet.has(name));
if (missing.length === 0 && unexpected.length === 0) return;
fail(
`${label} set does not match manifest `
+ `(missing: ${missing.length > 0 ? missing.join(", ") : "none"}; `
+ `unexpected: ${unexpected.length > 0 ? unexpected.join(", ") : "none"})`,
);
}
function verifyCandidateManifest(directory, manifest, expectedMetadata, scope) {
if (scope !== "artifact" && scope !== "release") {
fail("verification scope must be artifact or release");
}
const validated = validateManifest(manifest);
const expected = validateExpectedMetadata(expectedMetadata);
if (validated.sourceSha !== expected.sourceSha) {
fail(
`manifest sourceSha does not match expected sourceSha `
+ `(${validated.sourceSha} != ${expected.sourceSha})`,
);
}
if (validated.version !== expected.version) {
fail(
`manifest version does not match expected version `
+ `(${validated.version} != ${expected.version})`,
);
}
if (validated.channel !== expected.channel) {
fail(
`manifest channel does not match expected channel `
+ `(${validated.channel} != ${expected.channel})`,
);
}
const actualNames = listRegularFiles(directory);
const releaseNames = validated.releaseAssets.map((asset) => asset.name);
const expectedNames = scope === "artifact"
? [...releaseNames, validated.npmPackage.name, MANIFEST_NAME].sort()
: [...releaseNames].sort();
describeSetMismatch(
scope === "artifact" ? "artifact file" : "release asset",
expectedNames,
actualNames,
);
if (scope === "artifact") {
const inDirectoryManifest = readManifest(
filePath(directory, MANIFEST_NAME, "candidate manifest"),
);
if (!isDeepStrictEqual(inDirectoryManifest, manifest)) {
fail(
"in-directory candidate manifest does not match the manifest supplied for verification",
);
}
}
for (const asset of validated.releaseAssets) {
const actual = sha256File(assertRegularFile(directory, asset.name, "release asset"));
if (actual !== asset.sha256) {
fail(
`SHA-256 mismatch for release asset ${asset.name}: `
+ `expected ${asset.sha256}, observed ${actual}`,
);
}
}
if (scope === "artifact") {
const target = assertRegularFile(
directory,
validated.npmPackage.name,
"npm package",
);
const actual = npmDigests(target);
if (actual.sha256 !== validated.npmPackage.sha256) {
fail(
`npm package SHA-256 mismatch for ${validated.npmPackage.name}: `
+ `expected ${validated.npmPackage.sha256}, observed ${actual.sha256}`,
);
}
if (actual.integrity !== validated.npmPackage.integrity) {
fail(
`npm package integrity mismatch for ${validated.npmPackage.name}: `
+ `expected ${validated.npmPackage.integrity}, observed ${actual.integrity}`,
);
}
}
return true;
}
function compareReleaseVersions(left, right) {
const leftMatch = RELEASE_VERSION_PATTERN.exec(left);
const rightMatch = RELEASE_VERSION_PATTERN.exec(right);
const leftParts = leftMatch.slice(1, 4).map((part) => BigInt(part));
const rightParts = rightMatch.slice(1, 4).map((part) => BigInt(part));
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] < rightParts[index]) return -1;
if (leftParts[index] > rightParts[index]) return 1;
}
if (leftMatch[4] === undefined && rightMatch[4] === undefined) return 0;
const leftBeta = BigInt(leftMatch[4]);
const rightBeta = BigInt(rightMatch[4]);
if (leftBeta < rightBeta) return -1;
if (leftBeta > rightBeta) return 1;
return 0;
}
function validateObservedDistTags(distTags) {
if (distTags === undefined) return {};
assertObject(distTags, "observed.distTags");
for (const [distTag, channel] of [["latest", "stable"], ["beta", "beta"]]) {
if (!hasOwn(distTags, distTag)) continue;
let parsed;
try {
parsed = parseReleaseVersion(distTags[distTag]);
} catch {
fail(`observed dist-tag ${distTag} must contain a valid ${channel} version`);
}
if (parsed.channel !== channel) {
fail(`observed dist-tag ${distTag} must contain a valid ${channel} version`);
}
}
return distTags;
}
function evaluateNpmState(target, observed) {
assertExactKeys(target, ["version", "channel", "integrity"], "target");
assertObject(observed, "observed");
if (typeof target.version !== "string") {
fail("target.version must be a string");
}
validateChannel(target.version, target.channel, "target");
validateIntegrity(target.integrity, "target.integrity");
if (hasOwn(observed, "versionPresent") && typeof observed.versionPresent !== "boolean") {
fail("observed.versionPresent must be a boolean");
}
if (
hasOwn(observed, "publishedVersion")
&& observed.publishedVersion !== undefined
&& observed.publishedVersion !== target.version
) {
fail("observed.publishedVersion must equal target.version when provided");
}
const hasPublishedIntegrity = hasOwn(observed, "publishedIntegrity");
if (hasPublishedIntegrity) {
validateIntegrity(observed.publishedIntegrity, "observed.publishedIntegrity");
}
const distTags = validateObservedDistTags(observed.distTags);
const distTag = target.channel === "stable" ? "latest" : "beta";
const versionPresent =
observed.versionPresent === true
|| observed.publishedVersion === target.version
|| hasPublishedIntegrity;
if (observed.versionPresent === false && hasPublishedIntegrity) {
fail("observed npm state is inconsistent: version is absent but integrity is present");
}
if (versionPresent) {
if (!hasPublishedIntegrity) {
fail(`npm version ${target.version} is present but published integrity is missing`);
}
if (observed.publishedIntegrity !== target.integrity) {
fail(`npm version ${target.version} already exists with different integrity`);
}
if (!hasOwn(distTags, distTag)) {
fail(
`cannot reuse npm version ${target.version}: `
+ `dist-tag ${distTag} is missing; repair registry state manually`,
);
}
if (compareReleaseVersions(distTags[distTag], target.version) < 0) {
fail(
`cannot reuse npm version ${target.version}: dist-tag ${distTag} is behind `
+ `(${distTags[distTag]} < ${target.version}); repair registry state manually`,
);
}
return { distTag, action: "reuse" };
}
if (hasOwn(distTags, distTag)) {
const comparison = compareReleaseVersions(distTags[distTag], target.version);
if (comparison > 0) {
fail(
`npm dist-tag ${distTag} must not move backwards from `
+ `${distTags[distTag]} to ${target.version}`,
);
}
if (comparison === 0) {
fail(
`npm dist-tag ${distTag} already points to target version ${target.version}, `
+ "but the registry reports that version absent",
);
}
}
return { distTag, action: "publish" };
}
function parseCliOptions(args, allowedOptions) {
const allowed = new Set(allowedOptions);
const options = {};
for (let index = 0; index < args.length; index += 2) {
const option = args[index];
const value = args[index + 1];
if (typeof option !== "string" || !option.startsWith("--") || !allowed.has(option)) {
fail(`unknown option: ${option === undefined ? "(missing)" : option}`);
}
if (value === undefined || value.startsWith("--")) {
fail(`option ${option} requires a value`);
}
if (hasOwn(options, option)) {
fail(`option ${option} must not be repeated`);
}
options[option] = value;
}
for (const option of allowedOptions) {
if (!hasOwn(options, option)) fail(`required option is missing: ${option}`);
}
return options;
}
function readManifest(manifestPath) {
let stat;
try {
stat = fs.lstatSync(manifestPath);
} catch (error) {
fail(`manifest could not be inspected: ${error.message}`);
}
if (stat.isSymbolicLink() || !stat.isFile()) {
fail("manifest must be a regular file and must not be a symlink");
}
try {
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
} catch (error) {
fail(`manifest must contain valid JSON: ${error.message}`);
}
}
function writeSuccess(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function writeFailure(error) {
process.stderr.write(`${JSON.stringify({
ok: false,
error: {
type: "release_candidate",
message: error.message,
},
})}\n`);
process.exitCode = 1;
}
function main() {
try {
const [command, ...args] = process.argv.slice(2);
if (command !== "create" && command !== "verify") {
fail("command must be create or verify");
}
if (command === "create") {
const options = parseCliOptions(args, [
"--directory",
"--manifest",
"--source-sha",
"--version",
"--channel",
"--npm-package",
]);
const directory = path.resolve(options["--directory"]);
const manifestPath = path.resolve(options["--manifest"]);
const expectedManifestPath = path.join(directory, MANIFEST_NAME);
if (manifestPath !== expectedManifestPath) {
fail(`--manifest must be ${expectedManifestPath} for create`);
}
const manifest = createCandidateManifest(directory, {
sourceSha: options["--source-sha"],
version: options["--version"],
channel: options["--channel"],
npmPackage: options["--npm-package"],
});
try {
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
} catch (error) {
fail(`candidate manifest could not be written: ${error.message}`);
}
writeSuccess({ ok: true, manifest });
return;
}
const options = parseCliOptions(args, [
"--directory",
"--manifest",
"--scope",
"--source-sha",
"--version",
"--channel",
]);
const directory = path.resolve(options["--directory"]);
const manifestPath = path.resolve(options["--manifest"]);
const scope = options["--scope"];
if (scope === "artifact" && manifestPath !== path.join(directory, MANIFEST_NAME)) {
fail(
`--manifest must be ${path.join(directory, MANIFEST_NAME)} `
+ "inside --directory for artifact scope",
);
}
const manifest = readManifest(manifestPath);
verifyCandidateManifest(
directory,
manifest,
{
sourceSha: options["--source-sha"],
version: options["--version"],
channel: options["--channel"],
},
scope,
);
writeSuccess({
ok: true,
scope,
version: manifest.version,
channel: manifest.channel,
sourceSha: normalizeSourceSha(manifest.sourceSha),
});
} catch (error) {
writeFailure(error);
}
}
module.exports = {
createCandidateManifest,
verifyCandidateManifest,
evaluateNpmState,
parseReleaseVersion,
};
if (require.main === module) main();