Skip to content

Update Agent Versions #3861

Update Agent Versions

Update Agent Versions #3861

name: Update Agent Versions
on:
schedule:
# Run hourly at minute 0
- cron: "0 * * * *"
workflow_dispatch:
inputs:
apply:
description: "Apply updates and commit to main"
required: false
default: false
type: boolean
agents:
description: "Comma-separated agent IDs (leave empty for all)"
required: false
default: ""
type: string
permissions:
contents: read
jobs:
check-versions:
runs-on: ubuntu-latest
outputs:
has_updates: ${{ steps.check.outputs.has_updates }}
updates_json: ${{ steps.check.outputs.updates_json }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Check for version updates
id: check
env:
GITHUB_TOKEN: ${{ github.token }}
INPUT_AGENTS: ${{ inputs.agents }}
run: |
set +e
ARGS=(--json)
if [ -n "$INPUT_AGENTS" ]; then
if [[ ! "$INPUT_AGENTS" =~ ^[a-z0-9-]+(,[a-z0-9-]+)*$ ]]; then
echo "Invalid agents input: $INPUT_AGENTS" >&2
exit 1
fi
ARGS+=(--agents "$INPUT_AGENTS")
fi
OUTPUT=$(python .github/workflows/update_versions.py "${ARGS[@]}")
EXIT_CODE=$?
echo "$OUTPUT"
# Save JSON output
UPDATES_DELIMITER="$(uuidgen)"
{
echo "updates_json<<$UPDATES_DELIMITER"
printf '%s\n' "$OUTPUT"
echo "$UPDATES_DELIMITER"
} >> "$GITHUB_OUTPUT"
# Check if updates are available (exit code 2)
if [ "$EXIT_CODE" -eq 2 ]; then
echo "has_updates=true" >> "$GITHUB_OUTPUT"
else
echo "has_updates=false" >> "$GITHUB_OUTPUT"
fi
# Fail on actual errors (exit code 1)
if [ "$EXIT_CODE" -eq 1 ]; then
exit 1
fi
notify-failure:
needs: check-versions
if: failure()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Create failure issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const title = `Version check failed - ${new Date().toISOString().split('T')[0]}`;
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
// Check if issue already exists today
const existingIssues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'version-check-failure',
per_page: 10
});
const todayIssue = existingIssues.data.find(i => i.title === title);
if (todayIssue) {
// Add comment to existing issue
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: todayIssue.number,
body: `Another failure occurred.\n\n**Run:** ${runUrl}`
});
} else {
// Create new issue
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: `The automated version check workflow failed.\n\n**Run:** ${runUrl}\n\nPlease investigate the failure.`,
labels: ['version-check-failure', 'automated']
});
}
apply-updates:
needs: check-versions
if: needs.check-versions.outputs.has_updates == 'true' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || inputs.apply == true || inputs.apply == 'true')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: "lts/*"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4
- name: Apply version updates
env:
GITHUB_TOKEN: ${{ github.token }}
INPUT_AGENTS: ${{ inputs.agents }}
run: |
ARGS=(--apply)
if [ -n "$INPUT_AGENTS" ]; then
if [[ ! "$INPUT_AGENTS" =~ ^[a-z0-9-]+(,[a-z0-9-]+)*$ ]]; then
echo "Invalid agents input: $INPUT_AGENTS" >&2
exit 1
fi
ARGS+=(--agents "$INPUT_AGENTS")
fi
python .github/workflows/update_versions.py "${ARGS[@]}"
- name: Stage updated agent manifests
env:
UPDATES_JSON: ${{ needs.check-versions.outputs.updates_json }}
UPDATE_ARTIFACT_DIR: ${{ runner.temp }}/agent-updates
run: |
python3 - <<'PY'
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
data = json.loads(os.environ["UPDATES_JSON"])
updates = data.get("updates", [])
# path -> {channel -> version}: both channels of one agent live in the
# same file, so a path-only key would let one channel mask the other.
expected_versions = {}
for update in updates:
agent_id = update.get("agent_id", "")
channel = update.get("channel", "stable")
latest_version = update.get("latest_version", "")
if not re.fullmatch(r"[a-z][a-z0-9-]*", agent_id):
print(f"Invalid agent id in update JSON: {agent_id!r}", file=sys.stderr)
sys.exit(1)
if channel not in ("stable", "preview"):
print(f"Invalid channel in update JSON: {channel!r}", file=sys.stderr)
sys.exit(1)
expected_versions.setdefault(f"{agent_id}/agent.json", {})[channel] = latest_version
if not expected_versions:
print("No expected update files found", file=sys.stderr)
sys.exit(1)
status_lines = subprocess.check_output(
["git", "status", "--porcelain=v1"],
text=True,
).splitlines()
changed_paths = set()
for line in status_lines:
path = line[3:]
if " -> " in path:
path = path.split(" -> ", 1)[1]
changed_paths.add(path)
expected_paths = set(expected_versions)
unexpected = sorted(changed_paths - expected_paths)
missing = sorted(expected_paths - changed_paths)
if unexpected or missing:
if unexpected:
print("Unexpected changed paths:", file=sys.stderr)
for path in unexpected:
print(f" {path}", file=sys.stderr)
if missing:
print("Expected paths were not changed:", file=sys.stderr)
for path in missing:
print(f" {path}", file=sys.stderr)
sys.exit(1)
artifact_dir = Path(os.environ["UPDATE_ARTIFACT_DIR"])
if artifact_dir.exists():
shutil.rmtree(artifact_dir)
artifact_dir.mkdir(parents=True)
base_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
(artifact_dir / "base-sha.txt").write_text(base_sha + "\n")
for rel_path, channel_versions in sorted(expected_versions.items()):
source = Path(rel_path)
agent = json.loads(source.read_text())
for channel, expected_version in sorted(channel_versions.items()):
if channel == "preview":
actual_version = (agent.get("preview") or {}).get("version")
else:
actual_version = agent.get("version")
if actual_version != expected_version:
print(
f"{rel_path} has {channel} version {actual_version!r}, "
f"expected {expected_version!r}",
file=sys.stderr,
)
sys.exit(1)
destination = artifact_dir / rel_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
(artifact_dir / "files.json").write_text(
json.dumps(sorted(expected_paths), indent=2) + "\n",
)
PY
- name: Validate registry build
run: uv run --with jsonschema .github/workflows/build_registry.py
- name: Upload update artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: agent-updates
path: ${{ runner.temp }}/agent-updates/
if-no-files-found: error
retention-days: 1
verify-updates:
needs: [check-versions, apply-updates]
if: needs.apply-updates.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
has_verified_updates: ${{ steps.filter.outputs.has_verified_updates }}
verified_updates_json: ${{ steps.filter.outputs.verified_updates_json }}
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download update artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: agent-updates
path: ${{ runner.temp }}/agent-updates
- name: Apply staged update manifests
env:
UPDATE_ARTIFACT_DIR: ${{ runner.temp }}/agent-updates
run: |
python3 - <<'PY'
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
artifact_dir = Path(os.environ["UPDATE_ARTIFACT_DIR"])
base_sha = (artifact_dir / "base-sha.txt").read_text().strip()
if not re.fullmatch(r"[0-9a-f]{40}", base_sha):
print(f"Invalid artifact base SHA: {base_sha!r}", file=sys.stderr)
sys.exit(1)
current_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
if current_sha != base_sha:
print(
f"Update artifact was generated from {base_sha}, "
f"but this checkout is {current_sha}; refusing stale verification.",
file=sys.stderr,
)
sys.exit(1)
files = json.loads((artifact_dir / "files.json").read_text())
for rel_path in files:
if not re.fullmatch(r"[a-z][a-z0-9-]*/agent\.json", rel_path):
print(f"Unexpected artifact path: {rel_path}", file=sys.stderr)
sys.exit(1)
source = artifact_dir / rel_path
if not source.is_file():
print(f"Missing artifact file: {source}", file=sys.stderr)
sys.exit(1)
destination = Path(rel_path)
shutil.copy2(source, destination)
PY
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.14"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: "lts/*"
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4
- name: Validate registry build
run: uv run --with jsonschema .github/workflows/build_registry.py
- name: Verify agent auth support
env:
UPDATES_JSON: ${{ needs.check-versions.outputs.updates_json }}
VERIFICATION_RESULTS: ${{ runner.temp }}/verification-results.tsv
#language=bash
run: |
# The preview channel is explicitly unverified: preview distributions
# are never launched, so only stable bumps are auth-checked here.
mapfile -t STABLE_UPDATES < <(echo "$UPDATES_JSON" | python3 -c "
import sys, json
data = json.load(sys.stdin)
versions = {
u['agent_id']: u['latest_version']
for u in data.get('updates', [])
if u.get('channel', 'stable') == 'stable'
}
for agent_id, version in sorted(versions.items()):
print(f'{agent_id}\t{version}')
")
: > "$VERIFICATION_RESULTS"
if [ "${#STABLE_UPDATES[@]}" -eq 0 ]; then
echo "No stable agent updates in this run; skipping auth verification"
exit 0
fi
for STABLE_UPDATE in "${STABLE_UPDATES[@]}"; do
IFS=$'\t' read -r AGENT_ID AGENT_VERSION <<< "$STABLE_UPDATE"
echo "::group::Verify $AGENT_ID"
if python3 .github/workflows/verify_agents.py --auth-check --agent "$AGENT_ID"; then
printf '%s\tpassed\n' "$AGENT_ID" >> "$VERIFICATION_RESULTS"
else
printf '%s\tfailed\n' "$AGENT_ID" >> "$VERIFICATION_RESULTS"
echo "::warning title=Agent update skipped::$AGENT_ID@$AGENT_VERSION failed auth verification; its version updates will not be committed."
fi
echo "::endgroup::"
done
- name: Filter verified updates
id: filter
if: always()
env:
UPDATES_JSON: ${{ needs.check-versions.outputs.updates_json }}
UPDATE_ARTIFACT_DIR: ${{ runner.temp }}/agent-updates
VERIFIED_ARTIFACT_DIR: ${{ runner.temp }}/verified-agent-updates
VERIFICATION_RESULTS: ${{ runner.temp }}/verification-results.tsv
run: |
python3 - <<'PY'
import json
import os
import shutil
import sys
from pathlib import Path
updates = json.loads(os.environ["UPDATES_JSON"]).get("updates", [])
source_dir = Path(os.environ["UPDATE_ARTIFACT_DIR"])
destination_dir = Path(os.environ["VERIFIED_ARTIFACT_DIR"])
results_path = Path(os.environ["VERIFICATION_RESULTS"])
results = {}
if results_path.exists():
for line in results_path.read_text().splitlines():
agent_id, status = line.split("\t", 1)
if status not in ("passed", "failed"):
print(f"Invalid verification status for {agent_id}: {status}", file=sys.stderr)
sys.exit(1)
results[agent_id] = status
stable_agents = {
update["agent_id"]
for update in updates
if update.get("channel", "stable") == "stable"
}
missing_results = sorted(stable_agents - results.keys())
if missing_results:
print(
"Missing verification results for: " + ", ".join(missing_results),
file=sys.stderr,
)
sys.exit(1)
failed_agents = {
agent_id for agent_id, status in results.items() if status == "failed"
}
verified_updates = [
update for update in updates if update["agent_id"] not in failed_agents
]
verified_agent_ids = {update["agent_id"] for update in verified_updates}
files = json.loads((source_dir / "files.json").read_text())
verified_files = [
rel_path
for rel_path in files
if rel_path.split("/", 1)[0] in verified_agent_ids
]
if {path.split("/", 1)[0] for path in verified_files} != verified_agent_ids:
print("Verified update artifact is missing an agent manifest", file=sys.stderr)
sys.exit(1)
if destination_dir.exists():
shutil.rmtree(destination_dir)
destination_dir.mkdir(parents=True)
shutil.copy2(source_dir / "base-sha.txt", destination_dir / "base-sha.txt")
for rel_path in verified_files:
destination = destination_dir / rel_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_dir / rel_path, destination)
(destination_dir / "files.json").write_text(
json.dumps(verified_files, indent=2) + "\n"
)
verified_json = json.dumps({"updates": verified_updates}, separators=(",", ":"))
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"has_verified_updates={'true' if verified_updates else 'false'}", file=output)
print("verified_updates_json<<EOF", file=output)
print(verified_json, file=output)
print("EOF", file=output)
passed_agents = sorted(results.keys() - failed_agents)
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
print("## Agent update verification", file=summary)
print(file=summary)
if failed_agents:
print("> [!WARNING]", file=summary)
print(
f"> {len(failed_agents)} agent update(s) failed auth verification and "
"were excluded from the commit.",
file=summary,
)
print(file=summary)
print("### Skipped updates", file=summary)
print(file=summary)
print("| Agent | Updates |", file=summary)
print("| --- | --- |", file=summary)
for agent_id in sorted(failed_agents):
versions = ", ".join(
f"{u.get('channel', 'stable')} {u['current_version']} → {u['latest_version']}"
for u in updates
if u["agent_id"] == agent_id
)
print(f"| `{agent_id}` | {versions} |", file=summary)
print(file=summary)
print("### Updates proceeding to commit", file=summary)
print(file=summary)
if verified_updates:
print("| Agent | Updates | Verification |", file=summary)
print("| --- | --- | --- |", file=summary)
for agent_id in sorted(verified_agent_ids):
versions = ", ".join(
f"{u.get('channel', 'stable')} {u['current_version']} → {u['latest_version']}"
for u in verified_updates
if u["agent_id"] == agent_id
)
verification = (
"passed" if agent_id in passed_agents else "preview only (not required)"
)
print(f"| `{agent_id}` | {versions} | {verification} |", file=summary)
else:
print("No updates passed verification; no commit will be created.", file=summary)
PY
- name: Upload verified update artifact
if: steps.filter.outputs.has_verified_updates == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: verified-agent-updates
path: ${{ runner.temp }}/verified-agent-updates/
if-no-files-found: error
retention-days: 1
commit-updates:
needs: [check-versions, apply-updates, verify-updates]
if: needs.verify-updates.result == 'success' && needs.verify-updates.outputs.has_verified_updates == 'true'
concurrency:
group: registry-main-write
cancel-in-progress: false
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
persist-credentials: false
- name: Download update artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: verified-agent-updates
path: ${{ runner.temp }}/agent-updates
- name: Apply staged update manifests
id: apply_artifact
env:
UPDATE_ARTIFACT_DIR: ${{ runner.temp }}/agent-updates
run: |
python3 - <<'PY'
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
artifact_dir = Path(os.environ["UPDATE_ARTIFACT_DIR"])
base_sha = (artifact_dir / "base-sha.txt").read_text().strip()
if not re.fullmatch(r"[0-9a-f]{40}", base_sha):
print(f"Invalid artifact base SHA: {base_sha!r}", file=sys.stderr)
sys.exit(1)
current_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
if current_sha != base_sha:
print(
f"Update artifact was generated from {base_sha}, "
f"but main is now {current_sha}; refusing to overwrite newer main.",
file=sys.stderr,
)
sys.exit(1)
files = json.loads((artifact_dir / "files.json").read_text())
safe_files = []
for rel_path in files:
if not re.fullmatch(r"[a-z][a-z0-9-]*/agent\.json", rel_path):
print(f"Unexpected artifact path: {rel_path}", file=sys.stderr)
sys.exit(1)
source = artifact_dir / rel_path
if not source.is_file():
print(f"Missing artifact file: {source}", file=sys.stderr)
sys.exit(1)
destination = Path(rel_path)
shutil.copy2(source, destination)
safe_files.append(rel_path)
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"files={' '.join(safe_files)}", file=output)
PY
- name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4
- name: Validate registry build
run: uv run --with jsonschema .github/workflows/build_registry.py
- name: Generate commit message
id: commit_msg
env:
UPDATES_JSON: ${{ needs.verify-updates.outputs.verified_updates_json }}
run: |
# Generate summary of updates
SUMMARY=$(echo "$UPDATES_JSON" | python3 -c "
import sys, json
data = json.load(sys.stdin)
updates = data.get('updates', [])
if len(updates) == 1:
u = updates[0]
channel = u.get('channel', 'stable')
suffix = '' if channel == 'stable' else ' (' + channel + ')'
print('Update ' + u['agent_id'] + suffix + ' to ' + u['latest_version'])
else:
print('Update ' + str(len(updates)) + ' agents to latest versions')
")
# Generate details
DETAILS=$(echo "$UPDATES_JSON" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for u in data.get('updates', []):
channel = u.get('channel', 'stable')
suffix = '' if channel == 'stable' else ' (' + channel + ')'
print('- ' + u['agent_id'] + suffix + ': ' + u['current_version'] + ' -> ' + u['latest_version'])
")
echo "summary=$SUMMARY" >> "$GITHUB_OUTPUT"
DETAILS_DELIMITER="$(uuidgen)"
{
echo "details<<$DETAILS_DELIMITER"
printf '%s\n' "$DETAILS"
echo "$DETAILS_DELIMITER"
} >> "$GITHUB_OUTPUT"
- name: Commit updated manifests
id: commit
env:
COMMIT_SUMMARY: ${{ steps.commit_msg.outputs.summary }}
COMMIT_DETAILS: ${{ steps.commit_msg.outputs.details }}
UPDATED_FILES: ${{ steps.apply_artifact.outputs.files }}
run: |
git config user.name "acp-release[bot]"
git config user.email "2373403+acp-release[bot]@users.noreply.github.com"
read -r -a FILES <<< "$UPDATED_FILES"
git add -- "${FILES[@]}"
if git diff --cached --quiet; then
echo "No staged update files to commit" >&2
exit 1
fi
git commit -m "$COMMIT_SUMMARY" -m "$COMMIT_DETAILS"
echo "committed=true" >> "$GITHUB_OUTPUT"
# Generating a GitHub token, so that commits created by the
# action can trigger the registry publication workflow.
- name: Generate GitHub token
if: steps.commit.outputs.committed == 'true'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
id: generate-token
with:
# GitHub App ID secret name
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
# GitHub App private key secret name
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
- name: Push updates to main
if: steps.commit.outputs.committed == 'true'
env:
GH_APP_TOKEN: ${{ steps.generate-token.outputs.token }}
run: |
AUTH_HEADER="$(printf 'x-access-token:%s' "$GH_APP_TOKEN" | base64 | tr -d '\n')"
echo "::add-mask::$AUTH_HEADER"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${AUTH_HEADER}" push origin main
notify-apply-failure:
needs: [check-versions, apply-updates, verify-updates, commit-updates]
if: always() && (needs.apply-updates.result == 'failure' || needs.verify-updates.result == 'failure' || needs.commit-updates.result == 'failure')
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Create failure issue
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const title = `Version update failed - ${new Date().toISOString().split('T')[0]}`;
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
// Check if issue already exists today
const existingIssues = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'version-update-failure',
per_page: 10
});
const todayIssue = existingIssues.data.find(i => i.title === title);
if (todayIssue) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: todayIssue.number,
body: `Another failure occurred.\n\n**Run:** ${runUrl}`
});
} else {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: `The automated version update workflow failed while applying updates.\n\nThis could be due to:\n- Registry validation failure\n- Auth verification failure\n- Git push conflict\n- Network issues\n\n**Run:** ${runUrl}\n\nPlease investigate.`,
labels: ['version-update-failure', 'automated']
});
}