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
53 changes: 40 additions & 13 deletions .github/workflows/release_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ jobs:

- name: Install publisher deps
if: needs.version.outputs.dry_run == 'false'
run: python -m pip install --upgrade requests pyyaml
run: python -m pip install --upgrade requests pyyaml boto3

- name: Publish to the Agent Hub (POST /publish)
if: needs.version.outputs.dry_run == 'false'
Expand All @@ -340,6 +340,13 @@ jobs:
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 }}
# Artifacts at/over 90 MiB cannot travel through the Worker at all
# (Cloudflare caps request bodies at 100 MB on Free/Pro), so the
# publisher PUTs them straight into R2 and publishes them by
# reference. Only the Agent UI installers hit this today.
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
# Explicit =<platform-key> on every artifact: the publisher's
Expand Down Expand Up @@ -385,6 +392,30 @@ jobs:
# yet. Wait for the full set, bounded — and fail loudly on timeout rather
# than publishing a partial platform set that the catalog would then
# advertise as complete.
# Before the wait, not after: this job can sit for up to an hour on the
# installer assets and then pull ~400 MB, and discovering a missing secret
# at the end wastes all of it. Covers the R2 credentials too — the
# installers are large enough to take the direct-to-R2 lane, so those are
# as load-bearing here as the hub token.
- name: Require the publish credentials
if: needs.version.outputs.dry_run == 'false'
env:
GAIA_HUB_TOKEN: ${{ secrets.GAIA_HUB_TOKEN }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
missing=""
[ -n "${GAIA_HUB_TOKEN:-}" ] || missing="${missing} GAIA_HUB_TOKEN"
[ -n "${R2_ACCESS_KEY_ID:-}" ] || missing="${missing} R2_ACCESS_KEY_ID"
[ -n "${R2_SECRET_ACCESS_KEY:-}" ] || missing="${missing} R2_SECRET_ACCESS_KEY"
[ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ] || missing="${missing} CLOUDFLARE_ACCOUNT_ID"
if [ -n "${missing}" ]; then
echo "::error::missing secret(s):${missing}. GAIA_HUB_TOKEN must match the Worker's PUBLISH_TOKENS; the R2_* pair are R2 S3 credentials (Cloudflare -> R2 -> Manage API Tokens, Object Read & Write) needed because these installers exceed the Worker request-body cap and go straight to R2. See workers/agent-hub/README.md."
exit 1
fi

- name: Wait for the installer assets on the release
if: needs.version.outputs.dry_run == 'false'
shell: bash
Expand Down Expand Up @@ -431,20 +462,9 @@ jobs:
--pattern "gaia-agent-ui-${VERSION}-x86_64.AppImage"
ls -la dist

- name: Require the publish token
if: needs.version.outputs.dry_run == 'false'
env:
GAIA_HUB_TOKEN: ${{ secrets.GAIA_HUB_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GAIA_HUB_TOKEN:-}" ]; then
echo "::error::missing environment secret GAIA_HUB_TOKEN on the agent-publish environment (must match the Worker's PUBLISH_TOKENS). See workers/agent-hub/README.md."
exit 1
fi

- name: Install publisher deps
if: needs.version.outputs.dry_run == 'false'
run: python -m pip install --upgrade requests pyyaml
run: python -m pip install --upgrade requests pyyaml boto3

- name: Publish to the Agent Hub (POST /publish)
if: needs.version.outputs.dry_run == 'false'
Expand All @@ -453,6 +473,13 @@ jobs:
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 }}
# Artifacts at/over 90 MiB cannot travel through the Worker at all
# (Cloudflare caps request bodies at 100 MB on Free/Pro), so the
# publisher PUTs them straight into R2 and publishes them by
# reference. Only the Agent UI installers hit this today.
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
Expand Down
107 changes: 105 additions & 2 deletions hub/agents/email/python/packaging/publish_to_r2.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
from __future__ import annotations

import argparse
import base64
import contextlib
import hashlib
import json
import os
Expand Down Expand Up @@ -118,6 +120,74 @@ def _download_sha256(base_url: str, agent_id: str, version: str, filename: str)
return hashlib.sha256(resp.content).hexdigest()


# Cloudflare caps a Worker request body by plan — 100 MB on Free/Pro. Anything
# at or above this goes straight to R2 over the S3 API instead, and is published
# by reference. Deliberately below the real cap so an artifact that grows into
# the limit switches lanes before it starts 413ing mid-release.
DIRECT_UPLOAD_THRESHOLD = 90 * 1024 * 1024


def _r2_credentials() -> tuple[str, str, str] | None:
"""R2 S3 credentials, or None when direct upload is not configured."""
key = os.environ.get("R2_ACCESS_KEY_ID")
secret = os.environ.get("R2_SECRET_ACCESS_KEY")
account = os.environ.get("CLOUDFLARE_ACCOUNT_ID")
if key and secret and account:
return key, secret, account
return None


def _upload_to_r2(artifact_path: Path, key: str, sha_hex: str) -> None:
"""PUT an artifact straight into the hub bucket, bypassing the Worker.

Single-part on purpose: R2 records a whole-object SHA-256 only for
non-multipart uploads, and the Worker refuses to publish an object it cannot
verify. ``put_object`` is always single-part (``upload_file`` would switch to
multipart above its threshold and silently strip the checksum).
"""
try:
import boto3 # imported lazily: only the direct-upload path needs it
except ImportError as e: # pragma: no cover - environment problem, not logic
raise SystemExit(
"error: boto3 is required to upload artifacts larger than "
f"{DIRECT_UPLOAD_THRESHOLD} bytes directly to R2. "
"Install it (pip install boto3) and re-run."
) from e

creds = _r2_credentials()
if creds is None:
raise SystemExit(
f"error: {artifact_path.name} is too large to publish through the "
"Worker (Cloudflare caps request bodies at 100 MB on Free/Pro), so it "
"must go directly to R2 — but R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY "
"and CLOUDFLARE_ACCOUNT_ID are not all set. Create an R2 API token "
"(Cloudflare dashboard -> R2 -> Manage API Tokens) with Object "
"Read & Write on the hub bucket and set all three."
)
access_key, secret_key, account_id = creds
bucket = os.environ.get("R2_BUCKET", "gaia-hub")

client = boto3.client(
"s3",
endpoint_url=f"https://{account_id}.r2.cloudflarestorage.com",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name="auto",
)
print(
f"[publish] uploading {artifact_path.name} -> r2://{bucket}/{key}", flush=True
)
with artifact_path.open("rb") as fh:
client.put_object(
Bucket=bucket,
Key=key,
Body=fh,
ContentType="application/octet-stream",
# Base64, not hex — and this is what makes the object verifiable.
ChecksumSHA256=base64.b64encode(bytes.fromhex(sha_hex)).decode("ascii"),
)


def publish_one(
base_url: str,
manifest_path: Path,
Expand Down Expand Up @@ -148,15 +218,48 @@ def publish_one(
flush=True,
)

with artifact_path.open("rb") as fh:
# Oversized artifacts cannot travel through the Worker at all (Cloudflare
# caps the request body at 100 MB on Free/Pro), so they go straight to R2 and
# the POST below only carries their coordinates. The Worker verifies the
# stored object's size and SHA-256 before recording it, so this is a
# different transport, not a weaker guarantee.
by_reference = size >= DIRECT_UPLOAD_THRESHOLD
if by_reference:
# Check BEFORE uploading. The Worker's by-reference immutability guard
# keys on the agent manifest and therefore fires only AFTER this PUT
# would already have replaced the published bytes — leaving the catalog
# describing the old artifact while R2 serves the new one, and the 409
# handler below re-downloading the bytes it just overwrote and happily
# agreeing with itself. Skipping the upload keeps that 409 meaningful.
download_url = f"{base_url.rstrip('/')}/agents/{agent_id}/{version}/{filename}"
head = requests.head(download_url, timeout=60, allow_redirects=True)
if head.status_code == 200:
print(
f"[publish] {filename} is already in R2 — not overwriting it; "
"the POST below verifies the stored bytes against this build.",
flush=True,
)
else:
_upload_to_r2(
artifact_path, f"agents/{agent_id}/{version}/{filename}", local_sha
)

with contextlib.ExitStack() as stack:
files = {
"manifest": (
"gaia-agent.yaml",
manifest_path.read_bytes(),
"application/x-yaml",
),
"artifact": (filename, fh, "application/octet-stream"),
}
if by_reference:
files["artifact_ref_filename"] = (None, filename)
files["artifact_ref_sha256"] = (None, local_sha)
files["artifact_ref_size"] = (None, str(size))
files["artifact_ref_content_type"] = (None, "application/octet-stream")
else:
fh = stack.enter_context(artifact_path.open("rb"))
files["artifact"] = (filename, fh, "application/octet-stream")
# Same multipart field name + shape the Worker expects from
# `gaia agent publish` (src/gaia/hub/publisher.py): the README becomes
# the catalog entry's `readme` (rendered as sanitized markdown on the
Expand Down
Loading
Loading