diff --git a/.github/workflows/access-report.yml b/.github/workflows/access-report.yml
new file mode 100644
index 0000000..ce30866
--- /dev/null
+++ b/.github/workflows/access-report.yml
@@ -0,0 +1,63 @@
+name: Access Report
+
+on:
+ workflow_dispatch:
+ inputs:
+ organization:
+ description: Organization config to report on
+ required: true
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ report:
+ permissions:
+ contents: read
+ name: Access report
+ runs-on: ubuntu-latest
+ environment: read
+ env:
+ TF_IN_AUTOMATION: 1
+ TF_INPUT: 0
+ 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:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Setup terraform
+ uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0
+ with:
+ terraform_version: 1.12.0
+ terraform_wrapper: false
+ - name: Initialize terraform
+ run: terraform init
+ working-directory: terraform
+ - name: Install pnpm
+ uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
+ with:
+ version: 10
+ - name: Use Node.js lts/*
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: lts/*
+ cache: ''
+ - name: Initialize scripts
+ run: pnpm install --frozen-lockfile && pnpm run build
+ working-directory: scripts
+ - name: Generate access report
+ 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
+ - name: Publish access report summary
+ run: cat ACCESS_REPORT.md >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload access report
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: access-report-${{ env.TF_WORKSPACE }}
+ path: ACCESS_REPORT.md
+ if-no-files-found: error
+ retention-days: 14
diff --git a/.github/workflows/apply.yml b/.github/workflows/apply.yml
index 6dcaca9..7087b02 100644
--- a/.github/workflows/apply.yml
+++ b/.github/workflows/apply.yml
@@ -47,19 +47,65 @@ jobs:
GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }}
run: node lib/actions/find-sha-for-plan.js
working-directory: scripts
- apply:
+ classify:
needs: [prepare]
if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != ''
+ permissions:
+ contents: read
+ name: Classify
+ runs-on: ubuntu-latest
+ environment: read
+ outputs:
+ matrix: ${{ steps.classify.outputs.matrix }}
+ env:
+ TF_IN_AUTOMATION: 1
+ TF_INPUT: 0
+ AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }}
+ WORKSPACES: ${{ needs.prepare.outputs.workspaces }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Setup terraform
+ uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0
+ with:
+ terraform_version: 1.12.0
+ terraform_wrapper: false
+ - name: Initialize terraform
+ run: terraform init
+ working-directory: terraform
+ - name: Install pnpm
+ uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
+ with:
+ version: 10
+ - name: Use Node.js lts/*
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: lts/*
+ cache: ''
+ - run: pnpm install --frozen-lockfile && pnpm run build
+ working-directory: scripts
+ - name: Classify workspaces
+ id: classify
+ env:
+ MODE: write
+ run: node lib/actions/classify-allow-destroy.js
+ working-directory: scripts
+ apply:
+ needs: [prepare, classify]
+ if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != ''
permissions:
actions: read
contents: read
strategy:
fail-fast: false
- matrix:
- workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }}
+ matrix: ${{ fromJson(needs.classify.outputs.matrix) }}
name: Apply
runs-on: ubuntu-latest
- environment: write
+ environment: ${{ matrix.environment }}
env:
TF_IN_AUTOMATION: 1
TF_INPUT: 0
@@ -84,19 +130,83 @@ jobs:
terraform_wrapper: false
- name: Initialize terraform
run: terraform init
+ - name: Allow destroy in guarded environment
+ if: matrix.environment == 'write-allow-destroy'
+ env:
+ ALLOW_DESTROY: ${{ vars.ALLOW_DESTROY }}
+ run: |
+ if [[ "${ALLOW_DESTROY}" != "true" ]]; then
+ echo "The write-allow-destroy environment must define ALLOW_DESTROY=true."
+ 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 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/fix.yml b/.github/workflows/fix.yml
index fe3b7ef..50248cd 100644
--- a/.github/workflows/fix.yml
+++ b/.github/workflows/fix.yml
@@ -117,10 +117,23 @@ jobs:
id: fix
run: node lib/actions/fix-yaml-config.js
working-directory: scripts
+ env:
+ ACCESS_REPORT_PATH: ../ACCESS_REPORT.md
+ - name: Publish access report summary
+ if: always() && hashFiles('ACCESS_REPORT.md') != ''
+ run: cat ACCESS_REPORT.md >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload access report
+ if: always() && hashFiles('ACCESS_REPORT.md') != ''
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: access-report-${{ env.TF_WORKSPACE }}
+ path: ACCESS_REPORT.md
+ if-no-files-found: error
+ retention-days: 14
- name: Upload YAML config
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: ${{ env.TF_WORKSPACE }}.yml
+ name: fixed-config-${{ env.TF_WORKSPACE }}
path: github/${{ env.TF_WORKSPACE }}.yml
if-no-files-found: error
retention-days: 1
@@ -164,10 +177,10 @@ jobs:
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
+ pattern: fixed-config-*
+ merge-multiple: true
- name: Copy YAML configs
- run: |
- shopt -s globstar
- cp artifacts/**/*.yml head/github
+ run: cp artifacts/*.yml head/github
- name: Check if github was modified
id: github-modified
run: |
diff --git a/.github/workflows/plan.yml b/.github/workflows/plan.yml
index 8b95d94..069c8e1 100644
--- a/.github/workflows/plan.yml
+++ b/.github/workflows/plan.yml
@@ -47,18 +47,69 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
timeout-minutes: 10
- plan:
+ classify:
needs: [prepare]
+ permissions:
+ contents: read
+ pull-requests: read
+ name: Classify
+ runs-on: ubuntu-latest
+ environment: read
+ outputs:
+ matrix: ${{ steps.classify.outputs.matrix }}
+ env:
+ TF_IN_AUTOMATION: 1
+ TF_INPUT: 0
+ AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }}
+ WORKSPACES: ${{ needs.prepare.outputs.workspaces }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - if: github.event_name == 'pull_request_target'
+ env:
+ NUMBER: ${{ github.event.pull_request.number }}
+ SHA: ${{ github.event.pull_request.head.sha }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ git fetch origin "pull/${NUMBER}/head"
+ rm -rf github && git checkout "${SHA}" -- github
+ - name: Setup terraform
+ uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 # v4.0.0
+ with:
+ terraform_version: 1.12.0
+ terraform_wrapper: false
+ - name: Initialize terraform
+ run: terraform init
+ working-directory: terraform
+ - name: Install pnpm
+ uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
+ with:
+ version: 10
+ - name: Use Node.js lts/*
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: lts/*
+ cache: ''
+ - run: pnpm install --frozen-lockfile && pnpm run build
+ working-directory: scripts
+ - name: Classify workspaces
+ id: classify
+ env:
+ MODE: read
+ run: node lib/actions/classify-allow-destroy.js
+ working-directory: scripts
+ plan:
+ needs: [prepare, classify]
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
- matrix:
- workspace: ${{ fromJson(needs.prepare.outputs.workspaces || '[]') }}
+ matrix: ${{ fromJson(needs.classify.outputs.matrix) }}
name: Plan
runs-on: ubuntu-latest
- environment: read
+ environment: ${{ matrix.environment }}
env:
TF_IN_AUTOMATION: 1
TF_INPUT: 0
@@ -88,9 +139,43 @@ jobs:
- name: Initialize terraform
run: terraform init
working-directory: terraform
+ - name: Allow destroy in guarded environment
+ if: matrix.environment == 'read-allow-destroy'
+ env:
+ ALLOW_DESTROY: ${{ vars.ALLOW_DESTROY }}
+ run: |
+ if [[ "${ALLOW_DESTROY}" != "true" ]]; then
+ echo "The read-allow-destroy environment must define ALLOW_DESTROY=true."
+ exit 1
+ 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
+ terraform show -json > "$TF_WORKSPACE.tfstate.json"
terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color
working-directory: terraform
- name: Upload terraform plan
@@ -148,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/.github/workflows/update-members.yml b/.github/workflows/update-members.yml
new file mode 100644
index 0000000..4f42046
--- /dev/null
+++ b/.github/workflows/update-members.yml
@@ -0,0 +1,200 @@
+name: Update Members
+
+on:
+ workflow_dispatch:
+ inputs:
+ organization:
+ description: Organization config to update
+ required: true
+ cutoff-date:
+ description: Inactive before this date (YYYY-MM-DD)
+ required: false
+ limit:
+ description: Remove up to this many longest-inactive members
+ required: false
+ ignore:
+ description: Comma, space, or newline separated usernames to ignore
+ required: false
+ only:
+ description: Comma, space, or newline separated usernames to target
+ required: false
+ public-repo-access:
+ description: Whether to retain effective access to public repositories
+ required: true
+ default: retain
+ type: choice
+ options:
+ - retain
+ - remove
+ organization-membership:
+ description: Whether selected users remain organization members
+ required: true
+ default: keep
+ type: choice
+ options:
+ - keep
+ - remove
+ draft-run:
+ description: Only summarize the member update without creating a pull request
+ required: true
+ default: false
+ type: boolean
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ update:
+ permissions:
+ contents: read
+ pull-requests: read
+ name: Update members
+ runs-on: ubuntu-latest
+ 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
+ with:
+ version: 10
+ - name: Use Node.js lts/*
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: lts/*
+ cache: ""
+ - name: Initialize scripts
+ run: pnpm install --frozen-lockfile && pnpm run build
+ working-directory: scripts
+ - name: Update members
+ id: update
+ run: node lib/actions/update-members.js
+ working-directory: scripts
+ env:
+ 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'] }}
+ - name: Check if organization config was modified
+ id: config-modified
+ env:
+ ORGANIZATION: ${{ github.event.inputs.organization }}
+ run: |
+ if [ -z "$(git status --porcelain -- "github/${ORGANIZATION}.yml")" ]; then
+ echo "this=false" >> $GITHUB_OUTPUT
+ else
+ echo "this=true" >> $GITHUB_OUTPUT
+ fi
+ - 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' && github.event.inputs['draft-run'] != 'true'
+ 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'] }}
+ AFFECTED_USERS: ${{ steps.update.outputs.affected-users }}
+ GITHUB_TOKEN: ${{ steps.token.outputs.token }}
+ run: |
+ branch="update-members-${ORGANIZATION}-${GITHUB_RUN_ID}"
+ body="$(mktemp)"
+ {
+ echo 'The changes in this PR were made by a bot. Please review carefully.'
+ 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 "Affected users: ${AFFECTED_USERS:-none}"
+ } > "${body}"
+
+ git checkout -B "${branch}"
+ git add "github/${ORGANIZATION}.yml"
+ git commit -m "update-members@${GITHUB_RUN_ID} ${ORGANIZATION}"
+ git push origin "${branch}" --force
+ gh pr create \
+ --draft \
+ --title "Update members for ${ORGANIZATION}" \
+ --body-file "${body}" \
+ --head "${branch}" \
+ --base "${GITHUB_REF_NAME}"
diff --git a/.gitignore b/.gitignore
index 34adc7e..356c12f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ crash.log
# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*
*.tfplan
+
+# Enabled only by guarded allow-destroy workflow jobs
+terraform/allow_destroy_override.tf
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5549e1d..b93a8c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- allow-destroy workflow environments for guarded repository and membership deletion plans/applies
- shared action for adding a collaborator to all repositories
- clean workflow which removes resources from state
- information on how to handle private GitHub Management repository
@@ -23,7 +24,14 @@ 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
+- 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`
- scripts: update dependencies with security advisories
diff --git a/docs/ABOUT.md b/docs/ABOUT.md
index c7a44ef..d442054 100644
--- a/docs/ABOUT.md
+++ b/docs/ABOUT.md
@@ -27,6 +27,8 @@ The workflow for introducing changes to GitHub via YAML configuration file is as
1. Review the plan.
1. Merge the PR and wait for the GitHub Action workflow triggered on pushes to the default branch to apply it.
+Plans that remove managed repositories or organization memberships are routed through the `read-allow-destroy` GitHub Actions environment before the PR plan is created. The matching apply is routed through `write-allow-destroy`. These environments must be protected separately from the normal `read` and `write` environments. Outside the allow-destroy environments, Terraform keeps `prevent_destroy` enabled for repository and membership resources.
+
Neither creating the terraform plan nor applying it refreshes the underlying terraform state i.e. going through this workflow does **NOT** ask GitHub if the actual GitHub configuration state has changed. This makes the workflow fast and rate limit friendly because the number of requests to GitHub is minimised. This can result in the plan failing to be applied, e.g. if the underlying resource has been deleted. This assumes that YAML configuration is the main source of truth for GitHub configuration state. The plans that are created during the PR GitHub Action workflow are compared against plans regenerated from the merged commit before applying.
The workflow for synchronising the current GitHub configuration state with YAML configuration file is as follows:
diff --git a/docs/SETUP.md b/docs/SETUP.md
index 85cc586..123c6fb 100644
--- a/docs/SETUP.md
+++ b/docs/SETUP.md
@@ -116,7 +116,12 @@
## GitHub Actions Environments and Secrets
-- [ ] Create GitHub Actions environments named `read`, `write`, and `push`, and configure protection rules such as required reviewers. Workflows that read organization state reference `read`; workflows that write organization state reference `write`; workflows that push generated changes to the GitHub Management repository reference `push`.
+- [ ] Create GitHub Actions environments named `read`, `read-allow-destroy`, `write`, `write-allow-destroy`, and `push`, and configure protection rules such as required reviewers. Workflows that read organization state reference `read`; workflows that write organization state reference `write`; workflows that push generated changes to the GitHub Management repository reference `push`.
+- [ ] Configure `read-allow-destroy` and `write-allow-destroy` with stricter protection rules for repository and membership deletion plans/applies, and set an environment variable named `ALLOW_DESTROY` to `true` in each environment:
+ - [ ] Require reviewers
+ - [ ] Prevent self-review
+ - [ ] Restrict deployment branches to `master`
+ - [ ] Disable administrator bypass where available
- [ ] [Create encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-organization) for the GitHub organization and allow the repository to access them (\*replace `$GITHUB_ORGANIZATION_NAME` with the GitHub organization name) - *these secrets are read by the GitHub Action workflows*
- [ ] Go to `https://github.com/organizations/$GITHUB_ORGANIZATION_NAME/settings/apps/$GITHUB_APP_NAME` and copy the `App ID`
- [ ] `RO_GITHUB_APP_ID`
@@ -150,6 +155,11 @@
- [ ] Follow [How to synchronize GitHub Management with GitHub?](HOWTOS.md#synchronize-github-management-with-github) to commit the terraform lock and initialize terraform state
+## Member Update Workflows
+
+- [ ] Use `Update Members` to create a draft PR that removes selected members from teams and repository collaborators in `github/$ORGANIZATION_NAME.yml`. The workflow requires either `cutoff-date` or `only`, supports `ignore` and `limit`, can retain effective public repository access by converting that access to direct public repository collaborators, and can optionally remove selected users from organization membership in the YAML config.
+- [ ] Review and merge the draft PR through the normal GitHub Management PR flow.
+
## GitHub Management Repository Protections
*NOTE*: Advanced users might have to skip/adjust this step if they are not managing some of the arguments/attributes mentioned here with GitHub Management.
diff --git a/scripts/__tests__/actions/access-summary.test.ts b/scripts/__tests__/actions/access-summary.test.ts
new file mode 100644
index 0000000..be6d7e8
--- /dev/null
+++ b/scripts/__tests__/actions/access-summary.test.ts
@@ -0,0 +1,316 @@
+import 'reflect-metadata'
+
+import assert from 'node:assert'
+import {mkdtempSync, readFileSync, rmSync} from 'node:fs'
+import {join} from 'node:path'
+import {describe, it} from 'node:test'
+import {tmpdir} from 'node:os'
+import {Config} from '../../src/yaml/config.js'
+import {State} from '../../src/terraform/state.js'
+import {
+ categorizeAccessSummary,
+ getAccessSummaryFrom
+} from '../../src/actions/shared/access-summary.js'
+import {
+ describeAccessChanges,
+ describeAccessChangesComment,
+ describeAccessReport,
+ runDescribeAccessChanges
+} from '../../src/actions/shared/describe-access-changes.js'
+import {StateSchema} from '../../src/terraform/schema.js'
+
+describe('access summaries', () => {
+ it('categorizes post-change users', () => {
+ const config = new Config(`
+members:
+ member:
+ - alice
+ - carol
+ - dave
+ - frank
+ - kept # KEEP: manual exception
+repositories:
+ private-repo:
+ collaborators:
+ pull:
+ - outside
+ visibility: private
+ public-repo:
+ collaborators:
+ pull:
+ - alice
+ visibility: public
+ team-only-repo:
+ teams:
+ push:
+ - guests
+ visibility: public
+ team-repo:
+ teams:
+ push:
+ - maintainers
+ visibility: public
+teams:
+ empty:
+ members:
+ member:
+ - frank
+ guests:
+ members:
+ member:
+ - team-only-non-member
+ maintainers:
+ members:
+ member:
+ - dave
+`)
+
+ const summary = getAccessSummaryFrom(config)
+ const categories = categorizeAccessSummary(summary)
+
+ assert.equal(summary.outside.isMember, false)
+ assert.equal(summary.outside.isOutsideCollaborator, true)
+ assert.equal(summary['team-only-non-member'].isMember, false)
+ assert.equal(summary['team-only-non-member'].isOutsideCollaborator, false)
+ assert.deepEqual(categories.outsideCollaborators, ['outside'])
+ assert.deepEqual(categories.potentialOutsideCollaborators, ['alice'])
+ assert.deepEqual(categories.potentialNoMembers, ['carol', 'frank'])
+ assert.deepEqual(categories.anyOtherMembers, ['dave', 'kept'])
+ })
+
+ it('annotates repository visibility and access path in access changes and summaries', () => {
+ 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'
+ }
+ },
+ {
+ mode: 'managed',
+ index: 'public-repo',
+ address: 'github_repository.this["public-repo"]',
+ type: 'github_repository',
+ values: {
+ name: 'public-repo',
+ visibility: 'public'
+ }
+ },
+ {
+ mode: 'managed',
+ index: 'public-repo:alice',
+ address:
+ 'github_repository_collaborator.this["public-repo:alice"]',
+ type: 'github_repository_collaborator',
+ values: {
+ repository: 'public-repo',
+ username: 'alice',
+ permission: 'pull'
+ }
+ }
+ ]
+ }
+ }
+ } satisfies StateSchema)
+ )
+ const config = new Config(`
+members:
+ member:
+ - alice
+repositories:
+ public-repo:
+ collaborators:
+ push:
+ - alice
+ visibility: public
+`)
+
+ const changes = describeAccessChanges(state, config)
+ const report = describeAccessReport(state, config)
+
+ assert.match(
+ changes,
+ /will change from having direct pull permission to public-repo \(public\) to having direct push permission to public-repo \(public\)/
+ )
+ 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\)/)
+ })
+
+ it('describes team and mixed repository access paths', () => {
+ const state = new State(
+ JSON.stringify({values: {root_module: {resources: []}}})
+ )
+ const config = new Config(`
+members:
+ member:
+ - alice
+ - bob
+repositories:
+ private-repo:
+ collaborators:
+ pull:
+ - bob
+ teams:
+ admin:
+ - owners
+ push:
+ - maintainers
+ visibility: private
+teams:
+ maintainers:
+ members:
+ member:
+ - alice
+ owners:
+ members:
+ member:
+ - bob
+`)
+
+ const changes = describeAccessChanges(state, config)
+ const report = describeAccessReport(state, config)
+
+ assert.match(
+ changes,
+ /will gain push permission to private-repo \(private\) through team maintainers/
+ )
+ assert.match(
+ changes,
+ /will gain effective admin permission to private-repo \(private\) through direct pull permission and team owners/
+ )
+ assert.match(
+ report,
+ /has push permission to private-repo \(private\) through team maintainers/
+ )
+ assert.match(
+ report,
+ /has effective admin permission to private-repo \(private\) through direct pull permission and team owners/
+ )
+ })
+
+ 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: []}}})
+ )
+ const config = new Config(`
+members:
+ member:
+ - alice
+`)
+
+ const comment = describeAccessChangesComment(state, config)
+
+ assert.match(comment, /The following access changes/)
+ assert.match(comment, /For the full access breakdown/)
+ assert.match(comment, /Access Changes<\/summary>/)
+ assert.doesNotMatch(comment, /Potential no members/)
+ assert.doesNotMatch(comment, /Any other members/)
+ })
+
+ it('falls back to workflow output when access change comments are too long', () => {
+ const state = new State(
+ JSON.stringify({values: {root_module: {resources: []}}})
+ )
+ const config = new Config(`
+members:
+ member:
+ - alice
+`)
+
+ const comment = describeAccessChangesComment(
+ state,
+ config,
+ 10,
+ 'https://github.example/runs/1'
+ )
+
+ assert.equal(
+ comment,
+ 'Access changes are too long to post as a comment. Please inspect [the Fix workflow summary or access report artifact](https://github.example/runs/1) instead.'
+ )
+ })
+
+ it('writes the full access report as a side effect of the action helper', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'access-report-'))
+ const reportPath = join(dir, 'ACCESS_REPORT.md')
+ const originalPath = process.env.ACCESS_REPORT_PATH
+
+ try {
+ process.env.ACCESS_REPORT_PATH = reportPath
+ const comment = await runDescribeAccessChanges()
+ const report = readFileSync(reportPath, 'utf8')
+
+ assert.match(comment, /Access Changes<\/summary>/)
+ assert.doesNotMatch(comment, /Potential no members/)
+ assert.match(
+ report,
+ /Post-change potential no members<\/summary>/
+ )
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.ACCESS_REPORT_PATH
+ } else {
+ process.env.ACCESS_REPORT_PATH = originalPath
+ }
+ rmSync(dir, {recursive: true, force: true})
+ }
+ })
+})
diff --git a/scripts/__tests__/actions/classify-allow-destroy.test.ts b/scripts/__tests__/actions/classify-allow-destroy.test.ts
new file mode 100644
index 0000000..67969af
--- /dev/null
+++ b/scripts/__tests__/actions/classify-allow-destroy.test.ts
@@ -0,0 +1,347 @@
+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'
+import {Config} from '../../src/yaml/config.js'
+import {State} from '../../src/terraform/state.js'
+import {Locals} from '../../src/terraform/locals.js'
+
+function setManagedResourceTypes(resourceTypes: string[]): void {
+ Locals.locals = {
+ resource_types: resourceTypes,
+ ignore: {
+ repositories: [],
+ teams: [],
+ users: []
+ }
+ }
+}
+
+function state(source: object): State {
+ return new State(JSON.stringify(source))
+}
+
+describe('allow destroy classification', () => {
+ it('routes repository deletes to allow-destroy environments', async () => {
+ setManagedResourceTypes(['github_repository', 'github_membership'])
+
+ const allowDestroy = await hasAllowDestroyChange(
+ new Config(`
+repositories:
+ kept: {}
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'kept'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'removed'}
+ }
+ ]
+ }
+ }
+ })
+ )
+
+ assert.equal(allowDestroy, true)
+ assert.equal(getEnvironment('read', allowDestroy), 'read-allow-destroy')
+ assert.equal(getEnvironment('write', allowDestroy), 'write-allow-destroy')
+ })
+
+ it('routes membership deletes to allow-destroy environments', async () => {
+ setManagedResourceTypes(['github_repository', 'github_membership'])
+
+ const allowDestroy = await hasAllowDestroyChange(
+ new Config(`
+members:
+ admin:
+ - 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'}
+ }
+ ]
+ }
+ }
+ })
+ )
+
+ 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'])
+
+ const allowDestroy = await hasAllowDestroyChange(
+ new Config(`
+members:
+ member:
+ - octocat
+repositories:
+ github:
+ description: updated
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'github', description: 'old'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'octocat', role: 'member'}
+ }
+ ]
+ }
+ }
+ })
+ )
+
+ assert.equal(allowDestroy, false)
+ assert.equal(getEnvironment('read', allowDestroy), 'read')
+ assert.equal(getEnvironment('write', allowDestroy), 'write')
+ })
+
+ it('ignores deletes for other resource types', async () => {
+ setManagedResourceTypes(['github_team'])
+
+ const allowDestroy = await hasAllowDestroyChange(
+ new Config('{}'),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_team',
+ values: {name: 'removed'}
+ }
+ ]
+ }
+ }
+ })
+ )
+
+ assert.equal(allowDestroy, false)
+ })
+
+ it('ignores repository and membership types that are not managed', async () => {
+ setManagedResourceTypes([])
+
+ const allowDestroy = await hasAllowDestroyChange(
+ new Config('{}'),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'removed'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'removed', role: 'member'}
+ }
+ ]
+ }
+ }
+ })
+ )
+
+ assert.equal(allowDestroy, false)
+ })
+
+ it('fails when removing a member who remains in a team', async () => {
+ setManagedResourceTypes(['github_membership'])
+
+ await assert.rejects(
+ validateRemovedMembersHaveNoDanglingAccess(
+ new Config(`
+teams:
+ maintainers:
+ members:
+ member:
+ - removed
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'removed', role: 'member'}
+ }
+ ]
+ }
+ }
+ })
+ ),
+ /removed is still a member of team maintainers/
+ )
+ })
+
+ it('fails when removing a member who keeps direct private repository access', async () => {
+ setManagedResourceTypes(['github_membership'])
+
+ await assert.rejects(
+ validateRemovedMembersHaveNoDanglingAccess(
+ new Config(`
+repositories:
+ private-repo:
+ collaborators:
+ pull:
+ - removed
+ visibility: private
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'removed', role: 'member'}
+ }
+ ]
+ }
+ }
+ })
+ ),
+ /removed still has direct access to private repository private-repo/
+ )
+ })
+
+ it('allows member removal when team and private direct access are removed too', async () => {
+ setManagedResourceTypes(['github_membership'])
+
+ await validateRemovedMembersHaveNoDanglingAccess(
+ new Config(`
+repositories:
+ public-repo:
+ collaborators:
+ pull:
+ - removed
+ visibility: public
+`),
+ state({
+ values: {
+ root_module: {
+ resources: [
+ {
+ mode: 'managed',
+ type: 'github_membership',
+ values: {username: 'removed', role: 'member'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_repository',
+ values: {name: 'private-repo', visibility: 'private'}
+ },
+ {
+ mode: 'managed',
+ type: 'github_repository_collaborator',
+ values: {
+ username: 'removed',
+ repository: 'private-repo',
+ permission: 'pull'
+ }
+ }
+ ]
+ }
+ }
+ })
+ )
+ })
+})
diff --git a/scripts/__tests__/actions/update-members.test.ts b/scripts/__tests__/actions/update-members.test.ts
new file mode 100644
index 0000000..b115ef7
--- /dev/null
+++ b/scripts/__tests__/actions/update-members.test.ts
@@ -0,0 +1,183 @@
+import 'reflect-metadata'
+
+import assert from 'node:assert'
+import {describe, it} from 'node:test'
+import {Config} from '../../src/yaml/config.js'
+import {
+ parseCutoffDate,
+ parseLimit,
+ parseOrganizationMembership,
+ selectMembersForUpdate,
+ updateMembersConfig
+} from '../../src/actions/update-members.js'
+import {Member} from '../../src/resources/member.js'
+import {TeamMember} from '../../src/resources/team-member.js'
+import {RepositoryCollaborator} from '../../src/resources/repository-collaborator.js'
+
+describe('update members', () => {
+ it('requires cutoff date or only list', () => {
+ const config = new Config('members:\n member:\n - alice\n')
+
+ assert.throws(() =>
+ selectMembersForUpdate(config, [], {
+ ignore: [],
+ only: [],
+ publicRepoAccess: 'retain',
+ organizationMembership: 'keep'
+ })
+ )
+ })
+
+ it('validates workflow inputs', () => {
+ assert.equal(
+ parseCutoffDate('2025-01-02')?.toISOString(),
+ '2025-01-02T00:00:00.000Z'
+ )
+ assert.equal(parseLimit('2'), 2)
+ assert.equal(parseOrganizationMembership('keep'), 'keep')
+ assert.equal(parseOrganizationMembership('remove'), 'remove')
+ assert.throws(() => parseCutoffDate('01-02-2025'))
+ assert.throws(() => parseLimit('0'))
+ assert.throws(() => parseOrganizationMembership('invalid'))
+ })
+
+ it('selects inactive members with only, ignore, limit, and KEEP handling', () => {
+ const config = new Config(`
+members:
+ member:
+ - active
+ - ignored
+ - kept # KEEP: manual exception
+ - manual
+ - never-active
+ - old
+`)
+
+ const selected = selectMembersForUpdate(
+ config,
+ [
+ {username: 'active', latestActivity: new Date('2025-01-01T00:00:00Z')},
+ {username: 'old', latestActivity: new Date('2023-01-01T00:00:00Z')}
+ ],
+ {
+ cutoffDate: new Date('2024-01-01T00:00:00Z'),
+ limit: 2,
+ ignore: ['ignored'],
+ only: ['active', 'ignored', 'kept', 'manual', 'never-active', 'old'],
+ publicRepoAccess: 'retain',
+ organizationMembership: 'keep'
+ }
+ )
+
+ assert.deepEqual(selected, ['manual', 'never-active'])
+ })
+
+ it('retains effective public repository access when configured', () => {
+ const config = new Config(`
+members:
+ member:
+ - alice
+repositories:
+ private-repo:
+ collaborators:
+ pull:
+ - alice
+ teams:
+ admin:
+ - maintainers
+ visibility: private
+ public-repo:
+ collaborators:
+ pull:
+ - alice
+ teams:
+ push:
+ - maintainers
+ visibility: public
+teams:
+ maintainers:
+ members:
+ member:
+ - alice
+`)
+
+ updateMembersConfig(config, ['alice'], 'retain', 'keep')
+
+ assert.equal(
+ config
+ .getResources(TeamMember)
+ .some(teamMember => teamMember.username === 'alice'),
+ false
+ )
+ assert.equal(
+ config
+ .getResources(RepositoryCollaborator)
+ .some(
+ collaborator =>
+ collaborator.username === 'alice' &&
+ collaborator.repository === 'private-repo'
+ ),
+ false
+ )
+
+ const publicCollaborator = config
+ .getResources(RepositoryCollaborator)
+ .find(
+ collaborator =>
+ collaborator.username === 'alice' &&
+ collaborator.repository === 'public-repo'
+ )
+
+ assert.equal(publicCollaborator?.permission, 'push')
+ })
+
+ it('removes public repository access when configured', () => {
+ const config = new Config(`
+members:
+ member:
+ - alice
+repositories:
+ public-repo:
+ collaborators:
+ pull:
+ - alice
+ teams:
+ push:
+ - maintainers
+ visibility: public
+teams:
+ maintainers:
+ members:
+ member:
+ - alice
+`)
+
+ updateMembersConfig(config, ['alice'], 'remove', 'keep')
+
+ assert.equal(
+ config
+ .getResources(RepositoryCollaborator)
+ .some(collaborator => collaborator.username === 'alice'),
+ false
+ )
+ })
+
+ it('removes organization membership when configured', () => {
+ const config = new Config(`
+members:
+ member:
+ - alice
+ - bob
+repositories:
+ public-repo:
+ visibility: public
+`)
+
+ updateMembersConfig(config, ['alice'], 'remove', 'remove')
+
+ assert.deepEqual(
+ config.getResources(Member).map(member => member.username),
+ ['bob']
+ )
+ })
+})
diff --git a/scripts/__tests__/workflows.test.ts b/scripts/__tests__/workflows.test.ts
new file mode 100644
index 0000000..1894acc
--- /dev/null
+++ b/scripts/__tests__/workflows.test.ts
@@ -0,0 +1,290 @@
+import assert from 'node:assert'
+import {existsSync, readFileSync} from 'node:fs'
+import {describe, it} from 'node:test'
+import * as YAML from 'yaml'
+
+type WorkflowStep = {
+ name?: string
+ if?: string
+ uses?: string
+ run?: string
+ env?: Record
+ with?: Record
+}
+
+type Workflow = {
+ on: {
+ workflow_dispatch?: unknown
+ }
+ jobs: Record<
+ string,
+ {
+ permissions?: Record
+ environment?: string
+ steps: WorkflowStep[]
+ }
+ >
+}
+
+function workflow(path: string): Workflow {
+ return YAML.parse(readFileSync(`../.github/workflows/${path}`, 'utf8'))
+}
+
+describe('workflows', () => {
+ it('guards allow-destroy override steps with an environment variable', () => {
+ const plan = workflow('plan.yml')
+ const apply = workflow('apply.yml')
+ const planStep = plan.jobs.plan.steps.find(
+ step => step.name === 'Allow destroy in guarded environment'
+ )
+ const applyStep = apply.jobs.apply.steps.find(
+ step => step.name === 'Allow destroy in guarded environment'
+ )
+
+ assert.ok(planStep)
+ assert.ok(applyStep)
+ assert.equal(planStep.env?.ALLOW_DESTROY, '${{ vars.ALLOW_DESTROY }}')
+ assert.match(planStep.run ?? '', /ALLOW_DESTROY.*true/)
+ assert.match(planStep.run ?? '', /allow_destroy_override\.tf\.disabled/)
+ assert.equal(applyStep.env?.ALLOW_DESTROY, '${{ vars.ALLOW_DESTROY }}')
+ assert.match(applyStep.run ?? '', /ALLOW_DESTROY.*true/)
+ assert.match(applyStep.run ?? '', /allow_destroy_override\.tf\.disabled/)
+ })
+
+ 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', () => {
+ const fix = workflow('fix.yml')
+ const steps = fix.jobs.fix.steps.map(step => step.name)
+
+ assert.ok(steps.includes('Publish access report summary'))
+ assert.ok(steps.includes('Upload access report'))
+ })
+
+ it('downloads only fixed YAML config artifacts before pushing fix changes', () => {
+ const fix = workflow('fix.yml')
+ const fixSteps = fix.jobs.fix.steps
+ const pushSteps = fix.jobs.push.steps
+ const uploadYamlStep = fixSteps.find(
+ step => step.name === 'Upload YAML config'
+ )
+ const downloadYamlStep = pushSteps.find(
+ step => step.name === 'Download YAML configs'
+ )
+ const copyYamlStep = pushSteps.find(
+ step => step.name === 'Copy YAML configs'
+ )
+
+ assert.ok(uploadYamlStep)
+ assert.ok(downloadYamlStep)
+ assert.ok(copyYamlStep)
+ assert.equal(
+ uploadYamlStep.with?.name,
+ 'fixed-config-${{ env.TF_WORKSPACE }}'
+ )
+ assert.equal(downloadYamlStep.with?.pattern, 'fixed-config-*')
+ 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'
+ )
+ })
+
+ 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'"
+ )
+ })
+})
diff --git a/scripts/src/actions/classify-allow-destroy.ts b/scripts/src/actions/classify-allow-destroy.ts
new file mode 100644
index 0000000..bea8b79
--- /dev/null
+++ b/scripts/src/actions/classify-allow-destroy.ts
@@ -0,0 +1,255 @@
+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 {
+ Resource,
+ ResourceConstructor,
+ ResourceConstructors
+} from '../resources/resource.js'
+import {Member} from '../resources/member.js'
+import {Repository, Visibility} from '../resources/repository.js'
+import {TeamMember} from '../resources/team-member.js'
+import {RepositoryCollaborator} from '../resources/repository-collaborator.js'
+
+const ALLOW_DESTROY_RESOURCE_CLASSES: ResourceConstructor[] = [
+ Member,
+ Repository
+]
+
+type Mode = 'read' | 'write'
+
+type Matrix = {
+ include: {
+ workspace: string
+ environment: string
+ environmentReasons: string[]
+ }[]
+}
+
+function getStateAddress(resource: Resource): string {
+ return resource.getStateAddress().toLowerCase()
+}
+
+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
+): T[] {
+ const desiredAddresses = new Set(
+ config.getResources(resourceClass).map(getStateAddress)
+ )
+ return state
+ .getResources(resourceClass)
+ .filter(resource => !desiredAddresses.has(getStateAddress(resource)))
+}
+
+export async function getAllowDestroyReasons(
+ config: Config,
+ state: State
+): Promise {
+ const reasons = []
+
+ for (const resourceClass of ALLOW_DESTROY_RESOURCE_CLASSES) {
+ if (
+ ResourceConstructors.includes(resourceClass) &&
+ !(await state.isIgnored(resourceClass))
+ ) {
+ reasons.push(
+ ...getMissingResources(config, state, resourceClass).map(
+ formatAllowDestroyReason
+ )
+ )
+ }
+ }
+
+ return reasons.sort()
+}
+
+export async function hasAllowDestroyChange(
+ config: Config,
+ state: State
+): Promise {
+ return (await getAllowDestroyReasons(config, state)).length > 0
+}
+
+export async function validateRemovedMembersHaveNoDanglingAccess(
+ config: Config,
+ state: State
+): Promise {
+ if (await state.isIgnored(Member)) {
+ return
+ }
+
+ const desiredMembers = new Set(
+ config.getResources(Member).map(member => member.username.toLowerCase())
+ )
+ const removedMembers = state
+ .getResources(Member)
+ .map(member => member.username.toLowerCase())
+ .filter(username => !desiredMembers.has(username))
+
+ if (removedMembers.length === 0) {
+ return
+ }
+
+ const repositoryVisibility = new Map(
+ [...state.getResources(Repository), ...config.getResources(Repository)].map(
+ repository => [
+ repository.name.toLowerCase(),
+ repository.visibility ?? Visibility.Private
+ ]
+ )
+ )
+ const teamMembers = config.getResources(TeamMember)
+ const repositoryCollaborators = config.getResources(RepositoryCollaborator)
+ const errors: string[] = []
+
+ for (const username of removedMembers.sort()) {
+ const teams = teamMembers
+ .filter(teamMember => teamMember.username.toLowerCase() === username)
+ .map(teamMember => teamMember.team.toLowerCase())
+ .sort()
+ const privateRepositories = repositoryCollaborators
+ .filter(collaborator => collaborator.username.toLowerCase() === username)
+ .filter(
+ collaborator =>
+ (repositoryVisibility.get(collaborator.repository.toLowerCase()) ??
+ Visibility.Private) === Visibility.Private
+ )
+ .map(collaborator => collaborator.repository.toLowerCase())
+ .sort()
+
+ if (teams.length > 0) {
+ errors.push(
+ `${username} is still a member of ${teams.length === 1 ? 'team' : 'teams'} ${teams.join(
+ ', '
+ )}`
+ )
+ }
+
+ if (privateRepositories.length > 0) {
+ errors.push(
+ `${username} still has direct access to private ${privateRepositories.length === 1 ? 'repository' : 'repositories'} ${privateRepositories.join(
+ ', '
+ )}`
+ )
+ }
+ }
+
+ if (errors.length > 0) {
+ throw new Error(
+ [
+ 'Cannot remove organization members while leaving dangling access:',
+ ...errors.map(error => `- ${error}`)
+ ].join('\n')
+ )
+ }
+}
+
+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,
+ githubDir
+}: {
+ mode: Mode
+ workspaces: string[]
+ githubDir: string
+}): Promise {
+ const include = []
+ const originalWorkspace = process.env.TF_WORKSPACE
+
+ try {
+ for (const workspace of workspaces) {
+ process.env.TF_WORKSPACE = workspace
+ const config = Config.FromPath(`${githubDir}/${workspace}.yml`)
+ const state = await State.New()
+ await validateRemovedMembersHaveNoDanglingAccess(config, state)
+ const environmentReasons = await getAllowDestroyReasons(config, state)
+ const environment = getEnvironment(mode, environmentReasons.length > 0)
+ core.info(`${workspace}: ${environment}`)
+ for (const reason of environmentReasons) {
+ core.info(`- ${reason}`)
+ }
+ include.push({workspace, environment, environmentReasons})
+ }
+ } finally {
+ if (originalWorkspace === undefined) {
+ delete process.env.TF_WORKSPACE
+ } else {
+ process.env.TF_WORKSPACE = originalWorkspace
+ }
+ }
+
+ return {include}
+}
+
+async function run(): Promise {
+ const mode = (process.env.MODE ?? 'read') as Mode
+ if (mode !== 'read' && mode !== 'write') {
+ throw new Error(`MODE must be one of "read" or "write", got "${mode}"`)
+ }
+
+ const workspaces = JSON.parse(process.env.WORKSPACES?.trim() || '[]')
+ if (!Array.isArray(workspaces)) {
+ throw new Error('WORKSPACES must be a JSON array')
+ }
+
+ const matrix = await classifyWorkspaces({
+ mode,
+ workspaces,
+ githubDir: process.env.GITHUB_DIR ?? '../github'
+ })
+
+ writeStepSummary(describeWorkspaceClassification(matrix))
+ core.setOutput('matrix', JSON.stringify(matrix))
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ run().catch(error => core.setFailed(error))
+}
diff --git a/scripts/src/actions/fix-yaml-config.ts b/scripts/src/actions/fix-yaml-config.ts
index a88b191..142297c 100644
--- a/scripts/src/actions/fix-yaml-config.ts
+++ b/scripts/src/actions/fix-yaml-config.ts
@@ -8,20 +8,9 @@ import * as core from '@actions/core'
async function run(): Promise {
await runToggleArchivedRepos()
- const accessChangesDescription = await runDescribeAccessChanges()
+ const accessChangesComment = await runDescribeAccessChanges()
- core.setOutput(
- 'comment',
- `The following access changes will be introduced as a result of applying the plan:
-
-Access Changes
-
-\`\`\`
-${accessChangesDescription}
-\`\`\`
-
- `
- )
+ core.setOutput('comment', accessChangesComment)
}
run()
diff --git a/scripts/src/actions/shared/access-summary.ts b/scripts/src/actions/shared/access-summary.ts
new file mode 100644
index 0000000..b3fc9a8
--- /dev/null
+++ b/scripts/src/actions/shared/access-summary.ts
@@ -0,0 +1,410 @@
+import {Config} from '../../yaml/config.js'
+import {State} from '../../terraform/state.js'
+import {RepositoryCollaborator} from '../../resources/repository-collaborator.js'
+import {Member} from '../../resources/member.js'
+import {TeamMember} from '../../resources/team-member.js'
+import {RepositoryTeam} from '../../resources/repository-team.js'
+import {Repository, Visibility} from '../../resources/repository.js'
+
+export type RepositoryAccess = {
+ permission: string
+ visibility: Visibility
+ grants: RepositoryAccessGrant[]
+}
+
+export type RepositoryAccessGrant = {
+ source: 'direct' | 'team'
+ permission: string
+ team?: string
+}
+
+export type UserAccess = {
+ role?: string
+ isMember: boolean
+ isOutsideCollaborator: boolean
+ repositories: Record
+ directRepositories: Record
+ teams: string[]
+ hasKeepComment: boolean
+}
+
+export type AccessSummary = Record
+
+export type AccessCategory =
+ | 'outsideCollaborators'
+ | 'potentialOutsideCollaborators'
+ | 'potentialNoMembers'
+ | 'anyOtherMembers'
+
+export type AccessCategories = Record
+
+export const permissions = ['admin', 'maintain', 'push', 'triage', 'pull']
+
+export function betterPermission(current: string, next: string): string {
+ return permissions.indexOf(next) < permissions.indexOf(current)
+ ? next
+ : current
+}
+
+function betterGrant(
+ current: RepositoryAccessGrant | undefined,
+ next: RepositoryAccessGrant
+): RepositoryAccessGrant {
+ return current === undefined ||
+ permissions.indexOf(next.permission) <
+ permissions.indexOf(current.permission)
+ ? next
+ : current
+}
+
+function addRepositoryGrant(
+ repositories: Record,
+ repository: string,
+ visibility: Visibility,
+ grant: RepositoryAccessGrant
+): void {
+ const current = repositories[repository]
+ if (current === undefined) {
+ repositories[repository] = {
+ permission: grant.permission,
+ visibility,
+ grants: [grant]
+ }
+ } else {
+ current.permission = betterPermission(current.permission, grant.permission)
+ current.grants.push(grant)
+ }
+}
+
+function sortRepositoryAccess(access: RepositoryAccess): RepositoryAccess {
+ return {
+ ...access,
+ grants: access.grants.sort((a, b) =>
+ JSON.stringify(a).localeCompare(JSON.stringify(b))
+ )
+ }
+}
+
+export function parseUserList(source?: string): string[] {
+ return Array.from(
+ new Set(
+ (source || '')
+ .split(/[\s,]+/)
+ .map(username => username.trim().toLowerCase())
+ .filter(username => username !== '')
+ )
+ ).sort()
+}
+
+export function hasKeepComment(config: Config, member: Member): boolean {
+ const node = config.document.getIn(
+ member.getSchemaPath(config.get()),
+ true
+ ) as {comment?: string} | undefined
+ return node?.comment?.includes('KEEP:') ?? false
+}
+
+export function getAccessSummaryFrom(source: State | Config): AccessSummary {
+ const members = source.getResources(Member)
+ const teamMembers = source.getResources(TeamMember)
+ const teamRepositories = source.getResources(RepositoryTeam)
+ const repositoryCollaborators = source.getResources(RepositoryCollaborator)
+ const repositories = source.getResources(Repository)
+
+ const repositoryVisibility = new Map(
+ repositories.map(repository => [
+ repository.name.toLowerCase(),
+ repository.visibility ?? Visibility.Private
+ ])
+ )
+ const archivedRepositories = repositories
+ .filter(repository => repository.archived)
+ .map(repository => repository.name.toLowerCase())
+
+ const usernames = new Set([
+ ...members.map(member => member.username.toLowerCase()),
+ ...teamMembers.map(teamMember => teamMember.username.toLowerCase()),
+ ...repositoryCollaborators.map(collaborator =>
+ collaborator.username.toLowerCase()
+ )
+ ])
+
+ const accessSummary: AccessSummary = {}
+
+ for (const username of Array.from(usernames).sort()) {
+ const member = members.find(
+ candidate => candidate.username.toLowerCase() === username
+ )
+ const role = member?.role
+ const teams = teamMembers
+ .filter(teamMember => teamMember.username.toLowerCase() === username)
+ .map(teamMember => teamMember.team.toLowerCase())
+ .sort()
+ const repositoryCollaborator = repositoryCollaborators
+ .filter(collaborator => collaborator.username.toLowerCase() === username)
+ .filter(
+ collaborator =>
+ !archivedRepositories.includes(collaborator.repository.toLowerCase())
+ )
+ const teamRepository = teamRepositories
+ .filter(repository => teams.includes(repository.team.toLowerCase()))
+ .filter(
+ repository =>
+ !archivedRepositories.includes(repository.repository.toLowerCase())
+ )
+
+ const repositories: Record = {}
+ const directRepositories: Record = {}
+
+ for (const rc of repositoryCollaborator) {
+ const repository = rc.repository.toLowerCase()
+ const access = {
+ permission: rc.permission,
+ visibility: repositoryVisibility.get(repository) ?? Visibility.Private,
+ grants: [
+ {
+ source: 'direct' as const,
+ permission: rc.permission
+ }
+ ]
+ }
+ directRepositories[repository] = {
+ ...access,
+ grants: [
+ betterGrant(
+ directRepositories[repository]?.grants[0],
+ access.grants[0]
+ )
+ ],
+ permission: directRepositories[repository]
+ ? betterPermission(
+ directRepositories[repository].permission,
+ access.permission
+ )
+ : access.permission
+ }
+ addRepositoryGrant(
+ repositories,
+ repository,
+ access.visibility,
+ access.grants[0]
+ )
+ }
+
+ for (const tr of teamRepository) {
+ const repository = tr.repository.toLowerCase()
+ addRepositoryGrant(
+ repositories,
+ repository,
+ repositoryVisibility.get(repository) ?? Visibility.Private,
+ {
+ source: 'team',
+ permission: tr.permission,
+ team: tr.team.toLowerCase()
+ }
+ )
+ }
+
+ const hasKeep =
+ source instanceof Config && member !== undefined
+ ? hasKeepComment(source, member)
+ : false
+
+ const isMember = role !== undefined
+ const isOutsideCollaborator =
+ !isMember && Object.keys(directRepositories).length > 0
+
+ if (isMember || isOutsideCollaborator || teams.length > 0) {
+ accessSummary[username] = {
+ role,
+ isMember,
+ isOutsideCollaborator,
+ repositories,
+ directRepositories: Object.fromEntries(
+ Object.entries(directRepositories).map(([repository, access]) => [
+ repository,
+ sortRepositoryAccess(access)
+ ])
+ ),
+ teams,
+ hasKeepComment: hasKeep
+ }
+ accessSummary[username].repositories = Object.fromEntries(
+ Object.entries(accessSummary[username].repositories).map(
+ ([repository, access]) => [repository, sortRepositoryAccess(access)]
+ )
+ )
+ }
+ }
+
+ return deepSort(accessSummary)
+}
+
+export function getComparableAccessSummary(source: State | Config): Record<
+ string,
+ {
+ role?: string
+ repositories: Record<
+ string,
+ {permission: string; grants: RepositoryAccessGrant[]}
+ >
+ }
+> {
+ return Object.fromEntries(
+ Object.entries(getAccessSummaryFrom(source)).map(([username, access]) => [
+ username,
+ {
+ role: access.role,
+ repositories: Object.fromEntries(
+ Object.entries(access.repositories).map(([repository, value]) => [
+ repository,
+ {
+ permission: value.permission,
+ grants: value.grants
+ }
+ ])
+ )
+ }
+ ])
+ )
+}
+
+export function categorizeAccessSummary(
+ summary: AccessSummary
+): AccessCategories {
+ const categories: AccessCategories = {
+ outsideCollaborators: [],
+ potentialOutsideCollaborators: [],
+ potentialNoMembers: [],
+ anyOtherMembers: []
+ }
+
+ for (const [username, access] of Object.entries(summary)) {
+ const repositories = Object.values(access.repositories)
+ if (access.isOutsideCollaborator) {
+ categories.outsideCollaborators.push(username)
+ } else if (
+ access.isMember &&
+ !access.hasKeepComment &&
+ access.teams.length === 0 &&
+ repositories.length > 0 &&
+ repositories.every(
+ repository => repository.visibility === Visibility.Public
+ )
+ ) {
+ categories.potentialOutsideCollaborators.push(username)
+ } else if (
+ access.isMember &&
+ !access.hasKeepComment &&
+ repositories.length === 0
+ ) {
+ categories.potentialNoMembers.push(username)
+ } else if (access.isMember) {
+ categories.anyOtherMembers.push(username)
+ }
+ }
+
+ for (const users of Object.values(categories)) {
+ users.sort()
+ }
+
+ return categories
+}
+
+export function formatRepositoryAccess(
+ repository: string,
+ access: RepositoryAccess
+): string {
+ return `${repository} (${access.visibility})`
+}
+
+function formatTeams(teams: string[]): string {
+ return teams.length === 1 ? `team ${teams[0]}` : `teams ${teams.join(', ')}`
+}
+
+export function formatRepositoryAccessDescription(
+ repository: string,
+ access: RepositoryAccess
+): string {
+ const directGrant = access.grants.find(grant => grant.source === 'direct')
+ const teamGrants = access.grants.filter(grant => grant.source === 'team')
+ const repositoryLabel = formatRepositoryAccess(repository, access)
+
+ if (directGrant !== undefined && teamGrants.length === 0) {
+ return `direct ${directGrant.permission} permission to ${repositoryLabel}`
+ }
+
+ if (directGrant === undefined) {
+ const teams = Array.from(
+ new Set(teamGrants.map(grant => grant.team).filter(Boolean) as string[])
+ ).sort()
+ return `${access.permission} permission to ${repositoryLabel} through ${formatTeams(
+ teams
+ )}`
+ }
+
+ const teams = Array.from(
+ new Set(teamGrants.map(grant => grant.team).filter(Boolean) as string[])
+ ).sort()
+ return `effective ${access.permission} permission to ${repositoryLabel} through direct ${directGrant.permission} permission and ${formatTeams(
+ teams
+ )}`
+}
+
+export function formatAccessSummarySection(
+ title: string,
+ users: string[],
+ summary: AccessSummary
+): string {
+ const lines = [
+ `${title}
`,
+ '',
+ `Affected users: ${users.length > 0 ? users.join(', ') : 'none'}`,
+ '',
+ '```'
+ ]
+
+ if (users.length === 0) {
+ lines.push('No users in this section')
+ }
+
+ for (const username of users) {
+ const access = summary[username]
+ const kind = access.isOutsideCollaborator
+ ? 'outside collaborator'
+ : 'member'
+ lines.push(`User ${username} (${kind}):`)
+ const repositories = Object.entries(access.repositories)
+ if (repositories.length === 0) {
+ lines.push(' - has no repository access')
+ } else {
+ for (const [repository, repositoryAccess] of repositories) {
+ lines.push(
+ ` - has ${formatRepositoryAccessDescription(
+ repository,
+ repositoryAccess
+ )}`
+ )
+ }
+ }
+ }
+
+ lines.push('```', '', ' ')
+ return lines.join('\n')
+}
+
+// deep sort object
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function deepSort(obj: any): any {
+ if (Array.isArray(obj)) {
+ return obj.map(deepSort)
+ } else if (obj !== null && typeof obj === 'object') {
+ const sorted: Record = {}
+ for (const key of Object.keys(obj).sort()) {
+ sorted[key] = deepSort(obj[key])
+ }
+ return sorted
+ } else {
+ return obj
+ }
+}
diff --git a/scripts/src/actions/shared/describe-access-changes.ts b/scripts/src/actions/shared/describe-access-changes.ts
index df76407..ea3093c 100644
--- a/scripts/src/actions/shared/describe-access-changes.ts
+++ b/scripts/src/actions/shared/describe-access-changes.ts
@@ -1,229 +1,212 @@
import {Config} from '../../yaml/config.js'
import {State} from '../../terraform/state.js'
-import {RepositoryCollaborator} from '../../resources/repository-collaborator.js'
-import {Member} from '../../resources/member.js'
-import {TeamMember} from '../../resources/team-member.js'
-import {RepositoryTeam} from '../../resources/repository-team.js'
-import diff from 'deep-diff'
import * as core from '@actions/core'
-import {Repository} from '../../resources/repository.js'
+import * as fs from 'fs'
+import {
+ categorizeAccessSummary,
+ formatAccessSummarySection,
+ formatRepositoryAccessDescription,
+ getAccessSummaryFrom
+} from './access-summary.js'
-type AccessSummary = Record<
- string,
- {
- role?: string
- repositories: Record
- }
->
-
-function getAccessSummaryFrom(source: State | Config): AccessSummary {
- const members = source.getResources(Member)
- const teamMembers = source.getResources(TeamMember)
- const teamRepositories = source.getResources(RepositoryTeam)
- const repositoryCollaborators = source.getResources(RepositoryCollaborator)
-
- const archivedRepositories = source
- .getResources(Repository)
- .filter(repository => repository.archived)
- .map(repository => repository.name.toLowerCase())
-
- const usernames = new Set([
- ...members.map(member => member.username.toLowerCase()),
- ...repositoryCollaborators.map(collaborator =>
- collaborator.username.toLowerCase()
- )
- ])
-
- const accessSummary: AccessSummary = {}
- const permissions = ['admin', 'maintain', 'push', 'triage', 'pull']
+const GITHUB_COMMENT_LENGTH_LIMIT = 65000
- for (const username of usernames) {
- const role = members.find(
- member => member.username.toLowerCase() === username
- )?.role
- const teams = teamMembers
- .filter(teamMember => teamMember.username.toLowerCase() === username)
- .map(teamMember => teamMember.team.toLowerCase())
- const repositoryCollaborator = repositoryCollaborators
- .filter(collaborator => collaborator.username.toLowerCase() === username)
- .filter(
- collaborator =>
- !archivedRepositories.includes(collaborator.repository.toLowerCase())
- )
- const teamRepository = teamRepositories
- .filter(repository => teams.includes(repository.team.toLowerCase()))
- .filter(
- repository =>
- !archivedRepositories.includes(repository.repository.toLowerCase())
- )
-
- const repositories: Record = {}
-
- for (const rc of repositoryCollaborator) {
- const repository = rc.repository.toLowerCase()
- repositories[repository] = repositories[repository] ?? {}
- if (
- !repositories[repository].permission ||
- permissions.indexOf(rc.permission) <
- permissions.indexOf(repositories[repository].permission)
- ) {
- repositories[repository].permission = rc.permission
- }
- }
+export async function runDescribeAccessChanges(): Promise {
+ const state = await State.New()
+ const config = Config.FromPath()
+ const accessReport = describeAccessReport(state, config)
+ const accessReportPath = process.env.ACCESS_REPORT_PATH ?? 'ACCESS_REPORT.md'
- for (const tr of teamRepository) {
- const repository = tr.repository.toLowerCase()
- repositories[repository] = repositories[repository] ?? {}
- if (
- !repositories[repository].permission ||
- permissions.indexOf(tr.permission) <
- permissions.indexOf(repositories[repository].permission)
- ) {
- repositories[repository].permission = tr.permission
- }
- }
+ fs.writeFileSync(accessReportPath, accessReport)
+ return describeAccessChangesComment(state, config)
+}
- if (role !== undefined || Object.keys(repositories).length > 0) {
- accessSummary[username] = {
- role,
- repositories
- }
- }
+export function workflowRunUrl(): string | undefined {
+ const serverUrl = process.env.GITHUB_SERVER_URL
+ const repository = process.env.GITHUB_REPOSITORY
+ const runId = process.env.GITHUB_RUN_ID
+
+ if (
+ serverUrl === undefined ||
+ repository === undefined ||
+ runId === undefined
+ ) {
+ return undefined
}
- return deepSort(accessSummary)
+ return `${serverUrl}/${repository}/actions/runs/${runId}`
}
-// deep sort object
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function deepSort(obj: any): any {
- if (Array.isArray(obj)) {
- return obj.map(deepSort)
- } else if (typeof obj === 'object') {
- const sorted: Record = {}
- for (const key of Object.keys(obj).sort()) {
- sorted[key] = deepSort(obj[key])
- }
- return sorted
- } else {
- return obj
+export function describeAccessChangesComment(
+ state: State,
+ config: Config,
+ maxLength = GITHUB_COMMENT_LENGTH_LIMIT,
+ runUrl = workflowRunUrl()
+): string {
+ const accessChangesDescription = describeAccessChanges(state, config)
+ const reportDestination =
+ runUrl === undefined
+ ? 'the Fix workflow summary or access report artifact'
+ : `[the Fix workflow summary or access report artifact](${runUrl})`
+ const comment = [
+ 'The following access changes will be introduced as a result of applying the plan:',
+ '',
+ 'Access Changes
',
+ '',
+ '```',
+ accessChangesDescription,
+ '```',
+ '',
+ ' ',
+ '',
+ `For the full access breakdown, inspect ${reportDestination}.`
+ ].join('\n')
+
+ if (Buffer.byteLength(comment, 'utf8') < maxLength) {
+ return comment
}
-}
-export async function runDescribeAccessChanges(): Promise {
- const state = await State.New()
- const config = Config.FromPath()
+ return `Access changes are too long to post as a comment. Please inspect ${reportDestination} instead.`
+}
- return await describeAccessChanges(state, config)
+export function describeAccessReport(state: State, config: Config): string {
+ const accessChangesDescription = describeAccessChanges(state, config)
+ const after = getAccessSummaryFrom(config)
+ const categories = categorizeAccessSummary(after)
+
+ return [
+ 'The following access changes will be introduced as a result of applying the plan:',
+ '',
+ 'Access Changes
',
+ '',
+ '```',
+ accessChangesDescription,
+ '```',
+ '',
+ ' ',
+ '',
+ 'The sections below describe effective access after these config changes are applied:',
+ '',
+ formatAccessSummarySection(
+ 'Post-change outside collaborators',
+ categories.outsideCollaborators,
+ after
+ ),
+ '',
+ formatAccessSummarySection(
+ 'Post-change potential outside collaborators',
+ categories.potentialOutsideCollaborators,
+ after
+ ),
+ '',
+ formatAccessSummarySection(
+ 'Post-change potential no members',
+ categories.potentialNoMembers,
+ after
+ ),
+ '',
+ formatAccessSummarySection(
+ 'Post-change any other members',
+ categories.anyOtherMembers,
+ after
+ )
+ ].join('\n')
}
-export async function describeAccessChanges(
- state: State,
- config: Config
-): Promise {
+export function describeAccessChanges(state: State, config: Config): string {
const before = getAccessSummaryFrom(state)
const after = getAccessSummaryFrom(config)
core.info(JSON.stringify({before, after}, null, 2))
- const changes = diff(before, after) || []
-
- core.debug(JSON.stringify(changes, null, 2))
+ const lines = []
+ const usernames = Array.from(
+ new Set([...Object.keys(before), ...Object.keys(after)])
+ ).sort()
- const changesByUser: Record = {}
- for (const change of changes) {
- if (change.path === undefined) {
- throw new Error(`Change ${change.kind} has no path`)
+ for (const username of usernames) {
+ const beforeAccess = before[username]
+ const afterAccess = after[username]
+ const userLines = []
+
+ if (beforeAccess?.role !== afterAccess?.role) {
+ if (beforeAccess?.role === undefined && afterAccess?.role !== undefined) {
+ userLines.push(
+ ` - will join the organization as a ${afterAccess.role} (remind them to accept the email invitation)`
+ )
+ } else if (
+ beforeAccess?.role !== undefined &&
+ afterAccess?.role === undefined
+ ) {
+ 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}`
+ )
+ }
}
- const path = change.path
- changesByUser[path[0]] = changesByUser[path[0]] || []
- changesByUser[path[0]].push(change)
- }
- // iterate over changesByUser and build a description
- const lines = []
- for (const [username, userChanges] of Object.entries(changesByUser)) {
- lines.push(`User ${username}:`)
- for (const change of userChanges) {
- if (change.path === undefined) {
- throw new Error(`Change ${change.kind} has no path`)
+ const repositories = Array.from(
+ new Set([
+ ...Object.keys(beforeAccess?.repositories ?? {}),
+ ...Object.keys(afterAccess?.repositories ?? {})
+ ])
+ ).sort()
+
+ for (const repository of repositories) {
+ const beforeRepositoryAccess = beforeAccess?.repositories[repository]
+ const afterRepositoryAccess = afterAccess?.repositories[repository]
+ if (
+ JSON.stringify(beforeRepositoryAccess) ===
+ JSON.stringify(afterRepositoryAccess)
+ ) {
+ continue
}
- const path = change.path
- switch (change.kind) {
- case 'E':
- if (path[1] === 'role') {
- if (change.lhs === undefined) {
- lines.push(
- ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)`
- )
- } else if (change.rhs === undefined) {
- lines.push(` - will leave the organization`)
- } else {
- lines.push(
- ` - will have the role in the organization change from ${change.lhs} to ${change.rhs}`
- )
- }
- } else {
- lines.push(
- ` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}`
- )
- }
- break
- case 'N':
- if (path.length === 1) {
- if (change.rhs.role) {
- lines.push(
- ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)`
- )
- }
- if (change.rhs.repositories) {
- const repositories = change.rhs.repositories as unknown as Record<
- string,
- {permission: string}
- >
- for (const [repository, {permission}] of Object.entries(
- repositories
- )) {
- lines.push(
- ` - will gain ${permission} permission to ${repository}`
- )
- }
- }
- } else {
- lines.push(
- ` - will gain ${change.rhs.permission} permission to ${path[2]}`
- )
- }
- break
- case 'D':
- if (path.length === 1) {
- if (change.lhs.role) {
- lines.push(` - will leave the organization`)
- }
- if (change.lhs.repositories) {
- const repositories = change.lhs.repositories as unknown as Record<
- string,
- {permission: string}
- >
- for (const [repository, {permission}] of Object.entries(
- repositories
- )) {
- lines.push(
- ` - will lose ${permission} permission to ${repository}`
- )
- }
- }
- } else {
- lines.push(
- ` - will lose ${change.lhs.permission} permission to ${path[2]}`
- )
- }
- break
+
+ if (
+ beforeRepositoryAccess === undefined &&
+ afterRepositoryAccess !== undefined
+ ) {
+ userLines.push(
+ ` - will gain ${formatRepositoryAccessDescription(
+ repository,
+ afterRepositoryAccess
+ )}`
+ )
+ } else if (
+ beforeRepositoryAccess !== undefined &&
+ afterRepositoryAccess === undefined
+ ) {
+ userLines.push(
+ ` - will lose ${formatRepositoryAccessDescription(
+ repository,
+ beforeRepositoryAccess
+ )}`
+ )
+ } else if (
+ beforeRepositoryAccess !== undefined &&
+ afterRepositoryAccess !== undefined
+ ) {
+ userLines.push(
+ ` - will change from having ${formatRepositoryAccessDescription(
+ repository,
+ beforeRepositoryAccess
+ )} to having ${formatRepositoryAccessDescription(
+ repository,
+ afterRepositoryAccess
+ )}`
+ )
}
}
+
+ if (userLines.length > 0) {
+ lines.push(`User ${username}:`, ...userLines)
+ }
}
- return changes.length > 0
- ? lines.join('\n')
- : 'There will be no access changes'
+ return lines.length > 0 ? lines.join('\n') : 'There will be no access changes'
}
diff --git a/scripts/src/actions/update-members.ts b/scripts/src/actions/update-members.ts
new file mode 100644
index 0000000..77613a4
--- /dev/null
+++ b/scripts/src/actions/update-members.ts
@@ -0,0 +1,328 @@
+import 'reflect-metadata'
+import * as core from '@actions/core'
+import {pathToFileURL} from 'url'
+import {Config} from '../yaml/config.js'
+import {Member} from '../resources/member.js'
+import {Repository, Visibility} from '../resources/repository.js'
+import {
+ Permission as RepositoryCollaboratorPermission,
+ RepositoryCollaborator
+} from '../resources/repository-collaborator.js'
+import {TeamMember} from '../resources/team-member.js'
+import {GitHub} from '../github.js'
+import {
+ betterPermission,
+ getAccessSummaryFrom,
+ hasKeepComment,
+ parseUserList
+} from './shared/access-summary.js'
+
+export type PublicRepoAccess = 'retain' | 'remove'
+export type OrganizationMembership = 'keep' | 'remove'
+
+export type MemberActivity = {
+ username: string
+ latestActivity?: Date
+}
+
+export type UpdateMembersOptions = {
+ cutoffDate?: Date
+ limit?: number
+ ignore: string[]
+ only: string[]
+ publicRepoAccess: PublicRepoAccess
+ organizationMembership: OrganizationMembership
+}
+
+type ActivityRecord = {
+ username: string
+ createdAt: Date
+}
+
+export function parseCutoffDate(source?: string): Date | undefined {
+ if (source === undefined || source.trim() === '') {
+ return undefined
+ }
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(source)) {
+ throw new Error('cutoff-date must use YYYY-MM-DD format')
+ }
+ const date = new Date(`${source}T00:00:00.000Z`)
+ if (Number.isNaN(date.valueOf())) {
+ throw new Error('cutoff-date must be a valid date')
+ }
+ return date
+}
+
+export function parseLimit(source?: string): number | undefined {
+ if (source === undefined || source.trim() === '') {
+ return undefined
+ }
+ const limit = Number(source)
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new Error('limit must be a positive integer')
+ }
+ return limit
+}
+
+export function parsePublicRepoAccess(source?: string): PublicRepoAccess {
+ if (source === 'retain' || source === 'remove') {
+ return source
+ }
+ throw new Error('public-repo-access must be retain or remove')
+}
+
+export function parseOrganizationMembership(
+ source?: string
+): OrganizationMembership {
+ if (source === 'keep' || source === 'remove') {
+ return source
+ }
+ throw new Error('organization-membership must be keep or remove')
+}
+
+export function selectMembersForUpdate(
+ config: Config,
+ activities: MemberActivity[],
+ options: UpdateMembersOptions
+): string[] {
+ if (options.cutoffDate === undefined && options.only.length === 0) {
+ throw new Error('Either cutoff-date or only must be provided')
+ }
+
+ const activityByUsername = new Map(
+ activities.map(activity => [activity.username.toLowerCase(), activity])
+ )
+ const ignore = new Set(options.ignore)
+ const only = new Set(options.only)
+
+ const candidates = config
+ .getResources(Member)
+ .filter(member => !hasKeepComment(config, member))
+ .map(member => member.username.toLowerCase())
+ .filter(username => !ignore.has(username))
+ .filter(username => only.size === 0 || only.has(username))
+ .filter(username => {
+ if (options.cutoffDate === undefined) {
+ return true
+ }
+ const latestActivity = activityByUsername.get(username)?.latestActivity
+ return latestActivity === undefined || latestActivity < options.cutoffDate
+ })
+ .sort((a, b) => {
+ const aActivity = activityByUsername.get(a)?.latestActivity?.valueOf()
+ const bActivity = activityByUsername.get(b)?.latestActivity?.valueOf()
+ if (aActivity === undefined && bActivity === undefined) {
+ return a.localeCompare(b)
+ }
+ if (aActivity === undefined) {
+ return -1
+ }
+ if (bActivity === undefined) {
+ return 1
+ }
+ return aActivity - bActivity || a.localeCompare(b)
+ })
+
+ return options.limit === undefined
+ ? candidates
+ : candidates.slice(0, options.limit)
+}
+
+export function updateMembersConfig(
+ config: Config,
+ usernames: string[],
+ publicRepoAccess: PublicRepoAccess,
+ organizationMembership: OrganizationMembership
+): string[] {
+ const targets = new Set(usernames.map(username => username.toLowerCase()))
+ const repositories = new Map(
+ config
+ .getResources(Repository)
+ .map(repository => [repository.name.toLowerCase(), repository])
+ )
+ const accessBefore = getAccessSummaryFrom(config)
+ const retainedPublicAccess = new Map<
+ string,
+ Map
+ >()
+
+ if (publicRepoAccess === 'retain') {
+ for (const username of targets) {
+ const userAccess = accessBefore[username]
+ if (userAccess === undefined) {
+ continue
+ }
+ for (const [repository, access] of Object.entries(
+ userAccess.repositories
+ )) {
+ const configRepository = repositories.get(repository)
+ if (
+ configRepository?.archived ||
+ access.visibility !== Visibility.Public
+ ) {
+ continue
+ }
+ const retainedRepositories =
+ retainedPublicAccess.get(username) ?? new Map()
+ const current = retainedRepositories.get(repository)
+ retainedRepositories.set(
+ repository,
+ (current === undefined
+ ? access.permission
+ : betterPermission(
+ current,
+ access.permission
+ )) as RepositoryCollaboratorPermission
+ )
+ retainedPublicAccess.set(username, retainedRepositories)
+ }
+ }
+ }
+
+ for (const teamMember of config.getResources(TeamMember)) {
+ if (targets.has(teamMember.username.toLowerCase())) {
+ core.info(`Removing ${teamMember.username} from ${teamMember.team} team`)
+ config.removeResource(teamMember)
+ }
+ }
+
+ for (const collaborator of config.getResources(RepositoryCollaborator)) {
+ if (targets.has(collaborator.username.toLowerCase())) {
+ core.info(
+ `Removing ${collaborator.username} from ${collaborator.repository} repository`
+ )
+ config.removeResource(collaborator)
+ }
+ }
+
+ for (const [username, retainedRepositories] of retainedPublicAccess) {
+ for (const [repository, permission] of retainedRepositories) {
+ core.info(
+ `Retaining ${username} ${permission} access to public repository ${repository}`
+ )
+ config.addResource(
+ new RepositoryCollaborator(repository, username, permission)
+ )
+ }
+ }
+
+ if (organizationMembership === 'remove') {
+ for (const member of config.getResources(Member)) {
+ if (targets.has(member.username.toLowerCase())) {
+ core.info(`Removing ${member.username} from the organization`)
+ config.removeResource(member)
+ }
+ }
+ }
+
+ return Array.from(targets).sort()
+}
+
+function latestActivityByUser(activities: ActivityRecord[]): MemberActivity[] {
+ const latest = new Map()
+ for (const activity of activities) {
+ const username = activity.username.toLowerCase()
+ const previous = latest.get(username)
+ if (previous === undefined || activity.createdAt > previous) {
+ latest.set(username, activity.createdAt)
+ }
+ }
+ return Array.from(latest.entries()).map(([username, latestActivity]) => ({
+ username,
+ latestActivity
+ }))
+}
+
+async function collectActivities(since: Date): Promise {
+ const github = await GitHub.getGitHub()
+ const [
+ githubRepositoryActivities,
+ githubRepositoryIssues,
+ githubRepositoryPullRequestReviewComments,
+ githubRepositoryIssueComments,
+ githubRepositoryCommitComments
+ ] = await Promise.all([
+ github.listRepositoryActivities(since),
+ github.listRepositoryIssues(since),
+ github.listRepositoryPullRequestReviewComments(since),
+ github.listRepositoryIssueComments(since),
+ github.listRepositoryCommitComments(since)
+ ])
+
+ return latestActivityByUser(
+ [
+ ...githubRepositoryActivities.map(({activity}) => ({
+ username: activity.actor?.login,
+ createdAt: new Date(activity.timestamp)
+ })),
+ ...githubRepositoryIssues.map(({issue}) => ({
+ username: issue.user?.login,
+ createdAt: new Date(issue.created_at)
+ })),
+ ...githubRepositoryPullRequestReviewComments.map(({comment}) => ({
+ username: comment.user?.login,
+ createdAt: new Date(comment.created_at)
+ })),
+ ...githubRepositoryIssueComments.map(({comment}) => ({
+ username: comment.user?.login,
+ createdAt: new Date(comment.created_at)
+ })),
+ ...githubRepositoryCommitComments.map(({comment}) => ({
+ username: comment.user?.login,
+ createdAt: new Date(comment.created_at)
+ }))
+ ]
+ .filter(
+ (
+ activity
+ ): activity is {
+ username: string
+ createdAt: Date
+ } => activity.username !== undefined
+ )
+ .filter(activity => !Number.isNaN(activity.createdAt.valueOf()))
+ )
+}
+
+async function run(): Promise {
+ const cutoffDate = parseCutoffDate(process.env.CUTOFF_DATE)
+ const limit = parseLimit(process.env.LIMIT)
+ const ignore = parseUserList(process.env.IGNORE)
+ const only = parseUserList(process.env.ONLY)
+ const publicRepoAccess = parsePublicRepoAccess(process.env.PUBLIC_REPO_ACCESS)
+ const organizationMembership = parseOrganizationMembership(
+ process.env.ORGANIZATION_MEMBERSHIP || 'keep'
+ )
+
+ const config = Config.FromPath()
+ const activities =
+ cutoffDate === undefined
+ ? []
+ : await collectActivities(limit === undefined ? cutoffDate : new Date(0))
+ const selectedMembers = selectMembersForUpdate(config, activities, {
+ cutoffDate,
+ limit,
+ ignore,
+ only,
+ publicRepoAccess,
+ organizationMembership
+ })
+ const affectedUsers = updateMembersConfig(
+ config,
+ selectedMembers,
+ publicRepoAccess,
+ organizationMembership
+ )
+
+ config.save()
+
+ core.setOutput('affected-users', affectedUsers.join(', '))
+ core.setOutput('affected-users-json', JSON.stringify(affectedUsers))
+}
+
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(process.argv[1]).href
+) {
+ run()
+}
diff --git a/scripts/src/terraform/schema.ts b/scripts/src/terraform/schema.ts
index fded2c2..0f7b027 100644
--- a/scripts/src/terraform/schema.ts
+++ b/scripts/src/terraform/schema.ts
@@ -53,6 +53,8 @@ type ResourceSchema = {
type: 'github_repository'
values: {
name: string
+ archived?: boolean
+ visibility?: 'private' | 'public'
pages?: {
source?: object[]
}[]
diff --git a/terraform/allow_destroy_override.tf.disabled b/terraform/allow_destroy_override.tf.disabled
new file mode 100644
index 0000000..012e584
--- /dev/null
+++ b/terraform/allow_destroy_override.tf.disabled
@@ -0,0 +1,11 @@
+resource "github_membership" "this" {
+ lifecycle {
+ prevent_destroy = false
+ }
+}
+
+resource "github_repository" "this" {
+ lifecycle {
+ prevent_destroy = false
+ }
+}