diff --git a/.github/workflows/agent_hub_worker_ci.yml b/.github/workflows/agent_hub_worker_ci.yml index d2dac88e6..773f2f4be 100644 --- a/.github/workflows/agent_hub_worker_ci.yml +++ b/.github/workflows/agent_hub_worker_ci.yml @@ -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 diff --git a/.github/workflows/release_components.yml b/.github/workflows/release_components.yml index a339f2128..3d24a5539 100644 --- a/.github/workflows/release_components.yml +++ b/.github/workflows/release_components.yml @@ -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:-}', 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 @@ -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 diff --git a/workers/agent-hub/README.md b/workers/agent-hub/README.md index 9a35ba5fc..599d4f0dc 100644 --- a/workers/agent-hub/README.md +++ b/workers/agent-hub/README.md @@ -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":""} — "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/*`. diff --git a/workers/agent-hub/src/index.ts b/workers/agent-hub/src/index.ts index 5bfa5a3f8..904e035b8 100644 --- a/workers/agent-hub/src/index.ts +++ b/workers/agent-hub/src/index.ts @@ -39,7 +39,10 @@ async function route(request: Request, env: Env): Promise { 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") { diff --git a/workers/agent-hub/src/types.ts b/workers/agent-hub/src/types.ts index 003c6a7d3..44edc186e 100644 --- a/workers/agent-hub/src/types.ts +++ b/workers/agent-hub/src/types.ts @@ -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:`; 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 diff --git a/workers/agent-hub/test/routes.test.ts b/workers/agent-hub/test/routes.test.ts index 704997cab..83d7397bd 100644 --- a/workers/agent-hub/test/routes.test.ts +++ b/workers/agent-hub/test/routes.test.ts @@ -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);