Local-first admin tooling for managing settings, teams, and CODEOWNERS
across every repository in the cloudnative-pg
GitHub organization.
Every script here assumes a human, locally-authenticated gh session with
real org-admin scope (admin:org, repo, read:org). None of this runs
from GitHub Actions or any other CI pipeline, on purpose: keeping org-admin
credentials on an admin's laptop instead of in a pipeline secret means a
compromised workflow or a malicious PR never has a path to org-wide admin
access. The tradeoff is that these changes don't happen automatically —
an admin has to actually run them — which is the point, not a limitation.
-
Create an empty directory and clone this repo as a sibling of every other
cloudnative-pgrepo you'll manage (or into a fresh empty directory — the next step populates it):mkdir cnpg.io && cd cnpg.io git clone https://github.com/cloudnative-pg/cnpg-infra.git
-
Authenticate
ghwith sufficient scope:gh auth login gh auth refresh -h github.com -s admin:org
-
From inside
cnpg-infra/, run the bootstrap script. It checks you're an active member of theadminsteam, then clones every repo listed ingenerated/managed-repos.yamlthat isn't already a sibling directory (existing clones are left completely untouched):cd cnpg-infra ./scripts/bootstrap.sh
The admin-team check is a fail-fast sanity check, not the real security boundary — GitHub's own API authorization is what actually stops a non-admin from doing anything privileged. It just avoids a confusing half-completed run before that happens.
Generated files, in generated/ — do not hand-edit, re-run the script that owns them:
| File | Rebuilt by | Contents |
|---|---|---|
generated/managed-repos.yaml |
scripts/update-managed-repos.sh |
Every non-archived cloudnative-pg/* repo cloned as a sibling of cnpg-infra, with its origin remote verified |
generated/teams.yaml |
scripts/update-teams.sh |
Live GitHub team roster (org owners, org members, each team's members) — deliberately does NOT track per-repo access; see the policy files below for that |
Hand-maintained policy files — the desired state the scripts below check/enforce against:
| File | Scope |
|---|---|
repo-policy.yaml |
Per-repo exceptions to the settings baseline (e.g. a repo whose content is machine-published and so doesn't need a PR-review gate; a standalone review-count override for a repo not yet in repo-tiers.yaml; extra_team_permissions, a per-repo team grant on top of org-policy.yaml's org-wide floors, for a team a repo's CODEOWNERS names but that holds no access there) |
repo-tiers.yaml |
Every managed repo's importance class (A = critical, B = important, C = low-stakes), governance subproject, and (optionally) a desired GitHub description. Class drives the settings floor: A gets 2 required reviews + code-owner review forced on, B gets 1 + code-owner review forced on, C gets the org default (1, untouched code-owner-review setting). A repo's description field, if set, is pushed to GitHub whenever it drifts — the only field in any policy file here that changes something a repo already has rather than just flooring it |
componentowners-policy.yaml |
Per-repo CODEOWNERS content — both the catch-all * rule and any path-scoped rules, each as teams + users |
org-policy.yaml |
Rules that apply identically to every repo (e.g. which teams get admin everywhere), so they don't need repeating per-repo |
milestones-policy.yaml |
Desired "horizon" milestones (Now / Next / Later, title + description copied verbatim from cloudnative-pg/cloudnative-pg's own generic triage milestones), each one's due_offset_months (used to compute a rolling due date, not a fixed one), and the explicit, hand-picked list of repos it should exist in |
labels-policy.yaml |
GitHub issue labels the org's issue templates depend on, org-wide or per-repo. A form silently drops a label the repository doesn't have, so every label a template references has to exist wherever that template can be used |
gitvote-policy.yaml |
Per-repo exceptions to the gitvote configuration baseline (see below). Only the five org-control repos need an entry; every other managed repo gets the default template with no bespoke entry at all |
Scripts (all in scripts/, YAML config stays in the repo root):
| Script | Effect |
|---|---|
scripts/bootstrap.sh |
Checks admin-team membership, clones any managed repo missing as a sibling |
scripts/create-new-repo.sh --name <repo> --class <A|B|C> --subproject <...> --description "<text>" --owners user1,user2,... [--apply] |
Onboards a brand-new repo end-to-end — see "Adding a new repo" below |
scripts/update-managed-repos.sh |
Rebuilds generated/managed-repos.yaml from live GitHub state |
scripts/update-teams.sh |
Rebuilds generated/teams.yaml from live GitHub state |
scripts/check-repo-settings.sh [repo] |
Read-only audit of repo settings (branch protection, teams, collaborators, security features) against the policy files and the GitHub-API-checkable subset of the OSPS Baseline checklist → repo-settings-report.md |
scripts/fix-repo-settings.sh <repo> [--apply] |
Remediates one repo against the policy files. Defaults to dry-run — always review the diff before re-running with --apply. Only ever raises settings, never lowers an existing stricter one |
scripts/fix-all-repos.sh [--apply] |
Runs fix-repo-settings.sh across every repo in managed-repos.yaml, one at a time, with a summary at the end. Same dry-run-by-default safety model — nothing here is new logic, just a loop |
scripts/render-component-owners.rb <repo> |
Renders that repo's COMPONENT_OWNERS.md from repo-tiers.yaml's owners: to stdout. Pure renderer, like render-codeowners.rb: touches nothing. The public, per-repo answer to "who owns this component" that a GitHub team can't be, since team membership is only visible to other org members |
scripts/sync-repo.sh <repo> [--apply] |
Runs every per-repo reconcile step below in order (validate, owners team, Reviewer grants, settings, labels, milestones, ownership files). The single command to run after a policy change. Same dry-run-by-default model, no logic of its own |
scripts/sync-ownership-files.sh <repo> [--apply] |
Writes everything cnpg-infra generates for a repo into its local sibling clone: CODEOWNERS, COMPONENT_OWNERS.md, CONTRIBUTORS.md where it has contributors, .gitvote.yml, and a copy of each org-wide issue template from cloudnative-pg/.github if the repo has an ISSUE_TEMPLATE folder of its own (GitHub stops serving org defaults to those repos, silently). One command stages a repository completely, so landing it is one PR and one diff to review rather than three passes. Defaults to dry-run, showing a unified diff per file. Local only: never commits, pushes, or calls GitHub, so the target repo's own PR review still gates the change |
scripts/sync-reviewer-grants.rb [repo] [--apply] |
Reconciles the Write access that Reviewers need, against repo-tiers.yaml's reviewers: lists (naming them on componentowners-policy.yaml paths is optional routing). Direct grants rather than a team, since the Reviewer rung implies no org membership and teams can only hold members. Reports, but never revokes, a direct write grant no rule explains. Defaults to dry-run |
scripts/sync-labels.rb [repo] [--apply] |
Reconciles GitHub issue labels against labels-policy.yaml: creates a missing label, updates a drifted colour or description. Never deletes, and never touches a label the policy doesn't name. Defaults to dry-run |
scripts/validate-policy.rb |
Static validation of the YAML policy files: every file parses, every managed repo has a repo-tiers.yaml/componentowners-policy.yaml entry, class/subproject values are from the documented enum, every referenced team slug exists in generated/teams.yaml's last snapshot, and every repo referenced in milestones-policy.yaml is managed. No GitHub API calls — safe to run in CI, and also runs there (.github/workflows/lint.yml, alongside ShellCheck on every script) |
scripts/sync-milestones.rb [repo] [--apply] |
Reconciles GitHub milestones against milestones-policy.yaml: creates a missing milestone (matched by exact title), updates its description and/or due date if either has drifted. Due date is a rolling window computed from due_offset_months and the date the script runs, not a fixed date — it advances on its own each time this next runs in a new calendar month. Never touches open/closed state or issue assignments. Defaults to dry-run — always review the plan before re-running with --apply |
scripts/check-gitvote-config.sh [repo] |
Read-only audit of each repo's .gitvote.yml against gitvote-policy.yaml's baseline, plus a one-time check of the gitvote GitHub App's org-wide installation scope. Writes gitvote-config-report.md |
scripts/fix-gitvote-config.sh <repo> [--apply] |
Lands the .gitvote.yml that repo's category calls for. Not a settings PATCH like fix-repo-settings.sh -- .gitvote.yml lives in the repo's own git history, so this opens a PR (branch, commit, gh pr create), except for cnpg-infra itself, which gets a direct commit to its default branch per its own documented bypass exception. Defaults to dry-run -- always review the diff (and, for a non-cnpg-infra repo, that a PR is about to be opened) before re-running with --apply |
Merging a change here only updates the desired state. GitHub and the target repos change when you run a script. For any change to one repo, run:
./scripts/sync-repo.sh <repo> # dry run of every step
./scripts/sync-repo.sh <repo> --applyIt validates the policy files, then runs the steps below in order: owners
team, Reviewer grants, repo settings, labels, milestones (only for repos
milestones-policy.yaml names) and the generated ownership files. Each step
is idempotent and only raises, so running all of them is safe when only one
was needed. Afterwards, commit the regenerated files in <repo> and open a
PR there (the script stops at the working tree).
The table says which step a given edit actually exercises, for running one script on its own. Every script is dry-run by default.
| You changed | Run, in this order | Then |
|---|---|---|
repo-tiers.yaml owners: (add or remove a Component Owner) |
sync-project-owner-teams.sh <repo>, then sync-ownership-files.sh <repo> |
Commit and open a PR in <repo> for the regenerated COMPONENT_OWNERS.md. CODEOWNERS only changes if its policy changed too, since it references the <repo>-owners team. A person who is not yet an org member gets a pending invite, not access, until they accept |
repo-tiers.yaml contributors: |
sync-ownership-files.sh <repo> |
PR in <repo> for CONTRIBUTORS.md. No GitHub access is granted by this list |
componentowners-policy.yaml (path rules, Reviewers) |
sync-reviewer-grants.rb <repo>, then sync-ownership-files.sh <repo> |
PR in <repo> for CODEOWNERS and COMPONENT_OWNERS.md |
people.yaml (name, organization, country) |
sync-ownership-files.sh <repo> for each repo that lists the person |
Display data only, grants nothing. The rendered files in the repo change, nothing on GitHub does |
repo-tiers.yaml class: or description: |
fix-repo-settings.sh <repo> |
Class sets the review floor, description is pushed to GitHub |
repo-policy.yaml or org-policy.yaml |
fix-repo-settings.sh <repo> for one repo, fix-all-repos.sh for an org-wide rule |
|
gitvote-policy.yaml |
sync-ownership-files.sh <repo> (writes .gitvote.yml) or fix-gitvote-config.sh <repo> |
Then check-gitvote-config.sh to confirm |
labels-policy.yaml |
sync-labels.rb [repo] |
|
milestones-policy.yaml |
sync-milestones.rb [repo] |
|
| A repo was cloned, archived or created | update-managed-repos.sh |
See "Adding a new repo" |
| A team's membership changed outside this repo | update-teams.sh |
Refreshes the snapshot validate-policy.rb checks team slugs against |
When a script lands files in a sibling clone, git fetch origin and branch
from origin/main before committing, and cite the passed vote issue in the
PR body for any ownership change. check-repo-settings.sh [repo] is the
read-only way to confirm the end state afterwards.
./scripts/create-new-repo.sh does this end-to-end for a genuinely new
repo — creates it on GitHub from cnpg-template, clones it as a sibling,
registers it in repo-tiers.yaml and componentowners-policy.yaml,
regenerates generated/managed-repos.yaml, creates and populates its
<repo>-owners team, renders and pushes its real CODEOWNERS, brings it
up to the settings baseline (fix-repo-settings.sh), runs
validate-policy.rb, and opens a PR on cloudnative-pg/.project adding
it to project.yaml's repositories: list. Dry-run by default, like
every other script here:
./scripts/create-new-repo.sh \
--name <repo> \
--class <A|B|C> \
--subproject <core|supply-chain|community-ecosystem|extensibility|org-control|unclassified> \
--description "<one-line GitHub description>" \
--owners user1,user2,...
# review the plan, then:
./scripts/create-new-repo.sh ... --applyNew entries are appended to the end of each policy file's repositories:
list, not re-sorted into their class/alphabetical grouping — that's a
human tidy-up afterward, if you want one.
If the repo already exists on GitHub (an existing repo just joining
cnpg-infra's management rather than a genuinely new one), use the
older manual path instead:
- Clone it as a sibling of
cnpg-infra:git clone https://github.com/cloudnative-pg/<name>.git - Run
./scripts/update-managed-repos.sh— it'll now find the new sibling clone and add it togenerated/managed-repos.yaml. - Add policy entries for it in
repo-tiers.yamlandcomponentowners-policy.yamlby hand. - Run
./scripts/fix-repo-settings.sh <name>(dry-run first, review the diff, then--apply) to bring it up to the settings baseline.
gitvote is already installed org-wide
with repository selection "all", so every managed repo, current and
future, already has the app -- there's no per-repo install step. What
each repo needs is its own .gitvote.yml, scoped to that repo's actual
eligible voters rather than a stale or overly broad team.
A repo with its own <repo>-owners team (see repo-tiers.yaml's
owners field) gets two profiles, both scoped to that team, mirroring
CONTRIBUTOR_LADDER.md's two repository-level thresholds: default
(simple majority, Contributor promotion/removal) and component-owner
(two-thirds majority, Component Owner promotion/removal). Those render as
pass_threshold: 50.01 and 66.66 rather than the round numbers, because
gitvote passes a vote on in_favor_percentage >= pass_threshold: at a flat
50 an even-sized electorate splitting down the middle would pass its own
tie, and a flat 66 sits just under a true two-thirds. The comment block
above render_gitvote_config() has the full reasoning. The five
org-control repos (governance, .project, .github, cnpg-infra,
cnpg-template) get the same shape scoped to steering-committee
instead, since their votes are Steering Committee business, not a
subproject's.
gitvote-policy.yaml is where a repo's category (org-control today)
or an outright exclusion is recorded; most repos need no entry there at
all and just get the default per-repo-owners template.
scripts/render-gitvote-config.sh <repo> prints what a given repo's
.gitvote.yml should be; scripts/check-gitvote-config.sh audits every
managed repo's live file against that render, and
scripts/fix-gitvote-config.sh <repo> [--apply] lands it (a PR for most
repos, a direct commit for cnpg-infra's own bypass exception). Same
dry-run-by-default rule as everything else here: review the diff, then
--apply.
governance is excluded outright (gitvote-policy.yaml's
excluded: true) -- its own .gitvote.yml fix landed as its own PR in
that repo, not from here.
Same as every other repo in the org — DCO sign-off, Conventional Commits,
Assisted-by: trailer for material AI assistance (see
governance/AI_POLICY.md).