Deploy from GitHub Actions
A push to the group's repo changes nothing by itself: the workers serve what the last deploy synced. This page
wires the two together. A GitHub Actions job holds a console API key, asks the console to deploy the group's
environment when mcp/ changes on main, and waits for the deploy job to finish, failing the run when the deploy
fails. The demo repo carries the same workflow.
1. Create an API key for CI
Use a devops key (rmn_) with the least it needs: role Group Admin, one group. A group admin of demo may
deploy demo and nothing else. Only a super admin can create API keys.
On the API keys page: Name ci-demo, Client type Devops — console API, Role Group Admin, pick the group
demo and press Add, then Generate key. Name the group explicitly: a key with no group takes the creator's
groups, and a super admin has none of their own, so that key could deploy nothing (403). The key appears once,
under the form.
Or through the API, as a super admin (session plus CSRF header, or an existing super-admin key):
CONSOLE=https://<console> # the base URI, path prefix included
curl -s -c c.txt -X POST $CONSOLE/login --data-urlencode email=admin@ramen.local --data-urlencode password='...' -o /dev/null
CSRF="X-Ramen-CSRF: $(awk '$6=="ramen_csrf"{print $7}' c.txt)"
curl -s -b c.txt -H "$CSRF" -H 'Content-Type: application/json' -X POST $CONSOLE/api/v1/api-keys \
-d '{"name":"ci-demo","role":"group_admin","groups":["demo"]}'
# 201 {"name":"ci-demo","role":"group_admin","groups":["demo"],"client_type":"devops",...,"id":"41ec71772452","key":"rmn_..."}
Store it in the repo, never in a file:
gh secret set RAMEN_API_KEY --repo <owner>/<group repo> # paste the rmn_ key when asked
gh variable set RAMEN_URL --repo <owner>/<group repo> --body https://<console>
gh variable set RAMEN_GROUP --repo <owner>/<group repo> --body demo
gh variable set RAMEN_ENV --repo <owner>/<group repo> --body prod
gh secret set RAMEN_CA_PEM --repo <owner>/<group repo> < ramen-lb.pem # only for a self-signed console
RAMEN_URL is the address the console is reached at: its base URI,
path prefix included (https://ops.example.com/ramen). The job calls $RAMEN_URL/api/v1/..., which a prefix
proxy forwards to the console like any other path. RAMEN_CA_PEM is for a console behind a self-signed
certificate, such as the one the AWS guide exports from ACM; a GCP console has a
publicly trusted certificate and needs none.
2. How code reaches a group
The group's git repo is the source, and the console is the only thing that reads it. A deploy job:
- clones or fetches the repo at the environment's ref (or, if the environment has none, the group's), on the console, and copies it into the group's bucket prefix;
- writes each zone's config, rolls a canary, reloads and smoke-tests it, then rolls the stable pods (Deploying).
So the workflow needs no checkout and no GitHub token: it only tells the console to deploy. The deploy takes the
tip of the ref when the job syncs, not the commit that triggered the run; a second push during a deploy is
picked up by the next one. An environment pinned to a tag deploys that tag whatever main holds.
A private repo needs the console to authenticate, in this order of preference:
| Where | What |
|---|---|
| Group page, GitHub App installation id | With the GitHub App configured once on the Config page, the console mints a short-lived installation token per deploy. Nothing long-lived is stored for the group |
Secrets, GITHUB_TOKEN |
A fine-grained token with Contents: read on the repo, as a group secret or scoped to the environment |
| Group page, Fallback GitHub token | The same kind of token stored on the group, used when there is no GITHUB_TOKEN secret |
The token goes to git as an HTTP header for that one command, never into the remote URL, the clone's config or the job log.
3. The workflow
Commit this as .github/workflows/ramen-deploy.yml in the group's repo. It runs on a push to main that touches
mcp/, and by hand from the Actions tab with a canary switch. Without RAMEN_URL and RAMEN_API_KEY it
prints a notice and does nothing, so forks and fresh clones stay green.
# Deploys this group to a Ramen console when mcp/ changes on main, and waits for the deploy job.
# Settings (repo → Settings → Secrets and variables → Actions):
# secret RAMEN_API_KEY rmn_ key, role Group Admin, groups [<this group>] (required)
# variable RAMEN_URL the console's address, the base URI with any path prefix (required)
# variable RAMEN_GROUP the group's name in Ramen (default demo)
# variable RAMEN_ENV the environment to deploy (default prod)
# secret RAMEN_CA_PEM PEM of a self-signed console certificate (optional)
# Without RAMEN_URL and RAMEN_API_KEY the job does nothing and says so.
name: ramen-deploy
on:
push:
branches: [main]
paths: ["mcp/**"]
workflow_dispatch:
inputs:
canary:
description: Roll a canary and smoke-test it before the stable pods
type: boolean
default: true
permissions: {} # the console clones the repo itself; this job needs no GitHub token
concurrency:
group: ramen-deploy-${{ vars.RAMEN_GROUP || 'demo' }}-${{ vars.RAMEN_ENV || 'prod' }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
RAMEN_URL: ${{ vars.RAMEN_URL }}
RAMEN_API_KEY: ${{ secrets.RAMEN_API_KEY }}
RAMEN_CA_PEM: ${{ secrets.RAMEN_CA_PEM }}
RAMEN_GROUP: ${{ vars.RAMEN_GROUP || 'demo' }}
RAMEN_ENV: ${{ vars.RAMEN_ENV || 'prod' }}
CANARY: ${{ github.event_name != 'workflow_dispatch' || inputs.canary }}
DEPLOY_TIMEOUT_SECS: "900"
steps:
- name: Not configured
if: env.RAMEN_URL == '' || env.RAMEN_API_KEY == ''
run: echo "::notice::RAMEN_URL or RAMEN_API_KEY is not set; nothing deployed."
- name: Deploy and wait for the job
if: env.RAMEN_URL != '' && env.RAMEN_API_KEY != ''
run: |
set -euo pipefail
U="${RAMEN_URL%/}/api/v1"
TLS=()
if [ -n "${RAMEN_CA_PEM:-}" ]; then
CA="${RUNNER_TEMP:-/tmp}/ramen-ca.pem"; printf '%s\n' "$RAMEN_CA_PEM" > "$CA"; TLS=(--cacert "$CA")
fi
OUT="${RUNNER_TEMP:-/tmp}/ramen-out.json"
api() { # METHOD PATH [JSON] -> HTTP status (000: no answer); body in $OUT
rm -f "$OUT"
curl -sS ${TLS[@]+"${TLS[@]}"} --max-time 30 -o "$OUT" -w '%{http_code}' -X "$1" "$U$2" \
-H "X-Ramen-Api-Key: $RAMEN_API_KEY" -H 'Content-Type: application/json' ${3:+-d "$3"} || true
}
code=$(api POST "/groups/$RAMEN_GROUP/environments/$RAMEN_ENV/deploy" "{\"canary\": $CANARY}")
case "$code" in
202) ;;
000) echo "::error::no answer from $U (network, or TLS: set RAMEN_CA_PEM for a self-signed console)"; exit 1 ;;
401) echo "::error::401: the API key is wrong, revoked or missing"; exit 1 ;;
403) echo "::error::403: the key may not deploy $RAMEN_GROUP (role or groups): $(cat "$OUT")"; exit 1 ;;
*) echo "::error::deploy refused with HTTP $code: $(cat "$OUT" 2>/dev/null)"; exit 1 ;;
esac
JOB=$(jq -r .id "$OUT")
echo "deploy job $JOB: $RAMEN_GROUP/$RAMEN_ENV, canary=$CANARY"
deadline=$((SECONDS + DEPLOY_TIMEOUT_SECS))
while :; do
sleep 5
code=$(api GET "/jobs/$JOB")
status=unknown
if [ "$code" = 200 ]; then status=$(jq -r '.status // "unknown"' "$OUT" 2>/dev/null || echo unknown); fi
if [ "$code" = 404 ]; then
echo "::error::job $JOB is gone: jobs live in the console's memory, so it restarted. See the group page."
exit 1
fi
case "$status" in
ok)
jq -r '.log[]' "$OUT"; echo "deploy ok"; exit 0 ;;
error)
jq -r '.log[]' "$OUT"; echo "::error::deploy failed: $(jq -r '.error' "$OUT")"; exit 1 ;;
esac
# running, or a load-balancer blip (503, non-JSON) while the console restarts: keep polling
if [ "$SECONDS" -ge "$deadline" ]; then
echo "::error::no result after ${DEPLOY_TIMEOUT_SECS}s (last HTTP $code, status $status)"; exit 1
fi
done
What it does, in order:
POST $RAMEN_URL/api/v1/groups/<group>/environments/<env>/deploywith{"canary": true}and theX-Ramen-Api-Keyheader.202carries the job id.GET $RAMEN_URL/api/v1/jobs/<id>every five seconds untilstatusisokorerror, then prints the job log.errorfails the run with the job's error line, and so does no answer withinDEPLOY_TIMEOUT_SECS.- A
503or a non-JSON answer while it polls (a load balancer blip during a console restart) is ridden out. A404for the job is not: jobs live in the console's memory, so the console restarted (or another console replica answered) and the outcome is on the group page's environment row. concurrencyqueues a second run for the same environment behind the first instead of starting two deploys.
A successful run ends like this (the local compose stack, zone local):
deploy job f6b28cac554645c7b3115046906aaaab: demo/dev, canary=true
2026-10-04T14:25:27+00:00 syncing repo https://github.com/bkraad47/ramen-demo-mcp-group
2026-10-04T14:25:29+00:00 zone local: deploying (canary=on)
deploy ok
A failed deploy prints its log and fails the run, here an environment whose ref does not exist:
deploy job 7fc641bf16774099823ef3c0dc51cbc0: demo/cifail, canary=true
2026-10-04T14:26:40+00:00 syncing repo https://github.com/bkraad47/ramen-demo-mcp-group
2026-10-04T14:26:41+00:00 zone local: aborting, canary scaled to 0
::error::deploy failed: RuntimeError: git failed: fatal: couldn't find remote ref no-such-branch
Both runs are the workflow's own run: block, taken from the YAML and executed against the compose stack with a
key minted as in step 1; so were a run through a prefix proxy (RAMEN_URL=http://localhost:9090/ramen with the
base URI set to it) and every row of the table below except 422. The workflow passes actionlint.
4. Rotate and revoke
- Rotate: create a second key,
gh secret set RAMEN_API_KEYwith it, run the workflow once by hand, then Revoke the old key on the API keys page (orDELETE /api/v1/api-keys/<id>). A revoked key fails at once. - Revoke on a leak first, then rotate. The audit log lists every deploy the key started, as
deploy.startanddeploy, under the key's name.
| Answer | Meaning |
|---|---|
401 |
The key is wrong, revoked, or not sent. Check the secret's name and that it holds the whole rmn_... value |
403 with Agent key cannot call the console API |
An rmk_ key was stored; CI needs a devops rmn_ key |
403 otherwise |
The key's role or groups do not cover this group: it needs Group Admin on it |
404 on the deploy |
The group or environment name is wrong (RAMEN_GROUP, RAMEN_ENV), or RAMEN_URL lacks the path prefix |
422 |
The environment has no such zone, or the body is malformed |
No answer (000, curl: (60) above it) |
A self-signed console without RAMEN_CA_PEM, or a RAMEN_URL the runner cannot reach |
See API keys and the API for the rest of the API, and Groups, zones and regions for what a deploy does in each zone.