{ 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" ]; 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; }; actions = { ENABLED = true; }; }; }; networking.firewall.allowedTCPPorts = [ 2222 ]; 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=, 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 is provisioning parity with ci-bot only (account + collaborator + # branch protection) — it does NOT wire a token into mars/hermes-agent.nix # yet; that's a separate step once luna actually has git tooling to call. # # luna's own push token (used by whatever git tooling gets wired into # hermes-agent.nix later) 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' # 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. 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 the HomeLab PR-comment webhook on Gitea. This is idempotent: it # updates the existing hook for the relay target or creates it when absent. systemd.services.gitea-hermes-webhook-provision = { description = "Provision Gitea webhook for Hermes PR comments"; 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 admin_token="$(cat "$TOKEN_FILE")" secret="$(cat "$SECRET_FILE")" auth=(-H "Authorization: token $admin_token") target="http://mars.orbit.sol:8645/gitea" body="$(jq -n --arg url "$target" --arg secret "$secret" \ '{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: ["pull_request_comment", "pull_request_review_comment"], active: true}')" hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks" \ | jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')" if [ -n "$hook_id" ]; then curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ -X PATCH "$api/repos/darman/homelab/hooks/$hook_id" -d "$body" >/dev/null else curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \ -X POST "$api/repos/darman/homelab/hooks" -d "$body" >/dev/null fi ''; }; }