gitea: provision a ci-bot account with repo + branch-protection access

Workflows push as a dedicated ci-bot account rather than a human one, so its
PAT can be scoped, rotated and revoked on its own. Adding a repo to
`ciBotRepos` and redeploying is all it takes to grant access.

Collaborator access and branch-protection push-whitelisting exist only on
gitea's HTTP API — no CLI, no config-file surface — so this one part stays
imperative: a oneshot that PUT/PATCHes the API into the desired state. It
runs on deploys where the script changed, which means it won't self-heal a
revert done through the web UI unless the unit is restarted too.

Two secrets, deliberately distinct:
- gitea_provisioning_token is darman's own token (write:repository +
  write:user). Only an owner-scoped token clears reqOwnerCheck on the
  collaborator and branch-protection endpoints, and write:user is what lets
  it write the Actions secret below. ci-bot cannot grant itself access.
- gitea_ci_bot_token is ci-bot's push token, generated once by hand (the
  command is in the comment) and pushed into gitea as a user-level Actions
  secret CI_BOT_TOKEN. Gitea has no instance-wide secret scope, and every
  repo here is owned by darman directly rather than an org, so a user-level
  secret is the closest thing — repo-level lookups fall back to it.

Branch protection is applied to the default branch plus `develop`, since
version-bump.yml pushes there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:32:28 +02:00
co-authored by Claude Opus 5
parent c17524e358
commit 5a4588c532
3 changed files with 112 additions and 4 deletions
+6
View File
@@ -42,4 +42,10 @@
sops.templates."gitea-runner.env".content = sops.templates."gitea-runner.env".content =
"TOKEN=${config.sops.placeholder.gitea_runner_token}"; "TOKEN=${config.sops.placeholder.gitea_runner_token}";
# provisioning access token for gitea used to setup ci-bot account + repo access
sops.secrets.gitea_provisioning_token.owner = "gitea";
# ci-bot access token to allow the ci-bot user to push to repos
sops.secrets.gitea_ci_bot_token.owner = "gitea";
} }
+5 -3
View File
@@ -7,6 +7,8 @@ prowlarr_api_key: ENC[AES256_GCM,data:ab1QACaagI8ACJXFUy8r9X9lgYwRo1byUmhBPSRWwK
cinephage_better_auth_secret: ENC[AES256_GCM,data:S1ilcQeC2HmXe/4xdLi6wm5RNz954SL3qVur6JCn5ekBVCbXMd1DGCafjhU=,iv:9rS5gDuazMOAq/hWp0onvHZPzKJgQM3oWIrtplJN/9I=,tag:xNqf/unY2v/98p4v52vUqw==,type:str] cinephage_better_auth_secret: ENC[AES256_GCM,data:S1ilcQeC2HmXe/4xdLi6wm5RNz954SL3qVur6JCn5ekBVCbXMd1DGCafjhU=,iv:9rS5gDuazMOAq/hWp0onvHZPzKJgQM3oWIrtplJN/9I=,tag:xNqf/unY2v/98p4v52vUqw==,type:str]
immich_oauth_client_secret: ENC[AES256_GCM,data:+NbUnwImwFTYNjz3luzczpCf7oMetzYBkj5ZnuG2QQf0Wpm6OtYS3amTC8dwoh9F/DAos5224etncfEgEu2k2iMUACLADnlCGppIx0F7Gl1Ve7UF2VzKJ3xQpgCDrXklU+o5NxfU/YBn1Vfa3580wT3tr2++SCSrcKq1XGtfhv4=,iv:tjaPDQbrA6TxsDebgNOtO/ITfXzU5wTKU9SkfC0TQcY=,tag:AnlJR0tLExkB7Aeo/ZVTng==,type:str] immich_oauth_client_secret: ENC[AES256_GCM,data:+NbUnwImwFTYNjz3luzczpCf7oMetzYBkj5ZnuG2QQf0Wpm6OtYS3amTC8dwoh9F/DAos5224etncfEgEu2k2iMUACLADnlCGppIx0F7Gl1Ve7UF2VzKJ3xQpgCDrXklU+o5NxfU/YBn1Vfa3580wT3tr2++SCSrcKq1XGtfhv4=,iv:tjaPDQbrA6TxsDebgNOtO/ITfXzU5wTKU9SkfC0TQcY=,tag:AnlJR0tLExkB7Aeo/ZVTng==,type:str]
gitea_runner_token: ENC[AES256_GCM,data:8ji4Nia7GMCBBsemUeGZRqzhlk1RnzzOLLdo7+to85KIC5Kz4AtDsQ==,iv:2wotlB1B/Co/NrZVcIVB4AlwL7DF9KnEVKe32FJNErU=,tag:zLzKPgGmXCkGfK7P/74pyw==,type:str] gitea_runner_token: ENC[AES256_GCM,data:8ji4Nia7GMCBBsemUeGZRqzhlk1RnzzOLLdo7+to85KIC5Kz4AtDsQ==,iv:2wotlB1B/Co/NrZVcIVB4AlwL7DF9KnEVKe32FJNErU=,tag:zLzKPgGmXCkGfK7P/74pyw==,type:str]
gitea_provisioning_token: ENC[AES256_GCM,data:aVzD+3qb0eAuGCNIXgzR338jMz9MqXun3nbgfZAirewDwT3D7T5T0Q==,iv:OOeDRk+4CHQyRh09qgUp7I4vcrvuqaAPAh5HgJ10Uvo=,tag:2Yey9e9WZteERDoqkIppWQ==,type:str]
gitea_ci_bot_token: ENC[AES256_GCM,data:isgOYuA8S6w7WCUr2i2tW4F+b8mCRh8e+rjFJtM1fXEkkUIRNaiADA==,iv:W5IoxhuPCoTP2wLhedu4RYKrC8tBFFJ4B+QvNW3jjTc=,tag:ztxEwDdGJLc/JEkls1jrOA==,type:str]
sops: sops:
age: age:
- enc: | - enc: |
@@ -27,7 +29,7 @@ sops:
CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt
lastmodified: "2026-07-29T19:00:26Z" lastmodified: "2026-07-29T21:40:50Z"
mac: ENC[AES256_GCM,data:D5FLrembFUqs5yC2bHuhw2w8XUhF46LWpN5LF/VJKtLOo3A3KUdGV0HhA9k6gaTWyn5Zd7O9YSuJLknI62fbOtJkqOhMVUgj/VZXgrcQg4hlU53gDppryYNGi/7Vc9lxLfsJU4Yvk6yQfh7Gt19U+Mj6s7ETn6lJxC5FBnIkS3I=,iv:EJAM1cCD4rTq0XjIbo66di73hnOoklx5UoTrpdyKHq0=,tag:wEhFnwlXiYKyu/k/ED75TA==,type:str] mac: ENC[AES256_GCM,data:1tyHBdY4J4Q7QkjQrV7gCzAM4fGRxP1Qorq+nWW2N5chF1G8pUsof/qenjlGVkC0SwW4vsOHDPOxdPQGUatgsH442BY11lZ1YxbI96uu84XFcZkSye8jyvPLo37Lx6tOd10kZiAeY0hpyYGhJjRrO4p7Tmaa3y5ImiP6sS0Q4o0=,iv:RSn6zIjw76qzIFZKhDMBlcj4JiJDfLu8bO78eaCgj60=,tag:MX1ePOrbEzTs5cMUcuLa3Q==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.2 version: 3.13.3
+101 -1
View File
@@ -1,4 +1,4 @@
{ config, ... }: { config, lib, pkgs, ... }:
# Gitea — self-hosted git. stateDir/repositories were migrated from the old # Gitea — self-hosted git. stateDir/repositories were migrated from the old
# ZimaOS docker instance straight into stateDir's default layout, so no # ZimaOS docker instance straight into stateDir's default layout, so no
@@ -9,6 +9,12 @@
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix). # 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 # 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). # :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" ];
in
{ {
services.gitea = { services.gitea = {
enable = true; enable = true;
@@ -66,4 +72,98 @@
"ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04" "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}
'';
};
} }