Cycles
A cycle reconciles one resource domain. It fetches live state from the Forgejo
API and builds the desired slice from the policy, then diffs the two into a
change set and (in apply mode) applies each entry. Cycles run in registry
order; select a subset with --cycles (CLI.md).
Shared behavior, so it isn't repeated eight times:
- Selective-by-omission. An absent field or collection is never read for mutation, diffed, or changed: a slice absent from the policy produces an empty desired state, so the cycle proposes nothing for it.
- Ownership-gated deletes. 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. An org's
owned:declaration in the policy (true, or a list of resource types; see POLICY.md, "Delete semantics") marks resources owned; a programmaticdiffOptions.isOwnedpredicate passed torunReconcileoverrides the declaration when supplied. The per-cycle delete behavior below applies only in orgs that opted in. - Guardrails before apply. Chant's
removalDeltaCaprefuses an apply whose deletes exceed 25% (or--removal-cap-fraction) of any single resource type's live managed entries; see POLICY.md, "The removal cap". A team rename declared withpreviously:is planned as a single update, so a rename is not counted as a deletion. A tripped guardrail blocks the apply (exit 1) unless--allow-guardrail-overrideis set. - Request budget. A run has a shared budget of 1000 API requests. On
exhaustion the run stops cleanly and prints
DEFERRED (budget): <cycles>to stderr; run again (or narrow--cycles) to finish. List endpoints paginate at 50 per page, and each page costs one budget unit. - Keying. Collections are keyed by a stable logical key (name, URL, or rule name), never by Forgejo's numeric ids. Teams and webhooks are the resources Forgejo itself addresses by numeric id; for those the live snapshot carries the id and the apply path uses it.
org-settings
Reconciles org-level settings from orgs.<org>.settings.
- Read:
GET /orgs/{org}. The apply isPATCH /orgs/{org}with only the declared fields; the PATCH is a partial update, so no read-modify-write is needed. The managed fields arefullName,description,website,location,visibility(public|limited|private), andrepoAdminChangeTeamAccess. - The change set holds a single entry keyed
org-settingsper org, with nothing to delete: settings are only ever patched. - A 404 on the org (the token can't see it, or it doesn't exist) yields an empty live state; the plan then shows an entry that looks like a create, and its PATCH will fail visibly rather than silently skipping.
membership
Asserts the org member inventory from orgs.<org>.members, and by design it
can only remove.
- Read:
GET /orgs/{org}/members(paginated). The apply isDELETE /orgs/{org}/members/{username}for delete entries; updates are impossible because a member has no fields. - Entries are keyed by
username, and the delete candidates are unlisted members in an org that ownsmember. - Forgejo org membership is team-driven: there is no "add user to org"
endpoint, and a user becomes a member by joining a team. A
createentry for a member therefore fails loudly with a message pointing at theteamscycle (the run continues, and the entry lands in the cycle'sfailedlist). Day to day, useteams.<name>.membersto add people, and use themembers:list to assert the expected roster and (in an org that ownsmember) remove stragglers who are no longer in any declared team.
teams
Team CRUD plus two subresources, team membership and team repo access, from
orgs.<org>.teams.
- Read:
GET /orgs/{org}/teams(paginated), then per teamGET /teams/{id}/membersandGET /teams/{id}/repos. - A team create is
POST /orgs/{org}/teamsfollowed by inlinePUT /teams/{id}/members/{username}andPUT /teams/{id}/repos/{org}/{repo}for the declared members and repos (the diff emits no separate child entries for a not-yet-live team). A team update isPATCH /teams/{id}with the declared fields plus the team name (Forgejo's edit-team API requiresname, which also carries a rename), and a team delete isDELETE /teams/{id}. Child entries add or remove withPUT/DELETE /teams/{id}/members/{username}andPUT/DELETE /teams/{id}/repos/{org}/{repo}. - Teams are keyed by name (the
teams:map key); child entries carry the keys{team}/{username}and{team}/{repo}. Forgejo addresses teams by numeric id, so the apply path reads the id off the live snapshot. A child entry only knows the team name, so it resolves name to id viaGET /orgs/{org}/teams/searchwith an exact match on the result (Forgejo has no by-name team endpoint). Deleting a team or a child entry requires the org to own those types. - Rename: a team entry with
previously: <old-name>is an explicit rename. When a live team by the old name exists and none by the new name does, the diff plans a single update (the rename) against that live team. There is no delete plus create and noownedrequirement, and the team id and its memberships survive. For the team itself the diff comparesdescriptionandpermissionalong withcanCreateOrgRepo,includesAllRepositories, andunits. Units are compared as a set, since Forgejo does not guarantee unit order in responses.
repo-settings
Manages settings and topics of existing org repos from orgs.<org>.repos.
- Read:
GET /orgs/{org}/repos(paginated). Drift is fixed throughPATCH /repos/{org}/{repo}carrying just the declared fields; whentopicsis declared it also appliesPUT /repos/{org}/{repo}/topicsas a full replacement. - Entries are keyed by repo name, the
repos:map key. The cycle never deletes a repo (warden has no repo deletion anywhere), and it never creates one either: a declared repo that doesn't exist live shows up as acreatewhose PATCH 404s into the cycle'sfailedlist, an honest "this repo doesn't exist yet" signal. Provision the repo with therepo-baselinecycle instead. - The scalar diff covers
description,website,private,hasIssues,hasWiki,hasPullRequests,defaultBranch,allowMergeCommits,allowRebase,allowSquashMerge,defaultMergeStyle; declaredtopicsare compared order-insensitively.
branch-protection
Reconciles Forgejo branch protections (branch_protections) per repo, from
repos.<name>.branchProtection. Forgejo has no GitHub-style rulesets; a
protection rule applies to a branch name or glob.
- Read:
GET /orgs/{org}/repos, then per repoGET /repos/{org}/{repo}/branch_protections. A create isPOST /repos/{org}/{repo}/branch_protectionswithrule_namein the body, an update isPATCH /repos/{org}/{repo}/branch_protections/{rule}, and a delete isDELETEon the same path. - The applies use the change-set key
{repo}/{ruleName}, and a live rule absent from the policy becomes a delete candidate in an org that ownsbranch-protection. - A rule is diffed on five booleans (
enablePush,requireSignedCommits,enableStatusCheck,blockOnOutdatedBranch,dismissStaleApprovals) and therequiredApprovalsnumber;statusCheckContextsis compared without regard to order. This cycle owns only thebranchProtectionslice of each repo; the repo's scalar settings belong torepo-settings, so the two cycles never fight over the same fields.
repo-baseline
Provisions repos from orgs.<org>.repoBaselines: it ensures the named repos
exist in the org, and it only ever creates.
- Read:
GET /orgs/{org}/reposfor names only, an existence check. For a baseline whose repo is missing,template: owner/repoturns the apply intoPOST /repos/{owner}/{repo}/generate(generate from the template into the org, withgit_contentenabled so the template's files come along); otherwise the apply isPOST /orgs/{org}/reposfor an empty repo, andprivatedefaults totruefor the new repo. - The change-set key is the baseline's repo name. The diff emits a
createonly when the repo is absent and nothing else, so existing repos stay untouched regardless of how they differ from the baseline; ongoing settings management belongs torepo-settings.
secrets-variables
Reconciles Forgejo Actions secrets and variables at both org and repo scope,
from secrets/variables at the org level and under repos.<name>.
- Read:
GET /orgs/{org}/actions/secretsandGET /orgs/{org}/actions/variables, thenGET /orgs/{org}/reposand per repoGET /repos/{org}/{repo}/actions/secretsand.../actions/variables. A secret create or update isPUT .../secrets/{name}; a variable create isPOST .../variables/{name}and a variable update isPUT .../variables/{name}; a delete of either kind is aDELETEon the same path. - Everything here is keyed by
name, with repo entries using/repos/{org}/{repo}/actions/...paths and the key{repo}/{name}. In an org that owns the secret and variable types, unlisted live entries are removed. - Secrets come with a quirk: the Forgejo API is write-only for their values,
so warden reconciles presence only. A value is never read back or diffed,
so a drifted secret value is invisible to warden. On create the value
comes from the environment variable
FORGEJO_SECRET_<NAME>at apply time; if that variable is unset an empty placeholder is written, and the operator is expected to set the real value out-of-band. - Variables behave differently from secrets: their values are not secret,
and they are reconciled fully. Declaring
valuecorrects drift; omitting it makes the variable presence-only, and a create then writes an empty string.
webhooks
Reconciles org and repo webhooks together, from webhooks at the org level
and under repos.<name>.
- Read:
GET /orgs/{org}/hooks, followed byGET /orgs/{org}/reposand each repo'sGET /repos/{org}/{repo}/hooks. A create is aPOSTto the base (/orgs/{org}/hooksor/repos/{org}/{repo}/hooks), whose body includestype(defaultforgejo) andconfig.content_type(defaultjson); an update isPATCH .../hooks/{id}and a delete isDELETE .../hooks/{id}. - Hooks are matched by
url, with repo hooks keyed{repo}/{url}. Forgejo's numeric hookidrides on the live snapshot and drives update and delete; a hook whose live id can't be found fails that entry rather than guessing. Undeclared live hooks become delete candidates where the org owns the webhook types. - The hook diff compares
typeandcontentTypetogether withactiveandbranchFilter, and it treatseventsas an unordered set. Changing a hook'surlchanges its identity: the plan shows a create for the new URL and, where webhook types are owned, a delete for the old one. There is nopreviously:alias for webhooks.