From a6ac2363b05ec7a10f86f2cdbdf67e839792538a Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Mon, 22 Jun 2026 13:49:01 -0700 Subject: [PATCH] feat(agent-email): single-source version stamping + publish-time --check (bump 0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email package version lived in eight files of six types (Python, YAML, TOML, JSON, Markdown, HTML) with no sync tool, so references drifted: on main binaries.lock.json still pointed agentVersion + baseUrl at email/0.1.0 while every other file had already moved to 0.2.0 — a stale static reference to a prior hub deployment that nothing caught. Make AGENT_VERSION in gaia_agent_email/version.py the one source of truth and add packaging/stamp_version.py to stamp every downstream reference from it (mirrors installer/version/bump-ui-version.mjs). Default mode stamps; --check verifies and exits non-zero on any drift. Wired --check into the release job (before publish) and the unit-test PR workflow (early drift detection). Targets absent on main (npm README image URL, assets/architecture.html — they land on other in-flight branches) are skipped-with-warning so the script works across that partial state and stamps them once those branches merge. API_VERSION (the REST/contract version) is deliberately untouched — it is the contract version, independent of the package build version. --- .github/workflows/release_agent_email.yml | 10 + .github/workflows/test_email_agent_unit.yml | 9 + hub/agents/npm/agent-email/binaries.lock.json | 4 +- .../python/email/packaging/stamp_version.py | 250 ++++++++++++++++++ .../python/email/tests/test_stamp_version.py | 206 +++++++++++++++ 5 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 hub/agents/python/email/packaging/stamp_version.py create mode 100644 hub/agents/python/email/tests/test_stamp_version.py diff --git a/.github/workflows/release_agent_email.yml b/.github/workflows/release_agent_email.yml index e247dd826..3ed439e10 100644 --- a/.github/workflows/release_agent_email.yml +++ b/.github/workflows/release_agent_email.yml @@ -297,6 +297,16 @@ jobs: with: python-version: '3.12' + # Single-source version gate: every committed version reference (manifest, + # pyproject, package.json, the lock's agentVersion/baseUrl, README image + # URLs, the architecture.html badge) must match AGENT_VERSION before we + # publish. Stdlib-only; fails loudly on any drift so a release can't ship a + # stale static reference (e.g. a lock baseUrl still pointing at the prior + # version's hub directory). Mirrors the UI's bump-ui-version.mjs --check gate. + - name: Verify version references are in sync (single source of truth) + shell: bash + run: python hub/agents/python/email/packaging/stamp_version.py --check + - name: Set up Node uses: actions/setup-node@v6 with: diff --git a/.github/workflows/test_email_agent_unit.yml b/.github/workflows/test_email_agent_unit.yml index fe6bf54a2..4465aa5f2 100644 --- a/.github/workflows/test_email_agent_unit.yml +++ b/.github/workflows/test_email_agent_unit.yml @@ -14,6 +14,7 @@ on: branches: [ main ] paths: - 'hub/agents/python/email/**' + - 'hub/agents/npm/agent-email/**' - 'src/gaia/agents/base/**' - 'src/gaia/connectors/**' - 'src/gaia/eval/**' @@ -30,6 +31,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'hub/agents/python/email/**' + - 'hub/agents/npm/agent-email/**' - 'src/gaia/agents/base/**' - 'src/gaia/connectors/**' - 'src/gaia/eval/**' @@ -73,6 +75,13 @@ jobs: - name: Install uv run: curl -LsSf https://astral.sh/uv/install.sh | sh + # Catch version drift on the PR, long before release. AGENT_VERSION in + # version.py is the one source of truth; this fails loudly if any committed + # reference (manifest/pyproject/package.json/lock baseUrl+agentVersion/README + # images/architecture.html badge) disagrees. Stdlib-only — no install needed. + - name: Verify version references are in sync (single source of truth) + run: python hub/agents/python/email/packaging/stamp_version.py --check + - name: Install dependencies run: | # [dev] covers the email + eval unit tests (pytest, pytest-mock, diff --git a/hub/agents/npm/agent-email/binaries.lock.json b/hub/agents/npm/agent-email/binaries.lock.json index 355907cbe..380ff422c 100644 --- a/hub/agents/npm/agent-email/binaries.lock.json +++ b/hub/agents/npm/agent-email/binaries.lock.json @@ -1,7 +1,7 @@ { "schemaVersion": "1.0", - "agentVersion": "0.1.0", - "baseUrl": "https://hub.amd-gaia.ai/agents/email/0.1.0", + "agentVersion": "0.2.0", + "baseUrl": "https://hub.amd-gaia.ai/agents/email/0.2.0", "binaries": { "win32-x64": { "filename": "email-agent-win32-x64.exe", diff --git a/hub/agents/python/email/packaging/stamp_version.py b/hub/agents/python/email/packaging/stamp_version.py new file mode 100644 index 000000000..54d7a1ac7 --- /dev/null +++ b/hub/agents/python/email/packaging/stamp_version.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Single-source version stamping for the @amd-gaia/agent-email package. + +The package version lives in many files of different types (Python, YAML, TOML, +JSON, Markdown, HTML) with no sync tool, so references drift — a README image URL +or a lock ``baseUrl`` can statically point at a stale deployment long after the +package itself moved on. This script makes ``AGENT_VERSION`` in +``gaia_agent_email/version.py`` the ONE source of truth and stamps every other +file from it (mirrors ``installer/version/bump-ui-version.mjs`` for the Agent UI). + +Usage: + python hub/agents/python/email/packaging/stamp_version.py + # read AGENT_VERSION from version.py and stamp every present target to match + + python hub/agents/python/email/packaging/stamp_version.py --check + # verify every present target matches AGENT_VERSION; print each mismatch and + # exit non-zero (the CI / publish-time gate). Mirrors bump-ui-version.mjs --check. + +Targets that are absent (file missing, or the version field/URL not found) are +SKIPPED WITH A WARNING, never failed — some targets (npm README image URL, +assets/architecture.html) live on other in-flight branches and aren't on main +yet, so the script must work across that partial state and stamp them correctly +once those branches merge. + +``API_VERSION`` (the REST/contract version, == contract SCHEMA_VERSION) in +version.py is intentionally NOT touched — it is the contract version, independent +of the package build version. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# packaging/ -> email/ : the email package root holds every Python-side target; +# the npm-side targets are reached relative to the repo root (four levels up). +EMAIL_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = ( + EMAIL_ROOT.parent.parent.parent.parent +) # hub/agents/python/email -> repo root +NPM_ROOT = REPO_ROOT / "hub" / "agents" / "npm" / "agent-email" + +VERSION_PY = EMAIL_ROOT / "gaia_agent_email" / "version.py" + +_AGENT_VERSION_RE = re.compile(r'AGENT_VERSION\s*=\s*"([^"]+)"') +_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+") + + +@dataclass +class Rule: + """One (file, regex) version reference to stamp/verify. + + Each pattern must capture exactly three groups: (prefix, version, suffix). + The version is group 2; prefix/suffix are written back verbatim so unrelated + formatting never churns. + """ + + label: str + path: Path + pattern: re.Pattern + # Human-readable name of the field this rule targets (for warnings). + field: str + + +@dataclass +class Result: + stamped: list[str] = field(default_factory=list) + already_ok: list[str] = field(default_factory=list) + skipped: list[str] = field(default_factory=list) + mismatches: list[str] = field(default_factory=list) + + +def read_agent_version() -> str: + if not VERSION_PY.exists(): + sys.exit(f"ERROR: source of truth not found: {VERSION_PY}") + m = _AGENT_VERSION_RE.search(VERSION_PY.read_text(encoding="utf-8")) + if not m: + sys.exit(f"ERROR: could not parse AGENT_VERSION from {VERSION_PY}") + version = m.group(1) + if not _SEMVER_RE.match(version): + sys.exit(f"ERROR: AGENT_VERSION '{version}' is not a valid x.y.z version") + return version + + +def build_rules() -> list[Rule]: + """Every version reference downstream of AGENT_VERSION. + + Patterns intentionally match only the version token (group 2), leaving every + surrounding byte untouched so diffs stay minimal and JSON/TOML formatting is + never reserialized. + """ + return [ + # gaia-agent.yaml: top-level unquoted `version: ` (NOT min_gaia_version). + Rule( + "gaia-agent.yaml", + EMAIL_ROOT / "gaia-agent.yaml", + re.compile(r"(?m)^(version:[ \t]*)(\S+)([ \t]*)$"), + "version", + ), + # pyproject.toml: the [project] `version = ""` (only top-level match). + Rule( + "pyproject.toml", + EMAIL_ROOT / "pyproject.toml", + re.compile(r'(?m)^(version\s*=\s*")([^"]+)(")'), + "version", + ), + # npm package.json: the package's own top-level `"version": ""`. + Rule( + "npm package.json", + NPM_ROOT / "package.json", + re.compile(r'(?m)^( "version":\s*")([^"]+)(")'), + "version", + ), + # binaries.lock.json: agentVersion field. + Rule( + "binaries.lock.json (agentVersion)", + NPM_ROOT / "binaries.lock.json", + re.compile(r'("agentVersion":\s*")([^"]+)(")'), + "agentVersion", + ), + # binaries.lock.json: baseUrl trailing version segment + # (.../agents/email/). gen_binaries_lock.py derives both from --version. + Rule( + "binaries.lock.json (baseUrl)", + NPM_ROOT / "binaries.lock.json", + re.compile(r'("baseUrl":\s*"https?://[^"]*?/agents/email/)([^"/]+)(/?")'), + "baseUrl", + ), + # python README image: .../agents/email//playground.webp + Rule( + "python README image", + EMAIL_ROOT / "README.md", + re.compile(r"(/agents/email/)([^\"/\s)]+)(/playground\.webp)"), + "playground.webp image URL", + ), + # npm README image: .../agents/email//architecture.webp + Rule( + "npm README image", + NPM_ROOT / "README.md", + re.compile(r"(/agents/email/)([^\"/\s)]+)(/architecture\.webp)"), + "architecture.webp image URL", + ), + # npm assets/architecture.html: v badge. + Rule( + "npm architecture.html badge", + NPM_ROOT / "assets" / "architecture.html", + re.compile(r'(]*id="ver"[^>]*>v)([^<]+)()'), + 'id="ver" badge', + ), + ] + + +def process(version: str, check_only: bool) -> Result: + result = Result() + for rule in build_rules(): + if not rule.path.exists(): + result.skipped.append(f"{rule.label}: file absent ({_rel(rule.path)})") + continue + text = rule.path.read_text(encoding="utf-8") + matches = list(rule.pattern.finditer(text)) + if not matches: + result.skipped.append( + f"{rule.label}: {rule.field} not found in {_rel(rule.path)}" + ) + continue + + current_values = {m.group(2) for m in matches} + if check_only: + bad = sorted(v for v in current_values if v != version) + if bad: + result.mismatches.append( + f"{rule.label}: {rule.field} = {', '.join(bad)} " + f"-- expected {version} ({_rel(rule.path)})" + ) + else: + result.already_ok.append(rule.label) + continue + + # Stamp mode: rewrite every match's version token to `version`. + if current_values == {version}: + result.already_ok.append(rule.label) + continue + new_text = rule.pattern.sub( + lambda m: f"{m.group(1)}{version}{m.group(3)}", text + ) + rule.path.write_text(new_text, encoding="utf-8") + old = ", ".join(sorted(current_values)) + result.stamped.append(f"{rule.label}: {old} -> {version}") + return result + + +def _rel(path: Path) -> str: + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="Stamp/verify the agent-email package version from version.py." + ) + parser.add_argument( + "--check", + action="store_true", + help="Verify every present target matches AGENT_VERSION; exit non-zero on " + "any mismatch (CI / publish gate). Does not modify files.", + ) + args = parser.parse_args(argv) + + version = read_agent_version() + print(f"AGENT_VERSION (source of truth): {version}\n") + + result = process(version, check_only=args.check) + + for line in result.skipped: + print(f" SKIP {line}") + + if args.check: + for label in result.already_ok: + print(f" OK {label}") + if result.mismatches: + print() + for line in result.mismatches: + print(f" FAIL {line}") + print( + "\nVersion drift detected. Run " + "`python hub/agents/python/email/packaging/stamp_version.py` " + "to sync every target to AGENT_VERSION." + ) + return 1 + print(f"\nAll present targets match AGENT_VERSION ({version}).") + return 0 + + for line in result.stamped: + print(f" STAMP {line}") + for label in result.already_ok: + print(f" OK {label} (already {version})") + print(f"\nDone. {len(result.stamped)} file(s) stamped to v{version}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/hub/agents/python/email/tests/test_stamp_version.py b/hub/agents/python/email/tests/test_stamp_version.py new file mode 100644 index 000000000..e67dc1155 --- /dev/null +++ b/hub/agents/python/email/tests/test_stamp_version.py @@ -0,0 +1,206 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Unit tests for the single-source version stamper (packaging/stamp_version.py). + +Guards the contract that ``AGENT_VERSION`` in ``version.py`` is the one source of +truth: ``--check`` passes on a consistent tree, fails loudly (non-zero) when any +target drifts, the default mode stamps every present target to match, and absent +targets (files/fields that live on other in-flight branches) are skipped with a +warning rather than failing. Fully hermetic — operates on a synthesized temp tree, +no network, no dependence on the real repo's current versions. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +# stamp_version.py is a packaging script, not part of the gaia_agent_email +# package — load it by path (mirrors test_gen_binaries_lock.py). Register it in +# sys.modules before exec so its @dataclass definitions can resolve their module. +_STAMP_PATH = Path(__file__).resolve().parents[1] / "packaging" / "stamp_version.py" +_spec = importlib.util.spec_from_file_location("stamp_version", _STAMP_PATH) +stamp = importlib.util.module_from_spec(_spec) +sys.modules["stamp_version"] = stamp +_spec.loader.exec_module(stamp) + + +def _build_tree(root: Path, version: str, *, include_optional: bool = True) -> None: + """Synthesize a complete agent-email tree, every target at ``version``.""" + email = root / "hub" / "agents" / "python" / "email" + npm = root / "hub" / "agents" / "npm" / "agent-email" + (email / "gaia_agent_email").mkdir(parents=True) + npm.mkdir(parents=True) + + (email / "gaia_agent_email" / "version.py").write_text( + f'AGENT_VERSION = "{version}"\nAPI_VERSION = "9.9.9"\n', encoding="utf-8" + ) + (email / "gaia-agent.yaml").write_text( + f'id: email\nversion: {version}\nmin_gaia_version: "0.20.0"\n', + encoding="utf-8", + ) + (email / "pyproject.toml").write_text( + f'[project]\nname = "gaia-agent-email"\nversion = "{version}"\n', + encoding="utf-8", + ) + (npm / "package.json").write_text( + json.dumps( + {"name": "@amd-gaia/agent-email", "version": version, "type": "module"}, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (npm / "binaries.lock.json").write_text( + json.dumps( + { + "schemaVersion": "1.0", + "agentVersion": version, + "baseUrl": f"https://hub.amd-gaia.ai/agents/email/{version}", + "binaries": {}, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + if include_optional: + (email / "README.md").write_text( + f"![play](https://hub.amd-gaia.ai/agents/email/{version}/playground.webp)\n", + encoding="utf-8", + ) + (npm / "README.md").write_text( + f"![arch](https://hub.amd-gaia.ai/agents/email/{version}/architecture.webp)\n", + encoding="utf-8", + ) + (npm / "assets").mkdir() + (npm / "assets" / "architecture.html").write_text( + f'v{version}\n', encoding="utf-8" + ) + else: + # Mirror real `main`: the README files EXIST but carry no versioned image + # URL yet (field-absent skip), while architecture.html is entirely missing + # (file-absent skip). Both must skip-with-warning, never fail the gate. + (email / "README.md").write_text("# Email agent\nno image yet\n", "utf-8") + (npm / "README.md").write_text("# agent-email\nno image yet\n", "utf-8") + + +@pytest.fixture +def tree(tmp_path, monkeypatch): + """A consistent v1.2.3 tree with the module's path constants pointed at it.""" + _build_tree(tmp_path, "1.2.3") + email = tmp_path / "hub" / "agents" / "python" / "email" + npm = tmp_path / "hub" / "agents" / "npm" / "agent-email" + monkeypatch.setattr(stamp, "REPO_ROOT", tmp_path) + monkeypatch.setattr(stamp, "EMAIL_ROOT", email) + monkeypatch.setattr(stamp, "NPM_ROOT", npm) + monkeypatch.setattr(stamp, "VERSION_PY", email / "gaia_agent_email" / "version.py") + return tmp_path + + +def test_reads_agent_version_as_source(tree): + assert stamp.read_agent_version() == "1.2.3" + + +def test_api_version_is_not_touched(tree): + """API_VERSION (the contract version) must never be stamped as the pkg version.""" + result = stamp.process("1.2.3", check_only=False) + text = (tree / "hub/agents/python/email/gaia_agent_email/version.py").read_text() + assert 'API_VERSION = "9.9.9"' in text + # version.py is the SOURCE, not a stampable target — it shouldn't appear. + assert not any("version.py" in s for s in result.stamped) + + +def test_check_passes_on_consistent_tree(tree): + assert stamp.main(["--check"]) == 0 + result = stamp.process("1.2.3", check_only=True) + assert result.mismatches == [] + # every required target present + consistent + assert {"gaia-agent.yaml", "pyproject.toml", "npm package.json"} <= set( + result.already_ok + ) + + +@pytest.mark.parametrize( + "rel,marker,bad", + [ + ("hub/agents/python/email/gaia-agent.yaml", "version: 1.2.3", "version: 9.9.9"), + ( + "hub/agents/python/email/pyproject.toml", + 'version = "1.2.3"', + 'version = "9.9.9"', + ), + ( + "hub/agents/npm/agent-email/binaries.lock.json", + "/agents/email/1.2.3", + "/agents/email/9.9.9", + ), + ( + "hub/agents/npm/agent-email/assets/architecture.html", + 'id="ver">v1.2.3', + 'id="ver">v9.9.9', + ), + ], +) +def test_check_fails_when_a_target_drifts(tree, rel, marker, bad): + target = tree / rel + target.write_text(target.read_text().replace(marker, bad), encoding="utf-8") + assert stamp.main(["--check"]) == 1 + result = stamp.process("1.2.3", check_only=True) + assert any("9.9.9" in m for m in result.mismatches) + + +def test_stamp_syncs_every_target(tree): + # Start from an all-0.9.0 tree, then bump the source to 2.0.0 and stamp. + for f in tree.rglob("*"): + if f.is_file(): + f.write_text( + f.read_text(encoding="utf-8").replace("1.2.3", "0.9.0"), + encoding="utf-8", + ) + # Source of truth now says 2.0.0; every downstream target still says 0.9.0. + vpy = tree / "hub/agents/python/email/gaia_agent_email/version.py" + vpy.write_text('AGENT_VERSION = "2.0.0"\nAPI_VERSION = "9.9.9"\n', encoding="utf-8") + + assert stamp.read_agent_version() == "2.0.0" + assert stamp.main([]) == 0 # stamp mode + # Every present target now matches; --check is green. + assert stamp.main(["--check"]) == 0 + lock = json.loads( + (tree / "hub/agents/npm/agent-email/binaries.lock.json").read_text() + ) + assert lock["agentVersion"] == "2.0.0" + assert lock["baseUrl"].endswith("/agents/email/2.0.0") + + +def test_absent_targets_skipped_with_warning_not_failed(tmp_path, monkeypatch): + # Tree WITHOUT the optional npm-side targets (README images, architecture.html) + # — they live on other in-flight branches and aren't on main yet. + _build_tree(tmp_path, "1.2.3", include_optional=False) + email = tmp_path / "hub" / "agents" / "python" / "email" + npm = tmp_path / "hub" / "agents" / "npm" / "agent-email" + monkeypatch.setattr(stamp, "REPO_ROOT", tmp_path) + monkeypatch.setattr(stamp, "EMAIL_ROOT", email) + monkeypatch.setattr(stamp, "NPM_ROOT", npm) + monkeypatch.setattr(stamp, "VERSION_PY", email / "gaia_agent_email" / "version.py") + + result = stamp.process("1.2.3", check_only=True) + assert result.mismatches == [] # absent != mismatch + skipped = "\n".join(result.skipped) + assert "architecture.html" in skipped # file-absent skip + assert "playground.webp" in skipped # field-absent skip (README present) + assert "architecture.webp" in skipped # field-absent skip (README present) + # An absent optional target must NOT fail the gate. + assert stamp.main(["--check"]) == 0 + + +def test_stamp_is_idempotent_and_minimal(tree): + lock_path = tree / "hub/agents/npm/agent-email/binaries.lock.json" + before = lock_path.read_text(encoding="utf-8") + result = stamp.process("1.2.3", check_only=False) + assert result.stamped == [] # already consistent — nothing rewritten + assert lock_path.read_text(encoding="utf-8") == before # byte-identical