Skip to content
Merged
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
58 changes: 56 additions & 2 deletions .github/workflows/apply.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<details><summary>${TF_WORKSPACE}.tfplan</summary>"
echo ''
echo '~~~~terraform'
sed 's/^~~~~/~~~~ /' "${TF_WORKSPACE}.reviewed.txt"
echo '~~~~'
echo ''
echo '</details>'
} >> "$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 "<details><summary>${TF_WORKSPACE}.merged.tfplan</summary>"
echo ''
echo '~~~~terraform'
sed 's/^~~~~/~~~~ /' "${TF_WORKSPACE}.merged.txt"
echo '~~~~'
echo ''
echo '</details>'
} >> "$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: |
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/plan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)"
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 53 additions & 2 deletions scripts/__tests__/actions/access-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<summary>Potential outside collaborators<\/summary>/)
assert.match(
report,
/The sections below describe effective access after these config changes are applied:/
)
assert.match(
report,
/<summary>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\)/)
Expand Down Expand Up @@ -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: []}}})
Expand Down Expand Up @@ -252,7 +300,10 @@ members:

assert.match(comment, /<details><summary>Access Changes<\/summary>/)
assert.doesNotMatch(comment, /Potential no members/)
assert.match(report, /<summary>Potential no members<\/summary>/)
assert.match(
report,
/<summary>Post-change potential no members<\/summary>/
)
} finally {
if (originalPath === undefined) {
delete process.env.ACCESS_REPORT_PATH
Expand Down
62 changes: 62 additions & 0 deletions scripts/__tests__/actions/classify-allow-destroy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'])

Expand Down
85 changes: 85 additions & 0 deletions scripts/__tests__/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
})
})
Loading
Loading