Token broker deployment¶
The broker exchanges a consumer's GitHub Actions OIDC token for a short-lived, repo-scoped
Chargate[bot] installation token, so Chargate's PR comments carry that byline instead of
github-actions[bot].
It runs as an AWS Lambda behind an API Gateway HTTP API, in Chargate's own AWS account.
The Terraform lives in magmamoose/infra under
terraform/aws/chargate/; this repository owns the code that runs on it and the contract for
packaging it.
consumer repo's workflow any pinned chargate tag
scripts/request-app-token.sh POST {oidcToken, owner, repo, ref, runId, sha}
│
▼
broker-chargate.magmamoose.com Cloudflare DNS, PROXIED (orange cloud)
chargate.magmamoose.com also served — the legacy name pinned consumers still call
│ CNAME → the API Gateway custom-domain target
▼
API Gateway HTTP API $default route + stage · throttle 2 rps / burst 10
│ execute-api endpoint disabled: the custom domain is the only door
▼
chargate-broker (Lambda) python3.12 · x86_64 · app.lambda_handler.handler
│ code: s3://<artifacts bucket>/broker/<version>.zip
├── SSM Parameter Store /chargate/prod/{app-id,private-key} (SecureString)
└── api.github.com App JWT → installation → scoped token
GET /healthz proves nothing
It returns 200 on a Lambda with no SSM parameters, no permission to read them, and no
GitHub App installed. It deliberately answers before configuration is consulted, which is
what makes "deploy, then seed the secrets" work. Because
scripts/request-app-token.sh fails soft (empty token, exit 0), a broken broker
produces no red check anywhere, just comments quietly reverting to
github-actions[bot]. Verify with a real signed POST:
.github/workflows/broker-smoke.yml.
The package contract¶
broker/scripts/build_lambda_zip.py is the one definition of what ships. It is run by CI (so
a bad dependency bump fails the pull request) and by the release workflow (which publishes the
artifact). A local build and a released artifact produced by different means is a difference
nobody discovers until production behaves unlike the test.
cd broker
uv run python scripts/build_lambda_zip.py \
--out ../dist/chargate-broker.zip --platform x86_64-manylinux_2_28
What ships: app/ minus main.py, plus the resolved wheels for pyjwt[crypto] and
httpx. ~5.3 MB.
app/main.pyis excluded by name. It is the only module importing FastAPI, which is not in the shipped dependency set; including it turns a clean deploy into a cold-startImportErroron the fail-soft path above.boto3/botocoreare never bundled: the runtime supplies them.--platformis required and has no default, becausecryptographyships compiled wheels and a build on a developer's Mac would otherwise deploy and then fail to import.
The build is deterministic (fixed timestamps, fixed modes, sorted order, resolution pinned
by broker/uv.lock). The release workflow decides whether to publish by comparing against the
previous release tag; a non-reproducible build would mean every chargate release opened an
infra bump PR redeploying byte-identical code until nobody read them.
broker/tests/test_lambda_package.py defends all of this. It unzips the artifact
into an empty directory and importing it in a subprocess with a scrubbed path and poisoned
sys.modules, so a module that grows an import of something unshipped goes red here rather
than at the first real request.
Publishing and deploying are different acts¶
Publishing is .github/workflows/publish-broker.yml, triggered by the release tag. It
builds the zip, gates on whether anything shipped
actually changed since the previous tag, and uploads it with s3api put-object, refusing to
overwrite an existing key. It is deliberately NOT in release.yml, which caldrith provisions
centrally and overwrites in place.
Auth is GitHub OIDC, no AWS credential is stored in this repository. Two repo variables
turn it on; unset, diatreme skips the step and nothing changes:
| Variable | Value |
|---|---|
BROKER_ARTIFACT_BUCKET |
terragrunt output artifact_bucket from infra's chargate-artifacts leaf |
BROKER_PUBLISH_ROLE_ARN |
terragrunt output publish_role_arn from the same leaf |
The key is broker/<version>.zip and diatreme refuses to overwrite one that exists:
infra pins that key, so replacing its bytes would swap the running code under a version
somebody already reviewed.
Deploying is a reviewed one-line bump of broker_artifact_version in infra's
chargate-broker leaf. The release job writes the exact line to its step summary.
Configuration¶
The broker reads its configuration per request, so a Lambda whose parameters are seeded after the first deploy recovers without a redeploy.
| Variable | Type | Required | Default | Effect |
|---|---|---|---|---|
APP_ID |
string | yes | (none) | The Chargate GitHub App's numeric ID. Signs the App JWT. |
PRIVATE_KEY |
string, secret | yes | (none) | The App's RSA private key, PEM. A \n-escaped PEM is un-escaped on load, so a secret store that escapes newlines works as-is. |
SECRET_PATH |
string | no | (unset) | SSM Parameter Store path holding the two values above, for example /chargate/prod. Unset means read everything from the environment and never import boto3, which is the local and CI path. |
OIDC_AUDIENCE |
string | no | chargate |
The aud the caller's token must carry. Must match what the consumer's workflow requests. |
ALLOWED_REPOSITORIES |
string | no | "" |
Comma-separated owner/repo allowlist. Empty means any repository the App is installed on, which is what is deployed. |
GITHUB_API_URL |
string | no | https://api.github.com |
API base. Point it at a GitHub Enterprise host to mint there. |
TOKEN_PERMISSIONS_JSON |
JSON object | no | {"pull_requests": "write"} |
Permissions granted to the minted token. Least privilege: comments only. |
CHARGATE_LOG_LEVEL |
string | no | INFO |
Level for the chargate.broker logger. An unrecognised value falls back to INFO rather than failing at import. |
Precedence: SSM wins over the environment. Values are read from the process environment first, then the SSM overlay is applied on top. That is what keeps the two secrets out of the function's own configuration.
Why the level is set explicitly
The Lambda runtime pins the root logger to WARNING. Without CHARGATE_LOG_LEVEL
the per-request outcome lines, the only observability this service has, are dropped.
Secrets¶
Seeded by hand, never by Terraform: a secret in a Terraform resource is a secret in
Terraform state, and magmamoose/infra is public. They are not Lambda environment variables
either, where anything holding lambda:GetFunctionConfiguration could read them.
aws ssm put-parameter --region eu-west-1 --profile mm-prd-chargate \
--name /chargate/prod/app-id --type String --value "$APP_ID" --overwrite
aws ssm put-parameter --region eu-west-1 --profile mm-prd-chargate \
--name /chargate/prod/private-key --type SecureString \
--value "file://chargate-app.private-key.pem" --overwrite
rm chargate-app.private-key.pem
No --key-id, so the AWS-managed aws/ssm key is used, which carries no monthly charge.
Standard-tier parameters are free for both storage and reads. Verify:
aws ssm get-parameters-by-path --path /chargate/prod --recursive --with-decryption \
--region eu-west-1 --profile mm-prd-chargate
Parameter basenames map to config fields with - → _, so private-key lands on
BrokerConfig.private_key.
Going live¶
Steps marked H need a human with credentials or a console.
- H Confirm the Chargate GitHub App exists, note its App ID, confirm it is installed
on every repo that should get
Chargate[bot]comments, and download the private key. Its absence is invisible, a 404 becomesapp_not_installed, the client fails soft, silence. - Merge infra #638 (it is what creates
terraform/aws/at all), then the chargate infra PR. - H Apply the
chargate-artifactsleaf. SetBROKER_ARTIFACT_BUCKETandBROKER_PUBLISH_ROLE_ARNas repo variables from its outputs. - H Seed the two SSM parameters (above).
- Merge this repo's release changes to
main; confirmbroker/<version>.zipexists in the bucket. This is the cold start, the front door cannot be applied against a key that does not exist. - H Apply
chargate-brokerphase 1:enable_custom_domain = false. This requests the regional ACM certificate and prints the validation record. Nothing resolves yet. - Add the ACM validation CNAME to the Cloudflare leaf (grey cloud, a proxied record answers
with Cloudflare's own value and the certificate never leaves
PENDING_VALIDATION). Apply, wait forISSUED. - H Apply phase 2:
enable_custom_domain = true,disable_default_endpoint = true. Applying phase 2 before the certificate is issued fails with aBadRequestException. -
Add the
broker-chargate.magmamoose.comCNAME → the custom-domain target,proxied = true(orange cloud). The hostname resolves for the first time.Proxying needs the zone on Full (strict), and fails opaquely without it
Proxied, every request returned Cloudflare 521 while AWS saw nothing — API Gateway's
Countmetric had no datapoints and a unique probe path never reached the Lambda. The origin was healthy throughout (TCP/443 on every IP, correct certificate for both plausible SNIs, no AAAA records so not the IPv6 trap), and port 80 was refused on every origin IP — the port a Flexible origin fetch dials. Setting the zone's SSL/TLS mode to Full (strict) is what fixed it.Serve the OLD hostname too, or no consumer gets the byline
token_broker_urlis an action input with a default, so the old hostname is frozen into every tag released before a rename — and consumers pin chargate by SHA through a centrally-provisioned workflow, so they cannot all move at once. The module takesadditional_domain_namesfor exactly this; each entry gets its own certificate, domain and mapping, all free. Remove the entry once nothing references the old name.- H Run
broker-smoke.ymland confirm a real token is minted and accepted by GitHub. Then open a throwaway PR in a consumer repo and check the comment byline. - H Confirm the SNS subscription email, until clicked it delivers nothing, which Terraform reports as "created" either way.
- H Authorise the Slack workspace in the AWS Chatbot console for this account. Chatbot's workspace authorisation is per-account and cannot be done by Terraform; nievah's account being authorised does nothing for chargate's.
- H Run
Rollback before step 9 is "do nothing", nobody is using it. After step 9: delete the CNAME
or set enable_custom_domain = false; consumers return to github-actions[bot], which is
exactly the behaviour they have today.
Cost¶
Roughly under a cent a month. Everything is inside a permanent Always-Free allowance except API Gateway requests and S3 storage.
Note that Always-Free allowances are pooled across the AWS organization, not granted per account, AWS applies the free tier to total usage across all member accounts. And because free-tier eligibility dates from the management account's creation, the 12-month offers (including API Gateway's 1M calls) have already expired for this account, so gateway requests are billed from the first one at $1.00/million.
The controls that actually bound a bad day, in order of how fast they act:
- The 2 rps stage throttle, instantaneous, at the gateway. Deterministically caps Lambda invocations, compute, logs and egress at any load.
disable_execute_api_endpoint, the custom domain is the only door.- The Cloudflare proxy, abusive volume is absorbed at Cloudflare's free edge and never becomes an AWS line item. This is what makes the undocumented 429-billing question moot for traffic that arrives via the hostname.
- The account-level API Gateway throttle. Default 10,000 rps; lowering it to 50 is a support case (magmamoose/infra#642) and is what bounds the one genuinely unresolved question, whether API Gateway bills requests it rejects with its own 429. AWS does not document this.
- CloudWatch alarms and an AWS Budget, receipts, not controls. Budgets refresh at most three times a day and cannot stop spend.