mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
b3f016b866
commit
5383c6bf2f
9 changed files with 226 additions and 11 deletions
|
|
@ -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"
|
||||
|
|
|
|||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -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"
|
||||
|
|
|
|||
87
.github/workflows/release-metadata-sync.yml
vendored
Normal file
87
.github/workflows/release-metadata-sync.yml
vendored
Normal 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}"
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue