diff --git a/.github/workflows/apply.yml b/.github/workflows/apply.yml
index 4b619f5..7087b02 100644
--- a/.github/workflows/apply.yml
+++ b/.github/workflows/apply.yml
@@ -140,19 +140,73 @@ jobs:
exit 1
fi
cp allow_destroy_override.tf.disabled allow_destroy_override.tf
+ - name: Summarize apply target
+ env:
+ REVIEWED_SHA: ${{ needs.prepare.outputs.sha }}
+ ENVIRONMENT_REASONS: ${{ toJson(matrix.environmentReasons) }}
+ run: |
+ {
+ echo '## Apply target'
+ echo ''
+ echo "- Reviewed SHA: \`${REVIEWED_SHA}\`"
+ echo "- Workspace: \`${TF_WORKSPACE}\`"
+ echo "- Environment: \`${{ matrix.environment }}\`"
+ if [[ "$(jq 'length' <<< "${ENVIRONMENT_REASONS}")" == '0' ]]; then
+ echo "- Environment reason: no allow-destroy changes detected"
+ else
+ echo "- Environment reason:"
+ jq -r '.[] | " - " + .' <<< "${ENVIRONMENT_REASONS}"
+ fi
+ echo "- Reviewed plan artifact: \`${TF_WORKSPACE}_${REVIEWED_SHA}.tfplan\`"
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Download reviewed terraform plan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SHA: ${{ needs.prepare.outputs.sha }}
run: gh run download -n "${TF_WORKSPACE}_${SHA}.tfplan" --repo "${GITHUB_REPOSITORY}"
+ - name: Show reviewed terraform plan
+ run: |
+ terraform show -no-color "${TF_WORKSPACE}.tfplan" > "${TF_WORKSPACE}.reviewed.txt"
+ {
+ echo '## Reviewed Terraform plan'
+ echo ''
+ echo "${TF_WORKSPACE}.tfplan
"
+ echo ''
+ echo '~~~~terraform'
+ sed 's/^~~~~/~~~~ /' "${TF_WORKSPACE}.reviewed.txt"
+ echo '~~~~'
+ echo ''
+ echo ' '
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Replan merged commit
run: |
terraform show -json > "$TF_WORKSPACE.tfstate.json"
terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.merged.tfplan" -no-color
- - name: Compare reviewed and merged plans
+ - name: Show merged terraform plan
run: |
- terraform show -no-color "${TF_WORKSPACE}.tfplan" > "${TF_WORKSPACE}.reviewed.txt"
terraform show -no-color "${TF_WORKSPACE}.merged.tfplan" > "${TF_WORKSPACE}.merged.txt"
+ {
+ echo '## Merged Terraform plan'
+ echo ''
+ echo "${TF_WORKSPACE}.merged.tfplan
"
+ echo ''
+ echo '~~~~terraform'
+ sed 's/^~~~~/~~~~ /' "${TF_WORKSPACE}.merged.txt"
+ echo '~~~~'
+ echo ''
+ echo ' '
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload apply plan summaries
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: apply-plans-${{ env.TF_WORKSPACE }}-${{ needs.prepare.outputs.sha }}
+ path: |
+ terraform/${{ env.TF_WORKSPACE }}.reviewed.txt
+ terraform/${{ env.TF_WORKSPACE }}.merged.txt
+ if-no-files-found: error
+ retention-days: 14
+ - name: Compare reviewed and merged plans
+ run: |
diff -u "${TF_WORKSPACE}.reviewed.txt" "${TF_WORKSPACE}.merged.txt"
- name: Terraform Apply
run: |
diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml
index 8bbc433..069c8e1 100644
--- a/.github/workflows/plan.yml
+++ b/.github/workflows/plan.yml
@@ -150,6 +150,29 @@ jobs:
fi
cp allow_destroy_override.tf.disabled allow_destroy_override.tf
working-directory: terraform
+ - name: Summarize plan target
+ env:
+ SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+ PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || '' }}
+ ENVIRONMENT_REASONS: ${{ toJson(matrix.environmentReasons) }}
+ run: |
+ {
+ echo '## Plan target'
+ echo ''
+ if [[ -n "${PULL_REQUEST_NUMBER}" ]]; then
+ echo "- Pull request: #${PULL_REQUEST_NUMBER}"
+ fi
+ echo "- Source SHA: \`${SOURCE_SHA}\`"
+ echo "- Workspace: \`${TF_WORKSPACE}\`"
+ echo "- Environment: \`${{ matrix.environment }}\`"
+ if [[ "$(jq 'length' <<< "${ENVIRONMENT_REASONS}")" == '0' ]]; then
+ echo "- Environment reason: no allow-destroy changes detected"
+ else
+ echo "- Environment reason:"
+ jq -r '.[] | " - " + .' <<< "${ENVIRONMENT_REASONS}"
+ fi
+ echo "- Terraform plan artifact: \`${TF_WORKSPACE}_${SOURCE_SHA}.tfplan\`"
+ } >> "$GITHUB_STEP_SUMMARY"
- name: Plan terraform
run: |
terraform show -json > "$TF_WORKSPACE.tfstate.json"
@@ -210,6 +233,16 @@ jobs:
done
cat TERRAFORM_PLANS.md
working-directory: terraform
+ - name: Publish terraform plans summary
+ run: cat TERRAFORM_PLANS.md >> "$GITHUB_STEP_SUMMARY"
+ working-directory: terraform
+ - name: Upload terraform plans summary
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: terraform-plans-${{ github.event.pull_request.head.sha || github.sha }}
+ path: terraform/TERRAFORM_PLANS.md
+ if-no-files-found: error
+ retention-days: 14
- name: Prepare comment
run: |
delimiter="$(uuidgen)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fedcfd..c1f8e46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- new args for repositories and branch protection rules
### Changed
+- plan/apply workflows now publish planned/applied commit, workspace, environment, and rendered terraform plan details to workflow summaries and artifacts
+- allow-destroy workspace classification now reports which member or repository removals require guarded environments
+- access report member classifications now explicitly describe the post-change access state
- workflows: added separate GitHub Actions environments for reading organization state, writing organization state, and pushing repository changes
- **BREAKING**: access changes action now emits only the access change comment by default; update custom usage to avoid nesting the full access breakdown in PR comments
- workflows: pin third-party actions to latest release SHAs and replan from the merged commit before applying
diff --git a/scripts/__tests__/actions/access-summary.test.ts b/scripts/__tests__/actions/access-summary.test.ts
index 3bed0e2..be6d7e8 100644
--- a/scripts/__tests__/actions/access-summary.test.ts
+++ b/scripts/__tests__/actions/access-summary.test.ts
@@ -140,7 +140,14 @@ repositories:
changes,
/will change from having direct pull permission to public-repo \(public\) to having direct push permission to public-repo \(public\)/
)
- assert.match(report, /Potential outside collaborators<\/summary>/)
+ assert.match(
+ report,
+ /The sections below describe effective access after these config changes are applied:/
+ )
+ assert.match(
+ report,
+ /Post-change potential outside collaborators<\/summary>/
+ )
assert.match(report, /Affected users: alice/)
assert.match(report, /User alice \(member\):/)
assert.match(report, /has direct push permission to public-repo \(public\)/)
@@ -198,6 +205,47 @@ teams:
)
})
+ it('describes member removal with retained public access as outside collaborator transition', () => {
+ const state = new State(
+ JSON.stringify({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ index: 'alice',
+ address: 'github_membership.this["alice"]',
+ type: 'github_membership',
+ values: {
+ username: 'alice',
+ role: 'member'
+ }
+ }
+ ]
+ }
+ }
+ } satisfies StateSchema)
+ )
+ const config = new Config(`
+repositories:
+ public-repo:
+ collaborators:
+ pull:
+ - alice
+ visibility: public
+`)
+
+ const changes = describeAccessChanges(state, config)
+
+ assert.match(changes, /User alice:/)
+ assert.match(changes, /will become an outside collaborator/)
+ assert.doesNotMatch(changes, /will leave the organization/)
+ assert.match(
+ changes,
+ /will gain direct pull permission to public-repo \(public\)/
+ )
+ })
+
it('keeps routine comments to access changes only', () => {
const state = new State(
JSON.stringify({values: {root_module: {resources: []}}})
@@ -252,7 +300,10 @@ members:
assert.match(comment, /Access Changes<\/summary>/)
assert.doesNotMatch(comment, /Potential no members/)
- assert.match(report, /Potential no members<\/summary>/)
+ assert.match(
+ report,
+ /Post-change potential no members<\/summary>/
+ )
} finally {
if (originalPath === undefined) {
delete process.env.ACCESS_REPORT_PATH
diff --git a/scripts/__tests__/actions/classify-allow-destroy.test.ts b/scripts/__tests__/actions/classify-allow-destroy.test.ts
index 6d070a2..67969af 100644
--- a/scripts/__tests__/actions/classify-allow-destroy.test.ts
+++ b/scripts/__tests__/actions/classify-allow-destroy.test.ts
@@ -3,7 +3,9 @@ import 'reflect-metadata'
import {describe, it} from 'node:test'
import assert from 'node:assert'
import {
+ describeWorkspaceClassification,
getEnvironment,
+ getAllowDestroyReasons,
hasAllowDestroyChange,
validateRemovedMembersHaveNoDanglingAccess
} from '../../src/actions/classify-allow-destroy.js'
@@ -92,6 +94,66 @@ members:
assert.equal(allowDestroy, true)
})
+ it('describes why allow-destroy environments are selected', async () => {
+ setManagedResourceTypes(['github_repository', 'github_membership'])
+
+ const reasons = await getAllowDestroyReasons(
+ new Config(`
+members:
+ admin:
+ - kept
+repositories:
+ kept: {}
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'kept', role: 'admin'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'removed', role: 'admin'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'kept'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'removed'}
+ }
+ ]
+ }
+ }
+ })
+ )
+ const summary = describeWorkspaceClassification({
+ include: [
+ {
+ workspace: 'default',
+ environment: 'read-allow-destroy',
+ environmentReasons: reasons
+ }
+ ]
+ })
+
+ assert.deepEqual(reasons, [
+ 'removes organization member removed',
+ 'removes repository removed'
+ ])
+ assert.match(summary, /Workspace classification/)
+ assert.match(summary, /read-allow-destroy/)
+ assert.match(summary, /removes organization member removed/)
+ assert.match(summary, /removes repository removed/)
+ })
+
it('keeps repository and membership updates in normal environments', async () => {
setManagedResourceTypes(['github_repository', 'github_membership'])
diff --git a/scripts/__tests__/workflows.test.ts b/scripts/__tests__/workflows.test.ts
index 993d53e..ad24070 100644
--- a/scripts/__tests__/workflows.test.ts
+++ b/scripts/__tests__/workflows.test.ts
@@ -85,4 +85,89 @@ describe('workflows', () => {
assert.equal(downloadYamlStep.with?.['merge-multiple'], true)
assert.equal(copyYamlStep.run, 'cp artifacts/*.yml head/github')
})
+
+ it('publishes planned terraform targets and rendered plan summaries', () => {
+ const plan = workflow('plan.yml')
+ const planSteps = plan.jobs.plan.steps
+ const commentSteps = plan.jobs.comment.steps
+ const targetStep = planSteps.find(
+ step => step.name === 'Summarize plan target'
+ )
+ const publishStep = commentSteps.find(
+ step => step.name === 'Publish terraform plans summary'
+ )
+ const uploadStep = commentSteps.find(
+ step => step.name === 'Upload terraform plans summary'
+ )
+
+ assert.ok(targetStep)
+ assert.equal(
+ targetStep.env?.ENVIRONMENT_REASONS,
+ '${{ toJson(matrix.environmentReasons) }}'
+ )
+ assert.match(targetStep.run ?? '', /## Plan target/)
+ assert.match(targetStep.run ?? '', /Pull request/)
+ assert.match(targetStep.run ?? '', /Source SHA/)
+ assert.match(targetStep.run ?? '', /Environment reason/)
+ assert.match(targetStep.run ?? '', /Terraform plan artifact/)
+ assert.ok(publishStep)
+ assert.equal(
+ publishStep.run,
+ 'cat TERRAFORM_PLANS.md >> "$GITHUB_STEP_SUMMARY"'
+ )
+ assert.ok(uploadStep)
+ assert.equal(
+ uploadStep.with?.name,
+ 'terraform-plans-${{ github.event.pull_request.head.sha || github.sha }}'
+ )
+ assert.equal(uploadStep.with?.path, 'terraform/TERRAFORM_PLANS.md')
+ })
+
+ it('publishes apply targets and reviewed plan summaries', () => {
+ const apply = workflow('apply.yml')
+ const steps = apply.jobs.apply.steps
+ const targetStep = steps.find(
+ step => step.name === 'Summarize apply target'
+ )
+ const reviewedStep = steps.find(
+ step => step.name === 'Show reviewed terraform plan'
+ )
+ const mergedStep = steps.find(
+ step => step.name === 'Show merged terraform plan'
+ )
+ const uploadStep = steps.find(
+ step => step.name === 'Upload apply plan summaries'
+ )
+ const compareStep = steps.find(
+ step => step.name === 'Compare reviewed and merged plans'
+ )
+
+ assert.ok(targetStep)
+ assert.equal(
+ targetStep.env?.ENVIRONMENT_REASONS,
+ '${{ toJson(matrix.environmentReasons) }}'
+ )
+ assert.match(targetStep.run ?? '', /## Apply target/)
+ assert.match(targetStep.run ?? '', /Reviewed SHA/)
+ assert.match(targetStep.run ?? '', /Environment reason/)
+ assert.match(targetStep.run ?? '', /Reviewed plan artifact/)
+ assert.ok(reviewedStep)
+ assert.match(reviewedStep.run ?? '', /## Reviewed Terraform plan/)
+ assert.match(reviewedStep.run ?? '', /\.reviewed\.txt/)
+ assert.ok(mergedStep)
+ assert.match(mergedStep.run ?? '', /## Merged Terraform plan/)
+ assert.match(mergedStep.run ?? '', /\.merged\.txt/)
+ assert.ok(uploadStep)
+ assert.equal(
+ uploadStep.with?.name,
+ 'apply-plans-${{ env.TF_WORKSPACE }}-${{ needs.prepare.outputs.sha }}'
+ )
+ assert.match(String(uploadStep.with?.path), /\.reviewed\.txt/)
+ assert.match(String(uploadStep.with?.path), /\.merged\.txt/)
+ assert.ok(compareStep)
+ assert.equal(
+ compareStep.run,
+ 'diff -u "${TF_WORKSPACE}.reviewed.txt" "${TF_WORKSPACE}.merged.txt"\n'
+ )
+ })
})
diff --git a/scripts/src/actions/classify-allow-destroy.ts b/scripts/src/actions/classify-allow-destroy.ts
index a4828c5..bea8b79 100644
--- a/scripts/src/actions/classify-allow-destroy.ts
+++ b/scripts/src/actions/classify-allow-destroy.ts
@@ -2,6 +2,7 @@ import 'reflect-metadata'
import * as core from '@actions/core'
import {pathToFileURL} from 'url'
+import * as fs from 'fs'
import {Config} from '../yaml/config.js'
import {State} from '../terraform/state.js'
import {
@@ -25,6 +26,7 @@ type Matrix = {
include: {
workspace: string
environment: string
+ environmentReasons: string[]
}[]
}
@@ -32,34 +34,58 @@ function getStateAddress(resource: Resource): string {
return resource.getStateAddress().toLowerCase()
}
-function hasMissingResources(
+function formatAllowDestroyReason(resource: Resource): string {
+ if (resource instanceof Member) {
+ return `removes organization member ${resource.username.toLowerCase()}`
+ }
+
+ if (resource instanceof Repository) {
+ return `removes repository ${resource.name.toLowerCase()}`
+ }
+
+ return `removes ${resource.getStateAddress().toLowerCase()}`
+}
+
+function getMissingResources(
config: Config,
state: State,
resourceClass: ResourceConstructor
-): boolean {
+): T[] {
const desiredAddresses = new Set(
config.getResources(resourceClass).map(getStateAddress)
)
return state
.getResources(resourceClass)
- .some(resource => !desiredAddresses.has(getStateAddress(resource)))
+ .filter(resource => !desiredAddresses.has(getStateAddress(resource)))
}
-export async function hasAllowDestroyChange(
+export async function getAllowDestroyReasons(
config: Config,
state: State
-): Promise {
+): Promise {
+ const reasons = []
+
for (const resourceClass of ALLOW_DESTROY_RESOURCE_CLASSES) {
if (
ResourceConstructors.includes(resourceClass) &&
- !(await state.isIgnored(resourceClass)) &&
- hasMissingResources(config, state, resourceClass)
+ !(await state.isIgnored(resourceClass))
) {
- return true
+ reasons.push(
+ ...getMissingResources(config, state, resourceClass).map(
+ formatAllowDestroyReason
+ )
+ )
}
}
- return false
+ return reasons.sort()
+}
+
+export async function hasAllowDestroyChange(
+ config: Config,
+ state: State
+): Promise {
+ return (await getAllowDestroyReasons(config, state)).length > 0
}
export async function validateRemovedMembersHaveNoDanglingAccess(
@@ -140,6 +166,32 @@ export function getEnvironment(mode: Mode, allowDestroy: boolean): string {
return allowDestroy ? `${mode}-allow-destroy` : mode
}
+export function describeWorkspaceClassification(matrix: Matrix): string {
+ const lines = [
+ '## Workspace classification',
+ '',
+ '| Workspace | Environment | Reason |',
+ '| --- | --- | --- |'
+ ]
+
+ for (const item of matrix.include) {
+ const reasons =
+ item.environmentReasons.length === 0
+ ? 'No allow-destroy changes detected.'
+ : item.environmentReasons.join('
')
+ lines.push(`| ${item.workspace} | ${item.environment} | ${reasons} |`)
+ }
+
+ return lines.join('\n')
+}
+
+function writeStepSummary(markdown: string): void {
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY
+ if (summaryPath !== undefined) {
+ fs.appendFileSync(summaryPath, `${markdown}\n`)
+ }
+}
+
export async function classifyWorkspaces({
mode,
workspaces,
@@ -158,10 +210,13 @@ export async function classifyWorkspaces({
const config = Config.FromPath(`${githubDir}/${workspace}.yml`)
const state = await State.New()
await validateRemovedMembersHaveNoDanglingAccess(config, state)
- const allowDestroy = await hasAllowDestroyChange(config, state)
- const environment = getEnvironment(mode, allowDestroy)
+ const environmentReasons = await getAllowDestroyReasons(config, state)
+ const environment = getEnvironment(mode, environmentReasons.length > 0)
core.info(`${workspace}: ${environment}`)
- include.push({workspace, environment})
+ for (const reason of environmentReasons) {
+ core.info(`- ${reason}`)
+ }
+ include.push({workspace, environment, environmentReasons})
}
} finally {
if (originalWorkspace === undefined) {
@@ -191,6 +246,7 @@ async function run(): Promise {
githubDir: process.env.GITHUB_DIR ?? '../github'
})
+ writeStepSummary(describeWorkspaceClassification(matrix))
core.setOutput('matrix', JSON.stringify(matrix))
}
diff --git a/scripts/src/actions/shared/describe-access-changes.ts b/scripts/src/actions/shared/describe-access-changes.ts
index 3a163d7..ea3093c 100644
--- a/scripts/src/actions/shared/describe-access-changes.ts
+++ b/scripts/src/actions/shared/describe-access-changes.ts
@@ -85,26 +85,28 @@ export function describeAccessReport(state: State, config: Config): string {
'',
'
',
'',
+ 'The sections below describe effective access after these config changes are applied:',
+ '',
formatAccessSummarySection(
- 'Outside collaborators',
+ 'Post-change outside collaborators',
categories.outsideCollaborators,
after
),
'',
formatAccessSummarySection(
- 'Potential outside collaborators',
+ 'Post-change potential outside collaborators',
categories.potentialOutsideCollaborators,
after
),
'',
formatAccessSummarySection(
- 'Potential no members',
+ 'Post-change potential no members',
categories.potentialNoMembers,
after
),
'',
formatAccessSummarySection(
- 'Any other members',
+ 'Post-change any other members',
categories.anyOtherMembers,
after
)
@@ -136,7 +138,11 @@ export function describeAccessChanges(state: State, config: Config): string {
beforeAccess?.role !== undefined &&
afterAccess?.role === undefined
) {
- userLines.push(' - will leave the organization')
+ if (afterAccess?.isOutsideCollaborator) {
+ userLines.push(' - will become an outside collaborator')
+ } else {
+ userLines.push(' - will leave the organization')
+ }
} else {
userLines.push(
` - will have the role in the organization change from ${beforeAccess?.role} to ${afterAccess?.role}`