PAYCARGO · AI-SANDBOX · PROCESS

3aWire CI (GitHub Actions OIDC apply path)

Bootstrap created the deploy role and the OIDC trust. This step actually uses them. Every apply from here forward happens in CI under a manual approval gate, not on a laptop.
Block: 3a (between Network and the Step 4 workload layer) · Operator: One agentic + human handoff cycle; see § 4 · Cost: $0/month (GitHub Actions free tier) · Permanence: Permanent — the apply path for every future block
Pending

State: Workflow files written and reviewed locally. GitHub repo environment protection not yet configured. First CI run has not yet happened.

Why this exists: Bootstrap (Step 1) created ai-sandbox-github-actions-deploy with a repo-scoped OIDC trust to PayCargoDevOps/ai-sandbox. The doctrine said "CI replaces laptop applies from Step 3 onward." Until this step lands, that doctrine is aspirational — every apply so far has been from the operator's laptop. This step makes the doctrine real.

Executive Summary

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.

StatusPending
Cost$0/month
EffortPlan: ~1 hour
Setup: ~30 min
Test run: ~15 min
RiskLow

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.

1. Why this step exists (and why it has been deferred until now)

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.

The one-line summary

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.

Why this step was deferred until after Step 3

Why deferring further would be a mistake

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.

2. What this step creates

ArtifactWhere it livesWhat 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.

What this step does NOT create

3. The two workflows in detail

3.1 Plan workflow (read-only, runs on every PR)

# .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```'
            });

3.2 Apply workflow (write, gated by manual approval)

# .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

The single most important line: environment: production

That 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.

4. The agentic + human-in-the-loop sequence

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.

#ActorAction
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.

What this sequence proves

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.

How to read the color coding

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.

5. GitHub environment protection setup (the human-only step)

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.

Exact clicks

  1. Go to https://github.com/PayCargoDevOps/ai-sandbox/settings/environments
  2. Click New environment.
  3. Name: production. Click Configure environment.
  4. Under Deployment protection rules, check Required reviewers.
  5. Add yourself (and any other approvers). The order in which you add them does not matter; any one of them can approve.
  6. Under Deployment branches and tags, choose Selected branches and tags. Add a rule: main. (This means only the main branch can deploy through this environment; PRs and feature branches cannot.)
  7. Click Save protection rules.

What this enforces

Why this configuration is not in Terraform

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.

6. How to test (the first CI run)

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.

The test sequence

  1. After the workflow files are committed and the production environment is configured, push to a feature branch and open a PR.
  2. Observe the Terraform Plan workflow run start automatically on the PR.
  3. Within 2-3 minutes, a comment appears on the PR with the terraform plan output. Expected content: No changes. Your infrastructure matches the configuration.
  4. Merge the PR.
  5. Observe the Terraform Apply workflow run start on the merge. It runs init + plan, then pauses with a "Waiting for review" badge.
  6. Go to the Actions tab. Click the paused run. Click Review deployments. Approve.
  7. The apply step runs. Expected output: Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
  8. Done. CI is wired and operational.

Optional second test (after the first succeeds)

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.

7. How to view the work in GitHub + AWS Console

What to seeWhere to lookWhat 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.

The CloudTrail event is the audit-of-record

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.

8. Anticipated gotchas

Gotcha A — OIDC sub claim casing mismatch

If 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.

Gotcha B — permissions: id-token: write missing

The 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.

Gotcha C — The deploy role's permission boundary blocks something CI tries to do

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.

Gotcha D — Long-running NFW apply exceeds default workflow timeout

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.

Gotcha E — Plan output too large for a PR comment

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.

9. Go / no-go gates

Pass all four gates before merging the CI-wiring PR.

1
Bootstrap role gate

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.

2
Environment configuration gate

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.

3
Plan workflow on the PR succeeds

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).

4
Reviewer access gate

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.

10. Doctrine implications

The "no long-lived credentials" guarantee is now structural

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.

Every apply now has a named human approver in the audit trail

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.

Agents do mechanical work, humans do irreversible decisions

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.

The takeaway

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.

11. What comes next

After Step 3a is complete:

  1. Mark Step 3a complete and update _process/index.html.
  2. Push the Step 3 network-firewall.tf bug fix through the new CI path — the first real change to flow through GitHub Actions OIDC. Visible end-to-end test of the CI pipeline.
  3. Begin Step 4a: Data — the audit S3 bucket + lockbox DDB table + per-zone secrets. First Step 4 sub-block. From here forward, every module change happens via the CI path documented in this doc.

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.