Skip to content

Latest commit

 

History

History
183 lines (111 loc) · 23.1 KB

README.md

File metadata and controls

183 lines (111 loc) · 23.1 KB

GitHub Actions state-branch automation

This copyable example updates Terraform dependencies across selected branches and opens or refreshes a pull request for each changed branch. It uses three jobs:

  1. Discover selects branches by literal prefix, skipping any excluded branch, and records their commit IDs.
  2. Process updates each branch in a disposable checkout, runs terraform init, optionally formats the candidate, then runs terraform validate.
  3. Publish checks the result and patch, then manages the update branch, pull request and failure issue.

The processing job has read-only repository permissions. Only the separate publication job has repository write permissions; it does not run Terraform or receive the registry token.

Intended environment

Use this example with trusted official providers and your organisation's private modules, including modules from the HCP Terraform registry. The checks prevent configuration mistakes and accidental publication of unrelated files. They do not sandbox malicious providers or modules.

The workflow uses disposable Ubuntu runners and a pinned Terraform CLI. Docker is only used by this repository's local test harness. Processing and validation share one checkout and one initialisation; there is no separate fresh-checkout validation stage.

Install

Copy the example .github directory onto the default branch of your Terraform repository:

source=/path/to/tf-version-bump
consumer=/path/to/terraform-repository
mkdir -p "$consumer/.github"
cp -R "$source/examples/github-actions/.github/." "$consumer/.github/"

Review and commit the copied files. The example supplies separate callers for:

  • Non-production: state/nonproduction/, state/staging/, aws-state/nonproduction/ and aws-state/staging/.
  • Production: state/production/ and aws-state/production/.

The same copy adds tf-version-bump-report.yml, a read-only version report described below.

Both callers' live runs start only from the default branch; each caller also previews same-repository pull requests into the default branch (see Preview on pull requests). Their schedules are Monday 04:17 and Sunday 04:43 respectively in Australia/Melbourne. They also run when their control configuration changes, and can be started manually. A manual branch_prefix can narrow the configured prefixes but cannot select another branch family.

To leave one branch out of a caller's policy, add a line naming it with a leading !:

allowed_branch_prefixes: |
  state/production/
  aws-state/production/
  !state/production/specific-branch

An exclusion names one exact branch (wildcard characters fail validation), so state/production/specific-branch-2 is still selected. Every line beginning with ! is an exclusion, wherever it appears in the list, and exclusions apply to manual runs too. An exclusion must fall under one of the caller's prefixes, or discovery fails before any branch is processed. One that matches no remote branch, such as a deleted or mistyped branch, is ignored with a warning annotation on the workflow run. Excluding a branch leaves its open update pull request, failure issue and update_ branch untouched; close or delete them by hand. Keep the list a literal block scalar (|) so each line stays one entry; unquoted, YAML reads a value beginning with ! as a tag. Add the same line to the version report's matrix, keeping the two lists identical (see below).

Allow the workflow's contents, pull-requests and issues write permissions. Enable Settings → Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests before live publication.

Create an Actions secret named TF_API_TOKEN with read access to your HCP Terraform registry modules and providers. The workflow exposes it as TF_TOKEN_app_terraform_io only during processing. The processing checkouts disable persisted Git credentials. Pull-request previews run the same processing job, so they receive the same token.

Supply environment variables

terraform init, fmt and validate receive whatever you supply here; nothing else in the workflow does. Non-sensitive entries go in the terraform_env input, one NAME=VALUE per line:

terraform_env: |
  TF_VAR_region=ap-southeast-2
  TF_VAR_environment=production

Input values are not masked in logs. Put anything sensitive in the optional TERRAFORM_ENV Actions secret instead, in the same NAME=VALUE shape; both supplied callers already pass it through.

Both sources take one NAME=VALUE per line, so a multi-line value is written on one line with escapes: \n becomes a real newline and \\ becomes a single literal backslash. Any other backslash sequence is passed through unchanged, so \t stays as a backslash followed by t. A literal carriage return in an entry is rejected.

Terraform commands can need extra variables, such as a token for another private registry or credentials for a module source during terraform init. As an example of a multi-line value, GitHub App authentication for the integrations/github provider reads GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID and GITHUB_APP_PEM_FILE, and the last holds the private key's PEM contents rather than a path, so the TERRAFORM_ENV secret looks like this (key body abbreviated) and Terraform receives the key with real newlines:

GITHUB_APP_ID=12332432
GITHUB_APP_INSTALLATION_ID=12435523
GITHUB_APP_PEM_FILE=-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\n

Each of the secret's values is registered with ::add-mask::, which redacts it from the workflow console only. A multi-line value is registered as a single mask, so the console may not redact it line by line. When an update, terraform init, fmt or validate command fails, its output is printed in the Process candidate step log, where those masks apply. Step logs are kept for the repository's log retention period, 90 days by default, rather than the artefact's seven, so a multi-line credential a provider echoes can stay visible there for longer. Every command's output is also captured to log files inside the processing artefact, and those files are not redacted: the artefact is retained for seven days and can be downloaded by anyone with read access to the repository, so a credential a provider echoes into Terraform's output appears there in plaintext. Supplied variables at least cannot turn on Terraform's trace logging, because TF_LOG and TF_LOG_PATH are reserved.

A name may appear only once across both sources. Names the automation or the runner sets are rejected, as are names that would redirect the programs Terraform runs or its configuration, credentials, logging or plug-in sources. The reserved prefixes are PROCESS_, RECONCILE_, DISCOVERY_, RUNNER_, ACTIONS_, LD_, DYLD_, TF_CLI_ARGS, TF_LOG, TF_PLUGIN_CACHE and GIT_. The reserved exact names are PATH, IFS, ENV, BASH_ENV, SHELLOPTS, BASHOPTS, TF_DATA_DIR, TF_IN_AUTOMATION, CHECKPOINT_DISABLE, TF_CLI_CONFIG_FILE, TERRAFORM_CONFIG, TF_WORKSPACE, HOME, TMPDIR, SSL_CERT_FILE, SSL_CERT_DIR, GITHUB_ENV, GITHUB_PATH, GITHUB_OUTPUT, GITHUB_STEP_SUMMARY, GITHUB_STATE and TF_TOKEN_app_terraform_io. The last of those is reserved in any letter case or other spelling Terraform maps to the app.terraform.io host, because it would silently shadow the registry token the workflow injects from the TF_API_TOKEN secret; TF_TOKEN_* names for other registries remain allowed. The five GITHUB_* names are the runner's own command channels rather than provider configuration, which is why GITHUB_APP_ID and the provider's other GITHUB_ variables are accepted while GITHUB_ENV is not. Treat the list as best effort rather than exhaustive. The structural protection is a separate rule: a file newly created during a run is only ever publishable if it is a .terraform.lock.hcl directly inside a configured Terraform root. A rejected entry never prints its value, and is rejected before any Terraform command runs and before any file in the checkout is modified.

Both supplied callers forward the same repository TERRAFORM_ENV secret, so production credentials are also available to non-production state-branch jobs. To separate them, either give each policy its own secret name — mapping, say, TERRAFORM_ENV_PRODUCTION and TERRAFORM_ENV_NONPRODUCTION onto the reusable workflow's TERRAFORM_ENV secret in each caller — or hold the secret in a GitHub Environment named for the caller's automation_policy_id. A caller cannot pass an environment's secrets to a reusable workflow, so the reusable workflow's process job must then also declare that environment with its job-level environment key; GitHub then gives the job the environment's secret rather than the one the caller passes. An Environment's deployment-branch rules also apply to previews, which run from refs/pull/<number>/merge, so a rule that admits only the default branch may withhold the secret from a preview, and an Environment with required reviewers holds a preview for approval.

Configure updates

Edit the control configurations on the default branch:

.github/tf-version-bump/nonproduction.yml
.github/tf-version-bump/production.yml

These are strict tf-version-bump configuration files. The workflow owns file selection, so do not add a pattern key. Pull requests changing these files run a read-only config validation check and, for same-repository pull requests into the default branch, a preview that processes one sampled state branch per prefix without publishing anything (see Preview on pull requests).

The callers process the repository root by default:

terraform_directories: .

For several Terraform roots, use a newline-separated list:

terraform_directories: |
  environments/nonproduction
  environments/staging

Roots must exist inside the state-branch checkout and must not resolve to duplicate directories. Version updates apply to *.tf files directly inside each configured root. When terraform_fmt is enabled and updates have changed files, formatting runs recursively below each root. Both supplied callers enable formatting; the reusable workflow defaults it to false.

The callers pin tf-version-bump to v1.0.0-rc.17 and verify archive SHA-256 137eac8239d08e2a93409e9eda1fd59b52abcf3d774fa7c3882ce53534f56a4d before running it.

Initialisation and upgrades

terraform_init_upgrade defaults to false. Ordinary init preserves existing provider selections when they satisfy the updated constraints. If a requested version conflicts with the lock file, the run reports an initialisation failure; it does not retry with upgrade automatically.

Enable terraform_init_upgrade in the manual workflow inputs to use terraform init -upgrade. To enable it for scheduled and config-change runs, set terraform_init_upgrade: true in the caller's with block. Direct script callers can set PROCESS_TERRAFORM_INIT_UPGRADE=true; an omitted value defaults to false, and values other than exact true or false are rejected.

Upgrade applies to all eligible dependencies, including providers outside the bump configuration. Without an existing provider lock entry, ordinary init still selects a matching version. Modules are not covered by the provider lock file; a fresh checkout resolves their configured module constraints on each run.

Initialisation disables the backend and interactive prompts. Generated .terraform.lock.hcl files are included in the patch; do not ignore them in provider roots. Validation uses the same initialised directory. A provider-free root can have no lock file.

Results and publication

The processing result contains result.json, diagnostic logs and, for a changed valid candidate, candidate.patch. Publication checks the run and branch identity, patch checksum, configured roots and permitted file changes before constructing one commit containing dependency, lock-file and formatting changes.

For state/nonproduction/example-thing, the managed update branch is update_state/nonproduction/example-thing. The publisher checks that the state branch still matches its discovered commit. It updates an existing automation-owned ref using an exact force-with-lease; ownership or lease failures stop publication for the next run to handle.

Pull requests and failure issues carry this stable marker:

<!-- tf-version-bump:<policy>:<ref-hash> -->

Their body names the result, whether terraform fmt ran, the Terraform and tf-version-bump versions and the workflow run; a failure issue adds the failed stage, root and exit status.

Result Publication
Changed and valid Create or refresh the marked PR; close the marked failure issue
Unchanged and valid Close the obsolete marked PR and failure issue
Update, init, fmt or validation failure Close the marked PR first, then create or refresh the failure issue
Automation failure or missing/invalid result Stop without changing managed PRs, issues or refs

A failed candidate fails its process job, so the jobs list shows which branches broke. When processing wrote a result, the run summary names the branch and its classification, and for an update, initialisation, formatting or validation failure the stage and root that failed; a failure before the result exists writes no summary. Publication runs either way, so the table above still applies.

Unchanged candidates still run validation. Before any PR or issue reconciliation, the publisher checks that the remote state branch still matches the discovered commit; a moved or missing branch or failed lookup stops reconciliation. Cleanup matches the policy/branch marker and the expected PR head and base. Update refs are retained. GitHub lookup and closure errors stop reconciliation.

Publication uses the built-in GITHUB_TOKEN. The helper respects Git's signing configuration; the example does not provision a signing key. If signing is enabled, the runner must have a working key. Do not assume token-created branches and PRs will automatically run your downstream checks; review GitHub's GITHUB_TOKEN workflow behaviour when configuring required checks.

Preview on pull requests

A same-repository pull request into the default branch that changes a caller's configuration file or the caller workflow itself starts that caller's preview job. It discovers the policy's branches exactly as a live run does, then processes one branch per configured prefix, with its update, terraform init, optional formatting and terraform validate, and publishes nothing: the publish job is skipped, and publication also refuses any ref but the default branch. Pull requests into another branch skip the preview. The preview job skips pull requests from forks. A Dependabot pull request that changes a watched file, such as an action pin in a caller, does run the preview, because Dependabot's branches live in this repository; GitHub gives Dependabot runs Dependabot secrets rather than the repository's Actions secrets, so such a preview has no TF_API_TOKEN or TERRAFORM_ENV unless you also store them as Dependabot secrets.

The preview job shares the live job's inputs through YAML aliases, so a pull request that edits a pin, a prefix or a root is previewed with its edits. When a preview runs, it uses the pull request's own scripts and reusable workflow, including any edits to them; editing those files alone does not start a preview. A newer push to the pull request cancels its older preview; previews never wait behind or block a live run.

The branch for each prefix is picked from the pull request number, so every push to the same pull request previews the same branches while that prefix's branches are unchanged. Adding, deleting or excluding a branch under a prefix may move the pick, and different pull requests may land on the same branch. A branch belongs to the first prefix it matches, and a prefix that owns no branch is named in a warning annotation. The preview is a smoke test, not coverage: a branch-scoped ignore_modules entry is previewed only if its branch happens to be sampled.

Each process job's summary shows the updater's log and, for a changed candidate, the candidate patch. The patch is the full candidate the merged configuration would produce, not only what the pull request changes: on a branch whose update pull request has not been merged, it also includes the changes the current configuration would already make.

A sampled branch whose update, initialisation, formatting or validation fails fails its process job, and with it the pull request's check. The failure may already exist on that branch, so check the branch's marked failure issue before blaming the pull request, and do not make the preview a required check. An invalid configuration fails every sampled branch at the update stage as branch-update, while the configuration validation check reports the cause.

GitHub runs pull-request workflows only for the opened, synchronize and reopened activities, and not at all while the pull request has a merge conflict. Re-running the workflow picks up moved state branches, because discovery lists them live, but a GitHub re-run reuses the original event's GITHUB_SHA, so reflecting a moved default branch needs a new pull-request event instead, such as a push (for example updating the branch from the default branch) or closing and reopening the pull request.

Version report

tf-version-bump-report.yml compares every state branch with its policy's control configuration without changing anything. It runs on Mondays at 06:17 Australia/Melbourne, after both scheduled update runs, and can be started manually from the default branch. It has read-only repository access, runs no Terraform and receives no secrets.

One job per policy discovers the policy's branches exactly as the update workflow does, then audits each branch's discovered commit with tf-version-bump -audit-file. Its matrix repeats each caller's branch prefixes and exclusions, config path and Terraform directories; the example's harness fails if they differ, so change both together.

Each job writes two reports to its summary and uploads both as CSV files, with the collected records.json, in an artefact retained for seven days. A configured module, provider or required_version that a branch does not declare produces no row for that branch:

  • Version report (version-report.csv): one row per check with status, branch, kind, subject, block, file, actual, expected and detail columns. It checks each root's presence, whether main.tf and providers.tf exist, required_version, providers and modules. A value that already matches passes, which includes a later entry whose row shows the version an earlier entry for the same source would set, so a branch is current only when none of its rows fail; a module the config's ignore_modules, ignore_versions or from excludes is SKIP, naming the filter, as is a module with a local source; a branch that cannot be fetched, parsed or safely read — a symlinked Terraform file, a root outside the checkout, or a duplicate root — is ERROR. The summary counts every status and lists each branch's non-passing rows.
  • Legacy version report (legacy-report.csv): the columns Result, Test, Comment and State Branch in an existing report's format, with only FAIL rows in the summary. It checks modules and the two files only, ignores the config's filters, names each module by its source (so blocks sharing a source produce identical rows), writes none for a missing version, and supports a single Terraform root.

Version mismatches never fail the job. A branch that cannot be read fails it after both reports are written, so the gap is visible. Discovery runs exactly as in the update workflow, so a policy whose prefixes and exclusions select no branch, or more than 256, fails its report job before any report is written, as it fails the update run.

Branch-scoped ignore_modules entries work with this example. Both the update job and the version report pass the state branch as -branch, so an entry such as state/staging/example-thing/shared-vpc excludes that module on that branch alone: the update job leaves it untouched and the version report records it as SKIP, naming ignore_modules. The legacy report ignores the config's filters, so it still lists that module as FAIL. A malformed scoped entry such as state//shared-vpc fails configuration validation, so the configuration check stops it before an update runs. To leave a whole branch out of both updates and this report instead, use a ! exclusion (see Install).

Both CSVs keep values as the audit reports them: as written, except that when a config has several module entries for one source, a later entry's row shows the version an earlier entry would set, as the version audit describes. A missing version is rendered rather than passed through: as above, the legacy report writes none, and the version report leaves the cell empty. A value beginning with =, +, - or @, such as the valid Terraform pin = 5.0.0, may be evaluated as a formula by a spreadsheet that opens the file directly, so import the CSVs as text instead.

Run and inspect

Start with a manual run from the default branch and select dry_run. This processes and validates candidates and checks publication locally, without pushing refs or changing PRs or issues. Inspect the candidate patch and logs before enabling live publication.

Open the workflow run to inspect discover, process and publish. Each process job's summary includes the updater's log for every configured root, so the run overview page shows which versions changed; a long log is truncated there and kept in full in the artefact. In a preview, the summary also shows each changed branch's candidate patch, truncated the same way. Terraform's own logs stay out of the summary, because a provider can echo a credential into them; when a command fails, the Process candidate step log shows its output, masked as described in Supply environment variables. Each summary links that job's result artefact, whose name identifies the branch only by a hash. In a dry run, each publish job's summary states what a live run would have done to the branch's pull request and failure issue. Download its result artefacts to see result.json, candidate.patch and captured command logs. Artefacts are retained for seven days. Results belong to one run attempt; use Re-run all jobs, as partial job reruns are unsupported.

Before enabling schedules, test in a disposable private repository: run a dry run, publish a valid change twice and check that the same PR is refreshed, then introduce a validation failure and confirm that the PR closes and one failure issue is maintained. Finally, test a valid no-change result and confirm that the issue closes. No live GitHub repository is created or mutated by this repository's local component tests.