├── .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 │ └── yaml │ │ └── config.test.ts ├── .eslintignore ├── src │ ├── terraform │ │ ├── schema.ts │ │ └── state.ts │ ├── actions │ │ ├── fix-yaml-config.ts │ │ ├── format-yaml-config.ts │ │ ├── shared │ │ │ ├── format.ts │ │ │ ├── add-file-to-all-repos.ts │ │ │ ├── protect-default-branches.ts │ │ │ └── add-label-to-all-repos.ts │ │ ├── update-pull-requests.ts │ │ └── remove-inactive-members.ts │ ├── main.ts │ ├── env.ts │ ├── utils.ts │ ├── sync.ts │ ├── yaml │ │ ├── schema.ts │ │ └── config.ts │ ├── resources │ │ ├── resource.ts │ │ ├── member.ts │ │ ├── team.ts │ │ ├── team-member.ts │ │ ├── repository-team.ts │ │ ├── repository-file.ts │ │ ├── repository.ts │ │ ├── repository-collaborator.ts │ │ └── repository-branch-protection-rule.ts │ └── github.ts ├── tsconfig.build.json ├── jest.d.ts ├── .prettierrc.json ├── jest.config.js ├── tsconfig.json ├── package.json ├── .gitignore ├── jest.setup.ts └── .eslintrc.json ├── terraform ├── providers.tf ├── data.tf ├── locals.tf ├── variables.tf ├── terraform.tf ├── locals_override.tf ├── terraform_override.tf ├── resources_override.tf ├── .terraform.lock.hcl ├── bootstrap │ └── aws.tf └── resources.tf ├── files └── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── default.md ├── actions │ ├── git-config-user │ │ └── action.yml │ └── git-push │ │ └── action.yml ├── workflows │ ├── upgrade.yml │ ├── update.yml │ ├── clean.yml │ ├── apply.yml │ ├── sync.yml │ ├── plan.yml │ └── fix.yml └── PULL_REQUEST_TEMPLATE.md ├── CODEOWNERS ├── .gitignore ├── README.md ├── docs ├── STEWARDSHIP.md ├── EXAMPLE.yml ├── ABOUT.md ├── HOWTOS.md └── SETUP.md ├── CHANGELOG.md └── github └── .schema.json /.sync: -------------------------------------------------------------------------------- 1 | 3505998112 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/fix-yaml-config.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import {format} from './shared/format' 3 | 4 | format() 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 | -------------------------------------------------------------------------------- /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/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | organization = terraform.workspace 3 | config = yamldecode(file("${path.module}/../github/${local.organization}.yml")) 4 | resource_types = [] 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /terraform/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | github = { 4 | source = "integrations/github" 5 | version = "4.23.0" 6 | } 7 | } 8 | 9 | required_version = "~> 1.1.4" 10 | } 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /files/README.md: -------------------------------------------------------------------------------- 1 | Put files that you want to distribute through GitHub Management here. 2 | 3 | In `repositories.*.files.*.content`, put a relative path to the file. E.g. a reference to this file would be just `README.md`. 4 | 5 | You can create a tree directory structure here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Self-serve Request 4 | url: https://github.com/multiformats/github-mgmt/edit/master/github/multiformats.yml 5 | about: Propose a GitHub configuration change by modifying multiformats.yml 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /terraform/terraform_override.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | # account_id = "642361402189" 4 | region = "us-east-1" 5 | bucket = "github-mgmt" 6 | key = "terraform.tfstate" 7 | workspace_key_prefix = "org" 8 | dynamodb_table = "github-mgmt" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.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 | - run: | 8 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com>" 9 | git config --global user.name "${GITHUB_ACTOR}" 10 | shell: bash 11 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # The ipdx team is responsible for GitHub Management maintenance 2 | * @multiformats/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/multiformats.yml @multiformats/github-mgmt-stewards @multiformats/ipdx 7 | -------------------------------------------------------------------------------- /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 | ] 12 | } 13 | -------------------------------------------------------------------------------- /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 | 6 | async function run(): Promise { 7 | const state = await State.New() 8 | const config = Config.FromPath() 9 | 10 | await sync(state, config) 11 | 12 | config.save() 13 | } 14 | 15 | run() 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/__tests__/sync.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {Config as YAMLConfig} from '../src/yaml/config' 4 | import {State as TFConfig} from '../src/terraform/state' 5 | import {sync} from '../src/sync' 6 | 7 | test('sync', async () => { 8 | const yamlConfig = new YAMLConfig('{}') 9 | const tfConfig = await TFConfig.New() 10 | 11 | const expectedYamlConfig = YAMLConfig.FromPath() 12 | 13 | await sync(tfConfig, yamlConfig) 14 | 15 | yamlConfig.format() 16 | 17 | expect(yamlConfig.toString()).toEqual(expectedYamlConfig.toString()) 18 | }) 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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: protocol/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 | -------------------------------------------------------------------------------- /.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: multiformats 2 | 3 | This repository is responsible for managing GitHub configuration of `multiformats` 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/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 | await state.refresh() 8 | 9 | const resources: [Id, Resource][] = [] 10 | for (const resourceClass of ResourceConstructors) { 11 | const oldResources = state.getResources(resourceClass) 12 | const newResources = await resourceClass.FromGitHub(oldResources) 13 | resources.push(...newResources) 14 | } 15 | 16 | await state.sync(resources) 17 | await state.refresh() 18 | 19 | const syncedResources = [] 20 | for (const resourceClass of ResourceConstructors) { 21 | syncedResources.push(...state.getResources(resourceClass)) 22 | } 23 | 24 | config.sync(syncedResources) 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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, 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 | -------------------------------------------------------------------------------- /.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: multiformats/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/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 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | 4 | ### Why do you need this? 5 | 6 | 7 | ### What else do we need to know? 8 | 9 | 10 | **DRI:** myself 11 | 12 | 13 | ### Reviewer's Checklist 14 | 15 | - [ ] It is clear where the request is coming from (if unsure, ask) 16 | - [ ] All the automated checks passed 17 | - [ ] The YAML changes reflect the summary of the request 18 | - [ ] The Terraform plan posted as a comment reflects the summary of the request 19 | -------------------------------------------------------------------------------- /.github/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@v3 24 | - run: npm install && 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /terraform/resources_override.tf: -------------------------------------------------------------------------------- 1 | resource "github_repository" "this" { 2 | lifecycle { 3 | ignore_changes = [ 4 | allow_auto_merge, 5 | allow_merge_commit, 6 | allow_rebase_merge, 7 | allow_squash_merge, 8 | archive_on_destroy, 9 | archived, 10 | auto_init, 11 | delete_branch_on_merge, 12 | gitignore_template, 13 | has_downloads, 14 | has_issues, 15 | has_projects, 16 | has_wiki, 17 | homepage_url, 18 | ignore_vulnerability_alerts_during_read, 19 | is_template, 20 | license_template, 21 | pages, 22 | template, 23 | vulnerability_alerts, 24 | ] 25 | } 26 | } 27 | 28 | resource "github_branch_protection" "this" { 29 | lifecycle { 30 | ignore_changes = [ 31 | allows_deletions, 32 | allows_force_pushes, 33 | enforce_admins, 34 | push_restrictions, 35 | require_conversation_resolution, 36 | require_signed_commits, 37 | required_linear_history, 38 | required_pull_request_reviews, 39 | required_status_checks, 40 | ] 41 | } 42 | } 43 | 44 | resource "github_repository_file" "this" { 45 | lifecycle { 46 | ignore_changes = [ 47 | overwrite_on_create, 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /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 = "4.23.0" 6 | constraints = "4.23.0" 7 | hashes = [ 8 | "h1:C8mQRZrHenfru/LKN+qqwAI6aEIGbu9iWaWVmGFwcy4=", 9 | "zh:003f67dcb506ea50b34acce92f575cd04560a21c57bb63de1c9b3874dda10337", 10 | "zh:1b9e77fb728e3d2c8d25d04ac613e7587714c63c54532ac96787b4d351b164de", 11 | "zh:1d2c486c7529c832a3129c4aa38f63f20067ad0652dfdb55e5cb567a13551390", 12 | "zh:382ede0b5b2e14fa93f406472d3933460a3d2085d5fd8d0a80566ae92bb099fe", 13 | "zh:4a972a031c22f31ba5a6d219dcbeccb0658a4ccf09a0f0faaed71387864e4011", 14 | "zh:91674d804aeb24604128a35ed30d34debd0a52a8417a9f91e1c606553a2d7117", 15 | "zh:9265efbf7b3b5f42fb95cab2d3cefa88d4ee2a0dff4da7b6fcd8cf99c8c5b6ea", 16 | "zh:92bb5afd8dcca6fa650aed9d6c3b944a7264b79900e8e1c53984cea32cb1799f", 17 | "zh:9ae88926cbc1b56a5819f27ddb88cceac96b165a3c7e1ba52ab61af1bc5c26a8", 18 | "zh:a94ca44a74e9261ca5cad232254d557457a2db6482b4881f583701a7ca721bde", 19 | "zh:d15d210923606be5195c6826d5358a5ee5dca9afae84219ced59cf0eddf3e673", 20 | "zh:e4dda0cc46a27bc06074dcaee8a77fff3f391da820b7d3755ad7d484c8bb228f", 21 | "zh:f8fba7236a31c8d03f230f897855b0aadde90c788fd5e08ddf94b6b1cab9f6d6", 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/src/actions/shared/add-label-to-all-repos.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '../../yaml/config' 2 | import {Repository} from '../../resources/repository' 3 | import {GitHub} from '../../github' 4 | import env from '../../env' 5 | import * as core from '@actions/core' 6 | 7 | export async function addLabelToAllRepos( 8 | label: string, 9 | color: string, 10 | description: string, 11 | repositoryFilter: (repository: Repository) => boolean = () => true 12 | ): Promise { 13 | const config = Config.FromPath() 14 | const github = await GitHub.getGitHub() 15 | 16 | const repositories = config 17 | .getResources(Repository) 18 | .filter(r => !r.archived) 19 | .filter(repositoryFilter) 20 | 21 | for (const repository of repositories) { 22 | // labels are not supported by GitHub Management yet 23 | const labels = await github.client.paginate( 24 | github.client.issues.listLabelsForRepo, 25 | { 26 | owner: env.GITHUB_ORG, 27 | repo: repository.name 28 | } 29 | ) 30 | if (!labels.map(l => l.name).includes(label)) { 31 | core.info(`Adding label ${label} to ${repository.name}`) 32 | await github.client.issues.createLabel({ 33 | owner: env.GITHUB_ORG, 34 | repo: repository.name, 35 | name: label, 36 | color: color, 37 | description: description 38 | }) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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.9.0", 19 | "@actions/exec": "^1.1.1", 20 | "@actions/github": "^5.0.3", 21 | "@octokit/auth-app": "^4.0.2", 22 | "@octokit/graphql": "^4.8.0", 23 | "@octokit/plugin-retry": "^3.0.9", 24 | "@octokit/plugin-throttling": "^4.0.1", 25 | "@octokit/rest": "^19.0.1", 26 | "class-transformer": "^0.5.1", 27 | "deep-diff": "^1.0.2", 28 | "hcl2-parser": "^1.0.3", 29 | "reflect-metadata": "^0.1.13", 30 | "yaml": "^2.1.1" 31 | }, 32 | "devDependencies": { 33 | "@types/deep-diff": "^1.0.1", 34 | "@types/jest": "^27.5.2", 35 | "@types/node": "^16.11.42", 36 | "@typescript-eslint/parser": "^5.8.1", 37 | "eslint": "^8.0.1", 38 | "eslint-plugin-github": "^4.3.2", 39 | "eslint-plugin-jest": "^25.3.2", 40 | "eslint-plugin-prettier": "^4.2.1", 41 | "jest": "^27.2.5", 42 | "prettier": "2.5.1", 43 | "ts-jest": "^27.1.2", 44 | "ts-json-schema-generator": "^1.0.0", 45 | "typescript": "^4.4.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /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 {Role as TeamRole} from '../resources/team-member' 8 | import {Team} from '../resources/team' 9 | import * as YAML from 'yaml' 10 | import {yamlify} from '../utils' 11 | 12 | type TeamMember = string 13 | type RepositoryCollaborator = string 14 | type RepositoryTeam = string 15 | type Member = string 16 | 17 | interface RepositoryExtension { 18 | files?: Record 19 | collaborators?: { 20 | [permission in RepositoryCollaboratorPermission]?: RepositoryCollaborator[] 21 | } 22 | teams?: { 23 | [permission in RepositoryTeamPermission]?: RepositoryTeam[] 24 | } 25 | branch_protection?: Record 26 | } 27 | 28 | interface TeamExtension { 29 | members?: { 30 | [role in TeamRole]?: TeamMember[] 31 | } 32 | } 33 | 34 | export type Path = (string | number)[] 35 | 36 | export class ConfigSchema { 37 | members?: { 38 | [role in MemberRole]?: Member[] 39 | } 40 | repositories?: Record 41 | teams?: Record 42 | } 43 | 44 | export function pathToYAML(path: Path): (YAML.ParsedNode | number)[] { 45 | return path.map(e => (typeof e === 'number' ? e : yamlify(e))) 46 | } 47 | -------------------------------------------------------------------------------- /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 @multiformats/ipdx. 35 | -------------------------------------------------------------------------------- /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 {GitHub} from './src/github' 11 | 12 | jest.mock('./src/env', () => ({ 13 | TF_EXEC: 'false', 14 | TF_LOCK: 'false', 15 | TF_WORKING_DIR: '__tests__/__resources__/terraform', 16 | GITHUB_DIR: '__tests__/__resources__/github', 17 | FILES_DIR: '__tests__/__resources__/files', 18 | GITHUB_ORG: 'default' 19 | })) 20 | 21 | GitHub.github = { 22 | listMembers: async () => { 23 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 24 | }, 25 | listRepositories: async () => { 26 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 27 | }, 28 | listTeams: async () => { 29 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 30 | }, 31 | listRepositoryCollaborators: async () => { 32 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 33 | }, 34 | listRepositoryBranchProtectionRules: async () => { 35 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 36 | }, 37 | listTeamRepositories: async () => { 38 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 39 | }, 40 | listTeamMembers: async () => { 41 | return [] as any // eslint-disable-line @typescript-eslint/no-explicit-any 42 | }, 43 | getRepositoryFile: async (_repository: string, _path: string) => { 44 | return undefined 45 | } 46 | } as GitHub 47 | 48 | global.ResourceCounts = { 49 | [Member.name]: 2, 50 | [Repository.name]: 7, 51 | [Team.name]: 2, 52 | [RepositoryCollaborator.name]: 1, 53 | [RepositoryBranchProtectionRule.name]: 1, 54 | [RepositoryTeam.name]: 7, 55 | [TeamMember.name]: 2, 56 | [RepositoryFile.name]: 1 57 | } 58 | global.ResourcesCount = Object.values(global.ResourceCounts).reduce( 59 | (a, b) => a + b, 60 | 0 61 | ) 62 | -------------------------------------------------------------------------------- /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 {RepositoryTeam} from './repository-team' 10 | import {Team} from './team' 11 | import {TeamMember} from './team-member' 12 | 13 | export interface Resource { 14 | // returns YAML config path under which the resource can be found 15 | // e.g. ['members', 'admin', ] 16 | getSchemaPath(schema: ConfigSchema): Path 17 | // returns Terraform state path under which the resource can be found 18 | // e.g. github_membership.this["galargh"] 19 | getStateAddress(): string 20 | } 21 | 22 | export interface ResourceConstructor { 23 | new (...args: any[]): T 24 | // extracts all resources of specific type from the given YAML config 25 | FromConfig(config: ConfigSchema): T[] 26 | // extracts all resources of specific type from the given Terraform state 27 | FromState(state: StateSchema): T[] 28 | // retrieves all resources of specific type from GitHub API 29 | // it takes a list of resources of the same type as an argument 30 | // an implementation can choose to ignore it or use it to only check if given resources still exist 31 | // this is the case with repository files for example where we don't want to manage ALL the files thorugh GitHub Management 32 | FromGitHub(resources: T[]): Promise<[Id, Resource][]> 33 | StateType: string 34 | } 35 | 36 | export const ResourceConstructors: ResourceConstructor[] = [ 37 | Member, 38 | RepositoryBranchProtectionRule, 39 | RepositoryCollaborator, 40 | RepositoryFile, 41 | RepositoryTeam, 42 | Repository, 43 | TeamMember, 44 | Team 45 | ] 46 | 47 | export function resourceToPlain( 48 | resource: T | undefined 49 | ): string | Record | undefined { 50 | if (resource !== undefined) { 51 | if (resource instanceof String) { 52 | return resource.toString() 53 | } else { 54 | return instanceToPlain(resource, {exposeUnsetFields: false}) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /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 members = await github.listMembers() 17 | const result: [Id, Member][] = [] 18 | for (const member of members) { 19 | if (member.role === 'billing_manager') { 20 | throw new Error(`Member role 'billing_manager' is not supported.`) 21 | } 22 | result.push([ 23 | `${env.GITHUB_ORG}:${member.user!.login}`, 24 | new Member(member.user!.login, member.role as Role) 25 | ]) 26 | } 27 | return result 28 | } 29 | static FromState(state: StateSchema): Member[] { 30 | const members: Member[] = [] 31 | if (state.values?.root_module?.resources !== undefined) { 32 | for (const resource of state.values.root_module.resources) { 33 | if (resource.type === Member.StateType && resource.mode === 'managed') { 34 | members.push( 35 | new Member(resource.values.username, resource.values.role) 36 | ) 37 | } 38 | } 39 | } 40 | return members 41 | } 42 | static FromConfig(config: ConfigSchema): Member[] { 43 | const members: Member[] = [] 44 | if (config.members !== undefined) { 45 | for (const [role, usernames] of Object.entries(config.members)) { 46 | for (const username of usernames ?? []) { 47 | members.push(new Member(username, role as Role)) 48 | } 49 | } 50 | } 51 | return members 52 | } 53 | 54 | constructor(username: string, role: Role) { 55 | super(username) 56 | this._username = username 57 | this._role = role 58 | } 59 | 60 | private _username: string 61 | get username(): string { 62 | return this._username 63 | } 64 | private _role: Role 65 | get role(): Role { 66 | return this._role 67 | } 68 | 69 | getSchemaPath(schema: ConfigSchema): Path { 70 | const members = schema.members?.[this.role] ?? [] 71 | const index = members.indexOf(this.username) 72 | return ['members', this.role, index === -1 ? members.length : index] 73 | } 74 | 75 | getStateAddress(): string { 76 | return `${Member.StateType}.this["${this.username}"]` 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /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 | 22 | ### Changed 23 | - Synchronization script: to use GitHub API directly instead of relying on TF GH Provider's Data Sources 24 | - Configuration: replaced multiple JSONs with a single, unified YAML 25 | - Synchronization script: rewrote the script in JS 26 | - Upgrade (reusable) workflow: included docs and CHANGELOG in the upgrades 27 | - README: extracted sections to separate docs 28 | - GitHub Provider: upgraded to v4.23.0 29 | - Upgrade workflows: accept github-mgmt-template ref to upgrade to 30 | - Commit message for repository files: added chore: prefix and [skip ci] suffix 31 | - scripts: to export tf resource definitions and always sort before save 32 | - plan: to be triggered on pull_request_target 33 | - plan: to only check out github directory from the PR 34 | - plan: to wait for Apply workflow runs to finish 35 | - defaults: not to ignore any properties by default 36 | - add-file-to-all-repos: to accept a repo filter instead of an repo exclude list 37 | - sync: to push changes directly to the branch 38 | - automated commit messages: to include github run id information 39 | - apply: not to use deprecated GitHub API anymore 40 | - workflows: not to use deprecated GitHub Actions runners anymore 41 | - workflows: not to use deprecated GitHub Actions expressions anymore 42 | 43 | ### Fixed 44 | - links to supported resources in HOWTOs 45 | - posting PR comments when terraform plan output is very long 46 | - PR parsing in the update workflow 47 | - array head retrieval in scripts 48 | - team imports 49 | - parent_team_id retrieval from state 50 | - saving config sync result 51 | - how dry run flag is passed in the clean workflow 52 | - how sync invalidates PR plans 53 | - support for pull_request_bypassers in branch protection rules 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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 | const resources = config.getResources(Repository) 27 | expect(resources).not.toHaveLength(0) 28 | 29 | config['_ignoredTypes'] = ['github_repository'] 30 | await config.refresh() 31 | 32 | const refreshedResources = config.getResources(Repository) 33 | expect(refreshedResources).toHaveLength(0) 34 | }) 35 | 36 | test('can ignore resource properties', async () => { 37 | const config = await State.New() 38 | 39 | const resource = config.getResources(Repository)[0] 40 | expect(resource.description).toBeDefined() 41 | 42 | config['_ignoredProperties'] = {github_repository: ['description']} 43 | await config.refresh() 44 | 45 | const refreshedResource = config.getResources(Repository)[0] 46 | expect(refreshedResource.description).toBeUndefined() 47 | }) 48 | 49 | test('can add and remove resources through sync', async () => { 50 | const config = await State.New() 51 | 52 | let addResourceSpy = jest.spyOn(config, 'addResource') 53 | let removeResourceSpy = jest.spyOn(config, 'removeResource') 54 | 55 | let desiredResources: [Id, Resource][] = [] 56 | let resources = config.getAllResources() 57 | 58 | await config.sync(desiredResources) 59 | 60 | expect(addResourceSpy).not.toHaveBeenCalled() 61 | expect(removeResourceSpy).toHaveBeenCalledTimes(resources.length) 62 | addResourceSpy.mockReset() 63 | removeResourceSpy.mockReset() 64 | 65 | for (const resource of resources) { 66 | desiredResources.push(['id', resource]) 67 | } 68 | 69 | await config.sync(desiredResources) 70 | expect(addResourceSpy).not.toHaveBeenCalled() 71 | expect(removeResourceSpy).not.toHaveBeenCalled() 72 | addResourceSpy.mockReset() 73 | removeResourceSpy.mockReset() 74 | 75 | desiredResources.push(['id', new Repository('test')]) 76 | desiredResources.push(['id', new Repository('test2')]) 77 | desiredResources.push(['id', new Repository('test3')]) 78 | desiredResources.push(['id', new Repository('test4')]) 79 | 80 | await config.sync(desiredResources) 81 | expect(addResourceSpy).toHaveBeenCalledTimes( 82 | desiredResources.length - resources.length 83 | ) 84 | expect(removeResourceSpy).not.toHaveBeenCalled() 85 | }) 86 | -------------------------------------------------------------------------------- /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 | 6 | export enum Role { 7 | Maintainer = 'maintainer', 8 | Member = 'member' 9 | } 10 | 11 | export class TeamMember extends String implements Resource { 12 | static StateType: string = 'github_team_membership' 13 | static async FromGitHub(_members: TeamMember[]): Promise<[Id, TeamMember][]> { 14 | const github = await GitHub.getGitHub() 15 | const members = await github.listTeamMembers() 16 | const result: [Id, TeamMember][] = [] 17 | for (const member of members) { 18 | result.push([ 19 | `${member.team.id}:${member.member.login}`, 20 | new TeamMember( 21 | member.team.name, 22 | member.member.login, 23 | member.membership.role as Role 24 | ) 25 | ]) 26 | } 27 | return result 28 | } 29 | static FromState(state: StateSchema): TeamMember[] { 30 | const members: TeamMember[] = [] 31 | if (state.values?.root_module?.resources !== undefined) { 32 | for (const resource of state.values.root_module.resources) { 33 | if ( 34 | resource.type === TeamMember.StateType && 35 | resource.mode === 'managed' 36 | ) { 37 | const team = resource.index.split(`:${resource.values.username}`)[0] 38 | members.push( 39 | new TeamMember(team, resource.values.username, resource.values.role) 40 | ) 41 | } 42 | } 43 | } 44 | return members 45 | } 46 | static FromConfig(config: ConfigSchema): TeamMember[] { 47 | const members: TeamMember[] = [] 48 | if (config.teams !== undefined) { 49 | for (const [team_name, team] of Object.entries(config.teams)) { 50 | if (team.members !== undefined) { 51 | for (const [role, usernames] of Object.entries(team.members)) { 52 | for (const username of usernames ?? []) { 53 | members.push(new TeamMember(team_name, username, role as Role)) 54 | } 55 | } 56 | } 57 | } 58 | } 59 | return members 60 | } 61 | 62 | constructor(team: string, username: string, role: Role) { 63 | super(username) 64 | this._team = team 65 | this._username = username 66 | this._role = role 67 | } 68 | 69 | private _team: string 70 | get team(): string { 71 | return this._team 72 | } 73 | private _username: string 74 | get username(): string { 75 | return this._username 76 | } 77 | private _role: Role 78 | get role(): Role { 79 | return this._role 80 | } 81 | 82 | getSchemaPath(schema: ConfigSchema): Path { 83 | const members = schema.teams?.[this.team]?.members?.[this.role] || [] 84 | const index = members.indexOf(this.username) 85 | return [ 86 | 'teams', 87 | this.team, 88 | 'members', 89 | this.role, 90 | index === -1 ? members.length : index 91 | ] 92 | } 93 | 94 | getStateAddress(): string { 95 | return `${TeamMember.StateType}.this["${this.team}:${this.username}"]` 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /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.1.4" 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/protocol/github-mgmt-template" 31 | } 32 | } 33 | 34 | resource "aws_s3_bucket_acl" "this" { 35 | bucket = aws_s3_bucket.this.id 36 | acl = "private" 37 | } 38 | 39 | resource "aws_dynamodb_table" "this" { 40 | name = var.name 41 | billing_mode = "PAY_PER_REQUEST" 42 | hash_key = "LockID" 43 | 44 | attribute { 45 | name = "LockID" 46 | type = "S" 47 | } 48 | 49 | tags = { 50 | Name = "GitHub Management" 51 | Url = "https://github.com/protocol/github-mgmt-template" 52 | } 53 | } 54 | 55 | resource "aws_iam_user" "ro" { 56 | name = "${var.name}-ro" 57 | 58 | tags = { 59 | Name = "GitHub Management" 60 | Url = "https://github.com/protocol/github-mgmt-template" 61 | } 62 | } 63 | 64 | resource "aws_iam_user" "rw" { 65 | name = "${var.name}-rw" 66 | 67 | tags = { 68 | Name = "GitHub Management" 69 | Url = "https://github.com/protocol/github-mgmt-template" 70 | } 71 | } 72 | 73 | data "aws_iam_policy_document" "ro" { 74 | statement { 75 | actions = ["s3:ListBucket"] 76 | resources = ["${aws_s3_bucket.this.arn}"] 77 | effect = "Allow" 78 | } 79 | 80 | statement { 81 | actions = ["s3:GetObject"] 82 | resources = ["${aws_s3_bucket.this.arn}/*"] 83 | effect = "Allow" 84 | } 85 | 86 | statement { 87 | actions = ["dynamodb:GetItem"] 88 | resources = ["${aws_dynamodb_table.this.arn}"] 89 | effect = "Allow" 90 | } 91 | } 92 | 93 | data "aws_iam_policy_document" "rw" { 94 | statement { 95 | actions = ["s3:ListBucket"] 96 | resources = ["${aws_s3_bucket.this.arn}"] 97 | effect = "Allow" 98 | } 99 | 100 | statement { 101 | actions = [ 102 | "s3:GetObject", 103 | "s3:PutObject", 104 | "s3:DeleteObject", 105 | ] 106 | 107 | resources = ["${aws_s3_bucket.this.arn}/*"] 108 | effect = "Allow" 109 | } 110 | 111 | statement { 112 | actions = [ 113 | "dynamodb:GetItem", 114 | "dynamodb:PutItem", 115 | "dynamodb:DeleteItem", 116 | ] 117 | 118 | resources = ["${aws_dynamodb_table.this.arn}"] 119 | effect = "Allow" 120 | } 121 | } 122 | 123 | resource "aws_iam_user_policy" "ro" { 124 | name = "${var.name}-ro" 125 | user = "${aws_iam_user.ro.name}" 126 | 127 | policy = "${data.aws_iam_policy_document.ro.json}" 128 | } 129 | 130 | resource "aws_iam_user_policy" "rw" { 131 | name = "${var.name}-rw" 132 | user = "${aws_iam_user.rw.name}" 133 | 134 | policy = "${data.aws_iam_policy_document.rw.json}" 135 | } 136 | -------------------------------------------------------------------------------- /.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@v3 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@v3 73 | - name: Setup terraform 74 | uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 75 | with: 76 | terraform_version: 1.1.4 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 | 6 | export enum Permission { 7 | Admin = 'admin', 8 | Maintain = 'maintain', 9 | Push = 'push', 10 | Triage = 'triage', 11 | Pull = 'pull' 12 | } 13 | 14 | export class RepositoryTeam extends String implements Resource { 15 | static StateType: string = 'github_team_repository' 16 | static async FromGitHub( 17 | _teams: RepositoryTeam[] 18 | ): Promise<[Id, RepositoryTeam][]> { 19 | const github = await GitHub.getGitHub() 20 | const teams = await github.listTeamRepositories() 21 | const result: [Id, RepositoryTeam][] = [] 22 | for (const team of teams) { 23 | result.push([ 24 | `${team.team.id}:${team.repository.name}`, 25 | new RepositoryTeam( 26 | team.repository.name, 27 | team.team.name, 28 | team.team.permission as Permission 29 | ) 30 | ]) 31 | } 32 | return result 33 | } 34 | static FromState(state: StateSchema): RepositoryTeam[] { 35 | const teams: RepositoryTeam[] = [] 36 | if (state.values?.root_module?.resources !== undefined) { 37 | for (const resource of state.values.root_module.resources) { 38 | if ( 39 | resource.type === RepositoryTeam.StateType && 40 | resource.mode === 'managed' 41 | ) { 42 | const team = resource.index.split(`:${resource.values.repository}`)[0] 43 | teams.push( 44 | new RepositoryTeam( 45 | resource.values.repository, 46 | team, 47 | resource.values.permission 48 | ) 49 | ) 50 | } 51 | } 52 | } 53 | return teams 54 | } 55 | static FromConfig(config: ConfigSchema): RepositoryTeam[] { 56 | const teams: RepositoryTeam[] = [] 57 | if (config.repositories !== undefined) { 58 | for (const [repository_name, repository] of Object.entries( 59 | config.repositories 60 | )) { 61 | if (repository.teams !== undefined) { 62 | for (const [permission, team_names] of Object.entries( 63 | repository.teams 64 | )) { 65 | for (const team_name of team_names ?? []) { 66 | teams.push( 67 | new RepositoryTeam( 68 | repository_name, 69 | team_name, 70 | permission as Permission 71 | ) 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | return teams 79 | } 80 | constructor(repository: string, team: string, permission: Permission) { 81 | super(team) 82 | this._repository = repository 83 | this._team = team 84 | this._permission = permission 85 | } 86 | 87 | private _repository: string 88 | get repository(): string { 89 | return this._repository 90 | } 91 | private _team: string 92 | get team(): string { 93 | return this._team 94 | } 95 | private _permission: Permission 96 | get permission(): Permission { 97 | return this._permission 98 | } 99 | 100 | getSchemaPath(schema: ConfigSchema): Path { 101 | const teams = 102 | schema.repositories?.[this.repository]?.teams?.[this.permission] || [] 103 | const index = teams.indexOf(this.team) 104 | return [ 105 | 'repositories', 106 | this.repository, 107 | 'teams', 108 | this.permission, 109 | index === -1 ? teams.length : index 110 | ] 111 | } 112 | 113 | getStateAddress(): string { 114 | return `${RepositoryTeam.StateType}.this["${this.team}:${this.repository}"]` 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /.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@v3 27 | - name: Discover workspaces 28 | id: workspaces 29 | run: echo "this=$(ls github | jq --raw-input '[.[0:-4]]' | jq -sc add)" >> $GITHUB_OUTPUT 30 | - name: Find sha for plan 31 | id: sha 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | QUERY: repository:${{ github.repository }} ${{ github.sha }} 35 | uses: actions/github-script@v6 36 | with: 37 | result-encoding: string 38 | script: | 39 | if (process.env.GITHUB_EVENT_NAME === 'push') { 40 | const { data: search } = await github.rest.search.issuesAndPullRequests({ 41 | q: process.env.QUERY, 42 | per_page: 1 43 | }); 44 | if (search.total_count !== 0) { 45 | const { data: pulls } = await github.rest.pulls.listCommits({ 46 | owner: process.env.GITHUB_REPOSITORY_OWNER, 47 | repo: process.env.GITHUB_REPOSITORY.slice(process.env.GITHUB_REPOSITORY_OWNER.length + 1), 48 | pull_number: search.items[0].number, 49 | per_page: 100 50 | }); 51 | return pulls.at(-1).sha; 52 | } else { 53 | return ''; 54 | } 55 | } else { 56 | return process.env.GITHUB_SHA; 57 | } 58 | apply: 59 | needs: [prepare] 60 | if: needs.prepare.outputs.sha != '' && needs.prepare.outputs.workspaces != '' 61 | permissions: 62 | actions: read 63 | contents: read 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | workspace: ${{ fromJson(needs.prepare.outputs.workspaces) }} 68 | name: Apply 69 | runs-on: ubuntu-latest 70 | env: 71 | TF_IN_AUTOMATION: 1 72 | TF_INPUT: 0 73 | TF_WORKSPACE: ${{ matrix.workspace }} 74 | AWS_ACCESS_KEY_ID: ${{ secrets.RW_AWS_ACCESS_KEY_ID }} 75 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RW_AWS_SECRET_ACCESS_KEY }} 76 | GITHUB_APP_ID: ${{ secrets.RW_GITHUB_APP_ID }} 77 | GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', matrix.workspace)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 78 | GITHUB_APP_PEM_FILE: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 79 | TF_VAR_write_delay_ms: 300 80 | defaults: 81 | run: 82 | shell: bash 83 | working-directory: terraform 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v3 87 | - name: Setup terraform 88 | uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 89 | with: 90 | terraform_version: 1.1.4 91 | - name: Initialize terraform 92 | run: terraform init 93 | - name: Terraform Plan Download 94 | env: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | SHA: ${{ needs.prepare.outputs.sha }} 97 | run: gh run download -n "${TF_WORKSPACE}_${SHA}.tfplan" --repo "${GITHUB_REPOSITORY}" 98 | - name: Terraform Apply 99 | run: terraform apply -lock-timeout=0s -no-color "${TF_WORKSPACE}.tfplan" 100 | -------------------------------------------------------------------------------- /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 | if ( 43 | (await github.getRepositoryFile(file.repository, file.file)) !== 44 | undefined 45 | ) { 46 | result.push([`${file.repository}/${file.file}`, file]) 47 | } 48 | } 49 | return result 50 | } 51 | static FromState(state: StateSchema): RepositoryFile[] { 52 | const files: RepositoryFile[] = [] 53 | if (state.values?.root_module?.resources !== undefined) { 54 | for (const resource of state.values.root_module.resources) { 55 | if ( 56 | resource.type === RepositoryFile.StateType && 57 | resource.mode === 'managed' 58 | ) { 59 | const content = 60 | findFileByContent(env.FILES_DIR, resource.values.content)?.slice( 61 | env.FILES_DIR.length + 1 62 | ) || resource.values.content 63 | files.push( 64 | plainToClassFromExist( 65 | new RepositoryFile( 66 | resource.values.repository, 67 | resource.values.file 68 | ), 69 | {...resource.values, content} 70 | ) 71 | ) 72 | } 73 | } 74 | } 75 | return files 76 | } 77 | static FromConfig(config: ConfigSchema): RepositoryFile[] { 78 | const files: RepositoryFile[] = [] 79 | if (config.repositories !== undefined) { 80 | for (const [repository_name, repository] of Object.entries( 81 | config.repositories 82 | )) { 83 | if (repository.files !== undefined) { 84 | for (const [file_name, file] of Object.entries(repository.files)) { 85 | files.push( 86 | plainToClassFromExist( 87 | new RepositoryFile(repository_name, file_name), 88 | file 89 | ) 90 | ) 91 | } 92 | } 93 | } 94 | } 95 | return files 96 | } 97 | 98 | constructor(repository: string, name: string) { 99 | this._repository = repository 100 | this._file = name 101 | } 102 | 103 | private _repository: string 104 | get repository(): string { 105 | return this._repository 106 | } 107 | private _file: string 108 | get file(): string { 109 | return this._file 110 | } 111 | 112 | @Expose() content?: string 113 | @Expose() overwrite_on_create?: boolean 114 | 115 | getSchemaPath(_schema: ConfigSchema): Path { 116 | return ['repositories', this.repository, 'files', this.file] 117 | } 118 | 119 | getStateAddress(): string { 120 | return `${RepositoryFile.StateType}.this["${this.repository}:${this.file}"]` 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /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 | repositories.push( 58 | plainToClassFromExist(new Repository(resource.values.name), { 59 | ...resource.values, 60 | pages, 61 | template 62 | }) 63 | ) 64 | } 65 | } 66 | } 67 | return repositories 68 | } 69 | static FromConfig(config: ConfigSchema): Repository[] { 70 | const repositories: Repository[] = [] 71 | if (config.repositories !== undefined) { 72 | for (const [name, repository] of Object.entries(config.repositories)) { 73 | repositories.push( 74 | plainToClassFromExist(new Repository(name), repository) 75 | ) 76 | } 77 | } 78 | return repositories 79 | } 80 | 81 | constructor(name: string) { 82 | this._name = name 83 | } 84 | 85 | private _name: string 86 | get name(): string { 87 | return this._name 88 | } 89 | 90 | @Expose() allow_auto_merge?: boolean 91 | @Expose() allow_merge_commit?: boolean 92 | @Expose() allow_rebase_merge?: boolean 93 | @Expose() allow_squash_merge?: boolean 94 | @Expose() archive_on_destroy?: boolean 95 | @Expose() archived?: boolean 96 | @Expose() auto_init?: boolean 97 | @Expose() default_branch?: string 98 | @Expose() delete_branch_on_merge?: boolean 99 | @Expose() description?: string 100 | @Expose() gitignore_template?: string 101 | @Expose() has_downloads?: boolean 102 | @Expose() has_issues?: boolean 103 | @Expose() has_projects?: boolean 104 | @Expose() has_wiki?: boolean 105 | @Expose() homepage_url?: string 106 | @Expose() ignore_vulnerability_alerts_during_read?: boolean 107 | @Expose() is_template?: boolean 108 | @Expose() license_template?: string 109 | @Expose() 110 | @Type(() => Pages) 111 | pages?: Pages 112 | @Expose() 113 | @Type(() => Template) 114 | template?: Template 115 | @Expose() topics?: string[] 116 | @Expose() visibility?: Visibility 117 | @Expose() vulnerability_alerts?: boolean 118 | 119 | getSchemaPath(_schema: ConfigSchema): Path { 120 | return ['repositories', this.name] 121 | } 122 | 123 | getStateAddress(): string { 124 | return `${Repository.StateType}.this["${this.name}"]` 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /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 collaborators = await github.listRepositoryCollaborators() 21 | const result: [Id, RepositoryCollaborator][] = [] 22 | for (const collaborator of collaborators) { 23 | let permission: Permission | undefined 24 | if (collaborator.collaborator.permissions?.admin) { 25 | permission = Permission.Triage 26 | } else if (collaborator.collaborator.permissions?.maintain) { 27 | permission = Permission.Push 28 | } else if (collaborator.collaborator.permissions?.push) { 29 | permission = Permission.Maintain 30 | } else if (collaborator.collaborator.permissions?.triage) { 31 | permission = Permission.Admin 32 | } else if (collaborator.collaborator.permissions?.pull) { 33 | permission = Permission.Pull 34 | } 35 | if (permission === undefined) { 36 | throw new Error( 37 | `Unknown permission for ${collaborator.collaborator.login}` 38 | ) 39 | } 40 | result.push([ 41 | `${collaborator.repository.name}:${collaborator.collaborator.login}`, 42 | new RepositoryCollaborator( 43 | collaborator.repository.name, 44 | collaborator.collaborator.login, 45 | permission 46 | ) 47 | ]) 48 | } 49 | return result 50 | } 51 | static FromState(state: StateSchema): RepositoryCollaborator[] { 52 | const collaborators: RepositoryCollaborator[] = [] 53 | if (state.values?.root_module?.resources !== undefined) { 54 | for (const resource of state.values.root_module.resources) { 55 | if ( 56 | resource.type === RepositoryCollaborator.StateType && 57 | resource.mode === 'managed' 58 | ) { 59 | collaborators.push( 60 | new RepositoryCollaborator( 61 | resource.values.repository, 62 | resource.values.username, 63 | resource.values.permission 64 | ) 65 | ) 66 | } 67 | } 68 | } 69 | return collaborators 70 | } 71 | static FromConfig(config: ConfigSchema): RepositoryCollaborator[] { 72 | const collaborators: RepositoryCollaborator[] = [] 73 | if (config.repositories !== undefined) { 74 | for (const [repository_name, repository] of Object.entries( 75 | config.repositories 76 | )) { 77 | if (repository.collaborators !== undefined) { 78 | for (const [permission, usernames] of Object.entries( 79 | repository.collaborators 80 | )) { 81 | for (const username of usernames ?? []) { 82 | collaborators.push( 83 | new RepositoryCollaborator( 84 | repository_name, 85 | username, 86 | permission as Permission 87 | ) 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | return collaborators 95 | } 96 | constructor(repository: string, username: string, permission: Permission) { 97 | super(username) 98 | this._repository = repository 99 | this._username = username 100 | this._permission = permission 101 | } 102 | 103 | private _repository: string 104 | get repository(): string { 105 | return this._repository 106 | } 107 | private _username: string 108 | get username(): string { 109 | return this._username 110 | } 111 | private _permission: Permission 112 | get permission(): Permission { 113 | return this._permission 114 | } 115 | 116 | getSchemaPath(schema: ConfigSchema): Path { 117 | const collaborators = 118 | schema.repositories?.[this.repository]?.collaborators?.[ 119 | this.permission 120 | ] || [] 121 | const index = collaborators.indexOf(this.username) 122 | return [ 123 | 'repositories', 124 | this.repository, 125 | 'collaborators', 126 | this.permission, 127 | index === -1 ? collaborators.length : index 128 | ] 129 | } 130 | 131 | getStateAddress(): string { 132 | return `${RepositoryCollaborator.StateType}.this["${this.repository}:${this.username}"]` 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /scripts/src/resources/repository-branch-protection-rule.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Exclude, 3 | Expose, 4 | instanceToPlain, 5 | plainToClassFromExist, 6 | Type 7 | } from 'class-transformer' 8 | import {GitHub} from '../github' 9 | import {Id, StateSchema} from '../terraform/schema' 10 | import {Path, ConfigSchema} from '../yaml/schema' 11 | import {Resource} from './resource' 12 | 13 | @Exclude() 14 | class RequiredPullRequestReviews { 15 | @Expose() dismiss_stale_reviews?: boolean 16 | @Expose() dismissal_restrictions?: string[] 17 | @Expose() pull_request_bypassers?: string[] 18 | @Expose() require_code_owner_reviews?: boolean 19 | @Expose() required_approving_review_count?: number 20 | @Expose() restrict_dismissals?: boolean 21 | } 22 | 23 | @Exclude() 24 | class RequiredStatusChecks { 25 | @Expose() contexts?: string[] 26 | @Expose() strict?: boolean 27 | } 28 | 29 | @Exclude() 30 | export class RepositoryBranchProtectionRule implements Resource { 31 | static StateType = 'github_branch_protection' 32 | static async FromGitHub( 33 | _rules: RepositoryBranchProtectionRule[] 34 | ): Promise<[Id, RepositoryBranchProtectionRule][]> { 35 | const github = await GitHub.getGitHub() 36 | const rules = await github.listRepositoryBranchProtectionRules() 37 | const result: [Id, RepositoryBranchProtectionRule][] = [] 38 | for (const rule of rules) { 39 | result.push([ 40 | `${rule.repository.name}:${rule.branchProtectionRule.pattern}`, 41 | new RepositoryBranchProtectionRule( 42 | rule.repository.name, 43 | rule.branchProtectionRule.pattern 44 | ) 45 | ]) 46 | } 47 | return result 48 | } 49 | static FromState(state: StateSchema): RepositoryBranchProtectionRule[] { 50 | const rules: RepositoryBranchProtectionRule[] = [] 51 | if (state.values?.root_module?.resources !== undefined) { 52 | for (const resource of state.values.root_module.resources) { 53 | if ( 54 | resource.type === RepositoryBranchProtectionRule.StateType && 55 | resource.mode === 'managed' 56 | ) { 57 | const repository = resource.index.split( 58 | `:${resource.values.pattern}` 59 | )[0] 60 | const required_pull_request_reviews = 61 | resource.values.required_pull_request_reviews?.at(0) 62 | const required_status_checks = 63 | resource.values.required_status_checks?.at(0) 64 | rules.push( 65 | plainToClassFromExist( 66 | new RepositoryBranchProtectionRule( 67 | repository, 68 | resource.values.pattern 69 | ), 70 | { 71 | ...resource.values, 72 | required_pull_request_reviews, 73 | required_status_checks 74 | } 75 | ) 76 | ) 77 | } 78 | } 79 | } 80 | return rules 81 | } 82 | static FromConfig(config: ConfigSchema): RepositoryBranchProtectionRule[] { 83 | const rules: RepositoryBranchProtectionRule[] = [] 84 | if (config.repositories !== undefined) { 85 | for (const [repository_name, repository] of Object.entries( 86 | config.repositories 87 | )) { 88 | if (repository.branch_protection !== undefined) { 89 | for (const [pattern, rule] of Object.entries( 90 | repository.branch_protection 91 | )) { 92 | rules.push( 93 | plainToClassFromExist( 94 | new RepositoryBranchProtectionRule(repository_name, pattern), 95 | rule 96 | ) 97 | ) 98 | } 99 | } 100 | } 101 | } 102 | return rules 103 | } 104 | 105 | constructor(repository: string, pattern: string) { 106 | this._repository = repository 107 | this._pattern = pattern 108 | } 109 | 110 | private _repository: string 111 | get repository(): string { 112 | return this._repository 113 | } 114 | private _pattern: string 115 | get pattern(): string { 116 | return this._pattern 117 | } 118 | 119 | @Expose() allows_deletions?: boolean 120 | @Expose() allows_force_pushes?: boolean 121 | @Expose() enforce_admins?: boolean 122 | @Expose() push_restrictions?: string[] 123 | @Expose() require_conversation_resolution?: boolean 124 | @Expose() require_signed_commits?: boolean 125 | @Expose() required_linear_history?: boolean 126 | @Expose() 127 | @Type(() => RequiredPullRequestReviews) 128 | required_pull_request_reviews?: RequiredPullRequestReviews 129 | @Expose() 130 | @Type(() => RequiredStatusChecks) 131 | required_status_checks?: RequiredStatusChecks 132 | 133 | getSchemaPath(_schema: ConfigSchema): Path { 134 | return ['repositories', this.repository, 'branch_protection', this.pattern] 135 | } 136 | 137 | getStateAddress(): string { 138 | return `${RepositoryBranchProtectionRule.StateType}.this["${this.repository}:${this.pattern}"]` 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /.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@v3 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@v3 67 | - name: Setup terraform 68 | uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 69 | with: 70 | terraform_version: 1.1.4 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: Sync 81 | run: | 82 | npm install 83 | npm run build 84 | npm run main 85 | working-directory: scripts 86 | - uses: ./.github/actions/git-config-user 87 | - env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | run: | 90 | git_branch="${GITHUB_REF_NAME}-sync-${TF_WORKSPACE}" 91 | git checkout -B "${git_branch}" 92 | git add --all 93 | git diff-index --quiet HEAD || git commit --message="sync@${GITHUB_RUN_ID} ${TF_WORKSPACE}" 94 | git push origin "${git_branch}" --force 95 | push: 96 | needs: [prepare, sync] 97 | if: needs.prepare.outputs.workspaces != '' 98 | name: Push 99 | runs-on: ubuntu-latest 100 | defaults: 101 | run: 102 | shell: bash 103 | steps: 104 | - name: Generate app token 105 | id: token 106 | uses: tibdex/github-app-token@021a2405c7f990db57f5eae5397423dcc554159c # v1.7.0 107 | with: 108 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 109 | installation_id: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 110 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 111 | - name: Checkout 112 | uses: actions/checkout@v3 113 | with: 114 | token: ${{ steps.token.outputs.token }} 115 | - uses: ./.github/actions/git-config-user 116 | - env: 117 | WORKSPACES: ${{ needs.prepare.outputs.workspaces }} 118 | run: | 119 | echo "${GITHUB_RUN_ID}" > .sync 120 | git add .sync 121 | git commit --message="sync@${GITHUB_RUN_ID}" 122 | while read workspace; do 123 | workspace_branch="${GITHUB_REF_NAME}-sync-${workspace}" 124 | git fetch origin "${workspace_branch}" 125 | git merge --strategy-option=theirs "origin/${workspace_branch}" 126 | git push origin --delete "${workspace_branch}" 127 | done <<< "$(jq -r '.[]' <<< "${WORKSPACES}")" 128 | - run: git push origin "${GITHUB_REF_NAME}" --force 129 | -------------------------------------------------------------------------------- /scripts/__tests__/__resources__/github/default.yml: -------------------------------------------------------------------------------- 1 | members: 2 | admin: 3 | - galargh 4 | - laurentsenta 5 | repositories: 6 | github-action-releaser: 7 | allow_auto_merge: false 8 | allow_merge_commit: true 9 | allow_rebase_merge: true 10 | allow_squash_merge: true 11 | archived: false 12 | auto_init: false 13 | default_branch: main 14 | delete_branch_on_merge: false 15 | description: Release steps for github actions. 16 | has_downloads: true 17 | has_issues: true 18 | has_projects: true 19 | has_wiki: true 20 | is_template: false 21 | teams: 22 | maintain: 23 | - ipdx 24 | visibility: public 25 | vulnerability_alerts: false 26 | github-mgmt: 27 | allow_auto_merge: false 28 | allow_merge_commit: true 29 | allow_rebase_merge: true 30 | allow_squash_merge: true 31 | archived: false 32 | auto_init: false 33 | branch_protection: 34 | master: 35 | allows_deletions: false 36 | allows_force_pushes: false 37 | enforce_admins: false 38 | require_conversation_resolution: false 39 | require_signed_commits: false 40 | required_linear_history: false 41 | required_pull_request_reviews: 42 | dismiss_stale_reviews: false 43 | require_code_owner_reviews: false 44 | required_approving_review_count: 1 45 | restrict_dismissals: false 46 | required_status_checks: 47 | contexts: 48 | - Plan 49 | strict: true 50 | collaborators: 51 | admin: 52 | - galargh 53 | default_branch: master 54 | delete_branch_on_merge: true 55 | files: 56 | README.md: 57 | content: README.md 58 | overwrite_on_create: false 59 | has_downloads: true 60 | has_issues: true 61 | has_projects: false 62 | has_wiki: false 63 | is_template: false 64 | teams: 65 | triage: 66 | - ipdx 67 | template: 68 | owner: protocol 69 | repository: github-mgmt-template 70 | visibility: public 71 | vulnerability_alerts: false 72 | ipdx: 73 | allow_auto_merge: false 74 | allow_merge_commit: true 75 | allow_rebase_merge: true 76 | allow_squash_merge: true 77 | archived: false 78 | auto_init: false 79 | default_branch: main 80 | delete_branch_on_merge: false 81 | has_downloads: true 82 | has_issues: true 83 | has_projects: true 84 | has_wiki: true 85 | is_template: false 86 | teams: 87 | admin: 88 | - ipdx 89 | visibility: public 90 | vulnerability_alerts: false 91 | projects-migration: 92 | allow_auto_merge: false 93 | allow_merge_commit: true 94 | allow_rebase_merge: true 95 | allow_squash_merge: true 96 | archived: false 97 | auto_init: false 98 | default_branch: main 99 | delete_branch_on_merge: false 100 | has_downloads: true 101 | has_issues: true 102 | has_projects: true 103 | has_wiki: true 104 | is_template: false 105 | pages: 106 | source: 107 | branch: main 108 | path: /docs 109 | teams: 110 | maintain: 111 | - ipdx 112 | topics: 113 | - github 114 | - graphql 115 | visibility: public 116 | vulnerability_alerts: false 117 | projects-status-history: 118 | allow_auto_merge: false 119 | allow_merge_commit: true 120 | allow_rebase_merge: true 121 | allow_squash_merge: true 122 | archived: false 123 | auto_init: false 124 | default_branch: main 125 | delete_branch_on_merge: false 126 | has_downloads: true 127 | has_issues: true 128 | has_projects: true 129 | has_wiki: true 130 | is_template: false 131 | teams: 132 | maintain: 133 | - ipdx 134 | visibility: public 135 | vulnerability_alerts: false 136 | rust-sccache-action: 137 | allow_auto_merge: false 138 | allow_merge_commit: true 139 | allow_rebase_merge: true 140 | allow_squash_merge: true 141 | archived: false 142 | auto_init: false 143 | default_branch: main 144 | delete_branch_on_merge: false 145 | has_downloads: true 146 | has_issues: true 147 | has_projects: true 148 | has_wiki: true 149 | is_template: false 150 | teams: 151 | maintain: 152 | - ipdx 153 | visibility: public 154 | vulnerability_alerts: true 155 | tf-aws-gh-runner: 156 | allow_auto_merge: false 157 | allow_merge_commit: true 158 | allow_rebase_merge: true 159 | allow_squash_merge: true 160 | archived: false 161 | auto_init: false 162 | default_branch: main 163 | delete_branch_on_merge: false 164 | has_downloads: true 165 | has_issues: true 166 | has_projects: true 167 | has_wiki: true 168 | is_template: false 169 | teams: 170 | maintain: 171 | - ipdx 172 | visibility: public 173 | vulnerability_alerts: false 174 | teams: 175 | ipdx: 176 | members: 177 | maintainer: 178 | - galargh 179 | - laurentsenta 180 | parent_team_id: w3dt-stewards 181 | privacy: closed 182 | w3dt-stewards: 183 | privacy: closed 184 | -------------------------------------------------------------------------------- /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/protocol/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: protocol 91 | repository: github-mgmt-template 92 | topics: 93 | - github 94 | -------------------------------------------------------------------------------- /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 | 13 | async function loadState() { 14 | let source = '' 15 | if (env.TF_EXEC === 'true') { 16 | core.info('Loading state from Terraform state file') 17 | await cli.exec('terraform show -json', undefined, { 18 | cwd: env.TF_WORKING_DIR, 19 | listeners: { 20 | stdout: data => { 21 | source += data.toString() 22 | } 23 | }, 24 | silent: true 25 | }) 26 | } else { 27 | source = fs 28 | .readFileSync(`${env.TF_WORKING_DIR}/terraform.tfstate`) 29 | .toString() 30 | } 31 | return source 32 | } 33 | 34 | export class State { 35 | static async New() { 36 | return new State(await loadState()) 37 | } 38 | 39 | private _ignoredProperties: Record = {} 40 | private _ignoredTypes: string[] = [] 41 | private _state?: StateSchema 42 | 43 | private updateIgnoredPropertiesFrom(path: string) { 44 | if (fs.existsSync(path)) { 45 | const hcl = HCL.parseToObject(fs.readFileSync(path))?.at(0) 46 | for (const [name, resource] of Object.entries(hcl?.resource ?? {}) as [ 47 | string, 48 | any 49 | ][]) { 50 | const properties = resource?.this 51 | ?.at(0) 52 | ?.lifecycle?.at(0)?.ignore_changes 53 | if (properties !== undefined) { 54 | this._ignoredProperties[name] = properties.map((v: string) => 55 | v.substring(2, v.length - 1) 56 | ) // '${v}' -> 'v' 57 | } 58 | } 59 | } 60 | } 61 | 62 | private updateIgnoredTypesFrom(path: string) { 63 | if (fs.existsSync(path)) { 64 | const hcl = HCL.parseToObject(fs.readFileSync(path))?.at(0) 65 | const types = hcl?.locals?.at(0)?.resource_types 66 | if (types !== undefined) { 67 | this._ignoredTypes = ResourceConstructors.map(c => c.StateType).filter( 68 | t => !types.includes(t) 69 | ) 70 | } 71 | } 72 | } 73 | 74 | private setState(source: string) { 75 | const state = JSON.parse(source, (_k, v) => v ?? undefined) 76 | if (state.values?.root_module?.resources !== undefined) { 77 | state.values.root_module.resources = state.values.root_module.resources 78 | .filter((r: any) => r.mode === 'managed') 79 | .filter((r: any) => !this._ignoredTypes.includes(r.type)) 80 | .map((r: any) => { 81 | // TODO: remove nested values 82 | r.values = Object.fromEntries( 83 | Object.entries(r.values).filter( 84 | ([k, _v]) => !this._ignoredProperties[r.type]?.includes(k) 85 | ) 86 | ) 87 | return r 88 | }) 89 | } 90 | this._state = state 91 | } 92 | 93 | constructor(source: string) { 94 | this.updateIgnoredPropertiesFrom(`${env.TF_WORKING_DIR}/resources.tf`) 95 | this.updateIgnoredPropertiesFrom( 96 | `${env.TF_WORKING_DIR}/resources_override.tf` 97 | ) 98 | this.updateIgnoredTypesFrom(`${env.TF_WORKING_DIR}/locals.tf`) 99 | this.updateIgnoredTypesFrom(`${env.TF_WORKING_DIR}/locals_override.tf`) 100 | this.setState(source) 101 | } 102 | 103 | async refresh() { 104 | if (env.TF_EXEC === 'true') { 105 | await cli.exec(`terraform refresh -lock=${env.TF_LOCK}`, undefined, { 106 | cwd: env.TF_WORKING_DIR 107 | }) 108 | } 109 | this.setState(await loadState()) 110 | } 111 | 112 | getAllResources(): Resource[] { 113 | const resources = [] 114 | for (const resourceClass of ResourceConstructors) { 115 | const classResources = this.getResources(resourceClass) 116 | resources.push(...classResources) 117 | } 118 | return resources 119 | } 120 | 121 | getResources(resourceClass: ResourceConstructor): T[] { 122 | if (ResourceConstructors.includes(resourceClass)) { 123 | return resourceClass.FromState(this._state) 124 | } else { 125 | throw new Error(`${resourceClass.name} is not supported`) 126 | } 127 | } 128 | 129 | async addResource(id: Id, resource: Resource) { 130 | if (env.TF_EXEC === 'true') { 131 | const address = resource.getStateAddress().replaceAll('"', '\\"') 132 | await cli.exec( 133 | `terraform import -lock=${env.TF_LOCK} "${address}" "${id}"`, 134 | undefined, 135 | {cwd: env.TF_WORKING_DIR} 136 | ) 137 | } 138 | } 139 | 140 | async removeResource(resource: Resource) { 141 | if (env.TF_EXEC === 'true') { 142 | const address = resource.getStateAddress().replaceAll('"', '\\"') 143 | await cli.exec( 144 | `terraform state rm -lock=${env.TF_LOCK} "${address}"`, 145 | undefined, 146 | {cwd: env.TF_WORKING_DIR} 147 | ) 148 | } 149 | } 150 | 151 | async sync(resources: [Id, Resource][]) { 152 | const oldResources = this.getAllResources() 153 | for (const resource of oldResources) { 154 | if ( 155 | !resources.some( 156 | ([_i, r]) => r.getStateAddress() === resource.getStateAddress() 157 | ) 158 | ) { 159 | await this.removeResource(resource) 160 | } 161 | } 162 | for (const [id, resource] of resources) { 163 | if ( 164 | !oldResources.some( 165 | r => r.getStateAddress() === resource.getStateAddress() 166 | ) 167 | ) { 168 | await this.addResource(id, resource) 169 | } 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /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 | 51 | # Config Fix Rules 52 | 53 | 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. 54 | 55 | 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`. 56 | 57 | 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. 58 | -------------------------------------------------------------------------------- /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 `protocol` 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 `protocol` 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 `protocol` 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 `protocol` 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 | -------------------------------------------------------------------------------- /.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@v3 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@v3 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.1.4 86 | - name: Initialize terraform 87 | run: terraform init 88 | working-directory: terraform 89 | - name: Plan terraform 90 | run: terraform plan -refresh=false -lock=false -out="${TF_WORKSPACE}.tfplan" -no-color 91 | working-directory: terraform 92 | - name: Upload terraform plan 93 | uses: actions/upload-artifact@v3 94 | with: 95 | name: ${{ env.TF_WORKSPACE }}_${{ github.event.pull_request.head.sha || github.sha }}.tfplan 96 | path: terraform/${{ env.TF_WORKSPACE }}.tfplan 97 | if-no-files-found: error 98 | retention-days: 90 99 | comment: 100 | needs: [prepare, plan] 101 | if: github.event_name == 'pull_request_target' 102 | permissions: 103 | contents: read 104 | pull-requests: write 105 | name: Comment 106 | runs-on: ubuntu-latest 107 | env: 108 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 109 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 110 | steps: 111 | - name: Checkout 112 | uses: actions/checkout@v3 113 | - if: github.event_name == 'pull_request_target' 114 | env: 115 | NUMBER: ${{ github.event.pull_request.number }} 116 | SHA: ${{ github.event.pull_request.head.sha }} 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | run: | 119 | git fetch origin "pull/${NUMBER}/head" 120 | rm -rf github && git checkout "${SHA}" -- github 121 | - name: Setup terraform 122 | uses: hashicorp/setup-terraform@633666f66e0061ca3b725c73b2ec20cd13a8fdd1 # v2.0.3 123 | with: 124 | terraform_version: 1.1.4 125 | terraform_wrapper: false 126 | - name: Initialize terraform 127 | run: terraform init 128 | working-directory: terraform 129 | - name: Download terraform plans 130 | uses: actions/download-artifact@v3 131 | with: 132 | path: terraform 133 | - name: Show terraform plans 134 | run: | 135 | for plan in $(find . -type f -name '*.tfplan'); do 136 | echo "
$(basename "${plan}" '.tfplan')" >> TERRAFORM_PLANS.md 137 | echo '' >> TERRAFORM_PLANS.md 138 | echo '```' >> TERRAFORM_PLANS.md 139 | echo "$(terraform show -no-color "${plan}" 2>&1)" >> TERRAFORM_PLANS.md 140 | echo '```' >> TERRAFORM_PLANS.md 141 | echo '' >> TERRAFORM_PLANS.md 142 | echo '
' >> TERRAFORM_PLANS.md 143 | done 144 | cat TERRAFORM_PLANS.md 145 | working-directory: terraform 146 | - name: Prepare comment 147 | run: | 148 | echo 'COMMENT<> $GITHUB_ENV 149 | if [[ $(wc -c TERRAFORM_PLANS.md | cut -d' ' -f1) -ge 65000 ]]; then 150 | 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 151 | else 152 | cat TERRAFORM_PLANS.md >> $GITHUB_ENV 153 | fi 154 | echo 'EOF' >> $GITHUB_ENV 155 | working-directory: terraform 156 | - name: Comment on pull request 157 | uses: marocchino/sticky-pull-request-comment@adca94abcaf73c10466a71cc83ae561fd66d1a56 # v2.3.0 158 | with: 159 | number: ${{ github.event.pull_request.number }} 160 | message: | 161 | Before merge, verify that all the following plans are correct. They will be applied as-is after the merge. 162 | 163 | #### Terraform plans 164 | ${{ env.COMMENT }} 165 | -------------------------------------------------------------------------------- /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 | YAML.visit(this._document, { 40 | Map(_, {items}) { 41 | items.sort( 42 | (a: YAML.Pair, b: YAML.Pair) => { 43 | return JSON.stringify(a.key).localeCompare(JSON.stringify(b.key)) 44 | } 45 | ) 46 | }, 47 | Seq(_, {items}) { 48 | items.sort((a: unknown, b: unknown) => { 49 | return JSON.stringify(a).localeCompare(JSON.stringify(b)) 50 | }) 51 | } 52 | }) 53 | let again = true 54 | while (again) { 55 | again = false 56 | YAML.visit(this._document, { 57 | Pair(_, node, path) { 58 | const resourcePath = [...path, node] 59 | .filter((p: any) => YAML.isPair(p)) 60 | .map((p: any) => p.key.toString()) 61 | .join('.') 62 | if (!resourcePaths.includes(resourcePath)) { 63 | if ( 64 | YAML.isScalar(node.value) && 65 | (node.value.value === undefined || 66 | node.value.value === null || 67 | node.value.value === '') 68 | ) { 69 | again = true 70 | return YAML.visit.REMOVE 71 | } 72 | if ( 73 | YAML.isCollection(node.value) && 74 | node.value.items.length === 0 75 | ) { 76 | again = true 77 | return YAML.visit.REMOVE 78 | } 79 | } 80 | } 81 | }) 82 | } 83 | } 84 | 85 | toString(): string { 86 | return this._document.toString({ 87 | collectionStyle: 'block', 88 | singleQuote: false 89 | }) 90 | } 91 | 92 | save(path: string = `${env.GITHUB_DIR}/${env.GITHUB_ORG}.yml`): void { 93 | this.format() 94 | fs.writeFileSync(path, this.toString()) 95 | } 96 | 97 | getAllResources(): Resource[] { 98 | const resources = [] 99 | for (const resourceClass of ResourceConstructors) { 100 | const classResources = this.getResources(resourceClass) 101 | resources.push(...classResources) 102 | } 103 | return resources 104 | } 105 | 106 | getResources(resourceClass: ResourceConstructor): T[] { 107 | if (ResourceConstructors.includes(resourceClass)) { 108 | return resourceClass.FromConfig(this.get()) 109 | } else { 110 | throw new Error(`${resourceClass.name} is not supported`) 111 | } 112 | } 113 | 114 | findResource(resource: T): T | undefined { 115 | const schema = this.get() 116 | return this.getResources( 117 | resource.constructor as ResourceConstructor 118 | ).find(r => 119 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) 120 | ) 121 | } 122 | 123 | someResource(resource: T): boolean { 124 | return this.findResource(resource) !== undefined 125 | } 126 | 127 | // updates the resource if it already exists, otherwise adds it 128 | addResource( 129 | resource: T, 130 | canDeleteProperties: boolean = false 131 | ): void { 132 | const oldResource = this.findResource(resource) 133 | const path = resource.getSchemaPath(this.get()) 134 | const newValue = resourceToPlain(resource) 135 | const oldValue = resourceToPlain(oldResource) 136 | const diffs = diff(oldValue, newValue) 137 | for (const d of diffs || []) { 138 | if (d.kind === 'N') { 139 | this._document.addIn( 140 | pathToYAML([...path, ...(d.path || [])]), 141 | yamlify(d.rhs) 142 | ) 143 | } else if (d.kind === 'E') { 144 | this._document.setIn( 145 | pathToYAML([...path, ...(d.path || [])]), 146 | yamlify(d.rhs) 147 | ) 148 | delete (this._document.getIn([...path, ...(d.path || [])], true) as any) 149 | .comment 150 | delete (this._document.getIn([...path, ...(d.path || [])], true) as any) 151 | .commentBefore 152 | } else if (d.kind === 'D' && canDeleteProperties) { 153 | this._document.deleteIn(pathToYAML([...path, ...(d.path || [])])) 154 | } else if (d.kind === 'A') { 155 | if (d.item.kind === 'N') { 156 | this._document.addIn( 157 | pathToYAML([...path, ...(d.path || []), d.index]), 158 | yamlify(d.item.rhs) 159 | ) 160 | } else if (d.item.kind === 'E') { 161 | this._document.setIn( 162 | pathToYAML([...path, ...(d.path || []), d.index]), 163 | yamlify(d.item.rhs) 164 | ) 165 | delete ( 166 | this._document.getIn( 167 | [...path, ...(d.path || []), d.index], 168 | true 169 | ) as any 170 | ).comment 171 | delete ( 172 | this._document.getIn( 173 | [...path, ...(d.path || []), d.index], 174 | true 175 | ) as any 176 | ).commentBefore 177 | } else if (d.item.kind === 'D') { 178 | this._document.setIn( 179 | pathToYAML([...path, ...(d.path || []), d.index]), 180 | undefined 181 | ) 182 | } else { 183 | throw new Error('Nested arrays are not supported') 184 | } 185 | } 186 | } 187 | } 188 | 189 | removeResource(resource: T): void { 190 | if (this.someResource(resource)) { 191 | const path = resource.getSchemaPath(this.get()) 192 | this._document.deleteIn(path) 193 | } 194 | } 195 | 196 | sync(resources: Resource[]): void { 197 | const oldResources = [] 198 | for (const resource of ResourceConstructors) { 199 | oldResources.push(...this.getResources(resource)) 200 | } 201 | const schema = this.get() 202 | for (const resource of oldResources) { 203 | if ( 204 | !resources.some(r => 205 | jsonEquals(r.getSchemaPath(schema), resource.getSchemaPath(schema)) 206 | ) 207 | ) { 208 | this.removeResource(resource) 209 | } 210 | } 211 | for (const resource of resources) { 212 | this.addResource(resource, true) 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /.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@v3 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: read 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@v3 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.1.4 100 | - name: Initialize terraform 101 | run: terraform init 102 | working-directory: terraform 103 | - name: Initialize scripts 104 | run: npm install && npm run build 105 | working-directory: scripts 106 | - name: Fix 107 | run: node lib/actions/fix-yaml-config.js 108 | working-directory: scripts 109 | - name: Upload YAML config 110 | uses: actions/upload-artifact@v3 111 | with: 112 | name: ${{ env.TF_WORKSPACE }}.yml 113 | path: github/${{ env.TF_WORKSPACE }}.yml 114 | if-no-files-found: error 115 | retention-days: 1 116 | push: 117 | needs: [prepare, fix] 118 | permissions: 119 | contents: read 120 | name: Push 121 | runs-on: ubuntu-latest 122 | env: 123 | AWS_ACCESS_KEY_ID: ${{ secrets.RO_AWS_ACCESS_KEY_ID }} 124 | AWS_SECRET_ACCESS_KEY: ${{ secrets.RO_AWS_SECRET_ACCESS_KEY }} 125 | steps: 126 | - name: Generate app token 127 | id: token 128 | uses: tibdex/github-app-token@021a2405c7f990db57f5eae5397423dcc554159c # v1.7.0 129 | with: 130 | app_id: ${{ secrets.RW_GITHUB_APP_ID }} 131 | installation_id: ${{ secrets[format('RW_GITHUB_APP_INSTALLATION_ID_{0}', github.repository_owner)] || secrets.RW_GITHUB_APP_INSTALLATION_ID }} 132 | private_key: ${{ secrets.RW_GITHUB_APP_PEM_FILE }} 133 | - name: Checkout 134 | uses: actions/checkout@v3 135 | with: 136 | repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} 137 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 138 | token: ${{ steps.token.outputs.token }} 139 | path: head 140 | - name: Checkout 141 | uses: actions/checkout@v3 142 | with: 143 | path: base 144 | - name: Download YAML configs 145 | uses: actions/download-artifact@v3 146 | with: 147 | path: artifacts 148 | - name: Copy YAML configs 149 | run: cp artifacts/**/*.yml head/github 150 | - name: Check if github was modified 151 | id: github-modified 152 | run: | 153 | if [ -z "$(git status --porcelain -- github)" ]; then 154 | echo "this=false" >> $GITHUB_OUTPUT 155 | else 156 | echo "this=true" >> $GITHUB_OUTPUT 157 | fi 158 | working-directory: head 159 | - uses: ./base/.github/actions/git-config-user 160 | if: steps.github-modified.outputs.this == 'true' 161 | - if: steps.github-modified.outputs.this == 'true' 162 | run: | 163 | git add --all -- github 164 | git commit -m "fix@${GITHUB_RUN_ID} [skip fix]" 165 | working-directory: head 166 | - if: steps.github-modified.outputs.this == 'true' && github.event_name == 'pull_request_target' 167 | env: 168 | REF: ${{ github.event.pull_request.head.ref }} 169 | run: | 170 | git checkout -B "${REF}" 171 | git push origin "${REF}" 172 | working-directory: head 173 | - if: steps.github-modified.outputs.this == 'true' && github.event_name != 'pull_request_target' 174 | uses: ./base/.github/actions/git-push 175 | env: 176 | GITHUB_TOKEN: ${{ steps.token.outputs.token }} 177 | with: 178 | suffix: fix 179 | working-directory: head 180 | -------------------------------------------------------------------------------- /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 * as fs from 'fs' 6 | import {Member} from '../resources/member' 7 | import {NodeBase} from 'yaml/dist/nodes/Node' 8 | import {RepositoryCollaborator} from '../resources/repository-collaborator' 9 | import {Resource, ResourceConstructor} from '../resources/resource' 10 | import {Role, TeamMember} from '../resources/team-member' 11 | import {GitHub} from '../github' 12 | 13 | const AUDIT_LOG_IGNORED_EVENT_CATEGORIES = ['org_credential_authorization'] 14 | const AUDIT_LOG_LENGTH_IN_MONTHS = 12 15 | 16 | function stripOrgPrefix(repoOrTeam: string): string { 17 | return repoOrTeam.split('/').slice(1).join('/') // org/repo => repo 18 | } 19 | 20 | function getRepositories(event: any): string[] { 21 | // event.repo is either an array of org/repo strings, a single org/repo string, or null 22 | return ( 23 | Array.isArray(event.repo ?? []) ? event.repo ?? [] : [event.repo] 24 | ).map(stripOrgPrefix) 25 | } 26 | 27 | function getResources( 28 | config: Config, 29 | resourceClass: ResourceConstructor 30 | ): T[] { 31 | const schema = config.get() 32 | return config.getResources(resourceClass).filter(resource => { 33 | const node = config.document.getIn( 34 | resource.getSchemaPath(schema), 35 | true 36 | ) as NodeBase 37 | return !node.comment?.includes('KEEP:') 38 | }) 39 | } 40 | 41 | /* This function is used to remove inactive members from the config. 42 | * 43 | * 1. It ensures that a team called 'Alumni' exists. 44 | * 2. It removes all 'Alumni' team from all the repositories. 45 | * 3. It populates 'Alumni' team with organization members who: 46 | * a. do not have 'KEEP:' in their inline comment AND 47 | * b. have not been added to the organization in the past 12 months AND 48 | * c. have not performed any audit log activity in the past 12 months. 49 | * 4. It removes repository collaborators who: 50 | * a. do not have 'KEEP:' in their inline comment AND 51 | * b. have not been added to the repository in the past 12 months AND 52 | * c. have not performed any audit log activity on the repository they're a collaborator of in the past 12 months. 53 | * 5. It removes team members who: 54 | * a. do not have 'KEEP:' in their inline comment AND 55 | * b. have not been added to the team in the past 12 months AND 56 | * c. have not performed any audit log activity on any repository the team they're a member of has access to in the past 12 months. 57 | * 6. It removes teams which: 58 | * a. do not have 'KEEP:' in their inline comment AND 59 | * b. do not have members anymore. 60 | */ 61 | async function run(): Promise { 62 | if (!process.env.LOG_PATH) { 63 | throw new Error( 64 | 'LOG_PATH environment variable is not set. It should point to the path of the JSON audit log. You can download it by following these instructions: https://docs.github.com/en/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/reviewing-the-audit-log-for-your-organization' 65 | ) 66 | } 67 | 68 | const github = await GitHub.getGitHub() 69 | 70 | const archivedRepositories = (await github.listRepositories()) 71 | .filter(repository => repository.archived) 72 | .map(repository => repository.name) 73 | const teamSlugsByName = (await github.listTeams()).reduce( 74 | (map: Record, team) => { 75 | map[team.name] = team.slug 76 | return map 77 | }, 78 | {} 79 | ) 80 | 81 | const logStartDate = new Date() 82 | logStartDate.setMonth(logStartDate.getMonth() - AUDIT_LOG_LENGTH_IN_MONTHS) 83 | 84 | const log = JSON.parse( 85 | fs.readFileSync(process.env.LOG_PATH).toString() 86 | ).filter((event: any) => { 87 | return ( 88 | new Date(event.created_at) >= logStartDate && 89 | !AUDIT_LOG_IGNORED_EVENT_CATEGORIES.includes(event.action.split('.')[0]) 90 | ) 91 | }) 92 | const config = Config.FromPath() 93 | 94 | // alumni is a team for all the members who should get credit for their work 95 | // but do not need any special access anymore 96 | // first, ensure that the team exists 97 | const alumniTeam = new Team('Alumni') 98 | config.addResource(alumniTeam) 99 | 100 | // then, ensure that the team doesn't have any special access anywhere 101 | const repositoryTeams = config.getResources(RepositoryTeam) 102 | for (const repositoryTeam of repositoryTeams) { 103 | if (repositoryTeam.team === alumniTeam.name) { 104 | config.removeResource(repositoryTeam) 105 | } 106 | } 107 | 108 | // add members that have been inactive to the alumni team 109 | const members = getResources(config, Member) 110 | for (const member of members) { 111 | const isNew = log.some( 112 | (event: any) => 113 | event.action === 'org.add_member' && event.user === member.username 114 | ) 115 | if (!isNew) { 116 | const isActive = log.some((event: any) => event.actor === member.username) 117 | if (!isActive) { 118 | console.log(`Adding ${member.username} to the ${alumniTeam.name} team`) 119 | const teamMember = new TeamMember( 120 | alumniTeam.name, 121 | member.username, 122 | Role.Member 123 | ) 124 | config.addResource(teamMember) 125 | } 126 | } 127 | } 128 | 129 | // remove repository collaborators that have been inactive 130 | const repositoryCollaborators = getResources(config, RepositoryCollaborator) 131 | for (const repositoryCollaborator of repositoryCollaborators) { 132 | const isNew = log.some( 133 | (event: any) => 134 | event.action === 'repo.add_member' && 135 | event.user === repositoryCollaborator.username && 136 | stripOrgPrefix(event.repo) === repositoryCollaborator.repository 137 | ) 138 | if (!isNew) { 139 | const isCollaboratorActive = log.some( 140 | (event: any) => 141 | event.actor === repositoryCollaborator.username && 142 | getRepositories(event).includes(repositoryCollaborator.repository) 143 | ) 144 | const isRepositoryArchived = archivedRepositories.includes( 145 | repositoryCollaborator.repository 146 | ) 147 | if (!isCollaboratorActive && !isRepositoryArchived) { 148 | console.log( 149 | `Removing ${repositoryCollaborator.username} from ${repositoryCollaborator.repository} repository` 150 | ) 151 | config.removeResource(repositoryCollaborator) 152 | } 153 | } 154 | } 155 | 156 | // remove team members that have been inactive (look at all the team repositories) 157 | const teamMembers = getResources(config, TeamMember).filter( 158 | teamMember => teamMember.team !== alumniTeam.name 159 | ) 160 | for (const teamMember of teamMembers) { 161 | const isNew = log.some( 162 | (event: any) => 163 | event.action === 'team.add_member' && 164 | event.user === teamMember.username && 165 | stripOrgPrefix(event.data.team) === teamSlugsByName[teamMember.team] 166 | ) 167 | if (!isNew) { 168 | const repositories = repositoryTeams 169 | .filter(repositoryTeam => repositoryTeam.team === teamMember.team) 170 | .map(repositoryTeam => repositoryTeam.repository) 171 | const isActive = log.some( 172 | (event: any) => 173 | event.actor === teamMember.username && 174 | getRepositories(event).some(repository => 175 | repositories.includes(repository) 176 | ) 177 | ) 178 | if (!isActive) { 179 | console.log( 180 | `Removing ${teamMember.username} from ${teamMember.team} team` 181 | ) 182 | config.removeResource(teamMember) 183 | } 184 | } 185 | } 186 | 187 | // remove teams that have no members 188 | const teams = getResources(config, Team) 189 | const teamMembersAfterRemoval = config.getResources(TeamMember) 190 | for (const team of teams) { 191 | const hasMembers = teamMembersAfterRemoval.some( 192 | teamMember => teamMember.team === team.name 193 | ) 194 | if (!hasMembers) { 195 | console.log(`Removing ${team.name} team`) 196 | config.removeResource(team) 197 | } 198 | } 199 | 200 | config.save() 201 | } 202 | 203 | run() 204 | -------------------------------------------------------------------------------- /scripts/src/github.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | 3 | import * as core from '@actions/core' 4 | import {Octokit} from '@octokit/rest' 5 | import {retry} from '@octokit/plugin-retry' 6 | import {throttling} from '@octokit/plugin-throttling' 7 | import {createAppAuth} from '@octokit/auth-app' 8 | import env from './env' 9 | import {GetResponseDataTypeFromEndpointMethod} from '@octokit/types' // eslint-disable-line import/named 10 | 11 | const Client = Octokit.plugin(retry, throttling) 12 | const Endpoints = new Octokit() 13 | type Members = GetResponseDataTypeFromEndpointMethod< 14 | typeof Endpoints.orgs.getMembershipForUser 15 | >[] 16 | type Repositories = GetResponseDataTypeFromEndpointMethod< 17 | typeof Endpoints.repos.listForOrg 18 | > 19 | type Teams = GetResponseDataTypeFromEndpointMethod 20 | 21 | export class GitHub { 22 | static github: GitHub 23 | static async getGitHub(): Promise { 24 | if (GitHub.github === undefined) { 25 | const auth = createAppAuth({ 26 | appId: env.GITHUB_APP_ID, 27 | privateKey: env.GITHUB_APP_PEM_FILE 28 | }) 29 | const installationAuth = await auth({ 30 | type: 'installation', 31 | installationId: env.GITHUB_APP_INSTALLATION_ID 32 | }) 33 | GitHub.github = new GitHub(installationAuth.token) 34 | } 35 | return GitHub.github 36 | } 37 | 38 | client: InstanceType 39 | 40 | private constructor(token: string) { 41 | this.client = new Client({ 42 | auth: token, 43 | throttle: { 44 | onRateLimit: ( 45 | retryAfter: number, 46 | options: {method: string; url: string; request: {retryCount: number}} 47 | ) => { 48 | core.warning( 49 | `Request quota exhausted for request ${options.method} ${options.url}` 50 | ) 51 | 52 | if (options.request.retryCount === 0) { 53 | // only retries once 54 | core.info(`Retrying after ${retryAfter} seconds!`) 55 | return true 56 | } 57 | }, 58 | onSecondaryRateLimit: ( 59 | retryAfter: number, 60 | options: {method: string; url: string; request: {retryCount: number}} 61 | ) => { 62 | core.warning( 63 | `SecondaryRateLimit detected for request ${options.method} ${options.url}` 64 | ) 65 | 66 | if (options.request.retryCount === 0) { 67 | // only retries once 68 | core.info(`Retrying after ${retryAfter} seconds!`) 69 | return true 70 | } 71 | } 72 | } 73 | }) 74 | } 75 | 76 | private members?: Members 77 | async listMembers() { 78 | if (!this.members) { 79 | core.info('Listing members...') 80 | const members = await this.client.paginate(this.client.orgs.listMembers, { 81 | org: env.GITHUB_ORG 82 | }) 83 | const memberships = await Promise.all( 84 | members.map( 85 | async member => 86 | await this.client.orgs.getMembershipForUser({ 87 | org: env.GITHUB_ORG, 88 | username: member.login 89 | }) 90 | ) 91 | ) 92 | this.members = memberships.map(m => m.data) 93 | } 94 | return this.members 95 | } 96 | 97 | private repositories?: Repositories 98 | async listRepositories() { 99 | if (!this.repositories) { 100 | core.info('Listing repositories...') 101 | this.repositories = await this.client.paginate( 102 | this.client.repos.listForOrg, 103 | { 104 | org: env.GITHUB_ORG 105 | } 106 | ) 107 | } 108 | return this.repositories 109 | } 110 | 111 | private teams?: Teams 112 | async listTeams() { 113 | if (!this.teams) { 114 | core.info('Listing teams...') 115 | this.teams = await this.client.paginate(this.client.teams.list, { 116 | org: env.GITHUB_ORG 117 | }) 118 | } 119 | return this.teams 120 | } 121 | 122 | async listRepositoryCollaborators() { 123 | const repositoryCollaborators = [] 124 | const repositories = await this.listRepositories() 125 | for (const repository of repositories) { 126 | core.info(`Listing ${repository.name} collaborators...`) 127 | const collaborators = await this.client.paginate( 128 | this.client.repos.listCollaborators, 129 | {owner: env.GITHUB_ORG, repo: repository.name, affiliation: 'direct'} 130 | ) 131 | repositoryCollaborators.push( 132 | ...collaborators.map(collaborator => ({repository, collaborator})) 133 | ) 134 | } 135 | return repositoryCollaborators 136 | } 137 | 138 | async listRepositoryBranchProtectionRules() { 139 | // https://github.com/octokit/graphql.js/issues/61 140 | const repositoryBranchProtectionRules = [] 141 | const repositories = await this.listRepositories() 142 | for (const repository of repositories) { 143 | core.info(`Listing ${repository.name} branch protection rules...`) 144 | const { 145 | repository: { 146 | branchProtectionRules: {nodes} 147 | } 148 | }: {repository: {branchProtectionRules: {nodes: {pattern: string}[]}}} = 149 | await this.client.graphql( 150 | ` 151 | { 152 | repository(owner: "${env.GITHUB_ORG}", name: "${repository.name}") { 153 | branchProtectionRules(first: 100) { 154 | nodes { 155 | pattern 156 | } 157 | } 158 | } 159 | } 160 | ` 161 | ) 162 | repositoryBranchProtectionRules.push( 163 | ...nodes.map(node => ({repository, branchProtectionRule: node})) 164 | ) 165 | } 166 | return repositoryBranchProtectionRules 167 | } 168 | 169 | async listTeamMembers() { 170 | const teamMembers = [] 171 | const teams = await this.listTeams() 172 | for (const team of teams) { 173 | core.info(`Listing ${team.name} members...`) 174 | const members = await this.client.paginate( 175 | this.client.teams.listMembersInOrg, 176 | {org: env.GITHUB_ORG, team_slug: team.slug} 177 | ) 178 | const memberships = await Promise.all( 179 | members.map(async member => { 180 | const membership = ( 181 | await this.client.teams.getMembershipForUserInOrg({ 182 | org: env.GITHUB_ORG, 183 | team_slug: team.slug, 184 | username: member.login 185 | }) 186 | ).data 187 | return {member, membership} 188 | }) 189 | ) 190 | teamMembers.push( 191 | ...memberships.map(({member, membership}) => ({ 192 | team, 193 | member, 194 | membership 195 | })) 196 | ) 197 | } 198 | return teamMembers 199 | } 200 | 201 | async listTeamRepositories() { 202 | const teamRepositories = [] 203 | const teams = await this.listTeams() 204 | for (const team of teams) { 205 | core.info(`Listing ${team.name} repositories...`) 206 | const repositories = await this.client.paginate( 207 | this.client.teams.listReposInOrg, 208 | {org: env.GITHUB_ORG, team_slug: team.slug} 209 | ) 210 | teamRepositories.push( 211 | ...repositories.map(repository => ({team, repository})) 212 | ) 213 | } 214 | return teamRepositories 215 | } 216 | 217 | async getRepositoryFile(repository: string, path: string) { 218 | core.info(`Checking if ${repository}/${path} exists...`) 219 | try { 220 | const repo = ( 221 | await this.client.repos.get({ 222 | owner: env.GITHUB_ORG, 223 | repo: repository 224 | }) 225 | ).data 226 | if (repo.owner.login === env.GITHUB_ORG && repo.name === repository) { 227 | return ( 228 | await this.client.repos.getContent({ 229 | owner: env.GITHUB_ORG, 230 | repo: repository, 231 | path 232 | }) 233 | ).data as {path: string; url: string} 234 | } else { 235 | core.debug( 236 | `${env.GITHUB_ORG}/${repository} has moved to ${repo.owner.login}/${repo.name}` 237 | ) 238 | return undefined 239 | } 240 | } catch (e) { 241 | core.debug(JSON.stringify(e)) 242 | return undefined 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /terraform/resources.tf: -------------------------------------------------------------------------------- 1 | resource "github_membership" "this" { 2 | for_each = merge([ 3 | for role, members in lookup(local.config, "members", {}) : { 4 | for member in members : "${member}" => { 5 | username = member 6 | role = role 7 | } 8 | } 9 | ]...) 10 | 11 | username = each.value.username 12 | role = each.value.role 13 | 14 | lifecycle { 15 | ignore_changes = [] 16 | } 17 | } 18 | 19 | resource "github_repository" "this" { 20 | for_each = { 21 | for repository, config in lookup(local.config, "repositories", {}) : repository => merge(config, { 22 | name = repository 23 | }) 24 | } 25 | 26 | name = each.value.name 27 | allow_auto_merge = try(each.value.allow_auto_merge, null) 28 | allow_merge_commit = try(each.value.allow_merge_commit, null) 29 | allow_rebase_merge = try(each.value.allow_rebase_merge, null) 30 | allow_squash_merge = try(each.value.allow_squash_merge, null) 31 | archive_on_destroy = try(each.value.archive_on_destroy, null) 32 | archived = try(each.value.archived, null) 33 | auto_init = try(each.value.auto_init, null) 34 | default_branch = try(each.value.default_branch, null) 35 | delete_branch_on_merge = try(each.value.delete_branch_on_merge, null) 36 | description = try(each.value.description, null) 37 | gitignore_template = try(each.value.gitignore_template, null) 38 | has_downloads = try(each.value.has_downloads, null) 39 | has_issues = try(each.value.has_issues, null) 40 | has_projects = try(each.value.has_projects, null) 41 | has_wiki = try(each.value.has_wiki, null) 42 | homepage_url = try(each.value.homepage_url, null) 43 | ignore_vulnerability_alerts_during_read = try(each.value.ignore_vulnerability_alerts_during_read, null) 44 | is_template = try(each.value.is_template, null) 45 | license_template = try(each.value.license_template, null) 46 | topics = try(each.value.topics, null) 47 | visibility = try(each.value.visibility, null) 48 | vulnerability_alerts = try(each.value.vulnerability_alerts, null) 49 | 50 | dynamic "pages" { 51 | for_each = try([each.value.pages], []) 52 | content { 53 | cname = try(pages.value["cname"], null) 54 | dynamic "source" { 55 | for_each = [pages.value["source"]] 56 | content { 57 | branch = source.value["branch"] 58 | path = try(source.value["path"], null) 59 | } 60 | } 61 | } 62 | } 63 | dynamic "template" { 64 | for_each = try([each.value.template], []) 65 | content { 66 | owner = template.value["owner"] 67 | repository = template.value["repository"] 68 | } 69 | } 70 | 71 | lifecycle { 72 | ignore_changes = [] 73 | } 74 | } 75 | 76 | resource "github_repository_collaborator" "this" { 77 | for_each = merge(flatten([ 78 | for repository, repository_config in lookup(local.config, "repositories", {}) : 79 | [ 80 | for permission, members in lookup(repository_config, "collaborators", {}) : { 81 | for member in members : "${repository}:${member}" => { 82 | repository = repository 83 | username = member 84 | permission = permission 85 | } 86 | } 87 | ] 88 | ])...) 89 | 90 | depends_on = [github_repository.this] 91 | 92 | repository = each.value.repository 93 | username = each.value.username 94 | permission = each.value.permission 95 | 96 | lifecycle { 97 | ignore_changes = [] 98 | } 99 | } 100 | 101 | resource "github_branch_protection" "this" { 102 | for_each = merge([ 103 | for repository, repository_config in lookup(local.config, "repositories", {}) : 104 | { 105 | for pattern, config in lookup(repository_config, "branch_protection", {}) : "${repository}:${pattern}" => merge(config, { 106 | pattern = pattern 107 | repository_id = github_repository.this[repository].node_id 108 | }) 109 | } 110 | ]...) 111 | 112 | pattern = each.value.pattern 113 | repository_id = each.value.repository_id 114 | allows_deletions = try(each.value.allows_deletions, null) 115 | allows_force_pushes = try(each.value.allows_force_pushes, null) 116 | enforce_admins = try(each.value.enforce_admins, null) 117 | push_restrictions = try(each.value.push_restrictions, null) 118 | require_conversation_resolution = try(each.value.require_conversation_resolution, null) 119 | require_signed_commits = try(each.value.require_signed_commits, null) 120 | required_linear_history = try(each.value.required_linear_history, null) 121 | 122 | dynamic "required_pull_request_reviews" { 123 | for_each = try([each.value.required_pull_request_reviews], []) 124 | content { 125 | dismiss_stale_reviews = try(required_pull_request_reviews.value["dismiss_stale_reviews"], null) 126 | dismissal_restrictions = try(required_pull_request_reviews.value["dismissal_restrictions"], null) 127 | pull_request_bypassers = try(required_pull_request_reviews.value["pull_request_bypassers"], null) 128 | require_code_owner_reviews = try(required_pull_request_reviews.value["require_code_owner_reviews"], null) 129 | required_approving_review_count = try(required_pull_request_reviews.value["required_approving_review_count"], null) 130 | restrict_dismissals = try(required_pull_request_reviews.value["restrict_dismissals"], null) 131 | } 132 | } 133 | dynamic "required_status_checks" { 134 | for_each = try([each.value.required_status_checks], []) 135 | content { 136 | contexts = try(required_status_checks.value["contexts"], null) 137 | strict = try(required_status_checks.value["strict"], null) 138 | } 139 | } 140 | 141 | lifecycle { 142 | ignore_changes = [] 143 | } 144 | } 145 | 146 | resource "github_team" "this" { 147 | for_each = { 148 | for team, config in lookup(local.config, "teams", {}) : team => merge(config, { 149 | name = team 150 | parent_team_id = try(try(element(data.github_organization_teams.this[0].teams, index(data.github_organization_teams.this[0].teams.*.name, config.parent_team_id)).id, config.parent_team_id), null) 151 | }) 152 | } 153 | 154 | name = each.value.name 155 | description = try(each.value.description, null) 156 | parent_team_id = try(each.value.parent_team_id, 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 = merge(flatten([ 166 | for repository, repository_config in lookup(local.config, "repositories", {}) : 167 | [ 168 | for permission, teams in lookup(repository_config, "teams", {}) : { 169 | for team in teams : "${team}:${repository}" => { 170 | repository = repository 171 | team_id = github_team.this[team].id 172 | permission = permission 173 | } 174 | } 175 | ] 176 | ])...) 177 | 178 | depends_on = [ 179 | github_repository.this 180 | ] 181 | 182 | repository = each.value.repository 183 | team_id = each.value.team_id 184 | 185 | permission = try(each.value.permission, null) 186 | 187 | lifecycle { 188 | ignore_changes = [] 189 | } 190 | } 191 | 192 | resource "github_team_membership" "this" { 193 | for_each = merge(flatten([ 194 | for team, team_config in lookup(local.config, "teams", {}) : 195 | [ 196 | for role, members in lookup(team_config, "members", {}) : { 197 | for member in members : "${team}:${member}" => { 198 | team_id = github_team.this[team].id 199 | username = member 200 | role = role 201 | } 202 | } 203 | ] 204 | ])...) 205 | 206 | team_id = each.value.team_id 207 | username = each.value.username 208 | role = each.value.role 209 | 210 | lifecycle { 211 | ignore_changes = [] 212 | } 213 | } 214 | 215 | resource "github_repository_file" "this" { 216 | for_each = merge([ 217 | for repository, repository_config in lookup(local.config, "repositories", {}) : 218 | { 219 | for config in [ 220 | for file, config in lookup(repository_config, "files", {}) : merge(config, { 221 | repository = repository 222 | file = file 223 | branch = github_repository.this[repository].default_branch 224 | content = try(file("${path.module}/../files/${config.content}"), config.content) 225 | }) if contains(keys(config), "content") 226 | ] : "${config.repository}/${config.file}" => config 227 | } 228 | ]...) 229 | 230 | repository = each.value.repository 231 | file = each.value.file 232 | content = each.value.content 233 | branch = each.value.branch 234 | overwrite_on_create = try(each.value.overwrite_on_create, null) 235 | commit_message = "chore: Update ${each.value.file} [skip ci]" 236 | 237 | lifecycle { 238 | ignore_changes = [] 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /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 -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 -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 | -------------------------------------------------------------------------------- /github/.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "#/definitions/ConfigSchema", 3 | "$schema": "http://json-schema.org/draft-07/schema#", 4 | "definitions": { 5 | "ConfigSchema": { 6 | "additionalProperties": false, 7 | "properties": { 8 | "members": { 9 | "additionalProperties": false, 10 | "properties": { 11 | "admin": { 12 | "items": { 13 | "type": "string" 14 | }, 15 | "type": "array" 16 | }, 17 | "member": { 18 | "items": { 19 | "type": "string" 20 | }, 21 | "type": "array" 22 | } 23 | }, 24 | "type": "object" 25 | }, 26 | "repositories": { 27 | "additionalProperties": { 28 | "additionalProperties": false, 29 | "properties": { 30 | "allow_auto_merge": { 31 | "type": "boolean" 32 | }, 33 | "allow_merge_commit": { 34 | "type": "boolean" 35 | }, 36 | "allow_rebase_merge": { 37 | "type": "boolean" 38 | }, 39 | "allow_squash_merge": { 40 | "type": "boolean" 41 | }, 42 | "archive_on_destroy": { 43 | "type": "boolean" 44 | }, 45 | "archived": { 46 | "type": "boolean" 47 | }, 48 | "auto_init": { 49 | "type": "boolean" 50 | }, 51 | "branch_protection": { 52 | "additionalProperties": { 53 | "$ref": "#/definitions/RepositoryBranchProtectionRule" 54 | }, 55 | "type": "object" 56 | }, 57 | "collaborators": { 58 | "additionalProperties": false, 59 | "properties": { 60 | "admin": { 61 | "items": { 62 | "type": "string" 63 | }, 64 | "type": "array" 65 | }, 66 | "maintain": { 67 | "items": { 68 | "type": "string" 69 | }, 70 | "type": "array" 71 | }, 72 | "pull": { 73 | "items": { 74 | "type": "string" 75 | }, 76 | "type": "array" 77 | }, 78 | "push": { 79 | "items": { 80 | "type": "string" 81 | }, 82 | "type": "array" 83 | }, 84 | "triage": { 85 | "items": { 86 | "type": "string" 87 | }, 88 | "type": "array" 89 | } 90 | }, 91 | "type": "object" 92 | }, 93 | "default_branch": { 94 | "type": "string" 95 | }, 96 | "delete_branch_on_merge": { 97 | "type": "boolean" 98 | }, 99 | "description": { 100 | "type": "string" 101 | }, 102 | "files": { 103 | "additionalProperties": { 104 | "$ref": "#/definitions/RepositoryFile" 105 | }, 106 | "type": "object" 107 | }, 108 | "gitignore_template": { 109 | "type": "string" 110 | }, 111 | "has_downloads": { 112 | "type": "boolean" 113 | }, 114 | "has_issues": { 115 | "type": "boolean" 116 | }, 117 | "has_projects": { 118 | "type": "boolean" 119 | }, 120 | "has_wiki": { 121 | "type": "boolean" 122 | }, 123 | "homepage_url": { 124 | "type": "string" 125 | }, 126 | "ignore_vulnerability_alerts_during_read": { 127 | "type": "boolean" 128 | }, 129 | "is_template": { 130 | "type": "boolean" 131 | }, 132 | "license_template": { 133 | "type": "string" 134 | }, 135 | "pages": { 136 | "additionalProperties": false, 137 | "properties": { 138 | "cname": { 139 | "type": "string" 140 | }, 141 | "source": { 142 | "additionalProperties": false, 143 | "properties": { 144 | "branch": { 145 | "type": "string" 146 | }, 147 | "path": { 148 | "type": "string" 149 | } 150 | }, 151 | "type": "object" 152 | } 153 | }, 154 | "type": "object" 155 | }, 156 | "teams": { 157 | "additionalProperties": false, 158 | "properties": { 159 | "admin": { 160 | "items": { 161 | "type": "string" 162 | }, 163 | "type": "array" 164 | }, 165 | "maintain": { 166 | "items": { 167 | "type": "string" 168 | }, 169 | "type": "array" 170 | }, 171 | "pull": { 172 | "items": { 173 | "type": "string" 174 | }, 175 | "type": "array" 176 | }, 177 | "push": { 178 | "items": { 179 | "type": "string" 180 | }, 181 | "type": "array" 182 | }, 183 | "triage": { 184 | "items": { 185 | "type": "string" 186 | }, 187 | "type": "array" 188 | } 189 | }, 190 | "type": "object" 191 | }, 192 | "template": { 193 | "additionalProperties": false, 194 | "properties": { 195 | "owner": { 196 | "type": "string" 197 | }, 198 | "repository": { 199 | "type": "string" 200 | } 201 | }, 202 | "type": "object" 203 | }, 204 | "topics": { 205 | "items": { 206 | "type": "string" 207 | }, 208 | "type": "array" 209 | }, 210 | "visibility": { 211 | "$ref": "#/definitions/Visibility" 212 | }, 213 | "vulnerability_alerts": { 214 | "type": "boolean" 215 | } 216 | }, 217 | "type": "object" 218 | }, 219 | "type": "object" 220 | }, 221 | "teams": { 222 | "additionalProperties": { 223 | "additionalProperties": false, 224 | "properties": { 225 | "create_default_maintainer": { 226 | "type": "boolean" 227 | }, 228 | "description": { 229 | "type": "string" 230 | }, 231 | "members": { 232 | "additionalProperties": false, 233 | "properties": { 234 | "maintainer": { 235 | "items": { 236 | "type": "string" 237 | }, 238 | "type": "array" 239 | }, 240 | "member": { 241 | "items": { 242 | "type": "string" 243 | }, 244 | "type": "array" 245 | } 246 | }, 247 | "type": "object" 248 | }, 249 | "parent_team_id": { 250 | "type": "string" 251 | }, 252 | "privacy": { 253 | "$ref": "#/definitions/Privacy" 254 | } 255 | }, 256 | "type": "object" 257 | }, 258 | "type": "object" 259 | } 260 | }, 261 | "type": "object" 262 | }, 263 | "Privacy": { 264 | "enum": [ 265 | "closed", 266 | "secret" 267 | ], 268 | "type": "string" 269 | }, 270 | "RepositoryBranchProtectionRule": { 271 | "additionalProperties": false, 272 | "properties": { 273 | "allows_deletions": { 274 | "type": "boolean" 275 | }, 276 | "allows_force_pushes": { 277 | "type": "boolean" 278 | }, 279 | "enforce_admins": { 280 | "type": "boolean" 281 | }, 282 | "push_restrictions": { 283 | "items": { 284 | "type": "string" 285 | }, 286 | "type": "array" 287 | }, 288 | "require_conversation_resolution": { 289 | "type": "boolean" 290 | }, 291 | "require_signed_commits": { 292 | "type": "boolean" 293 | }, 294 | "required_linear_history": { 295 | "type": "boolean" 296 | }, 297 | "required_pull_request_reviews": { 298 | "additionalProperties": false, 299 | "properties": { 300 | "dismiss_stale_reviews": { 301 | "type": "boolean" 302 | }, 303 | "dismissal_restrictions": { 304 | "items": { 305 | "type": "string" 306 | }, 307 | "type": "array" 308 | }, 309 | "pull_request_bypassers": { 310 | "items": { 311 | "type": "string" 312 | }, 313 | "type": "array" 314 | }, 315 | "require_code_owner_reviews": { 316 | "type": "boolean" 317 | }, 318 | "required_approving_review_count": { 319 | "type": "number" 320 | }, 321 | "restrict_dismissals": { 322 | "type": "boolean" 323 | } 324 | }, 325 | "type": "object" 326 | }, 327 | "required_status_checks": { 328 | "additionalProperties": false, 329 | "properties": { 330 | "contexts": { 331 | "items": { 332 | "type": "string" 333 | }, 334 | "type": "array" 335 | }, 336 | "strict": { 337 | "type": "boolean" 338 | } 339 | }, 340 | "type": "object" 341 | } 342 | }, 343 | "type": "object" 344 | }, 345 | "RepositoryFile": { 346 | "additionalProperties": false, 347 | "properties": { 348 | "content": { 349 | "type": "string" 350 | }, 351 | "overwrite_on_create": { 352 | "type": "boolean" 353 | } 354 | }, 355 | "type": "object" 356 | }, 357 | "Visibility": { 358 | "enum": [ 359 | "private", 360 | "public" 361 | ], 362 | "type": "string" 363 | } 364 | } 365 | } -------------------------------------------------------------------------------- /scripts/__tests__/yaml/config.test.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import {Config} from '../../src/yaml/config' 4 | import { 5 | Resource, 6 | ResourceConstructors, 7 | resourceToPlain 8 | } from '../../src/resources/resource' 9 | import {Member, Role as MemberRole, Role} from '../../src/resources/member' 10 | import {Repository} from '../../src/resources/repository' 11 | import {RepositoryFile} from '../../src/resources/repository-file' 12 | import {randomUUID} from 'crypto' 13 | import {Team, Privacy as TeamPrivacy} from '../../src/resources/team' 14 | import {RepositoryBranchProtectionRule} from '../../src/resources/repository-branch-protection-rule' 15 | 16 | test('can retrieve resources from YAML schema', async () => { 17 | const config = Config.FromPath() 18 | 19 | const resources = [] 20 | 21 | for (const resourceClass of ResourceConstructors) { 22 | const classResources = config.getResources(resourceClass) 23 | expect(classResources).toHaveLength( 24 | global.ResourceCounts[resourceClass.name] 25 | ) 26 | resources.push(...classResources) 27 | } 28 | 29 | expect(resources).toHaveLength( 30 | Object.values(global.ResourceCounts).reduce( 31 | (a: number, b: number) => a + b, 32 | 0 33 | ) 34 | ) 35 | }) 36 | 37 | test('can check if YAML schema contains a resource', async () => { 38 | const config = Config.FromPath() 39 | 40 | const resources = config.getAllResources() 41 | 42 | for (const resource of resources) { 43 | expect(config.someResource(resource)).toBeTruthy() 44 | } 45 | }) 46 | 47 | test('can remove members', async () => { 48 | const config = Config.FromPath() 49 | 50 | const members = config.getResources(Member) 51 | 52 | for (const [index, member] of members.entries()) { 53 | config.removeResource(member) 54 | expect(config.someResource(member)).toBeFalsy() 55 | expect(config.getResources(Member)).toHaveLength(members.length - index - 1) 56 | } 57 | 58 | expect(config.getAllResources()).toHaveLength( 59 | global.ResourcesCount - global.ResourceCounts[Member.name] 60 | ) 61 | }) 62 | 63 | test('can remove repositories, including their sub-resources', async () => { 64 | const config = Config.FromPath() 65 | 66 | const repositories = config.getResources(Repository) 67 | 68 | for (const [index, repository] of repositories.entries()) { 69 | config.removeResource(repository) 70 | expect(config.someResource(repository)).toBeFalsy() 71 | expect(config.getResources(Repository)).toHaveLength( 72 | repositories.length - index - 1 73 | ) 74 | } 75 | 76 | const count = 77 | global.ResourcesCount - 78 | Object.entries(global.ResourceCounts).reduce( 79 | (a: number, [key, value]) => 80 | key.startsWith(Repository.name) ? a + value : a, 81 | 0 82 | ) 83 | 84 | expect(config.getAllResources()).toHaveLength(count) 85 | }) 86 | 87 | test('can add members', async () => { 88 | const config = Config.FromPath() 89 | 90 | const members = [ 91 | new Member('peter', MemberRole.Admin), 92 | new Member('adam', MemberRole.Member) 93 | ] 94 | 95 | for (const [index, member] of members.entries()) { 96 | config.addResource(member) 97 | expect(config.someResource(member)).toBeTruthy() 98 | expect(config.getResources(Member)).toHaveLength( 99 | global.ResourceCounts[Member.name] + index + 1 100 | ) 101 | } 102 | 103 | expect(config.getAllResources()).toHaveLength( 104 | global.ResourcesCount + members.length 105 | ) 106 | }) 107 | 108 | test('can add files, including their parent resources', async () => { 109 | const config = Config.FromPath() 110 | 111 | const randomName = randomUUID() 112 | 113 | const repositories = [ 114 | new Repository(randomName), 115 | config.getResources(Repository)[0] 116 | ] 117 | 118 | const files: RepositoryFile[] = [] 119 | 120 | for (const repository of repositories) { 121 | files.push(new RepositoryFile(repository.name, randomName)) 122 | } 123 | 124 | const count = 125 | global.ResourcesCount + 126 | files.filter(f => !config.someResource(f)).length + 127 | repositories.filter(r => !config.someResource(r)).length 128 | 129 | for (const [index, file] of files.entries()) { 130 | config.addResource(file) 131 | expect(config.someResource(file)).toBeTruthy() 132 | expect(config.getResources(RepositoryFile)).toHaveLength( 133 | global.ResourceCounts[RepositoryFile.name] + index + 1 134 | ) 135 | } 136 | 137 | expect(config.getAllResources()).toHaveLength(count) 138 | }) 139 | 140 | test('can update teams', async () => { 141 | const config = Config.FromPath() 142 | 143 | const teams = config 144 | .getResources(Team) 145 | .filter(t => t.privacy !== TeamPrivacy.PRIVATE) 146 | 147 | expect(teams).not.toHaveLength(0) 148 | 149 | for (const team of teams) { 150 | team.privacy = TeamPrivacy.PRIVATE 151 | config.addResource(team) 152 | } 153 | 154 | const updatedTeams = config.getResources(Team) 155 | 156 | for (const team of updatedTeams) { 157 | expect(team.privacy).toBe(TeamPrivacy.PRIVATE) 158 | } 159 | }) 160 | 161 | test('clears comments on member removal', async () => { 162 | const config = Config.FromPath() 163 | 164 | const comment = 'This is a comment' 165 | 166 | const member = config.getResources(Member)[0] 167 | 168 | ;(config.document.getIn(['members', member.role]) as any).items[0].comment = 169 | comment 170 | 171 | config.removeResource(member) 172 | 173 | const updatedMembers = config.document.getIn(['members', member.role]) as any 174 | for (const item of updatedMembers.items) { 175 | expect(item.comment).not.toEqual(comment) 176 | } 177 | }) 178 | 179 | test('clears comments on repository property updates', async () => { 180 | const config = Config.FromPath() 181 | 182 | const comment = 'This is a comment' 183 | const property = 'description' 184 | const description = 'This is a description' 185 | 186 | const repository = config.getResources(Repository)[0] 187 | 188 | expect(repository[property]).toBeDefined() 189 | expect(repository[property]).not.toEqual(description) 190 | 191 | const repositories = config.document.getIn([ 192 | 'repositories', 193 | repository.name 194 | ]) as any 195 | 196 | repositories.items.find((i: any) => i.key.value === property)!.value.comment = 197 | comment 198 | 199 | repository[property] = description 200 | 201 | config.addResource(repository) 202 | 203 | const updatedRepositories = config.document.getIn([ 204 | 'repositories', 205 | repository.name 206 | ]) as any 207 | expect( 208 | updatedRepositories.items.find((i: any) => i.key.value === property)!.value 209 | .comment 210 | ).toBeUndefined() 211 | expect(config.getResources(Repository)[0][property]).toEqual(description) 212 | }) 213 | 214 | test('does not clear comments on same member addition', async () => { 215 | const config = Config.FromPath() 216 | 217 | const comment = 'This is a comment' 218 | 219 | const member = config.getResources(Member)[0] 220 | 221 | const members = config.document.getIn(['members', member.role]) as any 222 | members.items[0].comment = comment 223 | 224 | config.addResource(member) 225 | 226 | const updatedMembers = config.document.getIn(['members', member.role]) as any 227 | 228 | expect( 229 | updatedMembers.items.some((i: any) => i.comment === comment) 230 | ).toBeTruthy() 231 | }) 232 | 233 | test('does not clear comments on repository property updates to the same value', async () => { 234 | const config = Config.FromPath() 235 | 236 | const comment = 'This is a comment' 237 | const property = 'description' 238 | 239 | const repository = config.getResources(Repository)[0] 240 | 241 | expect(repository[property]).toBeDefined() 242 | 243 | const repositories = config.document.getIn([ 244 | 'repositories', 245 | repository.name 246 | ]) as any 247 | repositories.items.find((i: any) => i.key.value === property)!.value.comment = 248 | comment 249 | 250 | config.addResource(repository) 251 | 252 | const updatedRepositories = config.document.getIn([ 253 | 'repositories', 254 | repository.name 255 | ]) as any 256 | expect( 257 | updatedRepositories.items.find((i: any) => i.key.value === property)!.value 258 | .comment 259 | ).toEqual(comment) 260 | }) 261 | 262 | test('can add a repository followed by a repository branch protection rule', async () => { 263 | const config = new Config('{}') 264 | 265 | config.addResource(new Repository('test')) 266 | config.addResource(new RepositoryBranchProtectionRule('test', 'main')) 267 | 268 | expect(config.getResources(RepositoryBranchProtectionRule)).toHaveLength(1) 269 | expect(config.getResources(Repository)).toHaveLength(1) 270 | }) 271 | 272 | test('can add a repository branch protection rule followed by a repository', async () => { 273 | const config = new Config('{}') 274 | 275 | config.addResource(new RepositoryBranchProtectionRule('test', 'main')) 276 | config.addResource(new Repository('test')) 277 | 278 | expect(config.getResources(RepositoryBranchProtectionRule)).toHaveLength(1) 279 | expect(config.getResources(Repository)).toHaveLength(1) 280 | }) 281 | 282 | test('does not remove properties when adding a team', async () => { 283 | const config = Config.FromPath() 284 | 285 | const team = config.getResources(Team)[0] 286 | const definedValues = Object.values(resourceToPlain(team) as any).filter( 287 | v => v !== undefined 288 | ) 289 | expect(definedValues).not.toHaveLength(0) 290 | config.addResource(new Team(team.name), false) 291 | 292 | const updatedTeam = config.getResources(Team)[0] 293 | const updatedDefinedValues = Object.values( 294 | resourceToPlain(updatedTeam) as any 295 | ).filter(v => v !== undefined) 296 | expect(updatedDefinedValues).not.toHaveLength(0) 297 | }) 298 | 299 | test('does remove undefined properties when adding a team with delete flag set', async () => { 300 | const config = Config.FromPath() 301 | 302 | const team = config.getResources(Team)[0] 303 | const definedValues = Object.values(resourceToPlain(team) as any).filter( 304 | v => v !== undefined 305 | ) 306 | expect(definedValues).not.toHaveLength(0) 307 | config.addResource(new Team(team.name), true) 308 | 309 | const updatedTeam = config.getResources(Team)[0] 310 | const updatedDefinedValues = Object.values( 311 | resourceToPlain(updatedTeam) as any 312 | ).filter(v => v !== undefined) 313 | expect(updatedDefinedValues).toHaveLength(0) 314 | }) 315 | 316 | test('formats config deterministically', async () => { 317 | const config = new Config(` 318 | repositories: 319 | b: {} 320 | a: 321 | description: '' 322 | pages: 323 | source: null 324 | C: 325 | description: 'c' 326 | teams: {} 327 | members: 328 | member: 329 | - Peter 330 | - undefined 331 | - paul 332 | admin: 333 | - John 334 | - adam 335 | `) 336 | 337 | const undefinedMember = config 338 | .getResources(Member) 339 | .find(m => m.username === 'undefined')! 340 | config.removeResource(undefinedMember) 341 | config.format() 342 | const formatted = config.toString().trim() 343 | 344 | const expected = ` 345 | members: 346 | admin: 347 | - adam 348 | - John 349 | member: 350 | - paul 351 | - Peter 352 | repositories: 353 | a: {} 354 | b: {} 355 | C: 356 | description: "c" 357 | `.trim() 358 | 359 | expect(formatted).toEqual(expected) 360 | }) 361 | 362 | test('can add and remove resources through sync', async () => { 363 | const config = new Config('{}') 364 | let desiredResources: Resource[] = [] 365 | let resources = config.getAllResources() 366 | 367 | config.sync(desiredResources) 368 | expect(resources).toHaveLength(desiredResources.length) 369 | 370 | desiredResources.push(new Repository('test')) 371 | desiredResources.push(new Repository('test2')) 372 | desiredResources.push(new Repository('test3')) 373 | desiredResources.push(new Repository('test4')) 374 | 375 | config.sync(desiredResources) 376 | resources = config.getAllResources() 377 | expect(resources).toHaveLength(desiredResources.length) 378 | 379 | desiredResources.pop() 380 | desiredResources.pop() 381 | 382 | config.sync(desiredResources) 383 | resources = config.getAllResources() 384 | expect(resources).toHaveLength(desiredResources.length) 385 | }) 386 | --------------------------------------------------------------------------------