From 5a4588c532ccb96863c1146c2093e7589f82b45d Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Thu, 6 Aug 2026 03:32:28 +0200 Subject: [PATCH] gitea: provision a ci-bot account with repo + branch-protection access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hosts/jupiter/secrets.nix | 6 +++ secrets/jupiter.yaml | 8 +-- services/dev/gitea.nix | 102 +++++++++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/hosts/jupiter/secrets.nix b/hosts/jupiter/secrets.nix index 87b25c1..fdcb155 100644 --- a/hosts/jupiter/secrets.nix +++ b/hosts/jupiter/secrets.nix @@ -42,4 +42,10 @@ sops.templates."gitea-runner.env".content = "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"; + } diff --git a/secrets/jupiter.yaml b/secrets/jupiter.yaml index 3fa0caa..90b4d62 100644 --- a/secrets/jupiter.yaml +++ b/secrets/jupiter.yaml @@ -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] 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_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: age: - enc: | @@ -27,7 +29,7 @@ sops: CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== -----END AGE ENCRYPTED FILE----- recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt - lastmodified: "2026-07-29T19:00:26Z" - mac: ENC[AES256_GCM,data:D5FLrembFUqs5yC2bHuhw2w8XUhF46LWpN5LF/VJKtLOo3A3KUdGV0HhA9k6gaTWyn5Zd7O9YSuJLknI62fbOtJkqOhMVUgj/VZXgrcQg4hlU53gDppryYNGi/7Vc9lxLfsJU4Yvk6yQfh7Gt19U+Mj6s7ETn6lJxC5FBnIkS3I=,iv:EJAM1cCD4rTq0XjIbo66di73hnOoklx5UoTrpdyKHq0=,tag:wEhFnwlXiYKyu/k/ED75TA==,type:str] + lastmodified: "2026-07-29T21:40:50Z" + mac: ENC[AES256_GCM,data:1tyHBdY4J4Q7QkjQrV7gCzAM4fGRxP1Qorq+nWW2N5chF1G8pUsof/qenjlGVkC0SwW4vsOHDPOxdPQGUatgsH442BY11lZ1YxbI96uu84XFcZkSye8jyvPLo37Lx6tOd10kZiAeY0hpyYGhJjRrO4p7Tmaa3y5ImiP6sS0Q4o0=,iv:RSn6zIjw76qzIFZKhDMBlcj4JiJDfLu8bO78eaCgj60=,tag:MX1ePOrbEzTs5cMUcuLa3Q==,type:str] unencrypted_suffix: _unencrypted - version: 3.13.2 + version: 3.13.3 diff --git a/services/dev/gitea.nix b/services/dev/gitea.nix index 32cf018..e7b0981 100644 --- a/services/dev/gitea.nix +++ b/services/dev/gitea.nix @@ -1,4 +1,4 @@ -{ config, ... }: +{ 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 @@ -9,6 +9,12 @@ # 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" ]; +in { services.gitea = { enable = true; @@ -66,4 +72,98 @@ "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} + ''; + }; }