diff --git a/.github/downstream/bump-agent-core.yml b/.github/downstream/bump-agent-core.yml index 1103636..4e548b4 100644 --- a/.github/downstream/bump-agent-core.yml +++ b/.github/downstream/bump-agent-core.yml @@ -41,16 +41,17 @@ jobs: python-version: "3.12" - name: Resolve target version + env: + TARGET_VERSION: ${{ github.event.client_payload.version || inputs.version }} run: | set -euo pipefail - version="${{ github.event.client_payload.version || inputs.version }}" - case "$version" in - v[0-9]*) ;; - *) echo "Refusing to pin '$version': expected a v-prefixed release tag." >&2 - exit 1 ;; - esac - echo "VERSION=$version" >> "$GITHUB_ENV" - echo "BRANCH=chore/agent-core-$version" >> "$GITHUB_ENV" + # Keep event data out of shell source: workflow expressions are + # expanded before bash parses the script, so direct interpolation + # would make a crafted manual-dispatch input executable. + python3 .github/workflows/repin_agent_core.py \ + --validate-only "$TARGET_VERSION" + printf 'VERSION=%s\n' "$TARGET_VERSION" >> "$GITHUB_ENV" + printf 'BRANCH=chore/agent-core-%s\n' "$TARGET_VERSION" >> "$GITHUB_ENV" # The dependency is declared as ssh://git@github.com/...; rewrite that # exact scheme so uv resolves it over HTTPS with a token instead of diff --git a/.github/downstream/repin_agent_core.py b/.github/downstream/repin_agent_core.py index 0c3845d..55de719 100644 --- a/.github/downstream/repin_agent_core.py +++ b/.github/downstream/repin_agent_core.py @@ -19,6 +19,20 @@ # Anchor on the repository path so the '@' inside 'git@github.com' is never # mistaken for the rev separator. PIN = re.compile(r'(AgentCore\.git@)[^"\'\s]+') +# AgentCore follows a three-part release scheme and may publish standard PEP 440 +# prereleases, post releases, dev releases, or local versions. Keeping this an +# allow-list also makes the value safe for GITHUB_ENV and git branch names. +VERSION = re.compile( + r"v[0-9]+\.[0-9]+\.[0-9]+" + r"(?:(?:a|b|rc)[0-9]+)?" + r"(?:\.post[0-9]+)?" + r"(?:\.dev[0-9]+)?" + r"(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?" +) + + +def is_valid_version(version: str) -> bool: + return VERSION.fullmatch(version) is not None def repin(text: str, version: str) -> tuple[str, int]: @@ -29,8 +43,25 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("version", help="Release tag to pin, e.g. v0.2.0") parser.add_argument("--file", default="pyproject.toml") + parser.add_argument( + "--validate-only", + action="store_true", + help="Validate the release tag without editing the dependency file.", + ) args = parser.parse_args(argv) + if not is_valid_version(args.version): + print( + f"Refusing to pin {args.version!r}: expected an AgentCore release tag " + "such as v0.2.0 or v0.3.0rc1.", + file=sys.stderr, + ) + return 1 + + if args.validate_only: + print(f"Validated release tag {args.version}.") + return 0 + path = Path(args.file) original = path.read_text(encoding="utf-8") updated, count = repin(original, args.version) diff --git a/tests/test_downstream_repin.py b/tests/test_downstream_repin.py new file mode 100644 index 0000000..22e3db9 --- /dev/null +++ b/tests/test_downstream_repin.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + + +def _load_repin_module() -> ModuleType: + path = Path(__file__).parents[1] / ".github" / "downstream" / "repin_agent_core.py" + spec = importlib.util.spec_from_file_location("repin_agent_core", path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +repin_agent_core = _load_repin_module() + + +@pytest.mark.parametrize( + "version", + [ + "v0.2.0", + "v1.0.0rc1", + "v1.0.0.post1", + "v1.0.0.dev1", + "v1.0.0+linux.x86", + ], +) +def test_accepts_release_tags(version: str) -> None: + assert repin_agent_core.is_valid_version(version) + + +@pytest.mark.parametrize( + "version", + [ + "0.2.0", + "v1", + "v1.2", + "v1.2.3 ", + "v1.2.3/other", + "v1.2.3\nINJECTED=value", + 'v1.2.3"$(echo injected)"', + ], +) +def test_rejects_unsafe_or_malformed_tags(version: str) -> None: + assert not repin_agent_core.is_valid_version(version) + + +def test_validation_fails_before_opening_dependency_file(tmp_path: Path) -> None: + missing = tmp_path / "missing.toml" + + result = repin_agent_core.main( + ["v1.2.3\nINJECTED=value", "--file", str(missing)] + ) + + assert result == 1 + assert not missing.exists() + + +def test_repin_replaces_exactly_one_agent_core_revision() -> None: + original = ( + 'dependencies = [\n' + ' "apodex-agent-core @ ' + 'git+ssh://git@github.com/ApodexAI/AgentCore.git@abc123",\n' + ']\n' + ) + + updated, count = repin_agent_core.repin(original, "v0.2.0") + + assert count == 1 + assert "AgentCore.git@v0.2.0" in updated