├── .github ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── git-config-user │ │ └── action.yml │ └── git-push │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── apply.yml │ ├── clean.yml │ ├── cleanup.yml │ ├── fix.yml │ ├── labels.yml │ ├── plan.yml │ ├── sync.yml │ ├── update.yml │ ├── upgrade.yml │ └── upgrade_reusable.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── docs ├── ABOUT.md ├── EXAMPLE.yml ├── HOWTOS.md └── SETUP.md ├── files └── README.md ├── github ├── $ORGANIZATION_NAME.yml └── .schema.json ├── scripts ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── __tests__ │ ├── __resources__ │ │ ├── .gitignore │ │ ├── files │ │ │ └── README.md │ │ ├── github │ │ │ └── default.yml │ │ └── terraform │ │ │ ├── locals.tf │ │ │ ├── locals_override.tf │ │ │ ├── resources.tf │ │ │ ├── resources_override.tf │ │ │ └── terraform.tfstate │ ├── github.test.ts │ ├── github.ts │ ├── resources │ │ ├── counts.ts │ │ └── repository-file.test.ts │ ├── sync.test.ts │ ├── terraform │ │ └── state.test.ts │ └── yaml │ │ └── config.test.ts ├── eslint.config.mjs ├── package-lock.json ├── package.json ├── src │ ├── actions │ │ ├── find-sha-for-plan.ts │ │ ├── fix-yaml-config.ts │ │ ├── format-yaml-config.ts │ │ ├── remove-inactive-members.ts │ │ ├── shared │ │ │ ├── add-collaborator-to-all-repos.ts │ │ │ ├── add-file-to-all-repos.ts │ │ │ ├── add-label-to-all-repos.ts │ │ │ ├── describe-access-changes.ts │ │ │ ├── format.ts │ │ │ ├── protect-default-branches.ts │ │ │ ├── set-property-in-all-repos.ts │ │ │ └── toggle-archived-repos.ts │ │ ├── sync-labels.ts │ │ └── update-pull-requests.ts │ ├── env.ts │ ├── github.ts │ ├── main.ts │ ├── resources │ │ ├── member.ts │ │ ├── repository-branch-protection-rule.ts │ │ ├── repository-collaborator.ts │ │ ├── repository-file.ts │ │ ├── repository-label.ts │ │ ├── repository-team.ts │ │ ├── repository.ts │ │ ├── resource.ts │ │ ├── team-member.ts │ │ └── team.ts │ ├── sync.ts │ ├── terraform │ │ ├── locals.ts │ │ ├── schema.ts │ │ └── state.ts │ ├── utils.ts │ └── yaml │ │ ├── config.ts │ │ └── schema.ts ├── tsconfig.build.json └── tsconfig.json └── terraform ├── bootstrap ├── .terraform.lock.hcl └── aws.tf ├── data.tf ├── locals.tf ├── locals_override.tf ├── providers.tf ├── resources.tf ├── resources_override.tf ├── terraform.tf ├── terraform_override.tf └── variables.tf /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | 4 | ### Why do you need this? 5 | 6 | 7 | ### What else do we need to know? 8 | 9 | 10 | **DRI:** myself 11 | 12 | 13 | ### Reviewer's Checklist 14 | 15 | - [ ] It is clear where the request is coming from (if unsure, ask) 16 | - [ ] All the automated checks passed 17 | - [ ] The YAML changes reflect the summary of the request 18 | - [ ] The Terraform plan posted as a comment reflects the summary of the request 19 | -------------------------------------------------------------------------------- /.github/actions/git-config-user/action.yml: -------------------------------------------------------------------------------- 1 | name: Configure git user 2 | description: Configure git user 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - if: github.event_name == 'workflow_dispatch' 8 | run: | 9 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com>" 10 | git config --global user.name "${GITHUB_ACTOR}" 11 | shell: bash 12 | - if: github.event_name != 'workflow_dispatch' 13 | run: | 14 | git config --global user.name "github-actions[bot]" 15 | git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" 16 | shell: bash 17 | -------------------------------------------------------------------------------- /.github/actions/git-push/action.yml: -------------------------------------------------------------------------------- 1 | name: Push to a git branch 2 | description: Push to a git branch 3 | 4 | inputs: 5 | suffix: 6 | description: Branch name suffix 7 | required: true 8 | working-directory: 9 | description: Working directory 10 | required: false 11 | default: ${{ github.workspace }} 12 | 13 | runs: 14 | using: composite 15 | steps: 16 | - env: 17 | SUFFIX: ${{ inputs.suffix }} 18 | run: | 19 | protected="$(gh api "repos/{owner}/{repo}/branches/${GITHUB_REF_NAME}" --jq '.protected')" 20 | 21 | if [[ "${protected}" == 'true' ]]; then 22 | git_branch="${GITHUB_REF_NAME}-${SUFFIX}" 23 | else 24 | git_branch="${GITHUB_REF_NAME}" 25 | fi 26 | 27 | git checkout -B "${git_branch}" 28 | 29 | if [[ "${protected}" == 'true' ]]; then 30 | git push origin "${git_branch}" --force 31 | # fetching PR base because we want to compare against it and it might not have been checked out yet 32 | git fetch origin "${GITHUB_REF_NAME}" 33 | if [[ ! -z "$(git diff --name-only "origin/${GITHUB_REF_NAME}")" ]]; then 34 | state="$(gh pr view "${git_branch}" --json state --jq .state 2> /dev/null || echo '')" 35 | if [[ "${state}" != 'OPEN' ]]; then 36 | gh pr create --body 'The changes in this PR were made by a bot. Please review carefully.' --head "${git_branch}" --base "${GITHUB_REF_NAME}" --fill 37 | fi 38 | fi 39 | else 40 | git push origin "${git_branch}" 41 | fi 42 | shell: bash 43 | working-directory: ${{ inputs.working-directory }} 44 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "npm" 8 | directory: "/scripts/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/apply.yml: -------------------------------------------------------------------------------- 1 | name: Apply 2 | 3 | on: 4 | push: 5 | branches: 6 | - master # we want this to be executed on the default branch only 7 | workflow_dispatch: 8 | 9 | jobs: 10 | prepare: 11 | if: github.event.repository.is_template == false 12 | permissions: 13 | contents: read 14 | issues: read 15 | pull-requests: read 16 | name: Prepare 17 | runs-on: ubuntu-latest 18 | outputs: 19 | workspaces: ${{ steps.workspaces.outputs.this }} 20 | sha: ${{ steps.sha.outputs.result }} 21 | defaults: 22 | run: 23 | shell: bash 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Discover workspaces 28 | id: workspaces 29 | run: echo "this=$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" >> $GITHUB_OUTPUT 30 | - run: npm ci && npm run build 31 | working-directory: scripts 32 | - name: Find sha for plan 33 | id: sha 34 | env: 35 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 36 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 37 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 38 | run: node lib/actions/find-sha-for-plan.js 39 | working-directory: scripts 40 | apply: 41 | needs: [prepare] 42 | if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != '' 43 | permissions: 44 | actions: read 45 | contents: read 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }} 50 | name: Apply 51 | runs-on: ubuntu-latest 52 | env: 53 | TF_IN_AUTOMATION: 1 54 | TF_INPUT: 0 55 | TF_WORKSPACE: ${{ matrix.workspace }} 56 | AWS_ACCESS_KEY_ID: ${{ secrets.RW_AWS_ACCESS_KEY_ID }} 57 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RW_AWS_SECRET_ACCESS_KEY }} 58 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 59 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 60 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 61 | TF_VAR_write_delay_ms: 300 62 | defaults: 63 | run: 64 | shell: bash 65 | working-directory: terraform 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | - name: Setup terraform 70 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 71 | with: 72 | terraform_version: 1.12.0 73 | terraform_wrapper: false 74 | - name: Initialize terraform 75 | run: terraform init 76 | - name: Terraform Plan Download 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | SHA: ${{ needs.prepare.outputs.sha }} 80 | run: gh run download -n "${TF_WORKSPACE}_${SHA}.tfplan" --repo "${GITHUB_REPOSITORY}" 81 | - name: Terraform Apply 82 | run: | 83 | terraform show -json > $TF_WORKSPACE.tfstate.json 84 | terraform apply -lock-timeout=0s -no-color "${TF_WORKSPACE}.tfplan" 85 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | name: Clean 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | workspaces: 7 | description: Space separated list of workspaces to clean (leave blank to clean all) 8 | required: false 9 | regex: 10 | description: Regex string to use to find the resources to remove from the state 11 | required: false 12 | default: .* 13 | dry-run: 14 | description: Whether to only print out what would've been removed 15 | required: false 16 | default: "true" 17 | lock: 18 | description: Whether to acquire terraform state lock during clean 19 | required: false 20 | default: "true" 21 | 22 | jobs: 23 | prepare: 24 | name: Prepare 25 | runs-on: ubuntu-latest 26 | outputs: 27 | workspaces: ${{ steps.workspaces.outputs.this }} 28 | defaults: 29 | run: 30 | shell: bash 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Discover workspaces 35 | id: workspaces 36 | env: 37 | WORKSPACES: ${{ github.event.inputs.workspaces }} 38 | run: | 39 | if [[ -z "${WORKSPACES}" ]]; then 40 | workspaces="$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" 41 | else 42 | workspaces="$(echo "${WORKSPACES}" | jq --raw-input 'split(" ")')" 43 | fi 44 | echo "this=${workspaces}" >> $GITHUB_OUTPUT 45 | clean: 46 | needs: [prepare] 47 | if: needs.prepare.outputs.workspaces != '' 48 | permissions: 49 | contents: write 50 | strategy: 51 | fail-fast: false 52 | matrix: 53 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }} 54 | name: Prepare 55 | runs-on: ubuntu-latest 56 | env: 57 | TF_IN_AUTOMATION: 1 58 | TF_INPUT: 0 59 | TF_LOCK: ${{ github.event.inputs.lock }} 60 | TF_WORKSPACE_OPT: ${{ matrix.workspace }} 61 | AWS_ACCESS_KEY_ID: ${{ secrets.RW_AWS_ACCESS_KEY_ID }} 62 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RW_AWS_SECRET_ACCESS_KEY }} 63 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 64 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 65 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 66 | TF_VAR_write_delay_ms: 300 67 | defaults: 68 | run: 69 | shell: bash 70 | steps: 71 | - name: Checkout 72 | uses: actions/checkout@v4 73 | - name: Setup terraform 74 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 75 | with: 76 | terraform_version: 1.12.0 77 | terraform_wrapper: false 78 | - name: Initialize terraform 79 | run: terraform init -upgrade 80 | working-directory: terraform 81 | - name: Select terraform workspace 82 | run: | 83 | terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}" 84 | echo "TF_WORKSPACE=${TF_WORKSPACE_OPT}" >> $GITHUB_ENV 85 | working-directory: terraform 86 | - name: Clean 87 | env: 88 | DRY_RUN: ${{ github.event.inputs.dry-run }} 89 | REGEX: ^${{ github.event.inputs.regex }}$ 90 | run: | 91 | dryRunFlag='' 92 | if [[ "${DRY_RUN}" == 'true' ]]; then 93 | dryRunFlag='-dry-run' 94 | fi 95 | terraform state list | grep -E "${REGEX}" | sed 's/"/\\"/g' | xargs -I {} terraform state rm -lock="${TF_LOCK}" ${dryRunFlag} {} 96 | working-directory: terraform 97 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Clean Up 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | members: 7 | description: 'The members added to the org recently (JSON Array)' 8 | required: false 9 | default: '[]' 10 | repository-collaborators: 11 | description: 'The repository collaborators added to the org recently (JSON Map)' 12 | required: false 13 | default: '{}' 14 | team-members: 15 | description: 'The team members added to the org recently (JSON Map)' 16 | required: false 17 | default: '{}' 18 | cutoff: 19 | description: 'The number of months to consider for inactivity' 20 | required: false 21 | default: '12' 22 | 23 | defaults: 24 | run: 25 | shell: bash 26 | 27 | jobs: 28 | sync: 29 | permissions: 30 | contents: write 31 | name: Clean Up 32 | runs-on: ubuntu-latest 33 | env: 34 | GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }} 35 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }} 36 | GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} 37 | TF_WORKSPACE: ${{ github.repository_owner }} 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | - name: Initialize scripts 42 | run: npm install && npm run build 43 | working-directory: scripts 44 | - name: Remove inactive members 45 | run: node lib/actions/remove-inactive-members.js 46 | working-directory: scripts 47 | env: 48 | NEW_MEMBERS: ${{ github.event.inputs.members }} 49 | NEW_REPOSITORY_COLLABORATORS: ${{ github.event.inputs['repository-collaborators'] }} 50 | NEW_TEAM_MEMBERS: ${{ github.event.inputs['team-members'] }} 51 | CUTOFF_IN_MONTHS: ${{ github.event.inputs.cutoff }} 52 | - name: Check if github was modified 53 | id: github-modified 54 | run: | 55 | if [ -z "$(git status --porcelain -- github)" ]; then 56 | echo "this=false" >> $GITHUB_OUTPUT 57 | else 58 | echo "this=true" >> $GITHUB_OUTPUT 59 | fi 60 | - uses: ./.github/actions/git-config-user 61 | if: steps.github-modified.outputs.this == 'true' 62 | - if: steps.github-modified.outputs.this == 'true' 63 | env: 64 | SUFFIX: cleanup 65 | run: | 66 | git add --all -- github 67 | git commit -m "cleanup@${GITHUB_RUN_ID}" 68 | git checkout -B "${GITHUB_REF_NAME}-${SUFFIX}" 69 | git push origin "${GITHUB_REF_NAME}-${SUFFIX}" --force 70 | -------------------------------------------------------------------------------- /.github/workflows/fix.yml: -------------------------------------------------------------------------------- 1 | name: Fix 2 | 3 | on: 4 | pull_request_target: 5 | branches: [master] 6 | workflow_dispatch: 7 | workflow_run: 8 | workflows: 9 | - "Apply" 10 | types: 11 | - completed 12 | 13 | defaults: 14 | run: 15 | shell: bash 16 | 17 | concurrency: 18 | group: fix-${{ github.event.pull_request.number || github.ref }} 19 | cancel-in-progress: true # we only care about the most recent fix run for any given PR/ref 20 | 21 | jobs: 22 | prepare: 23 | # WARN: writing to private forks is not supported 24 | if: github.event_name == 'workflow_dispatch' || 25 | github.event_name == 'pull_request_target' || 26 | (github.event_name == 'workflow_run' && 27 | github.event.workflow_run.conclusion == 'success') 28 | permissions: 29 | contents: read 30 | pull-requests: read 31 | name: Prepare 32 | runs-on: ubuntu-latest 33 | outputs: 34 | workspaces: ${{ steps.workspaces.outputs.this }} 35 | skip-fix: ${{ steps.skip-fix.outputs.this }} 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - if: github.event_name == 'pull_request_target' 40 | env: 41 | NUMBER: ${{ github.event.pull_request.number }} 42 | SHA: ${{ github.event.pull_request.head.sha }} 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | run: | 45 | git fetch origin "pull/${NUMBER}/head" 46 | rm -rf github && git checkout "${SHA}" -- github 47 | - name: Discover workspaces 48 | id: workspaces 49 | run: echo "this=$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" >> $GITHUB_OUTPUT 50 | - name: Check last commit 51 | id: skip-fix 52 | env: 53 | SHA: ${{ github.event.pull_request.head.sha || github.sha }} 54 | run: | 55 | # this workflow doesn't continue if the last commit has [skip fix] suffix or there are no user defined fix rules 56 | if [[ "$(git log --format=%B -n 1 "${SHA}" | head -n 1)" == *"[skip fix]" ]] || ! test -f scripts/src/actions/fix-yaml-config.ts 2> /dev/null; then 57 | echo "this=true" >> $GITHUB_OUTPUT 58 | else 59 | echo "this=false" >> $GITHUB_OUTPUT 60 | fi 61 | fix: 62 | needs: [prepare] 63 | if: needs.prepare.outputs.skip-fix == 'false' 64 | permissions: 65 | contents: read 66 | pull-requests: write 67 | strategy: 68 | fail-fast: false 69 | matrix: 70 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces || '[]') }} 71 | name: Fix 72 | runs-on: ubuntu-latest 73 | env: 74 | TF_IN_AUTOMATION: 1 75 | TF_INPUT: 0 76 | TF_WORKSPACE: ${{ matrix.workspace }} 77 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 78 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 79 | GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }} 80 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }} 81 | GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} 82 | TF_VAR_write_delay_ms: 300 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@v4 86 | - if: github.event_name == 'pull_request_target' 87 | env: 88 | NUMBER: ${{ github.event.pull_request.number }} 89 | SHA: ${{ github.event.pull_request.head.sha }} 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | run: | 92 | # only checking out github directory from the PR 93 | git fetch origin "pull/${NUMBER}/head" 94 | rm -rf github && git checkout "${SHA}" -- github 95 | - name: Setup terraform 96 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 97 | with: 98 | terraform_version: 1.12.0 99 | terraform_wrapper: false 100 | - name: Initialize terraform 101 | run: terraform init 102 | working-directory: terraform 103 | - name: Initialize scripts 104 | run: npm ci && npm run build 105 | working-directory: scripts 106 | - name: Fix 107 | id: fix 108 | run: node lib/actions/fix-yaml-config.js 109 | working-directory: scripts 110 | - name: Upload YAML config 111 | uses: actions/upload-artifact@v4 112 | with: 113 | name: ${{ env.TF_WORKSPACE }}.yml 114 | path: github/${{ env.TF_WORKSPACE }}.yml 115 | if-no-files-found: error 116 | retention-days: 1 117 | # NOTE(galargh, 2024-02-15): This will only work if GitHub as Code is used for a single organization 118 | - name: Comment on pull request 119 | if: github.event_name == 'pull_request_target' && steps.fix.outputs.comment 120 | uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 121 | with: 122 | header: fix 123 | number: ${{ github.event.pull_request.number }} 124 | message: ${{ steps.fix.outputs.comment }} 125 | 126 | push: 127 | needs: [prepare, fix] 128 | permissions: 129 | contents: read 130 | name: Push 131 | runs-on: ubuntu-latest 132 | env: 133 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 134 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 135 | steps: 136 | - name: Generate app token 137 | id: token 138 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 139 | with: 140 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 141 | installation_retrieval_mode: id 142 | installation_retrieval_payload: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 143 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 144 | - name: Checkout 145 | uses: actions/checkout@v4 146 | with: 147 | repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} 148 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 149 | token: ${{ steps.token.outputs.token }} 150 | path: head 151 | - name: Checkout 152 | uses: actions/checkout@v4 153 | with: 154 | path: base 155 | - name: Download YAML configs 156 | uses: actions/download-artifact@v4 157 | with: 158 | path: artifacts 159 | - name: Copy YAML configs 160 | run: cp artifacts/**/*.yml head/github 161 | - name: Check if github was modified 162 | id: github-modified 163 | run: | 164 | if [ -z "$(git status --porcelain -- github)" ]; then 165 | echo "this=false" >> $GITHUB_OUTPUT 166 | else 167 | echo "this=true" >> $GITHUB_OUTPUT 168 | fi 169 | working-directory: head 170 | - uses: ./base/.github/actions/git-config-user 171 | if: steps.github-modified.outputs.this == 'true' 172 | - if: steps.github-modified.outputs.this == 'true' 173 | run: | 174 | git add --all -- github 175 | git commit -m "fix@${GITHUB_RUN_ID} [skip fix]" 176 | working-directory: head 177 | - if: steps.github-modified.outputs.this == 'true' && github.event_name == 'pull_request_target' 178 | env: 179 | REF: ${{ github.event.pull_request.head.ref }} 180 | run: | 181 | git checkout -B "${REF}" 182 | git push origin "${REF}" 183 | working-directory: head 184 | - if: steps.github-modified.outputs.this == 'true' && github.event_name != 'pull_request_target' 185 | uses: ./base/.github/actions/git-push 186 | env: 187 | GITHUB_TOKEN: ${{ steps.token.outputs.token }} 188 | with: 189 | suffix: fix 190 | working-directory: head 191 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Labels 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | source: 7 | description: 'The source repository to sync labels from' 8 | required: true 9 | targets: 10 | description: 'The target repositories to sync labels to (comma-separated)' 11 | required: true 12 | add: 13 | description: 'Whether to add labels to the target repositories' 14 | required: false 15 | default: true 16 | remove: 17 | description: 'Whether to remove labels from the target repositories' 18 | required: false 19 | default: false 20 | 21 | defaults: 22 | run: 23 | shell: bash 24 | 25 | jobs: 26 | sync: 27 | permissions: 28 | contents: read 29 | name: Sync 30 | runs-on: ubuntu-latest 31 | env: 32 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 33 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 34 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 35 | TF_WORKSPACE: ${{ github.repository_owner }} 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v4 39 | - name: Initialize scripts 40 | run: npm install && npm run build 41 | working-directory: scripts 42 | - name: Sync 43 | run: node lib/actions/sync-labels.js 44 | working-directory: scripts 45 | env: 46 | SOURCE_REPOSITORY: ${{ github.event.inputs.source }} 47 | TARGET_REPOSITORIES: ${{ github.event.inputs.targets }} 48 | ADD_LABELS: ${{ github.event.inputs.add }} 49 | REMOVE_LABELS: ${{ github.event.inputs.remove }} 50 | -------------------------------------------------------------------------------- /.github/workflows/plan.yml: -------------------------------------------------------------------------------- 1 | name: Plan 2 | 3 | on: 4 | pull_request_target: 5 | branches: [master] # no need to create plans on other PRs because they can be only used after a merge to the default branch 6 | workflow_dispatch: 7 | 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | concurrency: 13 | group: plan-${{ github.event.pull_request.number || github.ref }} 14 | cancel-in-progress: true # we only care about the most recent plan for any given PR/ref 15 | 16 | jobs: 17 | prepare: 18 | permissions: 19 | actions: read 20 | contents: read 21 | pull-requests: read 22 | name: Prepare 23 | runs-on: ubuntu-latest 24 | outputs: 25 | workspaces: ${{ steps.workspaces.outputs.this }} 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - if: github.event_name == 'pull_request_target' 30 | env: 31 | NUMBER: ${{ github.event.pull_request.number }} 32 | SHA: ${{ github.event.pull_request.head.sha }} 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | git fetch origin "pull/${NUMBER}/head" 36 | # we delete github directory first to ensure we only get YAMLs from the PR 37 | rm -rf github && git checkout "${SHA}" -- github 38 | - name: Discover workspaces 39 | id: workspaces 40 | run: echo "this=$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" >> $GITHUB_OUTPUT 41 | - name: Wait for Apply to finish 42 | run: | 43 | while [[ "$(gh api /repos/${{ github.repository }}/actions/workflows/apply.yml/runs --jq '.workflow_runs | map(.status) | map(select(. != "completed")) | length')" != '0' ]]; do 44 | echo "Waiting for all Apply workflow runs to finish..." 45 | sleep 10 46 | done 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | timeout-minutes: 10 50 | plan: 51 | needs: [prepare] 52 | permissions: 53 | contents: read 54 | pull-requests: read 55 | strategy: 56 | fail-fast: false 57 | matrix: 58 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces || '[]') }} 59 | name: Plan 60 | runs-on: ubuntu-latest 61 | env: 62 | TF_IN_AUTOMATION: 1 63 | TF_INPUT: 0 64 | TF_WORKSPACE: ${{ matrix.workspace }} 65 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 66 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 67 | GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }} 68 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }} 69 | GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} 70 | TF_VAR_write_delay_ms: 300 71 | steps: 72 | - name: Checkout 73 | uses: actions/checkout@v4 74 | - if: github.event_name == 'pull_request_target' 75 | env: 76 | NUMBER: ${{ github.event.pull_request.number }} 77 | SHA: ${{ github.event.pull_request.head.sha }} 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | run: | 80 | git fetch origin "pull/${NUMBER}/head" 81 | rm -rf github && git checkout "${SHA}" -- github 82 | - name: Setup terraform 83 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 84 | with: 85 | terraform_version: 1.12.0 86 | terraform_wrapper: false 87 | - name: Initialize terraform 88 | run: terraform init 89 | working-directory: terraform 90 | - name: Plan terraform 91 | run: | 92 | terraform show -json > $TF_WORKSPACE.tfstate.json 93 | terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color 94 | working-directory: terraform 95 | - name: Upload terraform plan 96 | uses: actions/upload-artifact@v4 97 | with: 98 | name: ${{ env.TF_WORKSPACE }}_${{ github.event.pull_request.head.sha || github.sha }}.tfplan 99 | path: terraform/${{ env.TF_WORKSPACE }}.tfplan 100 | if-no-files-found: error 101 | retention-days: 90 102 | comment: 103 | needs: [prepare, plan] 104 | if: github.event_name == 'pull_request_target' 105 | permissions: 106 | contents: read 107 | pull-requests: write 108 | name: Comment 109 | runs-on: ubuntu-latest 110 | env: 111 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 112 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 113 | steps: 114 | - name: Checkout 115 | uses: actions/checkout@v4 116 | - if: github.event_name == 'pull_request_target' 117 | env: 118 | NUMBER: ${{ github.event.pull_request.number }} 119 | SHA: ${{ github.event.pull_request.head.sha }} 120 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 121 | run: | 122 | git fetch origin "pull/${NUMBER}/head" 123 | rm -rf github && git checkout "${SHA}" -- github 124 | - name: Setup terraform 125 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 126 | with: 127 | terraform_version: 1.12.0 128 | terraform_wrapper: false 129 | - name: Initialize terraform 130 | run: terraform init 131 | working-directory: terraform 132 | - name: Download terraform plans 133 | uses: actions/download-artifact@v4 134 | with: 135 | path: terraform 136 | - name: Show terraform plans 137 | run: | 138 | for plan in $(find . -type f -name '*.tfplan'); do 139 | echo "
$(basename "${plan}" '.tfplan')" >> TERRAFORM_PLANS.md 140 | echo '' >> TERRAFORM_PLANS.md 141 | echo '```' >> TERRAFORM_PLANS.md 142 | echo "$(terraform show -no-color "${plan}" 2>&1)" >> TERRAFORM_PLANS.md 143 | echo '```' >> TERRAFORM_PLANS.md 144 | echo '' >> TERRAFORM_PLANS.md 145 | echo '
' >> TERRAFORM_PLANS.md 146 | done 147 | cat TERRAFORM_PLANS.md 148 | working-directory: terraform 149 | - name: Prepare comment 150 | run: | 151 | echo 'COMMENT<> $GITHUB_ENV 152 | if [[ $(wc -c TERRAFORM_PLANS.md | cut -d' ' -f1) -ge 65000 ]]; then 153 | echo "Terraform plans are too long to post as a comment. Please inspect [Plan > Comment > Show terraform plans](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) instead." >> $GITHUB_ENV 154 | else 155 | cat TERRAFORM_PLANS.md >> $GITHUB_ENV 156 | fi 157 | echo 'EOF' >> $GITHUB_ENV 158 | working-directory: terraform 159 | - name: Comment on pull request 160 | uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 161 | with: 162 | header: plan 163 | number: ${{ github.event.pull_request.number }} 164 | message: | 165 | Before merge, verify that all the following plans are correct. They will be applied as-is after the merge. 166 | 167 | #### Terraform plans 168 | ${{ env.COMMENT }} 169 | -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: Sync 2 | 3 | on: 4 | schedule: 5 | - cron: 0 0 * * 0 6 | workflow_dispatch: 7 | inputs: 8 | workspaces: 9 | description: Space separated list of workspaces to sync (leave blank to sync all) 10 | required: false 11 | lock: 12 | description: Whether to acquire terraform state lock during sync 13 | required: false 14 | default: "true" 15 | 16 | jobs: 17 | prepare: 18 | name: Prepare 19 | runs-on: ubuntu-latest 20 | outputs: 21 | workspaces: ${{ steps.workspaces.outputs.this }} 22 | defaults: 23 | run: 24 | shell: bash 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | - name: Discover workspaces 29 | id: workspaces 30 | env: 31 | WORKSPACES: ${{ github.event.inputs.workspaces }} 32 | run: | 33 | if [[ -z "${WORKSPACES}" ]]; then 34 | workspaces="$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" 35 | else 36 | workspaces="$(echo "${WORKSPACES}" | jq --raw-input 'split(" ")')" 37 | fi 38 | echo "this=${workspaces}" >> $GITHUB_OUTPUT 39 | sync: 40 | needs: [prepare] 41 | if: needs.prepare.outputs.workspaces != '' 42 | permissions: 43 | contents: write 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }} 48 | name: Sync 49 | runs-on: ubuntu-latest 50 | env: 51 | TF_IN_AUTOMATION: 1 52 | TF_INPUT: 0 53 | TF_LOCK: ${{ github.event.inputs.lock }} 54 | TF_WORKSPACE_OPT: ${{ matrix.workspace }} 55 | AWS_ACCESS_KEY_ID: ${{ secrets.RW_AWS_ACCESS_KEY_ID }} 56 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RW_AWS_SECRET_ACCESS_KEY }} 57 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 58 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 59 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 60 | TF_VAR_write_delay_ms: 300 61 | defaults: 62 | run: 63 | shell: bash 64 | steps: 65 | - name: Checkout 66 | uses: actions/checkout@v4 67 | - name: Setup terraform 68 | uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 69 | with: 70 | terraform_version: 1.12.0 71 | terraform_wrapper: false 72 | - name: Initialize terraform 73 | run: terraform init -upgrade 74 | working-directory: terraform 75 | - name: Select terraform workspace 76 | run: | 77 | terraform workspace select "${TF_WORKSPACE_OPT}" || terraform workspace new "${TF_WORKSPACE_OPT}" 78 | echo "TF_WORKSPACE=${TF_WORKSPACE_OPT}" >> $GITHUB_ENV 79 | working-directory: terraform 80 | - name: Pull terraform state 81 | run: | 82 | terraform show -json > $TF_WORKSPACE.tfstate.json 83 | working-directory: terraform 84 | - name: Sync 85 | run: | 86 | npm ci 87 | npm run build 88 | npm run main 89 | working-directory: scripts 90 | - uses: ./.github/actions/git-config-user 91 | - env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | run: | 94 | git_branch="${GITHUB_REF_NAME}-sync-${TF_WORKSPACE}" 95 | git checkout -B "${git_branch}" 96 | git add --all 97 | git diff-index --quiet HEAD || git commit --message="sync@${GITHUB_RUN_ID} ${TF_WORKSPACE}" 98 | git push origin "${git_branch}" --force 99 | push: 100 | needs: [prepare, sync] 101 | if: needs.prepare.outputs.workspaces != '' 102 | name: Push 103 | runs-on: ubuntu-latest 104 | defaults: 105 | run: 106 | shell: bash 107 | steps: 108 | - name: Generate app token 109 | id: token 110 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 111 | with: 112 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 113 | installation_retrieval_mode: id 114 | installation_retrieval_payload: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 115 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 116 | - name: Checkout 117 | uses: actions/checkout@v4 118 | with: 119 | token: ${{ steps.token.outputs.token }} 120 | - uses: ./.github/actions/git-config-user 121 | - env: 122 | WORKSPACES: ${{ needs.prepare.outputs.workspaces }} 123 | run: | 124 | echo "${GITHUB_RUN_ID}" > .sync 125 | git add .sync 126 | git commit --message="sync@${GITHUB_RUN_ID}" 127 | while read workspace; do 128 | workspace_branch="${GITHUB_REF_NAME}-sync-${workspace}" 129 | git fetch origin "${workspace_branch}" 130 | git merge --strategy-option=theirs "origin/${workspace_branch}" 131 | git push origin --delete "${workspace_branch}" 132 | done <<< "$(jq -r '.[]' <<< "${WORKSPACES}")" 133 | - run: git push origin "${GITHUB_REF_NAME}" --force 134 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - "Apply" 7 | types: 8 | - completed 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update: 13 | if: (github.event_name == 'workflow_dispatch' && 14 | github.ref_name == github.event.repository.default_branch) || 15 | (github.event_name == 'workflow_run' && 16 | github.event.workflow_run.conclusion == 'success') 17 | name: Update 18 | runs-on: ubuntu-latest 19 | defaults: 20 | run: 21 | shell: bash 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: npm ci && npm run build 25 | working-directory: scripts 26 | - name: Update PRs 27 | env: 28 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 29 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 30 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 31 | run: node lib/actions/update-pull-requests.js 32 | working-directory: scripts 33 | -------------------------------------------------------------------------------- /.github/workflows/upgrade.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ref: 7 | required: false 8 | description: The github-mgmt-template ref to upgrade to 9 | default: master 10 | 11 | jobs: 12 | upgrade: 13 | uses: ipdxco/github-as-code/.github/workflows/upgrade_reusable.yml@master 14 | with: 15 | ref: inputs.ref 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 18 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 19 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 20 | -------------------------------------------------------------------------------- /.github/workflows/upgrade_reusable.yml: -------------------------------------------------------------------------------- 1 | name: Upgrade (reusable) 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ref: 7 | type: string 8 | required: false 9 | description: The github-mgmt-template ref to upgrade to 10 | default: master 11 | secrets: 12 | GITHUB_APP_ID: 13 | required: true 14 | GITHUB_APP_INSTALLATION_ID: 15 | required: true 16 | GITHUB_APP_PEM_FILE: 17 | required: true 18 | 19 | jobs: 20 | upgrade: 21 | name: Upgrade 22 | runs-on: ubuntu-latest 23 | defaults: 24 | run: 25 | shell: bash 26 | steps: 27 | - name: Generate app token 28 | id: token 29 | uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0 30 | with: 31 | app_id: ${{ secrets.GITHUB_APP_ID }} 32 | installation_retrieval_mode: id 33 | installation_retrieval_payload: ${{ secrets.GITHUB_APP_INSTALLATION_ID }} 34 | private_key: ${{ secrets.GITHUB_APP_PEM_FILE }} 35 | - name: Checkout GitHub Management template 36 | uses: actions/checkout@v4 37 | with: 38 | repository: pl-strflt/github-mgmt-template 39 | path: github-mgmt-template 40 | ref: ${{ github.event.inputs.ref }} 41 | - name: Checkout GitHub Management 42 | uses: actions/checkout@v4 43 | with: 44 | path: github-mgmt 45 | token: ${{ steps.token.outputs.token }} 46 | - name: Copy files from the template 47 | run: | 48 | for file in $(git ls-files ':!:github/*.yml' ':!:scripts/src/actions/fix-yaml-config.ts' ':!:terraform/*_override.tf' ':!:.github/dependabot.yml' ':!:.github/workflows/*_reusable.yml' ':!:README.md'); do 49 | mkdir -p "../github-mgmt/$(dirname "${file}")" 50 | cp -f "${file}" "../github-mgmt/${file}" 51 | done 52 | working-directory: github-mgmt-template 53 | - uses: ./github-mgmt-template/.github/actions/git-config-user 54 | - run: | 55 | git add --all 56 | git diff-index --quiet HEAD || git commit --message="upgrade@${GITHUB_RUN_ID}" 57 | working-directory: github-mgmt 58 | - uses: ./github-mgmt-template/.github/actions/git-push 59 | env: 60 | GITHUB_TOKEN: ${{ steps.token.outputs.token }} 61 | with: 62 | suffix: upgrade 63 | working-directory: github-mgmt 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | 11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 12 | # .tfvars files are managed as part of configuration and so should be included in 13 | # version control. 14 | # 15 | # example.tfvars 16 | 17 | # Ignore override files as they are usually used to override resources locally and so 18 | # are not checked in 19 | # override.tf 20 | # override.tf.json 21 | # *_override.tf 22 | # *_override.tf.json 23 | 24 | # Include override files you do wish to add to version control using negated pattern 25 | # 26 | # !example_override.tf 27 | 28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 29 | # example: *tfplan* 30 | *.tfplan 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Added 9 | - shared action for adding a collaborator to all repositories 10 | - clean workflow which removes resources from state 11 | - information on how to handle private GitHub Management repository 12 | - warning about GitHub Management repository access 13 | - PR template 14 | - examples to HOWTOs 15 | - repository_file support 16 | - repository.default_branch support 17 | - weekly schedule to the synchronization workflow 18 | - fix workflow which executes user defined config transforms on PRs and after Apply 19 | - shared config fix rule which adds missing default branch protections 20 | - shared action for adding a file to all repositories 21 | - shared action for adding a label to all repositories 22 | - issue_label support 23 | - new args for repositories and branch protection rules 24 | 25 | ### Changed 26 | - **BREAKING**: added support for efficient labels handling via the `github_issue_labels` resource (please clean `github_issue_label.this.*` from the terraform state and update `locals_override.tf` and `resources_override.tf` before syncing) 27 | - **BREAKING**: upgraded to terraform 1.12.0 and github provider 6.6.0 (please clean `github_branch_protection.this.*` from the terraform state and update `resources_override.tf` before syncing the upgrade) 28 | - **BREAKING**: turned scripts into an ESM project (please ensure you remove the following files during the upgrade: `scripts/.eslintignore`, `scripts/.eslintrc.json`, `scripts/jest.config.js`, `jest.d.ts`, `jest.setup.ts`; please update your imports in the `scripts/src/actions/fix-yaml-config.ts` file to include the `.js` extension) 29 | - **BREAKING**: Updated the signatures of all the shared actions; now the runAction function will persist the changes to disk while action functions will operate on the in-memory state (please update your imports in the `scripts/src/actions/fix-yaml-config.ts` file accordingly) 30 | - Synchronization script: to use GitHub API directly instead of relying on TF GH Provider's Data Sources 31 | - Configuration: replaced multiple JSONs with a single, unified YAML 32 | - Synchronization script: rewrote the script in JS 33 | - Upgrade (reusable) workflow: included docs and CHANGELOG in the upgrades 34 | - README: extracted sections to separate docs 35 | - GitHub Provider: upgraded to v4.23.0 36 | - Upgrade workflows: accept github-mgmt-template ref to upgrade to 37 | - Commit message for repository files: added chore: prefix and [skip ci] suffix 38 | - scripts: to export tf resource definitions and always sort before save 39 | - plan: to be triggered on pull_request_target 40 | - plan: to only check out github directory from the PR 41 | - plan: to wait for Apply workflow runs to finish 42 | - defaults: not to ignore any properties by default 43 | - add-file-to-all-repos: to accept a repo filter instead of an repo exclude list 44 | - sync: to push changes directly to the branch 45 | - automated commit messages: to include github run id information 46 | - apply: not to use deprecated GitHub API anymore 47 | - workflows: not to use deprecated GitHub Actions runners anymore 48 | - workflows: not to use deprecated GitHub Actions expressions anymore 49 | - tf: to prevent destroy of membership and repository resources 50 | - apply: find sha for plan using proper credentials 51 | - updated upload and download artifacts actions to v4 52 | 53 | ### Fixed 54 | - include labels in the config resources only if they are explicitly defined in the config 55 | - always assert state type when creating resources from state 56 | - do not break long file content lines 57 | - source TF_WORKING_DIR from env helper instead of process.env in locals helper 58 | - fixed how terraform state is accessed before it the initial synchronization 59 | - links to supported resources in HOWTOs 60 | - posting PR comments when terraform plan output is very long 61 | - PR parsing in the update workflow 62 | - array head retrieval in scripts 63 | - team imports 64 | - parent_team_id retrieval from state 65 | - saving config sync result 66 | - how dry run flag is passed in the clean workflow 67 | - how sync invalidates PR plans 68 | - support for pull_request_bypassers in branch protection rules 69 | - how repository files are imported 70 | - how sync handles ignored types 71 | - how indices are represented in the state (always lowercase) 72 | - how sync handles pending invitations (now it does not ignore them) 73 | - removed references to other resources from for_each expressions 74 | - downgraded terraform to 1.2.9 to fix an import bug affecting for_each expressions 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![Management Banner](https://github.com/pl-strflt/github-mgmt-template/assets/6688074/15b2101a-149a-4556-a1b5-497b50d77813) 4 | 5 | # GitHub Management as Code 6 | 7 | A robust template for managing GitHub configurations as code via Pull Requests. Designed with the effective administration of multiple medium to large-sized GitHub organizations in mind. 8 | 9 |
10 | 11 | ## Importance of Access Control 12 | 13 | **NOTE**: Possessing write access to the GitHub Management repository can equate to having administrative privileges over the organizations managed by that repository. Please exercise caution and ensure proper access control measures. 14 | 15 | ## Dive Deeper 16 | 17 | - Learn more about [what GitHub Management is and how it works](docs/ABOUT.md). 18 | - Get started with [setting up GitHub Management](docs/SETUP.md). 19 | - Uncover [how to effectively utilize GitHub Management](docs/HOWTOS.md). 20 | 21 | ## Active Users 22 | 23 | A broad range of diverse organizations are already leveraging our GitHub Management solution via Pull Request Workflow. You can find a comprehensive list of these organizations on our [GitHub Management Router repository](https://github.com/pl-strflt/github-mgmt-router). This list showcases the versatility and effectiveness of our GitHub Management system. Our solution can be tailored to meet your unique requirements, driving efficiency and streamlined GitHub organization management. 24 | -------------------------------------------------------------------------------- /docs/ABOUT.md: -------------------------------------------------------------------------------- 1 | # Key features 2 | 3 | - 2-way sync between GitHub Management and the actual GitHub configuration (including bootstrapping) 4 | - PR-based configuration change review process which guarantees the reviewed plan is the one being applied 5 | - control over what resources and what properties are managed by GitHub Management 6 | - auto-upgrades from the template repository 7 | 8 | # How does it work? 9 | 10 | GitHub Management allows management of GitHub configuration as code. It uses Terraform and GitHub Actions to achieve this. 11 | 12 | A GitHub organization is configured through a YAML configuration file - [github/$ORGANIZATION_NAME.yml](../github/$ORGANIZATION_NAME.yml). GitHub Management lets you manage multiple organizations from a single repository. It uses separate terraform workspaces for each organisation. The workspace names are the same as the organization names. Each workspace has its state hosted in the remote [S3 backend](https://www.terraform.io/language/settings/backends/s3). 13 | 14 | The configuration files follow [github/.schema.json](../github/.schema.json) schema. You can configure your editor to validate the schema for you, e.g. [a plugin for VS Code](https://github.com/redhat-developer/vscode-yaml). 15 | 16 | You can have a look at an [EXAMPLE.yml](./EXAMPLE.yml) which defines all the resources with all the attributes that can be managed through GitHub Management. 17 | 18 | Whether resources of a specific type are managed via GitHub Management or not is controlled through [resource_types] array in [terraform/locals_override.tf](../terraform/locals_override.tf). It accepts [supported resource](#supported-resources) names: 19 | 20 | Which properties of a resource are managed via GitHub Management is controlled through `lifecycle.ignore_changes` array in [terraform/resources_override.tf](../terraform/resources_override.tf) with a fallback to [terraform/resources.tf](../terraform/resources.tf). By default all but required properties are ignored. 21 | 22 | GitHub Management is capable of both applying the changes made to the YAML configuration to GitHub and of translating the current GitHub configuration state back into the YAML configuration file. 23 | 24 | The workflow for introducing changes to GitHub via YAML configuration file is as follows: 25 | 1. Modify the YAML configuration file. 26 | 1. Create a PR and wait for the GitHub Action workflow triggered on PRs to comment on it with a terraform plan. 27 | 1. Review the plan. 28 | 1. Merge the PR and wait for the GitHub Action workflow triggered on pushes to the default branch to apply it. 29 | 30 | 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 applied exactly as-is after the merge. 31 | 32 | The workflow for synchronising the current GitHub configuration state with YAML configuration file is as follows: 33 | 1. Run the `Sync` GitHub Action workflow and wait for the PR to be created. 34 | 1. If a PR was created, wait for the GitHub Action workflow triggered on PRs to comment on it with a terraform plan. 35 | 1. Ensure that the plan introduces no changes. 36 | 1. Merge the PR. 37 | 38 | Running the `Sync` GitHub Action workflows refreshes the underlying terraform state. It also automatically imports all the resources that were created outside GitHub Management into the state (except for `github_repository_file`s) and removes any that were deleted. After the `Sync` flow, all the other open PRs will have their GitHub Action workflows rerun (thanks to the `Update` workflow) because merging them without it would result in the application of their plans to fail due to the plans being created against a different state. 39 | 40 | # Supported Resources 41 | 42 | - [github_membership](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/membership) 43 | - [github_repository](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository) 44 | - [github_repository_collaborator](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator) 45 | - [github_branch_protection](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection) 46 | - [github_team](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team) 47 | - [github_team_repository](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) 48 | - [github_team_membership](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_membership) 49 | - [github_repository_file](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file) 50 | - [github_issue_labels](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_labels) 51 | 52 | # Config Fix Rules 53 | 54 | With GitHub Management, you can write config fix rules in TypeScript. Your code will get executed by the `Fix` workflow on each PR (if the repository isn't private) and after each `Apply` workflow run. If your code execution results in any changes to the YAML configuration files, they will be either pushed directly in case of PRs or proposed through PRs otherwise. 55 | 56 | Config fix rules have to be put inside `scripts/src/actions/fix-yaml-config.ts` file. Look around `scripts/src` to find useful abstractions for YAML manipulation. You can also browse through a catalog of ready-made rules in `scripts/src/actions/shared`. 57 | 58 | You can instruct GitHub Management to skip `Fix` workflow execution on your commit by adding a `[skip fix]` suffix to the first line of your commit message. 59 | -------------------------------------------------------------------------------- /docs/EXAMPLE.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=../github/.schema.json 2 | 3 | members: # This group defines org members (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/membership) 4 | admin: # This array defines org members with role=admin 5 | - peter # This is a GitHub username 6 | member: # This array defines org members with role=member 7 | - adam 8 | teams: # This group defines teams (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team) 9 | employees: {} 10 | developers: 11 | members: # This group defines team members (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_membership) 12 | maintainer: # This array defines team members with role=maintainer 13 | - peter 14 | member: # This member defines team members with role=member 15 | - adam 16 | description: Developers Team 17 | parent_team_id: employees # This field, unlike its terraform counterpart, accepts a team name 18 | privacy: closed # This field accepts either secret or closed (i.e. visible) - https://docs.github.com/en/organizations/organizing-members-into-teams/changing-team-visibility 19 | repositories: # This group defines repositories (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository) 20 | github-mgmt: 21 | files: # This group defines repository files (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_file) 22 | README.md: 23 | content: README.md # This field accepts either a relative path to a file from ./files directory... 24 | docs/HELLO.md: 25 | content: | # ... or a content string 26 | Hi! 27 | branch_protection: # This group defines branch protection rules (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection) 28 | master: # This key accepts only EXACT branch names, unlike the terraform resource which accepts any pattern 29 | allows_deletions: false 30 | allows_force_pushes: false 31 | enforce_admins: false 32 | require_conversation_resolution: false 33 | require_signed_commits: false 34 | required_linear_history: false 35 | required_pull_request_reviews: 36 | dismiss_stale_reviews: false 37 | dismissal_restrictions: [] # This field accepts node IDs (TODO: make this field accept human friendly names too) 38 | pull_request_bypassers: [] # This field accepts node IDs (TODO: make this field accept human friendly names too) 39 | require_code_owner_reviews: false 40 | required_approving_review_count: 1 41 | restrict_dismissals: false 42 | required_status_checks: 43 | contexts: 44 | - Comment 45 | strict: true 46 | collaborators: # This group defines repository collaborators (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator) 47 | admin: # This array defines repository collaborators with permission=admin 48 | - peter 49 | maintain: # This array defines repository collaborators with permission=maintain 50 | - adam 51 | push: [] # This array defines repository collaborators with permission=push 52 | triage: [] # This array defines repository collaborators with permission=triage 53 | pull: [] # This array defines repository collaborators with permission=pull 54 | teams: # This group defines teams with access to the repository (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) 55 | admin: [] # This array defines teams with permission=admin 56 | maintain: [] # This array defines teams with permission=maintain 57 | push: # This array defines teams with permission=push 58 | - developers 59 | triage: # This array defines teams with permission=triage 60 | - employees 61 | pull: [] # This array defines teams with permission=pull 62 | allow_merge_commit: true 63 | allow_rebase_merge: true 64 | allow_squash_merge: true 65 | has_downloads: true 66 | has_issues: true 67 | has_projects: true 68 | has_wiki: true 69 | visibility: public # This field accepts either public or private 70 | allow_auto_merge: false 71 | archived: false 72 | auto_init: false 73 | default_branch: master 74 | delete_branch_on_merge: false 75 | description: GitHub Management 76 | homepage_url: https://github.com/pl-strflt/github-mgmt-template 77 | is_template: false 78 | vulnerability_alerts: false 79 | archive_on_destroy: true 80 | gitignore_template: Terraform # This field accepts a name of a template from https://github.com/github/gitignore without extension 81 | ignore_vulnerability_alerts_during_read: false 82 | license_template: mit # This field accepts a name of a template from https://github.com/github/choosealicense.com/tree/gh-pages/_licenses without extension 83 | pages: 84 | cname: "" 85 | source: 86 | branch: master 87 | path: /docs 88 | template: 89 | owner: pl-strflt 90 | repository: github-mgmt-template 91 | topics: 92 | - github 93 | -------------------------------------------------------------------------------- /docs/HOWTOS.md: -------------------------------------------------------------------------------- 1 | ## How to... 2 | 3 | ### ...create a new resource? 4 | 5 | *NOTE*: You do not have to specify all the attributes when creating a new resource. If you don't, defaults as defined by the [GitHub Provider](https://registry.terraform.io/providers/integrations/github/latest/docs) will be used. The next `Sync` will fill out the remaining attributes in the YAML configuration file. 6 | 7 | *NOTE*: When creating a new resource, you can specify all the attributes that the resource supports even if changes to them are ignored. If you do specify attributes to which changes are ignored, their values are going to be applied during creation but a future `Sync` will remove them from YAML configuration file. 8 | 9 | - Add a new entry to the YAML configuration file - see [EXAMPLE.yml](EXAMPLE.yml) for inspiration 10 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) to create your newly added resource 11 | 12 | *Example* 13 | 14 | I want to invite `galargh` as an admin to `pl-strflt` organization through GitHub Management. 15 | 16 | I ensure the YAML configuration file has the following entry: 17 | ```yaml 18 | members: 19 | admin: 20 | - galargh 21 | ``` 22 | 23 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges it if everything looks OK. 24 | 25 | ### ...modify an existing resource? 26 | 27 | - Change the value of an attribute in the YAML configuration file - see [EXAMPLE.yml](EXAMPLE.yml) for inspiration 28 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) to create your newly added resource 29 | 30 | *Example* 31 | 32 | I want to demote `galargh` from being an `admin` of `pl-strflt` organization to a regular `member` through GitHub Management. 33 | 34 | I change the entry for `galargh` in the YAML configuration file from: 35 | ```yaml 36 | members: 37 | admin: 38 | - galargh 39 | ``` 40 | to: 41 | ```yaml 42 | members: 43 | member: 44 | - galargh 45 | ``` 46 | 47 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges it if everything looks OK. 48 | 49 | ### ...start managing new resource type with GitHub Management? 50 | 51 | - Add one of the [supported resources](ABOUT.md#supported-resources) names to the `resource_types` array in [terraform/locals_override.tf](../terraform/locals_override.tf) 52 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) - *the plan should not contain any changes* 53 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github) to import all the resources you want to manage for the organization 54 | 55 | *Example* 56 | 57 | I want to be able to configure who the member of the `pl-strflt` organization is through GitHub Management. 58 | 59 | I add `github_membership` to `resource_types` array in [terraform/locals_override.tf](../terraform/locals_override.tf). I push my changes to a new branch and create a PR. An admin reviews the PR and merges the PR if everything looks OK. Then, they synchronize GitHub Management with GitHub configuration. 60 | 61 | ### ...stop managing a resource attribute through GitHub Management? 62 | 63 | - If it doesn't exist yet, create an entry for the resource in [terraform/resources_override.tf](../terraform/resources_override.tf) and copy the `lifecycle.ignore_changes` block from the corresponding resource in [terraform/resources.tf](../terraform/resources.tf) 64 | - Add the attribute name to the `lifecycle.ignore_changes` block of the resource 65 | - Follow [How to apply GitHub Management changes to GitHub?](#apply-github-management-changes-to-github) - *the plan should not contain any changes* 66 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github) to remove all the resource attributes you do not want to manage for the organization anymore 67 | 68 | *Example* 69 | 70 | I do not want to configure the roles of `pl-strflt` organization members through GitHub Management anymore. 71 | 72 | I ensure that `terraform/resources_override.tf` contains the following entry: 73 | ```tf 74 | resource "github_membership" "this" { 75 | lifecycle { 76 | # @resources.membership.ignore_changes 77 | ignore_changes = [ 78 | role 79 | ] 80 | } 81 | } 82 | ``` 83 | 84 | I push my changes to a new branch and create a PR. An admin reviews the PR and merges the PR if everything looks OK. Then, they synchronize GitHub Management with GitHub configuration. 85 | 86 | ### ...apply GitHub Management changes to GitHub? 87 | 88 | - [Create a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) from the branch to the default branch 89 | - Merge the pull request once the `Comment` check passes and you verify the plan posted as a comment 90 | - Confirm that the `Apply` GitHub Action workflow run applied the plan by inspecting the output 91 | 92 | ### ...synchronize GitHub Management with GitHub? 93 | 94 | *NOTE*: Remember that the `Sync` operation modifes terraform state. Even if you run it from a branch, it modifies the global state that is shared with other branches. There is only one terraform state per organization. 95 | 96 | *NOTE*: `Sync` will force push changes directly to the branch you run it from. 97 | 98 | - Run `Sync` GitHub Action workflow from your desired `branch` - *this will import all the resources from the actual GitHub configuration state into GitHub Management* 99 | 100 | ### ...upgrade GitHub Management? 101 | 102 | - Run `Upgrade` GitHub Action workflow 103 | - Merge the pull request that the workflow created once the `Comment` check passes and you verify the plan posted as a comment - *the plan should not contain any changes* 104 | 105 | ### ...remove resources from GitHub Management state? 106 | 107 | - Run `Clean` GitHub Action workflow with a chosen regex 108 | - Follow [How to synchronize GitHub Management with GitHub?](#synchronize-github-management-with-github) 109 | 110 | ### ...add a new config fix rule? 111 | 112 | - Create or modify `scripts/src/actions/fix-yaml-config.ts` file 113 | 114 | *Example* 115 | 116 | I want to ensure that all the public repositories in my organization have their default branches protected. 117 | 118 | To do that, I ensure the following content is present in `scripts/src/actions/fix-yaml-config.ts`: 119 | ```ts 120 | import 'reflect-metadata' 121 | import { runProtectDefaultBranches } from './shared/protect-default-branches' 122 | 123 | runProtectDefaultBranches() 124 | ``` 125 | -------------------------------------------------------------------------------- /files/README.md: -------------------------------------------------------------------------------- 1 | Put files that you want to distribute through GitHub Management here. 2 | 3 | In `repositories.*.files.*.content`, put a relative path to the file. E.g. a reference to this file would be just `README.md`. 4 | 5 | You can create a tree directory structure here. 6 | -------------------------------------------------------------------------------- /github/$ORGANIZATION_NAME.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=.schema.json 2 | 3 | {} 4 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | 101 | # VSCode 102 | .vscode 103 | -------------------------------------------------------------------------------- /scripts/.prettierignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /scripts/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/.gitignore: -------------------------------------------------------------------------------- 1 | !terraform.tfstate 2 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/files/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Management via Terraform: pl-strflt 2 | 3 | This repository is responsible for managing GitHub configuration of `pl-strflt` organisation as code with Terraform. It was created from [github-mgmt-template](https://github.com/pl-strflt/github-mgmt-template) and it will receive updates from that repository. 4 | 5 | **IMPORTANT**: Having write access to GitHub Management repository can be as powerful as having admin access to the organizations managed by that repository. 6 | 7 | *NOTE*: Because we don't have merge queue functionality enabled for the repository yet, after a merge, wait for the `Apply` and `Update` workflows to complete before merging any other PRs. 8 | 9 | To learn more, check out: 10 | - [What is GitHub Management and how does it work?](docs/ABOUT.md) 11 | - [How to set up GitHub Management?](docs/SETUP.md) 12 | - [How to work with GitHub Management?](docs/HOWTOS.md) 13 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/github/default.yml: -------------------------------------------------------------------------------- 1 | members: 2 | admin: 3 | - galargh 4 | - laurentsenta 5 | repositories: 6 | github-action-releaser: 7 | advanced_security: false 8 | allow_auto_merge: false 9 | allow_merge_commit: true 10 | allow_rebase_merge: true 11 | allow_squash_merge: true 12 | archived: false 13 | auto_init: false 14 | default_branch: main 15 | delete_branch_on_merge: false 16 | description: Release steps for github actions. 17 | has_downloads: true 18 | has_issues: true 19 | has_projects: true 20 | has_wiki: true 21 | is_template: false 22 | labels: 23 | topic dx: 24 | color: '#57cc2c' 25 | description: Topic DX 26 | topic/ci: 27 | color: '#57cc2c' 28 | description: Topic CI 29 | secret_scanning_push_protection: false 30 | secret_scanning: false 31 | teams: 32 | maintain: 33 | - ipdx 34 | visibility: public 35 | vulnerability_alerts: false 36 | github-mgmt: 37 | advanced_security: false 38 | allow_auto_merge: false 39 | allow_merge_commit: true 40 | allow_rebase_merge: true 41 | allow_squash_merge: true 42 | archived: false 43 | auto_init: false 44 | branch_protection: 45 | master: 46 | allows_deletions: false 47 | allows_force_pushes: false 48 | enforce_admins: false 49 | require_conversation_resolution: false 50 | require_signed_commits: false 51 | required_linear_history: false 52 | required_pull_request_reviews: 53 | dismiss_stale_reviews: false 54 | require_code_owner_reviews: false 55 | required_approving_review_count: 1 56 | restrict_dismissals: false 57 | required_status_checks: 58 | contexts: 59 | - Plan 60 | strict: true 61 | collaborators: 62 | admin: 63 | - galargh 64 | default_branch: master 65 | delete_branch_on_merge: true 66 | files: 67 | README.md: 68 | content: README.md 69 | overwrite_on_create: false 70 | has_downloads: true 71 | has_issues: true 72 | has_projects: false 73 | has_wiki: false 74 | is_template: false 75 | secret_scanning_push_protection: false 76 | secret_scanning: false 77 | teams: 78 | triage: 79 | - ipdx 80 | template: 81 | owner: pl-strflt 82 | repository: github-mgmt-template 83 | visibility: public 84 | vulnerability_alerts: false 85 | ipdx: 86 | advanced_security: false 87 | allow_auto_merge: false 88 | allow_merge_commit: true 89 | allow_rebase_merge: true 90 | allow_squash_merge: true 91 | archived: false 92 | auto_init: false 93 | default_branch: main 94 | delete_branch_on_merge: false 95 | has_downloads: true 96 | has_issues: true 97 | has_projects: true 98 | has_wiki: true 99 | is_template: false 100 | secret_scanning_push_protection: false 101 | secret_scanning: false 102 | teams: 103 | admin: 104 | - ipdx 105 | visibility: public 106 | vulnerability_alerts: false 107 | projects-migration: 108 | advanced_security: false 109 | allow_auto_merge: false 110 | allow_merge_commit: true 111 | allow_rebase_merge: true 112 | allow_squash_merge: true 113 | archived: false 114 | auto_init: false 115 | default_branch: main 116 | delete_branch_on_merge: false 117 | has_downloads: true 118 | has_issues: true 119 | has_projects: true 120 | has_wiki: true 121 | is_template: false 122 | pages: 123 | source: 124 | branch: main 125 | path: /docs 126 | secret_scanning_push_protection: false 127 | secret_scanning: false 128 | teams: 129 | maintain: 130 | - ipdx 131 | topics: 132 | - github 133 | - graphql 134 | visibility: public 135 | vulnerability_alerts: false 136 | projects-status-history: 137 | advanced_security: false 138 | allow_auto_merge: false 139 | allow_merge_commit: true 140 | allow_rebase_merge: true 141 | allow_squash_merge: true 142 | archived: false 143 | auto_init: false 144 | default_branch: main 145 | delete_branch_on_merge: false 146 | has_downloads: true 147 | has_issues: true 148 | has_projects: true 149 | has_wiki: true 150 | is_template: false 151 | labels: 152 | stale: 153 | color: '#57cc2c' 154 | description: Stale 155 | secret_scanning_push_protection: false 156 | secret_scanning: false 157 | teams: 158 | maintain: 159 | - ipdx 160 | visibility: public 161 | vulnerability_alerts: false 162 | rust-sccache-action: 163 | archived: true 164 | tf-aws-gh-runner: 165 | advanced_security: false 166 | allow_auto_merge: false 167 | allow_merge_commit: true 168 | allow_rebase_merge: true 169 | allow_squash_merge: true 170 | archived: false 171 | auto_init: false 172 | default_branch: main 173 | delete_branch_on_merge: false 174 | has_downloads: true 175 | has_issues: true 176 | has_projects: true 177 | has_wiki: true 178 | is_template: false 179 | secret_scanning_push_protection: false 180 | secret_scanning: false 181 | teams: 182 | maintain: 183 | - ipdx 184 | visibility: public 185 | vulnerability_alerts: false 186 | teams: 187 | ipdx: 188 | members: 189 | maintainer: 190 | - galargh 191 | - laurentsenta 192 | parent_team_id: w3dt-stewards 193 | privacy: closed 194 | w3dt-stewards: 195 | privacy: closed 196 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/locals.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipdxco/github-as-code/d62014d5e32935b976875b01410da76a9d2be395/scripts/__tests__/__resources__/terraform/locals.tf -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/locals_override.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | resource_types = [ 3 | "github_membership", 4 | "github_repository_collaborator", 5 | "github_repository", 6 | "github_team_membership", 7 | "github_team_repository", 8 | "github_team", 9 | "github_branch_protection", 10 | "github_repository_file", 11 | "github_issue_labels" 12 | ] 13 | ignore = { 14 | "repositories" = ["ignored"] 15 | "teams" = ["ignored"] 16 | "users" = ["ignored"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/resources.tf: -------------------------------------------------------------------------------- 1 | resource "github_membership" "this" { 2 | lifecycle { 3 | ignore_changes = [] 4 | } 5 | } 6 | resource "github_repository" "this" { 7 | lifecycle { 8 | ignore_changes = [] 9 | } 10 | } 11 | resource "github_repository_collaborator" "this" { 12 | lifecycle { 13 | ignore_changes = [] 14 | } 15 | } 16 | resource "github_branch_protection" "this" { 17 | lifecycle { 18 | ignore_changes = [] 19 | } 20 | } 21 | resource "github_team" "this" { 22 | lifecycle { 23 | ignore_changes = [] 24 | } 25 | } 26 | resource "github_team_repository" "this" { 27 | lifecycle { 28 | ignore_changes = [] 29 | } 30 | } 31 | resource "github_team_membership" "this" { 32 | lifecycle { 33 | ignore_changes = [] 34 | } 35 | } 36 | resource "github_repository_file" "this" { 37 | lifecycle { 38 | ignore_changes = [] 39 | } 40 | } 41 | resource "github_issue_labels" "this" { 42 | lifecycle { 43 | ignore_changes = [] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/resources_override.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipdxco/github-as-code/d62014d5e32935b976875b01410da76a9d2be395/scripts/__tests__/__resources__/terraform/resources_override.tf -------------------------------------------------------------------------------- /scripts/__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {before, describe, it} from 'node:test' 4 | import assert from 'node:assert' 5 | import {mockGitHub} from './github.js' 6 | import {GitHub} from '../src/github.js' 7 | 8 | describe('github', () => { 9 | let github: GitHub 10 | 11 | before(async () => { 12 | mockGitHub({ 13 | invitations: [ 14 | { 15 | login: 'ignored' 16 | }, 17 | { 18 | login: 'unignored' 19 | } 20 | ], 21 | members: [ 22 | { 23 | login: 'ignored' 24 | }, 25 | { 26 | login: 'unignored' 27 | } 28 | ], 29 | repositories: [ 30 | { 31 | name: 'ignored', 32 | branchProtectionRules: [ 33 | { 34 | pattern: 'ignored' 35 | }, 36 | { 37 | pattern: 'unignored' 38 | } 39 | ], 40 | collaborators: [ 41 | { 42 | login: 'ignored' 43 | }, 44 | { 45 | login: 'unignored' 46 | } 47 | ], 48 | invitations: [ 49 | { 50 | login: 'ignored' 51 | }, 52 | { 53 | login: 'unignored' 54 | } 55 | ], 56 | labels: [ 57 | { 58 | name: 'ignored' 59 | }, 60 | { 61 | name: 'unignored' 62 | } 63 | ] 64 | }, 65 | { 66 | name: 'unignored', 67 | branchProtectionRules: [ 68 | { 69 | pattern: 'ignored' 70 | }, 71 | { 72 | pattern: 'unignored' 73 | } 74 | ], 75 | collaborators: [ 76 | { 77 | login: 'ignored' 78 | }, 79 | { 80 | login: 'unignored' 81 | } 82 | ], 83 | invitations: [ 84 | { 85 | login: 'ignored' 86 | }, 87 | { 88 | login: 'unignored' 89 | } 90 | ], 91 | labels: [ 92 | { 93 | name: 'ignored' 94 | }, 95 | { 96 | name: 'unignored' 97 | } 98 | ] 99 | } 100 | ], 101 | teams: [ 102 | { 103 | name: 'ignored', 104 | members: [ 105 | { 106 | login: 'ignored' 107 | }, 108 | { 109 | login: 'unignored' 110 | } 111 | ], 112 | invitations: [ 113 | { 114 | login: 'ignored' 115 | }, 116 | { 117 | login: 'unignored' 118 | } 119 | ], 120 | repositories: [ 121 | { 122 | name: 'ignored' 123 | }, 124 | { 125 | name: 'unignored' 126 | } 127 | ] 128 | }, 129 | { 130 | name: 'unignored', 131 | members: [ 132 | { 133 | login: 'ignored' 134 | }, 135 | { 136 | login: 'unignored' 137 | } 138 | ], 139 | invitations: [ 140 | { 141 | login: 'ignored' 142 | }, 143 | { 144 | login: 'unignored' 145 | } 146 | ], 147 | repositories: [ 148 | { 149 | name: 'ignored' 150 | }, 151 | { 152 | name: 'unignored' 153 | } 154 | ] 155 | } 156 | ] 157 | }) 158 | github = await GitHub.getGitHub() 159 | }) 160 | 161 | it('listInvitations', async () => { 162 | const invitations = await github.listInvitations() 163 | assert.ok(invitations.length > 0) 164 | assert.ok(!invitations.some(i => i.login === 'ignored')) 165 | }) 166 | 167 | it('listMembers', async () => { 168 | const members = await github.listMembers() 169 | assert.ok(members.length > 0) 170 | assert.ok(!members.some(m => m.user?.login === 'ignored')) 171 | }) 172 | 173 | it('listRepositories', async () => { 174 | const repositories = await github.listRepositories() 175 | assert.ok(repositories.length > 0) 176 | assert.ok(!repositories.some(r => r.name === 'ignored')) 177 | }) 178 | 179 | it('listRepositoryBranchProtectionRules', async () => { 180 | const rules = await github.listRepositoryBranchProtectionRules() 181 | assert.ok(rules.length > 0) 182 | assert.ok(!rules.some(r => r.repository.name === 'ignored')) 183 | // NOTE: Ignoring rules by pattern is not supported yet 184 | assert.ok(rules.some(r => r.branchProtectionRule.pattern === 'ignored')) 185 | }) 186 | 187 | it('listRepositoryCollaborators', async () => { 188 | const collaborators = await github.listRepositoryCollaborators() 189 | assert.ok(collaborators.length > 0) 190 | assert.ok(!collaborators.some(c => c.repository.name === 'ignored')) 191 | assert.ok(!collaborators.some(c => c.collaborator.login === 'ignored')) 192 | }) 193 | 194 | it('listRepositoryInvitations', async () => { 195 | const invitations = await github.listRepositoryInvitations() 196 | assert.ok(invitations.length > 0) 197 | assert.ok(!invitations.some(i => i.repository.name === 'ignored')) 198 | assert.ok(!invitations.some(i => i.invitee?.login === 'ignored')) 199 | }) 200 | 201 | it('listRepositoryLabels', async () => { 202 | const labels = await github.listRepositoryLabels() 203 | assert.ok(labels.length > 0) 204 | assert.ok(!labels.some(l => l.repository.name === 'ignored')) 205 | // NOTE: Ignoring labels by name is not supported yet 206 | assert.ok(labels.some(l => l.label.name === 'ignored')) 207 | }) 208 | 209 | it('listTeams', async () => { 210 | const teams = await github.listTeams() 211 | assert.ok(teams.length > 0) 212 | assert.ok(!teams.some(t => t.name === 'ignored')) 213 | }) 214 | 215 | it('listTeamInvitations', async () => { 216 | const invitations = await github.listTeamInvitations() 217 | assert.ok(invitations.length > 0) 218 | assert.ok(!invitations.some(i => i.team.name === 'ignored')) 219 | assert.ok(!invitations.some(i => i.invitation.login === 'ignored')) 220 | }) 221 | 222 | it('listTeamMembers', async () => { 223 | const members = await github.listTeamMembers() 224 | assert.ok(members.length > 0) 225 | assert.ok(!members.some(m => m.team.name === 'ignored')) 226 | assert.ok(!members.some(m => m.member.login === 'ignored')) 227 | }) 228 | 229 | it('listTeamRepositories', async () => { 230 | const repositories = await github.listTeamRepositories() 231 | assert.ok(repositories.length > 0) 232 | assert.ok(!repositories.some(r => r.team.name === 'ignored')) 233 | assert.ok(!repositories.some(r => r.repository.name === 'ignored')) 234 | }) 235 | }) 236 | -------------------------------------------------------------------------------- /scripts/__tests__/github.ts: -------------------------------------------------------------------------------- 1 | import {mock} from 'node:test' 2 | 3 | export interface GitHubConfig { 4 | repositories?: { 5 | name: string 6 | collaborators?: { 7 | login: string 8 | }[] 9 | branchProtectionRules?: { 10 | pattern: string 11 | }[] 12 | invitations?: { 13 | login: string 14 | }[] 15 | labels?: { 16 | name: string 17 | }[] 18 | }[] 19 | teams?: { 20 | name: string 21 | members?: { 22 | login: string 23 | }[] 24 | invitations?: { 25 | login: string 26 | }[] 27 | repositories?: { 28 | name: string 29 | }[] 30 | }[] 31 | invitations?: { 32 | login: string 33 | }[] 34 | members?: { 35 | login: string 36 | }[] 37 | } 38 | 39 | export function mockGitHub(config: GitHubConfig = {}): void { 40 | mock.module('@octokit/auth-app', { 41 | namedExports: { 42 | createAppAuth: () => () => ({token: undefined}) 43 | } 44 | }) 45 | 46 | mock.module('@octokit/rest', { 47 | namedExports: { 48 | Octokit: { 49 | plugin: () => { 50 | // return Constructor of Octokit-like object 51 | return class { 52 | issues = { 53 | listCommentsForRepo: async () => [], 54 | listForRepo: async () => [], 55 | listLabelsForRepo: async (opts: {repo: string}) => 56 | config?.repositories?.find(r => r.name === opts.repo)?.labels ?? 57 | [] 58 | } 59 | orgs = { 60 | getMembershipForUser: async () => ({ 61 | data: {} 62 | }), 63 | listMembers: async () => config?.members ?? [], 64 | listPendingInvitations: async () => config?.invitations ?? [] 65 | } 66 | pulls = { 67 | listReviewCommentsForRepo: async () => [] 68 | } 69 | repos = { 70 | get: async (opts: {owner: string; repo: string}) => ({ 71 | data: { 72 | owner: { 73 | login: opts.owner 74 | }, 75 | name: opts.repo, 76 | default_branch: 'main' 77 | } 78 | }), 79 | getContent: async (opts: { 80 | owner: string 81 | repo: string 82 | path: string 83 | ref: string 84 | }) => ({ 85 | data: { 86 | path: opts.path, 87 | url: `https://github.com/${opts.owner}/${opts.repo}/blob/${opts.ref}/${opts.path}`, 88 | ref: opts.ref 89 | } 90 | }), 91 | listActivities: async () => [], 92 | listCollaborators: async (opts: {repo: string}) => 93 | config?.repositories?.find(r => r.name === opts.repo) 94 | ?.collaborators ?? [], 95 | listCommitCommentsForRepo: async () => [], 96 | listForOrg: async () => config?.repositories ?? [], 97 | listInvitations: async (opts: {repo: string}) => { 98 | const repository = config?.repositories?.find( 99 | r => r.name === opts.repo 100 | ) 101 | 102 | return repository?.invitations?.map(invitee => ({ 103 | repository, 104 | invitee 105 | })) 106 | } 107 | } 108 | teams = { 109 | getMembershipForUserInOrg: async () => ({ 110 | data: {} 111 | }), 112 | list: async () => 113 | config?.teams?.map(t => ({slug: t.name, ...t})) ?? [], 114 | listMembersInOrg: async (opts: {team_slug: string}) => 115 | config?.teams?.find(t => t.name === opts.team_slug)?.members ?? 116 | [], 117 | listPendingInvitationsInOrg: async (opts: {team_slug: string}) => 118 | config?.teams?.find(t => t.name === opts.team_slug) 119 | ?.invitations ?? [], 120 | listReposInOrg: async (opts: {team_slug: string}) => 121 | config?.teams?.find(t => t.name === opts.team_slug) 122 | ?.repositories ?? [] 123 | } 124 | async paginate( 125 | f: (opts: K) => Promise, 126 | opts: K 127 | ): Promise { 128 | return f(opts) 129 | } 130 | async graphql(query: string): Promise { 131 | // extract owner and repo from query using repository\(owner: \"([^\"]+)\", name: \"([^\"]+)\"\) 132 | const match = query.match( 133 | /repository\(owner: "([^"]+)", name: "([^"]+)"\)/ 134 | ) 135 | if (match === null) { 136 | throw new Error(`Could not find repository in query: ${query}`) 137 | } 138 | const [, , repo] = match 139 | const nodes = 140 | config.repositories?.find(r => r.name === repo) 141 | ?.branchProtectionRules ?? [] 142 | return { 143 | repository: { 144 | branchProtectionRules: { 145 | nodes 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /scripts/__tests__/resources/counts.ts: -------------------------------------------------------------------------------- 1 | import {Member} from '../../src/resources/member.js' 2 | import {RepositoryBranchProtectionRule} from '../../src/resources/repository-branch-protection-rule.js' 3 | import {RepositoryCollaborator} from '../../src/resources/repository-collaborator.js' 4 | import {RepositoryFile} from '../../src/resources/repository-file.js' 5 | import {RepositoryLabel} from '../../src/resources/repository-label.js' 6 | import {RepositoryTeam} from '../../src/resources/repository-team.js' 7 | import {Repository} from '../../src/resources/repository.js' 8 | import {TeamMember} from '../../src/resources/team-member.js' 9 | import {Team} from '../../src/resources/team.js' 10 | 11 | export const ConfigResourceCounts = { 12 | [Member.name]: 2, 13 | [Repository.name]: 7, 14 | [Team.name]: 2, 15 | [RepositoryCollaborator.name]: 1, 16 | [RepositoryBranchProtectionRule.name]: 1, 17 | [RepositoryTeam.name]: 6, 18 | [TeamMember.name]: 2, 19 | [RepositoryFile.name]: 1, 20 | [RepositoryLabel.name]: 3 21 | } 22 | export const ConfigResourcesCount = Object.values(ConfigResourceCounts).reduce( 23 | (a, b) => a + b, 24 | 0 25 | ) 26 | export const StateResourceCounts = { 27 | [Member.name]: 2, 28 | [Repository.name]: 7, 29 | [Team.name]: 2, 30 | [RepositoryCollaborator.name]: 1, 31 | [RepositoryBranchProtectionRule.name]: 1, 32 | [RepositoryTeam.name]: 7, 33 | [TeamMember.name]: 2, 34 | [RepositoryFile.name]: 1, 35 | [RepositoryLabel.name]: 3 36 | } 37 | export const StateResourcesCount = Object.values(StateResourceCounts).reduce( 38 | (a, b) => a + b, 39 | 0 40 | ) 41 | -------------------------------------------------------------------------------- /scripts/__tests__/resources/repository-file.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import {findFileByContent} from '../../src/resources/repository-file.js' 3 | import {describe, it} from 'node:test' 4 | import assert from 'node:assert' 5 | 6 | describe('repository file', () => { 7 | it('finds file by content', async () => { 8 | const filePath = '__tests__/__resources__/files/README.md' 9 | const fileContent = fs.readFileSync(filePath).toString() 10 | const foundFilePath = findFileByContent( 11 | '__tests__/__resources__', 12 | fileContent 13 | ) 14 | assert.equal(foundFilePath, filePath) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /scripts/__tests__/sync.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import * as YAML from 'yaml' 4 | 5 | import {Config} from '../src/yaml/config.js' 6 | import {State} from '../src/terraform/state.js' 7 | import {sync} from '../src/sync.js' 8 | import {RepositoryFile} from '../src/resources/repository-file.js' 9 | import {StateSchema} from '../src/terraform/schema.js' 10 | import {toggleArchivedRepos} from '../src/actions/shared/toggle-archived-repos.js' 11 | import {before, describe, it, mock} from 'node:test' 12 | import assert from 'node:assert' 13 | import {mockGitHub} from './github.js' 14 | 15 | describe('sync', () => { 16 | before(() => { 17 | mockGitHub() 18 | }) 19 | 20 | it('sync', async () => { 21 | const yamlConfig = new Config('{}') 22 | const tfConfig = await State.New() 23 | 24 | const expectedYamlConfig = Config.FromPath() 25 | 26 | await sync(tfConfig, yamlConfig) 27 | await toggleArchivedRepos(tfConfig, yamlConfig) 28 | 29 | yamlConfig.format() 30 | 31 | assert.equal(yamlConfig.toString(), expectedYamlConfig.toString()) 32 | }) 33 | 34 | it('sync new repository file', async () => { 35 | const yamlSource = { 36 | repositories: { 37 | blog: { 38 | files: { 39 | 'README.md': { 40 | content: 'Hello, world!' 41 | } 42 | } 43 | } 44 | } 45 | } 46 | const tfSource: StateSchema = { 47 | values: { 48 | root_module: { 49 | resources: [] 50 | } 51 | } 52 | } 53 | 54 | const yamlConfig = new Config(YAML.stringify(yamlSource)) 55 | const tfConfig = new State(JSON.stringify(tfSource)) 56 | 57 | mock.module('../src/terraform/state.js', { 58 | namedExports: { 59 | loadState: async () => JSON.stringify(tfSource) 60 | } 61 | }) 62 | 63 | const resources = [ 64 | { 65 | mode: 'managed', 66 | index: 'blog:README.md', 67 | address: 'github_repository_file.this["blog/readme.md"]', 68 | type: RepositoryFile.StateType, 69 | values: { 70 | repository: 'blog', 71 | file: 'README.md', 72 | content: 'Hello, world!' 73 | } 74 | } 75 | ] 76 | 77 | tfConfig.addResourceAt = async (_id: string, address: string) => { 78 | const resource = resources.find(r => r.address === address) 79 | if (resource !== undefined) { 80 | tfSource?.values?.root_module?.resources?.push(resource) 81 | } 82 | } 83 | 84 | const expectedYamlConfig = new Config(YAML.stringify(yamlSource)) 85 | 86 | await sync(tfConfig, yamlConfig) 87 | 88 | yamlConfig.format() 89 | 90 | assert.equal(yamlConfig.toString(), expectedYamlConfig.toString()) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /scripts/__tests__/terraform/state.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {State} from '../../src/terraform/state.js' 4 | import {Resource, ResourceConstructors} from '../../src/resources/resource.js' 5 | import {Repository} from '../../src/resources/repository.js' 6 | import {Id} from '../../src/terraform/schema.js' 7 | import {describe, it, mock} from 'node:test' 8 | import assert from 'node:assert' 9 | import {StateResourceCounts, StateResourcesCount} from '../resources/counts.js' 10 | 11 | describe('state', () => { 12 | it('can retrieve resources from tf state', async () => { 13 | const config = await State.New() 14 | 15 | const resources = [] 16 | for (const resourceClass of ResourceConstructors) { 17 | const classResources = config.getResources(resourceClass) 18 | assert.equal( 19 | classResources.length, 20 | StateResourceCounts[resourceClass.name] 21 | ) 22 | resources.push(...classResources) 23 | } 24 | 25 | assert.equal(resources.length, StateResourcesCount) 26 | }) 27 | 28 | it('can ignore resource types', async () => { 29 | const state = await State.New() 30 | 31 | assert.equal(await state.isIgnored(Repository), false) 32 | 33 | mock.module('../../src/terraform/locals.js', { 34 | namedExports: { 35 | Locals: { 36 | getLocals: () => { 37 | return { 38 | resource_types: [] 39 | } 40 | } 41 | } 42 | } 43 | }) 44 | 45 | await state.refresh() 46 | 47 | assert.equal(await state.isIgnored(Repository), true) 48 | }) 49 | 50 | it('can ignore resource properties', async () => { 51 | const config = await State.New() 52 | 53 | const resource = config.getResources(Repository)[0] 54 | assert.notEqual(resource.description, undefined) 55 | 56 | config['_ignoredProperties'] = {github_repository: ['description']} 57 | await config.refresh() 58 | 59 | const refreshedResource = config.getResources(Repository)[0] 60 | assert.equal(refreshedResource.description, undefined) 61 | }) 62 | 63 | it('can add and remove resources through sync', async () => { 64 | const config = await State.New() 65 | 66 | const addResourceAtMock = mock.fn(config.addResourceAt.bind(config)) 67 | const removeResourceAtMock = mock.fn(config.removeResourceAt.bind(config)) 68 | 69 | config.addResourceAt = addResourceAtMock 70 | config.removeResourceAt = removeResourceAtMock 71 | 72 | const desiredResources: [Id, Resource][] = [] 73 | const resources = config.getAllResources() 74 | 75 | await config.sync(desiredResources) 76 | 77 | assert.equal(addResourceAtMock.mock.calls.length, 0) 78 | assert.equal( 79 | removeResourceAtMock.mock.calls.length, 80 | new Set(resources.map(r => r.getStateAddress().toLowerCase())).size 81 | ) 82 | 83 | addResourceAtMock.mock.resetCalls() 84 | removeResourceAtMock.mock.resetCalls() 85 | 86 | for (const resource of resources) { 87 | desiredResources.push(['id', resource]) 88 | } 89 | 90 | await config.sync(desiredResources) 91 | assert.equal(addResourceAtMock.mock.calls.length, 1) // adding github-mgmt/readme.md 92 | assert.equal(removeResourceAtMock.mock.calls.length, 1) // removing github-mgmt/README.md 93 | 94 | addResourceAtMock.mock.resetCalls() 95 | removeResourceAtMock.mock.resetCalls() 96 | 97 | desiredResources.push(['id', new Repository('test')]) 98 | desiredResources.push(['id', new Repository('test2')]) 99 | desiredResources.push(['id', new Repository('test3')]) 100 | desiredResources.push(['id', new Repository('test4')]) 101 | 102 | await config.sync(desiredResources) 103 | 104 | assert.equal( 105 | addResourceAtMock.mock.calls.length, 106 | 1 + desiredResources.length - resources.length 107 | ) 108 | assert.equal(removeResourceAtMock.mock.calls.length, 1) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /scripts/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 6 | 7 | export default tseslint.config( 8 | eslint.configs.recommended, 9 | tseslint.configs.recommended, 10 | eslintPluginPrettierRecommended, 11 | { 12 | rules: { 13 | "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], 14 | } 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scripts", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "lib/main.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc --project tsconfig.build.json", 9 | "format": "prettier --write '**/*.ts'", 10 | "format:check": "prettier --check '**/*.ts'", 11 | "lint": "eslint \"src/**/*.ts\" \"__tests__/**/*.ts\"", 12 | "lint:fix": "eslint --fix \"src/**/*.ts\" \"__tests__/**/*.ts\"", 13 | "test": "TF_EXEC=false TF_LOCK=false TF_WORKING_DIR=__tests__/__resources__/terraform GITHUB_DIR=__tests__/__resources__/github FILES_DIR=__tests__/__resources__/files GITHUB_ORG=default node --import tsx/esm --test --experimental-test-module-mocks \"__tests__/**/*.test.ts\"", 14 | "test:only": "TF_EXEC=false TF_LOCK=false TF_WORKING_DIR=__tests__/__resources__/terraform GITHUB_DIR=__tests__/__resources__/github FILES_DIR=__tests__/__resources__/files GITHUB_ORG=default node --import tsx/esm --test --test-only --experimental-test-module-mocks \"__tests__/**/*.test.ts\"", 15 | "all": "npm run build && npm run format && npm run lint && npm test", 16 | "schema": "ts-json-schema-generator --tsconfig tsconfig.json --path src/yaml/schema.ts --type ConfigSchema --out ../github/.schema.json", 17 | "main": "node lib/main.js" 18 | }, 19 | "dependencies": { 20 | "@actions/core": "^1.11.1", 21 | "@actions/exec": "^1.1.1", 22 | "@actions/github": "^6.0.0", 23 | "@octokit/auth-app": "^7.2.0", 24 | "@octokit/graphql": "^8.2.2", 25 | "@octokit/plugin-paginate-rest": "^11.6.0", 26 | "@octokit/plugin-retry": "^7.2.1", 27 | "@octokit/plugin-throttling": "^9.6.1", 28 | "@octokit/rest": "^21.1.1", 29 | "class-transformer": "^0.5.1", 30 | "deep-diff": "^1.0.2", 31 | "hcl2-parser": "^1.0.3", 32 | "reflect-metadata": "^0.2.2", 33 | "yaml": "^2.7.1" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.24.0", 37 | "@octokit/types": "^14.0.0", 38 | "@types/deep-diff": "^1.0.5", 39 | "@types/node": "^22.14.1", 40 | "eslint": "^9.25.0", 41 | "eslint-config-prettier": "^10.1.2", 42 | "eslint-plugin-prettier": "^5.2.6", 43 | "prettier": "^3.5.3", 44 | "ts-json-schema-generator": "^2.4.0", 45 | "tsx": "^4.19.3", 46 | "typescript": "^5.8.3", 47 | "typescript-eslint": "^8.29.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/src/actions/find-sha-for-plan.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {context} from '@actions/github' 3 | import * as core from '@actions/core' 4 | 5 | async function findShaForPlan(): Promise { 6 | const github = await GitHub.getGitHub() 7 | 8 | if (context.eventName !== 'push') { 9 | return context.sha 10 | } 11 | 12 | const pulls = await github.client.paginate( 13 | github.client.search.issuesAndPullRequests, 14 | { 15 | q: `repository:${context.repo.owner}/${context.repo.repo} ${context.sha} type:pr is:merged`, 16 | advanced_search: 'true' 17 | } 18 | ) 19 | 20 | if (pulls.length === 0) { 21 | return '' 22 | } 23 | 24 | const pull = pulls[0] 25 | const commits = await github.client.paginate( 26 | github.client.pulls.listCommits, 27 | { 28 | ...context.repo, 29 | pull_number: pull.number 30 | } 31 | ) 32 | 33 | if (commits.length === 0) { 34 | return '' 35 | } 36 | 37 | return commits[commits.length - 1].sha 38 | } 39 | 40 | async function run(): Promise { 41 | const sha = await findShaForPlan() 42 | core.setOutput('result', sha) 43 | } 44 | 45 | run() 46 | -------------------------------------------------------------------------------- /scripts/src/actions/fix-yaml-config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {runToggleArchivedRepos} from './shared/toggle-archived-repos.js' 4 | import {runDescribeAccessChanges} from './shared/describe-access-changes.js' 5 | 6 | import * as core from '@actions/core' 7 | 8 | async function run(): Promise { 9 | await runToggleArchivedRepos() 10 | 11 | const accessChangesDescription = await runDescribeAccessChanges() 12 | 13 | core.setOutput( 14 | 'comment', 15 | `The following access changes will be introduced as a result of applying the plan: 16 | 17 |
Access Changes 18 | 19 | \`\`\` 20 | ${accessChangesDescription} 21 | \`\`\` 22 | 23 |
` 24 | ) 25 | } 26 | 27 | run() 28 | -------------------------------------------------------------------------------- /scripts/src/actions/format-yaml-config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {Config} from '../yaml/config.js' 3 | 4 | async function run(): Promise { 5 | const config = Config.FromPath() 6 | config.save() 7 | } 8 | 9 | run() 10 | -------------------------------------------------------------------------------- /scripts/src/actions/remove-inactive-members.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {RepositoryTeam} from '../resources/repository-team.js' 3 | import {Team} from '../resources/team.js' 4 | import {Config} from '../yaml/config.js' 5 | import {Member} from '../resources/member.js' 6 | import {RepositoryCollaborator} from '../resources/repository-collaborator.js' 7 | import {Resource, ResourceConstructor} from '../resources/resource.js' 8 | import {Role, TeamMember} from '../resources/team-member.js' 9 | import {GitHub} from '../github.js' 10 | 11 | function getResources( 12 | config: Config, 13 | resourceClass: ResourceConstructor 14 | ): T[] { 15 | const schema = config.get() 16 | return config.getResources(resourceClass).filter(resource => { 17 | const node = config.document.getIn( 18 | resource.getSchemaPath(schema), 19 | true 20 | ) as {comment?: string} 21 | return !node.comment?.includes('KEEP:') 22 | }) 23 | } 24 | 25 | /* This function is used to remove inactive members from the config. 26 | * 27 | * 1. It ensures that a team called 'Alumni' exists. 28 | * 2. It removes all 'Alumni' team from all the repositories. 29 | * 3. It populates 'Alumni' team with organization members who: 30 | * a. do not have 'KEEP:' in their inline comment AND 31 | * b. have not been added to the organization recently (if passed through NEW_MEMBERS) AND 32 | * c. have not performed any activity in the past X months. 33 | * 4. It removes repository collaborators who: 34 | * a. do not have 'KEEP:' in their inline comment AND 35 | * b. have not been added to the repository recently (if passed through NEW_REPOSITORY_COLLABORATORS) AND 36 | * c. have not performed any activity on the repository they're a collaborator of in the past X months. 37 | * 5. It removes team members who: 38 | * a. do not have 'KEEP:' in their inline comment AND 39 | * b. have not been added to the team recently (if passed through NEW_TEAM_MEMBERS) AND 40 | * c. have not performed any activity on any repository the team they're a member of has access to in the past X months. 41 | */ 42 | async function run(): Promise { 43 | const newMembers = JSON.parse(process.env.NEW_MEMBERS || '[]') 44 | const newRepositoryCollaborators = JSON.parse( 45 | process.env.NEW_REPOSITORY_COLLABORATORS || '{}' 46 | ) 47 | const newTeamMembers = JSON.parse(process.env.NEW_TEAM_MEMBERS || '{}') 48 | const cutoffInMonths = parseInt(process.env.CUTOFF_IN_MONTHS || '12') 49 | 50 | const github = await GitHub.getGitHub() 51 | 52 | const githubRepositories = await github.listRepositories() 53 | 54 | const since = new Date() 55 | since.setMonth(since.getMonth() - cutoffInMonths) 56 | 57 | const githubRepositoryActivities = 58 | await github.listRepositoryActivities(since) 59 | const githubRepositoryIssues = await github.listRepositoryIssues(since) 60 | const githubRepositoryPullRequestReviewComments = 61 | await github.listRepositoryPullRequestReviewComments(since) 62 | const githubRepositoryIssueComments = 63 | await github.listRepositoryIssueComments(since) 64 | const githubRepositoryCommitComments = 65 | await github.listRepositoryCommitComments(since) 66 | 67 | const activeActorsByRepository = [ 68 | ...githubRepositoryActivities.map(({repository, activity}) => ({ 69 | repository: repository.name, 70 | actor: activity.actor?.login 71 | })), 72 | ...githubRepositoryIssues.map(({repository, issue}) => ({ 73 | repository: repository.name, 74 | actor: issue.user?.login 75 | })), 76 | ...githubRepositoryPullRequestReviewComments.map( 77 | ({repository, comment}) => ({ 78 | repository: repository.name, 79 | actor: comment.user?.login 80 | }) 81 | ), 82 | ...githubRepositoryIssueComments.map(({repository, comment}) => ({ 83 | repository: repository.name, 84 | actor: comment.user?.login 85 | })), 86 | ...githubRepositoryCommitComments.map(({repository, comment}) => ({ 87 | repository: repository.name, 88 | actor: comment.user?.login 89 | })) 90 | ] 91 | .filter(({actor}) => actor !== undefined) 92 | .reduce>((acc, {repository, actor}) => { 93 | acc[repository] = acc[repository] ?? [] 94 | acc[repository].push(actor!) 95 | return acc 96 | }, {}) 97 | const activeActors = Array.from( 98 | new Set(Object.values(activeActorsByRepository).flat()) 99 | ) 100 | const archivedRepositories = githubRepositories 101 | .filter(repository => repository.archived) 102 | .map(repository => repository.name) 103 | 104 | const config = Config.FromPath() 105 | 106 | // alumni is a team for all the members who should get credit for their work 107 | // but do not need any special access anymore 108 | // first, ensure that the team exists 109 | const alumniTeam = new Team('Alumni') 110 | config.addResource(alumniTeam) 111 | 112 | // then, ensure that the team doesn't have any special access anywhere 113 | const repositoryTeams = config.getResources(RepositoryTeam) 114 | for (const repositoryTeam of repositoryTeams) { 115 | if (repositoryTeam.team === alumniTeam.name) { 116 | config.removeResource(repositoryTeam) 117 | } 118 | } 119 | 120 | // add members that have been inactive to the alumni team 121 | const members = getResources(config, Member) 122 | for (const member of members) { 123 | const isNew = newMembers.includes(member.username) 124 | if (!isNew) { 125 | const isActive = activeActors.includes(member.username) 126 | if (!isActive) { 127 | console.log(`Adding ${member.username} to the ${alumniTeam.name} team`) 128 | const teamMember = new TeamMember( 129 | alumniTeam.name, 130 | member.username, 131 | Role.Member 132 | ) 133 | config.addResource(teamMember) 134 | } 135 | } 136 | } 137 | 138 | // remove repository collaborators that have been inactive 139 | const repositoryCollaborators = getResources(config, RepositoryCollaborator) 140 | for (const repositoryCollaborator of repositoryCollaborators) { 141 | const isNew = newRepositoryCollaborators[ 142 | repositoryCollaborator.username 143 | ]?.includes(repositoryCollaborator.repository) 144 | if (!isNew) { 145 | const isCollaboratorActive = activeActorsByRepository[ 146 | repositoryCollaborator.repository 147 | ]?.includes(repositoryCollaborator.username) 148 | const isRepositoryArchived = archivedRepositories.includes( 149 | repositoryCollaborator.repository 150 | ) 151 | if (!isCollaboratorActive && !isRepositoryArchived) { 152 | console.log( 153 | `Removing ${repositoryCollaborator.username} from ${repositoryCollaborator.repository} repository` 154 | ) 155 | config.removeResource(repositoryCollaborator) 156 | } 157 | } 158 | } 159 | 160 | // remove team members that have been inactive (look at all the team repositories) 161 | const teamMembers = getResources(config, TeamMember).filter( 162 | teamMember => teamMember.team !== alumniTeam.name 163 | ) 164 | for (const teamMember of teamMembers) { 165 | const isNew = newTeamMembers[teamMember.username]?.includes(teamMember.team) 166 | if (!isNew) { 167 | const repositories = repositoryTeams 168 | .filter(repositoryTeam => repositoryTeam.team === teamMember.team) 169 | .map(repositoryTeam => repositoryTeam.repository) 170 | const isActive = repositories.some(repository => 171 | activeActorsByRepository[repository]?.includes(teamMember.username) 172 | ) 173 | if (!isActive) { 174 | console.log( 175 | `Removing ${teamMember.username} from ${teamMember.team} team` 176 | ) 177 | config.removeResource(teamMember) 178 | } 179 | } 180 | } 181 | 182 | config.save() 183 | } 184 | 185 | run() 186 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-collaborator-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository} from '../../resources/repository.js' 3 | import * as core from '@actions/core' 4 | import { 5 | Permission, 6 | RepositoryCollaborator 7 | } from '../../resources/repository-collaborator.js' 8 | 9 | export async function runAddCollaboratorToAllRepos( 10 | username: string, 11 | permission: Permission, 12 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true 13 | ): Promise { 14 | const config = Config.FromPath() 15 | 16 | await addCollaboratorToAllRepos( 17 | config, 18 | username, 19 | permission, 20 | repositoryFilter 21 | ) 22 | 23 | config.save() 24 | } 25 | 26 | export async function addCollaboratorToAllRepos( 27 | config: Config, 28 | username: string, 29 | permission: Permission, 30 | repositoryFilter: (repository: Repository) => boolean = () => true 31 | ): Promise { 32 | const collaborators = config 33 | .getResources(RepositoryCollaborator) 34 | .filter(c => c.username === username) 35 | 36 | const repositories = config 37 | .getResources(Repository) 38 | .filter(r => !r.archived) 39 | .filter(repositoryFilter) 40 | .filter(r => !collaborators.some(c => c.repository === r.name)) 41 | 42 | for (const repository of repositories) { 43 | const collaborator = new RepositoryCollaborator( 44 | repository.name, 45 | username, 46 | permission 47 | ) 48 | core.info( 49 | `Adding ${collaborator.username} as a collaborator with ${collaborator.permission} access to ${collaborator.repository} repository` 50 | ) 51 | config.addResource(collaborator) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-file-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository} from '../../resources/repository.js' 3 | import {RepositoryFile} from '../../resources/repository-file.js' 4 | import * as core from '@actions/core' 5 | 6 | export async function runAddFileToAllRepos( 7 | name: string, 8 | content: string = name, 9 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true 10 | ): Promise { 11 | const config = Config.FromPath() 12 | 13 | await addFileToAllRepos(config, name, content, repositoryFilter) 14 | 15 | config.save() 16 | } 17 | 18 | export async function addFileToAllRepos( 19 | config: Config, 20 | name: string, 21 | content: string = name, 22 | repositoryFilter: (repository: Repository) => boolean = () => true 23 | ): Promise { 24 | const repositories = config 25 | .getResources(Repository) 26 | .filter(r => !r.archived) 27 | .filter(repositoryFilter) 28 | 29 | for (const repository of repositories) { 30 | const file = new RepositoryFile(repository.name, name) 31 | file.content = content 32 | if (!config.someResource(file)) { 33 | core.info(`Adding ${file.file} file to ${file.repository} repository`) 34 | config.addResource(file) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-label-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository} from '../../resources/repository.js' 3 | import {RepositoryLabel} from '../../resources/repository-label.js' 4 | import * as core from '@actions/core' 5 | 6 | export async function runAddLabelToAllRepos( 7 | name: string, 8 | color: string | undefined = undefined, 9 | description: string | undefined = undefined, 10 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true 11 | ): Promise { 12 | const config = Config.FromPath() 13 | 14 | await addLabelToAllRepos(config, name, color, description, repositoryFilter) 15 | 16 | config.save() 17 | } 18 | 19 | export async function addLabelToAllRepos( 20 | config: Config, 21 | name: string, 22 | color: string | undefined = undefined, 23 | description: string | undefined = undefined, 24 | repositoryFilter: (repository: Repository) => boolean = () => true 25 | ): Promise { 26 | const repositories = config 27 | .getResources(Repository) 28 | .filter(r => !r.archived) 29 | .filter(repositoryFilter) 30 | 31 | for (const repository of repositories) { 32 | const label = new RepositoryLabel(repository.name, name) 33 | label.color = color 34 | label.description = description 35 | 36 | if (!config.someResource(label)) { 37 | core.info(`Adding ${label.name} file to ${label.repository} repository`) 38 | config.addResource(label) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/describe-access-changes.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {State} from '../../terraform/state.js' 3 | import {RepositoryCollaborator} from '../../resources/repository-collaborator.js' 4 | import {Member} from '../../resources/member.js' 5 | import {TeamMember} from '../../resources/team-member.js' 6 | import {RepositoryTeam} from '../../resources/repository-team.js' 7 | import diff from 'deep-diff' 8 | import * as core from '@actions/core' 9 | import {Repository} from '../../resources/repository.js' 10 | 11 | type AccessSummary = Record< 12 | string, 13 | { 14 | role?: string 15 | repositories: Record 16 | } 17 | > 18 | 19 | function getAccessSummaryFrom(source: State | Config): AccessSummary { 20 | const members = source.getResources(Member) 21 | const teamMembers = source.getResources(TeamMember) 22 | const teamRepositories = source.getResources(RepositoryTeam) 23 | const repositoryCollaborators = source.getResources(RepositoryCollaborator) 24 | 25 | const archivedRepositories = source 26 | .getResources(Repository) 27 | .filter(repository => repository.archived) 28 | .map(repository => repository.name.toLowerCase()) 29 | 30 | const usernames = new Set([ 31 | ...members.map(member => member.username.toLowerCase()), 32 | ...repositoryCollaborators.map(collaborator => 33 | collaborator.username.toLowerCase() 34 | ) 35 | ]) 36 | 37 | const accessSummary: AccessSummary = {} 38 | const permissions = ['admin', 'maintain', 'push', 'triage', 'pull'] 39 | 40 | for (const username of usernames) { 41 | const role = members.find( 42 | member => member.username.toLowerCase() === username 43 | )?.role 44 | const teams = teamMembers 45 | .filter(teamMember => teamMember.username.toLowerCase() === username) 46 | .map(teamMember => teamMember.team.toLowerCase()) 47 | const repositoryCollaborator = repositoryCollaborators 48 | .filter(collaborator => collaborator.username.toLowerCase() === username) 49 | .filter( 50 | collaborator => 51 | !archivedRepositories.includes(collaborator.repository.toLowerCase()) 52 | ) 53 | const teamRepository = teamRepositories 54 | .filter(repository => teams.includes(repository.team.toLowerCase())) 55 | .filter( 56 | repository => 57 | !archivedRepositories.includes(repository.repository.toLowerCase()) 58 | ) 59 | 60 | const repositories: Record = {} 61 | 62 | for (const rc of repositoryCollaborator) { 63 | const repository = rc.repository.toLowerCase() 64 | repositories[repository] = repositories[repository] ?? {} 65 | if ( 66 | !repositories[repository].permission || 67 | permissions.indexOf(rc.permission) < 68 | permissions.indexOf(repositories[repository].permission) 69 | ) { 70 | repositories[repository].permission = rc.permission 71 | } 72 | } 73 | 74 | for (const tr of teamRepository) { 75 | const repository = tr.repository.toLowerCase() 76 | repositories[repository] = repositories[repository] ?? {} 77 | if ( 78 | !repositories[repository].permission || 79 | permissions.indexOf(tr.permission) < 80 | permissions.indexOf(repositories[repository].permission) 81 | ) { 82 | repositories[repository].permission = tr.permission 83 | } 84 | } 85 | 86 | if (role !== undefined || Object.keys(repositories).length > 0) { 87 | accessSummary[username] = { 88 | role, 89 | repositories 90 | } 91 | } 92 | } 93 | 94 | return deepSort(accessSummary) 95 | } 96 | 97 | // deep sort object 98 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 99 | function deepSort(obj: any): any { 100 | if (Array.isArray(obj)) { 101 | return obj.map(deepSort) 102 | } else if (typeof obj === 'object') { 103 | const sorted: Record = {} 104 | for (const key of Object.keys(obj).sort()) { 105 | sorted[key] = deepSort(obj[key]) 106 | } 107 | return sorted 108 | } else { 109 | return obj 110 | } 111 | } 112 | 113 | export async function runDescribeAccessChanges(): Promise { 114 | const state = await State.New() 115 | const config = Config.FromPath() 116 | 117 | return await describeAccessChanges(state, config) 118 | } 119 | 120 | export async function describeAccessChanges( 121 | state: State, 122 | config: Config 123 | ): Promise { 124 | const before = getAccessSummaryFrom(state) 125 | const after = getAccessSummaryFrom(config) 126 | 127 | core.info(JSON.stringify({before, after}, null, 2)) 128 | 129 | const changes = diff(before, after) || [] 130 | 131 | core.debug(JSON.stringify(changes, null, 2)) 132 | 133 | const changesByUser: Record = {} 134 | for (const change of changes) { 135 | if (change.path === undefined) { 136 | throw new Error(`Change ${change.kind} has no path`) 137 | } 138 | const path = change.path 139 | changesByUser[path[0]] = changesByUser[path[0]] || [] 140 | changesByUser[path[0]].push(change) 141 | } 142 | 143 | // iterate over changesByUser and build a description 144 | const lines = [] 145 | for (const [username, userChanges] of Object.entries(changesByUser)) { 146 | lines.push(`User ${username}:`) 147 | for (const change of userChanges) { 148 | if (change.path === undefined) { 149 | throw new Error(`Change ${change.kind} has no path`) 150 | } 151 | const path = change.path 152 | switch (change.kind) { 153 | case 'E': 154 | if (path[1] === 'role') { 155 | if (change.lhs === undefined) { 156 | lines.push( 157 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` 158 | ) 159 | } else if (change.rhs === undefined) { 160 | lines.push(` - will leave the organization`) 161 | } else { 162 | lines.push( 163 | ` - will have the role in the organization change from ${change.lhs} to ${change.rhs}` 164 | ) 165 | } 166 | } else { 167 | lines.push( 168 | ` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}` 169 | ) 170 | } 171 | break 172 | case 'N': 173 | if (path.length === 1) { 174 | if (change.rhs.role) { 175 | lines.push( 176 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` 177 | ) 178 | } 179 | if (change.rhs.repositories) { 180 | const repositories = change.rhs.repositories as unknown as Record< 181 | string, 182 | {permission: string} 183 | > 184 | for (const [repository, {permission}] of Object.entries( 185 | repositories 186 | )) { 187 | lines.push( 188 | ` - will gain ${permission} permission to ${repository}` 189 | ) 190 | } 191 | } 192 | } else { 193 | lines.push( 194 | ` - will gain ${change.rhs.permission} permission to ${path[2]}` 195 | ) 196 | } 197 | break 198 | case 'D': 199 | if (path.length === 1) { 200 | if (change.lhs.role) { 201 | lines.push(` - will leave the organization`) 202 | } 203 | if (change.lhs.repositories) { 204 | const repositories = change.lhs.repositories as unknown as Record< 205 | string, 206 | {permission: string} 207 | > 208 | for (const [repository, {permission}] of Object.entries( 209 | repositories 210 | )) { 211 | lines.push( 212 | ` - will lose ${permission} permission to ${repository}` 213 | ) 214 | } 215 | } 216 | } else { 217 | lines.push( 218 | ` - will lose ${change.lhs.permission} permission to ${path[2]}` 219 | ) 220 | } 221 | break 222 | } 223 | } 224 | } 225 | 226 | return changes.length > 0 227 | ? lines.join('\n') 228 | : 'There will be no access changes' 229 | } 230 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/format.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | 3 | export async function runFormat(): Promise { 4 | const config = Config.FromPath() 5 | config.save() 6 | } 7 | 8 | export async function format(_config: Config): Promise {} 9 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/protect-default-branches.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository, Visibility} from '../../resources/repository.js' 3 | import {RepositoryBranchProtectionRule} from '../../resources/repository-branch-protection-rule.js' 4 | 5 | export async function runProtectDefaultBranches( 6 | includePrivate = false 7 | ): Promise { 8 | const config = Config.FromPath() 9 | 10 | await protectDefaultBranches(config, includePrivate) 11 | 12 | config.save() 13 | } 14 | 15 | export async function protectDefaultBranches( 16 | config: Config, 17 | includePrivate = false 18 | ): Promise { 19 | const repositories = config.getResources(Repository).filter(r => !r.archived) 20 | 21 | for (const repository of repositories) { 22 | if (includePrivate || repository.visibility !== Visibility.Private) { 23 | const rule = new RepositoryBranchProtectionRule( 24 | repository.name, 25 | repository.default_branch ?? 'main' 26 | ) 27 | if (!config.someResource(rule)) { 28 | console.log( 29 | `Adding branch protection rule for ${rule.pattern} to ${rule.repository} repository` 30 | ) 31 | config.addResource(rule) 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/set-property-in-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository} from '../../resources/repository.js' 3 | import * as core from '@actions/core' 4 | 5 | export async function runSetPropertyInAllRepos( 6 | name: keyof Repository, 7 | value: unknown, 8 | repositoryFilter: (repository: Repository) => boolean = (): boolean => true 9 | ): Promise { 10 | const config = Config.FromPath() 11 | 12 | await setPropertyInAllRepos(config, name, value, repositoryFilter) 13 | 14 | config.save() 15 | } 16 | 17 | export async function setPropertyInAllRepos( 18 | config: Config, 19 | name: keyof Repository, 20 | value: unknown, 21 | repositoryFilter: (repository: Repository) => boolean = () => true 22 | ): Promise { 23 | const repositories = config 24 | .getResources(Repository) 25 | .filter(r => !r.archived) 26 | .filter(repositoryFilter) 27 | 28 | for (const repository of repositories) { 29 | const v = repository[name] 30 | if (v !== value) { 31 | // @ts-expect-error -- We expect the property to be writable 32 | repository[name] = value 33 | core.info( 34 | `Setting ${name} property to ${value} for ${repository.name} repository` 35 | ) 36 | config.addResource(repository) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/toggle-archived-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config.js' 2 | import {Repository} from '../../resources/repository.js' 3 | import {State} from '../../terraform/state.js' 4 | 5 | export async function runToggleArchivedRepos(): Promise { 6 | const state = await State.New() 7 | const config = Config.FromPath() 8 | 9 | await toggleArchivedRepos(state, config) 10 | 11 | config.save() 12 | } 13 | 14 | export async function toggleArchivedRepos( 15 | state: State, 16 | config: Config 17 | ): Promise { 18 | const resources = state.getAllResources() 19 | const stateRepositories = state.getResources(Repository) 20 | const configRepositories = config.getResources(Repository) 21 | 22 | for (const configRepository of configRepositories) { 23 | if (configRepository.archived) { 24 | config.removeResource(configRepository) 25 | const repository = new Repository(configRepository.name) 26 | repository.archived = true 27 | config.addResource(repository) 28 | } else { 29 | const stateRepository = stateRepositories.find( 30 | r => r.name === configRepository.name 31 | ) 32 | if (stateRepository !== undefined && stateRepository.archived) { 33 | stateRepository.archived = false 34 | config.addResource(stateRepository) 35 | for (const resource of resources) { 36 | if ( 37 | 'repository' in resource && 38 | resource.repository === stateRepository.name 39 | ) { 40 | config.addResource(resource) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/src/actions/sync-labels.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {Octokit} from '@octokit/rest' 3 | import {GitHub} from '../github.js' 4 | import env from '../env.js' 5 | import * as core from '@actions/core' 6 | import type {GetResponseDataTypeFromEndpointMethod} from '@octokit/types' 7 | 8 | type Endpoints = InstanceType 9 | 10 | type Labels = GetResponseDataTypeFromEndpointMethod< 11 | Endpoints['issues']['getLabel'] 12 | >[] 13 | 14 | async function getLabels(repo: string): Promise { 15 | // initialize GitHub client 16 | const github = await GitHub.getGitHub() 17 | 18 | // use the GitHub client to fetch the list of labels from js-libp2p 19 | const labels = await github.client.paginate( 20 | github.client.issues.listLabelsForRepo, 21 | { 22 | owner: env.GITHUB_ORG, 23 | repo 24 | } 25 | ) 26 | 27 | return labels 28 | } 29 | 30 | async function addLabel( 31 | repo: string, 32 | name: string, 33 | color: string, 34 | description: string | undefined 35 | ): Promise { 36 | // initialize GitHub client 37 | const github = await GitHub.getGitHub() 38 | 39 | await github.client.issues.createLabel({ 40 | owner: env.GITHUB_ORG, 41 | repo, 42 | name, 43 | color, 44 | description 45 | }) 46 | } 47 | 48 | async function removeLabel(repo: string, name: string): Promise { 49 | // initialize GitHub client 50 | const github = await GitHub.getGitHub() 51 | 52 | await github.client.issues.deleteLabel({ 53 | owner: env.GITHUB_ORG, 54 | repo, 55 | name 56 | }) 57 | } 58 | 59 | async function sync(): Promise { 60 | const sourceRepo = process.env.SOURCE_REPOSITORY 61 | const targetRepos = process.env.TARGET_REPOSITORIES?.split(',')?.map(r => 62 | r.trim() 63 | ) 64 | const addLabels = process.env.ADD_LABELS === 'true' 65 | const removeLabels = process.env.REMOVE_LABELS === 'true' 66 | 67 | if (!sourceRepo) { 68 | throw new Error('SOURCE_REPOSITORY environment variable not set') 69 | } 70 | 71 | if (!targetRepos) { 72 | throw new Error('TARGET_REPOSITORIES environment variable not set') 73 | } 74 | 75 | const sourceLabels = await getLabels(sourceRepo) 76 | core.info( 77 | `Found the following labels in ${sourceRepo}: ${sourceLabels 78 | .map(l => l.name) 79 | .join(', ')}` 80 | ) 81 | 82 | for (const repo of targetRepos) { 83 | const targetLabels = await getLabels(repo) 84 | core.info( 85 | `Found the following labels in ${repo}: ${targetLabels 86 | .map(l => l.name) 87 | .join(', ')}` 88 | ) 89 | 90 | if (removeLabels) { 91 | for (const label of targetLabels) { 92 | if (!sourceLabels.find(l => l.name === label.name)) { 93 | core.info(`Removing ${label.name} label from ${repo} repository`) 94 | await removeLabel(repo, label.name) 95 | } 96 | } 97 | } 98 | 99 | if (addLabels) { 100 | for (const label of sourceLabels) { 101 | if (!targetLabels.some(l => l.name === label.name)) { 102 | core.info(`Adding ${label.name} label to ${repo} repository`) 103 | await addLabel( 104 | repo, 105 | label.name, 106 | label.color, 107 | label.description || undefined 108 | ) 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | sync() 116 | -------------------------------------------------------------------------------- /scripts/src/actions/update-pull-requests.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {context} from '@actions/github' 3 | 4 | async function updatePullRequests(): Promise { 5 | const github = await GitHub.getGitHub() 6 | 7 | const pulls = await github.client.paginate(github.client.pulls.list, { 8 | ...context.repo, 9 | state: 'open' 10 | }) 11 | 12 | for (const pull of pulls) { 13 | if (pull.draft === true) { 14 | // skip draft pull requests 15 | continue 16 | } 17 | 18 | if (pull.user?.type === 'Bot') { 19 | // skip bot pull requests 20 | continue 21 | } 22 | 23 | // replace process.env.GITHUB_REF_NAME with context.refName if it becomes available https://github.com/actions/toolkit/pull/935 24 | if (pull.base.ref !== (process.env.GITHUB_REF_NAME as string)) { 25 | // skip pull requests that are not on the target branch 26 | continue 27 | } 28 | 29 | if (pull.base.sha === context.sha) { 30 | // skip pull requests that are already up to date 31 | continue 32 | } 33 | 34 | try { 35 | await github.client.pulls.updateBranch({ 36 | ...context.repo, 37 | pull_number: pull.number 38 | }) 39 | } catch (error) { 40 | // we might be unable to update the pull request if it there is a conflict 41 | console.error(error) 42 | } 43 | } 44 | } 45 | 46 | updatePullRequests() 47 | -------------------------------------------------------------------------------- /scripts/src/env.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | TF_EXEC: process.env.TF_EXEC || 'true', 3 | TF_LOCK: process.env.TF_LOCK || 'true', 4 | TF_WORKING_DIR: process.env.TF_WORKING_DIR || '../terraform', 5 | FILES_DIR: process.env.FILES_DIR || '../files', 6 | GITHUB_DIR: process.env.GITHUB_DIR || '../github', 7 | GITHUB_APP_ID: process.env.GITHUB_APP_ID || '', 8 | GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID || '', 9 | GITHUB_APP_PEM_FILE: process.env.GITHUB_APP_PEM_FILE || '', 10 | GITHUB_ORG: process.env.TF_WORKSPACE || 'default' 11 | } 12 | -------------------------------------------------------------------------------- /scripts/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {runSync} from './sync.js' 3 | import {runToggleArchivedRepos} from './actions/shared/toggle-archived-repos.js' 4 | 5 | async function run(): Promise { 6 | await runSync() 7 | await runToggleArchivedRepos() 8 | } 9 | 10 | run() 11 | -------------------------------------------------------------------------------- /scripts/src/resources/member.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {Id, StateSchema} from '../terraform/schema.js' 3 | import env from '../env.js' 4 | import {Path, ConfigSchema} from '../yaml/schema.js' 5 | import {Resource} from './resource.js' 6 | 7 | export enum Role { 8 | Admin = 'admin', 9 | Member = 'member' 10 | } 11 | 12 | export type MemberSchema = { 13 | type: typeof Member.StateType 14 | values: { 15 | username: string 16 | role: string 17 | } 18 | } 19 | 20 | export class Member extends String implements Resource { 21 | static StateType = 'github_membership' as const 22 | static async FromGitHub(_members: Member[]): Promise<[Id, Member][]> { 23 | const github = await GitHub.getGitHub() 24 | const invitations = await github.listInvitations() 25 | const members = await github.listMembers() 26 | const result: [Id, Member][] = [] 27 | for (const invitation of invitations) { 28 | if (invitation.role === 'billing_manager') { 29 | throw new Error(`Member role 'billing_manager' is not supported.`) 30 | } 31 | if (invitation.login === null) { 32 | throw new Error(`Invitation ${invitation.id} has no login`) 33 | } 34 | const role = invitation.role === 'admin' ? Role.Admin : Role.Member 35 | result.push([ 36 | `${env.GITHUB_ORG}:${invitation.login}`, 37 | new Member(invitation.login, role) 38 | ]) 39 | } 40 | for (const member of members) { 41 | if (member.role === 'billing_manager') { 42 | throw new Error(`Member role 'billing_manager' is not supported.`) 43 | } 44 | if (member.user === null) { 45 | throw new Error(`Member ${member.url} has no associated user`) 46 | } 47 | result.push([ 48 | `${env.GITHUB_ORG}:${member.user.login}`, 49 | new Member(member.user.login, member.role as Role) 50 | ]) 51 | } 52 | return result 53 | } 54 | static FromState(state: StateSchema): Member[] { 55 | const members: Member[] = [] 56 | if (state.values?.root_module?.resources !== undefined) { 57 | for (const resource of state.values.root_module.resources) { 58 | if (resource.type === Member.StateType && resource.mode === 'managed') { 59 | members.push( 60 | new Member(resource.values.username, resource.values.role as Role) 61 | ) 62 | } 63 | } 64 | } 65 | return members 66 | } 67 | static FromConfig(config: ConfigSchema): Member[] { 68 | const members: Member[] = [] 69 | if (config.members !== undefined) { 70 | for (const [role, usernames] of Object.entries(config.members)) { 71 | for (const username of usernames ?? []) { 72 | members.push(new Member(username, role as Role)) 73 | } 74 | } 75 | } 76 | return members 77 | } 78 | 79 | constructor(username: string, role: Role) { 80 | super(username) 81 | this._username = username 82 | this._role = role 83 | } 84 | 85 | private _username: string 86 | get username(): string { 87 | return this._username 88 | } 89 | private _role: Role 90 | get role(): Role { 91 | return this._role 92 | } 93 | 94 | getSchemaPath(schema: ConfigSchema): Path { 95 | const members = schema.members?.[this.role] ?? [] 96 | const index = members.indexOf(this.username) 97 | return ['members', this.role, index === -1 ? members.length : index] 98 | } 99 | 100 | getStateAddress(): string { 101 | return `${Member.StateType}.this["${this.username}"]` 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-branch-protection-rule.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer' 2 | import {GitHub} from '../github.js' 3 | import {Id, StateSchema} from '../terraform/schema.js' 4 | import {Path, ConfigSchema} from '../yaml/schema.js' 5 | import {Repository} from './repository.js' 6 | import {Resource} from './resource.js' 7 | 8 | @Exclude() 9 | class RequiredPullRequestReviews { 10 | @Expose() dismiss_stale_reviews?: boolean 11 | @Expose() dismissal_restrictions?: string[] 12 | @Expose() pull_request_bypassers?: string[] 13 | @Expose() require_code_owner_reviews?: boolean 14 | @Expose() require_last_push_approval?: boolean 15 | @Expose() required_approving_review_count?: number 16 | @Expose() restrict_dismissals?: boolean 17 | } 18 | 19 | @Exclude() 20 | class RequiredStatusChecks { 21 | @Expose() contexts?: string[] 22 | @Expose() strict?: boolean 23 | } 24 | 25 | @Exclude() 26 | class RestrictPushes { 27 | @Expose() blocks_creations?: boolean 28 | @Expose() push_allowances?: string[] 29 | } 30 | 31 | @Exclude() 32 | export class RepositoryBranchProtectionRule implements Resource { 33 | static StateType = 'github_branch_protection' as const 34 | static async FromGitHub( 35 | _rules: RepositoryBranchProtectionRule[] 36 | ): Promise<[Id, RepositoryBranchProtectionRule][]> { 37 | const github = await GitHub.getGitHub() 38 | const rules = await github.listRepositoryBranchProtectionRules() 39 | const result: [Id, RepositoryBranchProtectionRule][] = [] 40 | for (const rule of rules) { 41 | result.push([ 42 | `${rule.repository.name}:${rule.branchProtectionRule.pattern}`, 43 | new RepositoryBranchProtectionRule( 44 | rule.repository.name, 45 | rule.branchProtectionRule.pattern 46 | ) 47 | ]) 48 | } 49 | return result 50 | } 51 | static FromState(state: StateSchema): RepositoryBranchProtectionRule[] { 52 | const rules: RepositoryBranchProtectionRule[] = [] 53 | if (state.values?.root_module?.resources !== undefined) { 54 | for (const resource of state.values.root_module.resources) { 55 | if ( 56 | resource.type === RepositoryBranchProtectionRule.StateType && 57 | resource.mode === 'managed' 58 | ) { 59 | const repositoryIndex: string = resource.index.split(':')[0] 60 | const repository = state.values.root_module.resources.find( 61 | r => 62 | r.mode === 'managed' && 63 | r.type === Repository.StateType && 64 | r.index === repositoryIndex 65 | ) 66 | const required_pull_request_reviews = 67 | resource.values.required_pull_request_reviews?.at(0) 68 | const required_status_checks = 69 | resource.values.required_status_checks?.at(0) 70 | rules.push( 71 | plainToClassFromExist( 72 | new RepositoryBranchProtectionRule( 73 | repository !== undefined && 74 | repository.type === Repository.StateType 75 | ? repository.values.name 76 | : repositoryIndex, 77 | resource.values.pattern 78 | ), 79 | { 80 | ...resource.values, 81 | required_pull_request_reviews, 82 | required_status_checks 83 | } 84 | ) 85 | ) 86 | } 87 | } 88 | } 89 | return rules 90 | } 91 | static FromConfig(config: ConfigSchema): RepositoryBranchProtectionRule[] { 92 | const rules: RepositoryBranchProtectionRule[] = [] 93 | if (config.repositories !== undefined) { 94 | for (const [repository_name, repository] of Object.entries( 95 | config.repositories 96 | )) { 97 | if (repository.branch_protection !== undefined) { 98 | for (const [pattern, rule] of Object.entries( 99 | repository.branch_protection 100 | )) { 101 | rules.push( 102 | plainToClassFromExist( 103 | new RepositoryBranchProtectionRule(repository_name, pattern), 104 | rule 105 | ) 106 | ) 107 | } 108 | } 109 | } 110 | } 111 | return rules 112 | } 113 | 114 | constructor(repository: string, pattern: string) { 115 | this._repository = repository 116 | this._pattern = pattern 117 | } 118 | 119 | private _repository: string 120 | get repository(): string { 121 | return this._repository 122 | } 123 | private _pattern: string 124 | get pattern(): string { 125 | return this._pattern 126 | } 127 | 128 | @Expose() allows_deletions?: boolean 129 | @Expose() allows_force_pushes?: boolean 130 | @Expose() enforce_admins?: boolean 131 | @Expose() force_push_bypassers?: string[] 132 | @Expose() lock_branch?: boolean 133 | @Expose() require_conversation_resolution?: boolean 134 | @Expose() require_signed_commits?: boolean 135 | @Expose() required_linear_history?: boolean 136 | @Expose() 137 | @Type(() => RequiredPullRequestReviews) 138 | required_pull_request_reviews?: RequiredPullRequestReviews 139 | @Expose() 140 | @Type(() => RequiredStatusChecks) 141 | required_status_checks?: RequiredStatusChecks 142 | @Expose() 143 | @Type(() => RestrictPushes) 144 | restrict_pushes?: RestrictPushes 145 | 146 | getSchemaPath(_schema: ConfigSchema): Path { 147 | return ['repositories', this.repository, 'branch_protection', this.pattern] 148 | } 149 | 150 | getStateAddress(): string { 151 | return `${RepositoryBranchProtectionRule.StateType}.this["${this.repository}:${this.pattern}"]` 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-collaborator.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {Id, StateSchema} from '../terraform/schema.js' 3 | import {Path, ConfigSchema} from '../yaml/schema.js' 4 | import {Resource} from './resource.js' 5 | 6 | export enum Permission { 7 | Admin = 'admin', 8 | Maintain = 'maintain', 9 | Push = 'push', 10 | Triage = 'triage', 11 | Pull = 'pull' 12 | } 13 | 14 | export class RepositoryCollaborator extends String implements Resource { 15 | static StateType = 'github_repository_collaborator' as const 16 | static async FromGitHub( 17 | _collaborators: RepositoryCollaborator[] 18 | ): Promise<[Id, RepositoryCollaborator][]> { 19 | const github = await GitHub.getGitHub() 20 | const invitations = await github.listRepositoryInvitations() 21 | const collaborators = await github.listRepositoryCollaborators() 22 | const result: [Id, RepositoryCollaborator][] = [] 23 | for (const invitation of invitations) { 24 | if (invitation.invitee === null) { 25 | throw new Error(`Invitation ${invitation.id} has no invitee`) 26 | } 27 | result.push([ 28 | `${invitation.repository.name}:${invitation.invitee.login}`, 29 | new RepositoryCollaborator( 30 | invitation.repository.name, 31 | invitation.invitee.login, 32 | invitation.permissions as Permission 33 | ) 34 | ]) 35 | } 36 | for (const collaborator of collaborators) { 37 | let permission: Permission | undefined 38 | if (collaborator.collaborator.permissions?.admin) { 39 | permission = Permission.Triage 40 | } else if (collaborator.collaborator.permissions?.maintain) { 41 | permission = Permission.Push 42 | } else if (collaborator.collaborator.permissions?.push) { 43 | permission = Permission.Maintain 44 | } else if (collaborator.collaborator.permissions?.triage) { 45 | permission = Permission.Admin 46 | } else if (collaborator.collaborator.permissions?.pull) { 47 | permission = Permission.Pull 48 | } 49 | if (permission === undefined) { 50 | throw new Error( 51 | `Unknown permission for ${collaborator.collaborator.login}` 52 | ) 53 | } 54 | result.push([ 55 | `${collaborator.repository.name}:${collaborator.collaborator.login}`, 56 | new RepositoryCollaborator( 57 | collaborator.repository.name, 58 | collaborator.collaborator.login, 59 | permission 60 | ) 61 | ]) 62 | } 63 | return result 64 | } 65 | static FromState(state: StateSchema): RepositoryCollaborator[] { 66 | const collaborators: RepositoryCollaborator[] = [] 67 | if (state.values?.root_module?.resources !== undefined) { 68 | for (const resource of state.values.root_module.resources) { 69 | if ( 70 | resource.type === RepositoryCollaborator.StateType && 71 | resource.mode === 'managed' 72 | ) { 73 | collaborators.push( 74 | new RepositoryCollaborator( 75 | resource.values.repository, 76 | resource.values.username, 77 | resource.values.permission as Permission 78 | ) 79 | ) 80 | } 81 | } 82 | } 83 | return collaborators 84 | } 85 | static FromConfig(config: ConfigSchema): RepositoryCollaborator[] { 86 | const collaborators: RepositoryCollaborator[] = [] 87 | if (config.repositories !== undefined) { 88 | for (const [repository_name, repository] of Object.entries( 89 | config.repositories 90 | )) { 91 | if (repository.collaborators !== undefined) { 92 | for (const [permission, usernames] of Object.entries( 93 | repository.collaborators 94 | )) { 95 | for (const username of usernames ?? []) { 96 | collaborators.push( 97 | new RepositoryCollaborator( 98 | repository_name, 99 | username, 100 | permission as Permission 101 | ) 102 | ) 103 | } 104 | } 105 | } 106 | } 107 | } 108 | return collaborators 109 | } 110 | constructor(repository: string, username: string, permission: Permission) { 111 | super(username) 112 | this._repository = repository 113 | this._username = username 114 | this._permission = permission 115 | } 116 | 117 | private _repository: string 118 | get repository(): string { 119 | return this._repository 120 | } 121 | private _username: string 122 | get username(): string { 123 | return this._username 124 | } 125 | private _permission: Permission 126 | get permission(): Permission { 127 | return this._permission 128 | } 129 | 130 | getSchemaPath(schema: ConfigSchema): Path { 131 | const collaborators = 132 | schema.repositories?.[this.repository]?.collaborators?.[ 133 | this.permission 134 | ] || [] 135 | const index = collaborators.indexOf(this.username) 136 | return [ 137 | 'repositories', 138 | this.repository, 139 | 'collaborators', 140 | this.permission, 141 | index === -1 ? collaborators.length : index 142 | ] 143 | } 144 | 145 | getStateAddress(): string { 146 | return `${RepositoryCollaborator.StateType}.this["${this.repository}:${this.username}"]` 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-file.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 2 | import {Path, ConfigSchema} from '../yaml/schema.js' 3 | import {Resource} from './resource.js' 4 | import {GitHub} from '../github.js' 5 | import {Id, StateSchema} from '../terraform/schema.js' 6 | import env from '../env.js' 7 | import * as fs from 'fs' 8 | import * as path from 'path' 9 | 10 | export function findFileByContent( 11 | dirPath: string, 12 | content: string 13 | ): string | undefined { 14 | const files = fs.readdirSync(dirPath) 15 | for (const file of files) { 16 | const filePath = path.join(dirPath, file) 17 | const fileStats = fs.lstatSync(filePath) 18 | if (fileStats.isFile()) { 19 | const fileContent = fs.readFileSync(filePath).toString() 20 | if (fileContent === content) { 21 | return filePath 22 | } 23 | } else if (fileStats.isDirectory()) { 24 | const otherFilePath = findFileByContent(filePath, content) 25 | if (otherFilePath) { 26 | return otherFilePath 27 | } 28 | } 29 | } 30 | return undefined 31 | } 32 | 33 | @Exclude() 34 | export class RepositoryFile implements Resource { 35 | static StateType = 'github_repository_file' as const 36 | static async FromGitHub( 37 | files: RepositoryFile[] 38 | ): Promise<[Id, RepositoryFile][]> { 39 | const github = await GitHub.getGitHub() 40 | const result: [Id, RepositoryFile][] = [] 41 | for (const file of files) { 42 | const remoteFile = await github.getRepositoryFile( 43 | file.repository, 44 | file.file 45 | ) 46 | if (remoteFile !== undefined) { 47 | result.push([`${file.repository}/${file.file}:${remoteFile.ref}`, file]) 48 | } 49 | } 50 | return result 51 | } 52 | static FromState(state: StateSchema): RepositoryFile[] { 53 | const files: RepositoryFile[] = [] 54 | if (state.values?.root_module?.resources !== undefined) { 55 | for (const resource of state.values.root_module.resources) { 56 | if ( 57 | resource.type === RepositoryFile.StateType && 58 | resource.mode === 'managed' 59 | ) { 60 | const content = 61 | findFileByContent(env.FILES_DIR, resource.values.content)?.slice( 62 | env.FILES_DIR.length + 1 63 | ) || resource.values.content 64 | files.push( 65 | plainToClassFromExist( 66 | new RepositoryFile( 67 | resource.values.repository, 68 | resource.values.file 69 | ), 70 | {...resource.values, content} 71 | ) 72 | ) 73 | } 74 | } 75 | } 76 | return files 77 | } 78 | static FromConfig(config: ConfigSchema): RepositoryFile[] { 79 | const files: RepositoryFile[] = [] 80 | if (config.repositories !== undefined) { 81 | for (const [repository_name, repository] of Object.entries( 82 | config.repositories 83 | )) { 84 | if (repository.files !== undefined) { 85 | for (const [file_name, file] of Object.entries(repository.files)) { 86 | files.push( 87 | plainToClassFromExist( 88 | new RepositoryFile(repository_name, file_name), 89 | file 90 | ) 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | return files 97 | } 98 | 99 | constructor(repository: string, name: string) { 100 | this._repository = repository 101 | this._file = name 102 | } 103 | 104 | private _repository: string 105 | get repository(): string { 106 | return this._repository 107 | } 108 | private _file: string 109 | get file(): string { 110 | return this._file 111 | } 112 | 113 | @Expose() content?: string 114 | @Expose() overwrite_on_create?: boolean 115 | 116 | getSchemaPath(_schema: ConfigSchema): Path { 117 | return ['repositories', this.repository, 'files', this.file] 118 | } 119 | 120 | getStateAddress(): string { 121 | return `${RepositoryFile.StateType}.this["${this.repository}/${this.file}"]` 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-label.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 2 | import {Path, ConfigSchema} from '../yaml/schema.js' 3 | import {Resource} from './resource.js' 4 | import {GitHub} from '../github.js' 5 | import {Id, StateSchema} from '../terraform/schema.js' 6 | 7 | @Exclude() 8 | export class RepositoryLabel implements Resource { 9 | static StateType = 'github_issue_labels' as const 10 | static async FromGitHub( 11 | _labels: RepositoryLabel[] 12 | ): Promise<[Id, RepositoryLabel][]> { 13 | const github = await GitHub.getGitHub() 14 | const labels = await github.listRepositoryLabels() 15 | const result: [Id, RepositoryLabel][] = [] 16 | for (const label of labels) { 17 | result.push([ 18 | label.repository.name, 19 | new RepositoryLabel(label.repository.name, label.label.name) 20 | ]) 21 | } 22 | return result 23 | } 24 | static FromState(state: StateSchema): RepositoryLabel[] { 25 | const labels: RepositoryLabel[] = [] 26 | if (state.values?.root_module?.resources !== undefined) { 27 | for (const resource of state.values.root_module.resources) { 28 | if ( 29 | resource.type === RepositoryLabel.StateType && 30 | resource.mode === 'managed' 31 | ) { 32 | for (const label of resource.values.label ?? []) { 33 | labels.push( 34 | plainToClassFromExist( 35 | new RepositoryLabel(resource.values.repository, label.name), 36 | label 37 | ) 38 | ) 39 | } 40 | } 41 | } 42 | } 43 | return labels 44 | } 45 | static FromConfig(config: ConfigSchema): RepositoryLabel[] { 46 | const labels: RepositoryLabel[] = [] 47 | if (config.repositories !== undefined) { 48 | for (const [repository_name, repository] of Object.entries( 49 | config.repositories 50 | )) { 51 | if (repository.labels !== undefined) { 52 | for (const [name, label] of Object.entries(repository.labels)) { 53 | labels.push( 54 | plainToClassFromExist( 55 | new RepositoryLabel(repository_name, name), 56 | label 57 | ) 58 | ) 59 | } 60 | } 61 | } 62 | } 63 | return labels 64 | } 65 | constructor(repository: string, name: string) { 66 | this._repository = repository 67 | this._name = name 68 | } 69 | 70 | private _repository: string 71 | get repository(): string { 72 | return this._repository 73 | } 74 | private _name: string 75 | get name(): string { 76 | return this._name 77 | } 78 | 79 | @Expose() color?: string 80 | @Expose() description?: string 81 | 82 | getSchemaPath(_schema: ConfigSchema): Path { 83 | return ['repositories', this.repository, 'labels', this.name] 84 | } 85 | 86 | getStateAddress(): string { 87 | return `${RepositoryLabel.StateType}.this["${this.repository}"]` 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-team.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {Id, StateSchema} from '../terraform/schema.js' 3 | import {Path, ConfigSchema} from '../yaml/schema.js' 4 | import {Resource} from './resource.js' 5 | import {Team} from './team.js' 6 | 7 | export enum Permission { 8 | Admin = 'admin', 9 | Maintain = 'maintain', 10 | Push = 'push', 11 | Triage = 'triage', 12 | Pull = 'pull' 13 | } 14 | 15 | export class RepositoryTeam extends String implements Resource { 16 | static StateType = 'github_team_repository' as const 17 | static async FromGitHub( 18 | _teams: RepositoryTeam[] 19 | ): Promise<[Id, RepositoryTeam][]> { 20 | const github = await GitHub.getGitHub() 21 | const teams = await github.listTeamRepositories() 22 | const result: [Id, RepositoryTeam][] = [] 23 | for (const team of teams) { 24 | result.push([ 25 | `${team.team.id}:${team.repository.name}`, 26 | new RepositoryTeam( 27 | team.repository.name, 28 | team.team.name, 29 | team.team.permission as Permission 30 | ) 31 | ]) 32 | } 33 | return result 34 | } 35 | static FromState(state: StateSchema): RepositoryTeam[] { 36 | const teams: RepositoryTeam[] = [] 37 | if (state.values?.root_module?.resources !== undefined) { 38 | for (const resource of state.values.root_module.resources) { 39 | if ( 40 | resource.type === RepositoryTeam.StateType && 41 | resource.mode === 'managed' 42 | ) { 43 | const teamIndex = resource.index.split(`:`).slice(0, -1).join(`:`) 44 | const team = state.values.root_module.resources.find( 45 | r => 46 | r.mode === 'managed' && 47 | r.type === Team.StateType && 48 | r.index === teamIndex 49 | ) 50 | teams.push( 51 | new RepositoryTeam( 52 | resource.values.repository, 53 | team !== undefined && team.type === Team.StateType 54 | ? team.values.name 55 | : teamIndex, 56 | resource.values.permission as Permission 57 | ) 58 | ) 59 | } 60 | } 61 | } 62 | return teams 63 | } 64 | static FromConfig(config: ConfigSchema): RepositoryTeam[] { 65 | const teams: RepositoryTeam[] = [] 66 | if (config.repositories !== undefined) { 67 | for (const [repository_name, repository] of Object.entries( 68 | config.repositories 69 | )) { 70 | if (repository.teams !== undefined) { 71 | for (const [permission, team_names] of Object.entries( 72 | repository.teams 73 | )) { 74 | for (const team_name of team_names ?? []) { 75 | teams.push( 76 | new RepositoryTeam( 77 | repository_name, 78 | team_name, 79 | permission as Permission 80 | ) 81 | ) 82 | } 83 | } 84 | } 85 | } 86 | } 87 | return teams 88 | } 89 | constructor(repository: string, team: string, permission: Permission) { 90 | super(team) 91 | this._repository = repository 92 | this._team = team 93 | this._permission = permission 94 | } 95 | 96 | private _repository: string 97 | get repository(): string { 98 | return this._repository 99 | } 100 | private _team: string 101 | get team(): string { 102 | return this._team 103 | } 104 | private _permission: Permission 105 | get permission(): Permission { 106 | return this._permission 107 | } 108 | 109 | getSchemaPath(schema: ConfigSchema): Path { 110 | const teams = 111 | schema.repositories?.[this.repository]?.teams?.[this.permission] || [] 112 | const index = teams.indexOf(this.team) 113 | return [ 114 | 'repositories', 115 | this.repository, 116 | 'teams', 117 | this.permission, 118 | index === -1 ? teams.length : index 119 | ] 120 | } 121 | 122 | getStateAddress(): string { 123 | return `${RepositoryTeam.StateType}.this["${this.team}:${this.repository}"]` 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /scripts/src/resources/repository.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer' 2 | import {GitHub} from '../github.js' 3 | import {Id, StateSchema} from '../terraform/schema.js' 4 | import {Path, ConfigSchema} from '../yaml/schema.js' 5 | import {Resource} from './resource.js' 6 | 7 | @Exclude() 8 | class PageSource { 9 | @Expose() branch?: string 10 | @Expose() path?: string 11 | } 12 | 13 | @Exclude() 14 | class Pages { 15 | @Expose() source?: PageSource 16 | @Expose() build_type?: 'legacy' | 'workflow' 17 | @Expose() cname?: string 18 | } 19 | 20 | @Exclude() 21 | class Template { 22 | @Expose() owner?: string 23 | @Expose() repository?: string 24 | @Expose() include_all_branches?: boolean 25 | } 26 | 27 | export enum Visibility { 28 | Private = 'private', 29 | Public = 'public' 30 | } 31 | 32 | @Exclude() 33 | export class Repository implements Resource { 34 | static StateType = 'github_repository' as const 35 | static async FromGitHub( 36 | _repositories: Repository[] 37 | ): Promise<[Id, Repository][]> { 38 | const github = await GitHub.getGitHub() 39 | const repositories = await github.listRepositories() 40 | const result: [Id, Repository][] = [] 41 | for (const repository of repositories) { 42 | result.push([repository.name, new Repository(repository.name)]) 43 | } 44 | return result 45 | } 46 | static FromState(state: StateSchema): Repository[] { 47 | const repositories: Repository[] = [] 48 | if (state.values?.root_module?.resources !== undefined) { 49 | for (const resource of state.values.root_module.resources) { 50 | if ( 51 | resource.type === Repository.StateType && 52 | resource.mode === 'managed' 53 | ) { 54 | const pages = { 55 | ...resource.values.pages?.at(0), 56 | source: {...resource.values.pages?.at(0)?.source?.at(0)} 57 | } 58 | const template = resource.values.template?.at(0) 59 | const security_and_analysis = 60 | resource.values.security_and_analysis?.at(0) 61 | const advanced_security = 62 | security_and_analysis?.advanced_security?.at(0)?.status === 63 | 'enabled' 64 | const secret_scanning = 65 | security_and_analysis?.secret_scanning?.at(0)?.status === 'enabled' 66 | const secret_scanning_push_protection = 67 | security_and_analysis?.secret_scanning_push_protection?.at(0) 68 | ?.status === 'enabled' 69 | repositories.push( 70 | plainToClassFromExist(new Repository(resource.values.name), { 71 | ...resource.values, 72 | pages, 73 | template, 74 | advanced_security, 75 | secret_scanning, 76 | secret_scanning_push_protection 77 | }) 78 | ) 79 | } 80 | } 81 | } 82 | return repositories 83 | } 84 | static FromConfig(config: ConfigSchema): Repository[] { 85 | const repositories: Repository[] = [] 86 | if (config.repositories !== undefined) { 87 | for (const [name, repository] of Object.entries(config.repositories)) { 88 | repositories.push( 89 | plainToClassFromExist(new Repository(name), repository) 90 | ) 91 | } 92 | } 93 | return repositories 94 | } 95 | 96 | constructor(name: string) { 97 | this._name = name 98 | } 99 | 100 | private _name: string 101 | get name(): string { 102 | return this._name 103 | } 104 | 105 | @Expose() allow_auto_merge?: boolean 106 | @Expose() allow_merge_commit?: boolean 107 | @Expose() allow_rebase_merge?: boolean 108 | @Expose() allow_squash_merge?: boolean 109 | @Expose() allow_update_branch?: boolean 110 | @Expose() archive_on_destroy?: boolean 111 | @Expose() archived?: boolean 112 | @Expose() auto_init?: boolean 113 | @Expose() default_branch?: string 114 | @Expose() delete_branch_on_merge?: boolean 115 | @Expose() description?: string 116 | @Expose() gitignore_template?: string 117 | @Expose() has_discussions?: boolean 118 | @Expose() has_downloads?: boolean 119 | @Expose() has_issues?: boolean 120 | @Expose() has_projects?: boolean 121 | @Expose() has_wiki?: boolean 122 | @Expose() homepage_url?: string 123 | @Expose() ignore_vulnerability_alerts_during_read?: boolean 124 | @Expose() is_template?: boolean 125 | @Expose() license_template?: string 126 | @Expose() merge_commit_message?: string 127 | @Expose() merge_commit_title?: string 128 | @Expose() 129 | @Type(() => Pages) 130 | pages?: Pages 131 | // security_and_analysis 132 | @Expose() advanced_security?: boolean 133 | @Expose() secret_scanning?: boolean 134 | @Expose() secret_scanning_push_protection?: boolean 135 | @Expose() squash_merge_commit_message?: string 136 | @Expose() squash_merge_commit_title?: string 137 | @Expose() 138 | @Type(() => Template) 139 | template?: Template 140 | @Expose() topics?: string[] 141 | @Expose() visibility?: Visibility 142 | @Expose() vulnerability_alerts?: boolean 143 | @Expose() web_commit_signoff_required?: boolean 144 | 145 | getSchemaPath(_schema: ConfigSchema): Path { 146 | return ['repositories', this.name] 147 | } 148 | 149 | getStateAddress(): string { 150 | return `${Repository.StateType}.this["${this.name}"]` 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /scripts/src/resources/resource.ts: -------------------------------------------------------------------------------- 1 | import {instanceToPlain} from 'class-transformer' 2 | import {Id, StateSchema} from '../terraform/schema.js' 3 | import {Path, ConfigSchema} from '../yaml/schema.js' 4 | import {Member} from './member.js' 5 | import {Repository} from './repository.js' 6 | import {RepositoryBranchProtectionRule} from './repository-branch-protection-rule.js' 7 | import {RepositoryCollaborator} from './repository-collaborator.js' 8 | import {RepositoryFile} from './repository-file.js' 9 | import {RepositoryLabel} from './repository-label.js' 10 | import {RepositoryTeam} from './repository-team.js' 11 | import {Team} from './team.js' 12 | import {TeamMember} from './team-member.js' 13 | 14 | export interface Resource { 15 | // returns YAML config path under which the resource can be found 16 | // e.g. ['members', 'admin', ] 17 | getSchemaPath(schema: ConfigSchema): Path 18 | // returns Terraform state path under which the resource can be found 19 | // e.g. github_membership.this["galargh"] 20 | getStateAddress(): string 21 | } 22 | 23 | export interface ResourceConstructor { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | new (...args: any[]): T 26 | // extracts all resources of specific type from the given YAML config 27 | FromConfig(config: ConfigSchema): T[] 28 | // extracts all resources of specific type from the given Terraform state 29 | FromState(state: StateSchema): T[] 30 | // retrieves all resources of specific type from GitHub API 31 | // it takes a list of resources of the same type as an argument 32 | // an implementation can choose to ignore it or use it to only check if given resources still exist 33 | // this is the case with repository files for example where we don't want to manage ALL the files thorugh GitHub Management 34 | FromGitHub(resources: T[]): Promise<[Id, Resource][]> 35 | StateType: string 36 | } 37 | 38 | export const ResourceConstructors: ResourceConstructor[] = [ 39 | Member, 40 | RepositoryBranchProtectionRule, 41 | RepositoryCollaborator, 42 | RepositoryFile, 43 | RepositoryLabel, 44 | RepositoryTeam, 45 | Repository, 46 | TeamMember, 47 | Team 48 | ] 49 | 50 | export function resourceToPlain( 51 | resource: T | undefined 52 | ): string | Record | undefined { 53 | if (resource !== undefined) { 54 | if (resource instanceof String) { 55 | return resource.toString() 56 | } else { 57 | return instanceToPlain(resource, {exposeUnsetFields: false}) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /scripts/src/resources/team-member.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github.js' 2 | import {Id, StateSchema} from '../terraform/schema.js' 3 | import {Path, ConfigSchema} from '../yaml/schema.js' 4 | import {Resource} from './resource.js' 5 | import {Team} from './team.js' 6 | 7 | export enum Role { 8 | Maintainer = 'maintainer', 9 | Member = 'member' 10 | } 11 | 12 | export class TeamMember extends String implements Resource { 13 | static StateType = 'github_team_membership' as const 14 | static async FromGitHub(_members: TeamMember[]): Promise<[Id, TeamMember][]> { 15 | const github = await GitHub.getGitHub() 16 | const invitations = await github.listTeamInvitations() 17 | const members = await github.listTeamMembers() 18 | const result: [Id, TeamMember][] = [] 19 | for (const invitation of invitations) { 20 | if (invitation.invitation.login === null) { 21 | throw new Error(`Invitation ${invitation.invitation.id} has no login`) 22 | } 23 | const member = _members.find( 24 | m => 25 | m.team === invitation.team.name && 26 | m.username === invitation.invitation.login 27 | ) 28 | result.push([ 29 | `${invitation.team.id}:${invitation.invitation.login}`, 30 | new TeamMember( 31 | invitation.team.name, 32 | invitation.invitation.login, 33 | member?.role || Role.Member 34 | ) 35 | ]) 36 | } 37 | for (const member of members) { 38 | result.push([ 39 | `${member.team.id}:${member.member.login}`, 40 | new TeamMember( 41 | member.team.name, 42 | member.member.login, 43 | member.membership.role as Role 44 | ) 45 | ]) 46 | } 47 | return result 48 | } 49 | static FromState(state: StateSchema): TeamMember[] { 50 | const members: TeamMember[] = [] 51 | if (state.values?.root_module?.resources !== undefined) { 52 | for (const resource of state.values.root_module.resources) { 53 | if ( 54 | resource.type === TeamMember.StateType && 55 | resource.mode === 'managed' 56 | ) { 57 | const teamIndex = resource.index.split(`:`).slice(0, -1).join(`:`) 58 | const team = state.values.root_module.resources.find( 59 | r => 60 | r.mode === 'managed' && 61 | r.type === Team.StateType && 62 | r.index === teamIndex 63 | ) 64 | members.push( 65 | new TeamMember( 66 | team !== undefined && team.type === Team.StateType 67 | ? team.values.name 68 | : teamIndex, 69 | resource.values.username, 70 | resource.values.role as Role 71 | ) 72 | ) 73 | } 74 | } 75 | } 76 | return members 77 | } 78 | static FromConfig(config: ConfigSchema): TeamMember[] { 79 | const members: TeamMember[] = [] 80 | if (config.teams !== undefined) { 81 | for (const [team_name, team] of Object.entries(config.teams)) { 82 | if (team.members !== undefined) { 83 | for (const [role, usernames] of Object.entries(team.members)) { 84 | for (const username of usernames ?? []) { 85 | members.push(new TeamMember(team_name, username, role as Role)) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | return members 92 | } 93 | 94 | constructor(team: string, username: string, role: Role) { 95 | super(username) 96 | this._team = team 97 | this._username = username 98 | this._role = role 99 | } 100 | 101 | private _team: string 102 | get team(): string { 103 | return this._team 104 | } 105 | private _username: string 106 | get username(): string { 107 | return this._username 108 | } 109 | private _role: Role 110 | get role(): Role { 111 | return this._role 112 | } 113 | 114 | getSchemaPath(schema: ConfigSchema): Path { 115 | const members = schema.teams?.[this.team]?.members?.[this.role] || [] 116 | const index = members.indexOf(this.username) 117 | return [ 118 | 'teams', 119 | this.team, 120 | 'members', 121 | this.role, 122 | index === -1 ? members.length : index 123 | ] 124 | } 125 | 126 | getStateAddress(): string { 127 | return `${TeamMember.StateType}.this["${this.team}:${this.username}"]` 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /scripts/src/resources/team.ts: -------------------------------------------------------------------------------- 1 | import {Resource} from './resource.js' 2 | import {Path, ConfigSchema} from '../yaml/schema.js' 3 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 4 | import {GitHub} from '../github.js' 5 | import {Id, StateSchema} from '../terraform/schema.js' 6 | 7 | export enum Privacy { 8 | PUBLIC = 'closed', 9 | PRIVATE = 'secret' 10 | } 11 | 12 | @Exclude() 13 | export class Team implements Resource { 14 | static StateType = 'github_team' as const 15 | static async FromGitHub(_teams: Team[]): Promise<[Id, Team][]> { 16 | const github = await GitHub.getGitHub() 17 | const teams = await github.listTeams() 18 | const result: [Id, Team][] = [] 19 | for (const team of teams) { 20 | result.push([`${team.id}`, new Team(team.name)]) 21 | } 22 | return result 23 | } 24 | static FromState(state: StateSchema): Team[] { 25 | const teams: Team[] = [] 26 | if (state.values?.root_module?.resources !== undefined) { 27 | for (const resource of state.values.root_module.resources) { 28 | if (resource.type === Team.StateType && resource.mode === 'managed') { 29 | let parent_team_id = resource.values.parent_team_id 30 | if (parent_team_id !== undefined) { 31 | const parentTeam = state.values.root_module.resources.find( 32 | r => 33 | r.type === Team.StateType && 34 | r.mode === 'managed' && 35 | `${r.values.id}` === `${parent_team_id}` 36 | ) 37 | parent_team_id = 38 | parentTeam !== undefined && parentTeam.type === Team.StateType 39 | ? parentTeam.values.name 40 | : undefined 41 | } 42 | teams.push( 43 | plainToClassFromExist(new Team(resource.values.name), { 44 | ...resource.values, 45 | parent_team_id 46 | }) 47 | ) 48 | } 49 | } 50 | } 51 | return teams 52 | } 53 | static FromConfig(config: ConfigSchema): Team[] { 54 | const teams: Team[] = [] 55 | if (config.teams !== undefined) { 56 | for (const [name, team] of Object.entries(config.teams)) { 57 | teams.push(plainToClassFromExist(new Team(name), team)) 58 | } 59 | } 60 | return teams 61 | } 62 | 63 | constructor(name: string) { 64 | this._name = name 65 | } 66 | 67 | private _name: string 68 | get name(): string { 69 | return this._name 70 | } 71 | 72 | @Expose() create_default_maintainer?: boolean 73 | @Expose() description?: string 74 | @Expose() parent_team_id?: string 75 | @Expose() privacy?: Privacy 76 | 77 | getSchemaPath(_schema: ConfigSchema): Path { 78 | return ['teams', this.name] 79 | } 80 | 81 | getStateAddress(): string { 82 | return `${Team.StateType}.this["${this.name}"]` 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scripts/src/sync.ts: -------------------------------------------------------------------------------- 1 | import {Resource, ResourceConstructors} from './resources/resource.js' 2 | import {State} from './terraform/state.js' 3 | import {Id} from './terraform/schema.js' 4 | import {Config} from './yaml/config.js' 5 | 6 | export async function runSync(): Promise { 7 | const state = await State.New() 8 | const config = Config.FromPath() 9 | 10 | await sync(state, config) 11 | 12 | config.save() 13 | } 14 | 15 | export async function sync(state: State, config: Config): Promise { 16 | const resources: [Id, Resource][] = [] 17 | for (const resourceClass of ResourceConstructors) { 18 | const isIgnored = await state.isIgnored(resourceClass) 19 | if (!isIgnored) { 20 | const oldResources = config.getResources(resourceClass) 21 | const newResources = await resourceClass.FromGitHub(oldResources) 22 | resources.push(...newResources) 23 | } 24 | } 25 | 26 | await state.sync(resources) 27 | await state.refresh() 28 | 29 | const syncedResources = state.getAllResources() 30 | config.sync(syncedResources) 31 | } 32 | -------------------------------------------------------------------------------- /scripts/src/terraform/locals.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import HCL from 'hcl2-parser' 3 | import env from '../env.js' 4 | 5 | type LocalsSchema = { 6 | resource_types: string[] 7 | ignore: { 8 | repositories: string[] 9 | teams: string[] 10 | users: string[] 11 | } 12 | } 13 | 14 | export class Locals { 15 | static locals: LocalsSchema 16 | static getLocals(): LocalsSchema { 17 | if (Locals.locals === undefined) { 18 | const locals: LocalsSchema = { 19 | resource_types: [], 20 | ignore: { 21 | repositories: [], 22 | teams: [], 23 | users: [] 24 | } 25 | } 26 | for (const path of [ 27 | `${env.TF_WORKING_DIR}/locals.tf`, 28 | `${env.TF_WORKING_DIR}/locals_override.tf` 29 | ]) { 30 | if (fs.existsSync(path)) { 31 | const hcl = 32 | HCL.parseToObject(fs.readFileSync(path))?.at(0)?.locals?.at(0) ?? {} 33 | locals.resource_types = hcl.resource_types ?? locals.resource_types 34 | locals.ignore.repositories = 35 | hcl.ignore?.repositories ?? locals.ignore.repositories 36 | locals.ignore.teams = hcl.ignore?.teams ?? locals.ignore.teams 37 | locals.ignore.users = hcl.ignore?.users ?? locals.ignore.users 38 | } 39 | } 40 | this.locals = locals 41 | } 42 | return Locals.locals 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/src/terraform/schema.ts: -------------------------------------------------------------------------------- 1 | type ResourceSchema = { 2 | mode: string 3 | index: string 4 | address: string 5 | } & ( 6 | | { 7 | type: 'github_membership' 8 | values: {username: string; role: 'admin' | 'member'} 9 | } 10 | | { 11 | type: 'github_branch_protection' 12 | values: { 13 | pattern: string 14 | required_pull_request_reviews?: object[] 15 | required_status_checks?: object[] 16 | } 17 | } 18 | | { 19 | type: 'github_repository_collaborator' 20 | values: { 21 | permission: 'admin' | 'maintain' | 'push' | 'triage' | 'pull' 22 | repository: string 23 | username: string 24 | } 25 | } 26 | | { 27 | type: 'github_repository_file' 28 | values: { 29 | content: string 30 | file: string 31 | repository: string 32 | } 33 | } 34 | | { 35 | type: 'github_issue_labels' 36 | values: { 37 | repository: string 38 | label?: { 39 | name: string 40 | color: string 41 | description: string 42 | }[] 43 | } 44 | } 45 | | { 46 | type: 'github_team_repository' 47 | values: { 48 | repository: string 49 | permission: 'admin' | 'maintain' | 'push' | 'triage' | 'pull' 50 | } 51 | } 52 | | { 53 | type: 'github_repository' 54 | values: { 55 | name: string 56 | pages?: { 57 | source?: object[] 58 | }[] 59 | security_and_analysis?: { 60 | advanced_security?: { 61 | status: string 62 | }[] 63 | secret_scanning?: { 64 | status: string 65 | }[] 66 | secret_scanning_push_protection?: { 67 | status: string 68 | }[] 69 | }[] 70 | template?: object[] 71 | } 72 | } 73 | | { 74 | type: 'github_team_membership' 75 | values: { 76 | username: string 77 | role: 'maintainer' | 'member' 78 | } 79 | } 80 | | { 81 | type: 'github_team' 82 | values: { 83 | name: string 84 | id: string 85 | parent_team_id?: string 86 | } 87 | } 88 | ) 89 | 90 | export type StateSchema = { 91 | values?: { 92 | root_module?: { 93 | resources?: ResourceSchema[] 94 | } 95 | } 96 | } 97 | export type Id = string 98 | -------------------------------------------------------------------------------- /scripts/src/terraform/state.ts: -------------------------------------------------------------------------------- 1 | import {Id, StateSchema} from './schema.js' 2 | import { 3 | Resource, 4 | ResourceConstructors, 5 | ResourceConstructor 6 | } from '../resources/resource.js' 7 | import env from '../env.js' 8 | import * as cli from '@actions/exec' 9 | import * as fs from 'fs' 10 | import * as core from '@actions/core' 11 | import HCL from 'hcl2-parser' 12 | 13 | export async function loadState(): Promise { 14 | let source = '' 15 | if (env.TF_EXEC === 'true') { 16 | core.info('Loading state from Terraform state file') 17 | await cli.exec('terraform show -json', undefined, { 18 | cwd: env.TF_WORKING_DIR, 19 | listeners: { 20 | stdout: data => { 21 | source += data.toString() 22 | } 23 | }, 24 | silent: true 25 | }) 26 | } else { 27 | source = fs 28 | .readFileSync(`${env.TF_WORKING_DIR}/terraform.tfstate`) 29 | .toString() 30 | } 31 | return source 32 | } 33 | 34 | type HCLObject = { 35 | resource?: { 36 | [key: string]: { 37 | this?: { 38 | lifecycle?: { 39 | ignore_changes?: string[] 40 | }[] 41 | }[] 42 | } 43 | } 44 | }[] 45 | 46 | export class State { 47 | static async New(): Promise { 48 | const state = await import('./state.js') 49 | return new State(await state.loadState()) 50 | } 51 | 52 | private _ignoredProperties: Record = {} 53 | private _state: StateSchema 54 | 55 | private updateIgnoredPropertiesFrom(path: string): void { 56 | if (fs.existsSync(path)) { 57 | const hcl: HCLObject | undefined = HCL.parseToObject( 58 | fs.readFileSync(path) 59 | ) 60 | for (const [name, resource] of Object.entries( 61 | hcl?.at(0)?.resource ?? {} 62 | )) { 63 | const properties = resource?.this 64 | ?.at(0) 65 | ?.lifecycle?.at(0)?.ignore_changes 66 | if (properties !== undefined) { 67 | this._ignoredProperties[name] = properties.map((v: string) => 68 | v.substring(2, v.length - 1) 69 | ) // '${v}' -> 'v' 70 | } 71 | } 72 | } 73 | } 74 | 75 | private getState(source: string): StateSchema { 76 | const state: StateSchema = JSON.parse(source, (_k, v) => v ?? undefined) 77 | if (state.values?.root_module?.resources !== undefined) { 78 | state.values.root_module.resources = state.values.root_module.resources 79 | .filter(r => r.mode === 'managed') 80 | // .filter(r => !this._ignoredTypes.includes(r.type)) 81 | .map(r => { 82 | // TODO: remove nested values 83 | r.values = Object.fromEntries( 84 | Object.entries(r.values).filter( 85 | ([k, _v]) => !this._ignoredProperties[r.type]?.includes(k) 86 | ) 87 | ) as typeof r.values 88 | return r 89 | }) 90 | } 91 | return state 92 | } 93 | 94 | constructor(source: string) { 95 | this.updateIgnoredPropertiesFrom(`${env.TF_WORKING_DIR}/resources.tf`) 96 | this.updateIgnoredPropertiesFrom( 97 | `${env.TF_WORKING_DIR}/resources_override.tf` 98 | ) 99 | this._state = this.getState(source) 100 | } 101 | 102 | async reset(): Promise { 103 | const state = await import('./state.js') 104 | this._state = this.getState(await state.loadState()) 105 | } 106 | 107 | async refresh(): Promise { 108 | if (env.TF_EXEC === 'true') { 109 | await cli.exec( 110 | `terraform apply -refresh-only -auto-approve -lock=${env.TF_LOCK}`, 111 | undefined, 112 | { 113 | cwd: env.TF_WORKING_DIR 114 | } 115 | ) 116 | } 117 | await this.reset() 118 | } 119 | 120 | getAllAddresses(): string[] { 121 | const addresses = [] 122 | for (const resourceClass of ResourceConstructors) { 123 | const classAddresses = this.getAddresses(resourceClass) 124 | addresses.push(...classAddresses) 125 | } 126 | return addresses 127 | } 128 | 129 | getAddresses( 130 | resourceClass: ResourceConstructor 131 | ): string[] { 132 | if (ResourceConstructors.includes(resourceClass)) { 133 | return (this._state?.values?.root_module?.resources ?? []) 134 | .filter(r => r.type === resourceClass.StateType) 135 | .map(r => r.address) 136 | } else { 137 | throw new Error(`${resourceClass.name} is not supported`) 138 | } 139 | } 140 | 141 | getAllResources(): Resource[] { 142 | const resources = [] 143 | for (const resourceClass of ResourceConstructors) { 144 | const classResources = this.getResources(resourceClass) 145 | resources.push(...classResources) 146 | } 147 | return resources 148 | } 149 | 150 | getResources(resourceClass: ResourceConstructor): T[] { 151 | if (ResourceConstructors.includes(resourceClass)) { 152 | return resourceClass.FromState(this._state) 153 | } else { 154 | throw new Error(`${resourceClass.name} is not supported`) 155 | } 156 | } 157 | 158 | async isIgnored( 159 | resourceClass: ResourceConstructor 160 | ): Promise { 161 | const {Locals} = await import('./locals.js') 162 | return !Locals.getLocals().resource_types.includes(resourceClass.StateType) 163 | } 164 | 165 | async addResourceAt(id: Id, address: string): Promise { 166 | if (env.TF_EXEC === 'true') { 167 | const [, key, value] = address.match(/^([^.]+)\.this\["([^"]+)"\]$/)! 168 | const resources = JSON.stringify({ 169 | [key]: { 170 | [value]: {} 171 | } 172 | }).replaceAll('"', '\\"') 173 | const addr = address.replaceAll('"', '\\"') 174 | await cli.exec( 175 | `terraform import -lock=${env.TF_LOCK} -var "resources=${resources}" "${addr}" "${id}"`, 176 | undefined, 177 | {cwd: env.TF_WORKING_DIR} 178 | ) 179 | } 180 | } 181 | 182 | async removeResourceAt(address: string): Promise { 183 | if (env.TF_EXEC === 'true') { 184 | await cli.exec( 185 | `terraform state rm -lock=${env.TF_LOCK} "${address.replaceAll( 186 | '"', 187 | '\\"' 188 | )}"`, 189 | undefined, 190 | {cwd: env.TF_WORKING_DIR} 191 | ) 192 | } 193 | } 194 | 195 | async sync(resources: [Id, Resource][]): Promise { 196 | const addresses = new Set(this.getAllAddresses()) 197 | const addressToId = new Map() 198 | for (const [id, resource] of resources) { 199 | addressToId.set(resource.getStateAddress().toLowerCase(), id) 200 | } 201 | for (const address of addresses) { 202 | if (!addressToId.has(address)) { 203 | await this.removeResourceAt(address) 204 | } 205 | } 206 | for (const [address, id] of addressToId) { 207 | if (!addresses.has(address)) { 208 | await this.addResourceAt(id, address) 209 | } 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /scripts/src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from 'yaml' 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export function jsonEquals(a: any, b: any): boolean { 5 | return JSON.stringify(a) === JSON.stringify(b) 6 | } 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | export function yamlify(value: any): YAML.ParsedNode { 10 | const node = YAML.parseDocument(YAML.stringify(value)).contents 11 | if (node === null) { 12 | throw new Error( 13 | `Failed to parse YAML to a non-null value: ${YAML.stringify(value)}` 14 | ) 15 | } 16 | return node 17 | } 18 | 19 | export function globToRegex(globPattern: string): RegExp { 20 | const regexPattern = globPattern 21 | .split('') 22 | .map(char => { 23 | if (char === '*') { 24 | return '.*' 25 | } else if (char === '?') { 26 | return '.' 27 | } else if ( 28 | ['.', '\\', '+', '(', ')', '[', ']', '{', '}', '|', '^', '$'].includes( 29 | char 30 | ) 31 | ) { 32 | return `\\${char}` 33 | } else { 34 | return char 35 | } 36 | }) 37 | .join('') 38 | 39 | return new RegExp(`^${regexPattern}$`) 40 | } 41 | -------------------------------------------------------------------------------- /scripts/src/yaml/config.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from 'yaml' 2 | import {ConfigSchema, pathToYAML} from './schema.js' 3 | import { 4 | Resource, 5 | ResourceConstructor, 6 | ResourceConstructors, 7 | resourceToPlain 8 | } from '../resources/resource.js' 9 | import diff from 'deep-diff' 10 | import env from '../env.js' 11 | import * as fs from 'fs' 12 | import {jsonEquals, yamlify} from '../utils.js' 13 | 14 | export class Config { 15 | static FromPath(path = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml`): Config { 16 | const source = fs.readFileSync(path, 'utf8') 17 | return new Config(source) 18 | } 19 | 20 | constructor(source: string) { 21 | this._document = YAML.parseDocument(source) 22 | } 23 | 24 | private _document: YAML.Document 25 | get document(): YAML.Document { 26 | return this._document 27 | } 28 | 29 | get(): ConfigSchema { 30 | return this._document.toJSON() 31 | } 32 | 33 | format(): void { 34 | const schema = this.get() 35 | const resources = this.getAllResources() 36 | const resourcePaths = resources.map(r => r.getSchemaPath(schema).join('.')) 37 | let again = true 38 | while (again) { 39 | again = false 40 | YAML.visit(this._document, { 41 | Scalar(_, node) { 42 | if (node.value === undefined || node.value === null) { 43 | again = true 44 | return YAML.visit.REMOVE 45 | } 46 | }, 47 | Pair(_, node, path) { 48 | const resourcePath = [...path, node] 49 | .filter(p => YAML.isPair(p)) 50 | .map(p => (p as YAML.Pair).key) 51 | .map(k => String(k)) 52 | .join('.') 53 | if (!resourcePaths.includes(resourcePath)) { 54 | const isEmpty = node.value === null || node.value === undefined 55 | const isEmptyScalar = 56 | YAML.isScalar(node.value) && 57 | (node.value.value === undefined || 58 | node.value.value === null || 59 | node.value.value === '') 60 | const isEmptyCollection = 61 | YAML.isCollection(node.value) && node.value.items.length === 0 62 | if (isEmpty || isEmptyScalar || isEmptyCollection) { 63 | again = true 64 | return YAML.visit.REMOVE 65 | } 66 | } 67 | } 68 | }) 69 | } 70 | YAML.visit(this._document, { 71 | Map(_, {items}) { 72 | items.sort( 73 | (a: YAML.Pair, b: YAML.Pair) => { 74 | return JSON.stringify(a.key).localeCompare(JSON.stringify(b.key)) 75 | } 76 | ) 77 | }, 78 | Seq(_, {items}) { 79 | items.sort((a: unknown, b: unknown) => { 80 | return JSON.stringify(a).localeCompare(JSON.stringify(b)) 81 | }) 82 | } 83 | }) 84 | } 85 | 86 | toString(): string { 87 | return this._document.toString({ 88 | collectionStyle: 'block', 89 | singleQuote: false, 90 | lineWidth: 0 91 | }) 92 | } 93 | 94 | save(path = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml`): void { 95 | this.format() 96 | fs.writeFileSync(path, this.toString()) 97 | } 98 | 99 | getAllResources(): Resource[] { 100 | const resources = [] 101 | for (const resourceClass of ResourceConstructors) { 102 | const classResources = this.getResources(resourceClass) 103 | resources.push(...classResources) 104 | } 105 | return resources 106 | } 107 | 108 | getResources(resourceClass: ResourceConstructor): T[] { 109 | if (ResourceConstructors.includes(resourceClass)) { 110 | return resourceClass.FromConfig(this.get()) 111 | } else { 112 | throw new Error(`${resourceClass.name} is not supported`) 113 | } 114 | } 115 | 116 | findResource(resource: T): T | undefined { 117 | const schema = this.get() 118 | return this.getResources( 119 | resource.constructor as ResourceConstructor 120 | ).find(r => 121 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) 122 | ) 123 | } 124 | 125 | someResource(resource: T): boolean { 126 | return this.findResource(resource) !== undefined 127 | } 128 | 129 | // updates the resource if it already exists, otherwise adds it 130 | addResource( 131 | resource: T, 132 | canDeleteProperties = false 133 | ): void { 134 | const oldResource = this.findResource(resource) 135 | const path = resource.getSchemaPath(this.get()) 136 | const newValue = resourceToPlain(resource) 137 | const oldValue = resourceToPlain(oldResource) 138 | const diffs = diff(oldValue, newValue) 139 | for (const d of diffs || []) { 140 | if (d.kind === 'N') { 141 | this._document.addIn( 142 | pathToYAML([...path, ...(d.path || [])]), 143 | yamlify(d.rhs) 144 | ) 145 | } else if (d.kind === 'E') { 146 | this._document.setIn( 147 | pathToYAML([...path, ...(d.path || [])]), 148 | yamlify(d.rhs) 149 | ) 150 | const node = this._document.getIn( 151 | [...path, ...(d.path || [])], 152 | true 153 | ) as {comment?: string; commentBefore?: string} 154 | delete node.comment 155 | delete node.commentBefore 156 | } else if (d.kind === 'D' && canDeleteProperties) { 157 | this._document.deleteIn(pathToYAML([...path, ...(d.path || [])])) 158 | } else if (d.kind === 'A') { 159 | if (d.item.kind === 'N') { 160 | this._document.addIn( 161 | pathToYAML([...path, ...(d.path || []), d.index]), 162 | yamlify(d.item.rhs) 163 | ) 164 | } else if (d.item.kind === 'E') { 165 | this._document.setIn( 166 | pathToYAML([...path, ...(d.path || []), d.index]), 167 | yamlify(d.item.rhs) 168 | ) 169 | const node = this._document.getIn( 170 | [...path, ...(d.path || []), d.index], 171 | true 172 | ) as {comment?: string; commentBefore?: string} 173 | delete node.comment 174 | delete node.commentBefore 175 | } else if (d.item.kind === 'D') { 176 | this._document.setIn( 177 | pathToYAML([...path, ...(d.path || []), d.index]), 178 | undefined 179 | ) 180 | } else { 181 | throw new Error('Nested arrays are not supported') 182 | } 183 | } 184 | } 185 | } 186 | 187 | removeResource(resource: T): void { 188 | if (this.someResource(resource)) { 189 | const path = resource.getSchemaPath(this.get()) 190 | this._document.deleteIn(path) 191 | } 192 | } 193 | 194 | sync(resources: Resource[]): void { 195 | const oldResources = [] 196 | for (const resource of ResourceConstructors) { 197 | oldResources.push(...this.getResources(resource)) 198 | } 199 | const schema = this.get() 200 | for (const resource of oldResources) { 201 | if ( 202 | !resources.some(r => 203 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) 204 | ) 205 | ) { 206 | this.removeResource(resource) 207 | } 208 | } 209 | for (const resource of resources) { 210 | this.addResource(resource, true) 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /scripts/src/yaml/schema.ts: -------------------------------------------------------------------------------- 1 | import {Role as MemberRole} from '../resources/member.js' 2 | import {Repository} from '../resources/repository.js' 3 | import {RepositoryFile} from '../resources/repository-file.js' 4 | import {Permission as RepositoryCollaboratorPermission} from '../resources/repository-collaborator.js' 5 | import {Permission as RepositoryTeamPermission} from '../resources/repository-team.js' 6 | import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule.js' 7 | import {RepositoryLabel} from '../resources/repository-label.js' 8 | import {Role as TeamRole} from '../resources/team-member.js' 9 | import {Team} from '../resources/team.js' 10 | import * as YAML from 'yaml' 11 | import {yamlify} from '../utils.js' 12 | 13 | type TeamMember = string 14 | type RepositoryCollaborator = string 15 | type RepositoryTeam = string 16 | type Member = string 17 | 18 | interface RepositoryExtension { 19 | files?: Record 20 | collaborators?: { 21 | [permission in RepositoryCollaboratorPermission]?: RepositoryCollaborator[] 22 | } 23 | teams?: { 24 | [permission in RepositoryTeamPermission]?: RepositoryTeam[] 25 | } 26 | branch_protection?: Record 27 | labels?: Record 28 | } 29 | 30 | interface TeamExtension { 31 | members?: { 32 | [role in TeamRole]?: TeamMember[] 33 | } 34 | } 35 | 36 | export type Path = (string | number)[] 37 | 38 | export class ConfigSchema { 39 | members?: { 40 | [role in MemberRole]?: Member[] 41 | } 42 | repositories?: Record 43 | teams?: Record 44 | } 45 | 46 | export function pathToYAML(path: Path): (YAML.ParsedNode | number)[] { 47 | return path.map(e => (typeof e === 'number' ? e : yamlify(e))) 48 | } 49 | -------------------------------------------------------------------------------- /scripts/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["__tests__/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "nodenext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "experimentalDecorators": true, 11 | "lib": ["es2023"], 12 | "moduleResolution": "node16", 13 | }, 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /terraform/bootstrap/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.5.0" 6 | constraints = "4.5.0" 7 | hashes = [ 8 | "h1:PR5m6lcJZzSIYqfhnMd0YWTN+On2XGgfYV5AKIvOvBo=", 9 | "zh:0573de96ba316d808be9f8d6fc8e8e68e0e6b614ed4d707bd236c4f7b46ac8b1", 10 | "zh:37560469042f5f43fdb961eb6e6b0a8f95057df68af2c1168d5b8c66ddcb1512", 11 | "zh:44bb4f6bc1f58e19b8bf7041f981a2549a351762d17dd39654eb24d1fa7991c7", 12 | "zh:53af6557b68e547ac5c02cfd0e47ef63c8e9edfacf46921ccc97d73c0cd362c9", 13 | "zh:578a583f69a8e5947d66b2b9d6969690043b6887f6b574263be7ef05f82a82ad", 14 | "zh:6c2d42f30db198a4e7badd7f8037ef9bd951cfd6cf40328c6a7eed96801a374e", 15 | "zh:758f3fc4d833dbdda57a4db743cbbddc8fd8c0492df47771b848447ba7876ce5", 16 | "zh:78241bd45e2f6102055787b3697849fee7e9c28a744ba59cad956639c1aca07b", 17 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 18 | "zh:a3a7f4699c097c7b8364d05a5df9f3bd5d005fd5736c28ec5dc8f8c0ee340512", 19 | "zh:bf875483bf2ad6cfb4029813328cdcd9ea40f50b9f1c265f4e742fe8cc456157", 20 | "zh:f4722596e8b5f012013f87bf4d2b7d302c248a04a144de4563b3e3f754a30c51", 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /terraform/bootstrap/aws.tf: -------------------------------------------------------------------------------- 1 | # terraform init 2 | # export AWS_ACCESS_KEY_ID= 3 | # export AWS_SECRET_ACCESS_KEY= 4 | # export AWS_REGION= 5 | # export TF_VAR_name= 6 | # terraform apply 7 | 8 | terraform { 9 | required_providers { 10 | aws = { 11 | version = "4.5.0" 12 | } 13 | } 14 | 15 | required_version = "~> 1.12.0" 16 | } 17 | 18 | provider "aws" {} 19 | 20 | variable "name" { 21 | description = "The name to use for S3 bucket, DynamoDB table and IAM users." 22 | type = string 23 | } 24 | 25 | resource "aws_s3_bucket" "this" { 26 | bucket = var.name 27 | 28 | tags = { 29 | Name = "GitHub Management" 30 | Url = "https://github.com/pl-strflt/github-mgmt-template" 31 | } 32 | } 33 | 34 | resource "aws_s3_bucket_ownership_controls" "this" { 35 | bucket = aws_s3_bucket.this.id 36 | 37 | rule { 38 | object_ownership = "BucketOwnerPreferred" 39 | } 40 | } 41 | 42 | resource "aws_s3_bucket_acl" "this" { 43 | depends_on = [ aws_s3_bucket_ownership_controls.this ] 44 | 45 | bucket = aws_s3_bucket.this.id 46 | acl = "private" 47 | } 48 | 49 | resource "aws_dynamodb_table" "this" { 50 | name = var.name 51 | billing_mode = "PAY_PER_REQUEST" 52 | hash_key = "LockID" 53 | 54 | attribute { 55 | name = "LockID" 56 | type = "S" 57 | } 58 | 59 | tags = { 60 | Name = "GitHub Management" 61 | Url = "https://github.com/pl-strflt/github-mgmt-template" 62 | } 63 | } 64 | 65 | resource "aws_iam_user" "ro" { 66 | name = "${var.name}-ro" 67 | 68 | tags = { 69 | Name = "GitHub Management" 70 | Url = "https://github.com/pl-strflt/github-mgmt-template" 71 | } 72 | } 73 | 74 | resource "aws_iam_user" "rw" { 75 | name = "${var.name}-rw" 76 | 77 | tags = { 78 | Name = "GitHub Management" 79 | Url = "https://github.com/pl-strflt/github-mgmt-template" 80 | } 81 | } 82 | 83 | data "aws_iam_policy_document" "ro" { 84 | statement { 85 | actions = ["s3:ListBucket"] 86 | resources = ["${aws_s3_bucket.this.arn}"] 87 | effect = "Allow" 88 | } 89 | 90 | statement { 91 | actions = ["s3:GetObject"] 92 | resources = ["${aws_s3_bucket.this.arn}/*"] 93 | effect = "Allow" 94 | } 95 | 96 | statement { 97 | actions = ["dynamodb:GetItem"] 98 | resources = ["${aws_dynamodb_table.this.arn}"] 99 | effect = "Allow" 100 | } 101 | } 102 | 103 | data "aws_iam_policy_document" "rw" { 104 | statement { 105 | actions = ["s3:ListBucket"] 106 | resources = ["${aws_s3_bucket.this.arn}"] 107 | effect = "Allow" 108 | } 109 | 110 | statement { 111 | actions = [ 112 | "s3:GetObject", 113 | "s3:PutObject", 114 | "s3:DeleteObject", 115 | ] 116 | 117 | resources = ["${aws_s3_bucket.this.arn}/*"] 118 | effect = "Allow" 119 | } 120 | 121 | statement { 122 | actions = [ 123 | "dynamodb:GetItem", 124 | "dynamodb:PutItem", 125 | "dynamodb:DeleteItem", 126 | ] 127 | 128 | resources = ["${aws_dynamodb_table.this.arn}"] 129 | effect = "Allow" 130 | } 131 | } 132 | 133 | resource "aws_iam_user_policy" "ro" { 134 | name = "${var.name}-ro" 135 | user = aws_iam_user.ro.name 136 | 137 | policy = data.aws_iam_policy_document.ro.json 138 | } 139 | 140 | resource "aws_iam_user_policy" "rw" { 141 | name = "${var.name}-rw" 142 | user = aws_iam_user.rw.name 143 | 144 | policy = data.aws_iam_policy_document.rw.json 145 | } 146 | -------------------------------------------------------------------------------- /terraform/data.tf: -------------------------------------------------------------------------------- 1 | data "github_organization_teams" "this" { 2 | count = length(setintersection( 3 | toset(["github_team"]), 4 | toset(local.resource_types) 5 | )) == 0 ? 0 : 1 6 | } 7 | -------------------------------------------------------------------------------- /terraform/locals_override.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | resource_types = [ 3 | "github_repository", 4 | "github_branch_protection" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /terraform/providers.tf: -------------------------------------------------------------------------------- 1 | provider "github" { 2 | owner = local.organization 3 | write_delay_ms = var.write_delay_ms 4 | app_auth {} 5 | } 6 | -------------------------------------------------------------------------------- /terraform/resources.tf: -------------------------------------------------------------------------------- 1 | resource "github_membership" "this" { 2 | for_each = try(var.resources.github_membership, local.resources.github_membership) 3 | 4 | username = each.value.username 5 | role = each.value.role 6 | # downgrade_on_destroy = try(each.value.downgrade_on_destroy, null) 7 | 8 | lifecycle { 9 | ignore_changes = [] 10 | prevent_destroy = true 11 | } 12 | } 13 | 14 | resource "github_repository" "this" { 15 | for_each = try(var.resources.github_repository, local.resources.github_repository) 16 | 17 | name = each.value.name 18 | allow_auto_merge = try(each.value.allow_auto_merge, null) 19 | allow_merge_commit = try(each.value.allow_merge_commit, null) 20 | allow_rebase_merge = try(each.value.allow_rebase_merge, null) 21 | allow_squash_merge = try(each.value.allow_squash_merge, null) 22 | allow_update_branch = try(each.value.allow_update_branch, null) 23 | archive_on_destroy = try(each.value.archive_on_destroy, null) 24 | archived = try(each.value.archived, null) 25 | auto_init = try(each.value.auto_init, null) 26 | default_branch = try(each.value.default_branch, null) 27 | delete_branch_on_merge = try(each.value.delete_branch_on_merge, null) 28 | description = try(each.value.description, null) 29 | gitignore_template = try(each.value.gitignore_template, null) 30 | has_discussions = try(each.value.has_discussions, null) 31 | has_downloads = try(each.value.has_downloads, null) 32 | has_issues = try(each.value.has_issues, null) 33 | has_projects = try(each.value.has_projects, null) 34 | has_wiki = try(each.value.has_wiki, null) 35 | homepage_url = try(each.value.homepage_url, null) 36 | ignore_vulnerability_alerts_during_read = try(each.value.ignore_vulnerability_alerts_during_read, null) 37 | is_template = try(each.value.is_template, null) 38 | license_template = try(each.value.license_template, null) 39 | merge_commit_message = try(each.value.merge_commit_message, null) 40 | merge_commit_title = try(each.value.merge_commit_title, null) 41 | # private = try(each.value.private, null) 42 | squash_merge_commit_message = try(each.value.squash_merge_commit_message, null) 43 | squash_merge_commit_title = try(each.value.squash_merge_commit_title, null) 44 | topics = try(each.value.topics, null) 45 | visibility = try(each.value.visibility, null) 46 | vulnerability_alerts = try(each.value.vulnerability_alerts, null) 47 | web_commit_signoff_required = try(each.value.web_commit_signoff_required, null) 48 | 49 | dynamic "security_and_analysis" { 50 | for_each = try(each.value.security_and_analysis, []) 51 | 52 | content { 53 | dynamic "advanced_security" { 54 | for_each = security_and_analysis.value["advanced_security"] 55 | content { 56 | status = advanced_security.value["status"] 57 | } 58 | } 59 | dynamic "secret_scanning" { 60 | for_each = security_and_analysis.value["secret_scanning"] 61 | content { 62 | status = secret_scanning.value["status"] 63 | } 64 | } 65 | dynamic "secret_scanning_push_protection" { 66 | for_each = security_and_analysis.value["secret_scanning_push_protection"] 67 | content { 68 | status = secret_scanning_push_protection.value["status"] 69 | } 70 | } 71 | } 72 | } 73 | 74 | dynamic "pages" { 75 | for_each = try(each.value.pages, []) 76 | content { 77 | build_type = try(pages.value["build_type"], null) 78 | cname = try(pages.value["cname"], null) 79 | dynamic "source" { 80 | for_each = pages.value["source"] 81 | content { 82 | branch = source.value["branch"] 83 | path = try(source.value["path"], null) 84 | } 85 | } 86 | } 87 | } 88 | dynamic "template" { 89 | for_each = try(each.value.template, []) 90 | content { 91 | owner = template.value["owner"] 92 | repository = template.value["repository"] 93 | include_all_branches = try(template.value["include_all_branches"], null) 94 | } 95 | } 96 | 97 | lifecycle { 98 | ignore_changes = [] 99 | prevent_destroy = true 100 | } 101 | } 102 | 103 | resource "github_repository_collaborator" "this" { 104 | for_each = try(var.resources.github_repository_collaborator, local.resources.github_repository_collaborator) 105 | 106 | depends_on = [github_repository.this] 107 | 108 | repository = each.value.repository 109 | username = each.value.username 110 | permission = each.value.permission 111 | # permission_diff_suppression = try(each.value.permission_diff_suppression, null) 112 | 113 | lifecycle { 114 | ignore_changes = [] 115 | } 116 | } 117 | 118 | resource "github_branch_protection" "this" { 119 | for_each = try(var.resources.github_branch_protection, local.resources.github_branch_protection) 120 | 121 | pattern = each.value.pattern 122 | 123 | repository_id = lookup(each.value, "repository_id", lookup(lookup(github_repository.this, lower(lookup(each.value, "repository", "")), {}), "node_id", null)) 124 | 125 | allows_deletions = try(each.value.allows_deletions, null) 126 | allows_force_pushes = try(each.value.allows_force_pushes, null) 127 | enforce_admins = try(each.value.enforce_admins, null) 128 | force_push_bypassers = try(each.value.force_push_bypassers, null) 129 | lock_branch = try(each.value.lock_branch, null) 130 | require_conversation_resolution = try(each.value.require_conversation_resolution, null) 131 | require_signed_commits = try(each.value.require_signed_commits, null) 132 | required_linear_history = try(each.value.required_linear_history, null) 133 | 134 | dynamic "required_pull_request_reviews" { 135 | for_each = try(each.value.required_pull_request_reviews, []) 136 | content { 137 | dismiss_stale_reviews = try(required_pull_request_reviews.value["dismiss_stale_reviews"], null) 138 | dismissal_restrictions = try(required_pull_request_reviews.value["dismissal_restrictions"], null) 139 | pull_request_bypassers = try(required_pull_request_reviews.value["pull_request_bypassers"], null) 140 | require_code_owner_reviews = try(required_pull_request_reviews.value["require_code_owner_reviews"], null) 141 | require_last_push_approval = try(required_pull_request_reviews.value["require_last_push_approval"], null) 142 | required_approving_review_count = try(required_pull_request_reviews.value["required_approving_review_count"], null) 143 | restrict_dismissals = try(required_pull_request_reviews.value["restrict_dismissals"], null) 144 | } 145 | } 146 | dynamic "required_status_checks" { 147 | for_each = try(each.value.required_status_checks, null) 148 | content { 149 | contexts = try(required_status_checks.value["contexts"], null) 150 | strict = try(required_status_checks.value["strict"], null) 151 | } 152 | } 153 | dynamic "restrict_pushes" { 154 | for_each = try(each.value.restrict_pushes, []) 155 | content { 156 | blocks_creations = try(restrict_pushes.value["blocks_creations"], null) 157 | push_allowances = try(restrict_pushes.value["push_allowances"], null) 158 | } 159 | } 160 | } 161 | 162 | resource "github_team" "this" { 163 | for_each = try(var.resources.github_team, local.resources.github_team) 164 | 165 | name = each.value.name 166 | 167 | parent_team_id = try(try(element(data.github_organization_teams.this[0].teams, index(data.github_organization_teams.this[0].teams.*.name, each.value.parent_team_id)).id, each.value.parent_team_id), null) 168 | 169 | # create_default_maintainer = try(each.value.create_default_maintainer, null) 170 | description = try(each.value.description, null) 171 | # ldap_dn = try(each.value.ldap_dn, null) 172 | privacy = try(each.value.privacy, null) 173 | 174 | lifecycle { 175 | ignore_changes = [] 176 | } 177 | } 178 | 179 | resource "github_team_repository" "this" { 180 | for_each = try(var.resources.github_team_repository, local.resources.github_team_repository) 181 | 182 | depends_on = [github_repository.this] 183 | 184 | repository = each.value.repository 185 | permission = each.value.permission 186 | 187 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null)) 188 | 189 | lifecycle { 190 | ignore_changes = [] 191 | } 192 | } 193 | 194 | resource "github_team_membership" "this" { 195 | for_each = try(var.resources.github_team_membership, local.resources.github_team_membership) 196 | 197 | username = each.value.username 198 | role = each.value.role 199 | 200 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null)) 201 | 202 | lifecycle { 203 | ignore_changes = [] 204 | } 205 | } 206 | 207 | resource "github_repository_file" "this" { 208 | for_each = try(var.resources.github_repository_file, local.resources.github_repository_file) 209 | 210 | repository = each.value.repository 211 | file = each.value.file 212 | content = each.value.content 213 | # autocreate_branch = try(each.value.autocreate_branch, null) 214 | # autocreate_branch_source_branch = try(each.value.autocreate_branch_source_branch, null) 215 | # autocreate_branch_source_sha = try(each.value.autocreate_branch_source_sha, null) 216 | # Since 5.25.0 the branch attribute defaults to the default branch of the repository 217 | # branch = try(each.value.branch, null) 218 | branch = lookup(each.value, "branch", lookup(lookup(github_repository.this, each.value.repository, {}), "default_branch", null)) 219 | overwrite_on_create = try(each.value.overwrite_on_create, true) 220 | # Keep the defaults from 4.x 221 | commit_author = try(each.value.commit_author, "GitHub") 222 | commit_email = try(each.value.commit_email, "noreply@github.com") 223 | commit_message = try(each.value.commit_message, "chore: Update ${each.value.file} [skip ci]") 224 | 225 | lifecycle { 226 | ignore_changes = [] 227 | } 228 | } 229 | 230 | resource "github_issue_labels" "this" { 231 | for_each = try(var.resources.github_issue_labels, local.resources.github_issue_labels) 232 | 233 | depends_on = [github_repository.this] 234 | 235 | repository = each.value.repository 236 | 237 | dynamic "label" { 238 | for_each = try(each.value.label, []) 239 | content { 240 | color = try(label.value["color"], "7B42BC") 241 | description = try(label.value["description"], "") 242 | name = label.value["name"] 243 | } 244 | } 245 | 246 | lifecycle { 247 | ignore_changes = [] 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /terraform/resources_override.tf: -------------------------------------------------------------------------------- 1 | resource "github_repository" "this" { 2 | lifecycle { 3 | ignore_changes = [ 4 | allow_auto_merge, 5 | allow_merge_commit, 6 | allow_rebase_merge, 7 | allow_squash_merge, 8 | archive_on_destroy, 9 | archived, 10 | auto_init, 11 | default_branch, 12 | delete_branch_on_merge, 13 | description, 14 | gitignore_template, 15 | has_downloads, 16 | has_issues, 17 | has_projects, 18 | has_wiki, 19 | homepage_url, 20 | ignore_vulnerability_alerts_during_read, 21 | is_template, 22 | license_template, 23 | pages, 24 | template, 25 | topics, 26 | visibility, 27 | vulnerability_alerts, 28 | web_commit_signoff_required, 29 | ] 30 | } 31 | } 32 | 33 | resource "github_branch_protection" "this" { 34 | lifecycle { 35 | ignore_changes = [ 36 | allows_deletions, 37 | allows_force_pushes, 38 | enforce_admins, 39 | force_push_bypassers, 40 | require_conversation_resolution, 41 | require_signed_commits, 42 | required_linear_history, 43 | # required_pull_request_reviews, 44 | # required_status_checks, 45 | restrict_pushes, 46 | ] 47 | } 48 | } 49 | 50 | resource "github_team" "this" { 51 | lifecycle { 52 | ignore_changes = [ 53 | description, 54 | parent_team_id, 55 | privacy, 56 | ] 57 | } 58 | } 59 | 60 | resource "github_repository_file" "this" { 61 | lifecycle { 62 | ignore_changes = [ 63 | overwrite_on_create, 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /terraform/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | github = { 4 | source = "integrations/github" 5 | version = "~> 6.6.0" 6 | } 7 | } 8 | 9 | # https://github.com/hashicorp/terraform/issues/32329 10 | required_version = "~> 1.12.0" 11 | } 12 | -------------------------------------------------------------------------------- /terraform/terraform_override.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | region = "us-east-1" 4 | bucket = "github-mgmt" 5 | key = "terraform.tfstate" 6 | workspace_key_prefix = "org" 7 | dynamodb_table = "github-mgmt" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /terraform/variables.tf: -------------------------------------------------------------------------------- 1 | variable "write_delay_ms" { 2 | description = "Amount of time in milliseconds to sleep in between writes to GitHub API." 3 | type = number 4 | default = 1000 5 | } 6 | 7 | variable "resources" { 8 | description = "Resources to import." 9 | type = object({ 10 | github_membership = optional(map(any), {}) 11 | github_repository = optional(map(any), {}) 12 | github_repository_collaborator = optional(map(any), {}) 13 | github_branch_protection = optional(map(any), {}) 14 | github_team = optional(map(any), {}) 15 | github_team_repository = optional(map(any), {}) 16 | github_team_membership = optional(map(any), {}) 17 | github_repository_file = optional(map(any), {}) 18 | github_issue_labels = optional(map(any), {}) 19 | }) 20 | default = null 21 | } 22 | --------------------------------------------------------------------------------