From acea3f2465752d8bb403bfe996d99eec30819f62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 07:56:00 +0100 Subject: [PATCH] fix(build): stamp runtime postbuild artifacts --- CHANGELOG.md | 1 + package.json | 6 +- scripts/build-all.mjs | 14 +++- scripts/build-stamp.d.mts | 23 +----- scripts/build-stamp.mjs | 39 +-------- scripts/check-gateway-watch-regression.mjs | 28 ++++--- scripts/check-openclaw-package-tarball.mjs | 7 ++ scripts/lib/local-build-metadata-paths.d.mts | 7 ++ scripts/lib/local-build-metadata-paths.mjs | 13 +++ scripts/lib/local-build-metadata.d.mts | 43 ++++++++++ scripts/lib/local-build-metadata.mjs | 79 +++++++++++++++++++ scripts/openclaw-cross-os-release-checks.ts | 4 + scripts/openclaw-npm-release-check.ts | 6 ++ scripts/release-check.ts | 2 + scripts/run-node.mjs | 35 ++++---- scripts/runtime-postbuild-stamp.mjs | 18 +++++ src/infra/build-stamp.test.ts | 2 + src/infra/package-dist-inventory.test.ts | 6 ++ src/infra/package-dist-inventory.ts | 6 ++ src/infra/run-node.test.ts | 8 +- test/helpers/gateway-e2e-harness.ts | 8 +- test/openclaw-npm-release-check.test.ts | 14 +++- test/release-check.test.ts | 20 ++++- test/scripts/build-all.test.ts | 12 +++ .../check-gateway-watch-regression.test.ts | 26 ++++++ .../check-openclaw-package-tarball.test.ts | 22 ++++++ .../openclaw-cross-os-release-checks.test.ts | 28 +++++++ test/scripts/runtime-postbuild-stamp.test.ts | 29 +++++++ 28 files changed, 410 insertions(+), 96 deletions(-) create mode 100644 scripts/lib/local-build-metadata-paths.d.mts create mode 100644 scripts/lib/local-build-metadata-paths.mjs create mode 100644 scripts/lib/local-build-metadata.d.mts create mode 100644 scripts/lib/local-build-metadata.mjs create mode 100644 scripts/runtime-postbuild-stamp.mjs create mode 100644 test/scripts/runtime-postbuild-stamp.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ceba2ac81..a8de04e51bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9. +- Build/runtime: write the runtime-postbuild stamp after `pnpm build` writes the build stamp, so the next CLI invocation does not re-sync runtime artifacts after a successful build. Fixes #73151. Thanks @bittoby. - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. - Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah. - Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf. diff --git a/package.json b/package.json index 0c892be7abc..037b4e836f8 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "README.md", "assets/", "dist/", + "!dist/.buildstamp", + "!dist/.runtime-postbuildstamp", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", "!dist/extensions/*/.openclaw-install-stage*/**", @@ -1246,10 +1248,10 @@ "audit:seams": "node scripts/audit-seams.mjs", "build": "node scripts/build-all.mjs", "build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true", "build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", + "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", "canon:check": "node scripts/canon.mjs check", "canon:check:json": "node scripts/canon.mjs check --json", "canon:enforce": "node scripts/canon.mjs enforce --json", diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 75e281ec53c..07e9dfc5105 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -20,6 +20,11 @@ export const BUILD_ALL_STEPS = [ }, { label: "runtime-postbuild", kind: "node", args: ["scripts/runtime-postbuild.mjs"] }, { label: "build-stamp", kind: "node", args: ["scripts/build-stamp.mjs"] }, + { + label: "runtime-postbuild-stamp", + kind: "node", + args: ["scripts/runtime-postbuild-stamp.mjs"], + }, { label: "build:plugin-sdk:dts", kind: "pnpm", @@ -99,6 +104,7 @@ export const BUILD_ALL_PROFILES = { "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", + "runtime-postbuild-stamp", "build:plugin-sdk:dts", "write-plugin-sdk-entry-dts", "check-plugin-sdk-exports", @@ -109,7 +115,13 @@ export const BUILD_ALL_PROFILES = { "write-cli-startup-metadata", "write-cli-compat", ], - gatewayWatch: ["tsdown", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp"], + gatewayWatch: [ + "tsdown", + "check-cli-bootstrap-imports", + "runtime-postbuild", + "build-stamp", + "runtime-postbuild-stamp", + ], }; export function resolveBuildAllSteps(profile = "full") { diff --git a/scripts/build-stamp.d.mts b/scripts/build-stamp.d.mts index 88546b87076..85f6d049d26 100644 --- a/scripts/build-stamp.d.mts +++ b/scripts/build-stamp.d.mts @@ -1,22 +1 @@ -export function resolveGitHead(params?: { - cwd?: string; - spawnSync?: ( - cmd: string, - args: string[], - options: unknown, - ) => { status: number | null; stdout?: string | null }; -}): string | null; - -export function writeBuildStamp(params?: { - cwd?: string; - fs?: { - mkdirSync(path: string, options?: { recursive?: boolean }): void; - writeFileSync(path: string, data: string, encoding?: string): void; - }; - now?: () => number; - spawnSync?: ( - cmd: string, - args: string[], - options: unknown, - ) => { status: number | null; stdout?: string | null }; -}): string; +export { BUILD_STAMP_FILE, resolveGitHead, writeBuildStamp } from "./lib/local-build-metadata.mjs"; diff --git a/scripts/build-stamp.mjs b/scripts/build-stamp.mjs index a86153d137d..57edd449c2b 100644 --- a/scripts/build-stamp.mjs +++ b/scripts/build-stamp.mjs @@ -1,44 +1,9 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { writeBuildStamp } from "./lib/local-build-metadata.mjs"; -export function resolveGitHead(params = {}) { - const cwd = params.cwd ?? process.cwd(); - const spawnSyncImpl = params.spawnSync ?? spawnSync; - try { - const result = spawnSyncImpl("git", ["rev-parse", "HEAD"], { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - if (result.status !== 0) { - return null; - } - const head = (result.stdout ?? "").trim(); - return head || null; - } catch { - return null; - } -} - -export function writeBuildStamp(params = {}) { - const cwd = params.cwd ?? process.cwd(); - const fsImpl = params.fs ?? fs; - const now = params.now ?? Date.now; - const distRoot = path.join(cwd, "dist"); - const buildStampPath = path.join(distRoot, ".buildstamp"); - const head = resolveGitHead({ - cwd, - spawnSync: params.spawnSync, - }); - - fsImpl.mkdirSync(distRoot, { recursive: true }); - fsImpl.writeFileSync(buildStampPath, `${JSON.stringify({ builtAt: now(), head })}\n`, "utf8"); - return buildStampPath; -} +export { BUILD_STAMP_FILE, resolveGitHead, writeBuildStamp } from "./lib/local-build-metadata.mjs"; if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { try { diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index 1fa143fbf5c..03e612ace34 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -7,7 +7,11 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { writeBuildStamp } from "./build-stamp.mjs"; +import { + BUILD_STAMP_FILE, + writeBuildStamp, + writeRuntimePostBuildStamp, +} from "./lib/local-build-metadata.mjs"; import { resolveBuildRequirement } from "./run-node.mjs"; const DEFAULTS = { @@ -594,7 +598,7 @@ function buildRunNodeDeps(env) { spawnSync, distRoot: path.join(cwd, "dist"), distEntry: path.join(cwd, "dist", "/entry.js"), - buildStampPath: path.join(cwd, "dist", ".buildstamp"), + buildStampPath: path.join(cwd, "dist", BUILD_STAMP_FILE), sourceRoots: ["src", "extensions"].map((sourceRoot) => ({ name: sourceRoot, path: path.join(cwd, sourceRoot), @@ -613,19 +617,25 @@ export function shouldRefreshBuildStampForRestoredArtifacts(params) { ); } +export function writeBuildAndRuntimePostBuildStamps(params = {}) { + const cwd = params.cwd ?? process.cwd(); + writeBuildStamp({ cwd }); + writeRuntimePostBuildStamp({ cwd }); +} + async function main() { const options = parseArgs(process.argv.slice(2)); ensureDir(options.outputDir); if (!options.skipBuild) { runCheckedCommand("node", ["scripts/build-all.mjs", "gatewayWatch"]); // The watch harness must start from a completed dist/runtime baseline. - // Refresh the build stamp after the gateway build finishes so run-node - // does not spuriously rebuild inside the bounded watch window. - writeBuildStamp({ cwd: process.cwd() }); + // Refresh both stamps after the gateway build finishes so run-node does not + // leave stale local artifact metadata after the bounded watch window. + writeBuildAndRuntimePostBuildStamps(); } else { // Restored CI artifacts can be older than the fresh checkout mtimes. - // Refresh only the stamp so run-node trusts the already-built dist. - writeBuildStamp({ cwd: process.cwd() }); + // Refresh the local artifact stamps so run-node trusts the already-built dist. + writeBuildAndRuntimePostBuildStamps(); } let preflightBuildRequirement = resolveBuildRequirement(buildRunNodeDeps(process.env)); @@ -636,9 +646,9 @@ async function main() { }) ) { // CI's skip-build path restores a built dist artifact after checkout. - // Refresh the stamp so checkout mtimes for package/config files do not + // Refresh the stamps so checkout mtimes for package/config files do not // force a duplicate build during the bounded gateway:watch window. - writeBuildStamp({ cwd: process.cwd() }); + writeBuildAndRuntimePostBuildStamps(); preflightBuildRequirement = resolveBuildRequirement(buildRunNodeDeps(process.env)); } if ( diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index d0a08146cf8..8aeb3ca6b47 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -4,6 +4,7 @@ // prebuilt package artifact with dist inventory, not a source checkout. import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs"; function usage() { return "Usage: node scripts/check-openclaw-package-tarball.mjs "; @@ -39,6 +40,7 @@ const entrySet = new Set(normalized); const errors = []; const warnings = []; const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 }; +const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS); const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [ "dist/extensions/qa-channel/", @@ -121,6 +123,11 @@ if (!entrySet.has("package.json")) { if (!normalized.some((entry) => entry.startsWith("dist/"))) { errors.push("missing dist/ entries"); } +for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) { + if (entrySet.has(forbiddenEntry)) { + errors.push(`forbidden local build metadata tar entry ${forbiddenEntry}`); + } +} if (!entrySet.has("dist/postinstall-inventory.json")) { errors.push("missing dist/postinstall-inventory.json"); } diff --git a/scripts/lib/local-build-metadata-paths.d.mts b/scripts/lib/local-build-metadata-paths.d.mts new file mode 100644 index 00000000000..8a1a1a5ba9c --- /dev/null +++ b/scripts/lib/local-build-metadata-paths.d.mts @@ -0,0 +1,7 @@ +export const BUILD_STAMP_FILE: ".buildstamp"; +export const RUNTIME_POSTBUILD_STAMP_FILE: ".runtime-postbuildstamp"; +export const LOCAL_BUILD_METADATA_DIST_PATHS: readonly [ + "dist/.buildstamp", + "dist/.runtime-postbuildstamp", +]; +export function isLocalBuildMetadataDistPath(relativePath: string): boolean; diff --git a/scripts/lib/local-build-metadata-paths.mjs b/scripts/lib/local-build-metadata-paths.mjs new file mode 100644 index 00000000000..b140ec71d74 --- /dev/null +++ b/scripts/lib/local-build-metadata-paths.mjs @@ -0,0 +1,13 @@ +export const BUILD_STAMP_FILE = ".buildstamp"; +export const RUNTIME_POSTBUILD_STAMP_FILE = ".runtime-postbuildstamp"; + +export const LOCAL_BUILD_METADATA_DIST_PATHS = Object.freeze([ + `dist/${BUILD_STAMP_FILE}`, + `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`, +]); + +const LOCAL_BUILD_METADATA_DIST_PATH_SET = new Set(LOCAL_BUILD_METADATA_DIST_PATHS); + +export function isLocalBuildMetadataDistPath(relativePath) { + return LOCAL_BUILD_METADATA_DIST_PATH_SET.has(relativePath); +} diff --git a/scripts/lib/local-build-metadata.d.mts b/scripts/lib/local-build-metadata.d.mts new file mode 100644 index 00000000000..33fb34896b5 --- /dev/null +++ b/scripts/lib/local-build-metadata.d.mts @@ -0,0 +1,43 @@ +export { + BUILD_STAMP_FILE, + LOCAL_BUILD_METADATA_DIST_PATHS, + RUNTIME_POSTBUILD_STAMP_FILE, + isLocalBuildMetadataDistPath, +} from "./local-build-metadata-paths.mjs"; + +export function resolveGitHead(params?: { + cwd?: string; + spawnSync?: ( + cmd: string, + args: string[], + options: unknown, + ) => { status: number | null; stdout?: string | null }; +}): string | null; + +export function writeBuildStamp(params?: { + cwd?: string; + fs?: { + mkdirSync(path: string, options?: { recursive?: boolean }): void; + writeFileSync(path: string, data: string, encoding?: string): void; + }; + now?: () => number; + spawnSync?: ( + cmd: string, + args: string[], + options: unknown, + ) => { status: number | null; stdout?: string | null }; +}): string; + +export function writeRuntimePostBuildStamp(params?: { + cwd?: string; + fs?: { + mkdirSync(path: string, options?: { recursive?: boolean }): void; + writeFileSync(path: string, data: string, encoding?: string): void; + }; + now?: () => number; + spawnSync?: ( + cmd: string, + args: string[], + options: unknown, + ) => { status: number | null; stdout?: string | null }; +}): string; diff --git a/scripts/lib/local-build-metadata.mjs b/scripts/lib/local-build-metadata.mjs new file mode 100644 index 00000000000..09950fadc64 --- /dev/null +++ b/scripts/lib/local-build-metadata.mjs @@ -0,0 +1,79 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { + BUILD_STAMP_FILE, + LOCAL_BUILD_METADATA_DIST_PATHS, + RUNTIME_POSTBUILD_STAMP_FILE, + isLocalBuildMetadataDistPath, +} from "./local-build-metadata-paths.mjs"; + +export { + BUILD_STAMP_FILE, + LOCAL_BUILD_METADATA_DIST_PATHS, + RUNTIME_POSTBUILD_STAMP_FILE, + isLocalBuildMetadataDistPath, +}; + +export function resolveGitHead(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const spawnSyncImpl = params.spawnSync ?? spawnSync; + try { + const result = spawnSyncImpl("git", ["rev-parse", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + const head = (result.stdout ?? "").trim(); + return head || null; + } catch { + return null; + } +} + +export function writeBuildStamp(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const fsImpl = params.fs ?? fs; + const now = params.now ?? Date.now; + const distRoot = path.join(cwd, "dist"); + const buildStampPath = path.join(distRoot, BUILD_STAMP_FILE); + const head = resolveGitHead({ + cwd, + spawnSync: params.spawnSync, + }); + + fsImpl.mkdirSync(distRoot, { recursive: true }); + fsImpl.writeFileSync(buildStampPath, `${JSON.stringify({ builtAt: now(), head })}\n`, "utf8"); + return buildStampPath; +} + +export function writeRuntimePostBuildStamp(params = {}) { + const cwd = params.cwd ?? process.cwd(); + const fsImpl = params.fs ?? fs; + const now = params.now ?? Date.now; + const distRoot = path.join(cwd, "dist"); + const stampPath = path.join(distRoot, RUNTIME_POSTBUILD_STAMP_FILE); + const head = resolveGitHead({ + cwd, + spawnSync: params.spawnSync, + }); + + fsImpl.mkdirSync(distRoot, { recursive: true }); + fsImpl.writeFileSync( + stampPath, + `${JSON.stringify( + { + syncedAt: now(), + ...(head ? { head } : {}), + }, + null, + 2, + )}\n`, + "utf8", + ); + return stampPath; +} diff --git a/scripts/openclaw-cross-os-release-checks.ts b/scripts/openclaw-cross-os-release-checks.ts index b4214b929f6..2e806ca5339 100644 --- a/scripts/openclaw-cross-os-release-checks.ts +++ b/scripts/openclaw-cross-os-release-checks.ts @@ -20,6 +20,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { assertNoBundledRuntimeDepsStagingDebris } from "../src/infra/package-dist-inventory.ts"; +import { isLocalBuildMetadataDistPath } from "./lib/local-build-metadata-paths.mjs"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; @@ -479,6 +480,9 @@ function isPackagedDistPath(relativePath) { if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { return false; } + if (isLocalBuildMetadataDistPath(relativePath)) { + return false; + } if (relativePath.endsWith(".map")) { return false; } diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 1d975d20f98..a9cf59493d2 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { pathToFileURL } from "node:url"; import { + LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; @@ -69,6 +70,11 @@ const REQUIRED_PACKED_PATHS = [ ]; const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/"; const FORBIDDEN_PACKED_PATH_RULES = [ + ...LOCAL_BUILD_METADATA_DIST_PATHS.map((prefix) => ({ + prefix, + describe: (packedPath: string) => + `npm package must not include local build metadata "${packedPath}".`, + })), { prefix: "docs/.generated/", describe: (packedPath: string) => diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 060358cdf27..7fd2c7fb806 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -16,6 +16,7 @@ import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { isBundledRuntimeDepsInstallStagePath, + LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, writePackageDistInventory, } from "../src/infra/package-dist-inventory.ts"; @@ -77,6 +78,7 @@ const requiredPathGroups = [ "dist/control-ui/index.html", ]; const forbiddenPrefixes = [ + ...LOCAL_BUILD_METADATA_DIST_PATHS, "dist-runtime/", "dist/OpenClaw.app/", "dist/extensions/qa-channel/", diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 167fcfc6f83..b70d38e860a 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -4,11 +4,17 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; -import { resolveGitHead, writeBuildStamp as writeDistBuildStamp } from "./build-stamp.mjs"; import { BUNDLED_PLUGIN_PATH_PREFIX, BUNDLED_PLUGIN_ROOT_DIR, } from "./lib/bundled-plugin-paths.mjs"; +import { + BUILD_STAMP_FILE, + RUNTIME_POSTBUILD_STAMP_FILE, + resolveGitHead, + writeBuildStamp as writeDistBuildStamp, + writeRuntimePostBuildStamp as writeDistRuntimePostBuildStamp, +} from "./lib/local-build-metadata.mjs"; import { runRuntimePostBuild } from "./runtime-postbuild.mjs"; const buildScript = "scripts/tsdown-build.mjs"; @@ -17,12 +23,14 @@ const compilerArgs = [buildScript, "--no-clean"]; const runNodeSourceRoots = ["src", BUNDLED_PLUGIN_ROOT_DIR]; const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"]; export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles]; -const runtimePostBuildStampFile = ".runtime-postbuildstamp"; const runtimePostBuildWatchedPaths = [ "scripts/copy-bundled-plugin-metadata.mjs", "scripts/copy-plugin-sdk-root-alias.mjs", "scripts/lib", + "scripts/lib/local-build-metadata.mjs", + "scripts/lib/local-build-metadata-paths.mjs", "scripts/npm-runner.mjs", + "scripts/runtime-postbuild-stamp.mjs", "scripts/runtime-postbuild-shared.mjs", "scripts/runtime-postbuild.mjs", "scripts/stage-bundled-plugin-runtime-deps.mjs", @@ -756,20 +764,11 @@ const syncRuntimeArtifacts = async (deps) => { const writeRuntimePostBuildStamp = (deps) => { try { - deps.fs.mkdirSync(path.dirname(deps.runtimePostBuildStampPath), { recursive: true }); - const head = resolveGitHead(deps); - deps.fs.writeFileSync( - deps.runtimePostBuildStampPath, - `${JSON.stringify( - { - syncedAt: Date.now(), - ...(head ? { head } : {}), - }, - null, - 2, - )}\n`, - "utf8", - ); + writeDistRuntimePostBuildStamp({ + cwd: deps.cwd, + fs: deps.fs, + spawnSync: deps.spawnSync, + }); } catch (error) { logRunner( `Failed to write runtime postbuild stamp: ${error?.message ?? "unknown error"}`, @@ -827,8 +826,8 @@ export async function runNodeMain(params = {}) { deps.distRoot = path.join(deps.cwd, "dist"); deps.distEntry = path.join(deps.distRoot, "/entry.js"); - deps.buildStampPath = path.join(deps.distRoot, ".buildstamp"); - deps.runtimePostBuildStampPath = path.join(deps.distRoot, runtimePostBuildStampFile); + deps.buildStampPath = path.join(deps.distRoot, BUILD_STAMP_FILE); + deps.runtimePostBuildStampPath = path.join(deps.distRoot, RUNTIME_POSTBUILD_STAMP_FILE); deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({ name: sourceRoot, path: path.join(deps.cwd, sourceRoot), diff --git a/scripts/runtime-postbuild-stamp.mjs b/scripts/runtime-postbuild-stamp.mjs new file mode 100644 index 00000000000..7bbd8e691d7 --- /dev/null +++ b/scripts/runtime-postbuild-stamp.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { writeRuntimePostBuildStamp } from "./lib/local-build-metadata.mjs"; + +export { + RUNTIME_POSTBUILD_STAMP_FILE, + writeRuntimePostBuildStamp, +} from "./lib/local-build-metadata.mjs"; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + try { + writeRuntimePostBuildStamp(); + } catch (error) { + console.error(error); + process.exit(1); + } +} diff --git a/src/infra/build-stamp.test.ts b/src/infra/build-stamp.test.ts index 0faea138efe..8b0e9748ca2 100644 --- a/src/infra/build-stamp.test.ts +++ b/src/infra/build-stamp.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { writeBuildStamp } from "../../scripts/build-stamp.mjs"; +import { BUILD_STAMP_FILE } from "../../scripts/lib/local-build-metadata-paths.mjs"; import { withTempDir } from "../test-helpers/temp-dir.js"; describe("build-stamp script", () => { @@ -16,6 +17,7 @@ describe("build-stamp script", () => { return { status: 1, stdout: "" }; }, }); + expect(stampPath.endsWith(`/dist/${BUILD_STAMP_FILE}`)).toBe(true); await expect(fs.readFile(stampPath, "utf8")).resolves.toBe( '{"builtAt":1700000000000,"head":"abc123"}\n', diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 4f9e7029600..6e7169866dc 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -6,6 +6,7 @@ import { assertNoBundledRuntimeDepsStagingDebris, collectBundledRuntimeDepsStagingDebrisPaths, collectPackageDistInventoryErrors, + LOCAL_BUILD_METADATA_DIST_PATHS, PACKAGE_DIST_INVENTORY_RELATIVE_PATH, collectPackageDistInventory, isBundledRuntimeDepsInstallStagePath, @@ -92,6 +93,9 @@ describe("package dist inventory", () => { "discord", ".openclaw-runtime-deps-stamp.json", ); + const [omittedBuildStamp, omittedRuntimePostBuildStamp] = LOCAL_BUILD_METADATA_DIST_PATHS.map( + (relativePath) => path.join(packageRoot, relativePath), + ); const omittedRuntimeDepsTempFile = path.join( packageRoot, "dist", @@ -151,6 +155,8 @@ describe("package dist inventory", () => { await fs.writeFile(omittedQaLabTypes, "export {};\n", "utf8"); await fs.writeFile(omittedQaRuntimeChunk, "export {};\n", "utf8"); await fs.writeFile(omittedRuntimeDepsStamp, "{}\n", "utf8"); + await fs.writeFile(omittedBuildStamp, "{}\n", "utf8"); + await fs.writeFile(omittedRuntimePostBuildStamp, "{}\n", "utf8"); await fs.writeFile(omittedRuntimeDepsTempFile, "module.exports = 1;\n", "utf8"); await fs.symlink(path.join(packageRoot, "color-support.js"), omittedRuntimeDepsTempSymlink); await fs.symlink( diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index 7afefd88a95..0d2ae6d2d36 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -1,5 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { isLocalBuildMetadataDistPath } from "../../scripts/lib/local-build-metadata-paths.mjs"; + +export { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; const LEGACY_QA_CHANNEL_DIR = ["qa", "channel"].join("-"); @@ -64,6 +67,9 @@ function isPackagedDistPath(relativePath: string): boolean { if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { return false; } + if (isLocalBuildMetadataDistPath(relativePath)) { + return false; + } if (relativePath.endsWith("/.openclaw-runtime-deps-stamp.json")) { return false; } diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 1fcdd7ef2a9..d7fa4266209 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -8,6 +8,10 @@ import { bundledPluginRoot, } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it, vi } from "vitest"; +import { + BUILD_STAMP_FILE, + RUNTIME_POSTBUILD_STAMP_FILE, +} from "../../scripts/lib/local-build-metadata-paths.mjs"; import { acquireRunNodeBuildLock, resolveBuildRequirement, @@ -23,8 +27,8 @@ const ROOT_TSDOWN = "tsdown.config.ts"; const GENERATED_A2UI_BUNDLE = "src/canvas-host/a2ui/a2ui.bundle.js"; const GENERATED_A2UI_BUNDLE_HASH = "src/canvas-host/a2ui/.bundle.hash"; const DIST_ENTRY = "dist/entry.js"; -const BUILD_STAMP = "dist/.buildstamp"; -const RUNTIME_POSTBUILD_STAMP = "dist/.runtime-postbuildstamp"; +const BUILD_STAMP = `dist/${BUILD_STAMP_FILE}`; +const RUNTIME_POSTBUILD_STAMP = `dist/${RUNTIME_POSTBUILD_STAMP_FILE}`; const QA_LAB_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-lab.js"; const QA_RUNTIME_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-runtime.js"; const EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts"); diff --git a/test/helpers/gateway-e2e-harness.ts b/test/helpers/gateway-e2e-harness.ts index bd04cd6566c..bfbdfc1ed66 100644 --- a/test/helpers/gateway-e2e-harness.ts +++ b/test/helpers/gateway-e2e-harness.ts @@ -5,6 +5,10 @@ import { request as httpRequest } from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { + BUILD_STAMP_FILE, + RUNTIME_POSTBUILD_STAMP_FILE, +} from "../../scripts/lib/local-build-metadata-paths.mjs"; import { GatewayClient } from "../../src/gateway/client.js"; import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js"; import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js"; @@ -46,8 +50,8 @@ const GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS = 120_000; let gatewayEntrypointPromise: Promise | null = null; async function resolveBuiltGatewayEntrypoint(cwd: string): Promise { - const buildStampPath = path.join(cwd, "dist", ".buildstamp"); - const runtimePostBuildStampPath = path.join(cwd, "dist", ".runtime-postbuildstamp"); + const buildStampPath = path.join(cwd, "dist", BUILD_STAMP_FILE); + const runtimePostBuildStampPath = path.join(cwd, "dist", RUNTIME_POSTBUILD_STAMP_FILE); for (const entrypoint of ["dist/index.js", "dist/index.mjs"]) { try { await Promise.all([ diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 6a1721bed76..4b86971345e 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -20,7 +20,10 @@ import { shouldSkipPackedTarballValidation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; -import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../src/infra/package-dist-inventory.ts"; +import { + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, +} from "../src/infra/package-dist-inventory.ts"; const REQUIRED_PACKED_PATHS = [ PACKAGE_DIST_INVENTORY_RELATIVE_PATH, @@ -326,6 +329,15 @@ describe("collectForbiddenPackedPathErrors", () => { ]); }); + it("rejects local build metadata in npm pack output", () => { + expect( + collectForbiddenPackedPathErrors(["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS]), + ).toEqual([ + 'npm package must not include local build metadata "dist/.buildstamp".', + 'npm package must not include local build metadata "dist/.runtime-postbuildstamp".', + ]); + }); + it("rejects private qa artifacts in npm pack output", () => { expect( collectForbiddenPackedPathErrors([ diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 5fcf706222e..38c1527d2a5 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { bundledDistPluginFile, bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; @@ -24,7 +24,10 @@ import { packageNameFromSpecifier, resolveMissingPackBuildHint, } from "../scripts/release-check.ts"; -import { PACKAGE_DIST_INVENTORY_RELATIVE_PATH } from "../src/infra/package-dist-inventory.ts"; +import { + LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, +} from "../src/infra/package-dist-inventory.ts"; function makeItem(shortVersion: string, sparkleVersion: string): string { return `${shortVersion}${shortVersion}${sparkleVersion}`; @@ -433,6 +436,19 @@ describe("collectForbiddenPackPaths", () => { ]); }); + it("blocks local build metadata from npm pack output", () => { + expect( + collectForbiddenPackPaths(["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS]), + ).toEqual([...LOCAL_BUILD_METADATA_DIST_PATHS]); + }); + + it("keeps local build metadata excluded by package files", () => { + const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { files?: string[] }; + expect(pkg.files).toEqual( + expect.arrayContaining(LOCAL_BUILD_METADATA_DIST_PATHS.map((entry) => `!${entry}`)), + ); + }); + it("blocks legacy runtime dependency stamps from npm pack output", () => { expect( collectForbiddenPackPaths([ diff --git a/test/scripts/build-all.test.ts b/test/scripts/build-all.test.ts index 412154612ab..00f598813fa 100644 --- a/test/scripts/build-all.test.ts +++ b/test/scripts/build-all.test.ts @@ -134,6 +134,7 @@ describe("resolveBuildAllSteps", () => { "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", + "runtime-postbuild-stamp", "build:plugin-sdk:dts", "write-plugin-sdk-entry-dts", "check-plugin-sdk-exports", @@ -152,9 +153,20 @@ describe("resolveBuildAllSteps", () => { "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", + "runtime-postbuild-stamp", ]); }); + it("writes the runtime postbuild stamp after the build stamp", () => { + expect(resolveBuildAllSteps("full").map((step) => step.label)).toEqual( + expect.arrayContaining(["runtime-postbuild", "build-stamp", "runtime-postbuild-stamp"]), + ); + const labels = resolveBuildAllSteps("full").map((step) => step.label); + expect(labels.indexOf("runtime-postbuild-stamp")).toBeGreaterThan( + labels.indexOf("build-stamp"), + ); + }); + it("does not cache plugin-sdk entry shims over compiled JS", () => { const step = BUILD_ALL_STEPS.find((entry) => entry.label === "write-plugin-sdk-entry-dts"); expect(step).toBeTruthy(); diff --git a/test/scripts/check-gateway-watch-regression.test.ts b/test/scripts/check-gateway-watch-regression.test.ts index 411db26a079..133d263f8e9 100644 --- a/test/scripts/check-gateway-watch-regression.test.ts +++ b/test/scripts/check-gateway-watch-regression.test.ts @@ -1,9 +1,17 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { hasGatewayReadyLog, isIgnoredDistRuntimeWatchPath, shouldRefreshBuildStampForRestoredArtifacts, + writeBuildAndRuntimePostBuildStamps, } from "../../scripts/check-gateway-watch-regression.mjs"; +import { + BUILD_STAMP_FILE, + RUNTIME_POSTBUILD_STAMP_FILE, +} from "../../scripts/lib/local-build-metadata-paths.mjs"; describe("check-gateway-watch-regression", () => { it("ignores top-level dist-runtime extension dependency repairs", () => { @@ -50,4 +58,22 @@ describe("check-gateway-watch-regression", () => { }), ).toBe(false); }); + + it("refreshes runtime postbuild stamps after build stamps", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-stamps-")); + try { + fs.mkdirSync(path.join(rootDir, ".git"), { recursive: true }); + writeBuildAndRuntimePostBuildStamps({ cwd: rootDir }); + + const buildStampPath = path.join(rootDir, "dist", BUILD_STAMP_FILE); + const runtimeStampPath = path.join(rootDir, "dist", RUNTIME_POSTBUILD_STAMP_FILE); + expect(fs.existsSync(buildStampPath)).toBe(true); + expect(fs.existsSync(runtimeStampPath)).toBe(true); + expect(fs.statSync(runtimeStampPath).mtimeMs).toBeGreaterThanOrEqual( + fs.statSync(buildStampPath).mtimeMs, + ); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 3dc28e74dce..dbba4e4abbf 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; +import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; const CHECK_SCRIPT = "scripts/check-openclaw-package-tarball.mjs"; @@ -83,4 +84,25 @@ describe("check-openclaw-package-tarball", () => { }, ); }); + + it("rejects local build metadata entries in package tarballs", () => { + withTarball( + ["dist/index.js", ...LOCAL_BUILD_METADATA_DIST_PATHS], + { + "dist/index.js": "export {};\n", + ...Object.fromEntries(LOCAL_BUILD_METADATA_DIST_PATHS.map((entry) => [entry, "{}\n"])), + }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "forbidden local build metadata tar entry dist/.buildstamp", + ); + expect(result.stderr).toContain( + "forbidden local build metadata tar entry dist/.runtime-postbuildstamp", + ); + }, + ); + }); }); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 1ea1149f9d2..b44442bc871 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; +import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; import { agentOutputHasExpectedOkMarker, buildReleaseOnboardArgs, @@ -541,6 +542,33 @@ describe("scripts/openclaw-cross-os-release-checks", () => { } }); + it("omits local build metadata from candidate package inventories", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "openclaw-cross-os-local-stamps-")); + try { + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw-fixture", version: "0.0.0", files: ["dist/"] }), + "utf8", + ); + writeFileSync(join(packageRoot, "dist", "index.js"), "export {};\n", "utf8"); + for (const relativePath of LOCAL_BUILD_METADATA_DIST_PATHS) { + writeFileSync(join(packageRoot, relativePath), "{}\n", "utf8"); + } + + await writePackageDistInventoryForCandidate({ + sourceDir: packageRoot, + logPath: join(packageRoot, "npm-pack-dry-run.log"), + }); + + expect( + JSON.parse(readFileSync(join(packageRoot, "dist", "postinstall-inventory.json"), "utf8")), + ).toEqual(["dist/index.js"]); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } + }); + it("accepts a git main dev-channel update status payload", () => { expect(() => verifyDevUpdateStatus( diff --git a/test/scripts/runtime-postbuild-stamp.test.ts b/test/scripts/runtime-postbuild-stamp.test.ts new file mode 100644 index 00000000000..2f9f4814e23 --- /dev/null +++ b/test/scripts/runtime-postbuild-stamp.test.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { RUNTIME_POSTBUILD_STAMP_FILE } from "../../scripts/lib/local-build-metadata-paths.mjs"; +import { writeRuntimePostBuildStamp } from "../../scripts/runtime-postbuild-stamp.mjs"; + +describe("runtime-postbuild-stamp script", () => { + it("writes dist/.runtime-postbuildstamp with the current git head", () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-postbuild-stamp-")); + try { + const stampPath = writeRuntimePostBuildStamp({ + cwd: rootDir, + now: () => 123, + spawnSync: () => ({ status: 0, stdout: "abc123\n" }), + }); + + expect(path.relative(rootDir, stampPath)).toBe( + path.join("dist", RUNTIME_POSTBUILD_STAMP_FILE), + ); + expect(JSON.parse(fs.readFileSync(stampPath, "utf8"))).toEqual({ + syncedAt: 123, + head: "abc123", + }); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); +});