fix(release): sync generated version metadata on the release branch (#2659)

## 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.
This commit is contained in:
Tejas Chopra 2026-07-29 15:12:04 -07:00 committed by GitHub
parent b3f016b866
commit 5383c6bf2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 226 additions and 11 deletions

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.31.0"
"version": "0.32.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.31.0",
"version": "0.32.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -5,14 +5,14 @@
},
"metadata": {
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
"version": "0.31.0"
"version": "0.32.0"
},
"plugins": [
{
"name": "headroom",
"source": "./plugins/headroom-agent-hooks",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"version": "0.31.0",
"version": "0.32.0",
"author": {
"name": "Headroom Contributors",
"url": "https://github.com/chopratejas/headroom"

View file

@ -0,0 +1,87 @@
name: Release Metadata Sync
# Keep generated version-carrying files in sync on release-please's branch.
#
# Why this exists
# ---------------
# release-please only rewrites `pyproject.toml` plus the `extra-files` listed in
# `.release-please-config.json` (currently the TypeScript SDK and OpenClaw
# package.json). Several 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 moment release-please bumps
# the version, `tests/test_mcp_registry/test_server_json.py::
# test_root_server_json_matches_builder` fails on the release PR, and the release
# cannot be merged. That is what blocked v0.33.0 (PR #2339).
#
# `release.yml` already runs `scripts/version-sync.py` before its own
# `verify-versions.py` gate, so the release *build* self-heals in the workspace.
# The regular CI test job does not, so the fix has to be committed.
#
# Why a workflow rather than more `extra-files` entries
# ----------------------------------------------------
# `scripts/version-sync.py` is the single place that knows every version-carrying
# file. Restating that list as per-file jsonpaths would duplicate it, and a
# jsonpath that silently fails to match produces exactly the failure we are trying
# to remove. Running the script instead means files added to it in future are
# covered with no change here.
#
# Why the push trigger
# --------------------
# release-please regenerates (force-pushes) its branch on every merge to main.
# That is what repeatedly wiped the hand-pushed metadata fixes on #2339. Keying
# off a push to the branch means the sync re-applies after every regeneration
# instead of being lost.
on:
push:
branches:
- "release-please--branches--**"
permissions:
contents: write
concurrency:
# Never cancel: a half-applied sync would leave the release PR inconsistent.
group: release-metadata-sync-${{ github.ref }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.ref_name }}
# PAT (not GITHUB_TOKEN) for the same reason release-please.yml uses one:
# a push made with GITHUB_TOKEN does not trigger workflows, so the release
# PR's checks would never re-run against the synced commit and would stay
# red. Falls back to GITHUB_TOKEN, where the sync still lands and a manual
# re-run of the PR's checks picks it up.
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
- uses: actions/setup-python@v6
with:
python-version: "3.12"
# version-sync.py is stdlib-only (json/re/tomllib), so no install step.
- name: Sync version-carrying files release-please does not bump
run: python scripts/version-sync.py
- name: Verify all versions agree
run: python scripts/verify-versions.py
- name: Commit and push if anything changed
run: |
if git diff --quiet; then
echo "Already in sync — nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: sync generated version metadata"
# This push re-triggers this workflow. version-sync.py is idempotent, so
# the next run finds no diff and exits above without pushing — the loop
# terminates after one no-op run.
git push origin HEAD:"${GITHUB_REF_NAME}"

View file

@ -1,9 +1,9 @@
{
"version": "0.31.0",
"version": "0.32.0",
"packages": {
"pypi": "0.31.0",
"npm-sdk": "0.31.0",
"npm-openclaw": "0.31.0",
"agent-hooks-plugin": "0.31.0"
"pypi": "0.32.0",
"npm-sdk": "0.32.0",
"npm-openclaw": "0.32.0",
"agent-hooks-plugin": "0.32.0"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.31.0",
"version": "0.32.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

View file

@ -1,6 +1,6 @@
{
"name": "headroom",
"version": "0.31.0",
"version": "0.32.0",
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
"author": {
"name": "Headroom Contributors",

View file

@ -80,6 +80,20 @@ def temp_project(tmp_path: Path) -> dict[str, Path]:
typescript_pkg = typescript / "package.json"
typescript_pkg.write_text(json.dumps({"name": "test", "version": "0.5.25"}))
# server.json — the MCP registry descriptor. Asserted byte-for-byte against
# render_server_json(), which reads the version from pyproject.toml, so it has
# to move with every bump or the release PR's test job fails.
server_json = root / "server.json"
server_json.write_text(
json.dumps(
{
"name": "io.github.headroomlabs-ai/headroom",
"version": "0.5.25",
"packages": [{"registryType": "pypi", "version": "0.5.25"}],
}
)
)
return {
"root": root,
"pyproject": pyproject,
@ -90,6 +104,7 @@ def temp_project(tmp_path: Path) -> dict[str, Path]:
"claude_plugin": claude_plugin,
"github_plugin": github_plugin,
"typescript_pkg": typescript_pkg,
"server_json": server_json,
}
@ -320,3 +335,30 @@ def test_openclaw_headroom_dependency_is_preserved_for_registry_installability(
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"
def test_server_json_version_is_synchronized(temp_project: dict[str, Path]) -> None:
"""server.json must track the bump or the release PR's test job fails.
``tests/test_mcp_registry/test_server_json.py::test_root_server_json_matches_builder``
asserts the tracked file equals ``render_server_json()``, which reads the version
from ``pyproject.toml``. Nothing regenerated server.json, so it fell behind every
release and blocked v0.33.0 (PR #2339).
"""
root = temp_project["root"]
script = Path(__file__).parent.parent / "version-sync.py"
result = subprocess.run(
[sys.executable, str(script), "--root", str(root), "--version", "0.33.0"],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"Script failed: {result.stderr}"
server_json = json.loads(temp_project["server_json"].read_text())
assert server_json["version"] == "0.33.0"
# The packages[] entry carries its own version and is checked by the builder too.
assert [p["version"] for p in server_json["packages"]] == ["0.33.0"]
# Untouched keys must survive so the file still matches the builder's output.
assert server_json["name"] == "io.github.headroomlabs-ai/headroom"
assert server_json["packages"][0]["registryType"] == "pypi"

View file

@ -74,6 +74,28 @@ def update_marketplace_manifest(file_path: Path, version: str) -> None:
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)
@ -172,6 +194,7 @@ def main() -> None:
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}")

View file

@ -1219,3 +1219,66 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None
"release-please must bump plugins/openclaw/package.json so the "
"openclaw npm publish stays in sync."
)
def test_release_metadata_sync_runs_on_release_please_branch() -> None:
"""The release branch must self-heal the versions release-please does not bump.
release-please rewrites `pyproject.toml` plus its configured `extra-files` only.
`server.json` is asserted byte-for-byte against `render_server_json()`, which
reads the version from `pyproject.toml`, so a bump without a sync fails
`test_root_server_json_matches_builder` on the release PR that is what blocked
v0.33.0 (#2339). `release.yml` syncs in-workspace before its own gate, but the
regular CI test job does not, so the sync has to be committed to the branch.
"""
content = (ROOT / ".github" / "workflows" / "release-metadata-sync.yml").read_text(
encoding="utf-8"
)
# Keyed off a push to the release branch: release-please force-regenerates that
# branch on every merge to main, which is what wiped the hand-pushed fixes.
assert '"release-please--branches--**"' in content
assert "contents: write" in content
# Sync, then gate on the verifier, then commit — in that order.
sync = content.index("python scripts/version-sync.py")
verify = content.index("python scripts/verify-versions.py", sync)
commit = content.index("git commit", verify)
assert sync < verify < commit
# Must no-op rather than loop when the branch is already in sync.
assert "git diff --quiet" in content
# A GITHUB_TOKEN push would not re-trigger the release PR's checks.
assert "RELEASE_PLEASE_TOKEN" in content
def test_version_sync_covers_every_file_the_verifier_gates() -> None:
"""version-sync.py must write every version location verify-versions.py checks.
These two scripts drifting apart is the root cause of the stuck release: the
verifier gated files nothing propagated a version to.
"""
sync = (ROOT / "scripts" / "version-sync.py").read_text(encoding="utf-8")
verify = (ROOT / "scripts" / "verify-versions.py").read_text(encoding="utf-8")
gated = [
"pyproject.toml",
"plugins/openclaw/package.json",
"sdk/typescript/package.json",
"plugins/headroom-agent-hooks/.claude-plugin/plugin.json",
"plugins/headroom-agent-hooks/.github/plugin/plugin.json",
"marketplace.json",
]
for path in gated:
assert path in verify, f"{path} unexpectedly no longer gated by verify-versions.py"
# version-sync builds paths piecewise, so match on the distinctive components.
for fragment in [
"openclaw",
"typescript",
"headroom-agent-hooks",
"marketplace.json",
"server.json",
]:
assert fragment in sync, f"version-sync.py no longer propagates a version to {fragment}"