Skip to content

Policy

The policy is the foundation of this tool: one YAML (or JSON) file with an orgs: map that declares the desired state of each Forgejo organization. Everything else (flags, tokens, cycles) just serves the policy. It is the one file you must author.

  • Warden loads it via the --config path (see CLI.md).
  • Schema (authoritative): src/config/types.ts (GovernanceConfig / OrgConfig).
  • Selective-by-omission: an absent field or collection is never read for mutation, diffed, or changed. This holds for every field, all of them optional. Declaring settings: {description: ...} manages the description and nothing else.

Each slice of the policy is consumed by exactly one reconcile cycle (see CYCLES.md); --cycles runs a subset without editing the policy.

Delete semantics (read this before trusting a plan)

Deletes are ownership-gated: the diff proposes deleting a live entry missing from the policy only when that entry's collection is marked owned (the isOwned predicate), and by default nothing is owned: a run creates and updates but never deletes. Ownership is declared per org with the owned: field.

With owned absent or false (the default), any live entry the policy doesn't mention is left alone whatever its type, and removing an entry from the policy stops managing it rather than removing it live. Declaring owned: true on an org makes warden own every resource collection it reconciles there, so a live entry missing from the policy is planned as a delete; owned: [type, ...] limits ownership to the listed resource types. The type strings are the change-set entry types (RESOURCE_TYPE_ORDER in src/reconcile/diff.ts): org-secret, org-variable, org-webhook, team, team-member, team-repo, member, branch-protection, repo-webhook, repo-secret, repo-variable. Types that never emit deletes (org-settings, repo, repo-baseline) are inert in this list.

When forgejo-warden is embedded as a library, a caller-supplied diffOptions.isOwned on runReconcile takes precedence over the policy's owned declarations.

The removal cap

Owned deletes still run a guardrail before any apply: chant's removalDeltaCap refuses an apply whose deletes exceed a threshold fraction (default 0.25; --removal-cap-fraction sets it, valid values in (0,1]) of the live managed entries — evaluated per collection. While diffing, warden counts the live entries of each delete-capable collection the policy declares, and every resource type's deletes divide by that type's own live count. Live entries of one type therefore never dilute a wipe of another: deleting 3 of 4 teams reads as 75% even with 20 team members live beside them. A converged plan whose only entry is one stale delete still passes (1 of N live entries, not 1 of 1 planned), and a type with no live entries falls back to a plan-relative count for that type. A team rename declared with previously: is planned as a single update and never counts as a delete. A tripped cap blocks the apply (exit 1) unless --allow-guardrail-override is set.

A complete policy

Copy this and trim it to what you need. Every field from src/config/types.ts is shown. Comments name the cycle that consumes each slice.

orgs:
  # One entry per Forgejo organization, keyed by org name.
  acme:

    # ----- delete opt-in (consulted by every cycle's diff) -----
    # Which resource collections warden OWNS in this org. Absent or false
    # (the default): the plan never contains deletes for this org. true:
    # warden owns everything it reconciles here, so live entries missing from
    # this policy are planned as deletes. Or list specific resource types:
    #   owned: [team, team-member, org-webhook]
    # See "Delete semantics" above for the type strings.
    owned: false

    # ----- org-settings cycle: PATCH /orgs/{org} (partial update) -----
    settings:
      fullName: ACME Corporation
      description: Internal engineering org
      website: https://acme.example.com
      location: Rotterdam
      visibility: limited            # public | limited | private
      repoAdminChangeTeamAccess: false  # may repo admins change team access?

    # ----- membership cycle: org member inventory -----
    # Forgejo org membership is team-driven: there is no "add org member" API.
    # This cycle can only REMOVE members (and only in an org that owns
    # `member`, see `owned:` above); to add someone, put them in a team below.
    # Declaring this list asserts "these are the members I expect".
    members:
      - username: alice
      - username: bob

    # ----- teams cycle: teams + their members and repo access -----
    teams:
      platform:                      # key = team name
        description: Platform engineers
        permission: write            # read | write | admin | owner
        canCreateOrgRepo: false
        includesAllRepositories: false
        units:                       # enabled units for the team
          - repo.code
          - repo.issues
          - repo.pulls
        members:
          - username: alice
          - username: bob
        repos:                       # repos the team's permission applies to
          - name: api
          - name: infra
        previously: platform-eng     # former name: turns a rename into an
                                     # update instead of a delete + create

    # ----- repos: a map of per-repo desired state. Four cycles read it. -----
    repos:
      api:                           # key = repo name (must already exist;
                                     # provisioning is repoBaselines, below)

        # -- repo-settings cycle: PATCH /repos/{org}/{repo} + PUT .../topics --
        description: The ACME API
        website: https://api.acme.example.com
        private: true
        hasIssues: true
        hasWiki: false
        hasPullRequests: true
        defaultBranch: main
        allowMergeCommits: false
        allowRebase: true
        allowSquashMerge: true
        defaultMergeStyle: squash    # merge | rebase | rebase-merge | squash
        topics: [api, golang]        # full replacement when declared

        # -- branch-protection cycle: Forgejo branch_protections, keyed by
        #    ruleName (Forgejo's rule_name; a branch name or glob) --
        branchProtection:
          - ruleName: main
            enablePush: false
            requireSignedCommits: true
            requiredApprovals: 2
            enableStatusCheck: true
            statusCheckContexts: [ci/test, ci/lint]
            blockOnOutdatedBranch: true
            dismissStaleApprovals: true
          - ruleName: "release/*"
            enablePush: false
            requiredApprovals: 1

        # -- webhooks cycle (repo scope), keyed by url --
        webhooks:
          - url: https://ci.acme.example.com/hooks/api
            type: forgejo            # forgejo | gitea | slack | ...; default forgejo
            contentType: json        # json | form; default json
            events: [push, pull_request]
            active: true
            branchFilter: "main"

        # -- secrets-variables cycle (repo scope) --
        secrets:                     # Actions secrets: presence only.
          - name: DEPLOY_KEY         # Value never read; written from
                                     # $FORGEJO_SECRET_DEPLOY_KEY at apply time
                                     # (empty placeholder if unset).
        variables:                   # Actions variables: values reconciled fully.
          - name: DEPLOY_ENV
            value: production

    # ----- repo-baseline cycle: repos that must EXIST in the org -----
    # Existence-only: creates a missing repo, never updates or deletes an
    # existing one. Settings for it belong under repos: above.
    repoBaselines:
      - name: api
        private: true                # default true
      - name: new-service
        template: acme/service-template   # "owner/repo": generate from template
        private: true

    # ----- secrets-variables cycle (org scope) -----
    secrets:
      - name: NPM_TOKEN              # presence only, as with repo secrets
    variables:
      - name: REGISTRY
        value: registry.acme.example.com

    # ----- webhooks cycle (org scope), keyed by url -----
    webhooks:
      - url: https://audit.acme.example.com/hooks/org
        type: forgejo
        contentType: json
        events: [repository, organization]
        active: true

The smallest valid policy is orgs: {} (manages nothing). The smallest useful one declares a single slice, such as settings: for one org.

Field reference

orgs{} (top level)

The whole document hangs off one key. Everything else nests under it.

Field Type Required / default Cycle Meaning
orgs map of org name → org config required all organizations to manage; the key is the Forgejo org name used in API paths

orgs.<org> (org config)

Each entry under orgs: holds one organization's desired state. All of its fields are optional, per the selective-by-omission rule.

Field Type Required / default Cycle Meaning
owned boolean or list of type strings optional; default absent (no deletes) all delete opt-in: which resource collections warden owns in this org — see "Delete semantics" above
settings object optional org-settings org-level settings (below)
members list optional membership expected org members (presence; removal-only, see below)
teams map of name → team optional teams teams with members and repo access
repos map of name → repo optional repo-settings, branch-protection, webhooks, secrets-variables per-repo desired state (each cycle reads its own slice)
repoBaselines list optional repo-baseline repos that must exist (create-only provisioning)
secrets list optional secrets-variables org-level Actions secrets (presence only)
variables list optional secrets-variables org-level Actions variables (name + value)
webhooks list optional webhooks org-level webhooks, keyed by url

settings (org-settings cycle)

This slice is applied as a partial PATCH /orgs/{org} with only the declared keys. Anything you leave out keeps its live value.

Field Type Required / default Meaning
fullName string optional display name (full_name)
description string optional org description
website string optional org website URL
location string optional org location
visibility public | limited | private optional org visibility. limited = visible to signed-in users only (Forgejo-specific; GitHub has no equivalent)
repoAdminChangeTeamAccess boolean optional whether repo admins may change team access to their repos

members[] (membership cycle)

Field Type Required / default Meaning
username string required expected org member

Forgejo has no direct "add org member" endpoint; membership is a consequence of team membership. This cycle therefore only removes members (an unlisted member, in an org that owns member) and fails loudly, with a pointer at the teams cycle, if the diff asks it to add one. There is no role field here; permission is expressed through teams.

teams{} (teams cycle)

Keyed by team name. On create, members and repos are applied inline with the new team; on an existing team they reconcile as separate child entries.

Field Type Required / default Meaning
description string optional team description
permission read | write | admin | owner optional access level the team grants on its repos
canCreateOrgRepo boolean optional may team members create org repos
includesAllRepositories boolean optional team has access to every org repo
units list of string optional enabled units, e.g. repo.code, repo.issues, repo.pulls
members list of {username} optional team members (presence)
repos list of {name} optional org repos the team has access to (presence)
previously string optional former team name — an explicit rename intent, no owned needed: when a live team by the old name exists (and none by the new name), the plan is a single update that keeps the team id and its memberships

repos{} scalar fields + topics (repo-settings cycle)

Scalars are applied as a partial PATCH /repos/{org}/{repo}; topics is a separate full-replacement PUT /repos/{org}/{repo}/topics. This cycle never creates a repo. A declared repo missing live surfaces as a failed entry ("this repo doesn't exist yet"); provision it with repoBaselines.

Field Type Required / default Meaning
description string optional repo description
website string optional repo website URL
private boolean optional repo is private
hasIssues boolean optional issues enabled
hasWiki boolean optional wiki enabled
hasPullRequests boolean optional pull requests enabled
defaultBranch string optional default branch name
allowMergeCommits boolean optional merge commits allowed
allowRebase boolean optional rebase merges allowed
allowSquashMerge boolean optional squash merges allowed
defaultMergeStyle string optional merge | rebase | rebase-merge | squash
topics list of string optional repo topics; compared order-insensitively, applied as full replacement

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

Forgejo uses branch protections (its branch_protections API), not GitHub-style rulesets. Entries are keyed by ruleName (Forgejo's rule_name); the key holds a branch name or a glob such as release/*.

Field Type Required / default Meaning
ruleName string required (identity key) rule name / branch glob the rule applies to
enablePush boolean optional allow direct pushes to matching branches
requireSignedCommits boolean optional require signed commits
requiredApprovals number optional required PR approvals
enableStatusCheck boolean optional require status checks
statusCheckContexts list of string optional required status-check contexts (order-insensitive compare)
blockOnOutdatedBranch boolean optional block merge when the branch is behind
dismissStaleApprovals boolean optional dismiss approvals on new pushes

webhooks[] and repos.<name>.webhooks[] (webhooks cycle)

Both the org-level list and the per-repo list share this shape, keyed by url. Forgejo addresses a hook by numeric id internally; warden tracks the live id for you and updates or deletes by it.

Field Type Required / default Meaning
url string required (identity key) delivery URL
type string default forgejo on create hook type, e.g. forgejo, gitea, slack
contentType json | form default json payload content type
events list of string optional events to deliver (order-insensitive compare)
active boolean optional hook enabled
branchFilter string optional branch filter glob

secrets[] and repos.<name>.secrets[] (secrets-variables cycle)

Secrets are write-only in the Forgejo API, so warden reconciles presence only. A listed secret missing live is created, and its value comes from the environment variable FORGEJO_SECRET_<NAME> at apply time (an empty placeholder is written if that variable is unset; set the real value out-of-band). Values are never read back or diffed. A changed secret value is therefore invisible to warden.

Field Type Required / default Meaning
name string required (identity key) Actions secret name

variables[] and repos.<name>.variables[] (secrets-variables cycle)

Variables differ from secrets in that their values can be read back, so a declared value gets drift-corrected on every run. The same fields work at org and repo scope.

Field Type Required / default Meaning
name string required (identity key) Actions variable name
value string optional desired value. Declared: reconciled fully (drift is corrected). Omitted: presence only; a create writes an empty string

repoBaselines[] (repo-baseline cycle)

This slice is existence-only provisioning: a listed repo missing from the org is created, and existing repos are never updated or deleted by this cycle. Pair a baseline with an entry under repos: to also manage its settings.

Field Type Required / default Meaning
name string required repo that must exist in the org
template string (owner/repo) optional generate the new repo from this template (POST /repos/{owner}/{repo}/generate); omitted: create an empty repo
private boolean default true whether the newly created repo is private

What a plan looks like

--mode dry-run (the default) prints one plan per cycle per org in a stable order, listing creates and updates (with field-level before/after) as well as deletes in orgs marked owned. Nothing is written. --mode apply performs the same diff, runs the guardrails, and then applies each entry; see CLI.md.