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-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 the individuals named in componentowners-policy.yaml's path rules. 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 |
./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).