diff --git a/docs/ADVANCED-USAGE.md b/docs/ADVANCED-USAGE.md index ec2317d..ffe880c 100644 --- a/docs/ADVANCED-USAGE.md +++ b/docs/ADVANCED-USAGE.md @@ -6,7 +6,7 @@ This is intentionally separate from the core CLI. `tf-version-bump` edits one ch ## GitHub Actions state-branch automation POC -For a scheduled or manually dispatched GitHub Actions proof of concept, see [`examples/github-actions`](../examples/github-actions/README.md). It copies into a consuming repository's `.github` directory and provides separate production and non-production callers that run only from the default branch, plus config-change triggers, read-only pull-request config validation, dry-run support, update pull requests, and marked failure issues. The reusable workflow has three jobs: `discover`, `process`, and `publish`. The pull-request check validates only the control config with `tf-version-bump`; full state-branch dry runs remain deferred. The same copy adds a read-only version report workflow that audits every state branch against its policy's config, and each caller can leave individual branches out with a `!` exclusion; the example's README describes both. +For a scheduled or manually dispatched GitHub Actions proof of concept, see [`examples/github-actions`](../examples/github-actions/README.md). It copies into a consuming repository's `.github` directory and provides separate production and non-production callers whose live runs start only from the default branch, plus config-change triggers, read-only pull-request config validation, pull-request previews on one sampled state branch per prefix, dry-run support, update pull requests, and marked failure issues. The reusable workflow has three jobs: `discover`, `process`, and `publish`. The pull-request config check validates only the control config with `tf-version-bump`; the preview processes a sample of state branches and publishes nothing. The same copy adds a read-only version report workflow that audits every state branch against its policy's config, and each caller can leave individual branches out with a `!` exclusion; the example's README describes both. The example targets trusted official providers and organisation-authored private modules, including the HCP Terraform registry. It checks for configuration mistakes and unexpected file changes; it does not sandbox malicious Terraform or providers. diff --git a/examples/github-actions/.github/scripts/discover-state-branches.sh b/examples/github-actions/.github/scripts/discover-state-branches.sh index f312acd..6b667c5 100755 --- a/examples/github-actions/.github/scripts/discover-state-branches.sh +++ b/examples/github-actions/.github/scripts/discover-state-branches.sh @@ -9,6 +9,8 @@ usage() { Usage: discover-state-branches.sh --help Discover immutable inputs for configured Terraform state branches. +With DISCOVERY_PREVIEW=true, run from refs/pull//merge and select one +sampled branch per prefix instead of every matching branch. EOF } @@ -69,6 +71,35 @@ branch_is_excluded() { return 1 } +# Keeps one branch per prefix, so a preview processes a sample rather than the whole policy. A +# branch belongs to the first prefix it matches; the pick is the first 15 hex digits of +# sha256("\t") modulo the prefix's branch count, which stays within bash's signed +# 64-bit arithmetic and is stable while that prefix's branches are unchanged. +sample_branch_records() { + local prefix record digest + local -a owned sampled=() + declare -A seen_prefixes=() + for prefix in "${selection_prefixes[@]}"; do + [[ -z "${seen_prefixes[$prefix]-}" ]] || continue + seen_prefixes[$prefix]=1 + owned=() + for record in "${branch_records[@]}"; do + [[ "${branch_owners[${record%%$'\t'*}]}" != "$prefix" ]] || owned+=("$record") + done + if [[ ${#owned[@]} -eq 0 ]]; then + echo "::warning::branch prefix '${prefix//'%'/%25}' owns no branch to preview" >&2 + continue + fi + digest=$(printf '%s\t%s' "$preview_seed" "$prefix" | sha256sum) + sampled+=("${owned[$((16#${digest:0:15} % ${#owned[@]}))]}") + done + readarray -t branch_records < <(printf '%s\n' "${sampled[@]}" | sort) + # A failing sort inside the process substitution is not seen by set -e, and an empty matrix + # runs as zero jobs, so the preview would pass having previewed nothing. + [[ ${#sampled[@]} -gt 0 && ${#branch_records[@]} -eq ${#sampled[@]} ]] \ + || fail_discovery selection "preview sampling did not keep one branch per owning prefix" +} + : "${DISCOVERY_DEFAULT_BRANCH:?DISCOVERY_DEFAULT_BRANCH must be set}" : "${DISCOVERY_CALLER_REF:?DISCOVERY_CALLER_REF must be set}" : "${CONTROL_CHECKOUT:?CONTROL_CHECKOUT must be set}" @@ -80,9 +111,22 @@ branch_is_excluded() { : "${DISCOVERY_ALLOWED_PREFIXES?DISCOVERY_ALLOWED_PREFIXES must be set}" : "${DISCOVERY_MANUAL_PREFIX?DISCOVERY_MANUAL_PREFIX must be set}" -expected_caller_ref="refs/heads/$DISCOVERY_DEFAULT_BRANCH" -[[ "$DISCOVERY_CALLER_REF" == "$expected_caller_ref" ]] \ - || fail_discovery caller "caller ref must be $expected_caller_ref" +preview=${DISCOVERY_PREVIEW-false} +[[ "$preview" == true || "$preview" == false ]] \ + || fail_discovery input "DISCOVERY_PREVIEW must be true or false" +if [[ "$preview" == true ]]; then + # A preview runs from a pull request's merge ref. Its number seeds the sample, so the seed + # cannot disagree with the pull request, and a manual prefix only arises from a dispatch. + [[ "$DISCOVERY_CALLER_REF" =~ ^refs/pull/([1-9][0-9]*)/merge$ ]] \ + || fail_discovery caller "preview caller ref must be refs/pull//merge" + preview_seed=${BASH_REMATCH[1]} + [[ -z "$DISCOVERY_MANUAL_PREFIX" ]] \ + || fail_discovery input "a preview cannot take a manual prefix" +else + expected_caller_ref="refs/heads/$DISCOVERY_DEFAULT_BRANCH" + [[ "$DISCOVERY_CALLER_REF" == "$expected_caller_ref" ]] \ + || fail_discovery caller "caller ref must be $expected_caller_ref" +fi # shellcheck disable=SC2153 # Set by the reusable workflow or the focused harness. [[ "$CONTROL_CHECKOUT" == /* && -d "$CONTROL_CHECKOUT" ]] \ || fail_discovery input "CONTROL_CHECKOUT must be an absolute existing directory" @@ -141,6 +185,7 @@ if ! git -C "$control_checkout" ls-remote --heads --refs origin >"$remote_heads_ fi branch_records=() +declare -A branch_owners=() declare -A matched_exclusions=() while IFS=$'\t' read -r oid ref; do branch=${ref#refs/heads/} @@ -151,6 +196,7 @@ while IFS=$'\t' read -r oid ref; do for prefix in "${selection_prefixes[@]}"; do if [[ "$branch" == "$prefix"* ]]; then branch_records+=("$branch"$'\t'"$oid") + branch_owners[$branch]=$prefix break fi done @@ -172,6 +218,7 @@ done || fail_discovery matrix "more than 256 branches matched; narrow or partition the prefix policy" readarray -t branch_records < <(printf '%s\n' "${branch_records[@]}" | sort) +[[ "$preview" != true ]] || sample_branch_records # Git ref names cannot contain a tab (or any control character), so a tab-separated record per # branch is a safe carrier into the single jq pass below that replaces one jq -cn per branch. tsv_records=() diff --git a/examples/github-actions/.github/scripts/reconcile-state-branch.sh b/examples/github-actions/.github/scripts/reconcile-state-branch.sh index a264cbb..b362a09 100755 --- a/examples/github-actions/.github/scripts/reconcile-state-branch.sh +++ b/examples/github-actions/.github/scripts/reconcile-state-branch.sh @@ -15,9 +15,11 @@ the classification. Requires RECONCILE_RESULT_DIR and RECONCILE_RUN_ID, RECONCILE_RUN_ATTEMPT, RECONCILE_AUTOMATION_POLICY_ID, RECONCILE_CONTROL_OID, RECONCILE_STATE_BRANCH, RECONCILE_BASE_OID, RECONCILE_REF_HASH. -publish additionally checks the clean exact-base RECONCILE_TARGET_CHECKOUT and -RECONCILE_TERRAFORM_ROOTS, creates one owned update commit, and reconciles marked -PRs and failure issues. Requires RECONCILE_DRY_RUN (true/false), RUNNER_TEMP, +publish refuses to do anything, dry run or not, unless RECONCILE_CALLER_REF is +refs/heads/ followed by RECONCILE_DEFAULT_BRANCH. It then additionally checks the clean +exact-base RECONCILE_TARGET_CHECKOUT and RECONCILE_TERRAFORM_ROOTS, creates one owned +update commit, and reconciles marked PRs and failure issues. Requires RECONCILE_DRY_RUN +(true/false), RUNNER_TEMP, RECONCILE_RUN_URL, RECONCILE_GIT_REMOTE, RECONCILE_REPOSITORY, GH_TOKEN, RECONCILE_COMMIT_AUTHOR_NAME, RECONCILE_COMMIT_AUTHOR_EMAIL, and the RECONCILE_TERRAFORM_VERSION and RECONCILE_TF_VERSION_BUMP_VERSION their bodies name. @@ -235,6 +237,7 @@ reconcile_lifecycle() { } publish_result() { + require_default_branch_caller validate_result [[ "$CLASSIFICATION" != automation ]] || return 0 : "${RECONCILE_DRY_RUN:?RECONCILE_DRY_RUN must be set}" @@ -293,6 +296,16 @@ require_common_identity() { } +# Publication must not depend on the publish job's `if` alone: a pull-request run executes the +# workflows it changes, so this refuses every ref but the default branch, dry run or not. +require_default_branch_caller() { + : "${RECONCILE_CALLER_REF:?RECONCILE_CALLER_REF must be set}" + : "${RECONCILE_DEFAULT_BRANCH:?RECONCILE_DEFAULT_BRANCH must be set}" + [[ "$RECONCILE_CALLER_REF" == "refs/heads/$RECONCILE_DEFAULT_BRANCH" ]] \ + || reconcile_error "publication runs only from refs/heads/$RECONCILE_DEFAULT_BRANCH" +} + + github_marker() { printf '\n' \ "$RECONCILE_AUTOMATION_POLICY_ID" "$RECONCILE_REF_HASH" diff --git a/examples/github-actions/.github/workflows/tf-version-bump-nonproduction.yml b/examples/github-actions/.github/workflows/tf-version-bump-nonproduction.yml index 5dbfa48..d218b8c 100644 --- a/examples/github-actions/.github/workflows/tf-version-bump-nonproduction.yml +++ b/examples/github-actions/.github/workflows/tf-version-bump-nonproduction.yml @@ -4,6 +4,10 @@ on: push: paths: - .github/tf-version-bump/nonproduction.yml + pull_request: + paths: + - .github/tf-version-bump/nonproduction.yml + - .github/workflows/tf-version-bump-nonproduction.yml workflow_dispatch: inputs: branch_prefix: @@ -27,19 +31,18 @@ permissions: pull-requests: write issues: write -concurrency: - group: tf-version-bump-nonproduction-${{ github.repository_id }} - cancel-in-progress: false - queue: max - jobs: automation: if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + concurrency: + group: tf-version-bump-nonproduction-${{ github.repository_id }} + cancel-in-progress: false + queue: max uses: ./.github/workflows/tf-version-bump-reusable.yml - secrets: + secrets: &policy-secrets TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} TERRAFORM_ENV: ${{ secrets.TERRAFORM_ENV }} - with: + with: &policy-inputs automation_policy_id: nonproduction allowed_branch_prefixes: | state/nonproduction/ @@ -55,6 +58,20 @@ jobs: tf_version_bump_version: v1.0.0-rc.17 tf_version_bump_archive_sha256: 137eac8239d08e2a93409e9eda1fd59b52abcf3d774fa7c3882ce53534f56a4d dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + preview: ${{ github.event_name == 'pull_request' }} max_parallel: 4 commit_author_name: github-actions[bot] commit_author_email: 41898282+github-actions[bot]@users.noreply.github.com + # Previews a same-repository pull request into the default branch on one sampled branch per + # prefix, without publishing. The aliases give it the live job's inputs, including any the pull + # request edits. A newer push cancels an older preview, while live runs queue: GitHub rejects + # both settings in one concurrency block. It keeps the workflow's permissions because GitHub + # refuses to start a read-only caller of a workflow whose publish job requests write. + preview: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.ref == github.event.repository.default_branch }} + concurrency: + group: tf-version-bump-nonproduction-preview-${{ github.repository_id }}-${{ github.event.number }} + cancel-in-progress: true + uses: ./.github/workflows/tf-version-bump-reusable.yml + secrets: *policy-secrets + with: *policy-inputs diff --git a/examples/github-actions/.github/workflows/tf-version-bump-production.yml b/examples/github-actions/.github/workflows/tf-version-bump-production.yml index 5dc249c..d6c52f9 100644 --- a/examples/github-actions/.github/workflows/tf-version-bump-production.yml +++ b/examples/github-actions/.github/workflows/tf-version-bump-production.yml @@ -4,6 +4,10 @@ on: push: paths: - .github/tf-version-bump/production.yml + pull_request: + paths: + - .github/tf-version-bump/production.yml + - .github/workflows/tf-version-bump-production.yml workflow_dispatch: inputs: branch_prefix: @@ -27,19 +31,18 @@ permissions: pull-requests: write issues: write -concurrency: - group: tf-version-bump-production-${{ github.repository_id }} - cancel-in-progress: false - queue: max - jobs: automation: if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + concurrency: + group: tf-version-bump-production-${{ github.repository_id }} + cancel-in-progress: false + queue: max uses: ./.github/workflows/tf-version-bump-reusable.yml - secrets: + secrets: &policy-secrets TF_API_TOKEN: ${{ secrets.TF_API_TOKEN }} TERRAFORM_ENV: ${{ secrets.TERRAFORM_ENV }} - with: + with: &policy-inputs automation_policy_id: production allowed_branch_prefixes: | state/production/ @@ -53,6 +56,20 @@ jobs: tf_version_bump_version: v1.0.0-rc.17 tf_version_bump_archive_sha256: 137eac8239d08e2a93409e9eda1fd59b52abcf3d774fa7c3882ce53534f56a4d dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }} + preview: ${{ github.event_name == 'pull_request' }} max_parallel: 2 commit_author_name: github-actions[bot] commit_author_email: 41898282+github-actions[bot]@users.noreply.github.com + # Previews a same-repository pull request into the default branch on one sampled branch per + # prefix, without publishing. The aliases give it the live job's inputs, including any the pull + # request edits. A newer push cancels an older preview, while live runs queue: GitHub rejects + # both settings in one concurrency block. It keeps the workflow's permissions because GitHub + # refuses to start a read-only caller of a workflow whose publish job requests write. + preview: + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.ref == github.event.repository.default_branch }} + concurrency: + group: tf-version-bump-production-preview-${{ github.repository_id }}-${{ github.event.number }} + cancel-in-progress: true + uses: ./.github/workflows/tf-version-bump-reusable.yml + secrets: *policy-secrets + with: *policy-inputs diff --git a/examples/github-actions/.github/workflows/tf-version-bump-reusable.yml b/examples/github-actions/.github/workflows/tf-version-bump-reusable.yml index 4f2e6e3..cb2c340 100644 --- a/examples/github-actions/.github/workflows/tf-version-bump-reusable.yml +++ b/examples/github-actions/.github/workflows/tf-version-bump-reusable.yml @@ -39,6 +39,9 @@ on: dry_run: type: boolean default: false + preview: + type: boolean + default: false max_parallel: type: number default: 4 @@ -74,6 +77,7 @@ jobs: CONTROL_CHECKOUT: ${{ github.workspace }}/control DISCOVERY_ALLOWED_PREFIXES: ${{ inputs.allowed_branch_prefixes }} DISCOVERY_MANUAL_PREFIX: ${{ inputs.branch_prefix }} + DISCOVERY_PREVIEW: ${{ inputs.preview }} DISCOVERY_POLICY_ID: ${{ inputs.automation_policy_id }} DISCOVERY_RUN_ID: ${{ github.run_id }} DISCOVERY_RUN_ATTEMPT: ${{ github.run_attempt }} @@ -163,9 +167,11 @@ jobs: retention-days: 7 - name: Report processing result if: ${{ always() }} + shell: bash env: PROCESS_OUTCOME: ${{ steps.process.outcome }} RESULT_MANIFEST: ${{ runner.temp }}/result/result.json + PREVIEW: ${{ inputs.preview }} run: | if [[ ! -f "$RESULT_MANIFEST" ]]; then echo "Processing wrote no result manifest; publication will not change managed refs, pull requests or issues." >&2 @@ -178,24 +184,34 @@ jobs: else "\n- Automation failed with status \(.failure.status); the `Process candidate` step log names the cause, and `logs/` in the processing artefact holds any command output" end)' \ "$RESULT_MANIFEST" >>"$GITHUB_STEP_SUMMARY" + # Appends a file in a fence one backtick longer than any run inside it, truncating past + # 64 KiB at a line boundary so the closing fence stays on a line of its own. + append_fenced_file() { + local heading=$1 file=$2 noun=$3 fence='```' longest + longest=$({ grep -aoE '`+' "$file" || true; } | awk '{ if (length > max) max = length } END { print max + 0 }') + while [[ ${#fence} -le $longest ]]; do fence+='`'; done + printf '\n#### %s\n\n%s\n' "$heading" "$fence" >>"$GITHUB_STEP_SUMMARY" + if [[ $(wc -c <"$file") -gt 65536 ]]; then + head -c 65536 "$file" | sed '$d' >>"$GITHUB_STEP_SUMMARY" + printf '%s\n\nThis %s was truncated; the processing artefact holds it in full.\n' "$fence" "$noun" >>"$GITHUB_STEP_SUMMARY" + else + awk 1 "$file" >>"$GITHUB_STEP_SUMMARY" + printf '%s\n' "$fence" >>"$GITHUB_STEP_SUMMARY" + fi + } # Only the updater's log is shown: Terraform's logs can carry a credential a provider echoed. index=0 while IFS= read -r root; do index=$((index + 1)) log="${RESULT_MANIFEST%/*}/logs/update-$index.log" [[ -f "$log" ]] || continue - fence='```' - longest=$({ grep -aoE '`+' "$log" || true; } | awk '{ if (length > max) max = length } END { print max + 0 }') - while [[ ${#fence} -le $longest ]]; do fence+='`'; done - printf '\n#### Updates in %s\n\n%s\n' "\`$root\`" "$fence" >>"$GITHUB_STEP_SUMMARY" - if [[ $(wc -c <"$log") -gt 65536 ]]; then - head -c 65536 "$log" | sed '$d' >>"$GITHUB_STEP_SUMMARY" - printf '%s\n\nThis log was truncated; the processing artefact holds it in full.\n' "$fence" >>"$GITHUB_STEP_SUMMARY" - else - awk 1 "$log" >>"$GITHUB_STEP_SUMMARY" - printf '%s\n' "$fence" >>"$GITHUB_STEP_SUMMARY" - fi + append_fenced_file "Updates in \`$root\`" "$log" log done < <(jq -r '(.roots // [])[]' "$RESULT_MANIFEST") + # The patch holds only Terraform files and root lock files, never Terraform's logs. + patch="${RESULT_MANIFEST%/*}/candidate.patch" + if [[ "$PREVIEW" == true && -f "$patch" ]]; then + append_fenced_file 'Candidate patch' "$patch" patch + fi classification=$(jq -er '.classification' "$RESULT_MANIFEST") if [[ "$PROCESS_OUTCOME" == success \ && "$classification" != success && "$classification" != no-change ]]; then @@ -206,7 +222,7 @@ jobs: publish: name: publish (${{ matrix.branch }}) needs: [discover, process] - if: ${{ always() && needs.discover.result == 'success' }} + if: ${{ always() && needs.discover.result == 'success' && !inputs.preview }} strategy: fail-fast: false max-parallel: ${{ inputs.max_parallel }} @@ -260,6 +276,8 @@ jobs: RECONCILE_TARGET_CHECKOUT: ${{ github.workspace }}/target RECONCILE_TERRAFORM_ROOTS: ${{ inputs.terraform_directories }} RECONCILE_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + RECONCILE_CALLER_REF: ${{ github.ref }} + RECONCILE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} RECONCILE_GIT_REMOTE: ${{ github.server_url }}/${{ github.repository }}.git RECONCILE_REPOSITORY: ${{ github.repository }} RECONCILE_DRY_RUN: ${{ inputs.dry_run }} diff --git a/examples/github-actions/README.md b/examples/github-actions/README.md index cd206b5..353f153 100644 --- a/examples/github-actions/README.md +++ b/examples/github-actions/README.md @@ -32,7 +32,7 @@ Review and commit the copied files. The example supplies separate callers for: The same copy adds `tf-version-bump-report.yml`, a read-only version report described [below](#version-report). -Both callers run only from the default branch. 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. +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](#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 `!`: @@ -47,7 +47,7 @@ An exclusion names one exact branch (wildcard characters fail validation), so `s 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. +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 @@ -75,7 +75,7 @@ Each of the secret's values is registered with `::add-mask::`, which redacts it 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. +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//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 @@ -86,7 +86,7 @@ Edit the control configurations on the default branch: .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; they do not process state branches or run Terraform. +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](#preview-on-pull-requests)). The callers process the repository root by default: @@ -143,6 +143,20 @@ Unchanged candidates still run validation. Before any PR or issue 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](https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#triggering-a-workflow-from-a-workflow) 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. @@ -164,6 +178,6 @@ Both CSVs keep values as the audit reports them: as written, except that when a 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. Terraform's own logs appear only in the artefact, because a provider can echo a credential into them. 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. +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 appear only in the artefact, because a provider can echo a credential into them. 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. diff --git a/examples/github-actions/reconcile-test.sh b/examples/github-actions/reconcile-test.sh index a546704..46b22b6 100755 --- a/examples/github-actions/reconcile-test.sh +++ b/examples/github-actions/reconcile-test.sh @@ -126,6 +126,8 @@ run_publish() { PATH="$FIXTURE_BIN:$PATH" \ GH_CAPTURE_DIR="$FIXTURE_GH_CAPTURE" \ RECONCILE_RUN_URL=https://github.com/yesdevnull/reconciliation-test/actions/runs/100 \ + RECONCILE_CALLER_REF=${RECONCILE_CALLER_REF-refs/heads/main} \ + RECONCILE_DEFAULT_BRANCH=main \ RECONCILE_RUN_ID=${RECONCILE_RUN_ID-100} \ RECONCILE_RUN_ATTEMPT=${RECONCILE_RUN_ATTEMPT-1} \ RECONCILE_AUTOMATION_POLICY_ID=nonproduction \ @@ -447,13 +449,36 @@ test_failure_issue_create_reopen_and_invalid_status() { done } +test_publication_runs_only_from_the_default_branch() { + # Production break caught: a preview whose publish gate is lost, or any other run from a + # non-default ref, pushes update refs or changes pull requests and issues from unmerged config. + local caller_ref dry_run remote + for caller_ref in refs/pull/7/merge refs/heads/feature; do + for dry_run in true false; do + setup_success_fixture + remote=$FIXTURE_REMOTE + if RECONCILE_CALLER_REF=$caller_ref RECONCILE_DRY_RUN=$dry_run run_publish \ + >"$FIXTURE_ROOT/stdout" 2>"$FIXTURE_ROOT/stderr"; then + fail "publication from $caller_ref (dry run $dry_run) was accepted" + fi + [[ "$(<"$FIXTURE_ROOT/stderr")" == 'reconciliation error: publication runs only from refs/heads/main' ]] \ + || fail "publication from $caller_ref reported: $(<"$FIXTURE_ROOT/stderr")" + [[ ! -s "$FIXTURE_ROOT/stdout" ]] || fail 'refused publication emitted stdout' + [[ ! -f "$FIXTURE_GH_CAPTURE/calls" ]] || fail "publication from $caller_ref called GitHub" + [[ -z "$("$TEST_GIT" --git-dir "$remote" for-each-ref --format='%(refname)' refs/heads/update_)" ]] \ + || fail "publication from $caller_ref pushed an update ref" + done + done +} + if [[ $# -eq 0 ]]; then tests=(test_publishes_one_owned_commit_from_exact_base test_result_validation_prevents_mutation test_rejects_unsafe_candidate_paths_and_modes test_publication_refuses_moved_base_and_foreign_update test_stale_results_do_not_reconcile_lifecycle test_reconciles_no_change_and_failure_in_order test_api_failures_stop_followup_actions test_dry_run_and_automation_do_not_mutate test_exact_lease_rejects_racing_update_ref - test_failure_issue_create_reopen_and_invalid_status) + test_failure_issue_create_reopen_and_invalid_status + test_publication_runs_only_from_the_default_branch) else tests=("$@"); fi for test_name in "${tests[@]}"; do "$test_name" diff --git a/examples/github-actions/test.sh b/examples/github-actions/test.sh index 522a4e9..5c5c0b0 100755 --- a/examples/github-actions/test.sh +++ b/examples/github-actions/test.sh @@ -361,6 +361,7 @@ assert_processing_failure() { setup_discovery_repository() { cleanup_discovery_repository + unset DISCOVERY_PREVIEW DISCOVERY_ALLOWED_PREFIXES="" DISCOVERY_MANUAL_PREFIX="" DISCOVERY_POLICY_ID="nonproduction" @@ -414,6 +415,7 @@ add_numbered_discovery_branches() { run_discovery() { ( cd "$DISCOVERY_REPO" + [[ -z "${DISCOVERY_PREVIEW+set}" ]] || export DISCOVERY_PREVIEW DISCOVERY_ALLOWED_PREFIXES=${DISCOVERY_ALLOWED_PREFIXES-} \ DISCOVERY_MANUAL_PREFIX=${DISCOVERY_MANUAL_PREFIX-} \ DISCOVERY_POLICY_ID=${DISCOVERY_POLICY_ID-nonproduction} \ @@ -725,6 +727,132 @@ test_discovery_rejects_invalid_inputs_by_stage() { } +test_discovery_preview_accepts_only_pull_request_merge_refs() { + # Production break caught: relaxing the default-branch guard for previews lets any ref drive + # discovery, a pull-request ref passes without asking for a preview, or a preview's seed can + # disagree with the pull request it runs for. + setup_discovery_repository + add_discovery_branch "state/prod/example" + DISCOVERY_ALLOWED_PREFIXES="state/prod/" + + DISCOVERY_CALLER_REF="refs/pull/42/merge" + assert_discovery_failure "discovery caller error: caller ref must be refs/heads/main" \ + "a pull-request ref without a preview" + + DISCOVERY_PREVIEW=true + local output + output=$(run_discovery) + jq -e '.include | map(.branch) == ["state/prod/example"]' <<<"$output" >/dev/null \ + || fail "preview discovery refused a pull-request merge ref: $output" + + local ref + for ref in refs/heads/main refs/pull/0/merge refs/pull/042/merge refs/pull/7/head refs/pull/x/merge; do + DISCOVERY_CALLER_REF=$ref + assert_discovery_failure "discovery caller error: preview caller ref must be refs/pull//merge" \ + "a preview from $ref" + done + + DISCOVERY_CALLER_REF="refs/pull/42/merge" + DISCOVERY_MANUAL_PREFIX="state/prod/" + assert_discovery_failure "discovery input error: a preview cannot take a manual prefix" \ + "a preview with a manual prefix" + + DISCOVERY_MANUAL_PREFIX="" + local value + for value in "" TRUE yes; do + DISCOVERY_PREVIEW=$value + assert_discovery_failure "discovery input error: DISCOVERY_PREVIEW must be true or false" \ + "a preview flag of '$value'" + done +} + + +test_discovery_preview_samples_one_branch_per_prefix() { + # Production break caught: a preview processes every branch, picks differently on each push, + # or samples a branch its caller excludes. + setup_discovery_repository + local name + for name in alpha bravo charlie; do + add_discovery_branch "state/staging/$name" + add_discovery_branch "state/production/$name" + done + add_discovery_branch "state/production/excluded" + DISCOVERY_ALLOWED_PREFIXES=$'state/staging/\nstate/production/\n!state/production/excluded' + DISCOVERY_PREVIEW=true + local output stderr_file="$DISCOVERY_TMP_ROOT/sample.stderr" + + # Known answers: the first 15 hex digits of sha256("\t"), modulo the three + # branches each prefix owns, index the sorted branches. Pull request 42 picks index 2 of + # state/production/ and 0 of state/staging/; pull request 43 picks index 1 of both. + DISCOVERY_CALLER_REF="refs/pull/42/merge" + output=$(run_discovery 2>"$stderr_file") + jq -e '.include | map(.branch) == ["state/production/charlie", "state/staging/alpha"]' \ + <<<"$output" >/dev/null || fail "pull request 42 did not sample its known branches: $output" + [[ ! -s "$stderr_file" ]] || fail "sampling reported a warning: $(<"$stderr_file")" + output=$(run_discovery) + jq -e '.include | map(.branch) == ["state/production/charlie", "state/staging/alpha"]' \ + <<<"$output" >/dev/null || fail "a repeat preview of pull request 42 sampled differently: $output" + + DISCOVERY_CALLER_REF="refs/pull/43/merge" + output=$(run_discovery) + jq -e '.include | map(.branch) == ["state/production/bravo", "state/staging/bravo"]' \ + <<<"$output" >/dev/null || fail "pull request 43 did not sample its known branches: $output" +} + + +test_discovery_preview_warns_for_prefixes_that_own_no_branch() { + # Production break caught: a prefix with nothing to preview passes silently, a narrower prefix + # listed after a broader one is reported as matching nothing without saying why, a repeated + # prefix previews its branch twice, or live runs gain the warning. + setup_discovery_repository + add_discovery_branch "state/staging/alpha" + DISCOVERY_ALLOWED_PREFIXES=$'state/\nstate/staging/\nstate/\naws-state/100%/' + local output stderr_file="$DISCOVERY_TMP_ROOT/unowned.stderr" + + output=$(run_discovery 2>"$stderr_file") + [[ ! -s "$stderr_file" ]] || fail "a live run reported unowned prefixes: $(<"$stderr_file")" + + DISCOVERY_PREVIEW=true + DISCOVERY_CALLER_REF="refs/pull/42/merge" + output=$(run_discovery 2>"$stderr_file") + jq -e '.include | map(.branch) == ["state/staging/alpha"]' <<<"$output" >/dev/null \ + || fail "a preview did not sample the one owned branch exactly once: $output" + [[ "$(<"$stderr_file")" == "::warning::branch prefix 'state/staging/' owns no branch to preview"$'\n'"::warning::branch prefix 'aws-state/100%25/' owns no branch to preview" ]] \ + || fail "a preview did not name each prefix that owns no branch once: $(<"$stderr_file")" +} + + +test_discovery_preview_keeps_the_live_matrix_limit() { + # Production break caught: a policy the live run refuses for exceeding the matrix limit + # previews green because the sample itself is small. + setup_discovery_repository + add_numbered_discovery_branches 0 256 + DISCOVERY_ALLOWED_PREFIXES="state/limit/" + DISCOVERY_PREVIEW=true + DISCOVERY_CALLER_REF="refs/pull/42/merge" + assert_discovery_failure "discovery matrix error:" "a preview of more than 256 branches" +} + + +test_discovery_preview_fails_when_sampling_loses_its_branches() { + # Production break caught: a failing sort inside a process substitution leaves discovery with + # an empty matrix, which GitHub runs as zero jobs, so the preview passes having previewed + # nothing. + setup_discovery_repository + add_discovery_branch "state/staging/alpha" + DISCOVERY_ALLOWED_PREFIXES="state/staging/" + DISCOVERY_PREVIEW=true + DISCOVERY_CALLER_REF="refs/pull/42/merge" + local failing_sort="$DISCOVERY_TMP_ROOT/failing-sort" + mkdir -m 700 "$failing_sort" + printf '#!/bin/sh\nexit 1\n' >"$failing_sort/sort" + chmod 700 "$failing_sort/sort" + DISCOVERY_PATH="$failing_sort:$PATH" assert_discovery_failure \ + "discovery selection error: preview sampling did not keep one branch per owning prefix" \ + "a preview whose sampling lost its branches" +} + + test_discovery_treats_hostile_refs_as_inert_data() { # Production break caught: shell evaluation, word splitting, option parsing, locale filtering, # or JSON interpolation executes or corrupts a Git-valid remote branch name. @@ -1095,7 +1223,11 @@ test_workflow_runs_three_jobs_with_current_attempt_results() { ([.publish.steps[] | select(.env.TF_TOKEN_app_terraform_io != null)] | length == 0) and ([.publish.steps[] | select(.env.RECONCILE_RUN_URL != null)] | length == 1) and ([.publish.steps[] | select(.env.RECONCILE_TERRAFORM_VERSION == "${{ inputs.terraform_version }}" - and .env.RECONCILE_TF_VERSION_BUMP_VERSION == "${{ inputs.tf_version_bump_version }}")] | length == 1) + and .env.RECONCILE_TF_VERSION_BUMP_VERSION == "${{ inputs.tf_version_bump_version }}")] | length == 1) and + ([.publish.steps[] | select(.env.RECONCILE_RUN_URL != null) + | .env | {RECONCILE_CALLER_REF, RECONCILE_DEFAULT_BRANCH}] + == [{RECONCILE_CALLER_REF: "${{ github.ref }}", + RECONCILE_DEFAULT_BRANCH: "${{ github.event.repository.default_branch }}"}]) ' >/dev/null || fail 'workflow does not wire the three-job current-attempt result contract' } @@ -1555,13 +1687,17 @@ run_report_step() { test_workflow_reports_the_processing_result() { # The report runs whatever processing did, judges it by the processing step's own - # outcome, and reads the manifest from the directory processing writes. + # outcome, and reads the manifest from the directory processing writes. `shell: bash` adds + # pipefail, so a failed read inside the summary's pipelines fails the step instead of + # leaving an empty section. yq -o=json '.jobs.process.steps' "$REUSABLE_WORKFLOW" | jq -e ' (.[] | select(.id == "process") | .env.PROCESS_RESULT_DIR) as $result | [.[] | select(.name == "Report processing result")] | length == 1 and .[0].if == "${{ always() }}" + and .[0].shell == "bash" and .[0].env.PROCESS_OUTCOME == "${{ steps.process.outcome }}" and .[0].env.RESULT_MANIFEST == $result + "/result.json" + and .[0].env.PREVIEW == "${{ inputs.preview }}" ' >/dev/null || fail 'the report step is not wired to the processing step and its result' local work="$TEST_TMP_ROOT/report-step" rm -rf -- "$work" @@ -1671,6 +1807,52 @@ test_workflow_summarises_update_logs() { } +run_preview_report_step() { + run_workflow_step process 'Report processing result' "$3" \ + PROCESS_OUTCOME="$2" RESULT_MANIFEST="$1" PREVIEW="$4" +} + + +test_workflow_summarises_the_preview_patch() { + # Production break caught: a preview hides the candidate it produced, a live run's summary + # gains the patch, or backticks or size in the patch break the summary. + local work="$TEST_TMP_ROOT/report-patch" + rm -rf -- "$work" + mkdir -p "$work/logs" + local manifest="$work/result.json" summary="$work/summary.md" diagnostics="$work/stderr" + local report + + write_report_manifest "$manifest" success null '["."]' + printf '%s\n' '- version = "1.0.0"' '+ version = "2.0.0"' '```' >"$work/candidate.patch" + assert_silent_success 'summarising a preview patch' "$work/stdout" "$diagnostics" \ + run_preview_report_step "$manifest" success "$summary" true + report=$(<"$summary") + [[ "$report" == *'#### Candidate patch'* && "$report" == *'+ version = "2.0.0"'* ]] \ + || fail "the preview summary omits the candidate patch: $report" + [[ $(grep -cx '````' "$summary") -eq 2 ]] \ + || fail "the patch's fence does not outlast the backticks inside it: $report" + + assert_silent_success 'summarising a live result' "$work/stdout" "$diagnostics" \ + run_preview_report_step "$manifest" success "$summary" false + [[ "$(<"$summary")" != *'Candidate patch'* ]] \ + || fail 'a live run summary shows the candidate patch' + + awk 'BEGIN { for (i = 1; i <= 1200; i++) printf "+line %04d %060d\n", i, 0 }' \ + >"$work/candidate.patch" + assert_silent_success 'summarising an oversized preview patch' "$work/stdout" "$diagnostics" \ + run_preview_report_step "$manifest" success "$summary" true + [[ "$(<"$summary")" == *$'\n```\n\nThis patch was truncated'* ]] \ + || fail "an oversized patch was not truncated with its closing fence on its own line: $(<"$summary")" + + rm -f -- "$work/candidate.patch" + write_report_manifest "$manifest" no-change + assert_silent_success 'summarising a preview without a patch' "$work/stdout" "$diagnostics" \ + run_preview_report_step "$manifest" success "$summary" true + [[ "$(<"$summary")" != *'Candidate patch'* ]] \ + || fail 'a preview without a candidate patch claims one' +} + + run_dry_run_report_step() { run_workflow_step publish 'Report dry-run outcome' "$2" RESULT_MANIFEST="$1" } @@ -1745,7 +1927,7 @@ test_workflow_fails_the_process_job_on_processing_failure() { workflow_publishes_after_processing_failures() { local workflow=$1 yq -o=json '.jobs' "$workflow" \ - | jq -e --arg condition "\${{ always() && needs.discover.result == 'success' }}" ' + | jq -e --arg condition "\${{ always() && needs.discover.result == 'success' && !inputs.preview }}" ' .process.strategy["fail-fast"] == false and .publish.strategy["fail-fast"] == false and .publish.if == $condition @@ -1768,6 +1950,11 @@ test_workflow_publishes_every_branch_after_processing_failures() { yq 'del(.jobs.publish.if)' "$REUSABLE_WORKFLOW" >"$mutant" ! workflow_publishes_after_processing_failures "$mutant" \ || fail 'the guard passes when publication runs only after every process leg succeeds' + mutant="$TEST_TMP_ROOT/publish-without-preview-gate.yml" + yq ".jobs.publish.if = \"\${{ always() && needs.discover.result == 'success' }}\"" \ + "$REUSABLE_WORKFLOW" >"$mutant" + ! workflow_publishes_after_processing_failures "$mutant" \ + || fail 'the guard passes when a preview can reach publication' } test_workflow_offers_input_and_secret_terraform_environment() { @@ -1815,6 +2002,49 @@ test_workflow_fails_discovery_when_the_script_fails() { || fail "the discover step emitted a matrix after discovery failed: $(<"$work/output")" } +test_workflow_previews_without_publishing() { + # Production break caught: callers cannot ask for a preview, or discovery never hears of it and + # processes every branch from a pull request. + yq -o=json '.' "$REUSABLE_WORKFLOW" | jq -e ' + .on.workflow_call.inputs.preview == {type: "boolean", default: false} + and ([.jobs.discover.steps[] | select(.id == "discover") | .env.DISCOVERY_PREVIEW] + == ["${{ inputs.preview }}"]) + ' >/dev/null || fail 'the reusable workflow does not offer a preview input wired into discovery' +} + +test_callers_preview_pull_requests() { + # Production break caught: a preview runs with inputs that differ from its live run, queues + # behind or blocks live runs, previews a fork or a non-default base, narrows its permissions so + # GitHub refuses to start it, or a live run starts from a pull request. + local policy workflow + for policy in nonproduction production; do + workflow="$SCRIPT_DIR/.github/workflows/tf-version-bump-$policy.yml" + yq -o=json '.' "$workflow" | jq -e --arg policy "$policy" ' + .jobs as $jobs + | (has("concurrency") | not) + and .on.pull_request == {paths: [$jobs.automation.with.config_path, + ".github/workflows/tf-version-bump-\($policy).yml"]} + and ($jobs | keys) == ["automation", "preview"] + and $jobs.automation.if == "${{ github.ref == format('"'"'refs/heads/{0}'"'"', github.event.repository.default_branch) }}" + and $jobs.automation.concurrency == {group: "tf-version-bump-\($policy)-${{ github.repository_id }}", + "cancel-in-progress": false, queue: "max"} + and $jobs.preview.if == "${{ github.event_name == '"'"'pull_request'"'"' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.ref == github.event.repository.default_branch }}" + and $jobs.preview.concurrency == {group: "tf-version-bump-\($policy)-preview-${{ github.repository_id }}-${{ github.event.number }}", + "cancel-in-progress": true} + and ($jobs.preview | has("permissions") | not) + and $jobs.preview.uses == $jobs.automation.uses + and $jobs.preview.with == $jobs.automation.with + and $jobs.preview.secrets == $jobs.automation.secrets + and $jobs.automation.with.preview == "${{ github.event_name == '"'"'pull_request'"'"' }}" + ' >/dev/null || fail "the $policy caller does not preview pull requests with its live inputs" + # The aliases keep one copy of each policy value, so a pull request's edits reach its preview. + grep -qxF ' with: *policy-inputs' "$workflow" \ + || fail "the $policy caller's preview does not reuse the live job's inputs by alias" + grep -qxF ' secrets: *policy-secrets' "$workflow" \ + || fail "the $policy caller's preview does not reuse the live job's secrets by alias" + done +} + cleanup_test_repositories() { cleanup_discovery_repository cleanup_processing_workspace @@ -1852,7 +2082,10 @@ if [[ $# -eq 0 ]]; then test_workflow_publishes_every_branch_after_processing_failures test_workflow_offers_input_and_secret_terraform_environment test_workflow_fails_discovery_when_the_script_fails - test_workflow_summarises_update_logs test_workflow_reports_the_dry_run_outcome) + test_workflow_previews_without_publishing + test_callers_preview_pull_requests + test_workflow_summarises_update_logs test_workflow_summarises_the_preview_patch + test_workflow_reports_the_dry_run_outcome) while IFS= read -r test_name; do tests+=("$test_name"); done < <(compgen -A function test_discovery_) for test_name in "${tests[@]}"; do "$test_name"