Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions .github/workflows/terraform-module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@
# contents: read
# secrets: inherit
#
# `secrets: inherit` is REQUIRED: the "Prepare test environment" step fans the
# full secret + variable sets into $GITHUB_ENV so terraform and example hooks
# inherit them. This replaces the governance cred-prep script's curated
# `AVM_*` / `TF_VAR_*` subset forwarding - and makes the old `AVM_BARE_` prefix
# escape hatch redundant, so it is intentionally dropped here.
# For trusted runs, `secrets: inherit` is REQUIRED: the authoritative
# credentialled `pr-check`, integration, and end-to-end jobs fan the full secret
# + variable sets into $GITHUB_ENV so terraform and example hooks inherit them.
# Fork pull requests receive no inherited secrets. They run unit tests plus a
# separate credential-free `pr-check` job, which uses Avm.Authoring's synthetic
# loopback-token fallback for Terraform policy plans. An example that reads an
# existing Azure resource through a data source fails that path with
# `avm.tf.policy-needs-credential`; verify it through a branch pull request so
# the trusted credentialled `pr-check` can run. Integration, example discovery,
# and end-to-end tests remain restricted to trusted, non-fork pull requests.
#
# Divergences from the governance template (tracked as follow-ups):
# * There is no global setup/teardown stage; the module implements per-example
Expand Down Expand Up @@ -332,6 +337,60 @@ jobs:
Import-Module Avm.Authoring
avm pr-check

pr-check-fork:
name: PR check (fork, credential-free)
if: github.event.pull_request.head.repo.fork == true
runs-on: ubuntu-latest
environment: no-approval
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Configure AVM tool cache
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$avmHome = Join-Path $env:RUNNER_TEMP 'avm-home'
$cache = Join-Path $env:RUNNER_TEMP 'tf-plugin-cache'
New-Item -ItemType Directory -Force -Path $avmHome, $cache | Out-Null
"AVM_HOME=$avmHome" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"TF_PLUGIN_CACHE_DIR=$cache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8

- name: Install Avm.Authoring
shell: pwsh
env:
AVM_AUTHORING_VERSION: ${{ inputs.avm-authoring-version }}
run: |
$ErrorActionPreference = 'Stop'
if (-not (Get-Module -ListAvailable -Name 'Microsoft.PowerShell.PSResourceGet')) {
Install-Module -Name 'Microsoft.PowerShell.PSResourceGet' -Scope CurrentUser -Force -AllowClobber
}
Import-Module 'Microsoft.PowerShell.PSResourceGet'

$params = @{
Name = 'Avm.Authoring'
Scope = 'CurrentUser'
TrustRepository = $true
}
if (-not [string]::IsNullOrWhiteSpace($env:AVM_AUTHORING_VERSION)) {
$params['Version'] = $env:AVM_AUTHORING_VERSION
}
Install-PSResource @params

Import-Module Avm.Authoring
avm version

- name: Run credential-free pr-check
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Import-Module Avm.Authoring
avm pr-check

integration-test:
name: Integration tests
if: github.event.pull_request.head.repo.fork == false
Expand Down
44 changes: 44 additions & 0 deletions docs/progress/2026-09-03-fork-safe-terraform-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Fork-safe Terraform module workflow

**Status**: complete
**Started**: 2026-09-03
**Updated**: 2026-09-04
**Branch**: `jaredfholgate-fork-safe-terraform-workflow`

## Outcome

Split the reusable Terraform module workflow's PR gate into two paths. Branch
pull requests retain the authoritative credentialled `pr-check`; fork pull
requests run a separate credential-free `pr-check` using the synthetic token
fallback from PR #98. Unit tests run for both paths, while integration, example
discovery, and end-to-end tests remain restricted to trusted, non-fork runs.

> [!IMPORTANT]
> This workflow pull request must not merge until
> [PR #98](https://github.com/Azure/azure-verified-modules-tools/pull/98) has
> merged and the resulting `Avm.Authoring` release has been published to
> PSGallery. Merging earlier would make consuming module repositories invoke
> credential-free policy behavior that the published module does not support.

## Checklist

- [x] Restore the authoritative credentialled `pr-check` for branch pull requests.
- [x] Add a fork-only credential-free `pr-check-fork` job.
- [x] Keep unit tests available to forks and integration/e2e trusted-only.
- [x] Document both paths and the policy-needs-credential limitation.
- [x] Add isolated unit assertions for both PR-check job contracts.
- [x] Run `./build.ps1 pre-commit`.

## Validation

- `./build.ps1 pre-commit` - 1,033 unit tests passed, 8 skipped;
29 component tests passed. Build completed with 0 errors. The lint task
recovered from known transient PSScriptAnalyzer `NullReferenceException`
retries and reported its existing warning set.

## Dependencies

- [PR #98](https://github.com/Azure/azure-verified-modules-tools/pull/98)
must merge first.
- The `Avm.Authoring` release containing PR #98 must be published to PSGallery
before this workflow pull request may merge.
53 changes: 53 additions & 0 deletions tests/Pester/Unit/Workflows/TerraformModuleWorkflow.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,59 @@ Describe 'terraform-module reusable workflow' {
$jobMatch = [regex]::Match($script:workflow, '(?ms)^ e2e-test:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the e2e-test job block.' }
$script:e2eJob = $jobMatch.Value

$jobMatch = [regex]::Match($script:workflow, '(?ms)^ pr-check:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the pr-check job block.' }
$script:prCheckJob = $jobMatch.Value

$jobMatch = [regex]::Match($script:workflow, '(?ms)^ pr-check-fork:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the pr-check-fork job block.' }
$script:prCheckForkJob = $jobMatch.Value

$jobMatch = [regex]::Match($script:workflow, '(?ms)^ unit-test:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the unit-test job block.' }
$script:unitTestJob = $jobMatch.Value

$jobMatch = [regex]::Match($script:workflow, '(?ms)^ integration-test:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the integration-test job block.' }
$script:integrationTestJob = $jobMatch.Value

$jobMatch = [regex]::Match($script:workflow, '(?ms)^ discover-examples:\r?\n.*?(?=^ [A-Za-z][\w-]*:\r?\n|\z)')
if (-not $jobMatch.Success) { throw 'Could not isolate the discover-examples job block.' }
$script:discoverExamplesJob = $jobMatch.Value
}

It 'keeps the authoritative pr-check credentialled and restricted to branch pull requests' {
$script:prCheckJob | Should -Match 'if:\s*github\.event\.pull_request\.head\.repo\.fork == false'
$script:prCheckJob | Should -Match '(?m)^\s*needs:\s*subscriptions\s*$'
$script:prCheckJob | Should -Match '(?m)^\s*environment:\s*pr-check\s*$'
$script:prCheckJob | Should -Match 'id-token:\s*write'
$script:prCheckJob | Should -Match 'ARM_OIDC_REQUEST_TOKEN'
$script:prCheckJob | Should -Match 'ARM_OIDC_REQUEST_URL'
$script:prCheckJob | Should -Match 'SELECTED_SUBSCRIPTION_ID:\s*\$\{\{ needs\.subscriptions\.outputs\.subscriptionId \}\}'
$script:prCheckJob | Should -Match 'ARM_SUBSCRIPTION_ID_OVERRIDE'
$script:prCheckJob | Should -Match '(?m)^\s*avm pr-check\s*$'
}

It 'runs a separate credential-free pr-check for fork pull requests' {
$script:prCheckForkJob | Should -Match 'if:\s*github\.event\.pull_request\.head\.repo\.fork == true'
$script:prCheckForkJob | Should -Not -Match '(?m)^\s*needs:'
$script:prCheckForkJob | Should -Not -Match 'id-token:\s*write'
$script:prCheckForkJob | Should -Match '(?m)^\s*contents:\s*read\s*$'
$script:prCheckForkJob | Should -Not -Match 'ARM_OIDC_REQUEST_(TOKEN|URL)'
$script:prCheckForkJob | Should -Not -Match 'SELECTED_SUBSCRIPTION_ID'
$script:prCheckForkJob | Should -Not -Match 'needs\.subscriptions'
$script:prCheckForkJob | Should -Match '(?m)^\s*environment:\s*no-approval\s*$'
$script:prCheckForkJob | Should -Match '(?m)^\s*avm pr-check\s*$'
}

It 'runs unit tests for forks while keeping credentialled tiers trusted-only' {
$script:unitTestJob | Should -Not -Match 'github\.event\.pull_request\.head\.repo\.fork'
$script:unitTestJob | Should -Match '(?m)^\s*avm test unit\s*$'
$script:integrationTestJob | Should -Match 'github\.event\.pull_request\.head\.repo\.fork == false'
$script:discoverExamplesJob | Should -Match 'github\.event\.pull_request\.head\.repo\.fork == false'
$script:e2eJob | Should -Match 'github\.event\.pull_request\.head\.repo\.fork == false'
$script:prCheckForkJob | Should -Not -Match 'avm test integration|avm test e2e'
}

It 'passes the non-secret subscription ID as a job output without masking it' {
Expand Down
Loading