Skip to content

Commit 996636b

Browse files
authored
fix(release): synchronize and validate the published agent card version (#520)
Keep Cargo, SKILL and the public agent card version consistent, validate replacements before writes, and verify staged release metadata. Refs #514.
1 parent 06549ea commit 996636b

7 files changed

Lines changed: 226 additions & 15 deletions

File tree

.github/release-metadata-policy.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"level": "blocking",
1616
"inputs": [
1717
"SKILL.md",
18-
"Cargo.toml"
18+
"Cargo.toml",
19+
".well-known/agent.json"
1920
],
2021
"modes": {
2122
"pull_request": "changed_only",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""Offline regressions for the static release card/version contract."""
3+
4+
import importlib.util
5+
import json
6+
from pathlib import Path
7+
import shutil
8+
import subprocess
9+
import tempfile
10+
import unittest
11+
12+
ROOT = Path(__file__).resolve().parents[2]
13+
VALIDATOR = Path('.github/scripts/validate_release_metadata.py')
14+
CARD = Path('.well-known/agent.json')
15+
16+
17+
class ReleaseCardTests(unittest.TestCase):
18+
def setUp(self):
19+
self.temp = tempfile.TemporaryDirectory()
20+
self.addCleanup(self.temp.cleanup)
21+
self.root = Path(self.temp.name)
22+
for name in [VALIDATOR, CARD, Path('Cargo.toml'), Path('SKILL.md'),
23+
Path('scripts/bump-version.sh')]:
24+
target = self.root / name
25+
target.parent.mkdir(parents=True, exist_ok=True)
26+
shutil.copyfile(ROOT / name, target)
27+
self.policy = json.loads((ROOT / '.github/release-metadata-policy.json').read_text())
28+
self.policy['rules'] = {'version_sync': self.policy['rules']['version_sync']}
29+
(self.root / '.github/release-metadata-policy.json').write_text(json.dumps(self.policy))
30+
spec = importlib.util.spec_from_file_location('validator_fixture', self.root / VALIDATOR)
31+
self.validator = importlib.util.module_from_spec(spec)
32+
spec.loader.exec_module(self.validator)
33+
self.version = self.validator.extract_cargo_version('Cargo.toml')
34+
35+
def validate(self, tag=None, card=None):
36+
command = ['python3', str(self.root / VALIDATOR), '--mode', 'release_tag',
37+
'--tag', 'v' + (tag or self.version)]
38+
if card:
39+
command += ['--agent-card', str(card)]
40+
return subprocess.run(command, cwd=self.root, capture_output=True, text=True)
41+
42+
def test_current_source_and_staged_asset_match_tag(self):
43+
staged = self.root / 'release-files/agent.json'
44+
staged.parent.mkdir()
45+
shutil.copyfile(self.root / CARD, staged)
46+
result = self.validate(card=staged)
47+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
48+
49+
def test_stale_source_and_staged_asset_are_blocking(self):
50+
for staged in [False, True]:
51+
with self.subTest(staged=staged):
52+
(self.root / CARD).write_text((ROOT / CARD).read_text())
53+
target = self.root / ('staged-agent.json' if staged else CARD)
54+
card = json.loads((ROOT / CARD).read_text())
55+
card['version'] = '0.10.0'
56+
target.write_text(json.dumps(card))
57+
result = self.validate(card=target if staged else None)
58+
self.assertEqual(result.returncode, 1)
59+
self.assertIn('Agent card version', result.stdout)
60+
61+
def test_card_only_pr_change_runs_blocking_rule(self):
62+
rule = self.policy['rules']['version_sync']
63+
self.assertTrue(self.validator.should_run_rule(
64+
rule, 'pull_request', [str(CARD)], self.policy))
65+
self.assertEqual(rule['level'], 'blocking')
66+
67+
def test_bump_preserves_every_other_card_byte_and_checks_new_tag(self):
68+
before = (self.root / CARD).read_text()
69+
result = subprocess.run(['bash', 'scripts/bump-version.sh', '9.8.7'],
70+
cwd=self.root, capture_output=True, text=True)
71+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
72+
self.assertEqual((self.root / CARD).read_text(), before.replace(
73+
'"version": "' + self.version + '"', '"version": "9.8.7"', 1))
74+
self.assertEqual(self.validate(tag='9.8.7').returncode, 0)
75+
self.assertEqual(self.validate(tag='9.8.6').returncode, 1)
76+
77+
def test_bump_accepts_reformatted_json_and_preserves_other_bytes(self):
78+
card = json.loads((ROOT / CARD).read_text())
79+
# Put another version before the top-level field to catch accidental
80+
# first-match replacement in nested objects.
81+
card = {'metadata': {'version': 'keep-me'}, **card}
82+
for indent, newline in [(None, '\n'), (4, '\n'), ('\t', '\n'), (2, '\r\n')]:
83+
with self.subTest(indent=indent, newline=newline):
84+
before = json.dumps(card, indent=indent).replace('\n', newline)
85+
(self.root / CARD).write_bytes(before.encode('utf-8'))
86+
result = subprocess.run(['bash', 'scripts/bump-version.sh', '9.8.7'],
87+
cwd=self.root, capture_output=True, text=True)
88+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
89+
self.assertEqual((self.root / CARD).read_bytes().decode('utf-8'), before.replace(
90+
'"version": "' + self.version + '"', '"version": "9.8.7"', 1))
91+
self.assertEqual(self.validate(tag='9.8.7').returncode, 0)
92+
93+
def test_invalid_card_leaves_every_version_file_unchanged(self):
94+
valid = json.loads((ROOT / CARD).read_text())
95+
missing_version = dict(valid)
96+
del missing_version['version']
97+
for card_text in ['{broken json', json.dumps(missing_version),
98+
json.dumps({**valid, 'version': 17}),
99+
'{"version": "0.1.0", "version": "0.2.0"}']:
100+
with self.subTest(card_text=card_text):
101+
for name in ['Cargo.toml', 'SKILL.md']:
102+
shutil.copyfile(ROOT / name, self.root / name)
103+
(self.root / CARD).write_text(card_text)
104+
paths = [Path('Cargo.toml'), Path('SKILL.md'), CARD]
105+
before = {path: (self.root / path).read_bytes() for path in paths}
106+
result = subprocess.run(['bash', 'scripts/bump-version.sh', '9.8.7'],
107+
cwd=self.root, capture_output=True, text=True)
108+
self.assertNotEqual(result.returncode, 0)
109+
self.assertEqual({path: (self.root / path).read_bytes() for path in paths},
110+
before, 'validation failure must not partially bump versions')
111+
112+
def test_unmatched_version_file_leaves_inputs_unchanged(self):
113+
for broken in ['Cargo.toml', 'SKILL.md']:
114+
with self.subTest(broken=broken):
115+
for name in ['Cargo.toml', 'SKILL.md']:
116+
shutil.copyfile(ROOT / name, self.root / name)
117+
(self.root / broken).write_text('no version field here\n')
118+
paths = [Path('Cargo.toml'), Path('SKILL.md'), CARD]
119+
before = {path: (self.root / path).read_bytes() for path in paths}
120+
result = subprocess.run(['bash', 'scripts/bump-version.sh', '9.8.7'],
121+
cwd=self.root, capture_output=True, text=True)
122+
self.assertNotEqual(result.returncode, 0)
123+
self.assertEqual({path: (self.root / path).read_bytes() for path in paths},
124+
before)
125+
126+
127+
if __name__ == '__main__':
128+
unittest.main()

.github/scripts/validate_release_metadata.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def parse_args():
7474
default=str(DEFAULT_POLICY_PATH),
7575
help="Path to the JSON policy file",
7676
)
77+
parser.add_argument(
78+
"--agent-card",
79+
help="Validate a staged release card instead of the source card",
80+
)
7781
return parser.parse_args()
7882

7983

@@ -344,13 +348,23 @@ def extract_release_windows_bins(path_str):
344348
return bins
345349

346350

347-
def validate_version_sync(rule, state, tag_version=None):
348-
skill_path, cargo_path = rule["inputs"]
351+
def validate_version_sync(rule, state, tag_version=None, agent_card=None):
352+
skill_path, cargo_path, card_path = rule["inputs"]
353+
card_path = agent_card or card_path
354+
card_version = json.loads(read_text(card_path)).get("version")
349355
skill_version = extract_skill_version(skill_path)
350356
cargo_version = extract_cargo_version(cargo_path)
351357

352358
fix_hint = "run `just bump-version <X.Y.Z>` to sync every version-bearing file"
353359

360+
if card_version != cargo_version:
361+
state.add(
362+
rule["level"],
363+
f"Agent card version {card_version!r} does not match Cargo.toml version "
364+
f"{cargo_version}{fix_hint}",
365+
card_path,
366+
)
367+
354368
if skill_version != cargo_version:
355369
state.add(
356370
rule["level"],
@@ -457,7 +471,9 @@ def main():
457471
print(f"Running {rule_name} ({rule['kind']})")
458472

459473
if rule["kind"] == "version_sync":
460-
validate_version_sync(rule, state, tag_version=tag_version)
474+
validate_version_sync(
475+
rule, state, tag_version=tag_version, agent_card=args.agent_card
476+
)
461477
elif rule["kind"] == "openclaw_bins":
462478
validate_openclaw_bins(rule, state)
463479
elif rule["kind"] == "current_release_docs":

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ jobs:
1919
with:
2020
fetch-depth: 0
2121

22+
- name: Test release metadata validation
23+
run: python3 .github/scripts/test_release_metadata.py
24+
2225
- name: Validate release metadata (pull request)
2326
if: github.event_name == 'pull_request'
2427
run: |

.github/workflows/release.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
with:
2929
fetch-depth: 0
3030

31+
- name: Test release metadata validation
32+
run: python3 .github/scripts/test_release_metadata.py
33+
3134
- name: Validate release metadata (tag)
3235
if: startsWith(github.ref, 'refs/tags/v')
3336
run: |
@@ -493,7 +496,9 @@ jobs:
493496
cp SKILL.md release-files/
494497
cp SKILL.md.sig release-files/
495498
cp SAORSA_PUBLIC_KEY.asc release-files/
496-
cp .well-known/agent.json release-files/ 2>/dev/null || true
499+
cp .well-known/agent.json release-files/
500+
python3 .github/scripts/validate_release_metadata.py \
501+
--mode push_main --agent-card release-files/agent.json
497502
ls -la release-files/
498503
499504
- name: Create GitHub Release

.well-known/agent.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://a2a.foundation/schemas/agent-card.json",
33
"name": "x0x",
4-
"version": "0.10.0",
4+
"version": "0.41.3",
55
"description": "Secure computer-to-computer networking for AI agents — gossip broadcast, direct messaging, CRDTs, group encryption. No servers, no intermediaries, no controllers. Post-quantum encrypted, NAT-traversing.",
66
"homepage": "https://saorsalabs.com",
77
"repository": "https://github.com/saorsa-labs/x0x",

scripts/bump-version.sh

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
# Bump the project version in EVERY file the release `version_sync` gate
44
# checks, in one shot, so a release can never fail because one copy was
55
# forgotten (the recurring SKILL.md drift that broke the 0.22.1 and 0.23.0
6-
# tags). The version lives in two hand-maintained places — Cargo.toml's
7-
# [package] version and SKILL.md's frontmatterand they MUST agree with the
8-
# release tag. Always bump via this script (or `just bump-version`) instead of
9-
# hand-editing either file.
6+
# tags). The version lives in three hand-maintained places — Cargo.toml's
7+
# [package] version, SKILL.md's frontmatter, and the static agent card — all
8+
# MUST agree with the release tag. Always bump via this script (or `just bump-version`) instead of
9+
# hand-editing these files.
1010
#
1111
# Usage: scripts/bump-version.sh <X.Y.Z>
1212
#
@@ -24,17 +24,75 @@ fi
2424
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
2525
cd "$ROOT"
2626

27-
# Cargo.toml: the [package] version is the first line-anchored `version = "..."`.
28-
perl -i -pe 'if (!$done && /^version = "/) { s/^version = ".*"/version = "'"$VERSION"'"/; $done = 1 }' Cargo.toml
27+
# Precompute and validate every replacement before writing any version file.
28+
# This prevents invalid input from leaving a partial bump; it is not a
29+
# multi-file transaction against write failures or process interruption.
30+
python3 - "$VERSION" <<'PYTHON'
31+
import json
32+
import re
33+
import sys
34+
from pathlib import Path
2935
30-
# SKILL.md frontmatter: the first `version: ...` line.
31-
perl -i -pe 'if (!$done && /^version:\s/) { s/^version:.*/version: '"$VERSION"'/; $done = 1 }' SKILL.md
36+
version = sys.argv[1]
37+
path = Path(".well-known/agent.json")
38+
text = path.read_bytes().decode("utf-8")
39+
card = json.loads(text)
40+
if not isinstance(card, dict) or not isinstance(card.get("version"), str):
41+
raise SystemExit("Agent card must have a top-level string version")
42+
43+
# Walk only the top-level object with the JSON decoder, so whitespace and
44+
# nested version fields do not matter. Replace the value's exact source span
45+
# to preserve every unrelated byte, including the card's existing formatting.
46+
decoder = json.JSONDecoder()
47+
48+
def skip_space(pos):
49+
while pos < len(text) and text[pos] in " \t\r\n":
50+
pos += 1
51+
return pos
52+
53+
pos = skip_space(0) + 1 # validated top-level opening brace
54+
spans = []
55+
while text[skip_space(pos)] != "}":
56+
key, pos = decoder.raw_decode(text, skip_space(pos))
57+
start = skip_space(skip_space(pos) + 1) # validated colon
58+
_, end = decoder.raw_decode(text, start)
59+
if key == "version":
60+
spans.append((start, end))
61+
pos = skip_space(end)
62+
if text[pos] == "}":
63+
break
64+
pos += 1 # validated comma
65+
if len(spans) != 1:
66+
raise SystemExit("Agent card must have exactly one top-level version")
67+
start, end = spans[0]
68+
updated = text[:start] + json.dumps(version) + text[end:]
69+
card["version"] = version
70+
if json.loads(updated) != card:
71+
raise SystemExit("Cannot safely update agent card top-level version")
72+
73+
replacements = {path: updated}
74+
for filename, pattern, replacement in [
75+
("Cargo.toml", r'(?m)^version = "[^"\n]*"', f'version = "{version}"'),
76+
("SKILL.md", r'(?m)^version:\s[^\n]*', f'version: {version}'),
77+
]:
78+
path = Path(filename)
79+
updated, count = re.subn(
80+
pattern, lambda _: replacement, path.read_bytes().decode("utf-8"), count=1
81+
)
82+
if count != 1:
83+
raise SystemExit(f"Cannot safely update version in {filename}")
84+
replacements[path] = updated
85+
86+
for path, updated in replacements.items():
87+
path.write_bytes(updated.encode("utf-8"))
88+
PYTHON
3289

3390
echo "Bumped to $VERSION:"
3491
grep -m1 '^version = ' Cargo.toml | sed 's/^/ Cargo.toml /'
3592
grep -m1 '^version:' SKILL.md | sed 's/^/ SKILL.md /'
93+
python3 -c 'import json; print(" agent.json version:", json.load(open(".well-known/agent.json"))["version"])'
3694

37-
# Prove the two are now in sync (and consistent with a vX.Y.Z tag) using the
95+
# Prove all three are now in sync (and consistent with a vX.Y.Z tag) using the
3896
# same validator the release workflow runs — fail loudly if anything drifted.
3997
if [[ -f .github/scripts/validate_release_metadata.py ]]; then
4098
echo

0 commit comments

Comments
 (0)