mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
The 0.33.0 release PR (#2339) has sat in `changes-requested` since
2026-07-17. Root cause: **release-please only rewrites `pyproject.toml`
and its configured `extra-files`**, but other tracked files also carry
the version — and `server.json` is asserted byte-for-byte against
`render_server_json()`, which derives its version from `pyproject.toml`.
So the bump alone fails
`tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder`
(the `test (2)` shard) on every regenerated release PR.
Nothing in the repo regenerated `server.json` at all, so it fell behind
every release.
Unblocks #2339.
### Why the release *build* passes but the release PR does not
`release.yml` already runs `scripts/version-sync.py` immediately before
its own `verify-versions.py` gate (lines 145 and 278). That is why
`build` and `build-wheels` are green on #2339 despite the drift — it
syncs in the workspace, uncommitted. The regular CI test job does
**not** sync, so the fix has to be committed to the branch.
This also explains why reviewers kept seeing `verify-versions.py` fail
locally while CI's build jobs passed: the verifier is never run
un-synced inside `release.yml`.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **`scripts/version-sync.py`**: also write `server.json`. It was the
one version-carrying file with no writer anywhere. Values are rewritten
in place so key order and formatting keep matching the builder's
byte-for-byte output (verified: the file is pure ASCII and round-trips
exactly through `json.dumps(..., indent=2) + "\n"`).
- **`.github/workflows/release-metadata-sync.yml`** (new): on a push to
`release-please--branches--**`, run version-sync → gate on
verify-versions → commit if changed.
- **Keyed off the branch push** because release-please force-regenerates
that branch on every merge to main. That is precisely what wiped the
hand-pushed metadata fixes on #2339 (`2a86c8ff`, `d5ea4dc5`) — a push
trigger re-heals after every regeneration instead of being lost.
- **Uses the same PAT as `release-please.yml`**: a `GITHUB_TOKEN` push
does not trigger workflows, so the release PR's checks would never
re-run against the synced commit and would stay red.
- **Idempotent**: the self-triggered rerun finds no diff and exits
before pushing, so the loop terminates after one no-op run.
- **Corrected pre-existing drift on `main`**: the agent-hooks plugin
manifests, both marketplace manifests, and `.releasemetadata` were
stranded at **0.31.0** — never bumped for 0.32.0 either.
`verify-versions.py` now passes on `main`.
### Why not more `extra-files` entries
That would need ~13 jsonpath entries restating what `version-sync.py`
already knows, and a jsonpath that fails to match **fails silently** —
the same class of failure this PR removes, discoverable only after a
real release PR regenerates. There is also no precedent for nested
jsonpath (`$.packages[0].version`, `$.metadata.version`) in the config
today; both existing entries are plain `$.version`. Running the script
keeps one source of truth, and files added to it later are covered with
no change here.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — no `headroom/` sources
touched
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ python -m pytest scripts/tests/ tests/test_release_workflows.py tests/test_mcp_registry/ -q
207 passed in 2.69s
$ ruff check scripts/version-sync.py scripts/tests/test_version_sync.py tests/test_release_workflows.py
All checks passed!
$ ruff format --check <same>
3 files already formatted
$ actionlint .github/workflows/release-metadata-sync.yml
(clean)
```
New tests:
- `test_server_json_version_is_synchronized` — version-sync moves both
`server.json` version fields and preserves the other keys.
- `test_release_metadata_sync_runs_on_release_please_branch` — asserts
the trigger, the sync→verify→commit ordering, the no-op guard, and the
PAT.
- `test_version_sync_covers_every_file_the_verifier_gates` — guards
`version-sync.py` and `verify-versions.py` against drifting apart again,
which is the root cause here.
## Real Behavior Proof
- **Environment:** macOS (Darwin arm64), Python 3.12, repo venv.
- **Exact command / steps:** reproduced the CI failure locally by
simulating release-please's partial bump, then applying the fix.
**Reproducing the exact `test (2)` failure** — set `pyproject` to 0.33.0
while `server.json` stays at 0.32.0, as release-please leaves it:
```text
$ python -m pytest tests/test_mcp_registry/test_server_json.py -q
FAILED tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder
1 failed, 3 passed
```
**After `version-sync.py`:**
```text
$ python scripts/version-sync.py && python -m pytest tests/test_mcp_registry/test_server_json.py -q
4 passed
```
**Both gates green on a simulated 0.33.0 bump:**
```text
$ python scripts/version-sync.py --version 0.33.0
Version synchronized to 0.33.0
$ python scripts/verify-versions.py
All versions aligned at 0.33.0
$ python -m pytest tests/test_mcp_registry/test_server_json.py -q
4 passed
```
**Idempotency** (the property the workflow's loop-termination relies
on): re-running against an already-synced tree leaves `pyproject.toml`,
`server.json`, `openclaw`, and `sdk/typescript` untouched.
- **Not tested:** the workflow has not executed on a real release-please
branch regeneration — that can only be exercised once this is on `main`
and release-please next updates #2339. The PAT push path and the
self-trigger no-op are reasoned from `release-please.yml`'s existing
token comment and from local idempotency, not observed in CI.
## 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
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] 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
- [x] I did **not** edit `CHANGELOG.md`
## Additional Notes
**Context on the v0.32.0 release failure, since it is easy to misread as
"images never build".** Every artifact built for v0.32.0 — all 5 wheel
platforms including Windows, all 16 Docker builds + 8 manifests +
`promote-latest`, npm, and GitHub Packages. Only `publish-pypi` failed
(PyPI attestations, already fixed by `f9cbdd6e` / #2405), and
`create-release` was skipped because it depends on it. That is why the
release looked like it produced nothing.
**Separate, approaching blocker — not addressed here.** PyPI is at
**9.69 GB of its 10 GB project cap (96.9%)**, leaving ~305 MB against
~68 MB per release, so roughly 4 more releases fit. The `0.21.x` series
alone holds **6.58 GB across 31 releases**, from the old
every-push-is-a-release era; pruning it would reclaim two thirds of the
quota. Worth a separate issue.
**`.releasemetadata` is written but never read** by anything outside
`version-sync.py` and its test. It is kept in sync here for internal
consistency, but it may be a deletion candidate.
204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Synchronize version across all headroom packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import tomllib
|
|
except ImportError: # pragma: no cover - Python 3.10 fallback
|
|
import tomli as tomllib
|
|
|
|
|
|
def get_version_from_pyproject(root: Path) -> str:
|
|
"""Read version from pyproject.toml."""
|
|
pyproject_path = root / "pyproject.toml"
|
|
with open(pyproject_path, "rb") as f:
|
|
data = tomllib.load(f)
|
|
return data["project"]["version"]
|
|
|
|
|
|
def bump_version(version: str, bump_type: str) -> str:
|
|
"""Bump version according to bump_type (major, minor, patch)."""
|
|
major, minor, patch = map(int, version.split("."))
|
|
if bump_type == "major":
|
|
major += 1
|
|
minor = 0
|
|
patch = 0
|
|
elif bump_type == "minor":
|
|
minor += 1
|
|
patch = 0
|
|
elif bump_type == "patch":
|
|
patch += 1
|
|
return f"{major}.{minor}.{patch}"
|
|
|
|
|
|
def update_package_json(file_path: Path, version: str) -> None:
|
|
"""Update a package.json version field."""
|
|
with open(file_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
data["version"] = version
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def update_plugin_manifest(file_path: Path, version: str) -> None:
|
|
"""Update a plugin.json version field."""
|
|
with open(file_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
data["version"] = version
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def update_marketplace_manifest(file_path: Path, version: str) -> None:
|
|
"""Update marketplace metadata and plugin entry versions."""
|
|
with open(file_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
metadata = data.get("metadata")
|
|
if isinstance(metadata, dict):
|
|
metadata["version"] = version
|
|
plugins = data.get("plugins")
|
|
if isinstance(plugins, list):
|
|
for plugin in plugins:
|
|
if isinstance(plugin, dict):
|
|
plugin["version"] = version
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def update_server_json(file_path: Path, version: str) -> None:
|
|
"""Update the MCP registry descriptor's version fields.
|
|
|
|
``server.json`` is asserted byte-for-byte against ``render_server_json()``
|
|
(tests/test_mcp_registry/test_server_json.py), which derives the version from
|
|
``pyproject.toml``. Nothing regenerated this file, so it silently fell behind
|
|
every release and failed that test on the release PR. Values are rewritten in
|
|
place so key order and formatting keep matching the builder's output.
|
|
"""
|
|
with open(file_path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
data["version"] = version
|
|
packages = data.get("packages")
|
|
if isinstance(packages, list):
|
|
for package in packages:
|
|
if isinstance(package, dict):
|
|
package["version"] = version
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def update_plugin_versions(root: Path, version: str) -> None:
|
|
"""Update marketplace and plugin manifest versions."""
|
|
update_marketplace_manifest(root / ".claude-plugin" / "marketplace.json", version)
|
|
update_marketplace_manifest(root / ".github" / "plugin" / "marketplace.json", version)
|
|
update_plugin_manifest(
|
|
root / "plugins" / "headroom-agent-hooks" / ".claude-plugin" / "plugin.json", version
|
|
)
|
|
update_plugin_manifest(
|
|
root / "plugins" / "headroom-agent-hooks" / ".github" / "plugin" / "plugin.json",
|
|
version,
|
|
)
|
|
|
|
|
|
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
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def update_pyproject_version(root: Path, version: str) -> None:
|
|
"""Update pyproject.toml version."""
|
|
pyproject_path = root / "pyproject.toml"
|
|
content = pyproject_path.read_text(encoding="utf-8")
|
|
updated = re.sub(
|
|
r'^version = "[^"]+"',
|
|
f'version = "{version}"',
|
|
content,
|
|
flags=re.MULTILINE,
|
|
)
|
|
pyproject_path.write_text(updated, encoding="utf-8")
|
|
|
|
|
|
def write_release_metadata(root: Path, version: str) -> None:
|
|
"""Write .releasemetadata JSON file."""
|
|
metadata = {
|
|
"version": version,
|
|
"packages": {
|
|
"pypi": version,
|
|
"npm-sdk": version,
|
|
"npm-openclaw": version,
|
|
"agent-hooks-plugin": version,
|
|
},
|
|
}
|
|
metadata_path = root / ".releasemetadata"
|
|
with open(metadata_path, "w", encoding="utf-8") as f:
|
|
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Synchronize version across headroom packages")
|
|
parser.add_argument(
|
|
"--root",
|
|
type=Path,
|
|
default=Path(__file__).parent.parent,
|
|
help="Root directory of the project",
|
|
)
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--version", help="Explicit version to set (e.g., 0.6.0)")
|
|
group.add_argument(
|
|
"--bump",
|
|
choices=["major", "minor", "patch"],
|
|
help="Bump version from pyproject.toml",
|
|
)
|
|
parser.add_argument(
|
|
"--plugin-manifests-only",
|
|
action="store_true",
|
|
help="Only update marketplace/plugin manifest versions",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.version:
|
|
version = args.version
|
|
elif args.bump:
|
|
base_version = get_version_from_pyproject(args.root)
|
|
version = bump_version(base_version, args.bump)
|
|
else:
|
|
version = get_version_from_pyproject(args.root)
|
|
|
|
if args.plugin_manifests_only:
|
|
update_plugin_versions(args.root, version)
|
|
print(f"Plugin versions synchronized to {version}")
|
|
return
|
|
|
|
# Update all versioned files
|
|
update_pyproject_version(args.root, 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)
|
|
update_server_json(args.root / "server.json", version)
|
|
write_release_metadata(args.root, version)
|
|
|
|
print(f"Version synchronized to {version}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|