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
64 changes: 64 additions & 0 deletions .github/workflows/release_agent_email.yml
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,38 @@ jobs:
npm ci
npm run build

- name: Assemble + publish the whole-package zip (all platforms + client + docs)
shell: bash
env:
AGENT_HUB_PUBLISH_TOKEN: ${{ secrets.GAIA_HUB_TOKEN }}
GAIA_HUB_PUBLISH_URL: ${{ vars.GAIA_HUB_PUBLISH_URL }}
GAIA_HUB_BASE_URL: ${{ vars.GAIA_HUB_BASE_URL }}
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
STAGE="agent-email-${VER}"
rm -rf "${STAGE}" "agent-email-${VER}.zip" package-files.json
mkdir -p "${STAGE}/binaries" "${STAGE}/dist"
# All platform binaries (whatever was published this run — Intel may be absent).
cp bins/email-agent-* "${STAGE}/binaries/"
# npm client + docs + the real (post-publish) lock + manifest + license.
cp -r "${PKG_DIR}/dist/." "${STAGE}/dist/"
cp "${PKG_DIR}/README.md" "${PKG_DIR}/SPEC.md" "${PKG_DIR}/SKILL.md" \
"${PKG_DIR}/CHANGELOG.md" "${PKG_DIR}/binaries.lock.json" \
"${PKG_DIR}/LICENSE" "${STAGE}/"
cp "${MANIFEST}" "${STAGE}/gaia-agent.yaml"
# Deterministic zip (sorted, no extra metadata).
( cd "${STAGE}/.." && zip -rX "agent-email-${VER}.zip" "agent-email-${VER}" >/dev/null )
# File listing for the hub's package file-list display.
python hub/agents/python/email/packaging/gen_package_files.py "${STAGE}" package-files.json
echo "=== package contents ===" && (cd "${STAGE}" && find . -type f | sort)
# Publish the zip as the version's package artifact + its file listing.
python hub/agents/python/email/packaging/publish_to_r2.py \
--base-url "${GAIA_HUB_PUBLISH_URL:-${GAIA_HUB_BASE_URL:-https://hub.amd-gaia.ai}}" \
--manifest "${MANIFEST}" \
--artifact "agent-email-${VER}.zip=package" \
--package-files package-files.json

- name: Verify every published object via the real fetch CLI
working-directory: ${{ env.PKG_DIR }}
shell: bash
Expand Down Expand Up @@ -531,6 +563,38 @@ jobs:
done
done

- name: Verify the published package zip is fetchable at the edge
shell: bash
env:
GAIA_HUB_BASE_URL: ${{ vars.GAIA_HUB_BASE_URL }}
run: |
set -euo pipefail
# The package zip rides the `artifact` path (server- + locally-SHA-verified
# on upload by publish_to_r2.py), but it is NOT in binaries.lock.json, so the
# fetch CLI above doesn't cover it. Round-trip it through the public origin so
# a non-propagated / edge-blocked zip fails the release instead of shipping a
# 404 download button. Bytes were already proven on upload; here we gate on a
# 200 with a Content-Length matching the local zip. Same bounded edge retry.
VER="${{ steps.ver.outputs.version }}"
BASE="${GAIA_HUB_BASE_URL:-https://hub.amd-gaia.ai}"
ZIP="${GITHUB_WORKSPACE}/agent-email-${VER}.zip"
URL="${BASE}/agents/email/${VER}/agent-email-${VER}.zip"
local_size="$(stat -c%s "${ZIP}" 2>/dev/null || stat -f%z "${ZIP}")"
echo "verifying package zip: ${URL} (expecting ${local_size} bytes)"
attempt=1; max=5
while true; do
remote_size="$(curl -fsSL -o /dev/null -w '%{size_download}' "${URL}" || echo "")"
if [ "${remote_size}" = "${local_size}" ]; then
echo "package zip verified (${remote_size} bytes)."; break
fi
if [ "${attempt}" -ge "${max}" ]; then
echo "::error::package zip verify failed after ${max} attempts (got '${remote_size}', want ${local_size}) — the zip never became fetchable/correct at ${URL}."
exit 1
fi
echo "attempt ${attempt}/${max}: got '${remote_size}', want ${local_size} — retrying in 10s."
attempt=$((attempt + 1)); sleep 10
done

- name: Upgrade npm (trusted publishing support)
run: npm install -g npm@latest

Expand Down
55 changes: 55 additions & 0 deletions hub/agents/python/email/packaging/gen_package_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
"""Emit ``package-files.json`` — the listing of files inside the whole-package
zip — for the hub's package file-list display.

The release workflow stages the package (``binaries/`` + ``dist/`` + docs + lock +
manifest + LICENSE), runs this over the staging dir, and POSTs the result as the
``package_files`` part on ``/publish`` (see ``publish_to_r2.py``). The Worker pairs
it with the published ``.zip`` artifact to build the catalog's ``package`` entry.

Usage:
gen_package_files.py <staging-dir> <out.json>

Output shape (paths are relative to the staging dir, sorted, forward slashes):
{"files": [{"name": "README.md", "size_bytes": 13000}, ...]}
"""

from __future__ import annotations

import json
import os
import sys


def collect(root: str) -> list[dict]:
files: list[dict] = []
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root).replace(os.sep, "/")
files.append({"name": rel, "size_bytes": os.path.getsize(path)})
files.sort(key=lambda f: f["name"])
return files


def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 2:
raise SystemExit("usage: gen_package_files.py <staging-dir> <out.json>")
root, out = argv
if not os.path.isdir(root):
raise SystemExit(f"error: staging dir not found: {root}")
files = collect(root)
if not files:
raise SystemExit(
f"error: no files under {root} — refusing to emit an empty package list"
)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"files": files}, fh)
print(f"[package] {out}: {len(files)} files", flush=True)
return 0


if __name__ == "__main__":
sys.exit(main())
38 changes: 37 additions & 1 deletion hub/agents/python/email/packaging/publish_to_r2.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def publish_one(
token: str,
readme_bytes: bytes | None = None,
changelog_bytes: bytes | None = None,
package_files_bytes: bytes | None = None,
) -> dict:
if not artifact_path.exists():
raise SystemExit(f"error: artifact not found: {artifact_path}")
Expand Down Expand Up @@ -163,6 +164,14 @@ def publish_one(
# `changelog`, rendered as a Changelog section on the hub agent page.
if changelog_bytes is not None:
files["changelog"] = ("CHANGELOG.md", changelog_bytes, "text/markdown")
# The whole-package file listing rides with the zip artifact — it becomes
# the catalog entry's `package.files` (the hub's file-list display).
if package_files_bytes is not None:
files["package_files"] = (
"package-files.json",
package_files_bytes,
"application/json",
)
resp = requests.post(
publish_url,
headers={"authorization": f"Bearer {token}"},
Expand Down Expand Up @@ -242,6 +251,13 @@ def main(argv=None) -> int:
help="Path to CHANGELOG.md to publish as the agent's catalog changelog "
"(POSTed as the multipart 'changelog' part the Worker accepts).",
)
parser.add_argument(
"--package-files",
type=Path,
help='Path to a package-files.json ({"files":[{name,size_bytes}]}) to '
"attach to the zip artifact (POSTed as the 'package_files' part). It "
"becomes the catalog's package.files (the hub's file-list display).",
)
parser.add_argument(
"--summary-out",
type=Path,
Expand Down Expand Up @@ -279,10 +295,29 @@ def main(argv=None) -> int:
flush=True,
)

package_files_bytes = None
if args.package_files is not None:
if not args.package_files.exists():
raise SystemExit(
f"error: --package-files path not found: {args.package_files}."
)
package_files_bytes = args.package_files.read_bytes()
print(
f"[publish] attaching package file list: {args.package_files} "
f"({len(package_files_bytes)} bytes)",
flush=True,
)

results = []
for raw in args.artifact:
path, key = _parse_artifact_arg(raw)
platform_key = key or _infer_platform_key(path.name)
# A .zip is the whole-package artifact (not a platform binary); it has no
# platform key to infer, so default it to "package".
platform_key = key or (
"package"
if path.name.lower().endswith(".zip")
else _infer_platform_key(path.name)
)
results.append(
publish_one(
args.base_url,
Expand All @@ -293,6 +328,7 @@ def main(argv=None) -> int:
token,
readme_bytes=readme_bytes,
changelog_bytes=changelog_bytes,
package_files_bytes=package_files_bytes,
)
)

Expand Down
19 changes: 19 additions & 0 deletions website/src/data/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export interface Agent {
// (e.g. "http://127.0.0.1:8131/v1/email/playground"). Only resolves once the
// package is installed and the sidecar is running — a best-effort dev link.
playground_url?: string;
// Whole-package download: a single zip (all platform binaries + client + docs)
// and its file listing. Present only when the latest version published one.
package?: {
filename: string;
size_bytes: number;
files: { name: string; size_bytes: number }[];
};
}

interface CatalogFile {
Expand Down Expand Up @@ -182,6 +189,18 @@ export function securityTierLabel(tier: SecurityTier): string {
return SECURITY_TIER_LABELS[tier] ?? tier;
}

/**
* Absolute URL of an agent's whole-package zip, served from the same hub origin
* as the catalog (`${HUB_CATALOG_URL}/agents/<id>/<version>/<filename>`). Returns
* null when the agent has no published package zip. Build-time only.
*/
export function packageDownloadUrl(agent: Agent): string | null {
if (!agent.package) return null;
const base = process.env.HUB_CATALOG_URL;
if (!base) return null;
return `${base.replace(/\/+$/, '')}/agents/${agent.id}/${agent.latest_version}/${agent.package.filename}`;
}

/** Human-readable download size, e.g. "2.3 MB". */
export function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
Expand Down
33 changes: 33 additions & 0 deletions website/src/pages/hub/[id].astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
formatBytes,
platformLabel,
npuLabel,
packageDownloadUrl,
} from '../../data/catalog';
import { renderMarkdown } from '../../data/markdown';

Expand All @@ -41,6 +42,7 @@ const readmeHtml = renderMarkdown(agent.readme);
// no changelog; render the section only when there's content.
const changelogHtml = agent.changelog ? renderMarkdown(agent.changelog) : '';
const req = agent.requirements;
const pkgUrl = packageDownloadUrl(agent);
const terminalLines = [
{ prompt: true, text: installCmd },
{ muted: true, text: `✓ ${agent.name} ${agent.latest_version} installed` },
Expand Down Expand Up @@ -168,6 +170,37 @@ const terminalLines = [
</dl>
</section>

{agent.package && pkgUrl && (
<!-- Whole-package download: one zip with the client, docs, and all platform binaries -->
<section class="rounded-xl border border-g-border bg-g-surface p-5">
<Eyebrow class="mb-2">Package</Eyebrow>
<a
href={pkgUrl}
download={agent.package.filename}
class="flex items-center justify-between gap-3 rounded-md border border-g-gold/40 bg-g-bg/40 px-4 py-2.5 text-[13px] font-medium text-g-gold-text hover:border-g-gold/70 transition-colors"
>
<span>Download .zip</span>
<span class="font-mono text-[12px] text-g-muted">{formatBytes(agent.package.size_bytes)}</span>
</a>
<p class="mt-2 text-[12px] leading-relaxed text-g-muted">
One archive — the client, docs, and every platform binary. Runs on any supported OS.
</p>
<details class="mt-3">
<summary class="cursor-pointer text-[12px] text-g-muted hover:text-g-text">
{agent.package.files.length} files
</summary>
<ul class="mt-2 max-h-64 space-y-1 overflow-y-auto pr-1 text-[11px] font-mono text-g-muted">
{agent.package.files.map((f) => (
<li class="flex items-baseline justify-between gap-2">
<span class="truncate" title={f.name}>{f.name}</span>
<span class="flex-none text-g-muted/70">{formatBytes(f.size_bytes)}</span>
</li>
))}
</ul>
</details>
</section>
)}

<!-- Requirements card -->
<section class="rounded-xl border border-g-border bg-g-surface p-5">
<Eyebrow class="mb-2">Requirements</Eyebrow>
Expand Down
11 changes: 7 additions & 4 deletions workers/agent-hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ depend on any `src/gaia` code.

| Route | Auth | Purpose |
|-------|------|---------|
| `POST /publish` | Bearer | Publish a new agent version (validate → scope-check → immutability-check → checksum → store → rebuild index). Form parts: `manifest` (gaia-agent.yaml text), `artifact` (wheel/binary file), optional `readme` (README.md markdown) and `changelog` (CHANGELOG.md markdown)both rendered on the website Hub pages |
| `POST /publish` | Bearer | Publish a new agent version (validate → scope-check → immutability-check → checksum → store → rebuild index). Form parts: `manifest` (gaia-agent.yaml text), `artifact` (wheel/binary/zip file), optional `readme` + `changelog` (markdown, rendered on the Hub pages), and optional `package_files` (JSON `{files:[{name,size_bytes}]}` listing the contents of a whole-package `.zip` artifactsurfaced as the catalog's `package`) |
| `GET /index.json` | none | Catalog of every agent (latest version only), including the latest README + CHANGELOG markdown |
| `GET /agents/<id>/manifest.json` | none | Per-agent aggregate manifest (all versions) |
| `GET /agents/<id>/<version>/<file>` | none | Download an artifact, the raw `gaia-agent.yaml`, `README.md`, or `CHANGELOG.md` |
Expand Down Expand Up @@ -91,10 +91,13 @@ schemas live in [`schemas/`](./schemas):
`min_disk_gb`, `min_context_size`, `platforms`, `npu` as
`"required"`/`"optional"`, `gpu_vram_gb`), `readme` (latest version's README
markdown, `""` if none was published), `changelog` (latest version's CHANGELOG
markdown, `""` if none was published), and the optional `npm_package` /
markdown, `""` if none was published), the optional `npm_package` /
`playground_url` (present only when the manifest declares them — they drive the
hub page's npm install method and playground launcher). This shape is the
build-time contract for the website Hub pages (`website/src/data/catalog.ts`).
hub page's npm install method and playground launcher), and the optional
`package` (`{ filename, size_bytes, files: [{name, size_bytes}] }` — the
whole-package `.zip` download + its file listing, present only when a
`package_files` manifest was published). This shape is the build-time contract
for the website Hub pages (`website/src/data/catalog.ts`).
- [`schemas/manifest.schema.json`](./schemas/manifest.schema.json) —
`GET /agents/<id>/manifest.json`. Full display metadata plus a `versions` map;
each version carries `published_at`, `publisher`, `deprecated`, an `artifact`
Expand Down
25 changes: 25 additions & 0 deletions workers/agent-hub/schemas/index.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,31 @@
"playground_url": {
"type": "string",
"description": "Localhost playground URL served by the agent's sidecar; absent otherwise."
},
"package": {
"type": "object",
"additionalProperties": false,
"description": "Whole-package download (all platform binaries + client + docs) as a single zip plus its file listing. Present only when a package_files manifest was published for the latest version.",
"required": ["filename", "size_bytes", "files"],
"properties": {
"filename": {
"type": "string",
"description": "Zip artifact filename under the version dir, e.g. \"agent-email-0.2.1.zip\"."
},
"size_bytes": { "type": "integer", "minimum": 0 },
"files": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "size_bytes"],
"properties": {
"name": { "type": "string" },
"size_bytes": { "type": "integer", "minimum": 0 }
}
}
}
}
}
}
}
Expand Down
Loading
Loading