diff --git a/.agents/skills/release-openclaw-maintainer/references/extended-stable-publish.md b/.agents/skills/release-openclaw-maintainer/references/extended-stable-publish.md index 893c1d5d295c..f2d0070b37c5 100644 --- a/.agents/skills/release-openclaw-maintainer/references/extended-stable-publish.md +++ b/.agents/skills/release-openclaw-maintainer/references/extended-stable-publish.md @@ -69,9 +69,31 @@ on pinned current `main` for command and validation requirements. Docker, and finalization. Docker-only recovery may dispatch from `main` with `publish_openclaw_npm=false` and `publish_docker_only=true`; that path does not attach evidence or finalize the release. -8. From a clean current-`main` checkout, run - `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.P`. - Verify package signatures, source commits, inventories, exact versions, and selectors. +8. Keep separate clean checkouts for trusted current-main tooling and the exact + frozen release source. Install the trusted tooling checkout with + `pnpm install --frozen-lockfile`, then run its verifier with the frozen + release checkout as the working directory. Use a disposable release + worktree with no existing `node_modules`, then expose only the trusted + tooling install through the same link used by the publication workflow: + + ```bash + VERSION=YYYY.M.P + TOOLING_ROOT=/absolute/path/to/clean-current-main + RELEASE_ROOT=/absolute/path/to/frozen-release-source + test "$(node -e 'console.log(require(process.argv[1]).version)' "$RELEASE_ROOT/package.json")" = "$VERSION" + test ! -e "$RELEASE_ROOT/node_modules" + ln -s "$TOOLING_ROOT/node_modules" "$RELEASE_ROOT/node_modules" + ( + cd "$RELEASE_ROOT" + node --import tsx "$TOOLING_ROOT/scripts/openclaw-npm-postpublish-verify.ts" "$VERSION" + ) + ``` + + The script, external dependencies, and TypeScript loader come from trusted + current main. The frozen `cwd` supplies its immutable source manifests and + workspace path aliases, matching the release workflow's `.release-harness` + layout. A different source version must fail closed. Verify package + signatures, source commits, inventories, exact versions, and selectors. To promote an already-published core version to `extended-stable`, use `promote_extended_stable` in the `openclaw/releases` dist-tag workflow from that repository's `main`. Follow @@ -82,6 +104,7 @@ on pinned current `main` for command and validation requirements. patch range. The same action can select an older extended-stable version for rollback. Repair other selectors separately with approved credential-isolated tooling. Never republish a version. + 9. Require `Docker Release` to verify default, slim, browser, and architecture images in GHCR and Docker Hub, including attestations and platform versions. It must advance only diff --git a/scripts/openclaw-npm-postpublish-verify.ts b/scripts/openclaw-npm-postpublish-verify.ts index 8497b0009f74..8bbf8b34a826 100644 --- a/scripts/openclaw-npm-postpublish-verify.ts +++ b/scripts/openclaw-npm-postpublish-verify.ts @@ -196,6 +196,28 @@ export function buildPublishedInstallScenarios(version: string): PublishedInstal return scenarios; } +export function resolvePublishedInstallSourceVerification( + sourceRoot: string, + expectedVersion: string, +): Pick< + Parameters[0], + "additionalCompanionManifestRoots" | "allowLegacyGeneratedOwnership" +> { + const packageJsonPath = join(sourceRoot, "package.json"); + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as InstalledPackageJson; + if (packageJson.name !== "openclaw" || packageJson.version !== expectedVersion) { + throw new Error( + `source checkout version mismatch: expected openclaw@${expectedVersion}, found ${packageJson.name ?? ""}@${packageJson.version ?? ""}.`, + ); + } + return { + additionalCompanionManifestRoots: [join(sourceRoot, "extensions")], + allowLegacyGeneratedOwnership: !existsSync( + join(sourceRoot, "scripts/lib/runtime-dependency-ownership-build-plugin.mts"), + ), + }; +} + type NpmRegistryKey = { key: string; keyid: string; @@ -1267,7 +1289,11 @@ async function verifyPublishedRegistryProvenanceOnce(version: string): Promise, +): void { const workingDir = mkdtempSync(join(tmpdir(), `openclaw-postpublish-${scenario.name}.`)); const prefixDir = join(workingDir, "prefix"); @@ -1282,6 +1308,7 @@ function verifyScenario(version: string, scenario: PublishedInstallScenario): vo readFileSync(join(packageRoot, "package.json"), "utf8"), ) as InstalledPackageJson; const errors = collectInstalledPackageErrors({ + ...sourceVerification, expectedVersion: scenario.expectedVersion, installedVersion: pkg.version?.trim() ?? "", packageRoot, @@ -1320,9 +1347,10 @@ async function main(argv = process.argv.slice(2)): Promise { const { version } = args; const scenarios = buildPublishedInstallScenarios(version); + const sourceVerification = resolvePublishedInstallSourceVerification(process.cwd(), version); await retryNpmRegistryProvenanceRead(() => verifyPublishedRegistryProvenanceOnce(version)); for (const scenario of scenarios) { - verifyScenario(version, scenario); + verifyScenario(version, scenario, sourceVerification); } console.log( diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 0ed783ce2a74..08b3ab83ace7 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -39,6 +39,7 @@ import { resolveNpmRunner, type NpmRunnerParams } from "./npm-runner.mts"; import { collectInstalledPackageErrors, normalizeInstalledBinaryVersion, + resolvePublishedInstallSourceVerification, } from "./openclaw-npm-postpublish-verify.ts"; import { assertPreparedOpenClawAiDependency } from "./openclaw-npm-prepublish-verify.ts"; import { parseNpmPackJsonOutput, type NpmPackResult } from "./openclaw-npm-release-check.ts"; @@ -671,12 +672,6 @@ export function collectPackedInstalledPackageVerificationErrors(params: { return errors; } -export function allowsLegacyGeneratedOwnershipForSourceRoot(sourceRoot: string): boolean { - return !existsSync( - resolve(sourceRoot, "scripts/lib/runtime-dependency-ownership-build-plugin.mts"), - ); -} - function verifyPackedInstalledPackage(params: { expectedVersion: string; packageRoot: string; @@ -690,10 +685,7 @@ function verifyPackedInstalledPackage(params: { stdio: ["ignore", "pipe", "pipe"], }).trim(); const errors = collectPackedInstalledPackageVerificationErrors({ - // The selected source checkout is immutable release input. Its companion - // manifests are the exact inputs packed by the following plugin preflight. - additionalCompanionManifestRoots: [resolve("extensions")], - allowLegacyGeneratedOwnership: allowsLegacyGeneratedOwnershipForSourceRoot(resolve()), + ...resolvePublishedInstallSourceVerification(resolve(), params.expectedVersion), expectedVersion: params.expectedVersion, installedBinaryVersion, packageRoot: params.packageRoot, diff --git a/test/openclaw-npm-postpublish-verify.test.ts b/test/openclaw-npm-postpublish-verify.test.ts index ab853886a48f..bc62daae59d2 100644 --- a/test/openclaw-npm-postpublish-verify.test.ts +++ b/test/openclaw-npm-postpublish-verify.test.ts @@ -22,6 +22,7 @@ import { collectInstalledPackageErrors, fetchRegistryJson, parseOpenClawNpmPostpublishVerifyArgs, + resolvePublishedInstallSourceVerification, resolveInstalledBinaryCommandInvocation, retryNpmRegistryProvenanceRead, verifyNpmProvenanceAttestation, @@ -97,6 +98,44 @@ describe("buildPublishedInstallScenarios", () => { }); }); +describe("resolvePublishedInstallSourceVerification", () => { + it("uses exact legacy source manifests for published chunk ownership", () => { + const sourceRoot = createTempDir("openclaw-postpublish-source-"); + writePackageFile(sourceRoot, "package.json", { + name: "openclaw", + version: "2026.8.34", + }); + writePackageFile(sourceRoot, "extensions/discord/package.json", { + name: "@openclaw/discord", + version: "2026.8.34", + dependencies: { "@discordjs/voice": "0.19.2" }, + }); + + expect(resolvePublishedInstallSourceVerification(sourceRoot, "2026.8.34")).toEqual({ + additionalCompanionManifestRoots: [join(sourceRoot, "extensions")], + allowLegacyGeneratedOwnership: true, + }); + + writeInstalledFile(sourceRoot, "scripts/lib/runtime-dependency-ownership-build-plugin.mts"); + expect(resolvePublishedInstallSourceVerification(sourceRoot, "2026.8.34")).toEqual({ + additionalCompanionManifestRoots: [join(sourceRoot, "extensions")], + allowLegacyGeneratedOwnership: false, + }); + }); + + it("rejects source manifests from another release", () => { + const sourceRoot = createTempDir("openclaw-postpublish-source-"); + writePackageFile(sourceRoot, "package.json", { + name: "openclaw", + version: "2026.9.7", + }); + + expect(() => resolvePublishedInstallSourceVerification(sourceRoot, "2026.8.34")).toThrow( + "source checkout version mismatch", + ); + }); +}); + describe("npm registry provenance verification", () => { const packageName = "openclaw"; const version = "2026.3.23"; diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 75cdcfe68fae..2e7ddd18bbf8 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -3,7 +3,7 @@ import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join, resolve as resolvePath, win32 } from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { collectBundledExtensionManifestErrors } from "../scripts/lib/bundled-extension-manifest.ts"; import { listBundledPluginPackArtifacts } from "../scripts/lib/bundled-plugin-build-entries.mjs"; import { resolveNpmJsonEntries } from "../scripts/lib/npm-json-output.mts"; @@ -12,7 +12,6 @@ import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../scripts/lib/package-dis import { createWorkspaceBootstrapSmokeEnv } from "../scripts/lib/workspace-bootstrap-smoke.mts"; import { collectInstalledBundledRuntimeSidecarPaths } from "../scripts/openclaw-npm-postpublish-verify.ts"; import { - allowsLegacyGeneratedOwnershipForSourceRoot, collectAppcastSparkleVersionErrors, collectCriticalPluginSdkEntrypointSizeFindings, collectForbiddenPackContentPaths, @@ -33,9 +32,6 @@ import { import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts"; import { resolveNpmJsonEntries as resolveRuntimeNpmJsonEntries } from "../src/infra/npm-registry-spec.js"; import { withEnv } from "../src/test-utils/env.js"; -import { useAutoCleanupTempDirTracker } from "./helpers/temp-dir.js"; - -const tempDirs = useAutoCleanupTempDirTracker(afterEach); function makeItem(shortVersion: string, sparkleVersion: string, channel?: string): string { const channelElement = channel ? `${channel}` : ""; @@ -522,20 +518,6 @@ describe("collectForbiddenPackPaths", () => { }); describe("packed install verification", () => { - it("disables legacy ownership when the historical metadata producer exists", () => { - const sourceRoot = tempDirs.make("release-check-ownership-producer-"); - expect(allowsLegacyGeneratedOwnershipForSourceRoot(sourceRoot)).toBe(true); - - const producerPath = join( - sourceRoot, - "scripts/lib/runtime-dependency-ownership-build-plugin.mts", - ); - mkdirSync(dirname(producerPath), { recursive: true }); - writeFileSync(producerPath, "export {};\n", "utf8"); - - expect(allowsLegacyGeneratedOwnershipForSourceRoot(sourceRoot)).toBe(false); - }); - it("runs postpublish package integrity checks against the packed install before publish", () => { const root = mkdtempSync(join(tmpdir(), "release-check-packed-install-")); try {