Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/agent_hub_worker_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
# The worker's test suite guards the shared catalog contract (POST /publish,
# index.json shape) that every hub agent release and the website depend on —
# previously it was local-only, so a contract regression could reach main
# without any CI ever running the suite. Deploys stay manual; this only tests.
# without any CI ever running the suite. This workflow only tests; the Worker
# is deployed by release_components.yml's deploy-worker job, immediately
# before it publishes to it (a Worker older than the manifests it validates
# rejects valid releases).

name: Agent Hub Worker CI

Expand Down
130 changes: 128 additions & 2 deletions .github/workflows/release_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,127 @@ jobs:
--verify-released \
--release-version "${VERSION}"

# ── Prove the Worker builds, before anything decides to deploy it ───────────
# Runs on dry runs too, which is the point: the deploy job below is skipped on
# a dry run, so without this its code path would first execute during a live
# release. `deploy:dry-run` bundles and resolves wrangler.toml exactly as a
# real deploy does, minus the upload.
worker-check:
name: Check Agent Hub Worker
needs: version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
cache-dependency-path: workers/agent-hub/package-lock.json

- working-directory: workers/agent-hub
run: npm ci

# `npm run typecheck` not `tsc --noEmit`: the script also checks
# tsconfig.test.json, and the suite below is what actually guards the
# /publish contract every hub release depends on.
- name: Type-check
working-directory: workers/agent-hub
run: npm run typecheck

- name: Unit tests (vitest)
working-directory: workers/agent-hub
run: npm test

- name: Bundle (dry-run deploy)
working-directory: workers/agent-hub
run: npm run deploy:dry-run

# ── Bring the Worker up to date before anything is uploaded to it ───────────
# The Worker validates every manifest it accepts, so a Worker older than the
# manifests rejects a release that is perfectly valid. That is not theoretical:
# `go` and `typescript` were added to VALID_LANGUAGES in July, the Worker was
# never redeployed, and every publish since died on
# invalid_manifest: language "go" is not supported. Use one of: cpp, python
# — an error quoting a list that no longer exists in the source. There is no
# other deploy path (agent_hub_worker_ci.yml only tests), so the Worker drifts
# until someone runs `wrangler deploy` by hand.
#
# Deploying here rather than on push-to-main is deliberate: this job inherits
# the agent-publish reviewer gate, so a production Worker deploy still needs a
# human, and it lands in the same approval as the upload it has to match.
deploy-worker:
name: Deploy Agent Hub Worker
needs: [version, worker-check]
if: needs.version.outputs.dry_run == 'false'
runs-on: ubuntu-latest
environment: agent-publish
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
cache-dependency-path: workers/agent-hub/package-lock.json

- working-directory: workers/agent-hub
run: npm ci

- name: Deploy
working-directory: workers/agent-hub
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
missing=""
[ -n "${CLOUDFLARE_API_TOKEN:-}" ] || missing="${missing} CLOUDFLARE_API_TOKEN"
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || missing="${missing} CLOUDFLARE_ACCOUNT_ID"
if [ -n "${missing}" ]; then
echo "::error::missing environment secret(s) on agent-publish:${missing}. The Agent Hub Worker validates the manifests this workflow uploads, so publishing without deploying it first is how a stale validator rejects a valid release. Create a Cloudflare API token from the 'Edit Cloudflare Workers' template (it must also cover R2 for the bucket binding) and add both secrets to the agent-publish environment. See workers/agent-hub/README.md."
exit 1
fi
# Stamp the commit so the check below can prove WHICH build went live.
npx wrangler deploy --var WORKER_BUILD:"${GITHUB_SHA}"
echo "✓ deployed ${GITHUB_SHA}"

# Assert the live Worker is the build we just pushed. Curling the same base
# URL the publish steps use, not the custom domain: CI publishes through
# workers.dev because the WAF on hub.amd-gaia.ai blocks large multipart
# uploads (wrangler.toml), so that is the origin whose freshness matters.
- name: Verify the deployed build is live
env:
GAIA_HUB_PUBLISH_URL: ${{ vars.GAIA_HUB_PUBLISH_URL }}
GAIA_HUB_BASE_URL: ${{ vars.GAIA_HUB_BASE_URL }}
run: |
set -euo pipefail
base="${GAIA_HUB_PUBLISH_URL:-${GAIA_HUB_BASE_URL:-https://hub.amd-gaia.ai}}"
for i in 1 2 3 4 5 6; do
live="$(curl -fsS --max-time 15 "${base}/health" | python -c 'import json,sys; print(json.load(sys.stdin).get("build",""))' 2>/dev/null || true)"
if [ "${live}" = "${GITHUB_SHA}" ]; then
echo "✓ ${base} is serving ${GITHUB_SHA}"
exit 0
fi
echo "attempt ${i}: ${base}/health reports build='${live:-<none>}', want '${GITHUB_SHA}'; retrying in 5s"
sleep 5
done
echo "::error::${base} is not serving the build just deployed (wanted ${GITHUB_SHA}). Publishing now would upload against a Worker whose manifest validator is not the one in this ref — the exact failure this job exists to prevent. Check the Cloudflare dashboard before re-running."
exit 1

# ── terminal-hub: build the 6 targets and publish them ──────────────────────
terminal-hub:
name: Publish terminal-hub
needs: version
needs: [version, worker-check, deploy-worker]
# deploy-worker is skipped on a dry run; a skipped dependency would skip this
# job too, so admit 'skipped' explicitly while still refusing 'failure'.
# !cancelled() rather than always(): always() survives a cancelled run, and
# an upload that outlives Cancel lands in an immutable path.
if: |
!cancelled()
&& needs.version.result == 'success'
&& needs.worker-check.result == 'success'
&& (needs.deploy-worker.result == 'success' || needs.deploy-worker.result == 'skipped')
runs-on: ubuntu-latest
# agent-publish gates on a deployment tag allowlist that lives in repo settings,
# not here: it must include v* or every release tag is rejected before any step
Expand Down Expand Up @@ -225,7 +342,16 @@ jobs:
# ── agent-ui: republish the installers build-installers.yml already made ────
agent-ui:
name: Publish agent-ui
needs: version
needs: [version, worker-check, deploy-worker]
# deploy-worker is skipped on a dry run; a skipped dependency would skip this
# job too, so admit 'skipped' explicitly while still refusing 'failure'.
# !cancelled() rather than always(): always() survives a cancelled run, and
# an upload that outlives Cancel lands in an immutable path.
if: |
!cancelled()
&& needs.version.result == 'success'
&& needs.worker-check.result == 'success'
&& (needs.deploy-worker.result == 'success' || needs.deploy-worker.result == 'skipped')
runs-on: ubuntu-latest
# Same tag-allowlist requirement as terminal-hub above (#2935).
environment: agent-publish
Expand Down
26 changes: 26 additions & 0 deletions workers/agent-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,32 @@ checked into the repo:
npx wrangler deploy
```

CI does this for you on a real publish. `release_components.yml`'s
`deploy-worker` job deploys this Worker before it uploads anything, because
the Worker *validates* the manifests being uploaded — a Worker older than the
manifests rejects a valid release. That is not hypothetical: `go` and
`typescript` were added to `VALID_LANGUAGES` and the Worker was not
redeployed, so every publish failed with `language "go" is not supported`
while the source said otherwise.

The job needs two secrets on the **`agent-publish`** environment:

| Secret | How to get it |
|---|---|
| `CLOUDFLARE_API_TOKEN` | Cloudflare dashboard → My Profile → API Tokens → Create Token → **Edit Cloudflare Workers** template. Must also cover R2 for the bucket binding. |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare dashboard → Workers & Pages → Account ID |

Without them the job fails loudly rather than publishing to a stale Worker.

The deploy stamps the commit into `WORKER_BUILD`, which `GET /health`
returns, so the workflow can assert *which* build went live instead of
assuming. Check it by hand any time:

```bash
curl -s https://hub.amd-gaia.ai/health
# {"status":"ok","build":"<commit>"} — "unknown" means a hand-run deploy
```

4. **(Optional) Bind the route** by uncommenting the `routes` line in
`wrangler.toml` to serve the API under `hub.amd-gaia.ai/*`.

Expand Down
5 changes: 4 additions & 1 deletion workers/agent-hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ async function route(request: Request, env: Env): Promise<Response> {
const method = request.method.toUpperCase();

if (path === "/health") {
return json({ status: "ok" });
// `build` is the commit this Worker was deployed from. A fixed "ok"
// cannot tell a current Worker from a months-stale one — which is how an
// outdated manifest validator went unnoticed until every release failed.
return json({ status: "ok", build: env.WORKER_BUILD ?? "unknown" });
}

if (path === "/publish") {
Expand Down
7 changes: 7 additions & 0 deletions workers/agent-hub/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface Env {
*/
PUBLISH_TOKENS?: string;

/**
* Commit this Worker was deployed from, surfaced by `GET /health` so a
* deploy can be verified rather than assumed. Set at deploy time with
* `wrangler deploy --var WORKER_BUILD:<sha>`; absent in local dev.
*/
WORKER_BUILD?: string;

/**
* Bearer token for the maintainer-only `POST /reindex` endpoint, which
* rebuilds index.json from the immutable R2 objects (idempotent). Separate
Expand Down
15 changes: 15 additions & 0 deletions workers/agent-hub/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ describe("GET routes", () => {
expect(((await res.json()) as any).status).toBe("ok");
});

it("reports the deployed build on /health so a stale Worker is detectable", async () => {
// The release workflow greps this for the commit it just deployed. A fixed
// "ok" would let a months-old Worker pass the post-deploy check, which is
// how an outdated manifest validator broke every publish unnoticed.
const env = { ...makeEnv(), WORKER_BUILD: "abc123" };
const res = await worker.fetch(get("/health"), env as never);
expect(((await res.json()) as any).build).toBe("abc123");
});

it("says 'unknown' rather than omitting the build when it is unset", async () => {
const env = makeEnv();
const res = await worker.fetch(get("/health"), env as never);
expect(((await res.json()) as any).build).toBe("unknown");
});

it("serves /index.json after a publish", async () => {
const env = makeEnv();
await seed(env);
Expand Down
Loading