diff --git a/.github/workflows/access-report.yml b/.github/workflows/access-report.yml
index b7907da..ce30866 100644
--- a/.github/workflows/access-report.yml
+++ b/.github/workflows/access-report.yml
@@ -21,7 +21,7 @@ jobs:
env:
TF_IN_AUTOMATION: 1
TF_INPUT: 0
- TF_WORKSPACE: ${{ github.event.inputs.organization }}
+ TF_WORKSPACE: ${{ inputs.organization }}
AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }}
steps:
@@ -48,7 +48,7 @@ jobs:
run: pnpm install --frozen-lockfile && pnpm run build
working-directory: scripts
- name: Generate access report
- run: node lib/actions/access-report.js
+ run: node --input-type=module --eval "import {runDescribeAccessChanges} from './lib/actions/shared/describe-access-changes.js'; await runDescribeAccessChanges();"
working-directory: scripts
env:
ACCESS_REPORT_PATH: ../ACCESS_REPORT.md
diff --git a/.github/workflows/update-members.yml b/.github/workflows/update-members.yml
index 6d9165a..4f42046 100644
--- a/.github/workflows/update-members.yml
+++ b/.github/workflows/update-members.yml
@@ -34,6 +34,11 @@ on:
options:
- keep
- remove
+ draft-run:
+ description: Only summarize the member update without creating a pull request
+ required: true
+ default: false
+ type: boolean
defaults:
run:
@@ -42,18 +47,33 @@ defaults:
jobs:
update:
permissions:
- contents: write
- pull-requests: write
+ contents: read
+ pull-requests: read
name: Update members
runs-on: ubuntu-latest
- environment: push
+ environment: ${{ github.event.inputs['draft-run'] == 'true' && 'read' || 'push' }}
env:
GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }}
GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', github.event.inputs.organization)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }}
GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }}
TF_WORKSPACE: ${{ github.event.inputs.organization }}
steps:
+ - name: Generate app token
+ if: github.event.inputs['draft-run'] != 'true'
+ id: token
+ uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
+ with:
+ app_id: ${{ secrets.RW_GITHUB_APP_ID }}
+ installation_retrieval_mode: id
+ installation_retrieval_payload: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }}
+ private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }}
+ - name: Checkout with app token
+ if: github.event.inputs['draft-run'] != 'true'
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ token: ${{ steps.token.outputs.token }}
- name: Checkout
+ if: github.event.inputs['draft-run'] == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
@@ -88,10 +108,60 @@ jobs:
else
echo "this=true" >> $GITHUB_OUTPUT
fi
- - uses: ./.github/actions/git-config-user
- if: steps.config-modified.outputs.this == 'true'
+ - name: Summarize member update
+ env:
+ ORGANIZATION: ${{ github.event.inputs.organization }}
+ CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }}
+ LIMIT: ${{ github.event.inputs.limit }}
+ IGNORE: ${{ github.event.inputs.ignore }}
+ ONLY: ${{ github.event.inputs.only }}
+ PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }}
+ ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }}
+ DRAFT_RUN: ${{ github.event.inputs['draft-run'] }}
+ AFFECTED_USERS: ${{ steps.update.outputs.affected-users }}
+ CONFIG_MODIFIED: ${{ steps.config-modified.outputs.this }}
+ run: |
+ {
+ echo '## Update members'
+ echo
+ echo "- Organization: \`${ORGANIZATION}\`"
+ echo "- Cutoff date: \`${CUTOFF_DATE:-not set}\`"
+ echo "- Limit: \`${LIMIT:-not set}\`"
+ echo "- Ignore: \`${IGNORE:-not set}\`"
+ echo "- Only: \`${ONLY:-not set}\`"
+ echo "- Public repo access: \`${PUBLIC_REPO_ACCESS}\`"
+ echo "- Organization membership: \`${ORGANIZATION_MEMBERSHIP}\`"
+ echo "- Draft run: \`${DRAFT_RUN}\`"
+ echo "- Affected users: \`${AFFECTED_USERS:-none}\`"
+ echo "- Config modified: \`${CONFIG_MODIFIED}\`"
+ if [[ "${DRAFT_RUN}" == 'true' ]]; then
+ echo "- Pull request: not created because draft run is enabled"
+ elif [[ "${CONFIG_MODIFIED}" == 'true' ]]; then
+ echo "- Pull request: will be created"
+ else
+ echo "- Pull request: not created because there are no config changes"
+ fi
+
+ if [[ "${CONFIG_MODIFIED}" == 'true' ]]; then
+ echo
+ echo 'Config diff
'
+ echo
+ echo '```diff'
+ git diff -- "github/${ORGANIZATION}.yml" | sed 's/^```/``` /'
+ echo '```'
+ echo
+ echo ' '
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Configure git user
+ if: steps.config-modified.outputs.this == 'true' && github.event.inputs['draft-run'] != 'true'
+ env:
+ GITHUB_MGMT_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }}
+ run: |
+ git config --global user.name "github-mgmt[bot]"
+ git config --global user.email "${GITHUB_MGMT_APP_ID}+github-mgmt[bot]@users.noreply.github.com"
- name: Create draft pull request
- if: steps.config-modified.outputs.this == 'true'
+ if: steps.config-modified.outputs.this == 'true' && github.event.inputs['draft-run'] != 'true'
env:
ORGANIZATION: ${{ github.event.inputs.organization }}
CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }}
@@ -101,7 +171,7 @@ jobs:
PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }}
ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }}
AFFECTED_USERS: ${{ steps.update.outputs.affected-users }}
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_TOKEN: ${{ steps.token.outputs.token }}
run: |
branch="update-members-${ORGANIZATION}-${GITHUB_RUN_ID}"
body="$(mktemp)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1f8e46..b93a8c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,9 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- plan/apply workflows now publish planned/applied commit, workspace, environment, and rendered terraform plan details to workflow summaries and artifacts
+- manual access report workflow for generating the full access breakdown on demand
+- update members workflow draft run mode for summarizing member updates without creating a pull request
- 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
+- update members workflow now creates branches and pull requests with the configured GitHub App token so follow-up workflows are triggered
- **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
- docs: update template repository references from `github-mgmt-template` to `github-as-code`
diff --git a/scripts/__tests__/workflows.test.ts b/scripts/__tests__/workflows.test.ts
index ad24070..1894acc 100644
--- a/scripts/__tests__/workflows.test.ts
+++ b/scripts/__tests__/workflows.test.ts
@@ -5,6 +5,8 @@ import * as YAML from 'yaml'
type WorkflowStep = {
name?: string
+ if?: string
+ uses?: string
run?: string
env?: Record
with?: Record
@@ -17,6 +19,7 @@ type Workflow = {
jobs: Record<
string,
{
+ permissions?: Record
environment?: string
steps: WorkflowStep[]
}
@@ -48,8 +51,34 @@ describe('workflows', () => {
assert.match(applyStep.run ?? '', /allow_destroy_override\.tf\.disabled/)
})
- it('does not provide a manual access report workflow', () => {
- assert.equal(existsSync('../.github/workflows/access-report.yml'), false)
+ it('provides a manual access report workflow through the shared formatter helper', () => {
+ assert.equal(existsSync('../.github/workflows/access-report.yml'), true)
+
+ const accessReport = workflow('access-report.yml')
+ const reportJob = accessReport.jobs.report
+ const steps = reportJob.steps
+ const generateStep = steps.find(
+ step => step.name === 'Generate access report'
+ )
+ const publishStep = steps.find(
+ step => step.name === 'Publish access report summary'
+ )
+ const uploadStep = steps.find(step => step.name === 'Upload access report')
+
+ assert.ok(accessReport.on.workflow_dispatch)
+ assert.equal(reportJob.environment, 'read')
+ assert.ok(generateStep)
+ assert.equal(generateStep.env?.ACCESS_REPORT_PATH, '../ACCESS_REPORT.md')
+ assert.match(generateStep.run ?? '', /runDescribeAccessChanges/)
+ assert.doesNotMatch(generateStep.run ?? '', /access-report\.js/)
+ assert.ok(publishStep)
+ assert.equal(
+ publishStep.run,
+ 'cat ACCESS_REPORT.md >> "$GITHUB_STEP_SUMMARY"'
+ )
+ assert.ok(uploadStep)
+ assert.equal(uploadStep.with?.name, 'access-report-${{ env.TF_WORKSPACE }}')
+ assert.equal(uploadStep.with?.path, 'ACCESS_REPORT.md')
})
it('publishes the full access report from the fix workflow', () => {
@@ -170,4 +199,92 @@ describe('workflows', () => {
'diff -u "${TF_WORKSPACE}.reviewed.txt" "${TF_WORKSPACE}.merged.txt"\n'
)
})
+
+ it('creates update-members pull requests with the GitHub App token', () => {
+ const updateMembers = workflow('update-members.yml')
+ const job = updateMembers.jobs.update
+ const steps = job.steps
+ const generateTokenStep = steps.find(
+ step => step.name === 'Generate app token'
+ )
+ const checkoutStep = steps.find(
+ step => step.name === 'Checkout with app token'
+ )
+ const configureGitStep = steps.find(
+ step => step.name === 'Configure git user'
+ )
+ const createPullRequestStep = steps.find(
+ step => step.name === 'Create draft pull request'
+ )
+
+ assert.equal(job.permissions?.contents, 'read')
+ assert.equal(job.permissions?.['pull-requests'], 'read')
+ assert.ok(generateTokenStep)
+ assert.equal(
+ generateTokenStep.if,
+ "github.event.inputs['draft-run'] != 'true'"
+ )
+ assert.equal(
+ generateTokenStep.with?.app_id,
+ '${{ secrets.RW_GITHUB_APP_ID }}'
+ )
+ assert.ok(checkoutStep)
+ assert.equal(checkoutStep.if, "github.event.inputs['draft-run'] != 'true'")
+ assert.equal(checkoutStep.with?.token, '${{ steps.token.outputs.token }}')
+ assert.ok(configureGitStep)
+ assert.equal(
+ configureGitStep.if,
+ "steps.config-modified.outputs.this == 'true' && github.event.inputs['draft-run'] != 'true'"
+ )
+ assert.equal(
+ configureGitStep.env?.GITHUB_MGMT_APP_ID,
+ '${{ secrets.RW_GITHUB_APP_ID }}'
+ )
+ assert.match(configureGitStep.run ?? '', /github-mgmt\[bot\]/)
+ assert.ok(createPullRequestStep)
+ assert.equal(
+ createPullRequestStep.env?.GITHUB_TOKEN,
+ '${{ steps.token.outputs.token }}'
+ )
+ })
+
+ it('supports update-members draft runs without creating pull requests', () => {
+ const updateMembers = workflow('update-members.yml')
+ const workflowDispatch = updateMembers.on.workflow_dispatch as {
+ inputs: Record
+ }
+ const job = updateMembers.jobs.update
+ const steps = job.steps
+ const checkoutStep = steps.find(step => step.name === 'Checkout')
+ const summaryStep = steps.find(
+ step => step.name === 'Summarize member update'
+ )
+ const createPullRequestStep = steps.find(
+ step => step.name === 'Create draft pull request'
+ )
+
+ assert.equal(workflowDispatch.inputs['draft-run'].default, false)
+ assert.equal(workflowDispatch.inputs['draft-run'].type, 'boolean')
+ assert.equal(
+ job.environment,
+ "${{ github.event.inputs['draft-run'] == 'true' && 'read' || 'push' }}"
+ )
+ assert.ok(checkoutStep)
+ assert.equal(checkoutStep.if, "github.event.inputs['draft-run'] == 'true'")
+ assert.ok(summaryStep)
+ assert.match(summaryStep.run ?? '', /## Update members/)
+ assert.match(
+ summaryStep.run ?? '',
+ /Pull request: not created because draft run is enabled/
+ )
+ assert.match(
+ summaryStep.run ?? '',
+ /git diff -- "github\/\$\{ORGANIZATION\}\.yml"/
+ )
+ assert.ok(createPullRequestStep)
+ assert.equal(
+ createPullRequestStep.if,
+ "steps.config-modified.outputs.this == 'true' && github.event.inputs['draft-run'] != 'true'"
+ )
+ })
})