> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ubiquex.io/llms.txt
> Use this file to discover all available pages before exploring further.

# CI/CD — GitHub Actions Plan and Apply

> Automate ubx plan on pull requests and ubx apply on merge to main.

Running `ubx apply` from a developer's laptop works fine when you're the only person touching the infrastructure. The moment a second engineer joins, you need a shared, auditable, automatable pipeline. The standard pattern is PR-plan / merge-apply: when a pull request is opened, the CI pipeline runs `ubx plan` and posts the output as a PR comment so reviewers can see exactly what will change before approving. When the PR merges to main, the pipeline runs `ubx apply` automatically. This creates a clear audit trail, prevents "it worked on my machine" surprises, ensures every production change goes through review, and makes rollback straightforward (revert the commit, CI applies the revert). This tutorial is the complete, working GitHub Actions workflow — not pseudocode, but the actual YAML you'd commit to `.github/workflows/`.

## What you'll learn

* The PR-plan / merge-apply GitHub Actions pattern for ubx
* How to post `ubx plan` output as a PR comment
* How environment protection rules gate `ubx apply` to reviewed changes only

***

## Why this matters

<Info>
  A CI pipeline that runs `ubx validate` on every push and `ubx apply` on every merge to main is the minimum viable infrastructure pipeline. It costs nothing extra, prevents most classes of production outage from accidental or unreviewed changes, and gives you a searchable history of every change in your CI logs.
</Info>

***

## The source code

<CodeGroup>
  ```hcl main.iac theme={"theme":"css-variables"}
  unit "aws_s3_bucket_v2" "app" {
    bucket = "myapp-${input.env}-artifacts"
    versioning = { enabled = true }
  }

  unit "aws_rds_instance" "db" {
    engine                  = "postgres"
    instance_class          = "db.t3.micro"
    identifier              = "myapp-${input.env}-db"
    storage_encrypted       = true
    backup_retention_period = 7
  }
  ```

  ```hcl inputs.iac theme={"theme":"css-variables"}
  input "env" {
    type    = "string"
    default = "prod"
  }
  ```

  ```hcl outputs.iac theme={"theme":"css-variables"}
  output "bucket_arn" { value = unit.aws_s3_bucket_v2.app.arn }
  ```

  ```hcl ubx.iac theme={"theme":"css-variables"}
  ubx {
    name    = "ci-cd-github-actions"
    runtime = "typescript"
    stacks  = ["dev", "staging", "prod"]
  }

  backend "s3" {
    bucket = "myorg-ubx-state"
    key    = "stacks/main/state.json"
    region = "us-east-1"
  }
  ```

  ````yaml .github/workflows/plan-apply.yml theme={"theme":"css-variables"}
  name: ubx plan / apply

  on:
    pull_request:
      branches: [main]
    push:
      branches: [main]

  permissions:
    contents: read
    pull-requests: write   # needed to post plan output as a PR comment

  jobs:
    plan:
      name: ubx plan
      if: github.event_name == 'pull_request'
      runs-on: ubuntu-latest
      environment: staging
      steps:
        - uses: actions/checkout@v4

        - name: Install ubx
          run: go install github.com/ubiquex/ubx@latest

        - name: ubx validate
          run: ubx validate
          env:
            AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_REGION:            us-east-1

        - name: ubx plan
          id: plan
          run: ubx plan --out plan.ubx 2>&1 | tee plan-output.txt
          env:
            AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_REGION:            us-east-1
            UBX_INPUT_ENV:         prod

        - name: Upload plan artifact
          uses: actions/upload-artifact@v4
          with:
            name: plan-${{ github.sha }}
            path: plan.ubx
            retention-days: 7

        - name: Post plan summary to PR
          uses: actions/github-script@v7
          with:
            script: |
              const fs = require('fs');
              const plan = fs.readFileSync('plan-output.txt', 'utf8');
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: '## ubx plan\n```\n' + plan + '\n```',
              });

    apply:
      name: ubx apply
      if: github.event_name == 'push' && github.ref == 'refs/heads/main'
      runs-on: ubuntu-latest
      environment: production
      steps:
        - uses: actions/checkout@v4

        - name: Install ubx
          run: go install github.com/ubiquex/ubx@latest

        - name: ubx validate
          run: ubx validate
          env:
            AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_REGION:            us-east-1

        - name: ubx apply
          run: ubx apply --yes
          env:
            AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
            AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
            AWS_REGION:            us-east-1
            UBX_INPUT_ENV:         prod
  ````
</CodeGroup>

***

## How it works

<Steps>
  <Step title="Pull request triggers the plan job">
    Any PR targeting `main` triggers the `plan` job. It runs `ubx validate` (fast, no state access), then `ubx plan --out plan.ubx` (requires AWS credentials and S3 state access). The plan output is captured to `plan-output.txt` and posted as a PR comment.
  </Step>

  <Step title="The plan file is uploaded as an artifact">
    `--out plan.ubx` saves the plan to a file. The artifact is uploaded with a name tied to the commit SHA — `plan-${{ github.sha }}`. This links the exact plan that was reviewed to the exact commit that was approved.
  </Step>

  <Step title="Merge to main triggers the apply job">
    The `apply` job runs only on `push` to `main` — not on PRs. The `environment: production` block (configured in GitHub Settings → Environments) can require manual approval from a designated reviewer before the apply runs, adding a final human gate.
  </Step>
</Steps>

***

## Common mistakes

<Warning>
  `ubx apply --yes` skips the interactive confirmation prompt — required in CI since there's no human to type "yes". Ensure your `environment: production` in GitHub Settings requires a reviewer to approve the apply job before it runs, so `--yes` doesn't mean "apply without any human review ever".
</Warning>

<Tip>
  Set `UBX_ACTOR=github-actions/${{ github.workflow }}/${{ github.run_id }}` in the apply step environment to get meaningful actor names in `ubx history` output. The default AWS IAM role ARN is technically accurate but hard to read in the history timeline.
</Tip>

***

## Run it

```bash theme={"theme":"css-variables"}
# Locally:
ubx validate
ubx plan --out plan.ubx
ubx apply --plan plan.ubx  # apply from saved plan (tutorial 53)

# In CI: push a PR to trigger plan, merge to main to trigger apply
```

***

## What you learned

<Check>The PR-plan / merge-apply pattern gives every change a review step before production</Check>
<Check>`ubx plan --out plan.ubx` saves the plan for upload as a CI artifact</Check>
<Check>`environment: production` in GitHub Settings adds a required-reviewer gate before apply</Check>

***

## Next steps

<CardGroup cols={2}>
  <Card title="Plan approval workflow" href="/v1/tutorials/53-plan-approval-workflow">
    Apply from a saved plan file for two-step approval
  </Card>

  <Card title="Shared apply history" href="/v1/tutorials/51-shared-apply-history">
    Track every CI apply in a shared audit log
  </Card>
</CardGroup>

***

<Info>
  Full runnable example: [github.com/ubiquex/ubx-examples/52-ci-cd-github-actions-plan-apply](https://github.com/ubiquex/ubx-examples/tree/main/52-ci-cd-github-actions-plan-apply)
</Info>
