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'" + ) + }) })