mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
ci: simplify release trust checks
This commit is contained in:
@@ -1,673 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,753 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { afterEach, describe, it } = require("node:test");
|
||||
|
||||
const {
|
||||
createCandidateManifest,
|
||||
evaluateNpmState,
|
||||
parseReleaseVersion,
|
||||
verifyCandidateManifest,
|
||||
} = require("./release-candidate");
|
||||
|
||||
const SOURCE_SHA = "ABCDEF0123456789ABCDEF0123456789ABCDEF01";
|
||||
const tempDirectories = [];
|
||||
|
||||
function tempDirectory() {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "release-candidate-"));
|
||||
tempDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeCandidate(version = "1.2.3", channel = "stable") {
|
||||
const directory = tempDirectory();
|
||||
const npmPackage = `larksuite-cli-${version}.tgz`;
|
||||
fs.writeFileSync(path.join(directory, "z-checksums.txt"), "checksums\n");
|
||||
fs.writeFileSync(path.join(directory, `lark-cli-${version}-linux-amd64.tar.gz`), "linux\n");
|
||||
fs.writeFileSync(path.join(directory, npmPackage), "npm package\n");
|
||||
return {
|
||||
directory,
|
||||
metadata: { sourceSha: SOURCE_SHA, version, channel, npmPackage },
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function sha512Integrity(value) {
|
||||
return `sha512-${crypto.createHash("sha512").update(value).digest("base64")}`;
|
||||
}
|
||||
|
||||
function writeManifest(directory, manifest) {
|
||||
fs.writeFileSync(
|
||||
path.join(directory, "candidate-manifest.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function copyReleaseAssets(sourceDirectory, manifest) {
|
||||
const releaseDirectory = tempDirectory();
|
||||
for (const asset of manifest.releaseAssets) {
|
||||
fs.copyFileSync(
|
||||
path.join(sourceDirectory, asset.name),
|
||||
path.join(releaseDirectory, asset.name),
|
||||
);
|
||||
}
|
||||
return releaseDirectory;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirectories.length > 0) {
|
||||
fs.rmSync(tempDirectories.pop(), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseReleaseVersion", () => {
|
||||
it("accepts exact stable and beta versions", () => {
|
||||
assert.deepEqual(parseReleaseVersion("1.2.3"), {
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
major: "1",
|
||||
minor: "2",
|
||||
patch: "3",
|
||||
beta: null,
|
||||
});
|
||||
assert.deepEqual(parseReleaseVersion("1.2.3-beta.4"), {
|
||||
version: "1.2.3-beta.4",
|
||||
channel: "beta",
|
||||
major: "1",
|
||||
minor: "2",
|
||||
patch: "3",
|
||||
beta: "4",
|
||||
});
|
||||
assert.deepEqual(parseReleaseVersion("9007199254740993.9007199254740995.0"), {
|
||||
version: "9007199254740993.9007199254740995.0",
|
||||
channel: "stable",
|
||||
major: "9007199254740993",
|
||||
minor: "9007199254740995",
|
||||
patch: "0",
|
||||
beta: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsupported labels, build metadata, and leading zeros", () => {
|
||||
for (const version of [
|
||||
"1.2.3-alpha.1",
|
||||
"1.2.3-rc.1",
|
||||
"1.2.3-beta",
|
||||
"1.2.3-beta.01",
|
||||
"1.2.3+build.1",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
]) {
|
||||
assert.throws(() => parseReleaseVersion(version), /Stable X\.Y\.Z or Beta X\.Y\.Z-beta\.N/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate manifest", () => {
|
||||
it("creates a normalized, sorted stable manifest with exact digests", () => {
|
||||
const { directory, metadata } = writeCandidate();
|
||||
|
||||
const manifest = createCandidateManifest(directory, metadata);
|
||||
|
||||
assert.deepEqual(manifest, {
|
||||
schemaVersion: 1,
|
||||
sourceSha: SOURCE_SHA.toLowerCase(),
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
releaseAssets: [
|
||||
{
|
||||
name: "lark-cli-1.2.3-linux-amd64.tar.gz",
|
||||
sha256: sha256("linux\n"),
|
||||
},
|
||||
{
|
||||
name: "z-checksums.txt",
|
||||
sha256: sha256("checksums\n"),
|
||||
},
|
||||
],
|
||||
npmPackage: {
|
||||
name: "larksuite-cli-1.2.3.tgz",
|
||||
sha256: sha256("npm package\n"),
|
||||
integrity: sha512Integrity("npm package\n"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and verifies a beta artifact candidate", () => {
|
||||
const candidate = writeCandidate("2.0.0-beta.7", "beta");
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
writeManifest(candidate.directory, manifest);
|
||||
|
||||
assert.equal(manifest.channel, "beta");
|
||||
assert.equal(
|
||||
verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies a release directory containing only release assets", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
|
||||
assert.equal(
|
||||
verifyCandidateManifest(releaseDirectory, manifest, candidate.metadata, "release"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a modified candidate file", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
writeManifest(candidate.directory, manifest);
|
||||
fs.appendFileSync(
|
||||
path.join(candidate.directory, manifest.releaseAssets[0].name),
|
||||
"tampered",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/SHA-256 mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects modified npm package content and integrity", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
writeManifest(candidate.directory, manifest);
|
||||
fs.appendFileSync(path.join(candidate.directory, manifest.npmPackage.name), "tampered");
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/npm package SHA-256 mismatch/,
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(candidate.directory, manifest.npmPackage.name),
|
||||
"npm package\n",
|
||||
);
|
||||
manifest.npmPackage.integrity = sha512Integrity("different npm package");
|
||||
writeManifest(candidate.directory, manifest);
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/npm package integrity mismatch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing and unexpected release assets", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
fs.rmSync(path.join(releaseDirectory, manifest.releaseAssets[0].name));
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(releaseDirectory, manifest, candidate.metadata, "release"),
|
||||
/release asset set does not match.*missing:/,
|
||||
);
|
||||
|
||||
fs.copyFileSync(
|
||||
path.join(candidate.directory, manifest.releaseAssets[0].name),
|
||||
path.join(releaseDirectory, manifest.releaseAssets[0].name),
|
||||
);
|
||||
fs.writeFileSync(path.join(releaseDirectory, "unexpected.zip"), "unexpected");
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(releaseDirectory, manifest, candidate.metadata, "release"),
|
||||
/release asset set does not match.*unexpected:/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects symlinks and unsafe designated package names", () => {
|
||||
const candidate = writeCandidate();
|
||||
fs.symlinkSync(
|
||||
path.join(candidate.directory, candidate.metadata.npmPackage),
|
||||
path.join(candidate.directory, "linked.tgz"),
|
||||
);
|
||||
assert.throws(
|
||||
() => createCandidateManifest(candidate.directory, candidate.metadata),
|
||||
/linked\.tgz.*regular file/,
|
||||
);
|
||||
|
||||
fs.rmSync(path.join(candidate.directory, "linked.tgz"));
|
||||
for (const npmPackage of ["../package.tgz", "nested/package.tgz", "nested\\package.tgz", ".", ".."]) {
|
||||
assert.throws(
|
||||
() => createCandidateManifest(
|
||||
candidate.directory,
|
||||
{ ...candidate.metadata, npmPackage },
|
||||
),
|
||||
/safe basename/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects symlinked candidate directories and candidate manifests", () => {
|
||||
const candidate = writeCandidate();
|
||||
const linkParent = tempDirectory();
|
||||
const directoryLink = path.join(linkParent, "candidate-link");
|
||||
fs.symlinkSync(candidate.directory, directoryLink, "dir");
|
||||
assert.throws(
|
||||
() => createCandidateManifest(directoryLink, candidate.metadata),
|
||||
/candidate directory.*symlink/,
|
||||
);
|
||||
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const externalDirectory = tempDirectory();
|
||||
const externalManifest = path.join(externalDirectory, "manifest.json");
|
||||
fs.writeFileSync(externalManifest, `${JSON.stringify(manifest)}\n`);
|
||||
fs.symlinkSync(
|
||||
externalManifest,
|
||||
path.join(candidate.directory, "candidate-manifest.json"),
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/candidate-manifest\.json must be a regular file/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects path traversal and duplicate manifest entries", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
|
||||
const traversing = clone(manifest);
|
||||
traversing.releaseAssets[0].name = "../outside";
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(releaseDirectory, traversing, candidate.metadata, "release"),
|
||||
/safe basename/,
|
||||
);
|
||||
|
||||
const duplicate = clone(manifest);
|
||||
duplicate.releaseAssets.push({ ...duplicate.releaseAssets[0] });
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(releaseDirectory, duplicate, candidate.metadata, "release"),
|
||||
/duplicate release asset/,
|
||||
);
|
||||
|
||||
const collision = clone(manifest);
|
||||
collision.npmPackage.name = collision.releaseAssets[0].name;
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(releaseDirectory, collision, candidate.metadata, "release"),
|
||||
/duplicates npm package/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects metadata, schema, and channel mismatches", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
releaseDirectory,
|
||||
manifest,
|
||||
{ ...candidate.metadata, sourceSha: "0".repeat(40) },
|
||||
"release",
|
||||
),
|
||||
/sourceSha does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
releaseDirectory,
|
||||
manifest,
|
||||
{ ...candidate.metadata, version: "1.2.4" },
|
||||
"release",
|
||||
),
|
||||
/version does not match/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createCandidateManifest(
|
||||
candidate.directory,
|
||||
{ ...candidate.metadata, channel: "beta" },
|
||||
),
|
||||
/version requires channel stable/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
releaseDirectory,
|
||||
{ ...manifest, schemaVersion: 2 },
|
||||
candidate.metadata,
|
||||
"release",
|
||||
),
|
||||
/schemaVersion must be 1/,
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
releaseDirectory,
|
||||
{ ...manifest, unexpected: true },
|
||||
candidate.metadata,
|
||||
"release",
|
||||
),
|
||||
/unexpected field/,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the exact artifact directory set", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/artifact file set does not match.*missing: candidate-manifest\.json/,
|
||||
);
|
||||
|
||||
writeManifest(candidate.directory, manifest);
|
||||
fs.writeFileSync(path.join(candidate.directory, "unexpected"), "unexpected");
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/artifact file set does not match.*unexpected: unexpected/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an artifact manifest object that differs from the in-directory manifest", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const tamperedManifest = clone(manifest);
|
||||
tamperedManifest.sourceSha = "0".repeat(40);
|
||||
writeManifest(candidate.directory, tamperedManifest);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
candidate.directory,
|
||||
manifest,
|
||||
candidate.metadata,
|
||||
"artifact",
|
||||
),
|
||||
/in-directory candidate manifest does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects candidate-manifest.json as a release asset entry", () => {
|
||||
const candidate = writeCandidate();
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
const injectedManifest = clone(manifest);
|
||||
const injectedContent = "not an authoritative manifest\n";
|
||||
injectedManifest.releaseAssets.unshift({
|
||||
name: "candidate-manifest.json",
|
||||
sha256: sha256(injectedContent),
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(releaseDirectory, "candidate-manifest.json"),
|
||||
injectedContent,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => verifyCandidateManifest(
|
||||
releaseDirectory,
|
||||
injectedManifest,
|
||||
candidate.metadata,
|
||||
"release",
|
||||
),
|
||||
/candidate-manifest\.json is reserved/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateNpmState", () => {
|
||||
it("publishes stable and beta versions to their fixed dist-tags", () => {
|
||||
const stableIntegrity = sha512Integrity("stable package");
|
||||
const betaIntegrity = sha512Integrity("beta package");
|
||||
assert.deepEqual(
|
||||
evaluateNpmState(
|
||||
{ version: "1.2.3", channel: "stable", integrity: stableIntegrity },
|
||||
{ distTags: { latest: "1.2.2", beta: "1.3.0-beta.1" } },
|
||||
),
|
||||
{ distTag: "latest", action: "publish" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateNpmState(
|
||||
{ version: "1.3.0-beta.2", channel: "beta", integrity: betaIntegrity },
|
||||
{ distTags: { latest: "1.2.3", beta: "1.3.0-beta.1" } },
|
||||
),
|
||||
{ distTag: "beta", action: "publish" },
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses a published stable version only when latest is equal or higher", () => {
|
||||
const integrity = sha512Integrity("stable package");
|
||||
const target = { version: "1.2.3", channel: "stable", integrity };
|
||||
for (const distTags of [undefined, { latest: "1.2.2" }]) {
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, {
|
||||
versionPresent: true,
|
||||
publishedIntegrity: integrity,
|
||||
...(distTags === undefined ? {} : { distTags }),
|
||||
}),
|
||||
/cannot reuse.*dist-tag latest (is missing|is behind)/,
|
||||
);
|
||||
}
|
||||
for (const latest of ["1.2.3", "1.2.4"]) {
|
||||
assert.deepEqual(
|
||||
evaluateNpmState(target, {
|
||||
versionPresent: true,
|
||||
publishedIntegrity: integrity,
|
||||
distTags: { latest },
|
||||
}),
|
||||
{ distTag: "latest", action: "reuse" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a published beta version only when beta is equal or higher", () => {
|
||||
const integrity = sha512Integrity("beta package");
|
||||
const target = { version: "2.0.0-beta.3", channel: "beta", integrity };
|
||||
for (const distTags of [undefined, { beta: "2.0.0-beta.2" }]) {
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, {
|
||||
versionPresent: true,
|
||||
publishedIntegrity: integrity,
|
||||
...(distTags === undefined ? {} : { distTags }),
|
||||
}),
|
||||
/cannot reuse.*dist-tag beta (is missing|is behind)/,
|
||||
);
|
||||
}
|
||||
for (const beta of ["2.0.0-beta.3", "2.0.0-beta.4"]) {
|
||||
assert.deepEqual(
|
||||
evaluateNpmState(target, {
|
||||
versionPresent: true,
|
||||
publishedIntegrity: integrity,
|
||||
distTags: { beta },
|
||||
}),
|
||||
{ distTag: "beta", action: "reuse" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects same npm version with different or missing integrity", () => {
|
||||
const target = {
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
integrity: sha512Integrity("target package"),
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, {
|
||||
versionPresent: true,
|
||||
publishedIntegrity: sha512Integrity("different package"),
|
||||
distTags: { latest: "1.2.3" },
|
||||
}),
|
||||
/different integrity/,
|
||||
);
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, { versionPresent: true }),
|
||||
/published integrity is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
it("validates every observed publishedIntegrity property", () => {
|
||||
const target = {
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
integrity: sha512Integrity("target package"),
|
||||
};
|
||||
for (const publishedIntegrity of [
|
||||
undefined,
|
||||
null,
|
||||
42,
|
||||
"",
|
||||
"sha256-YQ==",
|
||||
"sha512-YQ==",
|
||||
]) {
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, {
|
||||
versionPresent: false,
|
||||
publishedIntegrity,
|
||||
distTags: { latest: "1.2.2" },
|
||||
}),
|
||||
/observed\.publishedIntegrity must contain one canonical SHA-512 digest/,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, {
|
||||
versionPresent: false,
|
||||
publishedIntegrity: sha512Integrity("unexpected package"),
|
||||
distTags: { latest: "1.2.2" },
|
||||
}),
|
||||
/version is absent but integrity is present/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not move latest or beta backwards or overwrite an absent equal version", () => {
|
||||
const integrity = sha512Integrity("target package");
|
||||
assert.throws(
|
||||
() => evaluateNpmState(
|
||||
{ version: "1.2.3", channel: "stable", integrity },
|
||||
{ distTags: { latest: "1.2.4" } },
|
||||
),
|
||||
/must not move backwards/,
|
||||
);
|
||||
assert.throws(
|
||||
() => evaluateNpmState(
|
||||
{ version: "1.2.3-beta.2", channel: "beta", integrity },
|
||||
{ distTags: { beta: "1.2.3-beta.3" } },
|
||||
),
|
||||
/must not move backwards/,
|
||||
);
|
||||
assert.throws(
|
||||
() => evaluateNpmState(
|
||||
{ version: "1.2.3", channel: "stable", integrity },
|
||||
{ distTags: { latest: "1.2.3" } },
|
||||
),
|
||||
/already points to target version.*registry reports that version absent/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed and cross-channel dist-tags", () => {
|
||||
const target = {
|
||||
version: "1.2.3",
|
||||
channel: "stable",
|
||||
integrity: sha512Integrity("target package"),
|
||||
};
|
||||
for (const distTags of [
|
||||
{ latest: "1.2.3-beta.1" },
|
||||
{ beta: "1.2.3" },
|
||||
{ latest: "v1.2.2" },
|
||||
{ beta: "1.2.3-rc.1" },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => evaluateNpmState(target, { distTags }),
|
||||
/dist-tag (latest|beta).*must contain a valid (stable|beta) version/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI", () => {
|
||||
it("creates and verifies an artifact manifest with JSON stdout", () => {
|
||||
const candidate = writeCandidate("3.0.0-beta.1", "beta");
|
||||
const script = path.join(__dirname, "release-candidate.js");
|
||||
const manifestPath = path.join(candidate.directory, "candidate-manifest.json");
|
||||
const createResult = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
script,
|
||||
"create",
|
||||
"--directory", candidate.directory,
|
||||
"--manifest", manifestPath,
|
||||
"--source-sha", SOURCE_SHA,
|
||||
"--version", candidate.metadata.version,
|
||||
"--channel", candidate.metadata.channel,
|
||||
"--npm-package", candidate.metadata.npmPackage,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(createResult.status, 0, createResult.stderr);
|
||||
assert.equal(createResult.stderr, "");
|
||||
const createOutput = JSON.parse(createResult.stdout);
|
||||
assert.equal(createOutput.ok, true);
|
||||
assert.equal(createOutput.manifest.channel, "beta");
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(manifestPath, "utf8")), createOutput.manifest);
|
||||
|
||||
const verifyResult = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
script,
|
||||
"verify",
|
||||
"--directory", candidate.directory,
|
||||
"--manifest", manifestPath,
|
||||
"--scope", "artifact",
|
||||
"--source-sha", SOURCE_SHA,
|
||||
"--version", candidate.metadata.version,
|
||||
"--channel", candidate.metadata.channel,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(verifyResult.status, 0, verifyResult.stderr);
|
||||
assert.equal(verifyResult.stderr, "");
|
||||
assert.deepEqual(JSON.parse(verifyResult.stdout), {
|
||||
ok: true,
|
||||
scope: "artifact",
|
||||
version: "3.0.0-beta.1",
|
||||
channel: "beta",
|
||||
sourceSha: SOURCE_SHA.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
it("writes deterministic CLI failures to stderr", () => {
|
||||
const script = path.join(__dirname, "release-candidate.js");
|
||||
const result = spawnSync(process.execPath, [script, "unknown"], { encoding: "utf8" });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.deepEqual(JSON.parse(result.stderr), {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "release_candidate",
|
||||
message: "command must be create or verify",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an external artifact manifest that could mask a tampered candidate manifest", () => {
|
||||
const candidate = writeCandidate();
|
||||
const script = path.join(__dirname, "release-candidate.js");
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const externalDirectory = tempDirectory();
|
||||
const externalManifestPath = path.join(externalDirectory, "candidate-manifest.json");
|
||||
fs.writeFileSync(externalManifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
const tamperedManifest = clone(manifest);
|
||||
tamperedManifest.sourceSha = "0".repeat(40);
|
||||
writeManifest(candidate.directory, tamperedManifest);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
script,
|
||||
"verify",
|
||||
"--directory", candidate.directory,
|
||||
"--manifest", externalManifestPath,
|
||||
"--scope", "artifact",
|
||||
"--source-sha", SOURCE_SHA,
|
||||
"--version", candidate.metadata.version,
|
||||
"--channel", candidate.metadata.channel,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.match(
|
||||
JSON.parse(result.stderr).error.message,
|
||||
/--manifest must be .*candidate-manifest\.json inside --directory for artifact scope/,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows release scope to verify assets with an external manifest", () => {
|
||||
const candidate = writeCandidate();
|
||||
const script = path.join(__dirname, "release-candidate.js");
|
||||
const manifest = createCandidateManifest(candidate.directory, candidate.metadata);
|
||||
const releaseDirectory = copyReleaseAssets(candidate.directory, manifest);
|
||||
const externalDirectory = tempDirectory();
|
||||
const externalManifestPath = path.join(externalDirectory, "candidate-manifest.json");
|
||||
fs.writeFileSync(externalManifestPath, `${JSON.stringify(manifest)}\n`);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
script,
|
||||
"verify",
|
||||
"--directory", releaseDirectory,
|
||||
"--manifest", externalManifestPath,
|
||||
"--scope", "release",
|
||||
"--source-sha", SOURCE_SHA,
|
||||
"--version", candidate.metadata.version,
|
||||
"--channel", candidate.metadata.channel,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, "");
|
||||
assert.equal(JSON.parse(result.stdout).scope, "release");
|
||||
});
|
||||
});
|
||||
245
scripts/release-workflow.test.sh
Normal file → Executable file
245
scripts/release-workflow.test.sh
Normal file → Executable file
@@ -2,146 +2,129 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
# Keep the release pipeline's trust boundaries visible in a fast, dependency-free
|
||||
# test. This deliberately inspects the workflow as text: GitHub Actions has no
|
||||
# stable local schema validator for all of the expression and inline-script
|
||||
# constructs used here.
|
||||
set -euo pipefail
|
||||
|
||||
workflow=".github/workflows/release.yml"
|
||||
goreleaser=".goreleaser.yml"
|
||||
# This verifies the release workflow's declarative contract. The shell commands
|
||||
# inside individual steps are exercised by the beta release rehearsal instead.
|
||||
ruby -ryaml <<'RUBY'
|
||||
workflow = YAML.load_file(".github/workflows/release.yml")
|
||||
goreleaser = YAML.load_file(".goreleaser.yml")
|
||||
|
||||
fail() {
|
||||
echo "release workflow contract: $*" >&2
|
||||
exit 1
|
||||
def fail(message)
|
||||
abort("release workflow contract: #{message}")
|
||||
end
|
||||
|
||||
def expect_equal(actual, expected, description)
|
||||
return if actual == expected
|
||||
fail("#{description}; expected #{expected.inspect}, got #{actual.inspect}")
|
||||
end
|
||||
|
||||
def scalar_values(value)
|
||||
case value
|
||||
when Hash then value.values.flat_map { |item| scalar_values(item) }
|
||||
when Array then value.flat_map { |item| scalar_values(item) }
|
||||
else [value]
|
||||
end
|
||||
end
|
||||
|
||||
def action_references(value)
|
||||
case value
|
||||
when Hash
|
||||
value.flat_map { |key, item| key == "uses" ? [item] : action_references(item) }
|
||||
when Array
|
||||
value.flat_map { |item| action_references(item) }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
jobs = workflow.fetch("jobs")
|
||||
expected_jobs = %w[preflight build-sign-notarize create-draft-release verify-macos publish-github publish-npm]
|
||||
expect_equal(jobs.keys.sort, expected_jobs.sort, "release jobs")
|
||||
|
||||
expect_equal(workflow.fetch("concurrency"), {
|
||||
"group" => "release-${{ github.ref_name }}",
|
||||
"cancel-in-progress" => false,
|
||||
}, "release concurrency")
|
||||
|
||||
expected_needs = {
|
||||
"preflight" => nil,
|
||||
"build-sign-notarize" => "preflight",
|
||||
"create-draft-release" => %w[preflight build-sign-notarize],
|
||||
"verify-macos" => %w[preflight create-draft-release],
|
||||
"publish-github" => %w[preflight create-draft-release verify-macos],
|
||||
"publish-npm" => %w[preflight build-sign-notarize publish-github],
|
||||
}
|
||||
expected_needs.each do |job_name, needs|
|
||||
expect_equal(jobs.fetch(job_name)["needs"], needs, "#{job_name} dependencies")
|
||||
end
|
||||
|
||||
require() {
|
||||
local needle="$1"
|
||||
local haystack="$2"
|
||||
local message="$3"
|
||||
grep -Fq -- "$needle" <<<"$haystack" || fail "$message (missing: $needle)"
|
||||
expected_permissions = {
|
||||
"preflight" => { "contents" => "read" },
|
||||
"build-sign-notarize" => { "contents" => "read" },
|
||||
"create-draft-release" => { "contents" => "write" },
|
||||
"verify-macos" => { "contents" => "write" },
|
||||
"publish-github" => { "contents" => "write" },
|
||||
"publish-npm" => { "contents" => "read", "id-token" => "write" },
|
||||
}
|
||||
expected_permissions.each do |job_name, permissions|
|
||||
expect_equal(jobs.fetch(job_name)["permissions"], permissions, "#{job_name} permissions")
|
||||
end
|
||||
|
||||
job_section() {
|
||||
local job="$1"
|
||||
awk -v job="$job" '
|
||||
$0 == " " job ":" { in_job = 1; print; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:$/ { exit }
|
||||
in_job { print }
|
||||
' "$workflow"
|
||||
}
|
||||
signing_references = %w[
|
||||
secrets.MACOS_SIGN_P12
|
||||
secrets.MACOS_SIGN_PASSWORD
|
||||
secrets.MACOS_NOTARY_KEY
|
||||
vars.MACOS_NOTARY_KEY_ID
|
||||
vars.MACOS_NOTARY_ISSUER_ID
|
||||
]
|
||||
team_reference = "vars.MACOS_TEAM_ID"
|
||||
jobs.each do |job_name, job|
|
||||
references = scalar_values(job).grep(String).flat_map do |value|
|
||||
(signing_references + [team_reference]).select { |reference| value.include?(reference) }
|
||||
end.uniq.sort
|
||||
expected_references = case job_name
|
||||
when "build-sign-notarize" then signing_references + [team_reference]
|
||||
when "verify-macos" then [team_reference]
|
||||
else []
|
||||
end
|
||||
expect_equal(
|
||||
references,
|
||||
expected_references.sort,
|
||||
"#{job_name} Apple credential scope",
|
||||
)
|
||||
end
|
||||
|
||||
[[ -f "$workflow" ]] || fail "missing $workflow"
|
||||
[[ -f "$goreleaser" ]] || fail "missing $goreleaser"
|
||||
macos = jobs.fetch("verify-macos")
|
||||
expect_equal(macos.fetch("strategy").fetch("matrix").fetch("include"), [
|
||||
{ "runner" => "macos-15-intel", "arch" => "amd64" },
|
||||
{ "runner" => "macos-15", "arch" => "arm64" },
|
||||
], "macOS verification matrix")
|
||||
expect_equal(macos.fetch("runs-on"), "${{ matrix.runner }}", "macOS matrix runner")
|
||||
|
||||
mapfile -t jobs < <(awk '
|
||||
/^jobs:$/ { in_jobs = 1; next }
|
||||
in_jobs && /^ [A-Za-z0-9_-]+:$/ {
|
||||
name = $0
|
||||
sub(/^ /, "", name)
|
||||
sub(/:$/, "", name)
|
||||
print name
|
||||
}
|
||||
' "$workflow")
|
||||
expected_jobs=(preflight build-sign-notarize create-draft-release publish-github publish-npm verify-macos)
|
||||
if [[ "${jobs[*]}" != "${expected_jobs[*]}" ]]; then
|
||||
fail "expected exactly the six release jobs: ${expected_jobs[*]}; got: ${jobs[*]:-(none)}"
|
||||
fi
|
||||
npm_steps = jobs.fetch("publish-npm").fetch("steps")
|
||||
pinned_npm = npm_steps.find { |step| step["name"] == "Install pinned npm" }
|
||||
fail("publish-npm must install npm 11.16.0 for trusted publishing") unless pinned_npm&.fetch("run", nil) == "npm install --global npm@11.16.0"
|
||||
|
||||
preflight_section="$(job_section preflight)"
|
||||
build_section="$(job_section build-sign-notarize)"
|
||||
draft_section="$(job_section create-draft-release)"
|
||||
github_section="$(job_section publish-github)"
|
||||
npm_section="$(job_section publish-npm)"
|
||||
macos_section="$(job_section verify-macos)"
|
||||
action_references(workflow).each do |reference|
|
||||
fail("action is not pinned to a full commit SHA: #{reference}") unless reference.match?(%r{\A[^@]+@[0-9a-f]{40}\z})
|
||||
end
|
||||
|
||||
[[ -n "$preflight_section" && -n "$build_section" && -n "$draft_section" && -n "$github_section" && -n "$npm_section" && -n "$macos_section" ]] || fail "every release job must have a nonempty section"
|
||||
if grep -Eq '^[[:space:]]*needs:' <<<"$preflight_section"; then
|
||||
fail "preflight must start the release dependency graph"
|
||||
fi
|
||||
for requirement in 'needs: preflight'; do require "$requirement" "$build_section" "build-sign-notarize must follow preflight"; done
|
||||
for requirement in ' - preflight' ' - build-sign-notarize'; do require "$requirement" "$draft_section" "create-draft-release must wait for the signed candidate"; done
|
||||
for requirement in ' - preflight' ' - create-draft-release'; do require "$requirement" "$macos_section" "verify-macos must verify the draft Release"; done
|
||||
for requirement in ' - preflight' ' - build-sign-notarize' ' - create-draft-release' ' - verify-macos'; do require "$requirement" "$github_section" "publish-github must wait for every verification gate"; done
|
||||
for requirement in ' - preflight' ' - build-sign-notarize' ' - publish-github'; do require "$requirement" "$npm_section" "publish-npm must happen after GitHub publication"; done
|
||||
notarize = goreleaser.fetch("notarize").fetch("macos")
|
||||
expect_equal(notarize.length, 1, "number of macOS notarization configurations")
|
||||
macos_notarize = notarize.first
|
||||
expect_equal(macos_notarize.fetch("ids"), ["lark-cli"], "notarized build IDs")
|
||||
expect_equal(macos_notarize.fetch("sign"), {
|
||||
"certificate" => "{{ .Env.MACOS_SIGN_P12 }}",
|
||||
"password" => "{{ .Env.MACOS_SIGN_PASSWORD }}",
|
||||
}, "macOS signing inputs")
|
||||
expect_equal(macos_notarize.fetch("notarize"), {
|
||||
"issuer_id" => "{{ .Env.MACOS_NOTARY_ISSUER_ID }}",
|
||||
"key_id" => "{{ .Env.MACOS_NOTARY_KEY_ID }}",
|
||||
"key" => "{{ .Env.MACOS_NOTARY_KEY_PATH }}",
|
||||
"wait" => true,
|
||||
"timeout" => "20m",
|
||||
}, "macOS notarization inputs")
|
||||
|
||||
concurrency_section="$(awk '
|
||||
/^concurrency:$/ { in_concurrency = 1; print; next }
|
||||
in_concurrency && /^[^[:space:]]/ { exit }
|
||||
in_concurrency { print }
|
||||
' "$workflow")"
|
||||
require 'group: release-${{ github.ref_name }}' "$concurrency_section" "release concurrency must be per tag"
|
||||
require 'cancel-in-progress: false' "$concurrency_section" "release tags must not cancel an active publication"
|
||||
if grep -Eiq 'channel|lark-cli-release|release-[[:space:]]*$' <<<"$concurrency_section"; then
|
||||
fail "release concurrency must not use a channel-wide lock"
|
||||
fi
|
||||
|
||||
# Every third-party action must be immutable: action tags can be retargeted.
|
||||
awk '
|
||||
/^[[:space:]]*[-]?[[:space:]]*uses:[[:space:]]*/ {
|
||||
action = $0
|
||||
sub(/^.*uses:[[:space:]]*/, "", action)
|
||||
sub(/[[:space:]]*(#.*)?$/, "", action)
|
||||
split(action, parts, "@")
|
||||
if (length(parts) != 2 || length(parts[2]) != 40 || parts[2] !~ /^[0-9a-f]{40}$/) {
|
||||
printf "un-pinned action: %s\\n", action > "/dev/stderr"
|
||||
bad = 1
|
||||
}
|
||||
count++
|
||||
}
|
||||
END { if (count == 0 || bad) exit 1 }
|
||||
' "$workflow" || fail "all release actions must be pinned to 40-hex commit SHAs"
|
||||
|
||||
require 'version: 2' "$(head -n 1 "$goreleaser")" "GoReleaser config must use v2 schema"
|
||||
require 'version: v2.17.1' "$build_section" "build must use the approved GoReleaser version"
|
||||
require 'args: release --clean --skip=publish' "$build_section" "GoReleaser must build only; publication is separately gated"
|
||||
for requirement in 'notarize:' 'enabled:' 'MACOS_SIGN_P12' 'MACOS_NOTARY_KEY_PATH'; do require "$requirement" "$(<"$goreleaser")" "GoReleaser must retain macOS signing/notarization configuration"; done
|
||||
for requirement in ' - darwin' ' - linux' ' - windows' ' - amd64' ' - arm64' ' - riscv64' 'formats: [tar.gz]' 'formats: [zip]'; do require "$requirement" "$(<"$goreleaser")" "GoReleaser must produce the supported release archive matrix"; done
|
||||
|
||||
require 'contents: read' "$build_section" "the signing build job must remain read-only"
|
||||
if grep -Eq '^[[:space:]]*(contents|actions|packages|id-token):[[:space:]]*write' <<<"$build_section"; then
|
||||
fail "the signing build job must not receive write permissions"
|
||||
fi
|
||||
for secret in MACOS_NOTARY_KEY MACOS_SIGN_P12 MACOS_SIGN_PASSWORD; do
|
||||
require "secrets.${secret}" "$build_section" "build-sign-notarize must receive ${secret}"
|
||||
for job in preflight create-draft-release publish-github publish-npm verify-macos; do
|
||||
if grep -Fq "secrets.${secret}" <<<"$(job_section "$job")"; then
|
||||
fail "${secret} must be available only to build-sign-notarize"
|
||||
fi
|
||||
done
|
||||
done
|
||||
for requirement in 'umask 077' 'mktemp "${RUNNER_TEMP}/macos-notary-key.XXXXXX"' 'chmod 0600 "$notary_key"' 'trap cleanup EXIT'; do require "$requirement" "$build_section" "Apple key preparation must securely handle the temporary key"; done
|
||||
require 'name: Clean up Apple notarization key' "$build_section" "Apple key cleanup must always run"
|
||||
require 'if: ${{ always() }}' "$build_section" "Apple key cleanup must run after failures"
|
||||
require 'rm -f -- "$MACOS_NOTARY_KEY_PATH"' "$build_section" "Apple key cleanup must remove the temporary key"
|
||||
|
||||
require 'uses: actions/upload-artifact@' "$build_section" "the signed release candidate must be uploaded as an artifact"
|
||||
require 'if-no-files-found: error' "$build_section" "candidate upload must fail closed"
|
||||
for section_name in draft_section github_section npm_section; do
|
||||
section="${!section_name}"
|
||||
require 'uses: actions/download-artifact@' "$section" "each release gate must download the exact candidate artifact"
|
||||
require 'digest-mismatch: error' "$section" "candidate artifact digests must fail closed"
|
||||
done
|
||||
require 'draft: true' "$draft_section" "the candidate must first be created as a draft GitHub Release"
|
||||
require 'matrix:' "$macos_section" "macOS verification must cover both supported architectures"
|
||||
for requirement in 'runner: macos-15-intel' 'arch: amd64' 'runner: macos-15' 'arch: arm64' 'codesign --verify --strict --verbose=4' 'spctl --assess --type execute --verbose=4' 'source=Notarized Developer ID'; do require "$requirement" "$macos_section" "macOS verification must retain signing and Gatekeeper checks"; done
|
||||
|
||||
require 'Install the candidate through the public Release' "$github_section" "GitHub publication must be followed by a public candidate install"
|
||||
require 'npm install --global --prefix' "$github_section" "candidate install must exercise the packed npm artifact"
|
||||
require 'id-token: write' "$npm_section" "npm trusted publishing requires GitHub OIDC"
|
||||
require 'path.resolve("release-candidate", manifest.npmPackage.name)' "$npm_section" "npm publish must use the original verified candidate tarball"
|
||||
require 'npm", ["publish", tgz, "--access", "public", "--provenance", "--tag", before.distTag]' "$npm_section" "npm publish must use provenance and the evaluated dist-tag"
|
||||
for requirement in '"dist-tags"' 'evaluateNpmState' 'afterState.distTags?.[before.distTag] !== env.VERSION'; do require "$requirement" "$npm_section" "npm publication must validate the target dist-tag"; done
|
||||
|
||||
checkout_jobs=0
|
||||
while IFS= read -r job; do
|
||||
section="$(job_section "$job")"
|
||||
if grep -Fq 'actions/checkout@' <<<"$section"; then
|
||||
checkout_jobs=$((checkout_jobs + 1))
|
||||
require 'persist-credentials: false' "$section" "checkout in ${job} must not persist credentials"
|
||||
fi
|
||||
done < <(printf '%s\n' "${jobs[@]}")
|
||||
(( checkout_jobs > 0 )) || fail "release workflow must explicitly check out source where needed"
|
||||
|
||||
echo "release workflow contract passed"
|
||||
puts "release workflow contract passed"
|
||||
RUBY
|
||||
|
||||
Reference in New Issue
Block a user