Evaluate a custom policy: code on GitHub, weights in private storage
CI assembles a container from a GitHub repo and private S3 weights, pins its digest, and submits 600 humanoid episodes under a $4,000 cap.
GitHubAmazon S3Docker
Not every policy is a checkpoint a managed runtime can load. This one is
custom: the inference server lives in a GitHub repo, the weights sit in
a private S3 bucket, and there is no s3:// checkpoint source or
storage-credential field to point at them. Neither needs to be
importable. A GitHub Actions job assembles a container, pushes it to
ghcr.io, and Roborama runs the container — the credential that can
read your bucket exists for the duration of one CI job and never
reaches us.
- Lay out the pieces
github.com/acme/skill ├── server/serve.py # the inference server ├── server/policy.py ├── requirements.txt # pinned dependencies ├── Dockerfile └── .github/workflows/evaluate.yml s3://acme-checkpoints/skill/v4/ ├── model.safetensors ├── vision_encoder.safetensors └── manifest.txt # every file + its sha256The server implements the cell interface: it consumes the observations named by
droid-3cam, emitsjoint_delta_50hzactions at the declared rate, and clears its per-episode state at each episode boundary — scene reset is the cell's job, never the policy's. Run your container covers the interface; Import from private storage covers why the bucket stays private.The manifest is what makes the weights auditable: CI verifies every file against its sha256 before anything goes into an image, so "which weights are in production" always has a checkable answer.
- Write the Dockerfile
CI downloads the verified weights into
weights/before the build, so the image is self-contained — weights baked in, credentials not:FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY server/ server/ # weights/ is populated by CI before `docker build` runs; # nothing in the image can read the bucket COPY weights/ /app/weights/ ENV SKILL_WEIGHTS_DIR=/app/weights ENTRYPOINT ["python", "-m", "server.serve"] - Let CI assemble, verify, and submit
One
workflow_dispatchworkflow does the whole assembly: assume a scoped AWS role via OIDC (no long-lived key in secrets), fetch and checksum the weights, build and push the image, capture its digest, then quote and submit. The conventions from CI gates carry over unchanged: the API key arrives assecrets.ROBORAMA_API_KEY, and the submission carries a hardmax_budget_usd.name: evaluate-on-roborama on: workflow_dispatch: inputs: weights_version: description: "Prefix under s3://acme-checkpoints" default: skill/v4 permissions: contents: read packages: write # push to ghcr.io id-token: write # OIDC — no long-lived AWS key in secrets jobs: evaluate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Assume the scoped S3 read role (OIDC) uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: >- arn:aws:iam::123456789012:role/rbr-weights-read aws-region: us-east-1 - name: Fetch and verify the weights run: | aws s3 cp --recursive \ "s3://acme-checkpoints/${{ inputs.weights_version }}/" \ weights/ cd weights && sha256sum -c manifest.txt - name: Log in to ghcr.io run: | echo "${{ secrets.GITHUB_TOKEN }}" \ | docker login ghcr.io -u "${{ github.actor }}" \ --password-stdin - name: Build, push, capture the digest id: build run: | docker build -t ghcr.io/acme/skill:v4 . docker push ghcr.io/acme/skill:v4 digest=$(docker inspect ghcr.io/acme/skill:v4 \ --format '{{ index .RepoDigests 0 }}') echo "digest=${digest#*@}" >> "$GITHUB_OUTPUT" - name: Quote (free — both meters, before anything books) env: ROBORAMA_API_KEY: ${{ secrets.ROBORAMA_API_KEY }} run: | curl -sS --fail https://api.roborama.com/v1/quote \ -H "Authorization: Bearer $ROBORAMA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "robot": "g1-edu-pro", "environment": "kitchen-std", "episodes": 600 }' - name: Submit the run env: ROBORAMA_API_KEY: ${{ secrets.ROBORAMA_API_KEY }} run: | # pin the exact image this job built — commit # ${{ github.sha }} is what gets evaluated img="ghcr.io/acme/skill@${{ steps.build.outputs.digest }}" run_id=$(curl -sS --fail \ https://api.roborama.com/v1/runs \ -H "Authorization: Bearer $ROBORAMA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "run", "robot": "g1-edu-pro@fw2.3", "environment": "kitchen-std@v1.2", "policy": { "type": "container", "image": "'"$img"'", "action_space": "joint_delta_50hz", "observation_contract": "droid-3cam" }, "task": "load_dishwasher@v2", "episodes": "auto(ci=0.95, moe=0.03)", "max_budget_usd": 4000 }' | jq -r '.id') echo "run=$run_id commit=${{ github.sha }}" \ >> "$GITHUB_STEP_SUMMARY"The role behind
rbr-weights-readgrantss3:GetObjectonacme-checkpoints/skill/*and nothing else, and the assumed credential lives and dies inside the job. One visibility check before the first dispatch: the facility pullsghcr.io/acme/skillanonymously — ghcr packages default to private, so flip the package to public (registry pull credentials are not supported today). - Pin the digest, buy the episodes
ghcr.io/acme/skill:v4is a tag, and tags move. The digest names the exact bytes CI just built — the same reflex as pinningg1-edu-pro@fw2.3instead of "whatever firmware was around", and it is what the verification report should cite. Outside CI — from a workstation, or an agent — the same submission looks like this:pin the digest, buy the episodesimport roborama # reads ROBORAMA_API_KEY from the environment # The digest CI captured at push — the exact image, not a tag image = ( "ghcr.io/acme/skill@sha256:" "4e5b1a7c9d0f2a3b5c6d8e9f0a1b2c3d" "4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a90" ) policy = roborama.Policy.container( image=image, action_space="joint_delta_50hz", observation_contract="droid-3cam", ) run = roborama.run( robot="g1-edu-pro@fw2.3", environment="kitchen-std@v1.2", policy=policy, task="load_dishwasher@v2", episodes="auto(ci=0.95, moe=0.03)", max_budget_usd=4000, ) # Later — when the run completes print(run.result()) # n=612 rate=0.874 ci95=(0.846, 0.898) # failure_clusters: {grasp_slip: 41, perception_miss: 22, # collision: 9} # robot_hours=20.4 environment_hours=20.4 cost_usd=3812 run.report(format="pdf") # the citable verification reportepisodes="auto(ci=0.95, moe=0.03)"sizes the run to a ±3-point margin of error instead of a fixed n. Admission validates the declared contracts against the pinned robot and scene before any motor moves — a rejected submission returnscontract_validation_failedand costs nothing. - What the quote comes back as
Back in the Actions log, the quote step printed the price of asking: 600 humanoid episodes in a replica kitchen, both meters visible:
{ "object": "quote", "robot": "g1-edu-pro", "environment": "kitchen-std", "episodes": 600, "priority": "standard", "robot_hours": 20.0, "env_hours": 20.0, "usd": 3740, "queue_eta": "6h", "breakdown": { "minutes_per_episode_est": 2.0, "robot_usd_per_hour": 148, "env_usd_per_hour": 39, "robot_usd": 2960, "env_usd": 780 }, "expires": "2026-09-08T00:00:00Z" }Nothing has billed yet — quotes are free, rejected submissions are free. The meter starts when a motor moves.
- Read the result, keep the evidence
run_8842, complete:{ "n": 612, "success_rate": 0.874, "ci95": [0.846, 0.898], "interval_method": "wilson", "failure_clusters": { "grasp_slip": 41, "perception_miss": 22, "collision": 9 }, "robot_hours": 20.4, "environment_hours": 20.4, "cost_usd": 3812 }The auto-sized run settled at n=612 — the margin-of-error target, not the quote's 600, decided when to stop — and the meters billed what ran: 20.4 hours each, $3,812 against the $3,740 estimate, well inside the $4,000 cap. 0.874 with a Wilson interval of (0.846, 0.898) is a number a release review can lean on, and the clusters say where the other episodes went.
run.report(format="pdf")renders the verification report — rate, interval, resolved pins, the image digest among them.run.episodesholds the per-episode evidence: video, MCAP telemetry, and a replay spec for each episode, so everycollisionis a click away. And the workflow's last line wrote the run id andgithub.shato the job summary — the result is traceable to the commit that produced it. - When v5 ships
Same
workflow_dispatch,weights_version: skill/v5, a new digest — nothing else changes. To separate v5 from v4 rather than re-measure it, hand both images toroborama.compare(): paired episodes replay the same initial conditions for both, so the verdict arrives in about half the episodes of two independent runs.
Where next
- Run your container — the cell interface your server implements, in detail.
- Package code as a container — the container path without the CI framing.
- CI gates — turn this workflow into a release check that fails on regression.
- compare() — v5 against v4, paired.