initial a2a-pack

This commit is contained in:
robert
2026-05-08 21:59:51 -03:00
commit b6f6cd1643
29 changed files with 3218 additions and 0 deletions

1
a2a_pack/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""a2a CLI package."""

View File

@@ -0,0 +1,98 @@
"""Thin HTTP client for the control plane API."""
from __future__ import annotations
from typing import Any
import httpx
class ApiError(RuntimeError):
def __init__(self, status: int, message: str) -> None:
self.status = status
super().__init__(f"API {status}: {message}")
class ControlPlaneClient:
def __init__(self, api_url: str, token: str | None = None) -> None:
self.api_url = api_url.rstrip("/")
self.token = token
def _headers(self) -> dict[str, str]:
h = {"content-type": "application/json"}
if self.token:
h["authorization"] = f"bearer {self.token}"
return h
def _request(self, method: str, path: str, **kw: Any) -> Any:
url = f"{self.api_url}{path}"
with httpx.Client(timeout=30.0) as c:
resp = c.request(method, url, headers=self._headers(), **kw)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception: # noqa: BLE001
detail = resp.text
raise ApiError(resp.status_code, str(detail))
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def signup(self, email: str, password: str) -> dict[str, Any]:
return self._request("POST", "/v1/auth/signup", json={"email": email, "password": password})
def login(self, email: str, password: str) -> dict[str, Any]:
return self._request("POST", "/v1/auth/login", json={"email": email, "password": password})
def me(self) -> dict[str, Any]:
return self._request("GET", "/v1/me")
def register_agent(
self,
*,
name: str,
description: str,
version: str,
image: str,
public: bool,
card: dict[str, Any],
) -> dict[str, Any]:
return self._request(
"POST",
"/v1/agents",
json={
"name": name,
"description": description,
"version": version,
"image": image,
"public": public,
"card": card,
},
)
def from_source(
self,
*,
name: str,
description: str,
version: str,
public: bool,
) -> dict[str, Any]:
return self._request(
"POST",
"/v1/agents/from-source",
json={
"name": name,
"description": description,
"version": version,
"public": public,
},
)
def list_agents(self) -> list[dict[str, Any]]:
return self._request("GET", "/v1/agents")
def get_agent(self, name: str) -> dict[str, Any]:
return self._request("GET", f"/v1/agents/{name}")
def delete_agent(self, name: str) -> None:
self._request("DELETE", f"/v1/agents/{name}")

View File

@@ -0,0 +1,68 @@
"""Credentials store at ``~/.a2a/credentials.json``."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_API_URL = "http://api.127-0-0-1.nip.io"
def _config_dir() -> Path:
return Path.home() / ".a2a"
def _creds_path() -> Path:
return _config_dir() / "credentials.json"
@dataclass
class Credentials:
api_url: str
token: str
email: str
def save(api_url: str, token: str, email: str) -> Path:
d = _config_dir()
d.mkdir(parents=True, exist_ok=True)
path = _creds_path()
path.write_text(json.dumps({"api_url": api_url, "token": token, "email": email}))
os.chmod(path, 0o600)
return path
def load() -> Credentials | None:
path = _creds_path()
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return None
return Credentials(
api_url=data.get("api_url", DEFAULT_API_URL),
token=data["token"],
email=data.get("email", ""),
)
def clear() -> bool:
path = _creds_path()
if path.exists():
path.unlink()
return True
return False
def resolve_api_url(override: str | None = None) -> str:
if override:
return override
env = os.environ.get("A2A_API_URL")
if env:
return env
creds = load()
if creds is not None:
return creds.api_url
return DEFAULT_API_URL

34
a2a_pack/cli/loader.py Normal file
View File

@@ -0,0 +1,34 @@
"""Load an :class:`A2AAgent` subclass from a string entrypoint."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from ..agent import A2AAgent
def load_agent_class(entrypoint: str, *, project_dir: Path | None = None) -> type[A2AAgent]:
"""Resolve ``module:ClassName`` to an :class:`A2AAgent` subclass.
If ``project_dir`` is given, it is prepended to ``sys.path`` so a local
``agent.py`` can be imported without packaging.
"""
if ":" not in entrypoint:
raise ValueError(
f"entrypoint must be 'module:ClassName' (got {entrypoint!r})"
)
module_name, class_name = entrypoint.split(":", 1)
if project_dir is not None:
path = str(project_dir.resolve())
if path not in sys.path:
sys.path.insert(0, path)
module = importlib.import_module(module_name)
obj = getattr(module, class_name, None)
if obj is None:
raise AttributeError(f"{module_name} has no attribute {class_name!r}")
if not isinstance(obj, type) or not issubclass(obj, A2AAgent):
raise TypeError(f"{entrypoint} is not an A2AAgent subclass")
return obj

430
a2a_pack/cli/main.py Normal file
View File

@@ -0,0 +1,430 @@
"""``a2a`` CLI: scaffold, validate, build, deploy."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from importlib import resources
from pathlib import Path
from typing import Any
import typer
import yaml
from rich.console import Console
from rich.panel import Panel
from . import credentials
from .api_client import ApiError, ControlPlaneClient
from .loader import load_agent_class
from .manifests import INGRESS_HOST_TEMPLATE, NAMESPACE, render_manifests
app = typer.Typer(
add_completion=False,
no_args_is_help=True,
help="Build, package, and deploy A2A agents.",
)
console = Console()
DEFAULT_REGISTRY_HOST = "localhost:30500"
SDK_PYPI_PACKAGE = "a2a_pack"
# --------------------------------------------------------------------------- #
# helpers #
# --------------------------------------------------------------------------- #
def _fail(msg: str, code: int = 1) -> None:
console.print(f"[bold red]error:[/] {msg}")
raise typer.Exit(code)
def _read_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
_fail(f"missing {path.name} (run `a2a init` first)")
return yaml.safe_load(path.read_text()) or {}
def _render_template(template: str, /, **vars: Any) -> str:
text = (
resources.files("a2a_pack.cli.templates")
.joinpath(template)
.read_text(encoding="utf-8")
)
# tiny mustache-style substitution; avoids pulling Jinja just for {{ x }}
for k, v in vars.items():
text = text.replace("{{ " + k + " }}", str(v))
return text
def _slug_to_class(slug: str) -> str:
parts = re.split(r"[-_\s]+", slug.strip())
return "".join(p[:1].upper() + p[1:].lower() for p in parts if p) or "MyAgent"
def _git_short_sha(project: Path) -> str | None:
try:
out = subprocess.run(
["git", "-C", str(project), "rev-parse", "--short=12", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
return out or None
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _sdk_source_dir() -> Path:
"""Locate the on-disk a2a_pack source for inclusion in the build context."""
pkg_dir = Path(__file__).resolve().parents[1] # .../a2a_pack
project_root = pkg_dir.parent # .../apps/a2a
if not (project_root / "pyproject.toml").exists():
_fail(f"could not find a2a-pack source root from {pkg_dir}")
return project_root
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
console.print(f"[dim]$ {' '.join(cmd)}[/]")
return subprocess.run(cmd, check=True, text=True, **kwargs)
# --------------------------------------------------------------------------- #
# auth #
# --------------------------------------------------------------------------- #
def _client(api: str | None = None) -> ControlPlaneClient:
api_url = credentials.resolve_api_url(api)
creds = credentials.load()
token = creds.token if creds is not None else None
return ControlPlaneClient(api_url, token=token)
@app.command()
def signup(
email: str = typer.Option(..., "--email", "-e", prompt=True),
password: str = typer.Option(
..., "--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
),
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
) -> None:
"""Create an account on the control plane and store the JWT locally."""
try:
out = ControlPlaneClient(api).signup(email, password)
except ApiError as exc:
_fail(str(exc))
credentials.save(api, out["access_token"], out["user"]["email"])
console.print(f"[green]signed up[/] as [cyan]{out['user']['email']}[/] @ {api}")
@app.command()
def login(
email: str = typer.Option(..., "--email", "-e", prompt=True),
password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
) -> None:
"""Authenticate with the control plane and cache the JWT."""
try:
out = ControlPlaneClient(api).login(email, password)
except ApiError as exc:
_fail(str(exc))
credentials.save(api, out["access_token"], out["user"]["email"])
console.print(f"[green]logged in[/] as [cyan]{out['user']['email']}[/]")
@app.command()
def logout() -> None:
"""Forget the cached JWT."""
cleared = credentials.clear()
console.print("[green]logged out[/]" if cleared else "(not logged in)")
@app.command()
def whoami() -> None:
"""Show the currently logged-in user."""
creds = credentials.load()
if creds is None:
_fail("not logged in (run `a2a login` or `a2a signup`)")
try:
me = _client().me()
except ApiError as exc:
_fail(str(exc))
console.print(f"[cyan]{me['email']}[/] ({creds.api_url})")
@app.command(name="agents")
def list_agents(
api: str | None = typer.Option(None, "--api"),
) -> None:
"""List agents visible to the current user."""
try:
rows = _client(api).list_agents()
except ApiError as exc:
_fail(str(exc))
if not rows:
console.print("(no agents)")
return
for r in rows:
url = r.get("url") or "-"
console.print(
f" [cyan]{r['name']}[/] v{r['version']} [{r['status']}] {url}"
)
# --------------------------------------------------------------------------- #
# init #
# --------------------------------------------------------------------------- #
@app.command()
def init(
name: str = typer.Argument(..., help="Agent / project slug, e.g. research-agent"),
description: str = typer.Option("A new A2A agent", "--description", "-d"),
target: Path = typer.Option(Path("."), "--target", "-t", help="Parent dir for the new project"),
) -> None:
"""Scaffold a new agent project."""
project = target / name
if project.exists():
_fail(f"{project} already exists")
project.mkdir(parents=True)
class_name = _slug_to_class(name)
files = {
"agent.py": _render_template(
"agent.py.tmpl",
name=name,
class_name=class_name,
description=description,
),
"a2a.yaml": _render_template(
"a2a.yaml.tmpl", name=name, class_name=class_name
),
"requirements.txt": _render_template("requirements.txt.tmpl"),
"Dockerfile": _render_template(
"Dockerfile.tmpl", entrypoint=f"agent:{class_name}"
),
".dockerignore": _render_template("dockerignore.tmpl"),
".gitea/workflows/build.yml": _render_template(
"workflow.yml.tmpl", name=name
),
"deploy/20-deployment.yaml": _render_template(
"deployment.yaml.tmpl", name=name
),
}
for relpath, content in files.items():
target_path = project / relpath
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content)
console.print(f" [green]+[/] {project}/{relpath}")
console.print(
Panel.fit(
f"[bold]{name}[/] scaffolded at [cyan]{project}[/]\n\n"
"next:\n"
f" cd {project}\n"
" a2a card # see what your agent declares\n"
" a2a deploy # ship it",
title="ok",
)
)
# --------------------------------------------------------------------------- #
# validate / card / run #
# --------------------------------------------------------------------------- #
@app.command()
def validate(
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Load the agent and print its Card schema. Exits non-zero on errors."""
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
console.print(f"[green]ok[/] {cls.name} v{cls.version} ({len(cls._skills)} skills)")
@app.command()
def card(
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Print the Agent Card JSON for the project's agent."""
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
console.print_json(cls().card().model_dump_json())
@app.command()
def run(
entrypoint: str = typer.Option(..., "--entrypoint", "-e", help="module:Class"),
host: str = typer.Option("0.0.0.0", "--host"),
port: int = typer.Option(8000, "--port"),
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Run the agent's HTTP server locally (used inside the container too)."""
from ..serve import serve
cls = load_agent_class(entrypoint, project_dir=project)
serve(cls(), host=host, port=port)
# --------------------------------------------------------------------------- #
# build / deploy #
# --------------------------------------------------------------------------- #
def _resolve_image_ref(name: str, version: str, project: Path, registry: str) -> str:
sha = _git_short_sha(project)
tag = f"{version}-{sha}" if sha else version
return f"{registry}/agents/{name}:{tag}"
def _stage_build_context(project: Path, dst: Path) -> None:
"""Copy project files + bundled SDK source into a temp build dir."""
for item in project.iterdir():
if item.name in {".git", ".venv", "__pycache__", "_a2a_sdk"}:
continue
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__"))
else:
shutil.copy2(item, target)
sdk_src = _sdk_source_dir()
sdk_dst = dst / "_a2a_sdk"
shutil.copytree(
sdk_src,
sdk_dst,
ignore=shutil.ignore_patterns(".venv", "dist", "build", "__pycache__", "*.egg-info", ".pytest_cache"),
)
@app.command()
def build(
project: Path = typer.Option(Path("."), "--project", "-p"),
registry: str = typer.Option(DEFAULT_REGISTRY_HOST, "--registry"),
push: bool = typer.Option(False, "--push", help="Also push the built image"),
) -> None:
"""Build (and optionally push) the container image for the agent."""
cfg = _read_yaml(project / "a2a.yaml")
image = _resolve_image_ref(cfg["name"], cfg["version"], project, registry)
with tempfile.TemporaryDirectory(prefix="a2a-build-") as tmp:
ctx = Path(tmp)
_stage_build_context(project, ctx)
_run(["docker", "build", "-t", image, str(ctx)])
console.print(f"[green]built[/] [cyan]{image}[/]")
if push:
_run(["docker", "push", image])
console.print(f"[green]pushed[/] [cyan]{image}[/]")
def _git_push_source(project: Path, push_url: str) -> None:
"""Initialize the project as a git repo (if needed) and push to ``push_url``.
Idempotent: re-running on an existing repo just commits any changes
and pushes.
"""
git_dir = project / ".git"
if not git_dir.exists():
_run(["git", "-C", str(project), "init", "-q", "-b", "main"])
_run(["git", "-C", str(project), "config", "user.email", "agent@a2a.local"])
_run(["git", "-C", str(project), "config", "user.name", "agent"])
# Ensure remote
have_remote = subprocess.run(
["git", "-C", str(project), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
).returncode == 0
if have_remote:
_run(["git", "-C", str(project), "remote", "set-url", "origin", push_url])
else:
_run(["git", "-C", str(project), "remote", "add", "origin", push_url])
_run(["git", "-C", str(project), "add", "-A"])
status = subprocess.run(
["git", "-C", str(project), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
if status:
_run(["git", "-C", str(project), "commit", "-q", "-m", "deploy"])
# Pull --rebase first to integrate any auto-bump commits the runner pushed back.
subprocess.run(
["git", "-C", str(project), "pull", "--rebase", "origin", "main"],
capture_output=True,
text=True,
check=False,
)
_run(["git", "-C", str(project), "push", "-u", "origin", "main"])
@app.command()
def deploy(
project: Path = typer.Option(Path("."), "--project", "-p"),
public: bool | None = typer.Option(None, "--public/--private"),
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
) -> None:
"""Push source to gitea; the platform builds + deploys.
No local docker. The CLI:
1. asks the control plane to provision a gitea repo + ArgoCD app
2. ``git push``es the project source to that repo
The Gitea Actions runner builds the image and ArgoCD reconciles
``deploy/`` onto the cluster. Returns the public URL once registered.
"""
creds = credentials.load()
if creds is None:
_fail("not logged in (run `a2a signup` or `a2a login`)")
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
is_public = (
public if public is not None else bool(cfg.get("expose", {}).get("public", True))
)
description = cfg.get("description", cls.description or "")
if not (project / "deploy").exists() or not (project / ".gitea/workflows/build.yml").exists():
_fail(
"project missing deploy/ or .gitea/workflows/. "
"Re-run `a2a init` or upgrade the scaffold."
)
client = _client(api)
console.print(f"[dim]asking control plane to provision repo + argo app...[/]")
try:
prov = client.from_source(
name=cls.name,
description=description,
version=cfg["version"],
public=is_public,
)
except ApiError as exc:
_fail(str(exc))
console.print(f"[dim]pushing source → {prov['repo_url']}[/]")
_git_push_source(project, prov["push_url"])
summary = {
"agent": prov["name"],
"repo": prov["repo_url"],
"url": prov.get("expected_url"),
"card": f"{prov['expected_url']}/.well-known/agent-card" if prov.get("expected_url") else None,
"next": "the runner is building. `a2a agents` to check status, or curl the url in ~30s",
}
console.print(
Panel.fit(
json.dumps(summary, indent=2),
title="[bold green]shipped[/]",
)
)
# Used as `python -m a2a_pack.cli.main`
if __name__ == "__main__": # pragma: no cover
app()

133
a2a_pack/cli/manifests.py Normal file
View File

@@ -0,0 +1,133 @@
"""Generate Kubernetes manifests for a deployed agent.
Targets the existing local cluster: namespace ``agents``, registry at
``localhost:30500``, traefik ingress at ``<name>.127-0-0-1.nip.io``.
"""
from __future__ import annotations
from typing import Any
import yaml
from ..agent import A2AAgent
NAMESPACE = "agents"
INGRESS_HOST_TEMPLATE = "{name}.127-0-0-1.nip.io"
def render_manifests(
agent_cls: type[A2AAgent],
*,
image: str,
public: bool = True,
) -> str:
"""Return a multi-doc YAML string ready for ``kubectl apply -f -``."""
rt = agent_cls.runtime()
name = agent_cls.name
docs: list[dict[str, Any]] = []
docs.append(
{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": NAMESPACE},
}
)
docs.append(
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": name,
"namespace": NAMESPACE,
"labels": {
"app": name,
"a2a/version": agent_cls.version,
"a2a/lifecycle": rt.lifecycle.value,
},
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": name}},
"template": {
"metadata": {"labels": {"app": name}},
"spec": {
"containers": [
{
"name": "agent",
"image": image,
"imagePullPolicy": "Always",
"ports": [{"containerPort": 8000, "name": "http"}],
"readinessProbe": {
"httpGet": {"path": "/healthz", "port": 8000},
"initialDelaySeconds": 2,
"periodSeconds": 5,
},
"livenessProbe": {
"httpGet": {"path": "/healthz", "port": 8000},
"initialDelaySeconds": 10,
"periodSeconds": 15,
},
"resources": {
"requests": {
"cpu": rt.resources.cpu,
"memory": rt.resources.memory,
},
"limits": {
"cpu": rt.resources.cpu,
"memory": rt.resources.memory,
},
},
}
]
},
},
},
}
)
docs.append(
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {"name": name, "namespace": NAMESPACE},
"spec": {
"type": "ClusterIP",
"selector": {"app": name},
"ports": [{"name": "http", "port": 80, "targetPort": 8000}],
},
}
)
if public:
docs.append(
{
"apiVersion": "networking.k8s.io/v1",
"kind": "Ingress",
"metadata": {"name": name, "namespace": NAMESPACE},
"spec": {
"rules": [
{
"host": INGRESS_HOST_TEMPLATE.format(name=name),
"http": {
"paths": [
{
"path": "/",
"pathType": "Prefix",
"backend": {
"service": {
"name": name,
"port": {"number": 80},
}
},
}
]
},
}
]
},
}
)
return "---\n".join(yaml.safe_dump(d, sort_keys=False) for d in docs)

View File

@@ -0,0 +1,14 @@
FROM registry.127-0-0-1.nip.io/a2a/a2a-pack-base:latest
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV A2A_ENTRYPOINT={{ entrypoint }}
ENV PORT=8000
EXPOSE 8000
CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,8 @@
# Project identity for `a2a deploy`. Most metadata (resources, scopes,
# secrets, workspace, etc.) lives on the Python class — this file only
# tells the CLI how to find it.
name: {{ name }}
version: 0.1.0
entrypoint: agent:{{ class_name }}
expose:
public: true

View File

@@ -0,0 +1,24 @@
"""{{ name }} agent."""
from __future__ import annotations
from pydantic import BaseModel
from a2a_pack import A2AAgent, NoAuth, RunContext, skill
class {{ class_name }}Config(BaseModel):
pass
class {{ class_name }}(A2AAgent[{{ class_name }}Config, NoAuth]):
name = "{{ name }}"
description = "{{ description }}"
version = "0.1.0"
config_model = {{ class_name }}Config
auth_model = NoAuth
@skill(description="Say hello")
async def hello(self, ctx: RunContext[NoAuth], who: str = "world") -> str:
await ctx.emit_progress(f"greeting {who}")
return f"hello {who}"

View File

@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ name }}
namespace: agents
labels:
app: {{ name }}
a2a/managed-by: control-plane
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: {{ name }}
template:
metadata:
labels:
app: {{ name }}
spec:
containers:
- name: agent
# tag is rewritten by the build workflow on every push
image: registry.127-0-0-1.nip.io/agents/{{ name }}:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
readinessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 10
periodSeconds: 15
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 200m, memory: 256Mi}
---
apiVersion: v1
kind: Service
metadata:
name: {{ name }}
namespace: agents
spec:
type: ClusterIP
selector:
app: {{ name }}
ports:
- name: http
port: 80
targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ name }}
namespace: agents
spec:
rules:
- host: {{ name }}.127-0-0-1.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ name }}
port:
number: 80

View File

@@ -0,0 +1,7 @@
__pycache__
*.pyc
.venv
.git
.pytest_cache
.mypy_cache
node_modules

View File

@@ -0,0 +1 @@
# add agent-specific deps here; a2a-pack is auto-installed by the deploy build

View File

@@ -0,0 +1,35 @@
name: build
on:
push:
branches: [main]
paths-ignore:
- 'deploy/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: build image
run: |
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
docker build -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" .
docker push "$IMG:$GITHUB_SHA"
docker push "$IMG:latest"
- name: bump deploy manifest
run: |
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
sed -i "s|image: $IMG:.*|image: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml
git config user.email "ci@a2a.local"
git config user.name "ci"
git add deploy/20-deployment.yaml
if git diff --staged --quiet; then
echo "no manifest changes"
else
git commit -m "ci: bump image to $GITHUB_SHA"
git push "http://gitea_admin:gitea_admin@gitea-http.gitea.svc.cluster.local:3000/gitea_admin/{{ name }}.git" HEAD:main
fi