Config reference

A config file names the repositories to manage and a shared block of sections applied to every one of them. Every key is validated, so a typo becomes a load error, not a silent no-op — unknown fields anywhere in the schema are rejected.

File anatomy

A config lists repos:owner/repo strings, each validated to be in that exact form — plus the shared sections below. All sections are optional, but the repos: list must have at least one entry.

repos:
  - octocat/hello-world
  - octocat/spoon-knife

settings:
  description: "Example repository"
  private: false
  has_issues: true
  delete_branch_on_merge: true

actions:
  enabled: true
  allowed_actions: all
  can_approve_pull_request_reviews: true

labels:
  - name: bug
    color: d73a4a
    description: Something isn't working
  - name: needs-triage
    color: fbca04

collaborators:
  - username: octocat
    permission: admin

webhooks:
  - url: https://example.com/hooks/ci
    events: [push, pull_request]
    secret_from_env: CI_WEBHOOK_SECRET

secrets:
  - name: DEPLOY_TOKEN
    value_from_env: DEPLOY_TOKEN

variables:
  - name: ENVIRONMENT
    value: production

rulesets:
  - name: main-branch-protection
    target: branch
    rules:
      - type: pull_request
        required_approving_review_count: 1

Section authority

A declared section is the complete desired set. Anything present on the repo but absent from the section is removed on apply: labels, webhooks, secrets, and variables not listed are deleted; direct collaborators not listed are removed; repo rulesets not listed are deleted. There's no partial-update mode — declaring three labels when the repo has five means the other two go away.

An omitted section is the opposite: leave it out entirely and that whole domain is left unmanaged, untouched by apply. Whether you declare a section at all matters just as much as what you put in it.

rulesets

Repository rulesets are declared under rulesets:, one entry per ruleset, matched to live rulesets by name and reconciled to exactly the declared spec. The shape is large enough — target, enforcement, bypass actors, conditions, and a dozen rule types — that it has its own page: see the rulesets reference.

settings

Repository-level settings. Every field is optional; a field left unset is left unmanaged on the repo (not reset to a default).

FieldTypeDefaultNotes
descriptionstringunmanaged
homepagestringunmanaged
privateboolunmanaged
topicslist[string]unmanaged
has_issuesboolunmanaged
has_wikiboolunmanaged
has_projectsboolunmanaged
has_discussionsboolunmanaged
default_branchstringunmanaged
allow_squash_mergeboolunmanaged
allow_merge_commitboolunmanaged
allow_rebase_mergeboolunmanaged
allow_auto_mergeboolunmanaged
delete_branch_on_mergeboolunmanaged
allow_update_branchboolunmanaged
squash_merge_commit_titlePR_TITLE | COMMIT_OR_PR_TITLEunmanagedRequired if squash_merge_commit_message is set
squash_merge_commit_messagePR_BODY | COMMIT_MESSAGES | BLANKunmanaged
merge_commit_titlePR_TITLE | MERGE_MESSAGEunmanagedRequired if merge_commit_message is set
merge_commit_messagePR_BODY | PR_TITLE | BLANKunmanaged
web_commit_signoff_requiredboolunmanaged
is_templateboolunmanaged
archivedboolunmanaged

actions

Actions enablement/policy and workflow permissions — three separate GitHub API endpoints that PyGithub doesn't model, driven directly. Every field is optional; a field left unset is left unmanaged, and an unmanaged field of a pair (e.g. enabled vs. allowed_actions) is written back with its live value so the PUT doesn't clear it.

FieldTypeDefaultNotes
enabledboolunmanagedWhether Actions is enabled on the repo
allowed_actionsall | local_only | selectedunmanaged
selected_actions.github_owned_allowedbooltrueOnly meaningful when allowed_actions: selected
selected_actions.verified_allowedboolfalse
selected_actions.patterns_allowedlist[string][]owner/repo patterns
default_workflow_permissionsread | writeunmanaged
can_approve_pull_request_reviewsboolunmanaged"Allow GitHub Actions to create and approve pull requests"
actions:
  enabled: true
  allowed_actions: selected
  selected_actions:
    github_owned_allowed: true
    verified_allowed: true
    patterns_allowed: ["astral-sh/*"]
  default_workflow_permissions: read
  can_approve_pull_request_reviews: true

security

Repository security posture. Every field is optional and independently managed — each maps to its own GitHub endpoint, so a plan can show anywhere from zero to four separate changes for this section (secret scanning and push protection share one endpoint and are batched into a single change when either differs).

FieldTypeDefaultNotes
secret_scanningboolunmanaged
secret_scanning_push_protectionboolunmanaged
vulnerability_alertsboolunmanagedDependabot vulnerability alerts
automated_security_fixesboolunmanagedDependabot security updates
private_vulnerability_reportingboolunmanagedNo PyGithub support — driven directly through the authenticated requester
security:
  secret_scanning: true
  secret_scanning_push_protection: true
  vulnerability_alerts: true
  automated_security_fixes: true
  private_vulnerability_reporting: true

labels

Issue and PR labels, matched by name. Exact-set semantics apply: labels on the repo that aren't in the list are deleted.

FieldTypeDefaultNotes
namestringrequiredMatch key
colorstringedededA leading # is stripped and the value lowercased before it's sent
descriptionstringunmanagedOmitted means left as-is on GitHub, not cleared

collaborators

Direct collaborators and their permission level, matched by username.

FieldTypeDefaultNotes
usernamestringrequiredMatch key
permissionpull | triage | push | maintain | adminpush

Only direct collaborators (GitHub affiliation direct) are managed. Access inherited from an organization or a team is never inspected or touched, so this section can't accidentally revoke org-level access.

webhooks

Repository webhooks, matched by url.

FieldTypeDefaultNotes
urlstringrequiredMatch key
eventslist[string][push]
activebooltrue
content_typejson | formjson
insecure_sslboolfalse
secret_from_envstringunsetEnv var holding the webhook secret

A webhook with a secret is re-sent on every apply, whether or not anything else changed — the GitHub API never returns a webhook's existing secret, so there's no value to diff against. Plans show it as (set) rather than printing it.

deploy_keys

Deploy keys, matched by key content (not title — GitHub allows duplicate titles, but key content is the real identity).

FieldTypeDefaultNotes
titlestringrequired
keystringrequiredThe public key content; match key
read_onlybooltrue

GitHub's deploy-key API has no update endpoint. Changing title or read_only for the same key content is planned as a delete of the old key paired with a create of the new one, not a single in-place update.

Autolink references, matched by key_prefix — a prefix like TICKET- in commit messages and PR text is turned into a link using url_template.

FieldTypeDefaultNotes
key_prefixstringrequiredMatch key
url_templatestringrequiredMust contain <num>
is_alphanumericbooltrueWhether the reference may include letters, not just digits
autolinks:
  - key_prefix: "TICKET-"
    url_template: "https://example.atlassian.net/browse/TICKET-<num>"

Like deploy keys, GitHub's autolinks API has no update endpoint — a changed url_template/is_alphanumeric for an existing key_prefix is a delete-and-recreate, not an in-place update.

pages

GitHub Pages configuration. Omitting pages: entirely leaves it unmanaged; declaring it with enabled: false disables Pages if it's currently on.

FieldTypeDefaultNotes
enabledbooltrueSet false to disable an existing Pages site
build_typelegacy | workflowrequired when enabled
source.branchstringunsetOnly meaningful for build_type: legacy
source.path/ | /docs/
cnamestringunmanagedCustom domain
https_enforcedboolunmanaged
pages:
  build_type: legacy
  source: {branch: main, path: "/docs"}
  https_enforced: true

GitHub's create-Pages-site endpoint only accepts build_type/sourcecname/https_enforced are update-only. Creating a new site with those already set takes a create call followed immediately by an update call, both inside one planned change.

secrets

Actions secrets. Each entry needs a name plus exactly one of value or value_from_env — providing both, or neither, is a validation error.

FieldTypeDefaultNotes
namestringrequiredMatch key
valuestringunsetLiteral value; exactly one of value/value_from_env
value_from_envstringunsetEnv var to read the value from

Secret values are write-only: GitHub never returns them, so apply never diffs the current value against the declared one. By default a secret already present on the repo is left alone; pass --force-secrets to re-push every declared secret, e.g. to rotate one. Values are never printed in plans.

Literal value: is supported for convenience, but a secret's value in plain YAML is a secret in your git history. Prefer value_from_env and keep the actual value out of the repo entirely.

variables

Actions repository variables — same shape as secrets, but not sensitive.

FieldTypeDefaultNotes
namestringrequiredMatch key
valuestringunsetLiteral value; exactly one of value/value_from_env
value_from_envstringunsetEnv var to read the value from

Variable values are readable on GitHub and shown in plain text in plans. Unlike secrets, an existing variable is only pushed when its resolved value actually differs from what's on the repo.

environments

Deployment environments, matched by name: protection rules plus environment-scoped secrets/variables (same shape as the top-level sections above, but scoped to the environment).

FieldTypeDefaultNotes
namestringrequiredMatch key
wait_timerintunmanagedMinutes to wait before allowing deployment
reviewerslist[reviewer]unmanagedRequired reviewers; see below
prevent_self_reviewboolunmanaged
deployment_branch_policy.protected_branchesboolunmanaged
deployment_branch_policy.custom_branch_policiesboolunmanaged
secretslist[secret]unmanagedSame shape as the top-level secrets section
variableslist[variable]unmanagedSame shape as the top-level variables section

Each reviewer is a User (by login) or a Team (by slug), resolved to the numeric ID GitHub's API requires when the plan runs — a Team reviewer requires an org-owned repository. login/slug must be a valid GitHub identifier (letters, digits, and internal hyphens only — no /), since the value is used directly in the resolution request.

environments:
  - name: production
    wait_timer: 10
    reviewers:
      - {type: User, login: octocat}
      - {type: Team, slug: platform}
    prevent_self_review: true
    deployment_branch_policy: {protected_branches: true}
    secrets:
      - {name: DEPLOY_TOKEN, value_from_env: PROD_DEPLOY_TOKEN}
    variables:
      - {name: REGION, value: us-east-1}

GitHub's create-environment endpoint is a single call covering wait timer, reviewers, self-review prevention, and branch policy together — there's no partial-update form. An unset field on an existing environment is preserved at its current live value rather than reset, the same policy settings and actions use for their own unmanaged fields.

Composing with extends

A config can extends: one or more base files:

extends: ../base/org-defaults.yaml
# or
extends:
  - ../base/org-defaults.yaml
  - ../base/security-baseline.yaml

Paths are relative to the file that declares them, and extends: is recursive — a base can itself extend another base. Bases merge underneath the file that extends them, in list order, and the extending file's own content merges on top of all of them last.

Merge rules:

A circular extends chain (A extends B extends A) is detected and rejected as a config error rather than looping forever.

By convention, *.yml files in the config directory are the applied configs — each with its own repos: list, picked up by the CLI's config-dir glob — while *.yaml files are base layers that only exist to be pulled in via extends:. Neither extension is enforced by the schema; it's a naming convention this repo relies on to tell the two apart at a glance.

extends: reads local files by relative path with no sandboxing — treat a base file with exactly the same scrutiny you'd give the config that includes it. It's a full peer of the file that extends it, not an inert template.

Environment-sourced values

value_from_env (secrets, variables) and secret_from_env (webhooks) are resolved when a plan or apply actually runs — not when the config is loaded and validated, so schema checks don't require any secrets to be present in the environment.

An env var that's unset or empty is a hard error at resolve time, and the two are treated identically on purpose: in GitHub Actions, ${{ secrets.X }} for an unset secret expands to an empty string rather than failing the expression, so a presence-only check would silently propagate an empty value to every managed repo instead of failing loudly.