A centralised, reusable GitHub Actions workflow (workflow_call) that lets any application repo in the organisation onboard itself to HCP Terraform, GitHub Environments, and repository rulesets with a single .project/environments.yaml file.
- Single config file — all project settings live in
.project/environments.yamlin the calling repo - HCP Terraform integration — creates workspaces, links VCS, and optionally seeds variables
- GitHub Environments — creates environments with
AWS_ACCOUNT_ID,AWS_REGION, andAWS_OIDC_ROLEper environment;SNOWFLAKE_ACCOUNT_NAMEandSNOWFLAKE_ORGANIZATION_NAMEare set when provided - Account ID resolution — maps environment aliases to AWS account IDs via the
AWS_ACCOUNT_ID_MAPorg variable; no account IDs are stored in the config file - Production approval gate — configurable reviewers and wait timer (exact match only)
- Repository rulesets — branch protection with PR reviews, status checks, force-push blocking
- Fully idempotent — every step is safe to re-run on an already-onboarded repo
- Reusable design — consumed via
workflow_callby any repo in the org
The workflow runs 14 sequential steps:
| Step | Name | Description |
|---|---|---|
| 0 | Checkout | Check out the calling repository |
| 1 | Install yq | Install the yq YAML processor |
| 2 | resolve-config |
Locate .project/environments.yaml (or .yml) |
| 3 | parse |
Validate and extract all fields from the config |
| 4 | fetch-org-vars |
Fetch AWS_ACCOUNT_ID_MAP and AWS_OIDC_ROLE_NAME from org variables |
| 5 | check |
Check if the HCP Terraform workspace already exists |
| 6 | create |
Create workspace (skipped if it exists) |
| 7 | resolve |
Merge workspace ID from create or check |
| 8 | link-vcs |
Attach VCS repository to the workspace |
| 9 | set-vars |
Seed workspace variables (skipped when none defined) |
| 10 | setup-environments |
Create GitHub Environments + set AWS / Snowflake variables |
| 11 | setup-ruleset |
Create or update repository ruleset |
| 12 | sync-codeowners |
Grant push access to every team listed in .github/CODEOWNERS |
| 13 | summary |
Write $GITHUB_STEP_SUMMARY |
Create .project/environments.yaml and fill in your values:
hcp:
hcp_org: my-hcp-org
workspace_name: my-app # optional; defaults to repo name
terraform_version: "1.7.0"
execution_mode: remote
working_directory: ""
branch: main
auto_apply: false
tfe_address: https://app.terraform.io
workspace_variables: [] # optional; omit or leave empty to skip set-vars
aws:
aws_region: eu-west-1 # shared across all environments
environments:
- name: devl
aws_account_alias: DEVL # must match a key in AWS_ACCOUNT_ID_MAP
snowflake_account_name: DOC83156
snowflake_organization_name: AVDNPDD
- name: test
aws_account_alias: TEST
snowflake_account_name: DOC83158
snowflake_organization_name: AVDNPDE
- name: prod
aws_account_alias: PROD
snowflake_account_name: DOC83157
snowflake_organization_name: AVDNPDF
github:
production_environment: prod
approval_environments: [test, prod]
wait_timer: 5
ruleset:
ruleset_enabled: true
target_branch: ~DEFAULT_BRANCH
require_pr: true
required_approvals: 1
dismiss_stale_reviews: true
required_status_checks: []
block_force_pushes: true
prevent_deletion: trueCopy templates/onboard-aws-project.yaml to .github/workflows/onboard-aws-project.yaml in your app repo:
name: Onboard AWS Project
on:
push:
branches: [main]
paths:
- '.project/environments.yaml'
- '.github/workflows/onboard-aws-project.yaml'
workflow_dispatch:
jobs:
onboard:
uses: <ORG>/aws-project-onboarding/.github/workflows/aws-project-onboarding.yaml@v1
secrets: inheritVariables (set at the org level so every calling repo inherits them):
| Variable | Description |
|---|---|
AWS_ACCOUNT_ID_MAP |
JSON map of environment alias -> AWS account ID, e.g. {"DEVL":"123456789012","PROD":"..."} |
AWS_OIDC_ROLE_NAME |
IAM role name used for GitHub OIDC, e.g. github-oidc-role |
Secrets (set at the org level):
| Secret | Description |
|---|---|
TFE_TOKEN |
HCP Terraform API token |
TF_VCS_OAUTH_TOKEN_ID |
OAuth token ID for VCS provider |
GITHUB_PAT |
Fine-grained PAT (environments: read/write, administration: write) |
Push .project/environments.yaml and the caller workflow to main. The onboarding runs automatically.
.project/environments.yaml is the single source of truth for onboarding. The workflow reads it directly from the calling repo at run time — every field maps to a step output in the parse step and then to a downstream API call. Edit the file, commit, push, and the onboarding runs automatically because the caller workflow triggers on pushes that touch .project/environments.yaml.
| Field | Required | Default | Notes |
|---|---|---|---|
hcp_org |
yes | — | HCP Terraform organisation that owns the workspace |
workspace_name |
no | repo name | Defaults to ${{ github.event.repository.name }} |
terraform_version |
no | 1.7.0 |
Any version supported by HCP Terraform |
execution_mode |
no | remote |
remote, local, or agent |
working_directory |
no | "" |
Subdirectory inside the VCS repo where Terraform runs |
branch |
no | main |
VCS branch the workspace tracks |
auto_apply |
no | false |
Auto-apply successful plans |
tfe_address |
no | https://app.terraform.io |
Override for TFE / custom HCP endpoints |
workspace_variables |
no | [] |
Array of {key, value, sensitive, category} objects. When omitted or empty, the set-vars step is skipped |
The aws block contains the shared region and the list of environments to provision. At least one environment is required. The workflow always injects a ci environment cloned from devl if one exists, so you do not need to list ci yourself.
Top-level aws fields:
| Field | Required | Notes |
|---|---|---|
aws_region |
yes | Shared AWS region applied to every environment |
environments |
yes | Array of environment objects (see below) |
Per-environment fields:
| Field | Required | Notes |
|---|---|---|
name |
yes | Environment name (devl, test, prod, …). The workflow iterates in the order listed |
aws_account_alias |
yes | Key looked up in the AWS_ACCOUNT_ID_MAP org variable to resolve the AWS account ID |
snowflake_account_name |
no | Populates SNOWFLAKE_ACCOUNT_NAME (skipped if empty) |
snowflake_organization_name |
no | Populates SNOWFLAKE_ORGANIZATION_NAME (skipped if empty) |
GitHub Environment variables created per environment:
| Variable | Source |
|---|---|
AWS_ACCOUNT_ALIAS |
aws_account_alias field |
AWS_ACCOUNT_ID |
Resolved via AWS_ACCOUNT_ID_MAP[aws_account_alias] |
AWS_REGION |
aws.aws_region (shared across all environments) |
AWS_OIDC_ROLE |
arn:aws:iam::<AWS_ACCOUNT_ID>:role/<AWS_OIDC_ROLE_NAME> |
SNOWFLAKE_ACCOUNT_NAME |
snowflake_account_name (skipped if empty) |
SNOWFLAKE_ORGANIZATION_NAME |
snowflake_organization_name (skipped if empty) |
| Field | Default | Notes |
|---|---|---|
production_environment |
prod |
Exact-match name of the production environment |
approval_environments |
["test", <production_environment>] |
Exact-match list of environments that receive required reviewers, prevent_self_review: true, and a main-only deployment branch policy |
wait_timer |
0 |
Minutes to wait before allowing a protected deployment to proceed |
Reviewer teams come from
.github/CODEOWNERS. The workflow reads every@org/team-slugentry from the calling repo's CODEOWNERS file and uses those teams as required reviewers on every protected environment. Noreviewer_teamsfield is needed inenvironments.yaml. The workflow also grants each teampushaccess to the repo before attaching it as a reviewer — GitHub silently drops reviewer teams that have no repository access, which would causeprevent_self_reviewto fail with "Required reviewers must have at least one reviewer".
| Field | Default | Notes |
|---|---|---|
ruleset_enabled |
true |
When false, the setup-ruleset step is skipped entirely |
target_branch |
~DEFAULT_BRANCH |
Conditions include pattern for the standard-branch-protection ruleset |
require_pr |
true |
Adds the pull_request rule |
required_approvals |
1 |
Minimum approving reviews |
dismiss_stale_reviews |
true |
Dismiss stale reviews on new pushes |
required_status_checks |
[] |
Array of check contexts. Rule is omitted when empty |
block_force_pushes |
true |
Adds the non_fast_forward rule |
prevent_deletion |
true |
Adds the deletion rule |
A second ruleset, branch-name-policy, is always created targeting ~ALL (except refs/heads/main) and enforces the regex ^(main|feature/.+|bug/.+)$ on branch names. This one is not configurable from environments.yaml.
HCP Terraform needs a VCS OAuth connection before it can link a workspace to a GitHub repository. The TF_VCS_OAUTH_TOKEN_ID secret this workflow consumes is the OAuth token ID (prefix ot-…) produced once that connection is established. You only need to do this once per org.
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App (use the org settings if the app should be org-owned).
- Fill in:
- Application name — e.g.
HCP Terraform (my-org) - Homepage URL —
https://app.terraform.io - Authorization callback URL —
https://app.terraform.io/auth/<uuid>/callback. The exact URL is generated in step 2 below — create the app with any placeholder first, then come back and update this field.
- Application name — e.g.
- Click Register application. On the next screen, copy the Client ID and generate a Client secret.
- In HCP Terraform, open Settings → Version Control → Add a VCS provider → GitHub → GitHub.com (Custom).
- Paste the Client ID and Client secret from step 1.
- HCP Terraform now shows the exact Authorization callback URL that must be set on the GitHub OAuth App. Copy it, go back to the OAuth App settings in GitHub, and paste it into the Authorization callback URL field. Save.
- Back in HCP Terraform, click Connect and continue. You'll be redirected to GitHub to authorise the OAuth App against your org/user.
- After you authorise, HCP Terraform completes the setup and shows the VCS provider with an OAuth Token ID like
ot-aBcDeFgHiJkLmNoP. This is the value you need.
Add the token ID as an organisation secret in GitHub so every caller repo inherits it:
gh secret set TF_VCS_OAUTH_TOKEN_ID \
--org <ORG> \
--visibility all \
--body "ot-aBcDeFgHiJkLmNoP"Common failure:
link-vcsstep returns HTTP 400invalid oauth_token_id. Causes, in order of likelihood: (a) the secret holds the OAuth client ID (oc-…) instead of the OAuth token ID (ot-…); (b) the token belongs to a different HCP org thanhcp.hcp_org; (c) the token was revoked. Verify with:curl -s -H "Authorization: Bearer $TFE_TOKEN" \ https://app.terraform.io/api/v2/oauth-tokens/$TF_VCS_OAUTH_TOKEN_ID | jq .
| Resource | Mechanism |
|---|---|
| HCP workspace | Existence check before create; step skipped if exists |
| HCP workspace variables | POST — on 422 fetch var ID and PATCH |
| GitHub Environments | PUT is always an upsert |
| GitHub Environment variables | POST — on 409 use PATCH |
| Repository ruleset | List by name — PUT if found, POST if not |
| CODEOWNERS team access | PUT /orgs/{org}/teams/{slug}/repos — upsert |
aws-project-onboarding/
├── .github/workflows/
│ ├── aws-project-onboarding.yaml # The reusable workflow
│ ├── create-branch.yaml
│ └── release.yaml
├── CLAUDE.md # AI development conventions
└── README.md
Calling repo layout (application repos):
my-app/
├── .project/
│ └── environments.yaml # Onboarding config (the only required file)
└── .github/workflows/
└── onboard-aws-project.yaml # Caller workflow
The main branch of this platform repo has the following protection rules:
| Setting | Value |
|---|---|
| Require status checks to pass | Yes |
| Require branches to be up to date before merging | Yes |
| Do not require status checks on creation | Yes |
| Required status checks | checkov-scan (any source) |
Releases follow semver. The release.yaml workflow moves the major-version tag (v1) forward automatically.
- Patch (
v1.0.x) — bug fixes, no interface changes - Minor (
v1.x.0) — new optional config fields, backward compatible - Major (
v2.0.0) — breaking changes requiring migration
MIT
Built with Claude Code