Wire the GitHub Actions CI path so every future Terraform apply happens automatically on merge to main, gated by a manual approval, with no human credentials touching AWS.
Until this step, the architectural property "no long-lived AWS credentials on any laptop after bootstrap" has been a memo, not a fact — because the operator's SSO admin session has been running every apply from Steps 2 through 4. This step closes that gap. After this, the only AWS credentials used by the build process come from short-lived GitHub OIDC tokens consumed by GitHub Actions runners, with a human approver as the gate before any change is applied. The repo becomes the single source of truth for what runs in the AI-sandbox account; merging to main is the apply trigger; manual approval is the operational checkpoint.
If skipped: Every future apply continues to require the operator's laptop and a fresh SSO admin session. The deploy role bootstrap created sits unused. The "no long-lived credentials" doctrine is one apply away from a violation every time someone is in a hurry.
Bootstrap (Step 1) created two resources whose entire purpose is letting CI assume an AWS role: the GitHub OIDC provider, and the ai-sandbox-github-actions-deploy role with a trust policy scoped to PayCargoDevOps/ai-sandbox. Those resources are live and the trust policy is correctly cased. They just are not being used — because no GitHub Actions workflow file references them.
Step 3a closes the gap between "the deploy role exists" and "the deploy role is the apply path." Two small workflow files in .github/workflows/ are all that's needed. The harder work is the repo-side configuration: a GitHub Environment with required reviewers, set up by hand in the GitHub UI.
terraform apply does, which is the prerequisite for safely letting CI do them later.The cost of doing the next four blocks (Step 4a through 5g) by laptop apply is concrete: every one of those modules will have been "applied without going through the CI path that exists for exactly this purpose." The doctrine of "no long-lived AWS credentials anywhere" stays partially true (SSO is short-lived) but the auditable, peer-reviewable, blame-attributable apply trail lives only in terraform.tfstate, not in GitHub. After this step, every apply has a corresponding GitHub workflow run with a named human approver in the audit trail.
| Artifact | Where it lives | What it does |
|---|---|---|
terraform-plan.yml |
.github/workflows/ (in this repo) |
Runs on every pull request that touches terraform/. Initializes Terraform against the remote backend, runs terraform plan on the composition, posts the plan output as a PR comment. Read-only on AWS — no apply permissions used. |
terraform-apply.yml |
.github/workflows/ |
Runs on every push to main that touches terraform/. Waits for manual approval from a designated reviewer in the GitHub production environment, then runs terraform plan + terraform apply on the composition. |
GitHub Environment production |
GitHub repo settings (manual UI configuration) | Defines the manual-approval gate. Reviewer must explicitly click "Approve and deploy" before terraform apply runs. This is the operational checkpoint. |
Updates to README and _process/Step.3a.html |
Documentation | Documents the new apply path so future engineers don't keep running laptop applies out of habit. |
# .github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
paths:
- 'terraform/**'
- '.github/workflows/terraform-*.yml'
permissions:
id-token: write # required to mint the OIDC token
contents: read # required to checkout the repo
pull-requests: write # required to post the plan as a PR comment
jobs:
plan:
name: Plan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::959228203854:role/ai-sandbox-github-actions-deploy
aws-region: us-east-1
role-session-name: gha-plan-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- name: Terraform init
working-directory: substrate/environments/ai-sandbox
run: terraform init
- name: Terraform plan
id: plan
working-directory: substrate/environments/ai-sandbox
run: terraform plan -no-color -out=tfplan 2>&1 | tee plan-output.txt
- name: Post plan as PR comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('substrate/environments/ai-sandbox/plan-output.txt', 'utf8');
const truncated = plan.length > 60000 ? plan.slice(0, 60000) + '\n\n... (truncated)' : plan;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## Terraform Plan\n\n```\n' + truncated + '\n```'
});
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
jobs:
apply:
name: Apply
runs-on: ubuntu-latest
environment: production # <-- This is the manual-approval gate
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::959228203854:role/ai-sandbox-github-actions-deploy
aws-region: us-east-1
role-session-name: gha-apply-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- name: Terraform init
working-directory: substrate/environments/ai-sandbox
run: terraform init
- name: Terraform plan
working-directory: substrate/environments/ai-sandbox
run: terraform plan -out=tfplan
- name: Terraform apply
working-directory: substrate/environments/ai-sandbox
run: terraform apply tfplan
environment: productionThat one line is the manual approval gate. Without it, every push to main would auto-apply with no human in the loop. With it, the GitHub Actions run pauses, waits for a designated reviewer to explicitly click "Approve and deploy," and only then runs Terraform. The reviewer's identity is recorded in the workflow run history alongside the apply output, giving you a permanent named-human audit trail for every change.
This step is the first one where the apply itself is structured as an explicit dance between automated work and human approval. The sequence below is the exact order of operations, with the actor for each row.
| # | Actor | Action |
|---|---|---|
| 1 | Agent | Writes .github/workflows/terraform-plan.yml with the exact OIDC role ARN, region, Terraform version, and PR-comment scripting. Output: file in the working tree, not yet pushed. |
| 2 | Agent | Writes .github/workflows/terraform-apply.yml with the same OIDC config, plus the environment: production manual-approval gate. Output: second file in the working tree. |
| 3 | Agent | Writes this Step.3a.html doc, including the doctrine reasoning and the sequence below. Output: doc in _process/ (local-only, not pushed). |
| 4 | Handoff | Agent stops. Hands off to human for review. Human reads the two workflow files and the doc; verifies the role ARN matches what bootstrap created; confirms the doctrine framing is accurate. |
| 5 | Human | Configures the GitHub production Environment in repo Settings → Environments. Adds self (and any other designated approvers) as required reviewers. Sets the deployment branch policy to main only. (See § 5 for the exact clicks.) |
| 6 | Human | Commits both workflow files locally. Pushes to a feature branch (e.g., ci/wire-github-actions). Opens a PR against main. |
| 7 | Agent (CI) | The plan workflow fires on the PR open event. Initializes Terraform against the remote backend, runs plan, posts the plan output as a PR comment. Expected plan: 0 to add, 0 to change, 0 to destroy — nothing in the workflow files changes AWS state. |
| 8 | Handoff | CI stops. Human reads the PR comment to confirm the plan is clean. This is the first time CI has spoken; the visual confirmation that the OIDC trust works. |
| 9 | Human | Approves the PR, merges to main. The merge is the apply trigger. |
| 10 | Agent (CI) | The apply workflow fires on the push to main event. Runs through init and plan, then pauses at the environment: production gate. Workflow status shows "Waiting for review." |
| 11 | Handoff | CI is paused. Human is notified (GitHub notification) that an apply is pending review. |
| 12 | Human | Goes to the Actions tab, opens the workflow run, reviews the plan output one more time, clicks "Approve and deploy." The reviewer's identity is recorded. |
| 13 | Agent (CI) | The gate releases. terraform apply tfplan runs against AWS as the federated ai-sandbox-github-actions-deploy role. Expected: 0 changes (this is just the workflow files). Workflow ends successfully. |
| 14 | Human | Verifies the workflow run succeeded. Spot-checks AWS CloudTrail for an AssumeRoleWithWebIdentity event with caller identity matching the workflow run. CI is now the established apply path for every future block. |
Three things, in this order: (a) the workflow files are syntactically correct (they execute); (b) the OIDC trust is functioning (the role assumption succeeds); (c) the environment protection is working (the apply pauses for approval). The first apply through CI is deliberately a no-op so that all three properties are demonstrated without any AWS state changing. From the next PR forward, real changes flow through this same path.
The doctrine: agents do the mechanical work, humans do the irreversible decisions, and every transition is an explicit checkpoint. This sequence is the first place that pattern is structurally enforced in CI rather than enforced socially in chat.
The environment: production reference in the apply workflow is meaningless until the environment is actually configured in the repo. This step is done in the GitHub UI; it cannot be done in code.
https://github.com/PayCargoDevOps/ai-sandbox/settings/environmentsproduction. Click Configure environment.main. (This means only the main branch can deploy through this environment; PRs and feature branches cannot.)main branch cannot trigger an apply (branch policy).main, the apply pauses until a designated reviewer approves (required reviewers).GitHub Environment protection rules are not currently in the Terraform GitHub provider with full fidelity (the required-reviewers configuration in particular has historically been a UI-only setting). We could automate this with the github_repository_environment resource and accept some configuration drift between the UI and code, but the trade-off is not worth it for a single-environment setup. Documented here so the manual nature is explicit, not accidental.
The first time CI runs, both workflows should fire and complete successfully without changing anything in AWS. That is the proof that the path works.
terraform plan output. Expected content: No changes. Your infrastructure matches the configuration.Apply complete! Resources: 0 added, 0 changed, 0 destroyed.Push a real but harmless change to verify CI handles diffs. Recommended: add a new tag to one of the existing resources (e.g., git_commit_sha) so the plan shows 1 to change. The apply should successfully modify the resource. Visible end-to-end: code change → PR comment with plan → merge → approval → apply → resource updated.
| What to see | Where to look | What confirms success |
|---|---|---|
| The workflow files in the repo | GitHub → .github/workflows/ directory |
Two files: terraform-plan.yml and terraform-apply.yml. Reading them confirms they reference the right role ARN and use the environment: production gate. |
| The production environment configuration | GitHub → repo Settings → Environments → production |
Required reviewers list shows the approver(s). Deployment branches shows main only. Configuration history visible. |
| The OIDC role assumption in CloudTrail | CloudTrail → Event history → filter Event name = AssumeRoleWithWebIdentity |
An event for each CI run. The user identity will show the federated GitHub Actions principal: arn:aws:sts::959228203854:assumed-role/ai-sandbox-github-actions-deploy/gha-apply-... matching the role-session-name from the workflow. |
| The plan comment on a recent PR | Any PR that has gone through plan | A comment from github-actions[bot] with the terraform plan output formatted in a code block. |
| An apply run with a named human approver | GitHub → Actions tab → click a Terraform Apply run | The "Review pending" step shows the approver who clicked "Approve and deploy" and the timestamp. The apply step below shows the actual terraform apply output. |
Every apply now produces a CloudTrail AssumeRoleWithWebIdentity event with the GitHub Actions principal as the caller. That event is permanent, encrypted with the audit CMK once Step 4b (observability) lands, and cannot be deleted by any role in the account (permission boundary). It is the durable, account-side proof of who applied what and when, independent of GitHub. If GitHub history is ever in question, CloudTrail is the parallel record.
sub claim casing mismatchIf github_org was applied to bootstrap in lowercase (paycargodevops) instead of the actual repo casing (PayCargoDevOps), CI will fail with AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity. The role's trust policy must match what GitHub sends, character-for-character including case.
Fix: already addressed in bootstrap (verified in Step 1 closeout). If you see this error anyway, re-check the trust policy via aws iam get-role --role-name ai-sandbox-github-actions-deploy and confirm the sub condition matches PayCargoDevOps/ai-sandbox exactly.
permissions: id-token: write missingThe single most common cause of OIDC auth failure in GitHub Actions is forgetting the permissions: id-token: write block. Without it, the workflow has no permission to mint an OIDC token, and aws-actions/configure-aws-credentials falls back to looking for AWS_* environment variables (which don't exist), erroring with "no credentials found."
Fix: ensure the permissions block is present in both workflow files. Already in the templates above.
The deploy role has admin-grade permissions but the permission boundary deployed by bootstrap caps what it can do. Some apply might try to call an API that's explicitly denied by the boundary (e.g., creating an IAM user). The plan would succeed but the apply would fail with AccessDenied on the specific API call.
Fix: the boundary is permissive about everything our modules legitimately need. If this hits, the issue is that the module is trying to do something the doctrine forbids. The right fix is to revise the module to not do that thing, not to relax the boundary.
The default GitHub Actions job timeout is 360 minutes (6 hours). The Network module took ~15-20 minutes; well within range. But for some reason CI runners can be slower than your laptop, and adding initial init time could push a fresh-clone apply close to 25 minutes. Plenty of headroom; documented in case a future module is unusually slow.
Fix: add timeout-minutes: 60 to the apply job if you want explicit ceiling.
GitHub comment size limit is ~65k characters. A network-module-size plan output (hundreds of resource lines) can exceed it. The script in the plan workflow truncates at 60k and adds a "...(truncated)" marker. Full plan output is always available in the workflow run logs.
Fix (if truncation is a problem): upload the full plan as a workflow artifact and link to it in the comment. Out of scope for the initial setup.
Confirm the deploy role exists and trust policy matches: aws iam get-role --role-name ai-sandbox-github-actions-deploy --profile ai-sandbox. The sub condition must contain repo:PayCargoDevOps/ai-sandbox: with the exact repo-name casing.
The production environment is configured in GitHub repo settings with at least one required reviewer and deployment branch restricted to main. Verify in the UI before merging the PR — otherwise the apply workflow runs with no protection.
The plan workflow must complete successfully (exit 0) on this very PR and post the plan output as a comment. Expected plan: No changes (the workflow files don't modify AWS).
At least one designated reviewer is available to approve the apply when it runs. If you're the only reviewer and you're the one merging, GitHub will require self-review to be enabled or a second reviewer; configure accordingly.
If all four pass, merge the PR. The apply workflow will run, pause for approval, and complete successfully — CI is now the established apply path.
Until this step, the doctrine was true in spirit (SSO admin tokens expire in 12 hours) but not in practice (any operator with SSO admin could apply at any time, and applies were happening by hand). After this step, the production apply path is GitHub OIDC, which produces ~10-minute tokens that expire automatically. SSO admin sessions still exist for break-glass and for bootstrap-level changes, but they are no longer on the routine apply path.
The CloudTrail AssumeRoleWithWebIdentity event records the GitHub Actions principal. The GitHub workflow run records the human approver. Cross-referencing the two gives you a permanent named-human-plus-machine-identity audit trail for every infrastructure change. No more "who applied this last Tuesday" mysteries.
The handoff sequence in § 4 is the first place this pattern is enforced by infrastructure rather than by good intentions. CI cannot apply without human approval. The human cannot accidentally apply without going through the plan + approval flow. The two are locked together — not because anyone could be trusted to follow the convention, but because the convention is encoded in the deployment protection rules.
CI wiring looks like an infrastructure plumbing concern. It is actually the structural enforcement of the build doctrine. Before this step, the doctrine of "human approves, machine executes" depended on the operator manually approving and then manually executing — one person doing both halves. After this step, the two halves are owned by two different actors that have to explicitly cooperate. That separation is what makes the audit trail meaningful.
After Step 3a is complete:
_process/index.html.For the rest of the build, the agentic + human-in-the-loop sequence in § 4 is the canonical pattern. Agent writes the Terraform; human reviews via PR; CI plans and posts; human approves via PR merge; CI applies through the production environment; human approves the apply; the change is live and audited. Every block from 5a onward will look exactly like that.