Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/update-members.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Update Members

on:
workflow_dispatch:
inputs:
organization:
description: Organization config to update
required: true
cutoff-date:
description: Inactive before this date (YYYY-MM-DD)
required: false
limit:
description: Remove up to this many longest-inactive members
required: false
ignore:
description: Comma, space, or newline separated usernames to ignore
required: false
only:
description: Comma, space, or newline separated usernames to target
required: false
public-repo-access:
description: Whether to retain effective access to public repositories
required: true
default: retain
type: choice
options:
- retain
- remove
organization-membership:
description: Whether selected users remain organization members
required: true
default: keep
type: choice
options:
- keep
- remove

defaults:
run:
shell: bash

jobs:
update:
permissions:
contents: write
pull-requests: write
name: Update members
runs-on: ubuntu-latest
environment: push
env:
GITHUB_APP_ID: ${{ secrets.RO_GITHUB_APP_ID }}
GITHUB_APP_INSTALLATION_ID: ${{ secrets[format('RO_GITHUB_APP_INSTALLATION_ID_{0}', github.event.inputs.organization)] || secrets.RO_GITHUB_APP_INSTALLATION_ID }}
GITHUB_APP_PEM_FILE: ${{ secrets.RO_GITHUB_APP_PEM_FILE }}
TF_WORKSPACE: ${{ github.event.inputs.organization }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pnpm
uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788 # v6.0.6
with:
version: 10
- name: Use Node.js lts/*
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: lts/*
cache: ""
- name: Initialize scripts
run: pnpm install --frozen-lockfile && pnpm run build
working-directory: scripts
- name: Update members
id: update
run: node lib/actions/update-members.js
working-directory: scripts
env:
CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }}
LIMIT: ${{ github.event.inputs.limit }}
IGNORE: ${{ github.event.inputs.ignore }}
ONLY: ${{ github.event.inputs.only }}
PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }}
ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }}
- name: Check if organization config was modified
id: config-modified
env:
ORGANIZATION: ${{ github.event.inputs.organization }}
run: |
if [ -z "$(git status --porcelain -- "github/${ORGANIZATION}.yml")" ]; then
echo "this=false" >> $GITHUB_OUTPUT
else
echo "this=true" >> $GITHUB_OUTPUT
fi
- uses: ./.github/actions/git-config-user
if: steps.config-modified.outputs.this == 'true'
- name: Create draft pull request
if: steps.config-modified.outputs.this == 'true'
env:
ORGANIZATION: ${{ github.event.inputs.organization }}
CUTOFF_DATE: ${{ github.event.inputs['cutoff-date'] }}
LIMIT: ${{ github.event.inputs.limit }}
IGNORE: ${{ github.event.inputs.ignore }}
ONLY: ${{ github.event.inputs.only }}
PUBLIC_REPO_ACCESS: ${{ github.event.inputs['public-repo-access'] }}
ORGANIZATION_MEMBERSHIP: ${{ github.event.inputs['organization-membership'] }}
AFFECTED_USERS: ${{ steps.update.outputs.affected-users }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
branch="update-members-${ORGANIZATION}-${GITHUB_RUN_ID}"
body="$(mktemp)"
{
echo 'The changes in this PR were made by a bot. Please review carefully.'
echo
echo "Organization: ${ORGANIZATION}"
echo "Cutoff date: ${CUTOFF_DATE:-not set}"
echo "Limit: ${LIMIT:-not set}"
echo "Ignore: ${IGNORE:-not set}"
echo "Only: ${ONLY:-not set}"
echo "Public repo access: ${PUBLIC_REPO_ACCESS}"
echo "Organization membership: ${ORGANIZATION_MEMBERSHIP}"
echo "Affected users: ${AFFECTED_USERS:-none}"
} > "${body}"

git checkout -B "${branch}"
git add "github/${ORGANIZATION}.yml"
git commit -m "update-members@${GITHUB_RUN_ID} ${ORGANIZATION}"
git push origin "${branch}" --force
gh pr create \
--draft \
--title "Update members for ${ORGANIZATION}" \
--body-file "${body}" \
--head "${branch}" \
--base "${GITHUB_REF_NAME}"
5 changes: 5 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@

- [ ] Follow [How to synchronize GitHub Management with GitHub?](HOWTOS.md#synchronize-github-management-with-github) to commit the terraform lock and initialize terraform state

## Member Update Workflows

- [ ] Use `Update Members` to create a draft PR that removes selected members from teams and repository collaborators in `github/$ORGANIZATION_NAME.yml`. The workflow requires either `cutoff-date` or `only`, supports `ignore` and `limit`, can retain effective public repository access by converting that access to direct public repository collaborators, and can optionally remove selected users from organization membership in the YAML config.
- [ ] Review and merge the draft PR through the normal GitHub Management PR flow.

## GitHub Management Repository Protections

*NOTE*: Advanced users might have to skip/adjust this step if they are not managing some of the arguments/attributes mentioned here with GitHub Management.
Expand Down
138 changes: 138 additions & 0 deletions scripts/__tests__/actions/access-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import 'reflect-metadata'

import assert from 'node:assert'
import {describe, it} from 'node:test'
import {Config} from '../../src/yaml/config.js'
import {State} from '../../src/terraform/state.js'
import {
categorizeAccessSummary,
getAccessSummaryFrom
} from '../../src/actions/shared/access-summary.js'
import {
describeAccessChanges,
describeAccessReport
} from '../../src/actions/shared/describe-access-changes.js'
import {StateSchema} from '../../src/terraform/schema.js'

describe('access summaries', () => {
it('categorizes post-change users', () => {
const config = new Config(`
members:
member:
- alice
- carol
- dave
- kept # KEEP: manual exception
repositories:
private-repo:
collaborators:
pull:
- outside
visibility: private
public-repo:
collaborators:
pull:
- alice
visibility: public
team-only-repo:
teams:
push:
- guests
visibility: public
team-repo:
teams:
push:
- maintainers
visibility: public
teams:
guests:
members:
member:
- team-only-non-member
maintainers:
members:
member:
- dave
`)

const summary = getAccessSummaryFrom(config)
const categories = categorizeAccessSummary(summary)

assert.equal(summary.outside.isMember, false)
assert.equal(summary.outside.isOutsideCollaborator, true)
assert.equal(summary['team-only-non-member'].isMember, false)
assert.equal(summary['team-only-non-member'].isOutsideCollaborator, false)
assert.deepEqual(categories.outsideCollaborators, ['outside'])
assert.deepEqual(categories.potentialOutsideCollaborators, ['alice'])
assert.deepEqual(categories.potentialNoMembers, ['carol'])
assert.deepEqual(categories.anyOtherMembers, ['dave', 'kept'])
})

it('annotates repository visibility in access changes and summaries', () => {
const state = new State(
JSON.stringify({
values: {
root_module: {
resources: [
{
mode: 'managed',
index: 'alice',
address: 'github_membership.this["alice"]',
type: 'github_membership',
values: {
username: 'alice',
role: 'member'
}
},
{
mode: 'managed',
index: 'public-repo',
address: 'github_repository.this["public-repo"]',
type: 'github_repository',
values: {
name: 'public-repo',
visibility: 'public'
}
},
{
mode: 'managed',
index: 'public-repo:alice',
address:
'github_repository_collaborator.this["public-repo:alice"]',
type: 'github_repository_collaborator',
values: {
repository: 'public-repo',
username: 'alice',
permission: 'pull'
}
}
]
}
}
} satisfies StateSchema)
)
const config = new Config(`
members:
member:
- alice
repositories:
public-repo:
collaborators:
push:
- alice
visibility: public
`)

const changes = describeAccessChanges(state, config)
const report = describeAccessReport(state, config)

assert.match(
changes,
/will have the permission to public-repo \(public\) change from pull to push/
)
assert.match(report, /<summary>Potential outside collaborators<\/summary>/)
assert.match(report, /Affected users: alice/)
assert.match(report, /User alice \(member\):/)
assert.match(report, /has push permission to public-repo \(public\)/)
})
})
Loading