Reusable GitHub Actions workflows and composite actions for Terraform, security scanning, and cloud authentication. Designed for teams running on AWS (commercial + GovCloud) and Azure (commercial + Government).
flowchart LR
pr["Pull request"]
subgraph plan["terraform-plan.yml"]
p1["fmt -check"]
p2["init + validate"]
p3["tflint"]
p4["plan"]
p5["Plan posted as PR comment"]
end
replan["Post-merge plan<br/>saved as artifact + job summary"]
scan["trivy-scan.yml<br/>fs / config / image"]
sarif["SARIF in GitHub Security tab"]
review{"Human review + merge"}
gate{"GitHub environment<br/>approval gate"}
apply["terraform-apply.yml<br/>applies the exact saved plan"]
auth["aws-oidc-auth / azure-oidc-auth<br/>short-lived OIDC credentials"]
pr --> p1
p1 --> p2
p2 --> p3
p3 --> p4
p4 --> p5
pr --> scan
scan --> sarif
p5 --> review
review -- "push to main" --> replan
replan --> gate
gate --> apply
auth -.-> p4
auth -.-> apply
- No long-lived cloud credentials anywhere. Both clouds authenticate through OIDC federation; there are no access keys or client secrets to store, rotate, or leak.
- The approval gate lives in GitHub environments, not in workflow logic. Required reviewers are configured per environment in repo settings, so approvals have an audit trail and can't be bypassed by editing YAML in a PR. The apply job declares the environment itself, which also scopes the OIDC subject claim to
environment:<name>. - What gets approved is what gets applied. The post-merge plan is saved as a short-lived artifact and shown in the run's job summary; the approver reviews it at the environment gate, and apply runs that exact plan file. If state drifted in between, Terraform rejects the stale plan instead of applying something nobody saw.
- Plans are reviewable where the change is reviewed. The plan lands as a collapsible PR comment, so approvers see infrastructure impact next to the code diff.
- Least-privilege tokens. Each workflow declares only the
permissionsit needs; the scan job gets onlycontents: readandsecurity-events: write. - Supply-chain pinning. Third-party actions are pinned to full commit SHAs with the version as a comment, so Dependabot can propose bumps as reviewable PRs. The reusable workflows inline the steps of this repo's composite actions instead of calling them, so a released workflow never pulls in unreleased code from
main. Callers pin this repo by SHA. - Scan findings are visible, not build-breaking, by default. Trivy results go to the Security tab as SARIF, so teams can triage against a remediation SLA instead of having pipelines blocked by a newly published CVE.
Releases are cut by release-please from conventional commits. Pin callers to a release tag (shown below), or to its commit SHA with the tag as a comment so Dependabot can bump it:
uses: Krustytoe/platform-workflows/.github/workflows/trivy-scan.yml@<sha> # v0.1.0Avoid @main: any merge here would immediately change every caller's pipeline. The reusable workflows follow the same rule internally: they don't call this repo's composite actions (a workflow can't pin its own repo to its own SHA), they inline those steps with every third-party action SHA-pinned.
| Type | Name | What it does |
|---|---|---|
| Reusable workflow | terraform-plan |
fmt → init → validate → tflint → plan; posts plan to the PR and job summary, optionally saves it for apply |
| Reusable workflow | terraform-apply |
Applies the exact reviewed plan, behind a GitHub environment approval gate |
| Reusable workflow | trivy-scan |
Trivy scan on filesystem/IaC or container image; uploads SARIF to GitHub Security |
| Composite action | aws-oidc-auth |
OIDC auth to AWS — no long-lived keys |
| Composite action | azure-oidc-auth |
OIDC auth to Azure — no client secrets |
| Composite action | terraform-setup |
Install Terraform + tflint at consistent versions |
Runs fmt -check → init → validate → tflint → plan and posts the plan output as a collapsible PR comment and to the run's job summary. With upload_plan: true it saves the plan file as an artifact (1-day retention) for terraform-apply. Supports AWS and Azure; leave cloud empty for local/offline validation.
jobs:
plan:
uses: Krustytoe/platform-workflows/.github/workflows/terraform-plan.yml@v0.3.0 # x-release-please-version
permissions:
contents: read
id-token: write
pull-requests: write
with:
working_directory: deploy/terraform
cloud: aws
aws_region: us-gov-west-1
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}| Input | Type | Default | Description |
|---|---|---|---|
working_directory |
string | required | Path to the Terraform config, relative to repo root |
terraform_version |
string | ~1.10 |
Version constraint |
cloud |
string | "" |
aws, azure, or empty |
aws_region |
string | "" |
Required when cloud = aws |
upload_plan |
boolean | false |
Save the plan as an artifact (only when it has changes) |
plan_artifact_name |
string | tfplan |
Artifact name; make it unique per working directory in a run |
| Output | Description |
|---|---|
has_changes |
"true" when the plan has changes; use it to skip apply on no-op plans |
| Secret | Required | Description |
|---|---|---|
AWS_ROLE_ARN |
When AWS | IAM role ARN to assume via OIDC |
AZURE_CLIENT_ID |
When Azure | Managed identity client ID |
AZURE_TENANT_ID |
When Azure | Azure tenant ID |
AZURE_SUBSCRIPTION_ID |
When Azure | Azure subscription ID |
Runs init → apply <saved plan> inside a GitHub environment, so the environment's required reviewers approve before anything changes. GitHub doesn't allow environment: on a job that calls a reusable workflow, so pass it as an input.
jobs:
apply:
needs: plan
if: needs.plan.outputs.has_changes == 'true'
uses: Krustytoe/platform-workflows/.github/workflows/terraform-apply.yml@v0.3.0 # x-release-please-version
permissions:
contents: read
id-token: write
with:
working_directory: deploy/terraform
environment: prod # approval gate + OIDC sub claim environment:prod
plan_artifact_name: tfplan # from terraform-plan with upload_plan: true
cloud: aws
aws_region: us-gov-west-1
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}| Input | Type | Default | Description |
|---|---|---|---|
working_directory |
string | required | Same directory the plan ran in |
environment |
string | required | GitHub environment whose protection rules gate the apply |
plan_artifact_name |
string | "" |
Plan artifact from this run. If empty, applies a fresh unreviewed plan and logs a warning |
terraform_version |
string | ~1.10 |
Must match the version that produced the plan |
cloud / aws_region |
string | "" |
Same as terraform-plan |
Secrets are identical to terraform-plan.
Runs Trivy against a filesystem/IaC path or container image and uploads the results to GitHub Security as SARIF.
jobs:
scan:
uses: Krustytoe/platform-workflows/.github/workflows/trivy-scan.yml@v0.3.0 # x-release-please-version
permissions:
contents: read
security-events: write
with:
scan_type: fs
target: .
severity: HIGH,CRITICAL| Input | Type | Default | Description |
|---|---|---|---|
scan_type |
string | fs |
fs (vulnerabilities/secrets), config (IaC misconfiguration), or image |
target |
string | . |
Path for fs/config; image reference for image |
severity |
string | HIGH,CRITICAL |
Severities to report |
upload_sarif |
boolean | true |
Upload SARIF to GitHub Security tab |
- uses: Krustytoe/platform-workflows/actions/aws-oidc-auth@v0.3.0 # x-release-please-version
with:
role_arn: ${{ secrets.AWS_ROLE_ARN }}
region: us-gov-west-1Wraps aws-actions/configure-aws-credentials. Requires id-token: write permission on the calling job.
- uses: Krustytoe/platform-workflows/actions/azure-oidc-auth@v0.3.0 # x-release-please-version
with:
client_id: ${{ secrets.AZURE_CLIENT_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}Wraps azure/login. Requires id-token: write permission on the calling job.
- uses: Krustytoe/platform-workflows/actions/terraform-setup@v0.3.0 # x-release-please-version
with:
terraform_version: "~1.10"Installs Terraform and tflint, then runs tflint --init to download configured plugins.
A full plan-then-apply flow for a Terraform repo. PRs get a plan comment; after merge, the plan is re-run against main, saved, and applied only after an environment reviewer approves it.
on:
pull_request:
push:
branches: [main]
jobs:
plan:
uses: Krustytoe/platform-workflows/.github/workflows/terraform-plan.yml@v0.3.0 # x-release-please-version
permissions:
contents: read
id-token: write
pull-requests: write
with:
working_directory: deploy/terraform
cloud: aws
aws_region: us-gov-west-1
upload_plan: ${{ github.event_name == 'push' }}
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_PLAN_ROLE_ARN }} # read-only role
apply:
needs: plan
if: github.event_name == 'push' && needs.plan.outputs.has_changes == 'true'
uses: Krustytoe/platform-workflows/.github/workflows/terraform-apply.yml@v0.3.0 # x-release-please-version
permissions:
contents: read
id-token: write
with:
working_directory: deploy/terraform
environment: prod
plan_artifact_name: tfplan
cloud: aws
aws_region: us-gov-west-1
secrets:
AWS_ROLE_ARN: ${{ secrets.AWS_APPLY_ROLE_ARN }} # write roleUse separate roles for plan and apply. The two jobs present different OIDC subject claims, so each role's trust policy can be scoped tightly:
| Job | OIDC sub claim |
Role |
|---|---|---|
| plan (PR) | repo:<org>/<repo>:pull_request |
Read-only |
| plan (after merge) | repo:<org>/<repo>:ref:refs/heads/main |
Read-only |
| apply | repo:<org>/<repo>:environment:prod |
Write; only reachable through the approval gate |
MIT