chore(release): harden local artifact smokes (#1824)

## Description

Harden the release artifact workflow so maintainers can reproduce npm
and Python release checks locally and so OpenClaw packaging does not
depend on a not-yet-published SDK version. This follow-up also keeps the
OpenClaw loader export contract explicit and updates the PR after the
branch was merged with current `headroomlabs/main`.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [x] Documentation update

## Changes Made

- Added reusable local release smoke scripts for npm assets, Python
wheel/sdist artifacts, and the combined release gate.
- Switched the release workflow npm asset build to the reusable npm
builder.
- Made OpenClaw release asset building install against the just-built
local SDK tarball before rewriting packed metadata to the release
dependency range.
- Kept the OpenClaw source dependency registry-installable at
`headroom-ai@^0.22.3` while the packed artifact still ships
`^<release-version>`.
- Added `registerHeadroomPlugin` as a named export while preserving the
default `{ register }` OpenClaw loader contract.
- Added Windows development bootstrap docs/script and regression tests
for version sync, npm asset ordering, OpenClaw source installability,
and Python wheel smoke import isolation.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
uv run --with pytest python -m pytest tests/test_release_workflows.py scripts/tests/test_version_sync.py -q
44 passed, 1 warning

node --check scripts/build_npm_release_assets.mjs
node --check scripts/verify_npm_release_assets.mjs

python -m py_compile scripts/build_python_release_smoke.py scripts/release_smoke_all.py scripts/version-sync.py
python scripts/verify-versions.py
All versions aligned at 0.31.0

npm ci (plugins/openclaw)
added 88 packages, audited 89 packages, found 0 vulnerabilities

python scripts/release_smoke_all.py --out release-assets-local/all-pr1824-postmerge-fixed-20260710-104739
Verified npm release assets for 0.31.0
wheel metadata OK: headroom_ai-0.31.0-cp310-abi3-win_amd64.whl contains headroom/_core.pyd
sdist License-File metadata OK: ['LICENSE', 'NOTICE']
smoke-import OK: version=0.31.0 hello=headroom-core
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.14.3, Node v24.14.0, npm 11.9.0, uv
0.11.15, Rust/Cargo already installed in the local development
environment.
- Exact command / steps: Ran `python scripts/release_smoke_all.py --out
release-assets-local/all-pr1824-postmerge-fixed-20260710-104739` after
syncing this branch with current `headroomlabs/main`.
- Observed result: The command built `headroom-ai-0.31.0.tgz`,
`headroom-openclaw-0.31.0.tgz`,
`headroom_ai-0.31.0-cp310-abi3-win_amd64.whl`, and
`headroom_ai-0.31.0.tar.gz`; npm verifier passed; the wheel installed
into a fresh venv and imported `headroom._core`.
- Not tested: Full GitHub Actions release matrix and publish jobs with
real PyPI/npm/GitHub Packages credentials.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A.

## Additional Notes

The post-merge full smoke initially failed because the OpenClaw source
package depended on `headroom-ai@^0.31.0`, which is not available on
public npm yet. The builder now installs OpenClaw against the just-built
local SDK tarball before build/pack, and then rewrites packed metadata
to `^0.31.0`.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
ninosat00 2026-07-14 22:07:34 +02:00 committed by GitHub
parent 36577d9547
commit 5709291914
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1157 additions and 42 deletions

View file

@ -50,6 +50,8 @@ on:
- "pyproject.toml"
- "scripts/verify-versions.py"
- "scripts/version-sync.py"
- "scripts/build_npm_release_assets.mjs"
- "scripts/verify_npm_release_assets.mjs"
- "Cargo.toml"
- "Cargo.lock"
workflow_dispatch:
@ -187,20 +189,7 @@ jobs:
- name: Build npm release packages
run: |
mkdir -p release-assets
cd sdk/typescript
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm pack --pack-destination ../../release-assets
cd ../../plugins/openclaw
npm install ../../release-assets/headroom-ai-${{ needs.detect-version.outputs.npm_version }}.tgz
npm install
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm pack --pack-destination ../../release-assets
node scripts/build_npm_release_assets.mjs "${{ needs.detect-version.outputs.npm_version }}" release-assets
- name: Upload release assets artifact
uses: actions/upload-artifact@v7
@ -833,10 +822,11 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
version="${{ needs.detect-version.outputs.npm_version }}"
cd sdk/typescript
npm install
npm ci
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm version "$version" --no-git-tag-version --allow-same-version
npm publish --access public
continue-on-error: true
@ -845,10 +835,19 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
version="${{ needs.detect-version.outputs.npm_version }}"
cd plugins/openclaw
npm install
npm ci
npm run build
npm version ${{ needs.detect-version.outputs.npm_version }} --no-git-tag-version --allow-same-version
npm version "$version" --no-git-tag-version --allow-same-version
HEADROOM_NPM_VERSION="$version" node <<'EOF'
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.dependencies = pkg.dependencies || {};
pkg.dependencies["headroom-ai"] = `^${process.env.HEADROOM_NPM_VERSION}`;
fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`);
EOF
node prepare-dist.mjs
npm publish --access public
continue-on-error: true

8
.gitignore vendored
View file

@ -18,6 +18,11 @@ scripts/*
!scripts/changelog-gen.py
!scripts/verify-versions.py
!scripts/pr-governance.py
!scripts/bootstrap-windows-dev.ps1
!scripts/build_npm_release_assets.mjs
!scripts/build_python_release_smoke.py
!scripts/release_smoke_all.py
!scripts/verify_npm_release_assets.mjs
!scripts/tests/
!scripts/README.md
!scripts/repro_codex_replay.py
@ -121,6 +126,9 @@ venv.bak/
# Node.js dependencies (never commit vendored deps)
node_modules/
# Local release smoke outputs
release-assets-local/
# Secrets and API keys - NEVER commit these
*.pem
*.key

View file

@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"headroom-ai": "^0.31.0"
"headroom-ai": "^0.22.3"
},
"peerDependencies": {
"openclaw": "*"

View file

@ -1,4 +1,4 @@
export { default } from "./plugin/index.js";
export { default, registerHeadroomPlugin } from "./plugin/index.js";
export { HeadroomContextEngine } from "./engine.js";
export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";

View file

@ -29,10 +29,10 @@ import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
* See: https://github.com/chopratejas/headroom/issues/XXX
*/
export default {
register: headroomPlugin,
register: registerHeadroomPlugin,
};
function headroomPlugin(api: any) {
export function registerHeadroomPlugin(api: any) {
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
const logger = api.logger ?? console;
const rawProxyUrl = config.proxyUrl;

View file

@ -25,7 +25,7 @@ vi.mock("../src/tools/headroom-retrieve.js", () => ({
createHeadroomRetrieveTool: mocked.createHeadroomRetrieveTool,
}));
import headroomPlugin from "../src/plugin/index.js";
import headroomExtension, { registerHeadroomPlugin } from "../src/plugin/index.js";
afterEach(() => {
vi.restoreAllMocks();
@ -38,6 +38,10 @@ afterEach(() => {
});
describe("headroomPlugin runtime routing", () => {
it("exports the OpenClaw extension object with a register handler", () => {
expect(headroomExtension.register).toBe(registerHeadroomPlugin);
});
function stubConfiguredProxyProbe(response: "headroom" | "non-headroom" | "down") {
if (response === "down") {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED")));
@ -131,7 +135,7 @@ describe("headroomPlugin runtime routing", () => {
},
};
headroomPlugin(api);
registerHeadroomPlugin(api);
await Promise.resolve();
// With no active or configured proxy URL, initial routing defers without
@ -204,7 +208,7 @@ describe("headroomPlugin runtime routing", () => {
}),
};
headroomPlugin(api);
registerHeadroomPlugin(api);
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
@ -247,7 +251,7 @@ describe("headroomPlugin runtime routing", () => {
}),
};
headroomPlugin(api);
registerHeadroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
// Configured proxyUrl is probe-gated before provider mutation.
@ -290,7 +294,7 @@ describe("headroomPlugin runtime routing", () => {
}),
};
headroomPlugin(api);
registerHeadroomPlugin(api);
await Promise.resolve();
await Promise.resolve();
await gatewayHandlers.get("gateway_start")?.();
@ -336,7 +340,7 @@ describe("headroomPlugin runtime routing", () => {
}),
};
headroomPlugin(api);
registerHeadroomPlugin(api);
await gatewayHandlers.get("gateway_start")?.();
expect(api.config.models.providers.anthropic).toEqual({
@ -373,7 +377,7 @@ describe("headroomPlugin runtime routing", () => {
on: vi.fn(),
};
headroomPlugin(api);
registerHeadroomPlugin(api);
const [toolFactory] = api.registerTool.mock.calls[0];
const tool = toolFactory({});

View file

@ -73,3 +73,92 @@ exercises the script against a mock FastAPI server on every PR.
- `install.ps1` — Windows PowerShell installer.
These are generated by the release pipeline; edit with care.
## Windows development bootstrap
`bootstrap-windows-dev.ps1` prepares a Windows development checkout. It resolves
or creates a repo-local Python virtual environment, checks for Rust, installs
Python build/test tooling, installs npm dependencies for the TypeScript SDK and
OpenClaw plugin, and runs a small smoke set.
```powershell
powershell -ExecutionPolicy Bypass -File scripts/bootstrap-windows-dev.ps1
```
Use `-CheckOnly` to print detected tool versions without installing packages.
Use `-SkipSmoke`, `-SkipDocs`, `-SkipNode`, or `-SkipRust` when intentionally
debugging one part of the environment.
## npm release asset smoke
`build_npm_release_assets.mjs` locally reproduces the release workflow's npm
asset build. It builds the TypeScript SDK tarball, installs that tarball into
OpenClaw, rewrites OpenClaw's release dependency to the same version,
regenerates `dist/package.json`, packs OpenClaw, and then runs
`verify_npm_release_assets.mjs`.
```bash
node scripts/build_npm_release_assets.mjs <version>
```
By default, output goes into a timestamped `release-assets-local/<version>-*`
directory. Pass an explicit empty directory when you want a predictable path:
```bash
node scripts/build_npm_release_assets.mjs <version> release-assets-local/smoke
```
Expected tarballs:
- `headroom-ai-<version>.tgz`
- `headroom-openclaw-<version>.tgz`
The script restores package metadata after it finishes so the source tree keeps
the registry-installable development dependency range.
## Python release artifact smoke
`build_python_release_smoke.py` locally reproduces the Python artifact smoke:
it builds a wheel with `maturin`, builds an sdist, verifies the sdist
`License-File` metadata against tarball contents, installs the wheel into a
fresh `python -m venv` environment, and imports the native `headroom._core`
extension from that installed wheel.
```bash
python scripts/build_python_release_smoke.py
```
By default, the wheel uses the faster Cargo `ci` profile and output goes into a
timestamped `release-assets-local/python-<version>-*` directory. Use `--release`
when you want the slower shipped-wheel profile:
```bash
python scripts/build_python_release_smoke.py --release --out release-assets-local/python-release-smoke
```
Expected artifacts:
- `headroom_ai-<version>-*.whl`
- `headroom_ai-<version>.tar.gz`
## Full local release smoke
`release_smoke_all.py` is the one-command local release gate. It first runs
`scripts/verify-versions.py`, then runs the npm release asset smoke and the
Python wheel/sdist smoke into sibling output directories.
```bash
python scripts/release_smoke_all.py
```
By default, output goes into `release-assets-local/all-<version>-*/npm` and
`release-assets-local/all-<version>-*/python`. Pass an explicit empty output
directory for a predictable evidence path:
```bash
python scripts/release_smoke_all.py --out release-assets-local/full-release-smoke
```
Use `--python-release` when the Python smoke should build with maturin's slower
release profile. Use `--skip-npm` or `--skip-python` only when intentionally
debugging one side of the artifact pipeline.

View file

@ -0,0 +1,173 @@
param(
[string]$Python = "",
[switch]$CheckOnly,
[switch]$SkipRust,
[switch]$SkipNode,
[switch]$SkipDocs,
[switch]$SkipSmoke
)
$ErrorActionPreference = "Stop"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "==> $Message"
}
function Get-CommandPath {
param([string]$Name)
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) {
return $cmd.Source
}
return $null
}
function Resolve-Python {
param([string]$Requested)
if ($Requested) {
if (-not (Test-Path -LiteralPath $Requested)) {
throw "Requested Python path does not exist: $Requested"
}
return (Resolve-Path -LiteralPath $Requested).Path
}
if ($env:HEADROOM_DEV_PYTHON -and (Test-Path -LiteralPath $env:HEADROOM_DEV_PYTHON)) {
return (Resolve-Path -LiteralPath $env:HEADROOM_DEV_PYTHON).Path
}
$repoVenv = Join-Path $script:RepoRoot ".venv\Scripts\python.exe"
if (Test-Path -LiteralPath $repoVenv) {
return (Resolve-Path -LiteralPath $repoVenv).Path
}
$auditVenv = Join-Path $script:RepoRoot "..\..\.venv\Scripts\python.exe"
if (Test-Path -LiteralPath $auditVenv) {
return (Resolve-Path -LiteralPath $auditVenv).Path
}
$systemPython = Get-CommandPath "python.exe"
if (-not $systemPython) {
throw "python.exe not found. Install Python 3.10+ first."
}
Write-Step "Creating local .venv"
& $systemPython -m venv (Join-Path $script:RepoRoot ".venv")
return (Resolve-Path -LiteralPath $repoVenv).Path
}
function Ensure-Rust {
if ($SkipRust) {
return
}
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
if (Test-Path -LiteralPath $cargoBin) {
$env:PATH = "$cargoBin;$env:PATH"
}
if (Get-CommandPath "cargo.exe") {
return
}
$winget = Get-CommandPath "winget.exe"
if (-not $winget) {
throw "cargo.exe not found and winget.exe is unavailable. Install Rustup from https://rustup.rs/."
}
Write-Step "Installing Rustup"
& $winget install --id Rustlang.Rustup -e --silent --accept-package-agreements --accept-source-agreements
if (Test-Path -LiteralPath $cargoBin) {
$env:PATH = "$cargoBin;$env:PATH"
}
}
function Invoke-NpmCi {
param([string]$RelativePath)
if ($SkipNode) {
return
}
$npm = Get-CommandPath "npm.cmd"
if (-not $npm) {
throw "npm.cmd not found. Install Node.js 18+."
}
$dir = Join-Path $script:RepoRoot $RelativePath
if (-not (Test-Path -LiteralPath (Join-Path $dir "package-lock.json"))) {
return
}
Write-Step "npm ci in $RelativePath"
Push-Location $dir
try {
& $npm ci
}
finally {
Pop-Location
}
}
$script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $script:RepoRoot
$pythonExe = Resolve-Python $Python
$cargoBinPath = Join-Path $env:USERPROFILE ".cargo\bin"
if (Test-Path -LiteralPath $cargoBinPath) {
$env:PATH = "$cargoBinPath;$env:PATH"
}
Write-Step "Tool versions"
& $pythonExe --version
& $pythonExe -m pip --version
node.exe --version
npm.cmd --version
if (Get-CommandPath "cargo.exe") {
cargo.exe --version
}
if ($CheckOnly) {
exit 0
}
Ensure-Rust
Write-Step "Installing Python build/dev tools"
& $pythonExe -m pip install --upgrade pip
& $pythonExe -m pip install --upgrade `
"maturin>=1.5,<2" uv ruff mypy pre-commit pytest-cov `
opentelemetry-sdk opentelemetry-exporter-otlp-proto-http `
"tree-sitter-language-pack>=0.10.0,<1.0" "tree-sitter>=0.25.2,<0.26" `
openpyxl
Write-Step "Building native extension with maturin ci profile"
& $pythonExe -m maturin develop -m "crates\headroom-py\Cargo.toml" --profile ci
Write-Step "Installing runtime extras without rebuilding headroom-ai"
& $pythonExe -m pip install --upgrade `
"tree-sitter-language-pack>=0.10.0,<1.0" `
anthropic ollama langchain-ollama hnswlib `
"sentence-transformers>=2.2.0,<6.0" fastembed jinja2 xlrd
Invoke-NpmCi "sdk\typescript"
Invoke-NpmCi "plugins\openclaw"
if (-not $SkipDocs) {
Invoke-NpmCi "docs"
}
if (-not $SkipSmoke) {
Write-Step "Smoke checks"
& $pythonExe -c "import importlib.util, headroom; assert importlib.util.find_spec('headroom._core'); print(headroom.__version__)"
& $pythonExe -m headroom.cli --version
& $pythonExe -m pip check
Push-Location (Join-Path $script:RepoRoot "sdk\typescript")
try { npm.cmd run build } finally { Pop-Location }
Push-Location (Join-Path $script:RepoRoot "plugins\openclaw")
try { npm.cmd run build } finally { Pop-Location }
}
Write-Host ""
Write-Host "Headroom Windows dev environment is ready."

View file

@ -0,0 +1,197 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const sdkDir = path.join(rootDir, "sdk", "typescript");
const openClawDir = path.join(rootDir, "plugins", "openclaw");
const rawArgs = process.argv.slice(2);
const flags = new Set(rawArgs.filter((arg) => arg.startsWith("--")));
const positional = rawArgs.filter((arg) => !arg.startsWith("--"));
const [version, assetsDirArg] = positional;
if (!version || flags.has("--help") || flags.has("-h")) {
console.error(
[
"Usage: node scripts/build_npm_release_assets.mjs <version> [assets-dir] [--skip-install] [--no-verify]",
"",
"Builds the TypeScript SDK and OpenClaw npm release tarballs, rewrites",
"OpenClaw release metadata to depend on the just-built SDK version,",
"regenerates dist/package.json, and verifies the resulting assets.",
].join("\n"),
);
process.exit(flags.has("--help") || flags.has("-h") ? 0 : 2);
}
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
console.error(`Invalid version: ${version}`);
process.exit(2);
}
const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
const assetsDir = path.resolve(
rootDir,
assetsDirArg || path.join("release-assets-local", `${version}-${timestamp}`),
);
const trackedFiles = [
path.join(sdkDir, "package.json"),
path.join(sdkDir, "package-lock.json"),
path.join(openClawDir, "package.json"),
path.join(openClawDir, "package-lock.json"),
path.join(openClawDir, "dist", "package.json"),
];
const snapshots = new Map(
trackedFiles.map((filePath) => [
filePath,
existsSync(filePath) ? readFileSync(filePath, "utf8") : null,
]),
);
function quoteCmdArg(value) {
const arg = String(value);
if (/^[A-Za-z0-9_./:=\\-]+$/.test(arg)) {
return arg;
}
return `"${arg.replace(/"/g, '""')}"`;
}
function run(command, args, cwd) {
console.log(`\n> ${command} ${args.map(quoteCmdArg).join(" ")}`);
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
stdio: "inherit",
});
if (result.error) {
throw new Error(`${command} failed: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`${command} failed with exit code ${result.status ?? "unknown"}`);
}
}
function runNpm(args, cwd) {
if (process.platform === "win32") {
run("cmd.exe", ["/d", "/s", "/c", "npm.cmd", ...args], cwd);
return;
}
run("npm", args, cwd);
}
function runNode(args, cwd) {
run(process.execPath, args, cwd);
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function writeJson(filePath, data) {
writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
}
function ensureEmptyAssetsDir() {
mkdirSync(assetsDir, { recursive: true });
const existing = readdirSync(assetsDir);
if (existing.length > 0) {
throw new Error(
`Assets directory must be empty to avoid stale tarballs: ${assetsDir}`,
);
}
}
function restoreTrackedFiles() {
for (const [filePath, contents] of snapshots.entries()) {
if (contents === null) {
rmSync(filePath, { force: true });
} else {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, contents, "utf8");
}
}
}
function relativeFileSpec(fromDir, targetPath) {
let relativePath = path.relative(fromDir, targetPath).split(path.sep).join("/");
if (!relativePath.startsWith(".")) {
relativePath = `./${relativePath}`;
}
return `file:${relativePath}`;
}
function rewriteOpenClawDependency(spec) {
const packageJsonPath = path.join(openClawDir, "package.json");
const pkg = readJson(packageJsonPath);
pkg.dependencies = pkg.dependencies || {};
pkg.dependencies["headroom-ai"] = spec;
writeJson(packageJsonPath, pkg);
}
function rewriteOpenClawLocalDependency(sdkTarballPath) {
rewriteOpenClawDependency(relativeFileSpec(openClawDir, sdkTarballPath));
}
function rewriteOpenClawReleaseDependency() {
rewriteOpenClawDependency(`^${version}`);
}
function assertTarballBuilt(name) {
const tarballPath = path.join(assetsDir, `${name}-${version}.tgz`);
if (!existsSync(tarballPath)) {
throw new Error(`Expected npm pack to produce ${tarballPath}`);
}
return tarballPath;
}
try {
ensureEmptyAssetsDir();
if (!flags.has("--skip-install")) {
runNpm(["ci"], sdkDir);
}
runNpm(["run", "build"], sdkDir);
runNpm(["version", version, "--no-git-tag-version", "--allow-same-version"], sdkDir);
runNpm(["pack", "--pack-destination", assetsDir], sdkDir);
const sdkTarballPath = assertTarballBuilt("headroom-ai");
rewriteOpenClawLocalDependency(sdkTarballPath);
if (!flags.has("--skip-install")) {
runNpm(
["install", "--package-lock=false", "--no-audit", "--no-fund", "--ignore-scripts"],
openClawDir,
);
} else {
runNpm(["install", "--no-save", "--package-lock=false", sdkTarballPath], openClawDir);
}
runNpm(["run", "build"], openClawDir);
runNpm(["version", version, "--no-git-tag-version", "--allow-same-version"], openClawDir);
rewriteOpenClawReleaseDependency();
runNode(["prepare-dist.mjs"], openClawDir);
runNpm(["pack", "--pack-destination", assetsDir], openClawDir);
assertTarballBuilt("headroom-openclaw");
if (!flags.has("--no-verify")) {
runNode(["scripts/verify_npm_release_assets.mjs", assetsDir, version], rootDir);
}
console.log(`\nBuilt and verified npm release assets in ${assetsDir}`);
} finally {
restoreTrackedFiles();
runNode(["prepare-dist.mjs"], openClawDir);
}

View file

@ -0,0 +1,235 @@
#!/usr/bin/env python
"""Build and smoke-test local Python release artifacts.
This is the local companion to the release workflow's wheel/sdist gates. It
builds a wheel with maturin, builds an sdist, validates the sdist License-File
metadata, installs the wheel into a clean virtual environment, and imports the
native `headroom._core` extension from that installed wheel.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
import tomli as tomllib # type: ignore[no-redef]
ROOT = Path(__file__).resolve().parents[1]
PYPROJECT = ROOT / "pyproject.toml"
def load_project_version() -> str:
with PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def quote_arg(value: str | os.PathLike[str]) -> str:
text = str(value)
if re.fullmatch(r"[A-Za-z0-9_./:=\\-]+", text):
return text
return f'"{text.replace(chr(34), chr(34) * 2)}"'
def run(
args: list[str | os.PathLike[str]],
*,
env: dict[str, str] | None = None,
cwd: Path = ROOT,
) -> None:
print("\n> " + " ".join(quote_arg(arg) for arg in args), flush=True)
subprocess.run([str(arg) for arg in args], cwd=cwd, env=env, check=True)
def ensure_empty_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if any(path.iterdir()):
raise SystemExit(f"output directory must be empty to avoid stale artifacts: {path}")
def build_artifacts(out_dir: Path, python_exe: str, profile: str, release: bool) -> None:
env = os.environ.copy()
env.setdefault("PYO3_USE_ABI3_FORWARD_COMPATIBILITY", "1")
run([python_exe, "-m", "maturin", "--version"], env=env)
build_args = [
python_exe,
"-m",
"maturin",
"build",
"--out",
out_dir,
"--interpreter",
python_exe,
]
if release:
build_args.append("--release")
else:
build_args.extend(["--profile", profile])
run(build_args, env=env)
run([python_exe, "-m", "maturin", "sdist", "--out", out_dir], env=env)
def find_one_artifact(out_dir: Path, pattern: str) -> Path:
matches = sorted(out_dir.glob(pattern))
if len(matches) != 1:
raise SystemExit(f"expected exactly one {pattern} in {out_dir}, found {len(matches)}")
return matches[0]
def verify_wheel(wheel: Path, expected_version: str) -> None:
with zipfile.ZipFile(wheel) as archive:
names = set(archive.namelist())
native_members = [
name
for name in names
if name.startswith("headroom/_core") and Path(name).suffix.lower() in {".pyd", ".so"}
]
if not native_members:
raise SystemExit(f"{wheel.name} does not contain headroom/_core native extension")
metadata_members = [name for name in names if name.endswith(".dist-info/METADATA")]
if len(metadata_members) != 1:
raise SystemExit(
f"{wheel.name} should contain exactly one dist-info/METADATA, "
f"found {len(metadata_members)}"
)
metadata = archive.read(metadata_members[0]).decode("utf-8")
expected_line = f"Version: {expected_version}"
if expected_line not in metadata.splitlines():
raise SystemExit(f"{wheel.name} metadata missing {expected_line!r}")
print(f"wheel metadata OK: {wheel.name} contains {native_members[0]}")
def verify_sdist_license_files(sdist: Path) -> None:
with tarfile.open(sdist, "r:gz") as archive:
names = set(archive.getnames())
roots = {name.split("/", 1)[0] for name in names if "/" in name}
if len(roots) != 1:
raise SystemExit(f"expected one sdist root directory, found {sorted(roots)}")
root = roots.pop()
pkg_info_path = f"{root}/PKG-INFO"
member = archive.getmember(pkg_info_path)
fh = archive.extractfile(member)
if fh is None:
raise SystemExit(f"could not read {pkg_info_path} from {sdist.name}")
pkg_info = fh.read().decode("utf-8")
declared = []
for line in pkg_info.splitlines():
if not line.strip():
break
if line.startswith("License-File:"):
declared.append(line.split(":", 1)[1].strip())
if not declared:
raise SystemExit(f"{sdist.name} declares no License-File entries")
missing = [name for name in declared if f"{root}/{name}" not in names]
if missing:
raise SystemExit(
f"{sdist.name} declares License-File entries missing from tarball: {missing}"
)
print(f"sdist License-File metadata OK: {declared}")
def venv_python(venv_dir: Path) -> Path:
if os.name == "nt":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def smoke_install_wheel(wheel: Path, python_exe: str, expected_version: str) -> None:
with tempfile.TemporaryDirectory(prefix="headroom-python-smoke-") as tmp:
venv_dir = Path(tmp) / "venv"
run([python_exe, "-m", "venv", venv_dir])
smoke_python = venv_python(venv_dir)
run(
[
smoke_python,
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-warn-script-location",
wheel,
]
)
smoke_code = f"""
import importlib.metadata as metadata
import headroom
from headroom._core import DiffCompressor, SmartCrusher, hello
version = metadata.version("headroom-ai")
assert version == {expected_version!r}, version
assert headroom.__version__ == {expected_version!r}, headroom.__version__
print(f"smoke-import OK: version={{version}} hello={{hello()}} diff={{DiffCompressor!r}} smart={{SmartCrusher!r}}")
"""
import_cwd = Path(tmp) / "import-cwd"
import_cwd.mkdir()
run([smoke_python, "-c", smoke_code], cwd=import_cwd)
def parse_args() -> argparse.Namespace:
version = load_project_version()
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
default_out = ROOT / "release-assets-local" / f"python-{version}-{stamp}"
parser = argparse.ArgumentParser(
description="Build and smoke-test local Headroom Python release artifacts."
)
parser.add_argument("--out", type=Path, default=default_out)
parser.add_argument("--python", default=sys.executable)
parser.add_argument(
"--profile",
default="ci",
help="Cargo profile for local wheel smoke builds; ignored with --release.",
)
parser.add_argument(
"--release",
action="store_true",
help="Use maturin --release instead of the faster local smoke profile.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
out_dir = args.out.resolve()
python_exe = shutil.which(args.python) or args.python
expected_version = load_project_version()
ensure_empty_dir(out_dir)
build_artifacts(out_dir, python_exe, args.profile, args.release)
wheel = find_one_artifact(out_dir, "headroom_ai-*.whl")
sdist = find_one_artifact(out_dir, "headroom_ai-*.tar.gz")
verify_wheel(wheel, expected_version)
verify_sdist_license_files(sdist)
smoke_install_wheel(wheel, python_exe, expected_version)
print(f"\nBuilt and verified Python release artifacts in {out_dir}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,109 @@
#!/usr/bin/env python
"""Run the local release artifact smoke suite.
This command is the one-stop local release gate for artifact packaging. It runs
the version preflight, then delegates to the npm and Python artifact smoke
builders so their detailed checks stay in one place.
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
import tomli as tomllib # type: ignore[no-redef]
ROOT = Path(__file__).resolve().parents[1]
PYPROJECT = ROOT / "pyproject.toml"
def load_project_version() -> str:
with PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def quote_arg(value: str | os.PathLike[str]) -> str:
text = str(value)
if re.fullmatch(r"[A-Za-z0-9_./:=\\-]+", text):
return text
return f'"{text.replace(chr(34), chr(34) * 2)}"'
def run(args: list[str | os.PathLike[str]]) -> None:
print("\n> " + " ".join(quote_arg(arg) for arg in args), flush=True)
subprocess.run([str(arg) for arg in args], cwd=ROOT, check=True)
def ensure_empty_dir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
if any(path.iterdir()):
raise SystemExit(f"output directory must be empty to avoid stale artifacts: {path}")
def parse_args() -> argparse.Namespace:
version = load_project_version()
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
default_out = ROOT / "release-assets-local" / f"all-{version}-{stamp}"
parser = argparse.ArgumentParser(description="Run local npm and Python release smokes.")
parser.add_argument("--out", type=Path, default=default_out)
parser.add_argument("--python", default=sys.executable)
parser.add_argument("--node", default=shutil.which("node") or "node")
parser.add_argument(
"--python-release",
action="store_true",
help="Run the Python smoke with maturin --release instead of the faster ci profile.",
)
parser.add_argument("--skip-npm", action="store_true")
parser.add_argument("--skip-python", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
out_dir = args.out.resolve()
version = load_project_version()
if args.skip_npm and args.skip_python:
raise SystemExit("nothing to run: --skip-npm and --skip-python were both set")
ensure_empty_dir(out_dir)
run([args.python, "scripts/verify-versions.py"])
completed: list[tuple[str, Path]] = []
if not args.skip_npm:
npm_out = out_dir / "npm"
run([args.node, "scripts/build_npm_release_assets.mjs", version, npm_out])
completed.append(("npm", npm_out))
if not args.skip_python:
python_out = out_dir / "python"
python_args: list[str | os.PathLike[str]] = [
args.python,
"scripts/build_python_release_smoke.py",
"--out",
python_out,
]
if args.python_release:
python_args.append("--release")
run(python_args)
completed.append(("python", python_out))
print("\nLocal release smoke suite complete:")
for name, path in completed:
print(f"- {name}: {path}")
if __name__ == "__main__":
main()

View file

@ -40,7 +40,15 @@ def temp_project(tmp_path: Path) -> dict[str, Path]:
# plugins/openclaw/package.json
openclaw_pkg = openclaw / "package.json"
openclaw_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"}))
openclaw_pkg.write_text(
json.dumps(
{
"name": "test",
"version": "0.5.25",
"dependencies": {"headroom-ai": "^0.22.3"},
}
)
)
repo_claude_marketplace = repo_claude_plugin / "marketplace.json"
repo_claude_marketplace.write_text(
@ -109,6 +117,7 @@ def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None:
# Verify plugins/openclaw/package.json
openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text())
assert openclaw_pkg["version"] == "0.7.0"
assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3"
# Verify sdk/typescript/package.json
typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text())
@ -161,6 +170,7 @@ def test_bump_patch(temp_project: dict[str, Path]) -> None:
openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text())
assert openclaw_pkg["version"] == "0.5.26"
assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3"
typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text())
assert typescript_pkg["version"] == "0.5.26"
@ -191,6 +201,7 @@ def test_bump_minor(temp_project: dict[str, Path]) -> None:
openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text())
assert openclaw_pkg["version"] == "0.6.0"
assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3"
typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text())
assert typescript_pkg["version"] == "0.6.0"
@ -221,6 +232,7 @@ def test_bump_major(temp_project: dict[str, Path]) -> None:
openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text())
assert openclaw_pkg["version"] == "1.0.0"
assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3"
typescript_pkg = json.loads(temp_project["typescript_pkg"].read_text())
assert typescript_pkg["version"] == "1.0.0"
@ -289,3 +301,22 @@ def test_plugin_manifests_only_leaves_package_versions_unchanged(
== "0.8.0"
)
assert not (root / ".releasemetadata").exists()
def test_openclaw_headroom_dependency_is_preserved_for_registry_installability(
temp_project: dict[str, Path],
) -> None:
"""Source package stays installable even when the next SDK is not on npm yet."""
root = temp_project["root"]
script = Path(__file__).parent.parent / "version-sync.py"
result = subprocess.run(
[sys.executable, str(script), "--root", str(root), "--version", "0.28.0"],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Script failed: {result.stderr}"
openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text())
assert openclaw_pkg["version"] == "0.28.0"
assert openclaw_pkg["dependencies"]["headroom-ai"] == "^0.22.3"

View file

@ -0,0 +1,175 @@
#!/usr/bin/env node
import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
const [assetsDirArg, version] = process.argv.slice(2);
if (!assetsDirArg || !version) {
console.error("Usage: node scripts/verify_npm_release_assets.mjs <assets-dir> <version>");
process.exit(2);
}
const assetsDir = path.resolve(assetsDirArg);
const packages = [
{
name: "headroom-ai",
tarball: `headroom-ai-${version}.tgz`,
},
{
name: "headroom-openclaw",
tarball: `headroom-openclaw-${version}.tgz`,
dependencies: {
"headroom-ai": `^${version}`,
},
},
];
const tarballPaths = new Map();
function extractPackageJson(tarballPath) {
return extractJsonFromTarball(tarballPath, "package/package.json");
}
function extractDistPackageJson(tarballPath) {
return extractJsonFromTarball(tarballPath, "package/dist/package.json");
}
function extractJsonFromTarball(tarballPath, packageJsonPath) {
const workdir = mkdtempSync(path.join(tmpdir(), "headroom-npm-asset-"));
try {
const result = spawnSync("tar", ["-xzf", tarballPath, "-C", workdir], {
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(
`tar failed for ${tarballPath}: ${result.stderr || result.stdout || "unknown error"}`,
);
}
return JSON.parse(readFileSync(path.join(workdir, packageJsonPath), "utf8"));
} finally {
rmSync(workdir, { recursive: true, force: true });
}
}
function assertNoFileDependencies(pkg) {
for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) {
for (const [name, spec] of Object.entries(pkg[field] || {})) {
if (typeof spec === "string" && (spec.startsWith("file:") || spec.includes("release-assets"))) {
throw new Error(`${pkg.name} has non-portable ${field}.${name} spec: ${spec}`);
}
}
}
}
function runNpm(args, cwd) {
if (process.platform === "win32") {
return spawnSync("cmd.exe", ["/d", "/s", "/c", "npm.cmd", ...args], {
cwd,
encoding: "utf8",
});
}
return spawnSync("npm", args, {
cwd,
encoding: "utf8",
});
}
function assertOpenClawExtensionContract(cwd) {
const smoke = `
const mod = await import("headroom-openclaw");
if (typeof mod.default?.register !== "function") {
throw new Error("headroom-openclaw default export must expose register(api)");
}
if (typeof mod.registerHeadroomPlugin !== "function") {
throw new Error("headroom-openclaw must export registerHeadroomPlugin(api)");
}
if (mod.default.register !== mod.registerHeadroomPlugin) {
throw new Error("headroom-openclaw default.register must match registerHeadroomPlugin");
}
`;
const result = spawnSync(process.execPath, ["--input-type=module", "-e", smoke], {
cwd,
encoding: "utf8",
});
if (result.status !== 0) {
throw new Error(
`headroom-openclaw import smoke failed: ${
result.error?.message || result.stderr || result.stdout || "unknown error"
}`,
);
}
}
for (const expected of packages) {
const tarballPath = path.join(assetsDir, expected.tarball);
tarballPaths.set(expected.name, tarballPath);
const pkg = extractPackageJson(tarballPath);
if (pkg.name !== expected.name) {
throw new Error(`${expected.tarball} package name mismatch: expected ${expected.name}, got ${pkg.name}`);
}
if (pkg.version !== version) {
throw new Error(`${expected.tarball} version mismatch: expected ${version}, got ${pkg.version}`);
}
assertNoFileDependencies(pkg);
for (const [name, spec] of Object.entries(expected.dependencies || {})) {
const actual = pkg.dependencies?.[name];
if (actual !== spec) {
throw new Error(`${pkg.name} dependency ${name} mismatch: expected ${spec}, got ${actual}`);
}
}
if (expected.name === "headroom-openclaw") {
const distPkg = extractDistPackageJson(tarballPath);
if (distPkg.name !== expected.name) {
throw new Error(`${expected.tarball} dist package name mismatch: expected ${expected.name}, got ${distPkg.name}`);
}
if (distPkg.version !== version) {
throw new Error(`${expected.tarball} dist package version mismatch: expected ${version}, got ${distPkg.version}`);
}
assertNoFileDependencies(distPkg);
for (const [name, spec] of Object.entries(expected.dependencies || {})) {
const actual = distPkg.dependencies?.[name];
if (actual !== spec) {
throw new Error(`${distPkg.name} dist dependency ${name} mismatch: expected ${spec}, got ${actual}`);
}
}
}
}
const installDir = mkdtempSync(path.join(tmpdir(), "headroom-npm-install-"));
try {
for (const expected of packages) {
copyFileSync(tarballPaths.get(expected.name), path.join(installDir, expected.tarball));
}
const result = runNpm(
[
"install",
"--ignore-scripts",
"--no-audit",
"--no-fund",
`./${packages[0].tarball}`,
`./${packages[1].tarball}`,
],
installDir,
);
if (result.status !== 0) {
throw new Error(
`clean npm install failed: ${
result.error?.message || result.stderr || result.stdout || "unknown error"
}`,
);
}
assertOpenClawExtensionContract(installDir);
} finally {
rmSync(installDir, { recursive: true, force: true });
}
console.log(`Verified npm release assets for ${version}`);

View file

@ -43,7 +43,7 @@ def update_package_json(file_path: Path, version: str) -> None:
data = json.load(f)
data["version"] = version
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
@ -53,7 +53,7 @@ def update_plugin_manifest(file_path: Path, version: str) -> None:
data = json.load(f)
data["version"] = version
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
@ -70,7 +70,7 @@ def update_marketplace_manifest(file_path: Path, version: str) -> None:
if isinstance(plugin, dict):
plugin["version"] = version
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
@ -87,15 +87,18 @@ def update_plugin_versions(root: Path, version: str) -> None:
)
def update_openclaw_package_json(file_path: Path, version: str, sdk_version: str) -> None:
"""Update openclaw package.json version and headroom-ai dependency range."""
def update_openclaw_package_json(file_path: Path, version: str) -> None:
"""Update openclaw package.json version.
Keep the source `headroom-ai` dependency registry-installable. The release
workflow rewrites the packed tgz dependency to the exact release range after
the local SDK tarball is available.
"""
with open(file_path, encoding="utf-8") as f:
data = json.load(f)
data["version"] = version
if "dependencies" in data and "headroom-ai" in data["dependencies"]:
data["dependencies"]["headroom-ai"] = f"^{sdk_version}"
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
@ -125,7 +128,7 @@ def write_release_metadata(root: Path, version: str) -> None:
}
metadata_path = root / ".releasemetadata"
with open(metadata_path, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
json.dump(metadata, f, indent=2, ensure_ascii=False)
f.write("\n")
@ -166,9 +169,7 @@ def main() -> None:
# Update all versioned files
update_pyproject_version(args.root, version)
update_openclaw_package_json(
args.root / "plugins" / "openclaw" / "package.json", version, version
)
update_openclaw_package_json(args.root / "plugins" / "openclaw" / "package.json", version)
update_package_json(args.root / "sdk" / "typescript" / "package.json", version)
update_plugin_versions(args.root, version)
write_release_metadata(args.root, version)

View file

@ -555,6 +555,100 @@ def test_release_workflow_verifies_versions_before_build_outputs() -> None:
assert second_sync < second_verify < build_wheels
def test_release_workflow_uses_local_npm_asset_builder() -> None:
"""npm tarball metadata must be built and verified by the reusable local gate."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
verifier = (ROOT / "scripts" / "verify_npm_release_assets.mjs").read_text(encoding="utf-8")
assert (
'node scripts/build_npm_release_assets.mjs "${{ needs.detect-version.outputs.npm_version }}" release-assets'
in content
)
build_start = content.index("name: Build npm release packages")
upload_start = content.index("name: Upload release assets artifact", build_start)
build_block = content[build_start:upload_start]
assert "npm pack" not in build_block, (
"release.yml must not reimplement npm packing inline; the script "
"regenerates OpenClaw dist metadata and runs install/import smoke checks."
)
assert "scripts/build_npm_release_assets.mjs" in content
assert "scripts/verify_npm_release_assets.mjs" in content
assert "scripts/verify_npm_release_assets.mjs" in builder
assert "registerHeadroomPlugin" in verifier
def test_npm_release_builder_regenerates_openclaw_dist_metadata_after_rewrite() -> None:
"""OpenClaw's packed dist/package.json must see the release dependency."""
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
rewrite = builder.index("rewriteOpenClawReleaseDependency();")
prepare_dist = builder.index('runNode(["prepare-dist.mjs"], openClawDir);', rewrite)
pack = builder.index(
'runNpm(["pack", "--pack-destination", assetsDir], openClawDir);', prepare_dist
)
verify = builder.index(
'runNode(["scripts/verify_npm_release_assets.mjs", assetsDir, version], rootDir)', pack
)
assert rewrite < prepare_dist < pack < verify
def test_npm_release_builder_installs_openclaw_against_local_sdk_tarball() -> None:
"""The OpenClaw build must not require the release SDK to exist on npm."""
builder = (ROOT / "scripts" / "build_npm_release_assets.mjs").read_text(encoding="utf-8")
local_dependency = builder.index("rewriteOpenClawLocalDependency(sdkTarballPath);")
install = builder.index(
'["install", "--package-lock=false", "--no-audit", "--no-fund", "--ignore-scripts"]',
local_dependency,
)
build = builder.index('runNpm(["run", "build"], openClawDir);', install)
release_dependency = builder.index("rewriteOpenClawReleaseDependency();", build)
assert local_dependency < install < build < release_dependency
assert 'runNpm(["ci"], openClawDir)' not in builder
def test_openclaw_source_dependency_matches_lockfile_registry_range() -> None:
"""The source checkout must remain npm-ci installable before a release exists."""
import json
package_json = json.loads((ROOT / "plugins" / "openclaw" / "package.json").read_text())
package_lock = json.loads((ROOT / "plugins" / "openclaw" / "package-lock.json").read_text())
source_range = package_json["dependencies"]["headroom-ai"]
lock_range = package_lock["packages"][""]["dependencies"]["headroom-ai"]
assert source_range == lock_range == "^0.22.3"
def test_python_release_smoke_imports_installed_wheel_outside_source_tree() -> None:
"""The wheel smoke must not import the checkout package by accident."""
script = (ROOT / "scripts" / "build_python_release_smoke.py").read_text(encoding="utf-8")
assert "cwd: Path = ROOT" in script
assert 'import_cwd = Path(tmp) / "import-cwd"' in script
assert 'run([smoke_python, "-c", smoke_code], cwd=import_cwd)' in script
def test_publish_npm_regenerates_openclaw_dist_metadata_after_version_and_dependency() -> None:
"""The direct npm publish path must not ship stale OpenClaw dist metadata."""
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
start = content.index("name: Publish ${{ env.NPM_OPENCLAW_PACKAGE }} to npmjs.org")
end = content.index("continue-on-error: true", start)
block = content[start:end]
version = block.index('npm version "$version"')
dependency = block.index('pkg.dependencies["headroom-ai"]')
prepare_dist = block.index("node prepare-dist.mjs")
publish = block.index("npm publish --access public")
assert version < dependency < prepare_dist < publish
def test_sdist_license_is_packaged_and_verified_before_upload() -> None:
"""STRUCTURAL INVARIANT: the sdist tarball must physically contain
every license file PEP 639 declares in PKG-INFO, and the release