#!/usr/bin/env bash # gitea-selfhost: run your own Gitea with PR-only main branches and per-agent tokens. # Usage: ./kit.sh up | init | setup | protect OWNER/REPO [BRANCH] | protect-all | agent NAME # | backup | upgrade | status | down | install-service set -euo pipefail KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$KIT_DIR" GITEA_VERSION="28.1.0" # keep in step with docker-compose.yml SECRETS="$KIT_DIR/secrets" die() { echo "error: $*" >&2; exit 1; } log() { echo "==> $*"; } load_env() { [ -f .env ] || { cp .env.example .env; log "created .env from .env.example (edit it, then re-run)"; } set -a; . ./.env; set +a KIT_MODE="${KIT_MODE:-docker}" HTTP_PORT="${HTTP_PORT:-3000}" PROTECT_BRANCH="${PROTECT_BRANCH:-main}" REQUIRED_APPROVALS="${REQUIRED_APPROVALS:-0}" case "$KIT_MODE" in docker) WORK_PATH=/var/lib/gitea; CONF_HOST="$KIT_DIR/config/app.ini" ;; binary) WORK_PATH="${GITEA_HOME:-/var/lib/gitea}"; CONF_HOST="${GITEA_CONF:-/etc/gitea/app.ini}" ;; *) die "KIT_MODE must be docker or binary" ;; esac API="http://127.0.0.1:${HTTP_PORT}/api/v1" export KIT_UID="$(id -u)" KIT_GID="$(id -g)" WORK_PATH HTTP_ADDR="${HTTP_ADDR:-$([ "$KIT_MODE" = docker ] && echo 0.0.0.0 || echo 127.0.0.1)}" } compose() { if docker compose version >/dev/null 2>&1; then docker compose "$@" elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@" else die "docker compose not found (or set KIT_MODE=binary)"; fi } bin_path() { if [ -x "$KIT_DIR/bin/gitea" ]; then echo "$KIT_DIR/bin/gitea"; else command -v gitea || echo /usr/local/bin/gitea; fi; } # Run the gitea CLI against this instance. gitea_cli() { if [ "$KIT_MODE" = docker ]; then compose exec -T gitea gitea --config /etc/gitea/app.ini --work-path /var/lib/gitea "$@" elif [ "$(id -u)" = 0 ]; then # systemd install: Gitea refuses to run as root, so act as the service user. runuser -u git -- "$(bin_path)" --config "$CONF_HOST" --work-path "$WORK_PATH" "$@" else "$(bin_path)" --config "$CONF_HOST" --work-path "$WORK_PATH" "$@" fi } rand() { head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}"; } render_config() { # Fresh random secrets on first render; an existing config keeps its secrets. if [ -f "$CONF_HOST" ]; then log "config exists: $CONF_HOST (not overwritten)"; return; fi local s; s="$(cat config/app.ini.template)" SECRET_KEY="$(rand 64)"; INTERNAL_TOKEN="$(rand 105)"; JWT_SECRET="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')" local v for v in APP_NAME WORK_PATH DOMAIN HTTP_ADDR HTTP_PORT ROOT_URL MAX_REPOS_PER_USER DISABLE_REGISTRATION \ REGISTER_EMAIL_CONFIRM MAILER_ENABLED COOKIE_SECURE SECRET_KEY INTERNAL_TOKEN JWT_SECRET; do s="${s//\$\{$v\}/${!v-}}" done case "$s" in *'${'*) die "unrendered placeholder in template: $(grep -o '\${[A-Z_]*}' <<<"$s" | head -1)";; esac mkdir -p "$(dirname "$CONF_HOST")" umask 077; printf '%s\n' "$s" > "$CONF_HOST"; umask 022 log "wrote $CONF_HOST (secrets generated locally, never commit it)" } install_binary() { [ -x "$KIT_DIR/bin/gitea" ] && "$KIT_DIR/bin/gitea" --version 2>/dev/null | grep -q " $GITEA_VERSION " && return local arch; case "$(uname -m)" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; armv7l) arch=arm-6;; *) die "unsupported arch $(uname -m)";; esac local url="https://dl.gitea.com/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-${arch}" mkdir -p bin; log "downloading $url" curl -fsSL -o bin/gitea.tmp "$url" # sha256 pinned per release (from dl.gitea.com//*.sha256); update with GITEA_VERSION. local want got; case "$GITEA_VERSION-$arch" in 28.1.0-amd64) want=ea93cfa4a300564ac457d3903469fea49ec5a4da37d0cebaa435f8e329ea64d0 ;; 28.1.0-arm64) want=47bf18a8e4602b17910cd660a902541e2c13b67c732f2607da8326fa3cd46805 ;; 28.1.0-arm-6) want=2bf4d222be997103b6668cdf241a83bb26ec365ee6d182faa67ec9f7f021ef39 ;; *) rm -f bin/gitea.tmp; die "no pinned sha256 for gitea $GITEA_VERSION/$arch; add it to install_binary" ;; esac; got="$(sha256sum bin/gitea.tmp | awk '{print $1}')" [ -n "$want" ] && [ "$want" = "$got" ] || { rm -f bin/gitea.tmp; die "sha256 mismatch for gitea binary"; } chmod +x bin/gitea.tmp; mv bin/gitea.tmp bin/gitea; log "gitea $GITEA_VERSION verified (sha256 $got)" } wait_healthy() { local i; for i in $(seq 1 60); do curl -fsS "http://127.0.0.1:${HTTP_PORT}/api/healthz" >/dev/null 2>&1 && { log "gitea is up on 127.0.0.1:${HTTP_PORT}"; return; } sleep 1; done die "gitea did not become healthy on port ${HTTP_PORT}" } cmd_init() { mkdir -p "$SECRETS"; chmod 700 "$SECRETS" if [ "$KIT_MODE" = docker ]; then mkdir -p data config; else install_binary; mkdir -p "$WORK_PATH"; fi render_config } start() { if [ "$KIT_MODE" = docker ]; then compose up -d elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl start gitea else mkdir -p "$WORK_PATH/log" if [ -f "$WORK_PATH/gitea.pid" ] && kill -0 "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null; then log "already running"; return; fi nohup "$(bin_path)" web --config "$CONF_HOST" --work-path "$WORK_PATH" >> "$WORK_PATH/log/gitea.out" 2>&1 < /dev/null & echo $! > "$WORK_PATH/gitea.pid" fi } api() { # api METHOD PATH [JSON] local tok; tok="$(cat "$SECRETS/admin.token")" curl -fsS -X "$1" -H "Authorization: token $tok" -H "Content-Type: application/json" "$API$2" ${3:+-d "$3"} } cmd_setup() { mkdir -p "$SECRETS"; chmod 700 "$SECRETS" local user="${ADMIN_USER:-admin}" pass="${ADMIN_PASSWORD:-}" if gitea_cli admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$user"; then log "admin '$user' already exists" else if [ -z "$pass" ]; then pass="$(rand 24)"; (umask 077; printf '%s\n' "$pass" > "$SECRETS/admin.password"); fi gitea_cli admin user create --admin --username "$user" --password "$pass" \ --email "${ADMIN_EMAIL:-admin@example.com}" --must-change-password=false >/dev/null log "created admin '$user' (password in secrets/admin.password if generated)" fi if [ ! -s "$SECRETS/admin.token" ]; then gitea_cli admin user generate-access-token --username "$user" --token-name "kit-admin-$(date +%s)" \ --scopes "write:admin,write:repository,write:user,write:organization,write:issue" --raw \ | tail -n1 | (umask 077; cat > "$SECRETS/admin.token") log "admin API token saved to secrets/admin.token (0600)" fi api GET /user >/dev/null && log "admin token works" } cmd_protect() { # protect OWNER/REPO [BRANCH] local repo="$1" branch="${2:-$PROTECT_BRANCH}" local body; body=$(printf '{"rule_name":"%s","enable_push":false,"enable_merge_whitelist":false,"required_approvals":%s,"block_on_rejected_reviews":true,"dismiss_stale_approvals":true,"block_admin_merge_override":false}' "$branch" "$REQUIRED_APPROVALS") if api GET "/repos/$repo/branch_protections/$branch" >/dev/null 2>&1; then api PATCH "/repos/$repo/branch_protections/$branch" "$body" >/dev/null else api POST "/repos/$repo/branch_protections" "$body" >/dev/null fi echo "protected $repo:$branch (no direct pushes; merge through a PR; approvals=$REQUIRED_APPROVALS)" } cmd_protect_all() { local page=1 names while :; do names=$(api GET "/repos/search?limit=50&page=$page" | python3 -c 'import json,sys; [print(r["full_name"], r["default_branch"] or "main", r["empty"]) for r in json.load(sys.stdin)["data"]]') [ -n "$names" ] || break while read -r full branch empty; do [ "$empty" = "True" ] && { echo "skip $full (empty)"; continue; } cmd_protect "$full" "$branch" done <<<"$names" page=$((page+1)) done } cmd_agent() { # agent NAME : one Gitea user + one scoped token per agent local name="$1"; [[ "$name" =~ ^[a-z0-9][a-z0-9-]{1,38}$ ]] || die "agent name: lowercase letters, digits, dashes" mkdir -p "$SECRETS/agents"; chmod 700 "$SECRETS/agents" if ! gitea_cli admin user list 2>/dev/null | awk 'NR>1{print $2}' | grep -qx "$name"; then gitea_cli admin user create --username "$name" --password "$(rand 32)" --email "$name@agents.invalid" \ --must-change-password=false >/dev/null fi gitea_cli admin user generate-access-token --username "$name" --token-name "agent-$(date +%s)" \ --scopes "write:repository,write:issue,write:user" --raw | tail -n1 | (umask 077; cat > "$SECRETS/agents/$name.token") echo "agent '$name' ready: token in secrets/agents/$name.token (scopes: write:repository, write:issue, write:user; no admin)" echo "git remote: ${ROOT_URL%/}//.git (username $name, token as password)" } cmd_backup() { mkdir -p backups local f="gitea-dump-$(date +%Y%m%d-%H%M%S).zip" if [ "$KIT_MODE" = docker ]; then compose exec -T gitea sh -c "cd /tmp && gitea dump --config /etc/gitea/app.ini --work-path /var/lib/gitea --file /tmp/$f >/dev/null && cat /tmp/$f && rm -f /tmp/$f" > "backups/$f" else gitea_cli dump --file "$KIT_DIR/backups/$f" >/dev/null fi chmod 600 "backups/$f"; log "backup: backups/$f (contains secrets; store it encrypted)" } cmd_down() { # stops only what this kit started: its compose project, its own pid, or the unit it installed if [ "$KIT_MODE" = docker ]; then compose down elif [ -f "$WORK_PATH/gitea.pid" ]; then local p; p="$(cat "$WORK_PATH/gitea.pid")" local cmd=""; cmd="$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || true)" if kill -0 "$p" 2>/dev/null && [[ "$cmd" == *"--work-path $WORK_PATH "* ]]; then kill "$p"; log "stopped pid $p" else log "pid $p is not this kit's gitea; not touching it"; fi rm -f "$WORK_PATH/gitea.pid" elif [ -f "$KIT_DIR/.kit-systemd" ]; then sudo systemctl stop gitea else log "nothing started by this kit is running" fi } cmd_status() { curl -fsS "http://127.0.0.1:${HTTP_PORT}/api/v1/version" && echo || echo "not reachable on 127.0.0.1:${HTTP_PORT}" } cmd_install_service() { # binary mode, as root: git user, binary, config, systemd unit, admin [ "$KIT_MODE" = binary ] || die "install-service is for KIT_MODE=binary" [ "$(id -u)" = 0 ] || die "run with sudo" install_binary id git >/dev/null 2>&1 || useradd --system --create-home --home-dir /home/git --shell /bin/bash git install -m 0755 "$KIT_DIR/bin/gitea" /usr/local/bin/gitea mkdir -p "$WORK_PATH" "$(dirname "$CONF_HOST")" "$SECRETS"; chmod 700 "$SECRETS" render_config chown -R git:git "$WORK_PATH" "$(dirname "$CONF_HOST")"; chmod 750 "$(dirname "$CONF_HOST")"; chmod 640 "$CONF_HOST" sed -e "s#/var/lib/gitea#$WORK_PATH#g" -e "s#/etc/gitea/app.ini#$CONF_HOST#g" -e "s#/etc/gitea#$(dirname "$CONF_HOST")#g" \ systemd/gitea.service > /etc/systemd/system/gitea.service systemctl daemon-reload; systemctl enable --now gitea echo /etc/systemd/system/gitea.service > "$KIT_DIR/.kit-systemd" wait_healthy; cmd_setup log "systemd service 'gitea' enabled; run later kit commands with sudo" } load_env case "${1:-help}" in init) cmd_init ;; up) cmd_init; start; wait_healthy; cmd_setup log "open ${ROOT_URL} (admin: ${ADMIN_USER:-admin})" log "protect a repo: ./kit.sh protect OWNER/REPO (or protect-all)" ;; setup) cmd_setup ;; protect) [ $# -ge 2 ] || die "usage: ./kit.sh protect OWNER/REPO [BRANCH]"; cmd_protect "$2" "${3:-}" ;; protect-all) cmd_protect_all ;; agent) [ $# -ge 2 ] || die "usage: ./kit.sh agent NAME"; cmd_agent "$2" ;; backup) cmd_backup ;; upgrade) die "edit GITEA_VERSION here and the image tag in docker-compose.yml, run ./kit.sh backup, then: docker compose pull && docker compose up -d (binary: rm bin/gitea && ./kit.sh up). See README." ;; status) cmd_status ;; down) cmd_down ;; install-service) cmd_install_service ;; *) sed -n '2,4p' "$0" ;; esac