Realliance bot: keeps the forge in step with Realliance SSO (Go)
Find a file
Christopher Miller 26594e03f7
All checks were successful
ci / test (pull_request) Successful in 18s
ci / image (pull_request) Has been skipped
ci / test (push) Successful in 17s
ci / image (push) Successful in 5s
ci: push images to realliance-ops/forge-bot as ops-ci
The repo moved to the Owners-only org realliance-ops (2026-11-04). The image
job now logs in as ops-ci (team realliance-ops/ci, packages only) with the
realliance-ops org secret REGISTRY_TOKEN and pushes to
git.realliance.net/realliance-ops/forge-bot. The Go module path stays
git.realliance.net/realliance/forge-bot until the next reviewed release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWhaDeswMqYvQaVdpL8oit
2026-11-04 13:37:43 -08:00
.forgejo/workflows ci: push images to realliance-ops/forge-bot as ops-ci 2026-11-04 13:37:43 -08:00
cmd/forge-bot forge-bot: final-review fix pass (held losses write no picture, finding kinds, redacted credentials, govulncheck) 2026-11-03 17:34:25 -08:00
internal forge-bot: residual fixes (picture loss only with this cycle's prohibit, owners-unknown withholds it, findings selector parse, dot segments) 2026-11-03 17:55:12 -08:00
.ko.yaml forge-bot: module, model, config, CI workflow 2026-11-03 10:06:34 -08:00
go.mod forge-bot: final-review fix pass (held losses write no picture, finding kinds, redacted credentials, govulncheck) 2026-11-03 17:34:25 -08:00
go.sum forge-bot: cycle, apply, audit, metrics, main 2026-11-03 14:09:52 -08:00
LICENSE-APACHE forge-bot: module, model, config, CI workflow 2026-11-03 10:06:34 -08:00
LICENSE-MIT forge-bot: module, model, config, CI workflow 2026-11-03 10:06:34 -08:00
README.md ci: push images to realliance-ops/forge-bot as ops-ci 2026-11-04 13:37:43 -08:00

forge-bot

Realliance bot: keeps the forge (Forgejo at git.realliance.net) in step with Realliance SSO (Keycloak). Written in Go. Licensed MIT OR Apache-2.0.

The full design is in the forge-bot and CI spec in the luma-homeops repo.

What it writes

forge-bot writes exactly these, and only to accounts on the configured SSO login source (never to local accounts such as forgejo-admin, forge-bot, forge-ci or ops-ci):

Where What
Forgejo admin user edit prohibit_login, admin, email, full_name, pronouns: one field per request, plus the login_name and source_id Forgejo requires on every admin edit.
Forgejo team membership Add or remove a member of a team mapped in teams.yaml. Owners teams are refused, by the planner and again by the client.
SSO (Keycloak) attributes.picture only: a read-modify-write of the user that changes nothing else. The PUT re-sends the user's other fields as read; on a user with no stored mfa attribute it also stores the realm's default for it.

Both HTTP clients refuse, at the transport, any request outside these writes and the reads forge-bot needs. Any Keycloak admin event by forge-bot's client that is not a picture write it recorded raises ForgeBotUnexpectedSSOWrite.

Each cycle

A cycle runs at start, then every 5 minutes, plus or minus 30 seconds; cycles never overlap. In order:

  1. Inventory. Read the inventory's branch head and verify its signature locally against the Owners' keyring (never Forgejo's "verified" flag). Only at an Owner-signed head, read teams.yaml and forge-bot.yaml at that exact commit.
  2. Forgejo. Every account, the Owners teams, and each team teams.yaml maps.
  3. SSO. Every user, the access and admin groups, and each group teams.yaml maps.
  4. Self-check. forge-bot's own Keycloak admin events, against the picture writes it recorded.
  5. Plan each job that is not off and whose reads all completed.
  6. Guard the plan with the circuit breaker.
  7. Apply, in the order below, the changes of jobs in apply mode.
Job Desired state, per SSO-source account
access Login prohibited unless the SSO user is enabled and in the access group; lifted when they return.
admin Site admin exactly when in the SSO admin group.
teams Each team in teams.yaml holds the members of its SSO groups who have accounts. Teams not listed are never touched.
profile For people with access: email (verified only), "First Last", pronouns from SSO.
avatar SSO picture = <public_url>/<login>.png for people with access and a public Forgejo profile; removed otherwise.

Safety rules

Rule Behaviour
Complete reads or nothing A job writes only if every read it needs completed: every page, matching counts. A failed SSO or Forgejo read stops every job; a failed inventory read, or a failed read of a mapped team or group, stops only the teams job.
Empty is a failure An access group in which nobody has access (enabled and a member), or a Forgejo listing with no SSO-source accounts, is a failed read.
Circuit breaker Per cycle, counting distinct people: losses (prohibit, admin removal, team removal, and the picture removal that goes with a prohibit in the same cycle) at most 3 people and at most a third of those with access, with a floor of one; grants at most 10; profile changes (including every other picture change, such as a stale picture of someone prohibited earlier) at most 25. With no Owners known, every loss is withheld. Over a limit, that whole category applies nothing and ForgeBotBreakerTripped fires.
Bulk override allow_bulk_until (RFC 3339) in forge-bot.yaml lifts the breaker. It counts only if the Owner-signed head commit itself set or changed it, it is in the future, and it is at most 24 h after both now and that commit's committer time. It lapses as soon as any newer commit becomes the head; commit the value again to keep it.
Owners Owners teams are never written. An Owner is never prohibited or demoted; an owner_needs_action finding (and alert) is raised instead.
Level-triggered Compare, then write: a steady state means zero writes. A failed write is retried next cycle.
Refuses to start forge-bot exits at startup unless the configured login source lists at least one account and every one has a unique, lower-case UUID login_name (an OIDC subject).
Shutdown On SIGTERM the change in flight finishes (at most 25 s more), nothing further is attempted, and the process exits. It exits non-zero if the metrics/health server dies.

Modes

Each job is off, dry-run or apply (modes: in the config). The default is dry-run: plan, audit and meter, write nothing. off does not plan the job at all. Move one job at a time to apply and watch a cycle.

Metrics

Served on /metrics. Every label value comes from a fixed set: no user, org, team or group name is ever a label. /healthz answers 503 after three intervals without a completed cycle (the process start counts as one).

Metric Labels
forge_bot_cycle_last_success_timestamp (end of the last cycle whose three reads all completed)
forge_bot_read_ok source: sso, forgejo, inventory
forge_bot_planned_changes job
forge_bot_applied_changes_total job, kind: loss, grant, profile
forge_bot_errors_total job: the five jobs, read, selfcheck
forge_bot_breaker_tripped job (in dry-run: would have tripped)
forge_bot_job_mode job: 0 off, 1 dry-run, 2 apply
forge_bot_owner_needs_action
forge_bot_unexpected_sso_writes_total
forge_bot_inventory_ok check: signature, teams, bot_config
forge_bot_findings job, kind: the finding kinds listed on model.Finding

The ServiceMonitor must set honorLabels: true, or Prometheus renames the metrics' job label.

Alerts

The rules are in internal/metrics/alerts.yaml (the spec.groups of a PrometheusRule), with promtool unit tests beside them:

nix shell nixpkgs#prometheus.cli -c promtool check rules --lint=all --lint-fatal internal/metrics/alerts.yaml
nix shell nixpkgs#prometheus.cli -c promtool test rules internal/metrics/alerts_test.yaml
Alert Severity Fires when
ForgeBotBreakerTripped critical the breaker held back a job in apply
ReallianceSSOUnreadable critical the SSO read has failed for 20 min
ForgeBotOwnerNeedsAction critical a job in apply would prohibit or demote an Owner (...DryRun, warning, for a dry-run job)
ForgeBotUnexpectedSSOWrite critical forge-bot's Keycloak client made a write that is not its own picture write
ForgeBotOwnersUnknown critical no Owners known for three cycles, so every loss is withheld
ForgeBotStale warning no cycle with complete reads in 30 min
ForgeBotNotConverging warning a job in apply keeps planning changes for three cycles
ForgeBotInventoryUntrusted warning the inventory head is not Owner-signed
ForgeBotInventoryInvalid warning teams.yaml or forge-bot.yaml is invalid
ForgeBotJobWithheld warning a job plans nothing for three cycles (empty access group, invalid team map, unread group)
ForgeBotAccountSkipped warning part of one account's sync is skipped for three cycles (shared login_name, email conflict)
ForgeBotSelfCheckBlind warning admin events unreadable for two cycles, or a picture write left no admin event
ForgeBotConcurrentEdit warning a picture write raced a profile change

Audit and logs

stdout carries slog JSON logs and the audit trail: one JSON line per change, planned change or finding (cycle, job, account, field, old, new, reason, mode, applied, error, and the team for team changes). An email or display-name change carries the old and new value, so stdout must go only to a private sink. No log or audit line carries a token or secret.

Configuration

A YAML file (--config, default /etc/forge-bot/config/config.yaml). Unknown keys are rejected. Secrets are read from the files the config names, never from the config itself. URLs must be canonical (lower-case host, no default port): both clients pin the exact host.

sso:
  url: https://sso.example.org          # Keycloak root
  realm: example
  client_id: forge-bot                  # master-realm service client
  client_secret_file: /etc/forge-bot/secrets/keycloak-client-secret
  access_group: /apps/forgejo           # group paths: leading /, no empty segment
  admin_group: /forgejo-admins
forgejo:
  url: http://forgejo-http.forgejo.svc.cluster.local:3000
  public_url: https://git.example.org   # avatar URLs are <public_url>/<login>.png
  token_file: /etc/forge-bot/secrets/forgejo-token
  source_id: 1                          # the SSO login source; must be >= 1
inventory:
  repo: example-ops/forge               # owner/name
  branch: main
  keyring: /etc/forge-bot/keyring/owners.asc
  fingerprints: /etc/forge-bot/keyring/fingerprints.txt
interval: 5m                            # default; at least 1m
jitter: 30s                             # default; less than interval
modes:                                  # each defaults to dry-run
  access: dry-run
  admin: dry-run
  teams: dry-run
  profile: dry-run
  avatar: dry-run
limits:                                 # defaults shown
  loss_people: 3                        # loss_fraction: omit it to keep exactly 1/3
  grants: 10
  profile: 25
listen: ":8080"

Build and CI

Images are built with ko, daemonless, on a distroless non-root base pinned by digest in .ko.yaml:

KO_DOCKER_REPO=git.realliance.net/realliance-ops/forge-bot ko build --bare ./cmd/forge-bot

The repo moved to realliance-ops on 2026-11-04. The Go module path stays git.realliance.net/realliance/forge-bot until the next reviewed release, because a new path changes the binary and so the reviewed digest.

go.mod pins the Go toolchain (toolchain), so CI and the image use a standard library without the vulnerabilities govulncheck reports in older releases.

CI (.forgejo/workflows/ci.yaml) runs on pushes to main, on v* tags and on pull requests; pushes to other branches run nothing. The test job runs go vet, go test -race and a pinned govulncheck. The image job builds and publishes :sha-<short> to git.realliance.net/realliance-ops/forge-bot on pushes only, logged in as ops-ci (team realliance-ops/ci, packages only). Fork pull requests are never approved.

Releases are pinned by hand, after review, in the deployment repo; a new image does not deploy itself.