mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
36577d9547
commit
5709291914
15 changed files with 1157 additions and 42 deletions
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
|
|
@ -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
8
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"headroom-ai": "^0.31.0"
|
||||
"headroom-ai": "^0.22.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": "*"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
173
scripts/bootstrap-windows-dev.ps1
Normal file
173
scripts/bootstrap-windows-dev.ps1
Normal 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."
|
||||
197
scripts/build_npm_release_assets.mjs
Normal file
197
scripts/build_npm_release_assets.mjs
Normal 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);
|
||||
}
|
||||
235
scripts/build_python_release_smoke.py
Normal file
235
scripts/build_python_release_smoke.py
Normal 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()
|
||||
109
scripts/release_smoke_all.py
Normal file
109
scripts/release_smoke_all.py
Normal 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()
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
175
scripts/verify_npm_release_assets.mjs
Normal file
175
scripts/verify_npm_release_assets.mjs
Normal 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}`);
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue