Skip to content

Policy

Wardens are policy driven. The governance file is the policy: one YAML or JSON file that declares the desired state of your GitHub organization(s), and everything else (auth, the CLI, the cycles, the Action, the schedule) serves it. It is the one file you must author.

  • Loaded from the path given to --config on every subcommand (reconcile, audit, report). YAML or JSON, decided by file extension.
  • Schema (authoritative): src/config/types.ts. Validation: src/config/load.ts (throws a GovernanceConfigError with the exact field path on bad shape).
  • Selective-by-omission: an absent field or collection is never read for mutation, diffed, or changed. This holds at every level: if teams is absent, teams are untouched; if one team's members is absent, that team's membership is untouched even though the team itself is managed.
  • Deletes are ownership-gated. The diff proposes deleting a live entry missing from the policy only when that entry's collection is marked owned, and by default nothing is owned: a run creates and updates but never deletes. Declaring owned: true on an org makes warden own every resource collection it reconciles there; owned: [team, repo, ...] limits ownership to the listed change-set resource types. A programmatic diffOptions.isOwned predicate, when supplied, overrides the declaration. Owned deletes still run the guardrails (removalDeltaCap etc.) before any apply.

One authoring note: the CLI ships a small built-in YAML reader that handles block-style mappings and sequences, string/bool/number scalars, and comments. No flow style ({ } / [ ]), no multi-line scalars (| / >), no anchors. For anything richer, such as the multi-line dependabot.content, use JSON.

A complete policy

Copy this, delete what you don't want managed, and edit it. Every field in the schema is shown, and the loader validates and forwards every slice. Comments name the cycle that consumes each slice.

orgs:
  my-org:                                  # keyed by org login; one entry per org

    # ── Ownership ── consumed by the diff of every cycle ────────────────────
    # Gates deletes. Absent/false (default): nothing is owned, no deletes are
    # ever planned. true: every reconciled collection is owned; live resources
    # missing from this policy are planned for deletion. A list limits
    # ownership to those change-set resource types (e.g. team, repo, member,
    # branch-protection, org-variable). Guardrails still apply.
    owned: false

    # ── Org settings ── cycle: org-settings ─────────────────────────────────
    # PATCH /orgs/{org} is a partial update: only declared keys are sent.
    settings:
      description: Engineering org         # public org profile description
      email: eng@example.com               # public org email
      websiteUrl: https://example.com      # org website (GitHub's `blog` field)
      membersCanCreatePublicRepositories: false
      membersCanCreatePrivateRepositories: true
      membersCanCreateInternalRepositories: false   # Enterprise only
      defaultRepositoryPermission: read    # none | read | write | admin
      requireTwoFactorAuthentication: true # surfaced for drift; read-only on most plans

    # ── Org membership & roles ── cycle: membership ─────────────────────────
    # Adds / re-roles declared members. Removal of undeclared members is
    # ownership-gated (never happens from the CLI); when removals are enabled,
    # the member-aware guardrails (adminFloor etc.) gate the apply.
    members:
      - login: alice
        role: admin                        # member | admin; default member
      - login: bob

    # ── Teams ── cycle: teams ───────────────────────────────────────────────
    # Keyed by team slug. Emits team, team-member, and team-repo entries.
    teams:
      backend:
        description: Backend services team
        privacy: closed                    # secret (default) | closed
        parentTeamSlug: engineering        # nest under another team
        previously: platform-be            # rename hint: collapses
                                           # delete(platform-be)+create(backend)
                                           # into one update; never sent to GitHub
        members:                           # absent -> membership not managed
          - login: alice
            role: maintainer               # member (default) | maintainer
        repos:                             # absent -> repo access not managed
          - name: api
            permission: push               # pull | triage | push | maintain | admin

    # ── Repositories ── keyed by repo name (no org prefix) ──────────────────
    repos:
      api:
        # ── Repo settings ── cycle: repo-settings ───────────────────────────
        description: Public API service
        websiteUrl: https://api.example.com
        private: true
        hasIssues: true
        hasProjects: false
        hasWiki: false
        defaultBranch: main
        allowSquashMerge: true
        allowMergeCommit: false
        allowRebaseMerge: true
        deleteBranchOnMerge: true
        topics:                            # replaced as a whole list (PUT)
          - service
          - api

        # ── Classic branch protection ── cycle: branch-protection ───────────
        # One entry per branch pattern. The live-state probe only resolves
        # literal branch names; wildcard patterns exist on GitHub but are not
        # discovered (see the cycles doc).
        branchProtection:
          - pattern: main
            requirePullRequestReviews: true
            requiredApprovingReviewCount: 1
            dismissStaleReviews: true
            requireCodeOwnerReviews: false
            requireStatusChecks: true
            requiredStatusCheckContexts:
              - ci
            requireBranchesToBeUpToDate: true
            restrictPushes: false
            allowForcePushes: false
            allowDeletions: false
            requireLinearHistory: true

        # ── Repo rulesets ── cycle: rulesets ────────────────────────────────
        # The modern branch-protection replacement. bypassActors / conditions /
        # rules use GitHub's native snake_case API shape, forwarded verbatim.
        rulesets:
          - name: protect-main
            target: branch                 # branch | tag | push
            enforcement: active            # active | evaluate | disabled
            conditions:
              ref_name:
                include:
                  - "~DEFAULT_BRANCH"
                                           # omit exclude entirely: the diff
                                           # treats absent and [] as equal.
                                           # A non-empty flow-style value
                                           # still needs JSON.
            rules:
              - type: pull_request

        # ── Security features ── cycle: security-features ───────────────────
        # GHAS-gated features that your plan lacks surface as reported failed
        # entries, not a crashed run.
        security:
          advancedSecurity: false          # GHAS license required
          secretScanning: true
          secretScanningPushProtection: true
          vulnerabilityAlerts: true
          dependabotSecurityUpdates: true

        # ── Deployment environments ── cycle: environments ──────────────────
        environments:
          - name: production
            waitTimer: 10                  # minutes, 0-43200
            preventSelfReview: true
            reviewers:                     # users/teams by numeric GitHub id
              - type: Team                 # User | Team
                id: 42
            deploymentBranchPolicy:        # object configures; null disables;
              protectedBranches: true      # absent -> not managed
              customBranchPolicies: false

        # ── Repo Actions secrets/variables ── cycle: secrets-variables ──────
        secrets:                           # PRESENCE only; values never touched
          - name: DEPLOY_KEY
            rotationRef: OPS-123           # informational; never sent or diffed
        variables:                         # values are not secret: fully reconciled
          - name: SERVICE_TIER
            value: gold

        # ── Dependabot config file ── cycle: dependency-hygiene ─────────────
        # Ensures .github/dependabot.yml exists and matches `content` exactly.
        # content is multi-line and the built-in YAML reader has no multi-line
        # scalars (and does not process \n escapes) — author this slice in JSON.
        dependabot:
          content: "..."                   # exact file text; use JSON here

    # ── Org rulesets ── cycle: rulesets ─────────────────────────────────────
    rulesets:
      - name: org-default-protection
        target: branch
        enforcement: active
        conditions:
          ref_name:
            include:
              - "~DEFAULT_BRANCH"
        rules:
          - type: pull_request

    # ── Org Actions secrets/variables ── cycle: secrets-variables ───────────
    secrets:
      - name: ORG_DEPLOY_TOKEN             # presence only; provision out-of-band
    variables:
      - name: ENVIRONMENT
        value: production
        visibility: all                    # all (default) | private | selected
                                           # (org-level create only)

    # ── Repo provisioning ── cycle: repo-baseline ───────────────────────────
    # Ensures declared repos EXIST (creates missing ones, optionally from a
    # template). Never updates or deletes a repo.
    repoBaselines:
      - name: new-service
        template: my-org/service-template  # "owner/repo"; omit for an empty repo
        private: true                      # default true

    # ── Fine-grained PAT sweep ── cycle: token-governance ───────────────────
    # GitHub App auth required. Revokes a grant's ORG ACCESS on violation;
    # PATs themselves cannot be rotated or created via the API.
    tokenPolicy:
      revokeExpired: true                  # default true
      maxLifetimeDays: 90                  # 1-366; older grants revoked
      maxIdleDays: 60                      # staler grants revoked

    # ── Pending PAT requests ── cycle: token-approval ───────────────────────
    # GitHub App auth required. Approves a request only when EVERY requested
    # permission (flattened to group:scope) is allowed; otherwise `default`.
    tokenApproval:
      allowedPermissions:
        - repository:contents
        - repository:metadata
      default: manual                      # deny | manual (default manual)

    # ── Machine users ── consumed by `report --identity` ────────────────────
    # Operator-declared service-account logins; the identity report flags any
    # that consume an org seat and suggests migrating them to GitHub Apps.
    machineUsers:
      - ci-bot
      - deploy-bot

The smallest useful policy is one org with one managed slice, for example just settings.defaultRepositoryPermission; everything else then stays unmanaged.

Field reference

In the tables below, Required / default describes what the loader or cycle enforces, and Cycle names the reconcile cycle (from --cycles) that consumes the field.

orgs (top level)

Field Type Required / default Cycle Meaning
orgs map of org login → org config required all Organizations to manage. Each cycle runs once per org.
orgs.<org>.owned bool or list of resource type strings absent → nothing owned, no deletes planned all (diff layer) true = every reconciled collection in this org is owned and live resources missing from the policy are planned for deletion; a list (e.g. [team, repo]) owns only those change-set resource types. A programmatic diffOptions.isOwned overrides it. Guardrails still gate the apply.

orgs.<org>.settings (org settings)

Field Type Required / default Cycle Meaning
description string not managed org-settings Public description on the org profile.
email string not managed org-settings Public org email.
websiteUrl string not managed org-settings Org website (GitHub's blog field).
membersCanCreatePublicRepositories bool not managed org-settings Whether members can create public repos.
membersCanCreatePrivateRepositories bool not managed org-settings Whether members can create private repos.
membersCanCreateInternalRepositories bool not managed org-settings Internal repos (Enterprise only).
defaultRepositoryPermission none | read | write | admin not managed org-settings Base repo permission for all members.
requireTwoFactorAuthentication bool not managed org-settings Surfaced for drift reporting; GitHub treats the key as read-only on most plans.

orgs.<org>.members[] (org membership)

Field Type Required / default Cycle Meaning
login string required membership GitHub login.
role member | admin member membership Org role.

orgs.<org>.teams{} (teams, keyed by slug)

Field Type Required / default Cycle Meaning
description string not managed teams Team description.
privacy secret | closed not managed (GitHub default secret) teams Team visibility.
parentTeamSlug string not managed teams Parent team for nesting.
previously string not managed teams Former slug; a rename hint for the guardrail layer only, never written to GitHub.
members[] list absent → membership not managed teams { login (required), role: member (default) \| maintainer }.
repos[] list absent → repo access not managed teams { name (required), permission (required): pull \| triage \| push \| maintain \| admin }.

orgs.<org>.repos{} (repository settings, keyed by repo name)

Field Type Required / default Cycle Meaning
description string not managed repo-settings Repo description.
websiteUrl string not managed repo-settings Repo website (GitHub's homepage).
private bool not managed repo-settings Repo visibility.
hasIssues / hasProjects / hasWiki bool not managed repo-settings Feature toggles.
defaultBranch string not managed repo-settings Default branch name.
allowSquashMerge / allowMergeCommit / allowRebaseMerge bool not managed repo-settings Allowed merge methods.
deleteBranchOnMerge bool not managed repo-settings Auto-delete head branches after merge.
topics list of string not managed repo-settings Replaced as a whole list (full-replacement PUT), not merged.
branchProtection[] list not managed branch-protection See next table.
rulesets[] list not managed rulesets Repo rulesets; see the rulesets table.
security object not managed security-features See the security table.
environments[] list not managed environments See the environments table.
secrets[] / variables[] list not managed secrets-variables Repo-level Actions secrets (presence only) / variables.
dependabot object not managed dependency-hygiene { content (required) } — exact desired text of .github/dependabot.yml.

repos.<name>.branchProtection[] (classic branch protection)

Field Type Required / default Cycle Meaning
pattern string required branch-protection Branch name. Live probing resolves literal names only; wildcard rules are not discovered.
requirePullRequestReviews bool not managed branch-protection Require PR reviews before merge.
requiredApprovingReviewCount int not managed (falls back to live, then 1) branch-protection Approvals required.
dismissStaleReviews bool not managed branch-protection Dismiss stale reviews on new commits.
requireCodeOwnerReviews bool not managed branch-protection Require code-owner review.
requireStatusChecks bool not managed branch-protection Require status checks.
requiredStatusCheckContexts list of string not managed branch-protection Required check contexts.
requireBranchesToBeUpToDate bool not managed branch-protection The strict flag.
restrictPushes bool not managed branch-protection Restrict who can push (empty user/team lists).
allowForcePushes bool not managed branch-protection Allow force pushes.
allowDeletions bool not managed branch-protection Allow branch deletion.
requireLinearHistory bool not managed branch-protection Forbid merge commits.

The GitHub PUT for this endpoint is a full replacement, so the apply path does a read-modify-write: it echoes every undeclared live field back (including enforce_admins, which the schema does not expose) and overlays only what you declared.

rulesets[] at org (orgs.<org>.rulesets) and repo (repos.<name>.rulesets) level

Field Type Required / default Cycle Meaning
name string required rulesets Identity key within its scope (org or repo).
target branch | tag | push GitHub defaults branch on create rulesets What the ruleset targets.
enforcement active | evaluate | disabled not managed rulesets Enforcement level.
bypassActors list of object not managed rulesets GitHub-native shape: { actor_id, actor_type, bypass_mode }. Forwarded verbatim.
conditions object not managed rulesets GitHub-native shape, e.g. { ref_name: { include, exclude } }.
rules list of object not managed rulesets GitHub-native shape: [{ type, parameters? }].

A managed ruleset is authored as a unit: its declared rules / conditions / bypassActors are the source of truth for that ruleset. Undeclared rulesets are never touched.

repos.<name>.security (security features)

Field Type Required / default Cycle Meaning
advancedSecurity bool not managed security-features GHAS (security_and_analysis.advanced_security). Needs a GHAS license.
secretScanning bool not managed security-features Secret scanning. GHAS-gated on private repos.
secretScanningPushProtection bool not managed security-features Push protection for secret scanning.
vulnerabilityAlerts bool not managed security-features Dependabot alerts (vulnerability-alerts endpoint).
dependabotSecurityUpdates bool not managed security-features Automated security fixes (automated-security-fixes endpoint).

repos.<name>.environments[] (deployment environments)

Field Type Required / default Cycle Meaning
name string required environments Identity key within the repo.
waitTimer int (0–43200) not managed environments Minutes before a deployment can proceed.
preventSelfReview bool not managed environments Actor cannot approve their own deployment.
reviewers[] list not managed environments { type: User \| Team, id: <numeric GitHub id> }. Compared by id, so use the ids the API returns.
deploymentBranchPolicy object or null not managed environments { protectedBranches, customBranchPolicies } (at most one true); explicit null disables the policy.

secrets[] (Actions secrets, org and repo level)

Field Type Required / default Cycle Meaning
name string required secrets-variables Identity key within its scope.
rotationRef string not managed secrets-variables Informational rotation pointer (ticket / KMS ref). Never sent to GitHub, never diffed.

warden manages secret presence only. It never reads or writes secret values; a declared-but-missing secret surfaces as an error telling you to provision it out-of-band, and an undeclared live secret is removed only when ownership-gated.

variables[] (Actions variables, org and repo level)

Field Type Required / default Cycle Meaning
name string required secrets-variables Identity key within its scope.
value string absent → presence-only secrets-variables Reconciled fully (create/update) when declared.
visibility all | private | selected all secrets-variables Org-level create only; ignored for repo variables.

repoBaselines[] (repo provisioning)

Field Type Required / default Cycle Meaning
name string required repo-baseline Repo that must exist (no org prefix).
template string (owner/repo) absent → empty repo repo-baseline Create from this template when missing.
private bool true repo-baseline Visibility of a newly created repo.

Existence-only: this cycle creates missing repos and never updates or deletes one. Per-repo settings belong under repos.

tokenPolicy (fine-grained PAT governance)

Field Type Required / default Cycle Meaning
revokeExpired bool true token-governance Revoke org access of an expired grant still listed.
maxLifetimeDays int (1–366) not enforced when absent token-governance Grants older than this lose org access.
maxIdleDays int not enforced when absent token-governance Grants unused for longer than this lose org access.

Violations are modeled as updates ("revoke org access"), not deletes, so a routine sweep does not trip the removal guardrail; the expiry check uses GitHub's own expired flag. GitHub App auth is required here. The API cannot create or rotate a user's PAT; revoking its org access is the only enforcement lever.

tokenApproval (pending PAT requests)

Field Type Required / default Cycle Meaning
allowedPermissions list of string absent → nothing auto-approved token-approval Permission names as group:scope (e.g. repository:contents). A request is approved only when every requested permission is listed.
default deny | manual manual token-approval What happens to a request that is not auto-approved.

Like the token sweep, this cycle works only with GitHub App auth. Admins can only approve or deny; the requested repo scope cannot be changed.

machineUsers[] (service-account inventory)

Field Type Required / default Consumer Meaning
machineUsers list of string not managed report --identity Operator-declared machine logins. Seat-consuming ones are flagged with a recommendation to migrate to GitHub Apps (Apps consume no seat). Not a reconcile cycle.

What the policy does not declare

  • Guardrail thresholds. removalDeltaCap caps deletes per resource type. For each type in the plan (teams, members, environments, org variables, …), the deletes of that type may not exceed 25% of that type's own live entries in the collections the policy declares, and live entries of one type never dilute another's fraction, so many live repos cannot make an environment wipe look small. A type with no live count to measure against is measured against its own planned non-create entries instead. Set the threshold with --removal-cap-fraction <value> on reconcile (a number in (0,1]) or programmatically via runReconcile({ guardrails: { removalDeltaCap } }); a value outside (0,1] is rejected rather than silently disabling the cap. This paragraph is the canonical description of the cap, referenced from CLI.md and CYCLES.md. adminFloor (at least 2 org admins must remain, by default), requiredAdmins, and requireSelf are configured programmatically (runReconcile({ guardrails })), not in the policy file. The CLI runs with the defaults; --allow-guardrail-override applies anyway when one trips.
  • Secret values. Only secret presence is declared; values are provisioned out-of-band.
  • Auth. App/installation IDs, tokens, and private keys come from environment variables named on the command line, never from the policy. See SETUP.md.