Files
homelab/services/dev/gitea.nix
darmanandClaude Opus 5 b99337adb7 gitea: subscribe the review hook to pull_request_review
The hook registered with no events at all and delivered nothing.
"pull_request_review_comment" and "pull_request_review_rejected" are real
HookEventTypes and real X-GitHub-Event-Type values, but they are not
things gitea's hook API accepts. updateHookEvents
(routers/api/v1/utils/hook.go) matches a fixed list of api names and
silently ignores anything else, so every event flag stayed false, the POST
succeeded, and the hook sat there inert.

There is no narrower api name: HasEvent (models/webhook/webhook.go)
collapses approved, rejected and review-comment onto
HookEventPullRequestReview, so `pull_request_review` is a single switch for
all three. Approvals consequently cannot be excluded at the hook any more.
They now cross the wire as "pull_request_approved", which is not in the
route's event list, so Hermes ignores them on the event match -- before the
filter script and before any LLM call. Gitea's delivery log will show them
answered 200/ignored, which is intended.

That makes three namespaces for the same event rather than two, so the
tables in both nix files and the README now carry the api column, and the
README warns about the silent-ignore behaviour that hid this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S94o42aQ8VkBmEWvDem5xa
2026-08-24 03:27:52 +02:00

446 lines
21 KiB
Nix

{ config, lib, pkgs, ... }:
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
# ZimaOS docker instance straight into stateDir's default layout, so no
# import step is needed — just chown it to the gitea user after first deploy
# (currently darman:users from the CIFS copy):
# chown -R gitea:gitea /mnt/data/AppData/gitea
#
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
# :222 — the unpriv gitea user can't bind <1024).
let
# Repos where the ci-bot account (see below) should be a Write collaborator
# and whitelisted to push past branch protection. Add a repo here and
# redeploy — no manual UI clicking needed.
ciBotRepos = [ "darman/hypr-chrome" ];
# Repos where luna (Hermes Agent's own gitea identity — see below) gets PR-tier
# access: Write collaborator (so she can push feature branches and open PRs)
# but explicitly walled off `master`'s push/merge/approve whitelists so
# nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ];
# One gitea webhook per Hermes route. `route` is the path segment Hermes
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
#
# `events` are the strings gitea's HOOK API accepts. That set is coarser
# than gitea's internal HookEventType set, and both collide on spelling with
# the wire names Hermes matches on — three namespaces, one of which is a
# trap. From routers/api/v1/utils/hook.go (updateHookEvents),
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()):
#
# api event (here) delivers wire name (mars route)
# -------------------- ------------------- ----------------------
# pull_request_comment comment on a PR issue_comment
# pull_request_review review with a body pull_request_comment
# changes requested pull_request_rejected
# approval pull_request_approved
#
# So this file and hosts/mars/hermes-agent.nix name the same event
# differently on purpose, and neither is a typo.
#
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
# so a plausible-looking but non-API name leaves the hook registered with no
# events at all, delivering nothing and reporting no error. That is exactly
# what "pull_request_review_comment" did here — a real HookEventType, and a
# real value of X-GitHub-Event-Type, but not an API event name.
#
# There is no narrower name for reviews: HasEvent collapses approved,
# rejected and review-comment onto HookEventPullRequestReview, so
# `pull_request_review` is a single switch for all three. Approvals
# therefore cannot be excluded here. They are dropped on the mars side
# instead — the route's event list has no "pull_request_approved", so Hermes
# answers {"status": "ignored"} without running the filter or spending a
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
# that is the design, not a failure.
giteaHermesHooks = [
{
name = "PR comments Hermes";
route = "gitea-pr-comments";
events = [ "pull_request_comment" ];
}
{
name = "PR reviews Hermes";
route = "gitea-pr-reviews";
events = [ "pull_request_review" ];
}
];
in
{
services.gitea = {
enable = true;
stateDir = "/mnt/data/AppData/gitea";
lfs.enable = true;
settings = {
repository = {
DEFAULT_BRANCH = "master";
};
server = {
DOMAIN = "git.mgaction.town";
SSH_DOMAIN = "git.mgaction.town";
# https, not http: neptun's Caddy terminates TLS for this name. Gitea
# builds its absolute URLs (clone buttons, redirects, webhooks) from
# ROOT_URL, so an http:// value hands out downgraded links.
ROOT_URL = "https://git.mgaction.town/";
HTTP_PORT = 3000;
START_SSH_SERVER = true;
SSH_PORT = 2222;
SSH_LISTEN_PORT = 2222;
};
service = {
DISABLE_REGISTRATION = true;
};
security = {
# Gitea refuses to deliver a webhook to any host outside this list,
# which defaults to `external` — "a valid non-private unicast IP".
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT),
# which is neither RFC1918 private nor, as far as gitea's matcher is
# concerned, external — so the hermes relay on mars was refused with
# deny 'mars.orbit.sol(100.64.0.6:8644)'
# even though nothing here is private in the RFC1918 sense. Adding
# the tailnet CIDR is what makes tailnet-internal webhook targets
# deliverable at all; `external` is kept so a future webhook to a
# public service (discord, slack) still works without another edit.
#
# This lives in [security], not [webhook]: the webhook-section key is
# deprecated and now just falls back to this one, which is the name
# the delivery error itself reports.
ALLOWED_HOST_LIST = "external,100.64.0.0/10";
};
actions = {
ENABLED = true;
};
};
};
networking.firewall.allowedTCPPorts = [ 2222 ];
# `gitea <args>` == the admin CLI, as the gitea user, against the real
# state dir — mirrors the `hermes` alias on mars. Worth having because none
# of that is discoverable: the package is not in systemPackages (so `gitea`
# is not otherwise on PATH at all), every admin subcommand needs
# GITEA_WORK_DIR pointed at a stateDir that is not the module default, and
# it has to run as the gitea user or it writes root-owned files into that
# directory. Both paths come from the config rather than being spelled out,
# so a package bump or a stateDir move cannot leave this stale.
#
# Handy ones:
# gitea admin user generate-access-token --username luna \
# --token-name luna-$(date +%Y%m%d) \
# --scopes write:repository,write:issue,read:user --raw
# gitea admin user list
# gitea actions generate-runner-token
programs.zsh.shellAliases.gitea =
"sudo -u ${config.services.gitea.user} env GITEA_WORK_DIR=${config.services.gitea.stateDir} ${config.services.gitea.package}/bin/gitea";
users.users.gitea.extraGroups = [ "users" ];
# Runner instance registered against this same gitea. Jobs run in containers
# (podman, via services/containers.nix — already enabled on jupiter), one
# image per requested `runs-on` label using the catthehacker act-compatible
# images (same ones upstream `act`/Forgejo docs recommend).
#
# tokenFile points at an env file rendered by sops (TOKEN=<registration
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the
# secret never lands in the Nix store. The registration token itself is NOT
# generated by this module — it comes from gitea once Actions is enabled:
# su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
# then written into secrets/jupiter.yaml as gitea_runner_token.
services.gitea-actions-runner.instances.jupiter = {
enable = true;
name = "jupiter";
url = "https://git.mgaction.town/";
tokenFile = config.sops.templates."gitea-runner.env".path;
labels = [
"ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-latest"
"ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04"
];
};
# ci-bot: dedicated account CI workflows push as (kept separate from any
# human account so its own PAT can be scoped/rotated/revoked independently).
# Collaborator access + branch-protection push-whitelisting have no CLI or
# config-file surface in gitea — only the HTTP API — so this is the one
# part of the setup that stays imperative even though it's nix-triggered:
# a oneshot that PUTs/PATCHes the API into the desired state on every
# deploy where its script changed (adding a repo to `ciBotRepos` and
# redeploying is enough to pick it up; it won't self-heal a manual revert
# done via the web UI unless the unit is also restarted).
#
# Auth for those API calls is darman's OWN token (named
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
# the collaborator/branch-protection endpoints; write:user is additionally
# needed to push ci-bot's token below as a secret on darman's own account.
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
#
# ci-bot's own push token (separate secret, ci_bot_token) is generated
# once via:
# su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
# --username ci-bot --scopes write:repository'
# and this service pushes it into gitea itself as a user-level Actions
# secret (CI_BOT_TOKEN, on darman's account — see the PUT below) so
# workflows in ciBotRepos can push as ci-bot without a per-repo secret.
systemd.services.gitea-ci-bot-provision = {
description = "Provision ci-bot gitea account + repo access";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
CI_BOT_TOKEN_FILE = config.sops.secrets.gitea_ci_bot_token.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
admin_token="$(cat "$TOKEN_FILE")"
auth=(-H "Authorization: token $admin_token")
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
if ! curl -fs "''${auth[@]}" "$api/users/ci-bot" >/dev/null 2>&1; then
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
--username ci-bot \
--email ci-bot@${config.services.gitea.settings.server.DOMAIN} \
--random-password --must-change-password=false
fi
# No instance-wide secret scope exists in Gitea (it's an open feature
# request) - a user-level secret on darman's own account is the closest
# equivalent, since every repo below is owned directly by darman, not
# an org, and repo-level secrets fall back to user-level when unset.
ci_bot_token="$(cat "$CI_BOT_TOKEN_FILE")"
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/user/actions/secrets/CI_BOT_TOKEN" \
-d "$(jq -n --arg data "$ci_bot_token" '{data: $data}')"
${lib.concatMapStringsSep "\n" (repo: ''
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/repos/${repo}/collaborators/ci-bot" \
-d '{"permission":"write"}'
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
# ci-bot needs push access on every branch a workflow might commit
# back to (currently just `develop`, where version-bump.yml pushes),
# in addition to whatever the repo's actual default branch is.
branches="$(printf '%s\n' "$default_branch" develop | sort -u)"
for branch in $branches; do
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$branch" >/dev/null 2>&1; then
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/${repo}/branch_protections/$branch" \
-d '{"enable_push":true,"enable_push_whitelist":true,"push_whitelist_usernames":["ci-bot"]}'
else
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X POST "$api/repos/${repo}/branch_protections" \
-d "{\"branch_name\":\"$branch\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"ci-bot\"]}"
fi
done
'') ciBotRepos}
'';
};
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
# Hermes runs on mars, takes instructions over Telegram, and can be
# prompt-injected via tool output — a dedicated account with its own
# scoped, revocable token keeps that blast radius off darman's own
# credentials, and the branch-protection whitelists below keep it off
# `master` entirely regardless of what the token can technically do.
# She gets Write collaborator access (needed to push a branch and open a
# PR against the same repo — this instance has no fork workflow), but:
# - enable_push + enable_push_whitelist(darman only): nobody but darman
# can push straight to master; luna can only land on a side branch.
# - enable_merge_whitelist(darman only): opening a PR is not the same
# as merging one — only darman can click merge.
# - required_approvals=1 + enable_approvals_whitelist(darman only):
# an approval has to come from darman specifically, not luna
# rubber-stamping her own PR from a second identity.
# This covers the SERVER side only (account + collaborator + branch
# protection). The client side — git/tea inside the hermes-agent container,
# and the token below — lives in hosts/mars/hermes-agent.nix.
#
# luna's own push token is generated once, the same way ci-bot's was:
# su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
# --username luna --scopes write:repository,write:issue,read:user'
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
# since luna isn't a CI workflow running inside gitea, she's an external
# agent calling out to it.
#
# **write:issue is NOT optional and is easy to miss**: this token started
# life as `write:repository` alone, which clones, fetches and pushes
# branches perfectly well — so everything looks fine right up until the
# first `tea pr create`, which gitea rejects with
# token scope=write:repository,read:user required=read:issue
# A pull request IS an issue in gitea's data model, so every /pulls
# endpoint is gated on the *issue* scope category, not the repository one.
# write:issue covers it (in gitea's scope model write:X implies read:X);
# read:issue alone would satisfy the GET half and then fail the POST that
# actually opens the PR. The error names read:issue only because that's
# the first check tea trips on. Rotating the token is free — the prepare
# oneshot on mars does delete-then-add for the tea login on every start.
systemd.services.gitea-luna-provision = {
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
admin_token="$(cat "$TOKEN_FILE")"
auth=(-H "Authorization: token $admin_token")
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
if ! curl -fs "''${auth[@]}" "$api/users/luna" >/dev/null 2>&1; then
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
--username luna \
--email luna@${config.services.gitea.settings.server.DOMAIN} \
--random-password --must-change-password=false
fi
${lib.concatMapStringsSep "\n" (repo: ''
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/repos/${repo}/collaborators/luna" \
-d '{"permission":"write"}'
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
protect_body="$(jq -n '{
enable_push: true,
enable_push_whitelist: true,
push_whitelist_usernames: ["darman"],
enable_merge_whitelist: true,
merge_whitelist_usernames: ["darman"],
required_approvals: 1,
enable_approvals_whitelist: true,
approvals_whitelist_username: ["darman"]
}')"
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$default_branch" >/dev/null 2>&1; then
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/${repo}/branch_protections/$default_branch" \
-d "$protect_body"
else
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X POST "$api/repos/${repo}/branch_protections" \
-d "$(echo "$protect_body" | jq --arg b "$default_branch" '. + {branch_name: $b}')"
fi
'') lunaRepos}
'';
};
# Register one Gitea webhook per Hermes route (giteaHermesHooks above).
# Idempotent: each target URL is updated if a hook for it already exists and
# created otherwise.
#
# It deliberately does NOT delete anything, including hooks for routes that
# were removed from the list above. Retiring one is a one-off, done by hand
# in the repo's Settings -> Webhooks, so that a redeploy can never silently
# unregister a hook someone added on purpose.
systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhooks for Hermes routes";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
# Neither secret is ever passed as an argument. This unit runs as the
# gitea user on a multi-user box, where /proc/<pid>/cmdline is
# world-readable for the lifetime of the process — so `-H "Authorization:
# token $t"` would publish the admin token, and `jq --arg secret "$s"`
# the webhook secret. The token goes into a 0600 curl config file
# instead (printf is a shell builtin, so the substitution below never
# reaches an argv), the webhook secret into jq via --rawfile, and the
# request body into curl on stdin with --data @-.
authcfg="$(mktemp)"
trap 'rm -f "$authcfg"' EXIT
chmod 0600 "$authcfg"
printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg"
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
# above: After=gitea.service only means the process started, not that it
# is serving HTTP yet. Without this the first curl below fails under
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
# the webhooks silently unregistered until someone restarts the unit.
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
upsert_hook() {
local name="$1" route="$2" events="$3" url body hook_id
url="http://mars.orbit.sol:8644/webhooks/$route"
# rtrimstr: sops stores this without a trailing newline, but one
# slipping in would change the key the HMAC is computed with and make
# every delivery fail signature validation on the Hermes side. The
# same trim happens there, so both ends agree either way.
body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
--arg url "$url" --arg name "$name" --argjson events "$events" \
'{type: "gitea", name: $name, active: true, events: $events,
config: {content_type: "json", url: $url,
secret: ($rawSecret | rtrimstr("\n"))}}')"
hook_id="$(curl -fsS -K "$authcfg" "$api/repos/darman/homelab/hooks" \
| jq -r --arg url "$url" \
'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')"
if [ -n "$hook_id" ]; then
printf '%s' "$body" | curl -fsS -K "$authcfg" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null
else
printf '%s' "$body" | curl -fsS -K "$authcfg" -H 'Content-Type: application/json' \
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null
fi
}
${lib.concatMapStringsSep "\n " (h:
"upsert_hook ${lib.escapeShellArg h.name} ${lib.escapeShellArg h.route} "
+ lib.escapeShellArg (builtins.toJSON h.events)
) giteaHermesHooks}
'';
};
}