Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ INPUT_GITHUB-PRIVATE-KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29
INPUT_IGNORED-USERS=user1,user2
INPUT_SYNC-TEAM=TRUE
INPUT_TEAM-SLUG=remarkable
# Optional: sync Google Workspace groups into GitHub teams. Use a file path
# (preferred) or inline JSON. team-map-file wins when both are set.
# INPUT_TEAM-MAP-FILE=.github/github-teams.json
# INPUT_TEAM-MAP={"engineering@remarkable.no":"engineering"}
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Right now this only handles the organization membership, it **does not** touch t
- Client ID: `client id from user`
- OAuth scopes:
- `https://www.googleapis.com/auth/admin.directory.user.readonly`
- `https://www.googleapis.com/auth/admin.directory.group.member.readonly` (only needed for `team-map`/`team-map-file` group-to-team syncing)

1. Register new GitHub App
1. https://github.com/settings/organizations
Expand Down Expand Up @@ -103,6 +104,54 @@ jobs:
team-slug: 'remarkable'
```

#### Syncing Google Workspace groups to GitHub teams

Beyond `sync-team` (which adds *every* org member to a single team), you can
mirror the membership of specific Google Workspace groups into matching GitHub
teams. Provide a JSON object that maps a group's email address to a GitHub team
slug. The members of each group are resolved to their linked GitHub accounts
(via the same `Accounts.github` custom attribute) and reconciled against the
mapped team. Nested groups are expanded automatically.

Keep the mapping in a JSON file in your repo for maintainability:

```json
// .github/github-teams.json
{
"engineering@remarkable.no": "engineering",
"design@remarkable.no": "design",
"platform@remarkable.no": "platform-team"
}
```

```yaml
- name: Github Org Membership Manager
uses: docker://ghcr.io/remarkable/githubusermanager/githubusermanager:v2.0.0
with:
# ...the usual inputs...
github-org: 'reMarkable'
remove-users: 'true' # also prunes team members no longer in the group
team-map-file: .github/github-teams.json
```

Notes:

- Members missing from a team are always added. Members present in a team but
not in the mapped group are only removed when `remove-users` is `true`, so you
can run in a safe, add-only mode first. Team removals also respect
`max-remove-users`, and a group that resolves to zero linked GitHub accounts
will never empty a team (guards against a mistyped group email).
- Any difference between a mapped team and its group sets the
`exit-code-on-mismatch` exit code, so `dry-run` + `exit-code-on-mismatch`
reports team drift in CI without applying changes.
- `ignored-users` are never added to or removed from mapped teams.
- Requires the
`https://www.googleapis.com/auth/admin.directory.group.member.readonly` scope
on the service account's domain-wide delegation.
- For a single inline mapping you can use `team-map` instead of a file, e.g.
`team-map: '{"engineering@remarkable.no":"engineering"}'`. `team-map-file`
takes precedence when both are set.

Add a dependabot configuration to always get updates!

```yaml
Expand Down Expand Up @@ -174,6 +223,7 @@ them as `INPUT_<NAME>` environment variables (upper-cased input name).
| `add-users` | Set to TRUE to add users to the github organisation | `TRUE` | `false` |
| `remove-users` | Set to TRUE to remove users from the github organisation | `TRUE` | `false` |
| `remove-suspended_users` | Set to TRUE to remove users from the github organisation that are suspended in Google | `TRUE` | `false` |
| `dry-run` | Set to TRUE to log all intended org/team membership changes without applying them (useful with `exit-code-on-mismatch`) | `TRUE` | `false` |
| `exit-code-on-mismatch` | Exit code to use when there's a mismatch, useful when combined with `add-users` and `remove-users` to be used in a dry-run mode | `1` | `0` |
| `max-remove-users` | Safety limit on how many users may be removed in a single run | `100` | `100` |
| `github-org` | GitHub Organization | `chrisnstest` | `null` |
Expand All @@ -183,4 +233,6 @@ them as `INPUT_<NAME>` environment variables (upper-cased input name).
| `ignored-users` | Comma separated list of user ids to totally ignore always, useful for owners of an org you don't want accidentally removed | `owner1,owner2` | `null` |
| `sync-team` | Set to TRUE to ensure every org member is added to the team named in `team-slug` | `TRUE` | `false` |
| `team-slug` | Slug of the team every org member should be added to, e.g. to grant PR rights to shared repos | `remarkable` | `null` |
| `team-map` | Inline JSON mapping a Google Workspace group email to a GitHub team slug; members of each group are synced into the mapped team | `{"eng@x":"eng"}` | `null` |
| `team-map-file` | Path to a JSON file with the same group-to-team mapping as `team-map` (preferred for maintainability; wins if both are set) | `.github/teams.json` | `null` |
| `debug` | Set to TRUE to enable debug level logging | `TRUE` | `false` |
9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ inputs:
remove-suspended_users:
description: 'Set to TRUE to remove suspended users from the github organisation'
required: false
dry-run:
description: 'Set to TRUE to log all intended changes (org and team membership) without applying them. Combine with exit-code-on-mismatch for a reporting-only run.'
required: false
exit-code-on-mismatch:
description: 'Exit code to use when there is a mismatch, useful when combined with `ADD_USERS` and `REMOVE_USERS` to be used in a dry-run mode'
required: false
Expand Down Expand Up @@ -47,6 +50,12 @@ inputs:
team-slug:
description: 'Slug of the team that every org member should be added to (e.g. remarkable)'
required: false
team-map:
description: 'Inline JSON object mapping a Google Workspace group email to a GitHub team slug, e.g. {"engineering@remarkable.no":"engineering"}. Members of each group are synced into the mapped team. Prefer team-map-file for maintainability. team-map-file takes precedence when both are set.'
required: false
team-map-file:
description: 'Path to a JSON file mapping Google Workspace group emails to GitHub team slugs. Members of each group are synced into the mapped team.'
required: false
debug:
description: 'Set to TRUE to enable debug level logging'
required: false
Expand Down
49 changes: 48 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
// GoogleProvider supplies the desired set of GitHub users from Google Workspace.
type GoogleProvider interface {
GithubUsersFromGoogle(ctx context.Context) (set.Set, error)
GithubUsersForGroups(ctx context.Context, groups []string) (map[string]set.Set, error)
}

// GitHubProvider reads and reconciles GitHub organisation membership.
Expand All @@ -22,11 +23,15 @@ type GitHubProvider interface {
AddUsersToOrg(ctx context.Context, users set.Set) error
RemoveUsersFromOrg(ctx context.Context, users set.Set) error
SyncTeamMembers(ctx context.Context) error
SyncTeamMap(ctx context.Context, desired map[string]set.Set) (bool, error)
}

// Run reconciles GitHub org membership against Google Workspace and returns the
// process exit code (ExitCodeOnMismatch when the two sides differ, else 0).
func Run(ctx context.Context, cfg *config.Config, google GoogleProvider, gh GitHubProvider) (int, error) {
if cfg.DryRun {
slog.Info("dry run enabled: no changes will be applied")
}
googleUsers, err := google.GithubUsersFromGoogle(ctx)
if err != nil {
return 0, err
Expand Down Expand Up @@ -72,8 +77,50 @@ func Run(ctx context.Context, cfg *config.Config, google GoogleProvider, gh GitH
}
}

if usersNotInGoogle.Len() > 0 || usersNotInGitHub.Len() > 0 {
teamMapDrift := false
if len(cfg.TeamMap) > 0 {
drift, err := syncTeamMap(ctx, cfg, google, gh)
if err != nil {
return 0, err
}
teamMapDrift = drift
}

if usersNotInGoogle.Len() > 0 || usersNotInGitHub.Len() > 0 || teamMapDrift {
return cfg.ExitCodeOnMismatch, nil
}
return 0, nil
}

// syncTeamMap resolves each configured Google Workspace group to its members'
// GitHub usernames and reconciles the mapped GitHub team membership. Multiple
// groups mapping to the same team slug are unioned. It returns true when any
// mapped team differed from its group so callers can flag a mismatch.
func syncTeamMap(ctx context.Context, cfg *config.Config, google GoogleProvider, gh GitHubProvider) (bool, error) {
groups := make([]string, 0, len(cfg.TeamMap))
for group := range cfg.TeamMap {
groups = append(groups, group)
}

byGroup, err := google.GithubUsersForGroups(ctx, groups)
if err != nil {
return false, err
}

desired := make(map[string]set.Set, len(cfg.TeamMap))
for group, slug := range cfg.TeamMap {
members := byGroup[group]
slog.Info("group members resolved to github", "group", group, "team", slug,
"users", strings.Join(members.Sorted(), ", "))
team, ok := desired[slug]
if !ok {
team = set.New()
desired[slug] = team
}
for _, user := range members.Sorted() {
team.Add(user)
}
}

return gh.SyncTeamMap(ctx, desired)
}
65 changes: 65 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import (
type fakeGoogle struct{ users set.Set }

func (f fakeGoogle) GithubUsersFromGoogle(context.Context) (set.Set, error) { return f.users, nil }
func (f fakeGoogle) GithubUsersForGroups(context.Context, []string) (map[string]set.Set, error) {
return nil, nil
}

type fakeGitHub struct {
users set.Set
added, removed set.Set
syncCalled bool
teamMapDesired map[string]set.Set
teamMapDrift bool
}

func (f *fakeGitHub) UsersFromGitHub(context.Context) (set.Set, error) { return f.users, nil }
Expand All @@ -27,6 +32,10 @@ func (f *fakeGitHub) RemoveUsersFromOrg(_ context.Context, u set.Set) error {
return nil
}
func (f *fakeGitHub) SyncTeamMembers(context.Context) error { f.syncCalled = true; return nil }
func (f *fakeGitHub) SyncTeamMap(_ context.Context, desired map[string]set.Set) (bool, error) {
f.teamMapDesired = desired
return f.teamMapDrift, nil
}

func init() { slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) }

Expand Down Expand Up @@ -111,3 +120,59 @@ func TestMatchExitsZero(t *testing.T) {
t.Errorf("no changes expected when matched: added=%v removed=%v", gh.added, gh.removed)
}
}

type fakeGoogleGroups struct {
users set.Set
groups map[string]set.Set
}

func (f fakeGoogleGroups) GithubUsersFromGoogle(context.Context) (set.Set, error) {
return f.users, nil
}
func (f fakeGoogleGroups) GithubUsersForGroups(_ context.Context, _ []string) (map[string]set.Set, error) {
return f.groups, nil
}

func TestTeamMapSyncUnionsGroupsPerTeam(t *testing.T) {
g := fakeGoogleGroups{
users: set.New("a"),
groups: map[string]set.Set{
"eng@x": set.New("a", "b"),
"infra@x": set.New("b", "c"),
},
}
gh := &fakeGitHub{users: set.New("a")}
cfg := &config.Config{TeamMap: map[string]string{
"eng@x": "platform",
"infra@x": "platform",
}}
if _, err := Run(context.Background(), cfg, g, gh); err != nil {
t.Fatal(err)
}
got := gh.teamMapDesired["platform"]
if got == nil || got.Len() != 3 || !got.Has("a") || !got.Has("b") || !got.Has("c") {
t.Errorf("platform desired = %v, want [a b c]", got.Sorted())
}
}

func TestTeamMapSyncSkippedWhenUnset(t *testing.T) {
g := fakeGoogleGroups{users: set.New("a")}
gh := &fakeGitHub{users: set.New("a")}
if _, err := Run(context.Background(), &config.Config{}, g, gh); err != nil {
t.Fatal(err)
}
if gh.teamMapDesired != nil {
t.Errorf("SyncTeamMap should not be called, got %v", gh.teamMapDesired)
}
}

func TestTeamMapDriftDrivesExitCode(t *testing.T) {
g := fakeGoogleGroups{users: set.New("a"), groups: map[string]set.Set{"eng@x": set.New("a")}}
gh := &fakeGitHub{users: set.New("a"), teamMapDrift: true}
cfg := &config.Config{ExitCodeOnMismatch: 7, TeamMap: map[string]string{"eng@x": "eng"}}
code, err := Run(context.Background(), cfg, g, gh)
_ = err
if code != 7 {
t.Errorf("exit code = %d, want 7 (team drift should flag mismatch)", code)
}
}
51 changes: 50 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
Expand Down Expand Up @@ -35,6 +36,7 @@ type Config struct {
AddUsers bool
RemoveUsers bool
RemoveSuspendedUsers bool
DryRun bool
ExitCodeOnMismatch int
MaxRemoveUsers int
Debug bool
Expand All @@ -45,7 +47,10 @@ type Config struct {
GitHubPrivateKey string
GitHubTeamSlug string
SyncTeam bool
IgnoredUsers []string
// TeamMap maps a Google Workspace group email (lower-cased) to the GitHub
// team slug whose membership should mirror that group. Empty when unset.
TeamMap map[string]string
IgnoredUsers []string

GoogleEmailAddress string
GoogleCredentials GoogleCredentials
Expand All @@ -60,6 +65,7 @@ func Load(src InputSource) (*Config, error) {
AddUsers: boolVal(src.GetInput("add-users")),
RemoveUsers: boolVal(src.GetInput("remove-users")),
RemoveSuspendedUsers: boolVal(src.GetInput("remove-suspended_users")),
DryRun: boolVal(src.GetInput("dry-run")),
ExitCodeOnMismatch: intOr(src.GetInput("exit-code-on-mismatch"), 0),
MaxRemoveUsers: intOr(src.GetInput("max-remove-users"), 100),
Debug: boolVal(src.GetInput("debug")),
Expand Down Expand Up @@ -97,9 +103,52 @@ func Load(src InputSource) (*Config, error) {
return nil, fmt.Errorf("google-credentials is not valid JSON: %w", err)
}

teamMap, err := loadTeamMap(src.GetInput("team-map-file"), src.GetInput("team-map"))
if err != nil {
return nil, err
}
c.TeamMap = teamMap

return c, nil
}

// loadTeamMap resolves the group-to-team mapping from either a JSON file
// (team-map-file, preferred for maintainability) or inline JSON (team-map).
// The file takes precedence when both are set. Keys (group emails) are
// lower-cased and trimmed; empty entries are dropped.
func loadTeamMap(path, inline string) (map[string]string, error) {
raw := strings.TrimSpace(inline)
if p := strings.TrimSpace(path); p != "" {
b, err := os.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("reading team-map-file %q: %w", p, err)
}
raw = strings.TrimSpace(string(b))
}
if raw == "" {
return nil, nil
}

var parsed map[string]string
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil, fmt.Errorf("team map is not valid JSON (expected {\"group@domain\":\"team-slug\"}): %w", err)
}

out := make(map[string]string, len(parsed))
for group, slug := range parsed {
g := strings.TrimSpace(strings.ToLower(group))
s := strings.TrimSpace(slug)
if g == "" || s == "" {
continue
}
out[g] = s
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}

func boolVal(s string) bool {
return strings.EqualFold(strings.TrimSpace(s), "true")
}
Expand Down
Loading
Loading