├── .sync ├── scripts ├── .prettierignore ├── __tests__ │ ├── __resources__ │ │ ├── terraform │ │ │ ├── locals.tf │ │ │ ├── resources_override.tf │ │ │ ├── locals_override.tf │ │ │ └── resources.tf │ │ ├── .gitignore │ │ ├── files │ │ │ └── README.md │ │ └── github │ │ │ └── default.yml │ ├── resources │ │ └── repository-file.test.ts │ ├── sync.test.ts │ └── terraform │ │ └── state.test.ts ├── .eslintignore ├── src │ ├── terraform │ │ ├── schema.ts │ │ └── state.ts │ ├── actions │ │ ├── format-yaml-config.ts │ │ ├── shared │ │ │ ├── format.ts │ │ │ ├── set-property-in-all-repos.ts │ │ │ ├── add-file-to-all-repos.ts │ │ │ ├── protect-default-branches.ts │ │ │ ├── add-label-to-all-repos.ts │ │ │ ├── toggle-archived-repos.ts │ │ │ └── describe-access-changes.ts │ │ ├── add-need-author-input-label-to-all-repos.ts │ │ ├── find-sha-for-plan.ts │ │ ├── do-not-enforce-admins.ts │ │ ├── update-pull-requests.ts │ │ ├── fix-yaml-config.ts │ │ ├── sync-labels.ts │ │ └── remove-inactive-members.ts │ ├── env.ts │ ├── main.ts │ ├── sync.ts │ ├── utils.ts │ ├── yaml │ │ ├── schema.ts │ │ └── config.ts │ └── resources │ │ ├── resource.ts │ │ ├── team.ts │ │ ├── repository-label.ts │ │ ├── member.ts │ │ ├── repository-team.ts │ │ ├── repository-file.ts │ │ ├── team-member.ts │ │ ├── repository.ts │ │ ├── repository-collaborator.ts │ │ └── repository-branch-protection-rule.ts ├── tsconfig.build.json ├── jest.d.ts ├── .prettierrc.json ├── jest.config.js ├── add-need-author-input-label-to-all-repositories.sh ├── tsconfig.json ├── package.json ├── .gitignore ├── .eslintrc.json └── jest.setup.ts ├── terraform ├── providers.tf ├── data.tf ├── variables.tf ├── terraform.tf ├── locals_override.tf ├── terraform_override.tf ├── resources_override.tf ├── bootstrap │ ├── .terraform.lock.hcl │ └── aws.tf ├── .terraform.lock.hcl └── resources.tf ├── files ├── .github │ ├── dependabot.yml │ ├── workflows │ │ └── stale.yml │ └── helia_pull_request_template.md └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── default.md ├── workflows │ ├── stale.yml │ ├── upgrade.yml │ ├── update.yml │ ├── labels.yml │ ├── cleanup.yml │ ├── apply.yml │ ├── clean.yml │ ├── sync.yml │ ├── plan.yml │ └── fix.yml ├── actions │ ├── git-config-user │ │ └── action.yml │ └── git-push │ │ └── action.yml └── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── .gitignore ├── README.md ├── docs ├── STEWARDSHIP.md ├── EXAMPLE.yml ├── ABOUT.md ├── HOWTOS.md └── SETUP.md └── CHANGELOG.md /.sync: -------------------------------------------------------------------------------- 1 | 8311458498 2 | -------------------------------------------------------------------------------- /scripts/.prettierignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/locals.tf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/terraform/resources_override.tf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | jest.config.js 4 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/.gitignore: -------------------------------------------------------------------------------- 1 | !terraform.tfstate 2 | -------------------------------------------------------------------------------- /scripts/src/terraform/schema.ts: -------------------------------------------------------------------------------- 1 | export type StateSchema = any 2 | export type Id = string 3 | -------------------------------------------------------------------------------- /scripts/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["__tests__/**/*.ts", "jest.*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /scripts/src/actions/format-yaml-config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {format} from './shared/format' 3 | 4 | format() 5 | -------------------------------------------------------------------------------- /scripts/jest.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | var ResourceCounts: Record 3 | var ResourcesCount: number 4 | } 5 | 6 | export {} 7 | -------------------------------------------------------------------------------- /terraform/providers.tf: -------------------------------------------------------------------------------- 1 | provider "github" { 2 | owner = local.organization 3 | write_delay_ms = var.write_delay_ms 4 | app_auth {} 5 | } 6 | -------------------------------------------------------------------------------- /files/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/format.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | 3 | export async function format(): Promise { 4 | const config = Config.FromPath() 5 | config.save() 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Self-serve Request 4 | url: https://github.com/ipfs/github-mgmt/edit/master/github/ipfs.yml 5 | about: Propose a GitHub configuration change by modifying ipfs.yml 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/src/actions/add-need-author-input-label-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {addLabelToAllRepos} from './shared/add-label-to-all-repos' 3 | 4 | addLabelToAllRepos( 5 | 'need/author-input', 6 | 'ededed', 7 | 'Needs input from the original author' 8 | ) 9 | -------------------------------------------------------------------------------- /terraform/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | github = { 4 | source = "integrations/github" 5 | version = "5.25.0" 6 | } 7 | } 8 | 9 | # https://github.com/hashicorp/terraform/issues/32329 10 | required_version = "~> 1.2.9" 11 | } 12 | -------------------------------------------------------------------------------- /scripts/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest' 7 | }, 8 | verbose: true, 9 | setupFilesAfterEnv: ["/jest.setup.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /files/.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close and mark stale issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | stale: 13 | uses: pl-strflt/.github/.github/workflows/reusable-stale-issue.yml@v0.3 14 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close and mark stale issue 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: pl-strflt/.github/.github/workflows/reusable-stale-issue.yml@v0.3 15 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # The ipdx team is responsible for GitHub Management maintenance 2 | * @ipfs/ipdx 3 | 4 | # The github-mgmt stewards team is responsible for triaging/reviewing configuration change requests 5 | # The ipdx team is added here temporarily to witness use patterns in github-mgmt 6 | /github/ipfs.yml @ipfs/github-mgmt-stewards @ipfs/ipdx 7 | -------------------------------------------------------------------------------- /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 | ] 12 | } 13 | -------------------------------------------------------------------------------- /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_label" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /terraform/terraform_override.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | # account_id = < Managed by https://ipdx.co > 4 | region = "us-east-1" 5 | bucket = "github-as-code-interplanetary-shipyard" 6 | key = "terraform.tfstate" 7 | workspace_key_prefix = "org" 8 | dynamodb_table = "github-as-code-interplanetary-shipyard" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /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: '../terraform', 5 | FILES_DIR: '../files', 6 | 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/__tests__/resources/repository-file.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import {findFileByContent} from '../../src/resources/repository-file' 3 | 4 | test('finds file by content', async () => { 5 | const filePath = '__tests__/__resources__/files/README.md' 6 | const fileContent = fs.readFileSync(filePath).toString() 7 | const foundFilePath = findFileByContent( 8 | '__tests__/__resources__', 9 | fileContent 10 | ) 11 | expect(foundFilePath).toEqual(filePath) 12 | }) 13 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /scripts/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {sync} from './sync' 3 | import {State} from './terraform/state' 4 | import {Config} from './yaml/config' 5 | import {toggleArchivedRepos} from './actions/shared/toggle-archived-repos' 6 | 7 | async function runSync(): Promise { 8 | const state = await State.New() 9 | const config = Config.FromPath() 10 | 11 | await sync(state, config) 12 | 13 | config.save() 14 | } 15 | 16 | async function runToggleArchivedRepos(): Promise { 17 | await toggleArchivedRepos() 18 | } 19 | 20 | async function run(): Promise { 21 | await runSync() 22 | await runToggleArchivedRepos() 23 | } 24 | 25 | run() 26 | -------------------------------------------------------------------------------- /.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: pl-strflt/github-mgmt-template/.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 | -------------------------------------------------------------------------------- /scripts/add-need-author-input-label-to-all-repositories.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -u 5 | set -o pipefail 6 | 7 | root="$(realpath "$(dirname "$0")/..")" 8 | 9 | repository_file="$(jq '.' "$root/github/ipfs/repository_file.json")" 10 | 11 | while read repository; do 12 | echo "Checking if $repository has need/author-input label already" 13 | if ! gh api repos/ipfs/$repository/labels/need/author-input > /dev/null 2>&1; then 14 | echo "Adding label need/author-input to $repository" 15 | gh api -X POST repos/ipfs/$repository/labels -f name='need/author-input' -f color='ededed' -f description='Needs input from the original author' > /dev/null || true 16 | fi 17 | done <<< "$(jq -r 'keys | .[]' "$root/github/ipfs/repository.json")" 18 | -------------------------------------------------------------------------------- /scripts/src/sync.ts: -------------------------------------------------------------------------------- 1 | import {Resource, ResourceConstructors} from './resources/resource' 2 | import {State} from './terraform/state' 3 | import {Id} from './terraform/schema' 4 | import {Config} from './yaml/config' 5 | 6 | export async function sync(state: State, config: Config): Promise { 7 | const resources: [Id, Resource][] = [] 8 | for (const resourceClass of ResourceConstructors) { 9 | if (!state.isIgnored(resourceClass)) { 10 | const oldResources = config.getResources(resourceClass) 11 | const newResources = await resourceClass.FromGitHub(oldResources) 12 | resources.push(...newResources) 13 | } 14 | } 15 | 16 | await state.sync(resources) 17 | await state.refresh() 18 | 19 | const syncedResources = state.getAllResources() 20 | config.sync(syncedResources) 21 | } 22 | -------------------------------------------------------------------------------- /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 | auto_init, 10 | delete_branch_on_merge, 11 | gitignore_template, 12 | has_downloads, 13 | has_issues, 14 | has_projects, 15 | has_wiki, 16 | homepage_url, 17 | ignore_vulnerability_alerts_during_read, 18 | is_template, 19 | license_template, 20 | pages, 21 | template, 22 | vulnerability_alerts, 23 | ] 24 | } 25 | } 26 | 27 | resource "github_repository_file" "this" { 28 | lifecycle { 29 | ignore_changes = [ 30 | overwrite_on_create, 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Management via Terraform: ipfs 2 | 3 | This repository is responsible for managing GitHub configuration of `ipfs` organisation as code with Terraform. It was created from [github-mgmt-template](https://github.com/protocol/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__/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/src/actions/shared/set-property-in-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository} from '../../resources/repository' 3 | import * as core from '@actions/core' 4 | 5 | export async function setPropertyInAllRepos( 6 | name: keyof Repository, 7 | value: any, 8 | repositoryFilter: (repository: Repository) => boolean = () => true 9 | ): Promise { 10 | const config = Config.FromPath() 11 | 12 | const repositories = config 13 | .getResources(Repository) 14 | .filter(r => !r.archived) 15 | .filter(repositoryFilter) 16 | 17 | for (const repository of repositories) { 18 | const v = (repository as any)[name] 19 | if (v !== value) { 20 | ;(repository as any)[name] = value 21 | core.info( 22 | `Setting ${name} property to ${value} for ${repository.name} repository` 23 | ) 24 | config.addResource(repository) 25 | } 26 | } 27 | 28 | config.save() 29 | } 30 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-file-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository} from '../../resources/repository' 3 | import {RepositoryFile} from '../../resources/repository-file' 4 | import * as core from '@actions/core' 5 | 6 | export async function addFileToAllRepos( 7 | name: string, 8 | content: string = name, 9 | repositoryFilter: (repository: Repository) => boolean = () => true 10 | ): Promise { 11 | const config = Config.FromPath() 12 | 13 | const repositories = config 14 | .getResources(Repository) 15 | .filter(r => !r.archived) 16 | .filter(repositoryFilter) 17 | 18 | for (const repository of repositories) { 19 | const file = new RepositoryFile(repository.name, name) 20 | file.content = content 21 | if (!config.someResource(file)) { 22 | core.info(`Adding ${file.file} file to ${file.repository} repository`) 23 | config.addResource(file) 24 | } 25 | } 26 | 27 | config.save() 28 | } 29 | -------------------------------------------------------------------------------- /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_label" "this" { 42 | lifecycle { 43 | ignore_changes = [] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stewards Assistance Request 3 | about: Request assistance from this organization's GitHub Management Stewards team 4 | title: "[General] " 5 | labels: '' 6 | assignees: ipfs/github-mgmt-stewards 7 | --- 8 | 9 | ### Summary 10 | 11 | 12 | ### Why do you need this? 13 | 14 | 15 | ### What else do we need to know? 16 | 17 | 18 | **DRI:** myself 19 | 20 | -------------------------------------------------------------------------------- /scripts/src/actions/find-sha-for-plan.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {context} from '@actions/github' 3 | import * as core from '@actions/core' 4 | 5 | async function findShaForPlan() { 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 | } 17 | ) 18 | 19 | if (pulls.length === 0) { 20 | return '' 21 | } 22 | 23 | const pull = pulls[0] 24 | const commits = await github.client.paginate( 25 | github.client.pulls.listCommits, 26 | { 27 | ...context.repo, 28 | pull_number: pull.number 29 | } 30 | ) 31 | 32 | if (commits.length === 0) { 33 | return '' 34 | } 35 | 36 | return commits[commits.length - 1].sha 37 | } 38 | 39 | findShaForPlan().then(sha => { 40 | core.setOutput('result', sha) 41 | }) 42 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/protect-default-branches.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository, Visibility} from '../../resources/repository' 3 | import {RepositoryBranchProtectionRule} from '../../resources/repository-branch-protection-rule' 4 | 5 | export async function protectDefaultBranches( 6 | includePrivate: boolean = false 7 | ): Promise { 8 | const config = Config.FromPath() 9 | 10 | const repositories = config.getResources(Repository).filter(r => !r.archived) 11 | 12 | for (const repository of repositories) { 13 | if (includePrivate || repository.visibility !== Visibility.Private) { 14 | const rule = new RepositoryBranchProtectionRule( 15 | repository.name, 16 | repository.default_branch ?? 'main' 17 | ) 18 | if (!config.someResource(rule)) { 19 | console.log( 20 | `Adding branch protection rule for ${rule.pattern} to ${rule.repository} repository` 21 | ) 22 | config.addResource(rule) 23 | } 24 | } 25 | } 26 | 27 | config.save() 28 | } 29 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-label-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository} from '../../resources/repository' 3 | import {RepositoryLabel} from '../../resources/repository-label' 4 | import * as core from '@actions/core' 5 | 6 | export async function addLabelToAllRepos( 7 | name: string, 8 | color: string | undefined = undefined, 9 | description: string | undefined = undefined, 10 | repositoryFilter: (repository: Repository) => boolean = () => true 11 | ): Promise { 12 | const config = Config.FromPath() 13 | 14 | const repositories = config 15 | .getResources(Repository) 16 | .filter(r => !r.archived) 17 | .filter(repositoryFilter) 18 | 19 | for (const repository of repositories) { 20 | const label = new RepositoryLabel(repository.name, name) 21 | label.color = color 22 | label.description = description 23 | 24 | if (!config.someResource(label)) { 25 | core.info(`Adding ${label.name} file to ${label.repository} repository`) 26 | config.addResource(label) 27 | } 28 | } 29 | 30 | config.save() 31 | } 32 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* 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": ["ES2021.String", "ESNext"] 12 | }, 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/src/actions/do-not-enforce-admins.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../yaml/config' 2 | import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule' 3 | import {Repository} from '../resources/repository' 4 | import * as core from '@actions/core' 5 | 6 | export async function doNotEnforceAdmins( 7 | repositoryAndRuleFilter: ( 8 | repository: Repository, 9 | branchProtectionRule: RepositoryBranchProtectionRule 10 | ) => boolean = () => true 11 | ): Promise { 12 | const config = Config.FromPath() 13 | 14 | const repositories = config.getResources(Repository).filter(r => !r.archived) 15 | const rules = config 16 | .getResources(RepositoryBranchProtectionRule) 17 | .filter(r => r.enforce_admins) 18 | .filter(rule => { 19 | const repository = repositories.find( 20 | repo => repo.name === rule.repository 21 | ) 22 | if (!repository) { 23 | return false 24 | } 25 | return repositoryAndRuleFilter(repository, rule) 26 | }) 27 | 28 | for (const rule of rules) { 29 | core.info( 30 | `Disabling enforce_admins for ${rule.repository}@${rule.pattern} repository` 31 | ) 32 | rule.enforce_admins = false 33 | config.addResource(rule) 34 | } 35 | 36 | config.save() 37 | } 38 | -------------------------------------------------------------------------------- /scripts/src/actions/update-pull-requests.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {context} from '@actions/github' 3 | 4 | async function updatePullRequests() { 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/actions/shared/toggle-archived-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository} from '../../resources/repository' 3 | import {State} from '../../terraform/state' 4 | 5 | export async function toggleArchivedRepos(): Promise { 6 | const state = await State.New() 7 | const config = Config.FromPath() 8 | 9 | const resources = state.getAllResources() 10 | const stateRepositories = state.getResources(Repository) 11 | const configRepositories = config.getResources(Repository) 12 | 13 | for (const configRepository of configRepositories) { 14 | if (configRepository.archived) { 15 | config.removeResource(configRepository) 16 | const repository = new Repository(configRepository.name) 17 | repository.archived = true 18 | config.addResource(repository) 19 | } else { 20 | const stateRepository = stateRepositories.find( 21 | r => r.name === configRepository.name 22 | ) 23 | if (stateRepository !== undefined && stateRepository.archived) { 24 | config.addResource(stateRepository) 25 | for (const resource of resources) { 26 | if ( 27 | 'repository' in resource && 28 | (resource as any).repository === stateRepository.name 29 | ) { 30 | config.addResource(resource) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | config.save() 38 | } 39 | -------------------------------------------------------------------------------- /terraform/.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/integrations/github" { 5 | version = "5.25.0" 6 | constraints = "5.25.0" 7 | hashes = [ 8 | "h1:PV5RT2TVZIoaw9gLRM9iVMz1kKOoVYRz5AEsJ8OaXDM=", 9 | "zh:15e6e652b10f225b1d2a6c675e38e7e119b0a19a3009a88e5c6a478d65f02fc5", 10 | "zh:1a875d13507eca65d64bdac0f62910f6ce26fb1ef746bbf5f7b2bbe86c78441b", 11 | "zh:1f41a0053d13971d7c8fd2eb0b3ce263f65d7c2a393580f72ad83a28d562a45e", 12 | "zh:274fa0c49b3ef20f968cefd01d5e40af76437868ff7accc8430c0407b9f9d4b6", 13 | "zh:2b30f8de0f5e0f3157e368ae034fb62ee92798329afc5bf239d384e393ef6860", 14 | "zh:53e0da4b92ed389c766042fc60e72629896a2f6050ed3b7c036cc8fde8a22858", 15 | "zh:5a9e3900a0e7b62c7769e8c7e993e0f87229b0a0cc4fa3064fc79bfe73fa1ec9", 16 | "zh:7fa4a46ec94f6e1da93399955e8571ba0b20100e1bd7e34b5e75fbed7d43ae72", 17 | "zh:bc2f75e40c8743539199f09f0fc54ff091d1bb05398539642c3f75d869a251c5", 18 | "zh:d80a7bdfc4be101559c0bec516a73239291d18be522a2fa872fa8e07a65a3966", 19 | "zh:ea230531bb0fe2f778a72edb6bc6a80983a7a2c82a1c5f255a6ae11d45f714f2", 20 | "zh:f649cd014748ef498ccb8c07c4de1d55b736daeaeb8591395cd6b80a8502612a", 21 | "zh:fb94e009e8348bb016cde0b39b8e0968f60d5fd9cbc0be82bdb3ab498e5dea46", 22 | "zh:fbc119a51967c497d24a728d5afad72fb5f49494ef3645816336f898ac031661", 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /files/.github/helia_pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Title 2 | 3 | 9 | 10 | ## Description 11 | 12 | 18 | 19 | ## Notes & open questions 20 | 21 | 24 | 25 | ## Change checklist 26 | 27 | - [ ] I have performed a self-review of my own code 28 | - [ ] I have made corresponding changes to the documentation if necessary (this includes comments as well) 29 | - [ ] I have added tests that prove my fix is effective or that my feature works 30 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sync", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Sync", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc --project tsconfig.build.json", 9 | "format": "prettier --write '**/*.ts'", 10 | "format-check": "prettier --check '**/*.ts'", 11 | "lint": "eslint **/*.ts", 12 | "test": "jest --passWithNoTests", 13 | "all": "npm run build && npm run format && npm run lint && npm test", 14 | "schema": "npx ts-json-schema-generator --tsconfig tsconfig.json --path src/yaml/schema.ts --type ConfigSchema --out ../github/.schema.json", 15 | "main": "node lib/main.js" 16 | }, 17 | "dependencies": { 18 | "@actions/core": "^1.10.1", 19 | "@actions/exec": "^1.1.1", 20 | "@actions/github": "^6.0.0", 21 | "@octokit/auth-app": "^6.0.3", 22 | "@octokit/graphql": "^7.0.2", 23 | "@octokit/plugin-retry": "^6.0.1", 24 | "@octokit/plugin-throttling": "^8.2.0", 25 | "@octokit/rest": "^20.0.2", 26 | "class-transformer": "^0.5.1", 27 | "deep-diff": "^1.0.2", 28 | "hcl2-parser": "^1.0.3", 29 | "reflect-metadata": "^0.2.1", 30 | "yaml": "^2.3.4" 31 | }, 32 | "devDependencies": { 33 | "@types/deep-diff": "^1.0.5", 34 | "@types/jest": "^29.5.12", 35 | "@types/node": "^20.11.20", 36 | "@typescript-eslint/eslint-plugin": "^7.0.2", 37 | "@typescript-eslint/parser": "^7.0.2", 38 | "eslint": "^8.56.0", 39 | "eslint-plugin-github": "^4.10.1", 40 | "eslint-plugin-jest": "^27.9.0", 41 | "eslint-plugin-prettier": "^5.1.3", 42 | "jest": "^29.7.0", 43 | "prettier": "3.2.5", 44 | "ts-jest": "^29.1.2", 45 | "ts-json-schema-generator": "^1.5.0", 46 | "typescript": "^5.3.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/src/yaml/schema.ts: -------------------------------------------------------------------------------- 1 | import {Role as MemberRole} from '../resources/member' 2 | import {Repository} from '../resources/repository' 3 | import {RepositoryFile} from '../resources/repository-file' 4 | import {Permission as RepositoryCollaboratorPermission} from '../resources/repository-collaborator' 5 | import {Permission as RepositoryTeamPermission} from '../resources/repository-team' 6 | import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule' 7 | import {RepositoryLabel} from '../resources/repository-label' 8 | import {Role as TeamRole} from '../resources/team-member' 9 | import {Team} from '../resources/team' 10 | import * as YAML from 'yaml' 11 | import {yamlify} from '../utils' 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/.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 | -------------------------------------------------------------------------------- /docs/STEWARDSHIP.md: -------------------------------------------------------------------------------- 1 | _NOTE: This document is, and quite likely will always be, a work in progress. You can, and should, contribute to it to make as useful for GitHub Management Stewards as it can be._ 2 | 3 | # GitHub Management Steward 4 | 5 | `github-mgmt stewards` is a team of people who are responsible for managing the GitHub Management configuration for the organization. They have write access to the GitHub Management repository, can review change requests and merge changes to the `master` branch. 6 | 7 | Membership in the `github-mgmt stewards` team should be treated with **exactly as much care as having admin access to the organization**. 8 | 9 | ## What qualifications are expected from a GitHub Management Steward? 10 | 11 | - familiarity with [GitHub Management](ABOUT.md) 12 | - availability for reviews and merges of GitHub Management configuration changes 13 | - being a trusted member of the organization 14 | 15 | ## What is expected from a GitHub Management Steward? 16 | 17 | - review and merge changes to the GitHub Management configuration 18 | 19 | ### How to review and merge a GitHub Management pull request? 20 | 21 | - Wait for the `Comment` check to pass 22 | - Verify that the pull request contains only the changes you expect 23 | - Verify that the plan posted as a comment introduces **only** the changes you expect 24 | - Check if there are any open PRs created by the `Sync` workflow (titles starting with `sync`) and merge them first if there are 25 | - Ask the author of the pull request to provide more context if needed 26 | - Merge the pull request if everything checks out and verify that the `Apply` workflow initiated by the merge succeeded 27 | 28 | ## How to become a GitHub Management Steward? 29 | 30 | To become a GitHub Management Steward, you should meet the [qualifications](#what-qualifications-are-expected-from-a-github-management-steward) and ask one of the existing stewards to approve your change request which adds you to the `github-mgmt stewards` team. 31 | 32 | ## What do I do if...? 33 | 34 | GitHub Management is a relatively new project, GitHub APIs are constantly evolving and the GitHub Management configuration is a living document. You will likely encounter situations that are not covered by this document nor [HOWTOs](HOWTOS.md). If you do and you're unsure what to do, please reach out to @ipfs/ipdx. 35 | -------------------------------------------------------------------------------- /scripts/src/resources/resource.ts: -------------------------------------------------------------------------------- 1 | import {instanceToPlain} from 'class-transformer' 2 | import {Id, StateSchema} from '../terraform/schema' 3 | import {Path, ConfigSchema} from '../yaml/schema' 4 | import {Member} from './member' 5 | import {Repository} from './repository' 6 | import {RepositoryBranchProtectionRule} from './repository-branch-protection-rule' 7 | import {RepositoryCollaborator} from './repository-collaborator' 8 | import {RepositoryFile} from './repository-file' 9 | import {RepositoryLabel} from './repository-label' 10 | import {RepositoryTeam} from './repository-team' 11 | import {Team} from './team' 12 | import {TeamMember} from './team-member' 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 | new (...args: any[]): T 25 | // extracts all resources of specific type from the given YAML config 26 | FromConfig(config: ConfigSchema): T[] 27 | // extracts all resources of specific type from the given Terraform state 28 | FromState(state: StateSchema): T[] 29 | // retrieves all resources of specific type from GitHub API 30 | // it takes a list of resources of the same type as an argument 31 | // an implementation can choose to ignore it or use it to only check if given resources still exist 32 | // this is the case with repository files for example where we don't want to manage ALL the files thorugh GitHub Management 33 | FromGitHub(resources: T[]): Promise<[Id, Resource][]> 34 | StateType: string 35 | } 36 | 37 | export const ResourceConstructors: ResourceConstructor[] = [ 38 | Member, 39 | RepositoryBranchProtectionRule, 40 | RepositoryCollaborator, 41 | RepositoryFile, 42 | RepositoryLabel, 43 | RepositoryTeam, 44 | Repository, 45 | TeamMember, 46 | Team 47 | ] 48 | 49 | export function resourceToPlain( 50 | resource: T | undefined 51 | ): string | Record | undefined { 52 | if (resource !== undefined) { 53 | if (resource instanceof String) { 54 | return resource.toString() 55 | } else { 56 | return instanceToPlain(resource, {exposeUnsetFields: false}) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/src/actions/fix-yaml-config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {Repository} from '../resources/repository' 3 | import {RepositoryBranchProtectionRule} from '../resources/repository-branch-protection-rule' 4 | import {globToRegex} from '../utils' 5 | import {doNotEnforceAdmins} from './do-not-enforce-admins' 6 | import {addFileToAllRepos} from './shared/add-file-to-all-repos' 7 | import {format} from './shared/format' 8 | import {setPropertyInAllRepos} from './shared/set-property-in-all-repos' 9 | import {toggleArchivedRepos} from './shared/toggle-archived-repos' 10 | import {describeAccessChanges} from './shared/describe-access-changes' 11 | 12 | import * as core from '@actions/core' 13 | 14 | function isInitialised(repository: Repository) { 15 | return ![ 16 | '2022.ipfs.camp', 17 | 'go-data-transfer-bus', 18 | 'lightning-storm', 19 | 'helia-ipns' 20 | ].includes(repository.name) 21 | } 22 | 23 | function isHelia(repository: Repository) { 24 | return repository.name.startsWith('helia') 25 | } 26 | 27 | function isPublic(repository: Repository) { 28 | return repository.visibility === 'public' 29 | } 30 | 31 | function isFork(repository: Repository) { 32 | return [ 33 | 'uci' 34 | ].includes(repository.name) 35 | } 36 | 37 | async function run() { 38 | await addFileToAllRepos( 39 | '.github/workflows/stale.yml', 40 | '.github/workflows/stale.yml', 41 | r => isInitialised(r) && !isFork(r) 42 | ) 43 | 44 | await addFileToAllRepos( 45 | '.github/pull_request_template.md', 46 | '.github/helia_pull_request_template.md', 47 | r => isInitialised(r) && isHelia(r) 48 | ) 49 | 50 | await setPropertyInAllRepos( 51 | 'secret_scanning', 52 | true, 53 | r => isInitialised(r) && isPublic(r) 54 | ) 55 | await setPropertyInAllRepos( 56 | 'secret_scanning_push_protection', 57 | true, 58 | r => isInitialised(r) && isPublic(r) 59 | ) 60 | await doNotEnforceAdmins( 61 | (repository: Repository, rule: RepositoryBranchProtectionRule) => 62 | isInitialised(repository) && 63 | repository.default_branch !== undefined && 64 | globToRegex(rule.pattern).test(repository.default_branch) 65 | ) 66 | await toggleArchivedRepos() 67 | const accessChangesDescription = await describeAccessChanges() 68 | core.setOutput( 69 | 'comment', 70 | `The following access changes will be introduced as a result of applying the plan: 71 | 72 |
Access Changes 73 | 74 | \`\`\` 75 | ${accessChangesDescription} 76 | \`\`\` 77 | 78 |
` 79 | ) 80 | await format() 81 | } 82 | 83 | run() 84 | -------------------------------------------------------------------------------- /scripts/__tests__/sync.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import * as YAML from 'yaml' 4 | 5 | import * as config from '../src/yaml/config' 6 | import * as state from '../src/terraform/state' 7 | import {sync} from '../src/sync' 8 | import {GitHub} from '../src/github' 9 | import env from '../src/env' 10 | import {Resource} from '../src/resources/resource' 11 | import {RepositoryFile} from '../src/resources/repository-file' 12 | 13 | test('sync', async () => { 14 | const yamlConfig = new config.Config('{}') 15 | const tfConfig = await state.State.New() 16 | 17 | const expectedYamlConfig = config.Config.FromPath() 18 | 19 | await sync(tfConfig, yamlConfig) 20 | 21 | yamlConfig.format() 22 | 23 | expect(yamlConfig.toString()).toEqual(expectedYamlConfig.toString()) 24 | }) 25 | 26 | test('sync new repository file', async () => { 27 | const yamlSource = { 28 | repositories: { 29 | blog: { 30 | files: { 31 | 'README.md': { 32 | content: 'Hello, world!' 33 | } 34 | } 35 | } 36 | } 37 | } 38 | const tfSource = { 39 | values: { 40 | root_module: { 41 | resources: [] as any[] // eslint-disable-line @typescript-eslint/no-explicit-any 42 | } 43 | } 44 | } 45 | 46 | const loadStateMock = jest.spyOn(state, 'loadState') 47 | const getRepositoryFileMock = jest.spyOn(GitHub.github, 'getRepositoryFile') 48 | 49 | loadStateMock.mockImplementation(async () => JSON.stringify(tfSource)) 50 | getRepositoryFileMock.mockImplementation( 51 | async (repository: string, path: string) => ({ 52 | path, 53 | url: `https://github.com/${env.GITHUB_ORG}/${repository}/blob/main/${path}`, 54 | ref: 'main' 55 | }) 56 | ) 57 | 58 | const yamlConfig = new config.Config(YAML.stringify(yamlSource)) 59 | const tfConfig = await state.State.New() 60 | 61 | const addResourceMock = jest.spyOn(tfConfig, 'addResource') 62 | 63 | addResourceMock.mockImplementation( 64 | async (_id: string, resource: Resource) => { 65 | tfSource.values.root_module.resources.push({ 66 | mode: 'managed', 67 | type: RepositoryFile.StateType, 68 | values: { 69 | repository: (resource as RepositoryFile).repository, 70 | file: (resource as RepositoryFile).file, 71 | ...resource 72 | } 73 | }) 74 | } 75 | ) 76 | 77 | const expectedYamlConfig = new config.Config(YAML.stringify(yamlSource)) 78 | 79 | await sync(tfConfig, yamlConfig) 80 | 81 | yamlConfig.format() 82 | 83 | expect(yamlConfig.toString()).toEqual(expectedYamlConfig.toString()) 84 | }) 85 | -------------------------------------------------------------------------------- /scripts/src/resources/team.ts: -------------------------------------------------------------------------------- 1 | import {Resource} from './resource' 2 | import {Path, ConfigSchema} from '../yaml/schema' 3 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 4 | import {GitHub} from '../github' 5 | import {Id, StateSchema} from '../terraform/schema' 6 | 7 | export enum Privacy { 8 | PUBLIC = 'closed', 9 | PRIVATE = 'secret' 10 | } 11 | 12 | @Exclude() 13 | export class Team implements Resource { 14 | static StateType: string = 'github_team' 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 | parent_team_id = state.values.root_module.resources.find( 32 | (r: any) => 33 | r.type === 'github_team' && 34 | r.mode === 'managed' && 35 | `${r.values.id}` === `${parent_team_id}` 36 | )?.values?.name 37 | } 38 | teams.push( 39 | plainToClassFromExist(new Team(resource.values.name), { 40 | ...resource.values, 41 | parent_team_id 42 | }) 43 | ) 44 | } 45 | } 46 | } 47 | return teams 48 | } 49 | static FromConfig(config: ConfigSchema): Team[] { 50 | const teams: Team[] = [] 51 | if (config.teams !== undefined) { 52 | for (const [name, team] of Object.entries(config.teams)) { 53 | teams.push(plainToClassFromExist(new Team(name), team)) 54 | } 55 | } 56 | return teams 57 | } 58 | 59 | constructor(name: string) { 60 | this._name = name 61 | } 62 | 63 | private _name: string 64 | get name(): string { 65 | return this._name 66 | } 67 | 68 | @Expose() create_default_maintainer?: boolean 69 | @Expose() description?: string 70 | @Expose() parent_team_id?: string 71 | @Expose() privacy?: Privacy 72 | 73 | getSchemaPath(schema: ConfigSchema): Path { 74 | return ['teams', this.name] 75 | } 76 | 77 | getStateAddress(): string { 78 | return `${Team.StateType}.this["${this.name}"]` 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /scripts/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "i18n-text/no-en": "off", 12 | "eslint-comments/no-use": "off", 13 | "import/no-namespace": "off", 14 | "no-unused-vars": "off", 15 | "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], 16 | "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 17 | "@typescript-eslint/no-require-imports": "error", 18 | "@typescript-eslint/array-type": "error", 19 | "@typescript-eslint/await-thenable": "error", 20 | "@typescript-eslint/ban-ts-comment": "error", 21 | "camelcase": "off", 22 | "@typescript-eslint/consistent-type-assertions": "error", 23 | "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 24 | "@typescript-eslint/func-call-spacing": ["error", "never"], 25 | "@typescript-eslint/no-array-constructor": "error", 26 | "@typescript-eslint/no-empty-interface": "error", 27 | "@typescript-eslint/no-explicit-any": "error", 28 | "@typescript-eslint/no-extraneous-class": "error", 29 | "@typescript-eslint/no-for-in-array": "error", 30 | "@typescript-eslint/no-inferrable-types": "error", 31 | "@typescript-eslint/no-misused-new": "error", 32 | "@typescript-eslint/no-namespace": "error", 33 | "@typescript-eslint/no-non-null-assertion": "warn", 34 | "@typescript-eslint/no-unnecessary-qualifier": "error", 35 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 36 | "@typescript-eslint/no-useless-constructor": "error", 37 | "@typescript-eslint/no-var-requires": "error", 38 | "@typescript-eslint/prefer-for-of": "warn", 39 | "@typescript-eslint/prefer-function-type": "warn", 40 | "@typescript-eslint/prefer-includes": "error", 41 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 42 | "@typescript-eslint/promise-function-async": "error", 43 | "@typescript-eslint/require-array-sort-compare": "error", 44 | "@typescript-eslint/restrict-plus-operands": "error", 45 | "semi": "off", 46 | "@typescript-eslint/semi": ["error", "never"], 47 | "@typescript-eslint/type-annotation-spacing": "error", 48 | "@typescript-eslint/unbound-method": "error", 49 | "filenames/match-regex": ["error", "^([a-z0-9]+)([A-Z][a-z0-9]+)*(\\.test)?$"] 50 | }, 51 | "env": { 52 | "node": true, 53 | "es6": true, 54 | "jest/globals": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /scripts/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {Member} from './src/resources/member' 3 | import {Repository} from './src/resources/repository' 4 | import {Team} from './src/resources/team' 5 | import {RepositoryCollaborator} from './src/resources/repository-collaborator' 6 | import {RepositoryBranchProtectionRule} from './src/resources/repository-branch-protection-rule' 7 | import {RepositoryTeam} from './src/resources/repository-team' 8 | import {TeamMember} from './src/resources/team-member' 9 | import {RepositoryFile} from './src/resources/repository-file' 10 | import {RepositoryLabel} from './src/resources/repository-label' 11 | import {GitHub} from './src/github' 12 | 13 | jest.mock('./src/env', () => ({ 14 | TF_EXEC: 'false', 15 | TF_LOCK: 'false', 16 | TF_WORKING_DIR: '__tests__/__resources__/terraform', 17 | GITHUB_DIR: '__tests__/__resources__/github', 18 | FILES_DIR: '__tests__/__resources__/files', 19 | GITHUB_ORG: 'default' 20 | })) 21 | 22 | GitHub.github = { 23 | listMembers: async () => { 24 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 25 | }, 26 | listRepositories: async () => { 27 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 28 | }, 29 | listTeams: async () => { 30 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 31 | }, 32 | listRepositoryCollaborators: async () => { 33 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 34 | }, 35 | listRepositoryBranchProtectionRules: async () => { 36 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 37 | }, 38 | listTeamRepositories: async () => { 39 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 40 | }, 41 | listTeamMembers: async () => { 42 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 43 | }, 44 | getRepositoryFile: async (_repository: string, _path: string) => { 45 | return undefined 46 | }, 47 | listInvitations: async () => { 48 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 49 | }, 50 | listRepositoryInvitations: async () => { 51 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 52 | }, 53 | listTeamInvitations: async () => { 54 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 55 | }, 56 | listRepositoryLabels: async () => { 57 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 58 | } 59 | } as GitHub 60 | 61 | global.ResourceCounts = { 62 | [Member.name]: 2, 63 | [Repository.name]: 7, 64 | [Team.name]: 2, 65 | [RepositoryCollaborator.name]: 1, 66 | [RepositoryBranchProtectionRule.name]: 1, 67 | [RepositoryTeam.name]: 7, 68 | [TeamMember.name]: 2, 69 | [RepositoryFile.name]: 1, 70 | [RepositoryLabel.name]: 3 71 | } 72 | global.ResourcesCount = Object.values(global.ResourceCounts).reduce( 73 | (a, b) => a + b, 74 | 0 75 | ) 76 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-label.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 2 | import {Path, ConfigSchema} from '../yaml/schema' 3 | import {Resource} from './resource' 4 | import {GitHub} from '../github' 5 | import {Id, StateSchema} from '../terraform/schema' 6 | 7 | @Exclude() 8 | export class RepositoryLabel implements Resource { 9 | static StateType: string = 'github_issue_label' 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}:${label.label.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 | labels.push( 33 | plainToClassFromExist( 34 | new RepositoryLabel( 35 | resource.values.repository, 36 | resource.values.name 37 | ), 38 | resource.values 39 | ) 40 | ) 41 | } 42 | } 43 | } 44 | return labels 45 | } 46 | static FromConfig(config: ConfigSchema): RepositoryLabel[] { 47 | const labels: RepositoryLabel[] = [] 48 | if (config.repositories !== undefined) { 49 | for (const [repository_name, repository] of Object.entries( 50 | config.repositories 51 | )) { 52 | if (repository.labels !== undefined) { 53 | for (const [name, label] of Object.entries(repository.labels)) { 54 | labels.push( 55 | plainToClassFromExist( 56 | new RepositoryLabel(repository_name, name), 57 | label 58 | ) 59 | ) 60 | } 61 | } 62 | } 63 | } 64 | return labels 65 | } 66 | constructor(repository: string, name: string) { 67 | this._repository = repository 68 | this._name = name 69 | } 70 | 71 | private _repository: string 72 | get repository(): string { 73 | return this._repository 74 | } 75 | private _name: string 76 | get name(): string { 77 | return this._name 78 | } 79 | 80 | @Expose() color?: string 81 | @Expose() description?: string 82 | 83 | getSchemaPath(_schema: ConfigSchema): Path { 84 | return ['repositories', this.repository, 'labels', this.name] 85 | } 86 | 87 | getStateAddress(): string { 88 | return `${RepositoryLabel.StateType}.this["${this.repository}:${this.name}"]` 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /scripts/__tests__/terraform/state.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {State} from '../../src/terraform/state' 4 | import {Resource, ResourceConstructors} from '../../src/resources/resource' 5 | import {Repository} from '../../src/resources/repository' 6 | import {Id} from '../../src/terraform/schema' 7 | 8 | test('can retrieve resources from tf state', async () => { 9 | const config = await State.New() 10 | 11 | const resources = [] 12 | for (const resourceClass of ResourceConstructors) { 13 | const classResources = config.getResources(resourceClass) 14 | expect(classResources).toHaveLength( 15 | global.ResourceCounts[resourceClass.name] 16 | ) 17 | resources.push(...classResources) 18 | } 19 | 20 | expect(resources).toHaveLength(global.ResourcesCount) 21 | }) 22 | 23 | test('can ignore resource types', async () => { 24 | const config = await State.New() 25 | 26 | expect(config.isIgnored(Repository)).toBe(false) 27 | 28 | config['_ignoredTypes'] = ['github_repository'] 29 | await config.refresh() 30 | 31 | expect(config.isIgnored(Repository)).toBe(true) 32 | }) 33 | 34 | test('can ignore resource properties', async () => { 35 | const config = await State.New() 36 | 37 | const resource = config.getResources(Repository)[0] 38 | expect(resource.description).toBeDefined() 39 | 40 | config['_ignoredProperties'] = {github_repository: ['description']} 41 | await config.refresh() 42 | 43 | const refreshedResource = config.getResources(Repository)[0] 44 | expect(refreshedResource.description).toBeUndefined() 45 | }) 46 | 47 | test('can add and remove resources through sync', async () => { 48 | const config = await State.New() 49 | 50 | let addResourceSpy = jest.spyOn(config, 'addResource') 51 | let removeResourceAtSpy = jest.spyOn(config, 'removeResourceAt') 52 | 53 | let desiredResources: [Id, Resource][] = [] 54 | let resources = config.getAllResources() 55 | 56 | await config.sync(desiredResources) 57 | 58 | expect(addResourceSpy).not.toHaveBeenCalled() 59 | expect(removeResourceAtSpy).toHaveBeenCalledTimes(resources.length) 60 | addResourceSpy.mockReset() 61 | removeResourceAtSpy.mockReset() 62 | 63 | for (const resource of resources) { 64 | desiredResources.push(['id', resource]) 65 | } 66 | 67 | await config.sync(desiredResources) 68 | expect(addResourceSpy).toHaveBeenCalledTimes(1) // adding github-mgmt/readme.md 69 | expect(removeResourceAtSpy).toHaveBeenCalledTimes(1) // removing github-mgmt/README.md 70 | addResourceSpy.mockReset() 71 | removeResourceAtSpy.mockReset() 72 | 73 | desiredResources.push(['id', new Repository('test')]) 74 | desiredResources.push(['id', new Repository('test2')]) 75 | desiredResources.push(['id', new Repository('test3')]) 76 | desiredResources.push(['id', new Repository('test4')]) 77 | 78 | await config.sync(desiredResources) 79 | expect(addResourceSpy).toHaveBeenCalledTimes( 80 | 1 + desiredResources.length - resources.length 81 | ) 82 | expect(removeResourceAtSpy).toHaveBeenCalledTimes(1) 83 | }) 84 | -------------------------------------------------------------------------------- /scripts/src/resources/member.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {Id, StateSchema} from '../terraform/schema' 3 | import env from '../env' 4 | import {Path, ConfigSchema} from '../yaml/schema' 5 | import {Resource} from './resource' 6 | 7 | export enum Role { 8 | Admin = 'admin', 9 | Member = 'member' 10 | } 11 | 12 | export class Member extends String implements Resource { 13 | static StateType: string = 'github_membership' 14 | static async FromGitHub(_members: Member[]): Promise<[Id, Member][]> { 15 | const github = await GitHub.getGitHub() 16 | const invitations = await github.listInvitations() 17 | const members = await github.listMembers() 18 | const result: [Id, Member][] = [] 19 | for (const invitation of invitations) { 20 | if (invitation.role === 'billing_manager') { 21 | throw new Error(`Member role 'billing_manager' is not supported.`) 22 | } 23 | const role = invitation.role === 'admin' ? Role.Admin : Role.Member 24 | result.push([ 25 | `${env.GITHUB_ORG}:${invitation.login}`, 26 | new Member(invitation.login!, role) 27 | ]) 28 | } 29 | for (const member of members) { 30 | if (member.role === 'billing_manager') { 31 | throw new Error(`Member role 'billing_manager' is not supported.`) 32 | } 33 | result.push([ 34 | `${env.GITHUB_ORG}:${member.user!.login}`, 35 | new Member(member.user!.login, member.role as Role) 36 | ]) 37 | } 38 | return result 39 | } 40 | static FromState(state: StateSchema): Member[] { 41 | const members: Member[] = [] 42 | if (state.values?.root_module?.resources !== undefined) { 43 | for (const resource of state.values.root_module.resources) { 44 | if (resource.type === Member.StateType && resource.mode === 'managed') { 45 | members.push( 46 | new Member(resource.values.username, resource.values.role) 47 | ) 48 | } 49 | } 50 | } 51 | return members 52 | } 53 | static FromConfig(config: ConfigSchema): Member[] { 54 | const members: Member[] = [] 55 | if (config.members !== undefined) { 56 | for (const [role, usernames] of Object.entries(config.members)) { 57 | for (const username of usernames ?? []) { 58 | members.push(new Member(username, role as Role)) 59 | } 60 | } 61 | } 62 | return members 63 | } 64 | 65 | constructor(username: string, role: Role) { 66 | super(username) 67 | this._username = username 68 | this._role = role 69 | } 70 | 71 | private _username: string 72 | get username(): string { 73 | return this._username 74 | } 75 | private _role: Role 76 | get role(): Role { 77 | return this._role 78 | } 79 | 80 | getSchemaPath(schema: ConfigSchema): Path { 81 | const members = schema.members?.[this.role] ?? [] 82 | const index = members.indexOf(this.username) 83 | return ['members', this.role, index === -1 ? members.length : index] 84 | } 85 | 86 | getStateAddress(): string { 87 | return `${Member.StateType}.this["${this.username}"]` 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /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 | - clean workflow which removes resources from state 10 | - information on how to handle private GitHub Management repository 11 | - warning about GitHub Management repository access 12 | - PR template 13 | - examples to HOWTOs 14 | - repository_file support 15 | - repository.default_branch support 16 | - weekly schedule to the synchronization workflow 17 | - fix workflow which executes user defined config transforms on PRs and after Apply 18 | - shared config fix rule which adds missing default branch protections 19 | - shared action for adding a file to all repositories 20 | - shared action for adding a label to all repositories 21 | - issue_label support 22 | - new args for repositories and branch protection rules 23 | 24 | ### Changed 25 | - Synchronization script: to use GitHub API directly instead of relying on TF GH Provider's Data Sources 26 | - Configuration: replaced multiple JSONs with a single, unified YAML 27 | - Synchronization script: rewrote the script in JS 28 | - Upgrade (reusable) workflow: included docs and CHANGELOG in the upgrades 29 | - README: extracted sections to separate docs 30 | - GitHub Provider: upgraded to v4.23.0 31 | - Upgrade workflows: accept github-mgmt-template ref to upgrade to 32 | - Commit message for repository files: added chore: prefix and [skip ci] suffix 33 | - scripts: to export tf resource definitions and always sort before save 34 | - plan: to be triggered on pull_request_target 35 | - plan: to only check out github directory from the PR 36 | - plan: to wait for Apply workflow runs to finish 37 | - defaults: not to ignore any properties by default 38 | - add-file-to-all-repos: to accept a repo filter instead of an repo exclude list 39 | - sync: to push changes directly to the branch 40 | - automated commit messages: to include github run id information 41 | - apply: not to use deprecated GitHub API anymore 42 | - workflows: not to use deprecated GitHub Actions runners anymore 43 | - workflows: not to use deprecated GitHub Actions expressions anymore 44 | - tf: to prevent destroy of membership and repository resources 45 | - apply: find sha for plan using proper credentials 46 | 47 | ### Fixed 48 | - links to supported resources in HOWTOs 49 | - posting PR comments when terraform plan output is very long 50 | - PR parsing in the update workflow 51 | - array head retrieval in scripts 52 | - team imports 53 | - parent_team_id retrieval from state 54 | - saving config sync result 55 | - how dry run flag is passed in the clean workflow 56 | - how sync invalidates PR plans 57 | - support for pull_request_bypassers in branch protection rules 58 | - how repository files are imported 59 | - how sync handles ignored types 60 | - how indices are represented in the state (always lowercase) 61 | - how sync handles pending invitations (now it does not ignore them) 62 | - removed references to other resources from for_each expressions 63 | - downgraded terraform to 1.2.9 to fix an import bug affecting for_each expressions 64 | -------------------------------------------------------------------------------- /.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@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 71 | with: 72 | terraform_version: 1.2.9 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 | -------------------------------------------------------------------------------- /scripts/src/actions/sync-labels.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {Octokit} from '@octokit/rest' 3 | import {GitHub} from '../github' 4 | import env from '../env' 5 | import * as core from '@actions/core' 6 | import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types' // eslint-disable-line import/named 7 | 8 | const Endpoints = new Octokit() 9 | type Labels = GetResponseDataTypeFromEndpointMethod< 10 | typeof Endpoints.issues.getLabel 11 | >[] 12 | 13 | async function getLabels(repo: string): Promise { 14 | // initialize GitHub client 15 | const github = await GitHub.getGitHub() 16 | 17 | // use the GitHub client to fetch the list of labels from js-libp2p 18 | const labels = await github.client.paginate( 19 | github.client.issues.listLabelsForRepo, 20 | { 21 | owner: env.GITHUB_ORG, 22 | repo: repo 23 | } 24 | ) 25 | 26 | return labels 27 | } 28 | 29 | async function addLabel( 30 | repo: string, 31 | name: string, 32 | color: string, 33 | description: string | undefined 34 | ) { 35 | // initialize GitHub client 36 | const github = await GitHub.getGitHub() 37 | 38 | await github.client.issues.createLabel({ 39 | owner: env.GITHUB_ORG, 40 | repo: repo, 41 | name: name, 42 | color: color, 43 | description: description 44 | }) 45 | } 46 | 47 | async function removeLabel(repo: string, name: string) { 48 | // initialize GitHub client 49 | const github = await GitHub.getGitHub() 50 | 51 | await github.client.issues.deleteLabel({ 52 | owner: env.GITHUB_ORG, 53 | repo: repo, 54 | name: name 55 | }) 56 | } 57 | 58 | async function sync() { 59 | const sourceRepo = process.env.SOURCE_REPOSITORY 60 | const targetRepos = process.env.TARGET_REPOSITORIES?.split(',')?.map(r => 61 | r.trim() 62 | ) 63 | const addLabels = process.env.ADD_LABELS == 'true' 64 | const removeLabels = process.env.REMOVE_LABELS == 'true' 65 | 66 | if (!sourceRepo) { 67 | throw new Error('SOURCE_REPOSITORY environment variable not set') 68 | } 69 | 70 | if (!targetRepos) { 71 | throw new Error('TARGET_REPOSITORIES environment variable not set') 72 | } 73 | 74 | const sourceLabels = await getLabels(sourceRepo) 75 | core.info( 76 | `Found the following labels in ${sourceRepo}: ${sourceLabels 77 | .map(l => l.name) 78 | .join(', ')}` 79 | ) 80 | 81 | for (const repo of targetRepos) { 82 | const targetLabels = await getLabels(repo) 83 | core.info( 84 | `Found the following labels in ${repo}: ${targetLabels 85 | .map(l => l.name) 86 | .join(', ')}` 87 | ) 88 | 89 | if (removeLabels) { 90 | for (const label of targetLabels) { 91 | if (!sourceLabels.find(l => l.name === label.name)) { 92 | core.info(`Removing ${label.name} label from ${repo} repository`) 93 | await removeLabel(repo, label.name) 94 | } 95 | } 96 | } 97 | 98 | if (addLabels) { 99 | for (const label of sourceLabels) { 100 | if (!targetLabels.some(l => l.name === label.name)) { 101 | core.info(`Adding ${label.name} label to ${repo} repository`) 102 | await addLabel( 103 | repo, 104 | label.name, 105 | label.color, 106 | label.description || undefined 107 | ) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | sync() 115 | -------------------------------------------------------------------------------- /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.2.9" 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 | -------------------------------------------------------------------------------- /.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@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 75 | with: 76 | terraform_version: 1.2.9 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 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-team.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {Id, StateSchema} from '../terraform/schema' 3 | import {Path, ConfigSchema} from '../yaml/schema' 4 | import {Resource} from './resource' 5 | import {Team} from './team' 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: string = 'github_team_repository' 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: any) => 46 | r.type === Team.StateType && 47 | resource.mode === 'managed' && 48 | r.index === teamIndex 49 | ) 50 | teams.push( 51 | new RepositoryTeam( 52 | resource.values.repository, 53 | team.values.name || teamIndex, 54 | resource.values.permission 55 | ) 56 | ) 57 | } 58 | } 59 | } 60 | return teams 61 | } 62 | static FromConfig(config: ConfigSchema): RepositoryTeam[] { 63 | const teams: RepositoryTeam[] = [] 64 | if (config.repositories !== undefined) { 65 | for (const [repository_name, repository] of Object.entries( 66 | config.repositories 67 | )) { 68 | if (repository.teams !== undefined) { 69 | for (const [permission, team_names] of Object.entries( 70 | repository.teams 71 | )) { 72 | for (const team_name of team_names ?? []) { 73 | teams.push( 74 | new RepositoryTeam( 75 | repository_name, 76 | team_name, 77 | permission as Permission 78 | ) 79 | ) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | return teams 86 | } 87 | constructor(repository: string, team: string, permission: Permission) { 88 | super(team) 89 | this._repository = repository 90 | this._team = team 91 | this._permission = permission 92 | } 93 | 94 | private _repository: string 95 | get repository(): string { 96 | return this._repository 97 | } 98 | private _team: string 99 | get team(): string { 100 | return this._team 101 | } 102 | private _permission: Permission 103 | get permission(): Permission { 104 | return this._permission 105 | } 106 | 107 | getSchemaPath(schema: ConfigSchema): Path { 108 | const teams = 109 | schema.repositories?.[this.repository]?.teams?.[this.permission] || [] 110 | const index = teams.indexOf(this.team) 111 | return [ 112 | 'repositories', 113 | this.repository, 114 | 'teams', 115 | this.permission, 116 | index === -1 ? teams.length : index 117 | ] 118 | } 119 | 120 | getStateAddress(): string { 121 | return `${RepositoryTeam.StateType}.this["${this.team}:${this.repository}"]` 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-file.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist} from 'class-transformer' 2 | import {Path, ConfigSchema} from '../yaml/schema' 3 | import {Resource} from './resource' 4 | import {GitHub} from '../github' 5 | import {Id, StateSchema} from '../terraform/schema' 6 | import env from '../env' 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: string = 'github_repository_file' 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/team-member.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {Id, StateSchema} from '../terraform/schema' 3 | import {Path, ConfigSchema} from '../yaml/schema' 4 | import {Resource} from './resource' 5 | import {Team} from './team' 6 | 7 | export enum Role { 8 | Maintainer = 'maintainer', 9 | Member = 'member' 10 | } 11 | 12 | export class TeamMember extends String implements Resource { 13 | static StateType: string = 'github_team_membership' 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 | const member = _members.find( 21 | m => 22 | m.team === invitation.team.name && 23 | m.username === invitation.invitation.login! 24 | ) 25 | result.push([ 26 | `${invitation.team.id}:${invitation.invitation.login}`, 27 | new TeamMember( 28 | invitation.team.name, 29 | invitation.invitation.login!, 30 | member?.role || Role.Member 31 | ) 32 | ]) 33 | } 34 | for (const member of members) { 35 | result.push([ 36 | `${member.team.id}:${member.member.login}`, 37 | new TeamMember( 38 | member.team.name, 39 | member.member.login, 40 | member.membership.role as Role 41 | ) 42 | ]) 43 | } 44 | return result 45 | } 46 | static FromState(state: StateSchema): TeamMember[] { 47 | const members: TeamMember[] = [] 48 | if (state.values?.root_module?.resources !== undefined) { 49 | for (const resource of state.values.root_module.resources) { 50 | if ( 51 | resource.type === TeamMember.StateType && 52 | resource.mode === 'managed' 53 | ) { 54 | const teamIndex = resource.index.split(`:`).slice(0, -1).join(`:`) 55 | const team = state.values.root_module.resources.find( 56 | (r: any) => 57 | r.type === Team.StateType && 58 | resource.mode === 'managed' && 59 | r.index === teamIndex 60 | ) 61 | members.push( 62 | new TeamMember( 63 | team.values.name || teamIndex, 64 | resource.values.username, 65 | resource.values.role 66 | ) 67 | ) 68 | } 69 | } 70 | } 71 | return members 72 | } 73 | static FromConfig(config: ConfigSchema): TeamMember[] { 74 | const members: TeamMember[] = [] 75 | if (config.teams !== undefined) { 76 | for (const [team_name, team] of Object.entries(config.teams)) { 77 | if (team.members !== undefined) { 78 | for (const [role, usernames] of Object.entries(team.members)) { 79 | for (const username of usernames ?? []) { 80 | members.push(new TeamMember(team_name, username, role as Role)) 81 | } 82 | } 83 | } 84 | } 85 | } 86 | return members 87 | } 88 | 89 | constructor(team: string, username: string, role: Role) { 90 | super(username) 91 | this._team = team 92 | this._username = username 93 | this._role = role 94 | } 95 | 96 | private _team: string 97 | get team(): string { 98 | return this._team 99 | } 100 | private _username: string 101 | get username(): string { 102 | return this._username 103 | } 104 | private _role: Role 105 | get role(): Role { 106 | return this._role 107 | } 108 | 109 | getSchemaPath(schema: ConfigSchema): Path { 110 | const members = schema.teams?.[this.team]?.members?.[this.role] || [] 111 | const index = members.indexOf(this.username) 112 | return [ 113 | 'teams', 114 | this.team, 115 | 'members', 116 | this.role, 117 | index === -1 ? members.length : index 118 | ] 119 | } 120 | 121 | getStateAddress(): string { 122 | return `${TeamMember.StateType}.this["${this.team}:${this.username}"]` 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.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@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 69 | with: 70 | terraform_version: 1.2.9 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@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 111 | with: 112 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 113 | installation_id: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 114 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 115 | - name: Checkout 116 | uses: actions/checkout@v4 117 | with: 118 | token: ${{ steps.token.outputs.token }} 119 | - uses: ./.github/actions/git-config-user 120 | - env: 121 | WORKSPACES: ${{ needs.prepare.outputs.workspaces }} 122 | run: | 123 | echo "${GITHUB_RUN_ID}" > .sync 124 | git add .sync 125 | git commit --message="sync@${GITHUB_RUN_ID}" 126 | while read workspace; do 127 | workspace_branch="${GITHUB_REF_NAME}-sync-${workspace}" 128 | git fetch origin "${workspace_branch}" 129 | git merge --strategy-option=theirs "origin/${workspace_branch}" 130 | git push origin --delete "${workspace_branch}" 131 | done <<< "$(jq -r '.[]' <<< "${WORKSPACES}")" 132 | - run: git push origin "${GITHUB_REF_NAME}" --force 133 | -------------------------------------------------------------------------------- /scripts/src/resources/repository.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer' 2 | import {GitHub} from '../github' 3 | import {Id, StateSchema} from '../terraform/schema' 4 | import {Path, ConfigSchema} from '../yaml/schema' 5 | import {Resource} from './resource' 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() cname?: string 17 | } 18 | 19 | @Exclude() 20 | class Template { 21 | @Expose() owner?: string 22 | @Expose() repository?: string 23 | } 24 | 25 | export enum Visibility { 26 | Private = 'private', 27 | Public = 'public' 28 | } 29 | 30 | @Exclude() 31 | export class Repository implements Resource { 32 | static StateType: string = 'github_repository' 33 | static async FromGitHub( 34 | _repositories: Repository[] 35 | ): Promise<[Id, Repository][]> { 36 | const github = await GitHub.getGitHub() 37 | const repositories = await github.listRepositories() 38 | const result: [Id, Repository][] = [] 39 | for (const repository of repositories) { 40 | result.push([repository.name, new Repository(repository.name)]) 41 | } 42 | return result 43 | } 44 | static FromState(state: StateSchema): Repository[] { 45 | const repositories: Repository[] = [] 46 | if (state.values?.root_module?.resources !== undefined) { 47 | for (const resource of state.values.root_module.resources) { 48 | if ( 49 | resource.type === Repository.StateType && 50 | resource.mode === 'managed' 51 | ) { 52 | const pages = { 53 | ...resource.values.pages?.at(0), 54 | source: {...resource.values.pages?.at(0)?.source?.at(0)} 55 | } 56 | const template = resource.values.template?.at(0) 57 | const security_and_analysis = 58 | resource.values.security_and_analysis?.at(0) 59 | const advanced_security = 60 | security_and_analysis?.advanced_security?.at(0)?.status === 61 | 'enabled' 62 | const secret_scanning = 63 | security_and_analysis?.secret_scanning?.at(0)?.status === 'enabled' 64 | const secret_scanning_push_protection = 65 | security_and_analysis?.secret_scanning_push_protection?.at(0) 66 | ?.status === 'enabled' 67 | repositories.push( 68 | plainToClassFromExist(new Repository(resource.values.name), { 69 | ...resource.values, 70 | pages, 71 | template, 72 | advanced_security, 73 | secret_scanning, 74 | secret_scanning_push_protection 75 | }) 76 | ) 77 | } 78 | } 79 | } 80 | return repositories 81 | } 82 | static FromConfig(config: ConfigSchema): Repository[] { 83 | const repositories: Repository[] = [] 84 | if (config.repositories !== undefined) { 85 | for (const [name, repository] of Object.entries(config.repositories)) { 86 | repositories.push( 87 | plainToClassFromExist(new Repository(name), repository) 88 | ) 89 | } 90 | } 91 | return repositories 92 | } 93 | 94 | constructor(name: string) { 95 | this._name = name 96 | } 97 | 98 | private _name: string 99 | get name(): string { 100 | return this._name 101 | } 102 | 103 | @Expose() allow_auto_merge?: boolean 104 | @Expose() allow_merge_commit?: boolean 105 | @Expose() allow_rebase_merge?: boolean 106 | @Expose() allow_squash_merge?: boolean 107 | @Expose() allow_update_branch?: boolean 108 | @Expose() archive_on_destroy?: boolean 109 | @Expose() archived?: boolean 110 | @Expose() auto_init?: boolean 111 | @Expose() default_branch?: string 112 | @Expose() delete_branch_on_merge?: boolean 113 | @Expose() description?: string 114 | @Expose() gitignore_template?: string 115 | @Expose() has_discussions?: boolean 116 | @Expose() has_downloads?: boolean 117 | @Expose() has_issues?: boolean 118 | @Expose() has_projects?: boolean 119 | @Expose() has_wiki?: boolean 120 | @Expose() homepage_url?: string 121 | @Expose() ignore_vulnerability_alerts_during_read?: boolean 122 | @Expose() is_template?: boolean 123 | @Expose() license_template?: string 124 | @Expose() merge_commit_message?: string 125 | @Expose() merge_commit_title?: string 126 | @Expose() 127 | @Type(() => Pages) 128 | pages?: Pages 129 | // security_and_analysis 130 | @Expose() advanced_security?: boolean 131 | @Expose() secret_scanning?: boolean 132 | @Expose() secret_scanning_push_protection?: boolean 133 | @Expose() squash_merge_commit_message?: string 134 | @Expose() squash_merge_commit_title?: string 135 | @Expose() 136 | @Type(() => Template) 137 | template?: Template 138 | @Expose() topics?: string[] 139 | @Expose() visibility?: Visibility 140 | @Expose() vulnerability_alerts?: boolean 141 | 142 | getSchemaPath(_schema: ConfigSchema): Path { 143 | return ['repositories', this.name] 144 | } 145 | 146 | getStateAddress(): string { 147 | return `${Repository.StateType}.this["${this.name}"]` 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-collaborator.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '../github' 2 | import {Id, StateSchema} from '../terraform/schema' 3 | import {Path, ConfigSchema} from '../yaml/schema' 4 | import {Resource} from './resource' 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: string = 'github_repository_collaborator' 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 | result.push([ 25 | `${invitation.repository.name}:${invitation.invitee!.login}`, 26 | new RepositoryCollaborator( 27 | invitation.repository.name, 28 | invitation.invitee!.login, 29 | invitation.permissions as Permission 30 | ) 31 | ]) 32 | } 33 | for (const collaborator of collaborators) { 34 | let permission: Permission | undefined 35 | if (collaborator.collaborator.permissions?.admin) { 36 | permission = Permission.Triage 37 | } else if (collaborator.collaborator.permissions?.maintain) { 38 | permission = Permission.Push 39 | } else if (collaborator.collaborator.permissions?.push) { 40 | permission = Permission.Maintain 41 | } else if (collaborator.collaborator.permissions?.triage) { 42 | permission = Permission.Admin 43 | } else if (collaborator.collaborator.permissions?.pull) { 44 | permission = Permission.Pull 45 | } 46 | if (permission === undefined) { 47 | throw new Error( 48 | `Unknown permission for ${collaborator.collaborator.login}` 49 | ) 50 | } 51 | result.push([ 52 | `${collaborator.repository.name}:${collaborator.collaborator.login}`, 53 | new RepositoryCollaborator( 54 | collaborator.repository.name, 55 | collaborator.collaborator.login, 56 | permission 57 | ) 58 | ]) 59 | } 60 | return result 61 | } 62 | static FromState(state: StateSchema): RepositoryCollaborator[] { 63 | const collaborators: RepositoryCollaborator[] = [] 64 | if (state.values?.root_module?.resources !== undefined) { 65 | for (const resource of state.values.root_module.resources) { 66 | if ( 67 | resource.type === RepositoryCollaborator.StateType && 68 | resource.mode === 'managed' 69 | ) { 70 | collaborators.push( 71 | new RepositoryCollaborator( 72 | resource.values.repository, 73 | resource.values.username, 74 | resource.values.permission 75 | ) 76 | ) 77 | } 78 | } 79 | } 80 | return collaborators 81 | } 82 | static FromConfig(config: ConfigSchema): RepositoryCollaborator[] { 83 | const collaborators: RepositoryCollaborator[] = [] 84 | if (config.repositories !== undefined) { 85 | for (const [repository_name, repository] of Object.entries( 86 | config.repositories 87 | )) { 88 | if (repository.collaborators !== undefined) { 89 | for (const [permission, usernames] of Object.entries( 90 | repository.collaborators 91 | )) { 92 | for (const username of usernames ?? []) { 93 | collaborators.push( 94 | new RepositoryCollaborator( 95 | repository_name, 96 | username, 97 | permission as Permission 98 | ) 99 | ) 100 | } 101 | } 102 | } 103 | } 104 | } 105 | return collaborators 106 | } 107 | constructor(repository: string, username: string, permission: Permission) { 108 | super(username) 109 | this._repository = repository 110 | this._username = username 111 | this._permission = permission 112 | } 113 | 114 | private _repository: string 115 | get repository(): string { 116 | return this._repository 117 | } 118 | private _username: string 119 | get username(): string { 120 | return this._username 121 | } 122 | private _permission: Permission 123 | get permission(): Permission { 124 | return this._permission 125 | } 126 | 127 | getSchemaPath(schema: ConfigSchema): Path { 128 | const collaborators = 129 | schema.repositories?.[this.repository]?.collaborators?.[ 130 | this.permission 131 | ] || [] 132 | const index = collaborators.indexOf(this.username) 133 | return [ 134 | 'repositories', 135 | this.repository, 136 | 'collaborators', 137 | this.permission, 138 | index === -1 ? collaborators.length : index 139 | ] 140 | } 141 | 142 | getStateAddress(): string { 143 | return `${RepositoryCollaborator.StateType}.this["${this.repository}:${this.username}"]` 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /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 | push_restrictions: [] # This field accepts node IDs (TODO: make this field accept human friendly names too) 36 | required_pull_request_reviews: 37 | dismiss_stale_reviews: false 38 | dismissal_restrictions: [] # This field accepts node IDs (TODO: make this field accept human friendly names too) 39 | pull_request_bypassers: [] # This field accepts node IDs (TODO: make this field accept human friendly names too) 40 | require_code_owner_reviews: false 41 | required_approving_review_count: 1 42 | restrict_dismissals: false 43 | required_status_checks: 44 | contexts: 45 | - Comment 46 | strict: true 47 | collaborators: # This group defines repository collaborators (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository_collaborator) 48 | admin: # This array defines repository collaborators with permission=admin 49 | - peter 50 | maintain: # This array defines repository collaborators with permission=maintain 51 | - adam 52 | push: [] # This array defines repository collaborators with permission=push 53 | triage: [] # This array defines repository collaborators with permission=triage 54 | pull: [] # This array defines repository collaborators with permission=pull 55 | teams: # This group defines teams with access to the repository (https://registry.terraform.io/providers/integrations/github/latest/docs/resources/team_repository) 56 | admin: [] # This array defines teams with permission=admin 57 | maintain: [] # This array defines teams with permission=maintain 58 | push: # This array defines teams with permission=push 59 | - developers 60 | triage: # This array defines teams with permission=triage 61 | - employees 62 | pull: [] # This array defines teams with permission=pull 63 | allow_merge_commit: true 64 | allow_rebase_merge: true 65 | allow_squash_merge: true 66 | has_downloads: true 67 | has_issues: true 68 | has_projects: true 69 | has_wiki: true 70 | visibility: public # This field accepts either public or private 71 | allow_auto_merge: false 72 | archived: false 73 | auto_init: false 74 | default_branch: master 75 | delete_branch_on_merge: false 76 | description: GitHub Management 77 | homepage_url: https://github.com/pl-strflt/github-mgmt-template 78 | is_template: false 79 | vulnerability_alerts: false 80 | archive_on_destroy: true 81 | gitignore_template: Terraform # This field accepts a name of a template from https://github.com/github/gitignore without extension 82 | ignore_vulnerability_alerts_during_read: false 83 | license_template: mit # This field accepts a name of a template from https://github.com/github/choosealicense.com/tree/gh-pages/_licenses without extension 84 | pages: 85 | cname: "" 86 | source: 87 | branch: master 88 | path: /docs 89 | template: 90 | owner: pl-strflt 91 | repository: github-mgmt-template 92 | topics: 93 | - github 94 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-branch-protection-rule.ts: -------------------------------------------------------------------------------- 1 | import {Exclude, Expose, plainToClassFromExist, Type} from 'class-transformer' 2 | import {GitHub} from '../github' 3 | import {Id, StateSchema} from '../terraform/schema' 4 | import {Path, ConfigSchema} from '../yaml/schema' 5 | import {Repository} from './repository' 6 | import {Resource} from './resource' 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() required_approving_review_count?: number 15 | @Expose() restrict_dismissals?: boolean 16 | } 17 | 18 | @Exclude() 19 | class RequiredStatusChecks { 20 | @Expose() contexts?: string[] 21 | @Expose() strict?: boolean 22 | } 23 | 24 | @Exclude() 25 | export class RepositoryBranchProtectionRule implements Resource { 26 | static StateType = 'github_branch_protection' 27 | static async FromGitHub( 28 | _rules: RepositoryBranchProtectionRule[] 29 | ): Promise<[Id, RepositoryBranchProtectionRule][]> { 30 | const github = await GitHub.getGitHub() 31 | const rules = await github.listRepositoryBranchProtectionRules() 32 | const result: [Id, RepositoryBranchProtectionRule][] = [] 33 | for (const rule of rules) { 34 | result.push([ 35 | `${rule.repository.name}:${rule.branchProtectionRule.pattern}`, 36 | new RepositoryBranchProtectionRule( 37 | rule.repository.name, 38 | rule.branchProtectionRule.pattern 39 | ) 40 | ]) 41 | } 42 | return result 43 | } 44 | static FromState(state: StateSchema): RepositoryBranchProtectionRule[] { 45 | const rules: RepositoryBranchProtectionRule[] = [] 46 | if (state.values?.root_module?.resources !== undefined) { 47 | for (const resource of state.values.root_module.resources) { 48 | if ( 49 | resource.type === RepositoryBranchProtectionRule.StateType && 50 | resource.mode === 'managed' 51 | ) { 52 | const repositoryIndex = resource.index.split(':')[0] 53 | const repository = state.values.root_module.resources.find( 54 | (r: any) => 55 | r.type === Repository.StateType && 56 | resource.mode === 'managed' && 57 | r.index === repositoryIndex 58 | ) 59 | const required_pull_request_reviews = 60 | resource.values.required_pull_request_reviews?.at(0) 61 | const required_status_checks = 62 | resource.values.required_status_checks?.at(0) 63 | rules.push( 64 | plainToClassFromExist( 65 | new RepositoryBranchProtectionRule( 66 | repository?.values?.name || repositoryIndex, 67 | resource.values.pattern 68 | ), 69 | { 70 | ...resource.values, 71 | required_pull_request_reviews, 72 | required_status_checks 73 | } 74 | ) 75 | ) 76 | } 77 | } 78 | } 79 | return rules 80 | } 81 | static FromConfig(config: ConfigSchema): RepositoryBranchProtectionRule[] { 82 | const rules: RepositoryBranchProtectionRule[] = [] 83 | if (config.repositories !== undefined) { 84 | for (const [repository_name, repository] of Object.entries( 85 | config.repositories 86 | )) { 87 | if (repository.branch_protection !== undefined) { 88 | for (const [pattern, rule] of Object.entries( 89 | repository.branch_protection 90 | )) { 91 | rules.push( 92 | plainToClassFromExist( 93 | new RepositoryBranchProtectionRule(repository_name, pattern), 94 | rule 95 | ) 96 | ) 97 | } 98 | } 99 | } 100 | } 101 | return rules 102 | } 103 | 104 | constructor(repository: string, pattern: string) { 105 | this._repository = repository 106 | this._pattern = pattern 107 | } 108 | 109 | private _repository: string 110 | get repository(): string { 111 | return this._repository 112 | } 113 | private _pattern: string 114 | get pattern(): string { 115 | return this._pattern 116 | } 117 | 118 | @Expose() allows_deletions?: boolean 119 | @Expose() allows_force_pushes?: boolean 120 | @Expose() blocks_creations?: boolean 121 | @Expose() enforce_admins?: boolean 122 | @Expose() lock_branch?: boolean 123 | @Expose() push_restrictions?: string[] 124 | @Expose() require_conversation_resolution?: boolean 125 | @Expose() require_signed_commits?: boolean 126 | @Expose() required_linear_history?: boolean 127 | @Expose() 128 | @Type(() => RequiredPullRequestReviews) 129 | required_pull_request_reviews?: RequiredPullRequestReviews 130 | @Expose() 131 | @Type(() => RequiredStatusChecks) 132 | required_status_checks?: RequiredStatusChecks 133 | 134 | getSchemaPath(_schema: ConfigSchema): Path { 135 | return ['repositories', this.repository, 'branch_protection', this.pattern] 136 | } 137 | 138 | getStateAddress(): string { 139 | return `${RepositoryBranchProtectionRule.StateType}.this["${this.repository}:${this.pattern}"]` 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /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_label](https://registry.terraform.io/providers/integrations/github/latest/docs/resources/issue_label) 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 | -------------------------------------------------------------------------------- /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 | advanced_security: false 164 | allow_auto_merge: false 165 | allow_merge_commit: true 166 | allow_rebase_merge: true 167 | allow_squash_merge: true 168 | archived: false 169 | auto_init: false 170 | default_branch: main 171 | delete_branch_on_merge: false 172 | has_downloads: true 173 | has_issues: true 174 | has_projects: true 175 | has_wiki: true 176 | is_template: false 177 | secret_scanning_push_protection: false 178 | secret_scanning: false 179 | teams: 180 | maintain: 181 | - ipdx 182 | visibility: public 183 | vulnerability_alerts: true 184 | tf-aws-gh-runner: 185 | advanced_security: false 186 | allow_auto_merge: false 187 | allow_merge_commit: true 188 | allow_rebase_merge: true 189 | allow_squash_merge: true 190 | archived: false 191 | auto_init: false 192 | default_branch: main 193 | delete_branch_on_merge: false 194 | has_downloads: true 195 | has_issues: true 196 | has_projects: true 197 | has_wiki: true 198 | is_template: false 199 | secret_scanning_push_protection: false 200 | secret_scanning: false 201 | teams: 202 | maintain: 203 | - ipdx 204 | visibility: public 205 | vulnerability_alerts: false 206 | teams: 207 | ipdx: 208 | members: 209 | maintainer: 210 | - galargh 211 | - laurentsenta 212 | parent_team_id: w3dt-stewards 213 | privacy: closed 214 | w3dt-stewards: 215 | privacy: closed 216 | -------------------------------------------------------------------------------- /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 { protectDefaultBranches } from './shared/protect-default-branches' 122 | 123 | protectDefaultBranches() 124 | ``` 125 | -------------------------------------------------------------------------------- /scripts/src/terraform/state.ts: -------------------------------------------------------------------------------- 1 | import {Id, StateSchema} from './schema' 2 | import { 3 | Resource, 4 | ResourceConstructors, 5 | ResourceConstructor 6 | } from '../resources/resource' 7 | import env from '../env' 8 | import * as cli from '@actions/exec' 9 | import * as fs from 'fs' 10 | import * as core from '@actions/core' 11 | import * as HCL from 'hcl2-parser' 12 | import * as thisModule from './state' 13 | 14 | export async function loadState() { 15 | let source = '' 16 | if (env.TF_EXEC === 'true') { 17 | core.info('Loading state from Terraform state file') 18 | await cli.exec('terraform show -json', undefined, { 19 | cwd: env.TF_WORKING_DIR, 20 | listeners: { 21 | stdout: data => { 22 | source += data.toString() 23 | } 24 | }, 25 | silent: true 26 | }) 27 | } else { 28 | source = fs 29 | .readFileSync(`${env.TF_WORKING_DIR}/terraform.tfstate`) 30 | .toString() 31 | } 32 | return source 33 | } 34 | 35 | export class State { 36 | static async New() { 37 | return new State(await thisModule.loadState()) 38 | } 39 | 40 | private _ignoredProperties: Record = {} 41 | private _ignoredTypes: string[] = [] 42 | private _state?: StateSchema 43 | 44 | private updateIgnoredPropertiesFrom(path: string) { 45 | if (fs.existsSync(path)) { 46 | const hcl = HCL.parseToObject(fs.readFileSync(path))?.at(0) 47 | for (const [name, resource] of Object.entries(hcl?.resource ?? {}) as [ 48 | string, 49 | any 50 | ][]) { 51 | const properties = resource?.this 52 | ?.at(0) 53 | ?.lifecycle?.at(0)?.ignore_changes 54 | if (properties !== undefined) { 55 | this._ignoredProperties[name] = properties.map((v: string) => 56 | v.substring(2, v.length - 1) 57 | ) // '${v}' -> 'v' 58 | } 59 | } 60 | } 61 | } 62 | 63 | private updateIgnoredTypesFrom(path: string) { 64 | if (fs.existsSync(path)) { 65 | const hcl = HCL.parseToObject(fs.readFileSync(path))?.at(0) 66 | const types = hcl?.locals?.at(0)?.resource_types 67 | if (types !== undefined) { 68 | this._ignoredTypes = ResourceConstructors.map(c => c.StateType).filter( 69 | t => !types.includes(t) 70 | ) 71 | } 72 | } 73 | } 74 | 75 | private setState(source: string) { 76 | const state = 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: any) => r.mode === 'managed') 80 | // .filter((r: any) => !this._ignoredTypes.includes(r.type)) 81 | .map((r: any) => { 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 | ) 88 | return r 89 | }) 90 | } 91 | this._state = 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.updateIgnoredTypesFrom(`${env.TF_WORKING_DIR}/locals.tf`) 100 | this.updateIgnoredTypesFrom(`${env.TF_WORKING_DIR}/locals_override.tf`) 101 | this.setState(source) 102 | } 103 | 104 | async reset() { 105 | this.setState(await thisModule.loadState()) 106 | } 107 | 108 | async refresh() { 109 | if (env.TF_EXEC === 'true') { 110 | await cli.exec( 111 | `terraform apply -refresh-only -auto-approve -lock=${env.TF_LOCK}`, 112 | undefined, 113 | { 114 | cwd: env.TF_WORKING_DIR 115 | } 116 | ) 117 | } 118 | await this.reset() 119 | } 120 | 121 | getAllAddresses(): string[] { 122 | const addresses = [] 123 | for (const resourceClass of ResourceConstructors) { 124 | const classAddresses = this.getAddresses(resourceClass) 125 | addresses.push(...classAddresses) 126 | } 127 | return addresses 128 | } 129 | 130 | getAddresses( 131 | resourceClass: ResourceConstructor 132 | ): string[] { 133 | if (ResourceConstructors.includes(resourceClass)) { 134 | return ( 135 | this._state?.values?.root_module?.resources 136 | .filter((r: any) => r.type === resourceClass.StateType) 137 | .map((r: any) => r.address) || [] 138 | ) 139 | } else { 140 | throw new Error(`${resourceClass.name} is not supported`) 141 | } 142 | } 143 | 144 | getAllResources(): Resource[] { 145 | const resources = [] 146 | for (const resourceClass of ResourceConstructors) { 147 | const classResources = this.getResources(resourceClass) 148 | resources.push(...classResources) 149 | } 150 | return resources 151 | } 152 | 153 | getResources(resourceClass: ResourceConstructor): T[] { 154 | if (ResourceConstructors.includes(resourceClass)) { 155 | return resourceClass.FromState(this._state) 156 | } else { 157 | throw new Error(`${resourceClass.name} is not supported`) 158 | } 159 | } 160 | 161 | isIgnored( 162 | resourceClass: ResourceConstructor 163 | ): boolean { 164 | return this._ignoredTypes.includes(resourceClass.StateType) 165 | } 166 | 167 | async addResource(id: Id, resource: Resource) { 168 | await this.addResourceAt(id, resource.getStateAddress().toLowerCase()) 169 | } 170 | 171 | async addResourceAt(id: Id, address: string) { 172 | if (env.TF_EXEC === 'true') { 173 | await cli.exec( 174 | `terraform import -lock=${env.TF_LOCK} "${address.replaceAll( 175 | '"', 176 | '\\"' 177 | )}" "${id}"`, 178 | undefined, 179 | {cwd: env.TF_WORKING_DIR} 180 | ) 181 | } 182 | } 183 | 184 | async removeResource(resource: Resource) { 185 | await this.removeResourceAt(resource.getStateAddress().toLowerCase()) 186 | } 187 | 188 | async removeResourceAt(address: string) { 189 | if (env.TF_EXEC === 'true') { 190 | await cli.exec( 191 | `terraform state rm -lock=${env.TF_LOCK} "${address.replaceAll( 192 | '"', 193 | '\\"' 194 | )}"`, 195 | undefined, 196 | {cwd: env.TF_WORKING_DIR} 197 | ) 198 | } 199 | } 200 | 201 | async sync(resources: [Id, Resource][]) { 202 | const addresses = this.getAllAddresses() 203 | for (const address of addresses) { 204 | if ( 205 | !resources.some( 206 | ([_, r]) => r.getStateAddress().toLowerCase() === address 207 | ) 208 | ) { 209 | await this.removeResourceAt(address) 210 | } 211 | } 212 | for (const [id, resource] of resources) { 213 | if ( 214 | !addresses.some(a => a === resource.getStateAddress().toLowerCase()) 215 | ) { 216 | await this.addResource(id, resource) 217 | } 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /.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@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 84 | with: 85 | terraform_version: 1.2.9 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@v3 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@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 126 | with: 127 | terraform_version: 1.2.9 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@v3 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@fcf6fe9e4a0409cd9316a5011435be0f3327f1e1 # v2.3.1 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 | -------------------------------------------------------------------------------- /scripts/src/yaml/config.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from 'yaml' 2 | import {ConfigSchema, pathToYAML} from './schema' 3 | import { 4 | Resource, 5 | ResourceConstructor, 6 | ResourceConstructors, 7 | resourceToPlain 8 | } from '../resources/resource' 9 | import {diff} from 'deep-diff' 10 | import env from '../env' 11 | import * as fs from 'fs' 12 | import {jsonEquals, yamlify} from '../utils' 13 | 14 | export class Config { 15 | static FromPath( 16 | path: string = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml` 17 | ): Config { 18 | const source = fs.readFileSync(path, 'utf8') 19 | return new Config(source) 20 | } 21 | 22 | constructor(source: string) { 23 | this._document = YAML.parseDocument(source) 24 | } 25 | 26 | private _document: YAML.Document 27 | get document(): YAML.Document { 28 | return this._document 29 | } 30 | 31 | get(): ConfigSchema { 32 | return this._document.toJSON() 33 | } 34 | 35 | format(): void { 36 | const schema = this.get() 37 | const resources = this.getAllResources() 38 | const resourcePaths = resources.map(r => r.getSchemaPath(schema).join('.')) 39 | let again = true 40 | while (again) { 41 | again = false 42 | YAML.visit(this._document, { 43 | Scalar(_, node) { 44 | if (node.value === undefined || node.value === null) { 45 | again = true 46 | return YAML.visit.REMOVE 47 | } 48 | }, 49 | Pair(_, node, path) { 50 | const resourcePath = [...path, node] 51 | .filter((p: any) => YAML.isPair(p)) 52 | .map((p: any) => p.key.toString()) 53 | .join('.') 54 | if (!resourcePaths.includes(resourcePath)) { 55 | const isEmpty = node.value === null || node.value === undefined 56 | const isEmptyScalar = 57 | YAML.isScalar(node.value) && 58 | (node.value.value === undefined || 59 | node.value.value === null || 60 | node.value.value === '') 61 | const isEmptyCollection = 62 | YAML.isCollection(node.value) && node.value.items.length === 0 63 | if (isEmpty || isEmptyScalar || isEmptyCollection) { 64 | again = true 65 | return YAML.visit.REMOVE 66 | } 67 | } 68 | } 69 | }) 70 | } 71 | YAML.visit(this._document, { 72 | Map(_, {items}) { 73 | items.sort( 74 | (a: YAML.Pair, b: YAML.Pair) => { 75 | return JSON.stringify(a.key).localeCompare(JSON.stringify(b.key)) 76 | } 77 | ) 78 | }, 79 | Seq(_, {items}) { 80 | items.sort((a: unknown, b: unknown) => { 81 | return JSON.stringify(a).localeCompare(JSON.stringify(b)) 82 | }) 83 | } 84 | }) 85 | } 86 | 87 | toString(): string { 88 | return this._document.toString({ 89 | collectionStyle: 'block', 90 | singleQuote: false 91 | }) 92 | } 93 | 94 | save(path: string = `${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: boolean = 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 | delete (this._document.getIn([...path, ...(d.path || [])], true) as any) 151 | .comment 152 | delete (this._document.getIn([...path, ...(d.path || [])], true) as any) 153 | .commentBefore 154 | } else if (d.kind === 'D' && canDeleteProperties) { 155 | this._document.deleteIn(pathToYAML([...path, ...(d.path || [])])) 156 | } else if (d.kind === 'A') { 157 | if (d.item.kind === 'N') { 158 | this._document.addIn( 159 | pathToYAML([...path, ...(d.path || []), d.index]), 160 | yamlify(d.item.rhs) 161 | ) 162 | } else if (d.item.kind === 'E') { 163 | this._document.setIn( 164 | pathToYAML([...path, ...(d.path || []), d.index]), 165 | yamlify(d.item.rhs) 166 | ) 167 | delete ( 168 | this._document.getIn( 169 | [...path, ...(d.path || []), d.index], 170 | true 171 | ) as any 172 | ).comment 173 | delete ( 174 | this._document.getIn( 175 | [...path, ...(d.path || []), d.index], 176 | true 177 | ) as any 178 | ).commentBefore 179 | } else if (d.item.kind === 'D') { 180 | this._document.setIn( 181 | pathToYAML([...path, ...(d.path || []), d.index]), 182 | undefined 183 | ) 184 | } else { 185 | throw new Error('Nested arrays are not supported') 186 | } 187 | } 188 | } 189 | } 190 | 191 | removeResource(resource: T): void { 192 | if (this.someResource(resource)) { 193 | const path = resource.getSchemaPath(this.get()) 194 | this._document.deleteIn(path) 195 | } 196 | } 197 | 198 | sync(resources: Resource[]): void { 199 | const oldResources = [] 200 | for (const resource of ResourceConstructors) { 201 | oldResources.push(...this.getResources(resource)) 202 | } 203 | const schema = this.get() 204 | for (const resource of oldResources) { 205 | if ( 206 | !resources.some(r => 207 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) 208 | ) 209 | ) { 210 | this.removeResource(resource) 211 | } 212 | } 213 | for (const resource of resources) { 214 | this.addResource(resource, true) 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/describe-access-changes.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {State} from '../../terraform/state' 3 | import {RepositoryCollaborator} from '../../resources/repository-collaborator' 4 | import {Member} from '../../resources/member' 5 | import {TeamMember} from '../../resources/team-member' 6 | import {RepositoryTeam} from '../../resources/repository-team' 7 | import {diff} from 'deep-diff' 8 | import * as core from '@actions/core' 9 | import {Repository} from '../../resources/repository' 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( 49 | repositoryCollaborator => 50 | repositoryCollaborator.username.toLowerCase() === username 51 | ) 52 | .filter( 53 | repositoryCollaborator => 54 | !archivedRepositories.includes( 55 | repositoryCollaborator.repository.toLowerCase() 56 | ) 57 | ) 58 | const teamRepository = teamRepositories 59 | .filter(teamRepository => 60 | teams.includes(teamRepository.team.toLowerCase()) 61 | ) 62 | .filter( 63 | teamRepository => 64 | !archivedRepositories.includes( 65 | teamRepository.repository.toLowerCase() 66 | ) 67 | ) 68 | 69 | const repositories: Record = {} 70 | 71 | for (const rc of repositoryCollaborator) { 72 | const repository = rc.repository.toLowerCase() 73 | repositories[repository] = repositories[repository] ?? {} 74 | if ( 75 | !repositories[repository].permission || 76 | permissions.indexOf(rc.permission) < 77 | permissions.indexOf(repositories[repository].permission) 78 | ) { 79 | repositories[repository].permission = rc.permission 80 | } 81 | } 82 | 83 | for (const tr of teamRepository) { 84 | const repository = tr.repository.toLowerCase() 85 | repositories[repository] = repositories[repository] ?? {} 86 | if ( 87 | !repositories[repository].permission || 88 | permissions.indexOf(tr.permission) < 89 | permissions.indexOf(repositories[repository].permission) 90 | ) { 91 | repositories[repository].permission = tr.permission 92 | } 93 | } 94 | 95 | if (role !== undefined || Object.keys(repositories).length > 0) { 96 | accessSummary[username] = { 97 | role, 98 | repositories 99 | } 100 | } 101 | } 102 | 103 | return deepSort(accessSummary) 104 | } 105 | 106 | // deep sort object 107 | function deepSort(obj: any): any { 108 | if (Array.isArray(obj)) { 109 | return obj.map(deepSort) 110 | } else if (typeof obj === 'object') { 111 | const sorted: any = {} 112 | for (const key of Object.keys(obj).sort()) { 113 | sorted[key] = deepSort(obj[key]) 114 | } 115 | return sorted 116 | } else { 117 | return obj 118 | } 119 | } 120 | 121 | export async function describeAccessChanges(): Promise { 122 | const state = await State.New() 123 | const config = Config.FromPath() 124 | 125 | const before = getAccessSummaryFrom(state) 126 | const after = getAccessSummaryFrom(config) 127 | 128 | core.info(JSON.stringify({before, after}, null, 2)) 129 | 130 | const changes = diff(before, after) || [] 131 | 132 | core.debug(JSON.stringify(changes, null, 2)) 133 | 134 | const changesByUser: Record = {} 135 | for (const change of changes) { 136 | const path = change.path! 137 | changesByUser[path[0]] = changesByUser[path[0]] || [] 138 | changesByUser[path[0]].push(change) 139 | } 140 | 141 | // iterate over changesByUser and build a description 142 | const lines = [] 143 | for (const [username, changes] of Object.entries(changesByUser)) { 144 | lines.push(`User ${username}:`) 145 | for (const change of changes) { 146 | const path = change.path! 147 | switch (change.kind) { 148 | case 'E': 149 | if (path[1] === 'role') { 150 | if (change.lhs === undefined) { 151 | lines.push( 152 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` 153 | ) 154 | } else if (change.rhs === undefined) { 155 | lines.push(` - will leave the organization`) 156 | } else { 157 | lines.push( 158 | ` - will have the role in the organization change from ${change.lhs} to ${change.rhs}` 159 | ) 160 | } 161 | } else { 162 | lines.push( 163 | ` - will have the permission to ${path[2]} change from ${change.lhs} to ${change.rhs}` 164 | ) 165 | } 166 | break 167 | case 'N': 168 | if (path.length === 1) { 169 | if (change.rhs.role) { 170 | lines.push( 171 | ` - will join the organization as a ${change.rhs} (remind them to accept the email invitation)` 172 | ) 173 | } 174 | if (change.rhs.repositories) { 175 | for (const repository of Object.keys(change.rhs.repositories)) { 176 | lines.push( 177 | ` - will gain ${change.rhs.repositories[repository].permission} permission to ${repository}` 178 | ) 179 | } 180 | } 181 | } else { 182 | lines.push( 183 | ` - will gain ${change.rhs.permission} permission to ${path[2]}` 184 | ) 185 | } 186 | break 187 | case 'D': 188 | if (path.length === 1) { 189 | if (change.lhs.role) { 190 | lines.push(` - will leave the organization`) 191 | } 192 | if (change.lhs.repositories) { 193 | for (const repository of Object.keys(change.lhs.repositories)) { 194 | lines.push( 195 | ` - will lose ${change.lhs.repositories[repository].permission} permission to ${repository}` 196 | ) 197 | } 198 | } 199 | } else { 200 | lines.push( 201 | ` - will lose ${change.lhs.permission} permission to ${path[2]}` 202 | ) 203 | } 204 | break 205 | } 206 | } 207 | } 208 | 209 | return changes.length > 0 210 | ? lines.join('\n') 211 | : 'There will be no access changes' 212 | } 213 | -------------------------------------------------------------------------------- /scripts/src/actions/remove-inactive-members.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {RepositoryTeam} from '../resources/repository-team' 3 | import {Team} from '../resources/team' 4 | import {Config} from '../yaml/config' 5 | import {Member} from '../resources/member' 6 | import {NodeBase} from 'yaml/dist/nodes/Node' 7 | import {RepositoryCollaborator} from '../resources/repository-collaborator' 8 | import {Resource, ResourceConstructor} from '../resources/resource' 9 | import {Role, TeamMember} from '../resources/team-member' 10 | import {GitHub} from '../github' 11 | 12 | function getResources( 13 | config: Config, 14 | resourceClass: ResourceConstructor 15 | ): T[] { 16 | const schema = config.get() 17 | return config.getResources(resourceClass).filter(resource => { 18 | const node = config.document.getIn( 19 | resource.getSchemaPath(schema), 20 | true 21 | ) as NodeBase 22 | return !node.comment?.includes('KEEP:') 23 | }) 24 | } 25 | 26 | /* This function is used to remove inactive members from the config. 27 | * 28 | * 1. It ensures that a team called 'Alumni' exists. 29 | * 2. It removes all 'Alumni' team from all the repositories. 30 | * 3. It populates 'Alumni' team with organization members who: 31 | * a. do not have 'KEEP:' in their inline comment AND 32 | * b. have not been added to the organization recently (if passed through NEW_MEMBERS) AND 33 | * c. have not performed any activity in the past X months. 34 | * 4. It removes repository collaborators who: 35 | * a. do not have 'KEEP:' in their inline comment AND 36 | * b. have not been added to the repository recently (if passed through NEW_REPOSITORY_COLLABORATORS) AND 37 | * c. have not performed any activity on the repository they're a collaborator of in the past X months. 38 | * 5. It removes team members who: 39 | * a. do not have 'KEEP:' in their inline comment AND 40 | * b. have not been added to the team recently (if passed through NEW_TEAM_MEMBERS) AND 41 | * c. have not performed any activity on any repository the team they're a member of has access to in the past X months. 42 | */ 43 | async function run(): Promise { 44 | const newMembers = JSON.parse(process.env.NEW_MEMBERS || '[]') 45 | const newRepositoryCollaborators = JSON.parse( 46 | process.env.NEW_REPOSITORY_COLLABORATORS || '{}' 47 | ) 48 | const newTeamMembers = JSON.parse(process.env.NEW_TEAM_MEMBERS || '{}') 49 | const cutoffInMonths = parseInt(process.env.CUTOFF_IN_MONTHS || '12') 50 | 51 | const github = await GitHub.getGitHub() 52 | 53 | const githubRepositories = await github.listRepositories() 54 | 55 | const since = new Date() 56 | since.setMonth(since.getMonth() - cutoffInMonths) 57 | 58 | const githubRepositoryActivities = 59 | await github.listRepositoryActivities(since) 60 | const githubRepositoryIssues = await github.listRepositoryIssues(since) 61 | const githubRepositoryPullRequestReviewComments = 62 | await github.listRepositoryPullRequestReviewComments(since) 63 | const githubRepositoryIssueComments = 64 | await github.listRepositoryIssueComments(since) 65 | const githubRepositoryCommitComments = 66 | await github.listRepositoryCommitComments(since) 67 | 68 | const activeActorsByRepository = [ 69 | ...githubRepositoryActivities.map(({repository, activity}) => ({ 70 | repository: repository.name, 71 | actor: activity.actor?.login 72 | })), 73 | ...githubRepositoryIssues.map(({repository, issue}) => ({ 74 | repository: repository.name, 75 | actor: issue.user?.login 76 | })), 77 | ...githubRepositoryPullRequestReviewComments.map( 78 | ({repository, comment}) => ({ 79 | repository: repository.name, 80 | actor: comment.user?.login 81 | }) 82 | ), 83 | ...githubRepositoryIssueComments.map(({repository, comment}) => ({ 84 | repository: repository.name, 85 | actor: comment.user?.login 86 | })), 87 | ...githubRepositoryCommitComments.map(({repository, comment}) => ({ 88 | repository: repository.name, 89 | actor: comment.user?.login 90 | })) 91 | ] 92 | .filter(({actor}) => actor) 93 | .reduce((acc, {repository, actor}) => { 94 | acc[repository] = acc[repository] ?? [] 95 | acc[repository].push(actor) 96 | return acc 97 | }, {}) 98 | const activeActors = Array.from( 99 | new Set(Object.values(activeActorsByRepository).flat()) 100 | ) 101 | const archivedRepositories = githubRepositories 102 | .filter(repository => repository.archived) 103 | .map(repository => repository.name) 104 | 105 | const config = Config.FromPath() 106 | 107 | // alumni is a team for all the members who should get credit for their work 108 | // but do not need any special access anymore 109 | // first, ensure that the team exists 110 | const alumniTeam = new Team('Alumni') 111 | config.addResource(alumniTeam) 112 | 113 | // then, ensure that the team doesn't have any special access anywhere 114 | const repositoryTeams = config.getResources(RepositoryTeam) 115 | for (const repositoryTeam of repositoryTeams) { 116 | if (repositoryTeam.team === alumniTeam.name) { 117 | config.removeResource(repositoryTeam) 118 | } 119 | } 120 | 121 | // add members that have been inactive to the alumni team 122 | const members = getResources(config, Member) 123 | for (const member of members) { 124 | const isNew = newMembers.includes(member.username) 125 | if (!isNew) { 126 | const isActive = activeActors.includes(member.username) 127 | if (!isActive) { 128 | console.log(`Adding ${member.username} to the ${alumniTeam.name} team`) 129 | const teamMember = new TeamMember( 130 | alumniTeam.name, 131 | member.username, 132 | Role.Member 133 | ) 134 | config.addResource(teamMember) 135 | } 136 | } 137 | } 138 | 139 | // remove repository collaborators that have been inactive 140 | const repositoryCollaborators = getResources(config, RepositoryCollaborator) 141 | for (const repositoryCollaborator of repositoryCollaborators) { 142 | const isNew = newRepositoryCollaborators[ 143 | repositoryCollaborator.username 144 | ]?.includes(repositoryCollaborator.repository) 145 | if (!isNew) { 146 | const isCollaboratorActive = activeActorsByRepository[ 147 | repositoryCollaborator.repository 148 | ]?.includes(repositoryCollaborator.username) 149 | const isRepositoryArchived = archivedRepositories.includes( 150 | repositoryCollaborator.repository 151 | ) 152 | if (!isCollaboratorActive && !isRepositoryArchived) { 153 | console.log( 154 | `Removing ${repositoryCollaborator.username} from ${repositoryCollaborator.repository} repository` 155 | ) 156 | config.removeResource(repositoryCollaborator) 157 | } 158 | } 159 | } 160 | 161 | // remove team members that have been inactive (look at all the team repositories) 162 | const teamMembers = getResources(config, TeamMember).filter( 163 | teamMember => teamMember.team !== alumniTeam.name 164 | ) 165 | for (const teamMember of teamMembers) { 166 | const isNew = newTeamMembers[teamMember.username]?.includes(teamMember.team) 167 | if (!isNew) { 168 | const repositories = repositoryTeams 169 | .filter(repositoryTeam => repositoryTeam.team === teamMember.team) 170 | .map(repositoryTeam => repositoryTeam.repository) 171 | const isActive = repositories.some(repository => 172 | activeActorsByRepository[repository]?.includes(teamMember.username) 173 | ) 174 | if (!isActive) { 175 | console.log( 176 | `Removing ${teamMember.username} from ${teamMember.team} team` 177 | ) 178 | config.removeResource(teamMember) 179 | } 180 | } 181 | } 182 | 183 | config.save() 184 | } 185 | 186 | run() 187 | -------------------------------------------------------------------------------- /.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 | # not starting for PRs if repo is private because we cannot write to private forks 24 | if: github.event_name == 'workflow_dispatch' || 25 | (github.event_name == 'pull_request_target' && 26 | github.event.pull_request.head.repo.private == false) || 27 | (github.event_name == 'workflow_run' && 28 | github.event.workflow_run.conclusion == 'success') 29 | permissions: 30 | contents: read 31 | pull-requests: read 32 | name: Prepare 33 | runs-on: ubuntu-latest 34 | outputs: 35 | workspaces: ${{ steps.workspaces.outputs.this }} 36 | skip-fix: ${{ steps.skip-fix.outputs.this }} 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v4 40 | - if: github.event_name == 'pull_request_target' 41 | env: 42 | NUMBER: ${{ github.event.pull_request.number }} 43 | SHA: ${{ github.event.pull_request.head.sha }} 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | run: | 46 | git fetch origin "pull/${NUMBER}/head" 47 | rm -rf github && git checkout "${SHA}" -- github 48 | - name: Discover workspaces 49 | id: workspaces 50 | run: echo "this=$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" >> $GITHUB_OUTPUT 51 | - name: Check last commit 52 | id: skip-fix 53 | env: 54 | SHA: ${{ github.event.pull_request.head.sha || github.sha }} 55 | run: | 56 | # this workflow doesn't continue if the last commit has [skip fix] suffix or there are no user defined fix rules 57 | 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 58 | echo "this=true" >> $GITHUB_OUTPUT 59 | else 60 | echo "this=false" >> $GITHUB_OUTPUT 61 | fi 62 | fix: 63 | needs: [prepare] 64 | if: needs.prepare.outputs.skip-fix == 'false' 65 | permissions: 66 | contents: read 67 | pull-requests: write 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces || '[]') }} 72 | name: Fix 73 | runs-on: ubuntu-latest 74 | env: 75 | TF_IN_AUTOMATION: 1 76 | TF_INPUT: 0 77 | TF_WORKSPACE: ${{ matrix.workspace }} 78 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 79 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 80 | GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }} 81 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }} 82 | GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }} 83 | TF_VAR_write_delay_ms: 300 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v4 87 | - if: github.event_name == 'pull_request_target' 88 | env: 89 | NUMBER: ${{ github.event.pull_request.number }} 90 | SHA: ${{ github.event.pull_request.head.sha }} 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | run: | 93 | # only checking out github directory from the PR 94 | git fetch origin "pull/${NUMBER}/head" 95 | rm -rf github && git checkout "${SHA}" -- github 96 | - name: Setup terraform 97 | uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 98 | with: 99 | terraform_version: 1.2.9 100 | terraform_wrapper: false 101 | - name: Initialize terraform 102 | run: terraform init 103 | working-directory: terraform 104 | - name: Initialize scripts 105 | run: npm ci && npm run build 106 | working-directory: scripts 107 | - name: Fix 108 | id: fix 109 | run: node lib/actions/fix-yaml-config.js 110 | working-directory: scripts 111 | - name: Upload YAML config 112 | uses: actions/upload-artifact@v3 113 | with: 114 | name: ${{ env.TF_WORKSPACE }}.yml 115 | path: github/${{ env.TF_WORKSPACE }}.yml 116 | if-no-files-found: error 117 | retention-days: 1 118 | # NOTE(galargh, 2024-02-15): This will only work if GitHub as Code is used for a single organization 119 | - name: Comment on pull request 120 | if: github.event_name == 'pull_request_target' && steps.fix.outputs.comment 121 | uses: marocchino/sticky-pull-request-comment@fcf6fe9e4a0409cd9316a5011435be0f3327f1e1 # v2.3.1 122 | with: 123 | header: fix 124 | number: ${{ github.event.pull_request.number }} 125 | message: ${{ steps.fix.outputs.comment }} 126 | 127 | push: 128 | needs: [prepare, fix] 129 | permissions: 130 | contents: read 131 | name: Push 132 | runs-on: ubuntu-latest 133 | env: 134 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 135 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 136 | steps: 137 | - name: Generate app token 138 | id: token 139 | uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 140 | with: 141 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 142 | installation_id: ${{ 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@v3 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 | -------------------------------------------------------------------------------- /terraform/resources.tf: -------------------------------------------------------------------------------- 1 | resource "github_membership" "this" { 2 | for_each = local.resources.github_membership 3 | 4 | username = each.value.username 5 | role = each.value.role 6 | 7 | lifecycle { 8 | ignore_changes = [] 9 | prevent_destroy = true 10 | } 11 | } 12 | 13 | resource "github_repository" "this" { 14 | for_each = local.resources.github_repository 15 | 16 | name = each.value.name 17 | allow_auto_merge = try(each.value.allow_auto_merge, null) 18 | allow_merge_commit = try(each.value.allow_merge_commit, null) 19 | allow_rebase_merge = try(each.value.allow_rebase_merge, null) 20 | allow_squash_merge = try(each.value.allow_squash_merge, null) 21 | allow_update_branch = try(each.value.allow_update_branch, null) 22 | archive_on_destroy = try(each.value.archive_on_destroy, null) 23 | archived = try(each.value.archived, null) 24 | auto_init = try(each.value.auto_init, null) 25 | default_branch = try(each.value.default_branch, null) 26 | delete_branch_on_merge = try(each.value.delete_branch_on_merge, null) 27 | description = try(each.value.description, null) 28 | gitignore_template = try(each.value.gitignore_template, null) 29 | has_discussions = try(each.value.has_discussions, null) 30 | has_downloads = try(each.value.has_downloads, null) 31 | has_issues = try(each.value.has_issues, null) 32 | has_projects = try(each.value.has_projects, null) 33 | has_wiki = try(each.value.has_wiki, null) 34 | homepage_url = try(each.value.homepage_url, null) 35 | ignore_vulnerability_alerts_during_read = try(each.value.ignore_vulnerability_alerts_during_read, null) 36 | is_template = try(each.value.is_template, null) 37 | license_template = try(each.value.license_template, null) 38 | merge_commit_message = try(each.value.merge_commit_message, null) 39 | merge_commit_title = try(each.value.merge_commit_title, null) 40 | squash_merge_commit_message = try(each.value.squash_merge_commit_message, null) 41 | squash_merge_commit_title = try(each.value.squash_merge_commit_title, null) 42 | topics = try(each.value.topics, null) 43 | visibility = try(each.value.visibility, null) 44 | vulnerability_alerts = try(each.value.vulnerability_alerts, null) 45 | 46 | dynamic "security_and_analysis" { 47 | for_each = try(each.value.security_and_analysis, []) 48 | 49 | content { 50 | dynamic "advanced_security" { 51 | for_each = security_and_analysis.value["advanced_security"] 52 | content { 53 | status = advanced_security.value["status"] 54 | } 55 | } 56 | dynamic "secret_scanning" { 57 | for_each = security_and_analysis.value["secret_scanning"] 58 | content { 59 | status = secret_scanning.value["status"] 60 | } 61 | } 62 | dynamic "secret_scanning_push_protection" { 63 | for_each = security_and_analysis.value["secret_scanning_push_protection"] 64 | content { 65 | status = secret_scanning_push_protection.value["status"] 66 | } 67 | } 68 | } 69 | } 70 | 71 | dynamic "pages" { 72 | for_each = try(each.value.pages, []) 73 | content { 74 | cname = try(pages.value["cname"], null) 75 | dynamic "source" { 76 | for_each = pages.value["source"] 77 | content { 78 | branch = source.value["branch"] 79 | path = try(source.value["path"], null) 80 | } 81 | } 82 | } 83 | } 84 | dynamic "template" { 85 | for_each = try(each.value.template, []) 86 | content { 87 | owner = template.value["owner"] 88 | repository = template.value["repository"] 89 | } 90 | } 91 | 92 | lifecycle { 93 | ignore_changes = [] 94 | prevent_destroy = true 95 | } 96 | } 97 | 98 | resource "github_repository_collaborator" "this" { 99 | for_each = local.resources.github_repository_collaborator 100 | 101 | depends_on = [github_repository.this] 102 | 103 | repository = each.value.repository 104 | username = each.value.username 105 | permission = each.value.permission 106 | 107 | lifecycle { 108 | ignore_changes = [] 109 | } 110 | } 111 | 112 | resource "github_branch_protection" "this" { 113 | for_each = local.resources.github_branch_protection 114 | 115 | pattern = each.value.pattern 116 | 117 | repository_id = lookup(each.value, "repository_id", lookup(lookup(github_repository.this, lower(lookup(each.value, "repository", "")), {}), "node_id", null)) 118 | 119 | allows_deletions = try(each.value.allows_deletions, null) 120 | allows_force_pushes = try(each.value.allows_force_pushes, null) 121 | blocks_creations = try(each.value.blocks_creations, null) 122 | enforce_admins = try(each.value.enforce_admins, null) 123 | lock_branch = try(each.value.lock_branch, null) 124 | push_restrictions = try(each.value.push_restrictions, null) 125 | require_conversation_resolution = try(each.value.require_conversation_resolution, null) 126 | require_signed_commits = try(each.value.require_signed_commits, null) 127 | required_linear_history = try(each.value.required_linear_history, null) 128 | 129 | dynamic "required_pull_request_reviews" { 130 | for_each = try(each.value.required_pull_request_reviews, []) 131 | content { 132 | dismiss_stale_reviews = try(required_pull_request_reviews.value["dismiss_stale_reviews"], null) 133 | dismissal_restrictions = try(required_pull_request_reviews.value["dismissal_restrictions"], null) 134 | pull_request_bypassers = try(required_pull_request_reviews.value["pull_request_bypassers"], null) 135 | require_code_owner_reviews = try(required_pull_request_reviews.value["require_code_owner_reviews"], null) 136 | required_approving_review_count = try(required_pull_request_reviews.value["required_approving_review_count"], null) 137 | restrict_dismissals = try(required_pull_request_reviews.value["restrict_dismissals"], null) 138 | } 139 | } 140 | dynamic "required_status_checks" { 141 | for_each = try(each.value.required_status_checks, null) 142 | content { 143 | contexts = try(required_status_checks.value["contexts"], null) 144 | strict = try(required_status_checks.value["strict"], null) 145 | } 146 | } 147 | } 148 | 149 | resource "github_team" "this" { 150 | for_each = local.resources.github_team 151 | 152 | name = each.value.name 153 | 154 | 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) 155 | 156 | description = try(each.value.description, null) 157 | privacy = try(each.value.privacy, null) 158 | 159 | lifecycle { 160 | ignore_changes = [] 161 | } 162 | } 163 | 164 | resource "github_team_repository" "this" { 165 | for_each = local.resources.github_team_repository 166 | 167 | depends_on = [github_repository.this] 168 | 169 | repository = each.value.repository 170 | permission = each.value.permission 171 | 172 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null)) 173 | 174 | lifecycle { 175 | ignore_changes = [] 176 | } 177 | } 178 | 179 | resource "github_team_membership" "this" { 180 | for_each = local.resources.github_team_membership 181 | 182 | username = each.value.username 183 | role = each.value.role 184 | 185 | team_id = lookup(each.value, "team_id", lookup(lookup(github_team.this, lower(lookup(each.value, "team", "")), {}), "id", null)) 186 | 187 | lifecycle { 188 | ignore_changes = [] 189 | } 190 | } 191 | 192 | resource "github_repository_file" "this" { 193 | for_each = local.resources.github_repository_file 194 | 195 | repository = each.value.repository 196 | file = each.value.file 197 | content = each.value.content 198 | # Since 5.25.0 the branch attribute defaults to the default branch of the repository 199 | # branch = try(each.value.branch, null) 200 | branch = lookup(each.value, "branch", lookup(lookup(github_repository.this, each.value.repository, {}), "default_branch", null)) 201 | overwrite_on_create = try(each.value.overwrite_on_create, true) 202 | # Keep the defaults from 4.x 203 | commit_author = try(each.value.commit_author, "GitHub") 204 | commit_email = try(each.value.commit_email, "noreply@github.com") 205 | commit_message = try(each.value.commit_message, "chore: Update ${each.value.file} [skip ci]") 206 | 207 | lifecycle { 208 | ignore_changes = [] 209 | } 210 | } 211 | 212 | resource "github_issue_label" "this" { 213 | for_each = local.resources.github_issue_label 214 | 215 | depends_on = [github_repository.this] 216 | 217 | repository = each.value.repository 218 | name = each.value.name 219 | 220 | color = try(each.value.color, null) 221 | description = try(each.value.description, null) 222 | 223 | lifecycle { 224 | ignore_changes = [] 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /docs/SETUP.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | *NOTE*: The following TODO list is complete - it contains all the steps you should complete to get GitHub Management up. You might be able to skip some of them if you completed them before. 4 | 5 | - [ ] [Create a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template) from the template - *this is the place for GitHub Management to live in* 6 | 7 | ## GitHub Organization 8 | 9 | - [ ] [Set base permissions for the organization](https://docs.github.com/en/organizations/managing-access-to-your-organizations-repositories/setting-base-permissions-for-an-organization) to `Read` or `None` not to make all organization members de-facto admins through GitHub Management - `gh api -X PATCH /orgs/$GITHUB_ORGANIZATION -f default_repository_permission=read` 10 | - [ ] If you plan to keep the GitHub Management repository private, [allow forking of private repositories](https://docs.github.com/en/organizations/managing-organization-settings/managing-the-forking-policy-for-your-organization) and [enable workflows for private repository forks](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#enabling-workflows-for-private-repository-forks) - `gh api -X PATCH /orgs/$GITHUB_ORGANIZATION -f members_can_fork_private_repositories=true` (enabling workflows for private repository forks is not possible through API) 11 | 12 | ## AWS 13 | 14 | *NOTE*: Setting up AWS can be automated with [terraform](../terraform/bootstrap/aws.tf). If you choose to create AWS with terraform, remember that you'll still need to retrieve `AWS_ACCESS_KEY_ID`s and `AWS_SECRET_ACCESS_KEY`s manually. 15 | 16 | - [ ] [Create a S3 bucket](https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-bucket.html) - *this is where Terraform states for the organizations will be stored* 17 | - [ ] [Create a DynamoDB table](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/getting-started-step-1.html) using `LockID` of type `String` as the partition key - *this is where Terraform state locks will be stored* 18 | - [ ] [Create 2 IAM policies](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_create.html) - *they are going to be attached to the users that GitHub Management is going to use to interact with AWS* 19 |
Read-only 20 | 21 | ``` 22 | { 23 | "Version": "2012-10-17", 24 | "Statement": [ 25 | { 26 | "Action": [ 27 | "s3:ListBucket" 28 | ], 29 | "Effect": "Allow", 30 | "Resource": "arn:aws:s3:::$S3_BUCKET_NAME" 31 | }, 32 | { 33 | "Action": [ 34 | "s3:GetObject" 35 | ], 36 | "Effect": "Allow", 37 | "Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*" 38 | }, 39 | { 40 | "Action": [ 41 | "dynamodb:GetItem" 42 | ], 43 | "Effect": "Allow", 44 | "Resource": "arn:aws:dynamodb:*:*:table/$DYNAMO_DB_TABLE_NAME" 45 | } 46 | ] 47 | } 48 | ``` 49 |
50 |
Read & Write 51 | 52 | ``` 53 | { 54 | "Version": "2012-10-17", 55 | "Statement": [ 56 | { 57 | "Action": [ 58 | "s3:ListBucket" 59 | ], 60 | "Effect": "Allow", 61 | "Resource": "arn:aws:s3:::$S3_BUCKET_NAME" 62 | }, 63 | { 64 | "Action": [ 65 | "s3:PutObject", 66 | "s3:GetObject", 67 | "s3:DeleteObject" 68 | ], 69 | "Effect": "Allow", 70 | "Resource": "arn:aws:s3:::$S3_BUCKET_NAME/*" 71 | }, 72 | { 73 | "Action": [ 74 | "dynamodb:GetItem", 75 | "dynamodb:PutItem", 76 | "dynamodb:DeleteItem" 77 | ], 78 | "Effect": "Allow", 79 | "Resource": "arn:aws:dynamodb:*:*:table/$DYNAMO_DB_TABLE_NAME" 80 | } 81 | ] 82 | } 83 | ``` 84 |
85 | - [ ] [Create 2 IAM Users](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_users_create.html) and save their `AWS_ACCESS_KEY_ID`s and `AWS_SECRET_ACCESS_KEY`s - *they are going to be used by GitHub Management to interact with AWS* 86 | - [ ] one with read-only policy attached 87 | - [ ] one with read & write policy attached 88 | - [ ] Modify [terraform/terraform_override.tf](terraform/terraform_override.tf) to reflect your AWS setup 89 | 90 | ## GitHub App 91 | 92 | *NOTE*: If you already have a GitHub App with required permissions you can skip the app creation step. 93 | 94 | - [ ] [Create 2 GitHub Apps](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app) in the GitHub organization with the following permissions - *they are going to be used by terraform and GitHub Actions to authenticate with GitHub*: 95 |
read-only 96 | 97 | - `Repository permissions` 98 | - `Administration`: `Read-only` 99 | - `Contents`: `Read-only` 100 | - `Metadata`: `Read-only` 101 | - `Organization permissions` 102 | - `Members`: `Read-only` 103 |
104 |
read & write 105 | 106 | - `Repository permissions` 107 | - `Administration`: `Read & Write` 108 | - `Contents`: `Read & Write` 109 | - `Metadata`: `Read-only` 110 | - `Pull requests`: `Read & Write` 111 | - `Workflows`: `Read & Write` 112 | - `Organization permissions` 113 | - `Members`: `Read & Write` 114 |
115 | - [ ] [Install the GitHub Apps](https://docs.github.com/en/developers/apps/managing-github-apps/installing-github-apps) in the GitHub organization for `All repositories` 116 | 117 | ## GitHub Repository Secrets 118 | 119 | - [ ] [Create encrypted secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-organization) for the GitHub organization and allow the repository to access them (\*replace `$GITHUB_ORGANIZATION_NAME` with the GitHub organization name) - *these secrets are read by the GitHub Action workflows* 120 | - [ ] Go to `https://github.com/organizations/$GITHUB_ORGANIZATION_NAME/settings/apps/$GITHUB_APP_NAME` and copy the `App ID` 121 | - [ ] `RO_GITHUB_APP_ID` 122 | - [ ] `RW_GITHUB_APP_ID` 123 | - [ ] Go to `https://github.com/organizations/$GITHUB_ORGANIZATION_NAME/settings/installations`, click `Configure` next to the `$GITHUB_APP_NAME` and copy the numeric suffix from the URL 124 | - [ ] `RO_GITHUB_APP_INSTALLATION_ID` (or `RO_GITHUB_APP_INSTALLATION_ID_$GITHUB_ORGANIZATION_NAME` for organizations other than the repository owner) 125 | - [ ] `RW_GITHUB_APP_INSTALLATION_ID` (or `RW_GITHUB_APP_INSTALLATION_ID_$GITHUB_ORGANIZATION_NAME` for organizations other than the repository owner) 126 | - [ ] Go to `https://github.com/organizations/$GITHUB_ORGANIZATION_NAME/settings/apps/$GITHUB_APP_NAME`, click `Generate a private key` and copy the contents of the downloaded PEM file 127 | - [ ] `RO_GITHUB_APP_PEM_FILE` 128 | - [ ] `RW_GITHUB_APP_PEM_FILE` 129 | - [ ] Use the values generated during [AWS](#aws) setup 130 | - [ ] `RO_AWS_ACCESS_KEY_ID` 131 | - [ ] `RW_AWS_ACCESS_KEY_ID` 132 | - [ ] `RO_AWS_SECRET_ACCESS_KEY` 133 | - [ ] `RW_AWS_SECRET_ACCESS_KEY` 134 | 135 | ## GitHub Management Repository Setup 136 | 137 | *NOTE*: Advanced users might want to modify the resource types and their arguments/attributes managed by GitHub Management at this stage. 138 | 139 | *NOTE*: You can manage more than one organization from a single GitHub Management repository. To do so create more YAMLs under `github` directory. Remember to set up secrets for all your organizations. 140 | 141 | - [ ] [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) 142 | - [ ] Replace placeholder strings in the clone - *the repository needs to be customised for the specific organization it is supposed to manage* 143 | - [ ] Rename the `$GITHUB_ORGANIZATION_NAME.yml` in `github` to the name of the GitHub organization 144 | - [ ] Push the changes to `$GITHUB_MGMT_REPOSITORY_DEFAULT_BRANCH` 145 | 146 | ## GitHub Management Sync Flow 147 | 148 | - [ ] Follow [How to synchronize GitHub Management with GitHub?](HOWTOS.md#synchronize-github-management-with-github) to commit the terraform lock and initialize terraform state 149 | 150 | ## GitHub Management Repository Protections 151 | 152 | *NOTE*: Advanced users might have to skip/adjust this step if they are not managing some of the arguments/attributes mentioned here with GitHub Management. 153 | 154 | *NOTE*: If you want to require PRs to be created but don't care about reviews, then change `required_approving_review_count` value to `0`. It seems for some reason the provider's default is `1` instead of `0`. The next `Sync` will remove this value from the configuration file and will leave an empty object inside `required_pull_request_reviews` which is the desired state. 155 | 156 | *NOTE*: Branch protection rules are not available for private repositories on Free plan. 157 | 158 | - [ ] Manually set values that are impossible to control this value via terraform currently 159 | - [ ] [Set read repository contents permissions for `GITHUB_TOKEN`](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#setting-the-permissions-of-the-github_token-for-your-repository) 160 | - [ ] If the repository is public, [require approval for all outside collaborators](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#configuring-required-approval-for-workflows-from-public-forks) 161 | - [ ] If the repository is private, [disable sending write tokens or secrets to worfklows from fork pull requests](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#enabling-workflows-for-private-repository-forks) 162 | - [ ] Pull remote changes to the default branch 163 | - [ ] Enable required PRs, peer reviews, status checks and branch up-to-date check on the repository by making sure [github/$ORGANIZATION_NAME.yml](github/$ORGANIZATION_NAME.yml) contains the following entry: 164 | ```yaml 165 | repositories: 166 | $GITHUB_MGMT_REPOSITORY_NAME: 167 | branch_protection: 168 | $GITHUB_MGMT_REPOSITORY_DEFAULT_BRANCH: 169 | required_pull_request_reviews: 170 | required_approving_review_count: 1 171 | required_status_checks: 172 | contexts: 173 | - Comment 174 | strict": true 175 | ``` 176 | - [ ] Push the changes to a branch other than the default branch 177 | 178 | ## GitHub Management PR Flow 179 | 180 | *NOTE*: Advanced users might have to skip this step if they skipped setting up [GitHub Management Repository Protections](#github-management-repository-protections) via GitHub Management. 181 | 182 | - [ ] Follow [How to apply GitHub Management changes to GitHub?](HOWTOS.md#apply-github-management-changes-to-github) to apply protections to the repository 183 | --------------------------------------------------------------------------------