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

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
can_approve_pull_request_reviewsboolunmanaged"Allow GitHub Actions to create and approve pull requests" — an Actions workflow-permissions setting on its own API endpoint, managed with the same declare-or-unmanaged semantics

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.

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.

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.