Update Agent Versions #2742
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Python | |
| uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| with: | |
| python-version: "3.14" | |
| - name: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: "lts/*" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 | |
| - 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", []) | |
| expected_versions = {} | |
| for update in updates: | |
| agent_id = update.get("agent_id", "") | |
| 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) | |
| expected_versions[f"{agent_id}/agent.json"] = 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, expected_version in sorted(expected_versions.items()): | |
| source = Path(rel_path) | |
| agent = json.loads(source.read_text()) | |
| actual_version = agent.get("version") | |
| if actual_version != expected_version: | |
| print( | |
| f"{rel_path} has version {actual_version!r}, 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 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 | |
| with: | |
| python-version: "3.14" | |
| - name: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: "lts/*" | |
| - name: Install uv | |
| uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 | |
| - 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 }} | |
| run: | | |
| UPDATED_AGENTS=$(echo "$UPDATES_JSON" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| ids = [u['agent_id'] for u in data.get('updates', [])] | |
| if not ids: | |
| print('ERROR: No agent IDs found in updates JSON', file=sys.stderr) | |
| sys.exit(1) | |
| print(','.join(ids)) | |
| ") | |
| echo "Verifying updated agents: $UPDATED_AGENTS" | |
| python3 .github/workflows/verify_agents.py --auth-check --agent "$UPDATED_AGENTS" | |
| commit-updates: | |
| needs: [check-versions, apply-updates, verify-updates] | |
| if: needs.verify-updates.result == 'success' | |
| concurrency: | |
| group: registry-main-write | |
| cancel-in-progress: false | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| ref: main | |
| 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 | |
| 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@fac544c07dec837d0ccb6301d7b5580bf5edae39 | |
| - 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.check-versions.outputs.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] | |
| print(f\"Update {u['agent_id']} to {u['latest_version']}\") | |
| else: | |
| print(f\"Update {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', []): | |
| print(f\"- {u['agent_id']}: {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'] | |
| }); | |
| } |