- Go 100%
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 |
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/forge-bot | ||
| internal | ||
| .ko.yaml | ||
| go.mod | ||
| go.sum | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
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:
- 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.yamlandforge-bot.yamlat that exact commit. - Forgejo. Every account, the Owners teams, and each team
teams.yamlmaps. - SSO. Every user, the access and admin groups, and each group
teams.yamlmaps. - Self-check. forge-bot's own Keycloak admin events, against the picture writes it recorded.
- Plan each job that is not
offand whose reads all completed. - Guard the plan with the circuit breaker.
- Apply, in the order below, the changes of jobs in
applymode.
| 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.