diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md
index 298276bbde6..33431331399 100644
--- a/docs/reference/RELEASING.md
+++ b/docs/reference/RELEASING.md
@@ -32,6 +32,15 @@ OpenClaw has three public release lanes:
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
+## Release preflight
+
+- Run `pnpm release:check` before every tagged release
+- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
+ (or the matching beta/correction tag) before approval
+- npm release preflight fails closed unless the tarball includes both
+ `dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
+ so we do not ship an empty browser dashboard again
+
## Public references
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts
index e690f552c72..b998a85079f 100644
--- a/scripts/openclaw-npm-release-check.ts
+++ b/scripts/openclaw-npm-release-check.ts
@@ -41,6 +41,7 @@ const CORRECTION_TAG_REGEX = /^(?\d{4}\.[1-9]\d?\.[1-9]\d?)-(?
const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const MAX_CALVER_DISTANCE_DAYS = 2;
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
+const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/";
function normalizeRepoUrl(value: unknown): string {
if (typeof value !== "string") {
@@ -385,6 +386,28 @@ export function parseNpmPackJsonOutput(stdout: string): NpmPackResult[] | null {
return null;
}
+export function collectControlUiPackErrors(paths: Iterable): string[] {
+ const packedPaths = new Set(paths);
+ const assetPaths = [...packedPaths].filter((path) => path.startsWith(CONTROL_UI_ASSET_PREFIX));
+ const errors: string[] = [];
+
+ for (const requiredPath of REQUIRED_PACKED_PATHS) {
+ if (!packedPaths.has(requiredPath)) {
+ errors.push(
+ `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`,
+ );
+ }
+ }
+
+ if (assetPaths.length === 0) {
+ errors.push(
+ `npm package is missing Control UI asset payload under "${CONTROL_UI_ASSET_PREFIX}". Refuse release when the dashboard tarball would be empty.`,
+ );
+ }
+
+ return errors;
+}
+
function collectPackedTarballErrors(): string[] {
const errors: string[] = [];
let stdout = "";
@@ -415,15 +438,7 @@ function collectPackedTarballErrors(): string[] {
.filter((path): path is string => typeof path === "string" && path.length > 0),
);
- for (const requiredPath of REQUIRED_PACKED_PATHS) {
- if (!packedPaths.has(requiredPath)) {
- errors.push(
- `npm package is missing required path "${requiredPath}". Ensure UI assets are built and included before publish.`,
- );
- }
- }
-
- return errors;
+ return collectControlUiPackErrors(packedPaths);
}
function main(): number {
diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts
index d863ca74259..643b29aa19d 100644
--- a/test/openclaw-npm-release-check.test.ts
+++ b/test/openclaw-npm-release-check.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ collectControlUiPackErrors,
collectReleasePackageMetadataErrors,
collectReleaseTagErrors,
parseNpmPackJsonOutput,
@@ -130,6 +131,24 @@ describe("parseNpmPackJsonOutput", () => {
});
});
+describe("collectControlUiPackErrors", () => {
+ it("rejects packs that ship the dashboard HTML without the asset payload", () => {
+ expect(collectControlUiPackErrors(["dist/control-ui/index.html"])).toEqual([
+ 'npm package is missing Control UI asset payload under "dist/control-ui/assets/". Refuse release when the dashboard tarball would be empty.',
+ ]);
+ });
+
+ it("accepts packs that ship dashboard HTML and bundled assets", () => {
+ expect(
+ collectControlUiPackErrors([
+ "dist/control-ui/index.html",
+ "dist/control-ui/assets/index-Bu8rSoJV.js",
+ "dist/control-ui/assets/index-BK0yXA_h.css",
+ ]),
+ ).toEqual([]);
+ });
+});
+
describe("collectReleaseTagErrors", () => {
it("accepts versions within the two-day CalVer window", () => {
expect(