Cycles
A cycle is one reconcile domain. Each cycle knows how to read live state from
GitHub (fetchLive), derive the desired slice from the policy
(buildDesired), and apply one change entry back (apply). The runner wraps
every cycle with the same diff, guardrail, and dry-run/apply machinery.
--cycles accepts the names below (from src/cli/registry.ts); omitting the
flag runs all of them, in this order.
Shared behavior, so it isn't repeated thirteen times:
- Selective-by-omission. An absent field or collection is never read for mutation, diffed, or changed: a cycle only acts on the slice of the policy it owns. A repo, team, or field the policy leaves out is never touched.
- 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
owneddeclaration in the policy (true, or a list of resource types; see POLICY.md) marks resources owned; a programmaticdiffOptions.isOwnedpredicate overrides the declaration when supplied. - Guardrails before apply.
removalDeltaCaprefuses an apply whose deletes of any one resource type exceed 25% of that type's live managed entries — see POLICY.md for the canonical description of the cap and its--removal-cap-fractionthreshold.adminFloor(at least 2 org admins must remain) always runs too;requiredAdminsandrequireSelfrun when configured programmatically.resolveRenamescollapses apreviously-marked delete+create pair into an update first, so a rename is not counted as a deletion. A tripped guardrail blocks the apply (exit 1) unless--allow-guardrail-overrideis set. - Permission-gated reads are tolerated. A declared slice whose read comes
back 403 is tolerated and skipped, never fatal: the read yields no live
state, and the cycle's plan gains a NOTE line naming the slice
(
read was permission-gated (403); planned entries may fail on apply). A slice you declared anyway plans as a create, and its apply lands the 403 in that cycle'sfailed[]. With a narrow App grant the run still exits 0 and the notes list the slices the grant leaves uncovered. A 404 is a silent "nothing live". Any other read error still errors the cycle. - Request budget. A run has a shared budget of 1000 API requests. On
exhaustion the run stops cleanly and prints
DEFERRED cycles (budget exhausted): <cycles>to stderr; run again (or narrow--cycles) to finish. - Live-fetch scope. Cycles that read per-repo live state do so for repos
passed in their scope. The current CLI wiring passes no scope, so those
cycles skip the live fetch: every declared entry is planned as a create,
and the applies are idempotent writes (PUT/PATCH against the live
resource). Accurate drift detection for those cycles needs the programmatic
API with
scope: { repos: orgConfig.repos }.
branch-protection
Reconciles classic branch protection rules declared under
repos.<name>.branchProtection (one rule per branch pattern).
- Read:
GET /repos/{owner}/{repo}/branches/{branch}/protection, probed per declared pattern. A 404 on the probe means "no rule" and produces a create rather than an error. - The apply path is
PUT …/protectionon create and update, andDELETE …/protectionon delete. The PUT is a full replacement, so updates do a read-modify-write: the body is seeded from the live rule (from the diff'sbeforesnapshot, re-fetched if missing) and only declared fields are overlaid. Undeclared live settings, includingenforce_admins(not exposed in the schema), are preserved. - Change-set entries carry the key
{repo}/{pattern}. This cycle ends up planning no deletes, because the probe only reads declared patterns and an undeclared live rule is never discovered as a delete candidate. - Known limitation: the protection API resolves literal branch names only. A
wildcard rule like
release/*exists on GitHub but is never returned by the probe, sodumpomits it. Rulesets are the modern fix.
org-settings
Reconciles org-level settings (orgs.<org>.settings): public metadata such as
the description and website, member repo-creation privileges, the default
repository permission, plus the 2FA-requirement flag.
- Read:
GET /orgs/{org}. Apply:PATCH /orgs/{org}, a partial update that sends only declared keys, so no read-modify-write is needed. - There is a single settings entry per org, and nothing to delete: the one settings resource is only ever created or updated.
- GitHub treats
requireTwoFactorAuthenticationas read-only on most plans (the key is ignored rather than erroring), so warden surfaces it for drift reporting only.
repo-settings
Reconciles per-repo settings under repos.<name>: description, website,
visibility, issues/projects/wiki toggles, merge methods, default branch,
deleteBranchOnMerge, plus topics.
- Read:
GET /repos/{owner}/{repo}for the repos the policy declares. Apply: a partialPATCH /repos/{owner}/{repo}with only the declared fields, plusPUT /repos/{owner}/{repo}/topics(topics are replaced as a whole list). - Entries are keyed by repo name, the
repos:map key. Deleting repositories is out of scope for warden entirely: this cycle's apply path ignores delete entries outright, and since the live fetch only reads repos the policy declares, the diff has nothing undeclared to propose removing anyway. - A repo is never created here; a PATCH against a nonexistent repo 404s and
is recorded as a failed entry. Provisioning belongs to
repo-baseline.
membership
Reconciles org membership and roles (orgs.<org>.members): who is a member,
who is an admin.
- Read:
GET /orgs/{org}/members?role=admin|member(paginated, 100 per page). Apply:PUT/DELETE/orgs/{org}/memberships/{user}; the cycle adds or re-roles declared members by default. - Member entries are keyed by
login. Removal of an undeclared live member requires markingmemberowned (owned: trueorowned: [member, ...]on the org). Removals, once enabled, run the member-aware guardrails in full (adminFloor,requiredAdmins,requireSelf,removalDeltaCap);requireSelfmeans the managing identity must remain an org admin rather than a plain member. - The schema does not model outside collaborators (a per-repo concept), so they are out of scope.
teams
Reconciles the team tree, team membership/roles, and team-to-repo permissions
(orgs.<org>.teams, keyed by slug).
- Read:
GET /orgs/{org}/teamsandGET /orgs/{org}/teams/{slug}, plusGET …/{slug}/members?role=…andGET …/{slug}/repos. The team list is always fetched; member and repo sub-state only for teams in scope that manage them. - Team writes go through
POST /orgs/{org}/teamsfor creation andPATCH/DELETE/orgs/{org}/teams/{slug}afterwards. Team members go throughPUT/DELETE…/{slug}/memberships/{user}and team repo access throughPUT/DELETE…/{slug}/repos/{owner}/{repo}. The cycle emits three resource types in order (team,team-member,team-repo) so a new team exists before members and repos are attached. - Teams are keyed by slug; on create the slug is sent as the name (GitHub re-slugifies), on update the name is not sent, so an existing slug is never disturbed. Deleting a team, team member, or team repo requires ownership.
- Rename: a
previouslyslug makes the guardrail layer collapsedelete(old)+create(new)into one update, so a rename doesn't count towardremovalDeltaCap. When teams are not owned the delete half is never emitted anyway, so a rename appears purely as a create and the old team is left in place. The runner does not yet perform an atomic GitHub-side rename.
rulesets
Reconciles org rulesets (orgs.<org>.rulesets) and repo rulesets
(repos.<name>.rulesets), the modern replacement for classic branch
protection.
- Read:
GET /orgs/{org}/rulesetsandGET /orgs/{org}/rulesets/{id}, plus the analogous/repos/{owner}/{repo}/rulesetsendpoints. Each live ruleset costs a list page plus one detail GET so the diff can compare fullrules/conditions/bypassActors(all in GitHub's native snake_case shape, forwarded verbatim). - Apply:
POSTon create at either scope, withPUT …/rulesets/{id}for updates andDELETE …/rulesets/{id}for deletes. - Rulesets are matched by name, while GitHub's numeric id (carried on the live snapshot) addresses updates and deletes. Removing an undeclared live ruleset requires ownership as usual.
- Selective-by-omission operates at whole-ruleset granularity: a managed ruleset's declared body is the source of truth for that ruleset, and undeclared rulesets are never touched.
security-features
Reconciles the security toggles under repos.<name>.security: GHAS and secret
scanning (with push protection), plus Dependabot alerts and automated security
fixes.
- Read:
GET /repos/{o}/{r}(thesecurity_and_analysisobject),GET /repos/{o}/{r}/vulnerability-alerts(204 enabled / 404 disabled), andGET /repos/{o}/{r}/automated-security-fixes. The apply path isPATCH /repos/{o}/{r}for thesecurity_and_analysistoggles, plusPUT/DELETEon thevulnerability-alertsandautomated-security-fixesendpoints. - Each repo maps to one
repo-securityentry, which is only ever created or updated: the toggles are set, never removed, so this cycle has no delete path. - Degradation is license-gated and graceful. Where GHAS (or secret scanning on a private repo) is unavailable, GitHub rejects the enabling write and the cycle records a failed entry instead of crashing, so a mixed org reconciles what it can and reports the rest.
environments
Reconciles deployment environments under repos.<name>.environments: wait
timers, required reviewers, and deployment branch policies, along with
self-review prevention.
- Read:
GET /repos/{o}/{r}/environments. Apply:PUT/DELETE/repos/{o}/{r}/environments/{env}. The PUT replaces the environment configuration, so updates do a read-modify-write like branch-protection: seed from live, overlay declared fields. Declaring onlywaitTimerdoes not wipe reviewers or the branch policy. - Environments are keyed by name within the repo, and an environment is removed only when the org owns it.
- Reviewers are compared by numeric id (
{ type, id }), so author the same ids the API returns.
secrets-variables
Reconciles Actions secrets and variables at org level (orgs.<org>.secrets /
.variables) and repo level (repos.<name>.secrets / .variables).
- Read:
GET/orgs/{org}/actions/secrets|variablesand/repos/{o}/{r}/actions/secrets|variables(paginated). Apply:POST …/actions/variables,PATCH …/actions/variables/{name}, andDELETE …/actions/secrets|variables/{name}. - Secrets and variables are keyed by
namewithin each scope, and an undeclared live secret or variable is deleted only when ownership-gated. - Secrets are presence-only: warden never reads or writes a secret value, and there are no secret updates. A declared-but-missing secret raises a clear apply error telling you to provision it out-of-band.
- Variables are reconciled fully (name + value); a variable declared without
a
valueis presence-only. - Environment-level secrets/variables (GitHub's third scope) are a documented follow-up.
dependency-hygiene
Reconciles each managed repo's .github/dependabot.yml
(repos.<name>.dependabot): the file must exist and match the declared
content exactly.
- Read:
GET /repos/{o}/{r}/contents/.github/dependabot.yml(the Contents API; the live blob sha rides along for the update commit). The apply is aPUTon the same Contents path, as a direct commit to the default branch. - One entry per managed repo, and no delete path: the file is created or updated, never removed. If the default branch requires PRs, the commit is rejected and recorded as a failed entry; a PR-based apply variant is a documented follow-up.
repo-baseline
Ensures every repo in orgs.<org>.repoBaselines exists, creating missing
ones, optionally from a template. The provisioning backstop for scheduled
runs.
- Read:
GET /orgs/{org}/repos(paginated), an existence check. The apply isPOST /orgs/{org}/reposfor an empty repo, orPOST /repos/{tmplOwner}/{tmplRepo}/generatefrom a template. - Baselines are keyed by repo name. Existence-only: the cycle emits creates for missing declared repos, never updates or deletes a repo, and leaves settings of existing repos to the other cycles.
token-governance
Scheduled sweep over the org's fine-grained PAT grants against tokenPolicy:
expired, over-max-lifetime, or idle grants lose their org access.
- Read:
GET /orgs/{org}/personal-access-tokens(paginated); the apply isPOST /orgs/{org}/personal-access-tokens/{pat_id}, which revokes the grant's org access. Violations are emitted as updates ("revoke org access") on onetoken-grantentry per grant, rather than deletes, so a routine sweep does not tripremovalDeltaCap. - Callable only by a GitHub App; a PAT cannot drive this cycle. The API
cannot create or rotate a user's PAT, so revoking org access is the
enforcement lever. The expiry check uses GitHub's own
expiredflag; lifetime/idle checks compare against the run's clock.
token-approval
Auto-decides pending fine-grained PAT requests against tokenApproval: a
request whose every permission (flattened to group:scope) is in
allowedPermissions is approved; otherwise the policy default applies
(auto-deny, or leave pending for a human).
- Read:
GET /orgs/{org}/personal-access-token-requests(paginated); the apply isPOST /orgs/{org}/personal-access-token-requests/{id}, which approves or denies the request. Decisions are emitted as updates on onetoken-requestentry per pending request, and requests left for manual review produce no entry. - Only a GitHub App can reach these request endpoints. Admins can approve or deny but cannot narrow the repo scope the requester chose. The source notes this cycle is mock-tested; verify against a real App and test org before relying on it.