docs: document release audit scripts

This commit is contained in:
Peter Steinberger
2026-06-04 23:49:34 -04:00
parent 26bc069308
commit 72547a1ac6
8 changed files with 104 additions and 10 deletions

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node
// Creates the debug proxy CA and, on macOS, trusts it in the system keychain.
import { spawnSync } from "node:child_process";
import process from "node:process";
import { resolveSystemBin } from "../src/infra/resolve-system-bin.js";

View File

@@ -1,3 +1,5 @@
// Prunes omitted bundled plugin files and their unshared runtime dependencies
// from Docker-oriented production package output.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -18,6 +20,9 @@ function parsePluginList(value) {
);
}
/**
* Parses OPENCLAW_EXTENSIONS into the bundled plugin ids that Docker should keep.
*/
export function parseDockerPluginKeepList(value) {
return parsePluginList(value);
}
@@ -67,7 +72,9 @@ function collectPackageRuntimeClosure(repoRoot, seedPackageNames) {
}
seen.add(packageName);
const packageJson = readPackageJson(path.join(nodeModulePath(repoRoot, packageName), "package.json"));
const packageJson = readPackageJson(
path.join(nodeModulePath(repoRoot, packageName), "package.json"),
);
for (const dependencyName of collectRuntimeDependencyNames(packageJson)) {
if (!seen.has(dependencyName)) {
stack.push(dependencyName);
@@ -106,7 +113,9 @@ function pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPl
const omittedSeeds = new Set();
for (const pluginId of omittedPluginIds) {
const packageJson = readPackageJson(path.join(repoRoot, bundledPluginDir, pluginId, "package.json"));
const packageJson = readPackageJson(
path.join(repoRoot, bundledPluginDir, pluginId, "package.json"),
);
if (typeof packageJson?.name === "string") {
omittedPackageNames.add(packageJson.name);
}
@@ -116,7 +125,11 @@ function pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPl
}
const keptSeeds = new Set(collectRuntimeDependencyNames(rootPackageJson));
for (const dependencyName of collectWorkspacePackageRuntimeSeeds(repoRoot, "packages", new Set())) {
for (const dependencyName of collectWorkspacePackageRuntimeSeeds(
repoRoot,
"packages",
new Set(),
)) {
keptSeeds.add(dependencyName);
}
for (const dependencyName of collectWorkspacePackageRuntimeSeeds(
@@ -150,18 +163,25 @@ function pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPl
return removed;
}
/**
* Removes omitted plugin dist trees plus node_modules packages not needed by kept runtime code.
*/
export function pruneDockerPluginDist(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const env = params.env ?? process.env;
const bundledPluginDir = env.OPENCLAW_BUNDLED_PLUGIN_DIR ?? "extensions";
const keepPluginIds = parseDockerPluginKeepList(env.OPENCLAW_EXTENSIONS);
const excludedPluginIds = collectRootPackageExcludedExtensionDirs({ cwd: repoRoot });
const omittedPluginIds = new Set([...excludedPluginIds].filter((pluginId) => !keepPluginIds.has(pluginId)));
const omittedPluginIds = new Set(
[...excludedPluginIds].filter((pluginId) => !keepPluginIds.has(pluginId)),
);
const removed = [];
removed.push(...pruneNodeModulesForOmittedPlugins(repoRoot, bundledPluginDir, omittedPluginIds));
for (const pluginId of [...omittedPluginIds].toSorted((left, right) => left.localeCompare(right))) {
for (const pluginId of [...omittedPluginIds].toSorted((left, right) =>
left.localeCompare(right),
)) {
for (const pluginPath of [
path.join(bundledPluginDir, pluginId),
path.join("dist", "extensions", pluginId),

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env node
// Coordinates release-candidate validation runs and emits the publish command
// only after required local, CI, npm, plugin, and E2E evidence is green.
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
@@ -50,6 +52,9 @@ function requireValue(argv, index, flag) {
return value;
}
/**
* Parses release-candidate validation options and enforces publish-scope policy.
*/
export function parseArgs(argv) {
const args = stripLeadingPackageManagerSeparator(argv);
const options = {
@@ -201,6 +206,9 @@ function githubApiTimedOut(error) {
);
}
/**
* Calls the GitHub REST API with the gh-auth token and a bounded timeout.
*/
export async function githubApi(path, options = {}) {
const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim();
const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs();
@@ -254,6 +262,9 @@ async function runArtifacts(repo, runId) {
}));
}
/**
* Chooses the expected artifact name, allowing one same-prefix fallback per run.
*/
export function resolveArtifactName(artifacts, preferredName, prefix) {
const available = artifacts
.filter((artifact) => artifact.expired !== true)
@@ -312,6 +323,9 @@ function runLocalGeneratedCheckIfNeeded(options) {
return { status: "passed", command: "pnpm release:generated:check" };
}
/**
* Extracts a GitHub Actions run id from gh workflow dispatch output.
*/
export function parseRunIdFromDispatchOutput(output) {
return output.match(/actions\/runs\/([0-9]+)/u)?.[1] ?? "";
}
@@ -498,6 +512,9 @@ function shellQuote(value) {
return `'${String(value).replace(/'/gu, "'\\''")}'`;
}
/**
* Builds the final release publish workflow command once validation evidence is ready.
*/
export function buildPublishCommand(options) {
const fields = [
["tag", options.tag],

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node
// Checks or refreshes generated release artifacts before a release publish.
import { runManagedCommand } from "./lib/managed-child-process.mjs";
const args = new Set(process.argv.slice(2));

View File

@@ -1,3 +1,5 @@
// Resolves Docker upgrade-survivor baseline specs from requested tokens and
// live release history JSON captured by release workflows.
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { normalizeUpgradeSurvivorBaselineSpec } from "./lib/docker-e2e-plan.mjs";
@@ -101,6 +103,9 @@ function readStableReleases(file, publishedVersions) {
.toSorted((a, b) => String(b.publishedAt).localeCompare(String(a.publishedAt)));
}
/**
* Expands the release-history token into recent stable plus pinned historical baselines.
*/
export function resolveReleaseHistory(args) {
const releasesJson = args.get("releases-json");
if (!releasesJson) {
@@ -128,6 +133,9 @@ export function resolveReleaseHistory(args) {
return dedupeSpecs(versions);
}
/**
* Resolves the last N stable release versions from release metadata.
*/
export function resolveLastStable(args, count) {
const releasesJson = args.get("releases-json");
if (!releasesJson) {
@@ -141,6 +149,9 @@ export function resolveLastStable(args, count) {
return dedupeSpecs(releases.slice(0, count).map((release) => release.version));
}
/**
* Resolves all stable release versions at or after the requested minimum.
*/
export function resolveAllSince(args, minimumVersion) {
const releasesJson = args.get("releases-json");
if (!releasesJson) {
@@ -155,6 +166,9 @@ export function resolveAllSince(args, minimumVersion) {
);
}
/**
* Expands requested baseline tokens into normalized package/version specs.
*/
export function resolveBaselines(args) {
const requested = args.get("requested") ?? "";
const fallback = args.get("fallback") ?? "openclaw@latest";

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env node
// Audits root package runtime dependencies against source imports and bundled
// plugin ownership so extension-owned deps can move out of root.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -79,6 +81,9 @@ function sectionFor(relativePath) {
return section;
}
/**
* Collects static and simple constant-backed package specifiers from source text.
*/
export function collectModuleSpecifiers(source) {
const specifiers = new Set();
for (const pattern of IMPORT_PATTERNS) {
@@ -210,6 +215,9 @@ function sectionSetIsSubsetOf(sectionSet, allowed) {
return sectionSet.size > 0;
}
/**
* Classifies whether a root dependency is core-owned, shared, or extension-local.
*/
export function classifyRootDependencyOwnership(record) {
const sections = new Set(record.sections);
@@ -276,6 +284,9 @@ export function classifyRootDependencyOwnership(record) {
};
}
/**
* Builds dependency ownership records from root package.json and scanned imports.
*/
export function collectRootDependencyOwnershipAudit(params = {}) {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const rootPackageJson = readJson(path.join(repoRoot, "package.json"));
@@ -352,6 +363,9 @@ export function collectRootDependencyOwnershipAudit(params = {}) {
.toSorted((left, right) => left.depName.localeCompare(right.depName));
}
/**
* Returns actionable errors for dependencies that should not remain root-owned.
*/
export function collectRootDependencyOwnershipCheckErrors(records) {
return records
.filter((record) => record.category === "extension_only_localizable")

View File

@@ -1,4 +1,6 @@
#!/usr/bin/env node
// Runs additional architecture and boundary checks with sharding, concurrency,
// timeout handling, and grouped CI output.
import { spawn } from "node:child_process";
import { performance } from "node:perf_hooks";
@@ -6,6 +8,7 @@ const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024;
const TIMEOUT_KILL_GRACE_MS = 5_000;
/** Ordered list of supplemental boundary checks used by CI sharding. */
export const BOUNDARY_CHECKS = [
["prompt:snapshots:check", "pnpm", ["prompt:snapshots:check"]],
["plugin-extension-boundary", "pnpm", ["run", "lint:plugins:no-extension-imports"]],
@@ -66,10 +69,16 @@ export const BOUNDARY_CHECKS = [
["lint:ui:no-raw-window-open", "pnpm", ["lint:ui:no-raw-window-open"]],
].map(([label, command, args]) => ({ label, command, args }));
/**
* Resolves the configured boundary-check concurrency.
*/
export function resolveConcurrency(value, fallback = 4, label = "concurrency") {
return resolvePositiveInteger(value, fallback, label);
}
/**
* Parses positive integer CLI/env options with a fallback.
*/
export function resolvePositiveInteger(value, fallback, label = "value") {
if (value === undefined || value === null || value === "") {
return fallback;
@@ -85,6 +94,9 @@ export function resolvePositiveInteger(value, fallback, label = "value") {
return parsed;
}
/**
* Parses one N/TOTAL shard selector into zero-based index form.
*/
export function parseShardSpec(value) {
if (!value) {
return null;
@@ -107,6 +119,9 @@ export function parseShardSpec(value) {
return { count, index: index - 1, label: `${index}/${count}` };
}
/**
* Parses a comma-separated list of N/TOTAL shard selectors.
*/
export function parseShardSelection(value) {
if (!value) {
return null;
@@ -124,6 +139,9 @@ export function parseShardSelection(value) {
});
}
/**
* Selects checks whose ordinal belongs to the requested shard set.
*/
export function selectChecksForShard(checks, shardSpec) {
const shards =
typeof shardSpec === "string"
@@ -141,10 +159,16 @@ export function selectChecksForShard(checks, shardSpec) {
);
}
/**
* Formats a check command for CI group output.
*/
export function formatCommand({ command, args }) {
return [command, ...args].join(" ");
}
/**
* Keeps only the tail of noisy check output so failure logs stay bounded.
*/
export function createBoundedOutputBuffer(maxBytes = DEFAULT_OUTPUT_MAX_BYTES) {
const limit = Math.max(1, maxBytes);
const chunks = [];
@@ -247,6 +271,9 @@ function installActiveChildCleanup(activeChildren) {
};
}
/**
* Runs one boundary check with timeout and process-group termination.
*/
export function runSingleCheck(
check,
{
@@ -359,6 +386,9 @@ function writeTimingSummary(results, output) {
}
}
/**
* Runs boundary checks with bounded concurrency and returns the failure count.
*/
export async function runChecks(
checks = BOUNDARY_CHECKS,
{
@@ -442,11 +472,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
process.env.OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY === undefined
? "OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY"
: "OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY";
const concurrency = resolveConcurrency(
concurrencyRaw,
4,
concurrencyLabel,
);
const concurrency = resolveConcurrency(concurrencyRaw, 4, concurrencyLabel);
const checkTimeoutMs = resolvePositiveInteger(
process.env.OPENCLAW_ADDITIONAL_BOUNDARY_TIMEOUT_MS,
DEFAULT_CHECK_TIMEOUT_MS,

View File

@@ -1,3 +1,4 @@
// Runs oxlint over bundled plugin source files using the shared extension lint runner.
import { runExtensionOxlint } from "./lib/run-extension-oxlint.mjs";
runExtensionOxlint({