Skip to content

CI deploys with GitHub OIDC

blogwright deploys from CI without any stored AWS credentials. Set githubRepo in your config and bootstrap provisions an IAM role that GitHub Actions assumes via OIDC federation: the workflow exchanges its short-lived GitHub token for temporary AWS credentials, runs blogwright deploy, and nothing long-lived ever exists to leak or rotate.

Add your repository slug to the environment’s config:

config/production.jsonc
{
"region": "us-east-1",
"siteName": "example",
"githubRepo": "owner/repo", // enables the GitHub OIDC deploy role at bootstrap
}

Then run (or re-run) bootstrap for each environment CI should deploy:

Terminal window
blogwright bootstrap # creates production-example-gh
blogwright bootstrap staging # creates staging-example-gh

Bootstrap creates two things:

  • The account-wide OIDC identity provider for token.actions.githubusercontent.com (audience sts.amazonaws.com), created only if it doesn’t already exist.
  • A per-environment deploy role named <env>-<siteName>-gh - e.g. production-example-gh - trusted only for tokens from your repository.

Adding githubRepo to an already-bootstrapped environment works the same way: re-run blogwright bootstrap <env> and the reconcile creates the missing role. Re-running bootstrap also reapplies the trust policy and permissions, so changing githubRepo (a repo rename or transfer) is a config edit plus a bootstrap.

The role’s trust policy pins the token’s audience to sts.amazonaws.com and matches the OIDC subject claim per environment, mirroring how each one is meant to deploy:

Environment Subject claim Meaning
production repo:owner/repo:environment:production Only jobs running in the production GitHub environment
Any other (e.g. staging) repo:owner/repo:ref:refs/heads/main Only workflow runs on the main branch
Preview stack repo:owner/repo:* Any ref - PRs deploy and destroy previews

Scoping production to a GitHub environment rather than a branch lets you gate deploys behind environment protection rules - required reviewers, wait timers - in the repository settings. The workflow job must declare environment: production, or the token’s subject won’t match and AssumeRoleWithWebIdentity fails.

The role is scoped to one environment’s resources, not the account:

  • Read, write, and delete objects in the environment’s S3 bucket (source zips, the built site, staged releases, state, and the environment lock). In a static environment it may also read object tags under releases/* and write them under site/* - exactly what promote needs to carry a staged release’s tags into site/, and nothing wider. The preview role gets no tagging grant.
  • Run and manage builder MicroVMs, and rebuild the builder image when the agent bundle changed - so build-agent updates ship through CI without a separate bootstrap.
  • Read the MicroVM build logs, and pass the environment’s build and exec roles.
  • Create CloudFront invalidations on the environment’s distribution (previews are never cached, so the preview role omits this).
  • When headers is configured, read and update the environment’s own response headers policy (cloudfront:GetResponseHeadersPolicy and cloudfront:UpdateResponseHeadersPolicy on that one policy’s ARN), so a deploy or promote keeps the headers in step with the config. It cannot attach or detach the policy - that changes the distribution, which stays a bootstrap action. The grant names the policy id that bootstrap records, so it appears after the first bootstrap with headers set. deploy makes these calls only once bootstrap has recorded the policy as attached; it tells an unattached policy from state alone, so the role has no distribution read. The preview role never gets it.
  • In an SSR environment, publish revisions of the environment’s own Web Function: create revisions and read or list that function’s revisions and endpoints (never account-wide), invoke its endpoint for the deploy preflight and readiness probe, pass its execution role, and read artifact object versions (s3:GetObjectVersion) so rollback can reproduce a pinned revision. It cannot create or delete the function, change its endpoint, or touch the distribution’s configuration - bootstrap, including a --switch-mode bootstrap, stays an operator action. The grants are added by the same bootstrap that provisions the function, since the endpoint grant names the endpoint ARN it records.
  • When standard.site publishing is configured and blogwright pds bootstrap <env> has been run once for the environment, read and write the OAuth secret in Secrets Manager - the post-deploy sync rotates the session on every run. That grant is no longer part of the role’s <env>-deploy policy: it is a separate inline policy named blogwright-pds, owned and reconciled by the pds plugin. The preview role never gets it - its subject claim accepts any ref, while the secret is shared across environments - so preview deploys of a standard.site do not rotate the session.

When upgrading from the additive PDS migration release, run blogwright pds bootstrap <env> before the next blogwright bootstrap <env> in every configured non-preview environment (for example, production or staging). Site bootstrap rewrites the entire <env>-deploy policy, removing the old secret statement. If this step was missed, run blogwright pds bootstrap <env> to restore secret access; the next deploy can sync to the PDS again. The site deploy itself still succeeds while sync is unavailable.

The name is deterministic, so the ARN is too:

arn:aws:iam::<accountId>:role/<env>-<siteName>-gh

blogwright status <env> also prints each resource’s recorded outputs, including the deploy role’s ARN.

aws-actions/configure-aws-credentials exports AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN into the job. Exported credentials take precedence over the config’s aws.profile, so a profile you committed for local use is ignored in CI - the command logs ignoring aws.profile "<name>": environment credentials are set - and nothing breaks for want of a ~/.aws/config on the runner. (A self-hosted runner on an EC2 instance role exports nothing, so there export the instance credentials first - see troubleshooting.)

The aws.accountId pin still applies. If the role CI assumed belongs to another account, the deploy stops before it reads any state. The identity line each command prints goes to stderr in CI, so the job log always records which account the deploy ran against.

The workflow needs id-token: write to mint an OIDC token, and contents: read to check out the repo. aws-actions/configure-aws-credentials assumes the role and exports the temporary credentials into the environment, where blogwright’s ambient provider chain picks them up.

.github/workflows/staging.yml
name: Deploy staging
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/staging-example-gh
aws-region: us-east-1
- run: pnpm exec blogwright deploy staging

Identical shape, with two changes: the job runs in the production GitHub environment (the trust condition requires it), and the trigger is a release rather than a push. The trust policy only checks the environment claim - the trigger is yours to choose - but releasing production deliberately, gated by environment protection rules, is the intended flow.

.github/workflows/production.yml
name: Deploy production
on:
release:
types: [published]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/production-example-gh
aws-region: us-east-1
- run: pnpm exec blogwright deploy

To gate the release instead - build when the release is drafted, promote the reviewed bytes when it is published - run blogwright stage in one job and blogwright promote <hash> in a later one (both in the production environment); see Release gating. promote checks the staged bytes against their artifact manifest before it copies anything.

blogwright preview bootstrap creates its own role, preview-<siteName>-gh, trusted for any ref in the repository so a PR workflow can deploy a preview on open/update and destroy it on close. Its access is scoped to the preview stack’s own bucket, and it has no CloudFront invalidation permission - previews are served uncached. See PR previews for the preview workflow.