Compare commits

..

No commits in common. "ab7fdd50d72ef59ff24bb48abb6bd583d20d5c54" and "47fbd4bb11b7574ebaf700871226ce542acc5a5c" have entirely different histories.

11 changed files with 1 additions and 689 deletions

View File

@ -1,35 +0,0 @@
# Copy to .env (./kit.sh init does it for you) and edit. .env is git-ignored.
# How to run: "docker" (docker compose, official image) or "binary" (official binary, no Docker).
KIT_MODE=docker
APP_NAME="My Gitea"
# Public hostname and URL (what people type in the browser). Trailing slash required.
DOMAIN=localhost
ROOT_URL=http://localhost:3000/
HTTP_PORT=3000
# Docker: host interface the port is published on. 127.0.0.1 = only reachable through your proxy/tunnel.
BIND_ADDR=127.0.0.1
# Set to true when ROOT_URL is https.
COOKIE_SECURE=false
# Sign-up policy
DISABLE_REGISTRATION=false
MAX_REPOS_PER_USER=10
REGISTER_EMAIL_CONFIRM=false
MAILER_ENABLED=false
# First admin (created by ./kit.sh setup). Leave ADMIN_PASSWORD empty to generate one
# (saved to secrets/admin.password, mode 0600).
ADMIN_USER=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=
# Default branch protection applied by ./kit.sh protect / protect-all.
PROTECT_BRANCH=main
# Approvals needed to merge a PR (0 lets a solo admin merge their own PRs; 1+ for teams).
REQUIRED_APPROVALS=0
# Binary mode only: where Gitea keeps its data and config.
GITEA_HOME=/var/lib/gitea
GITEA_CONF=/etc/gitea/app.ini

7
.gitignore vendored
View File

@ -1,7 +0,0 @@
.env
config/app.ini
data/
secrets/
bin/
backups/
*.zip

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 The gitea-selfhost contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

178
README.md
View File

@ -1,179 +1,3 @@
# gitea-selfhost
Run your own [Gitea](https://about.gitea.com/) code host, set up the way the new internet's
hosted Gitea is: public repos browsable without an account, open sign-up behind a captcha,
`main` protected so every change lands through a pull request, and one Gitea user plus one
scoped token per agent.
Two ways to run it, same config:
| | Docker (default) | No Docker |
|---|---|---|
| What runs | official image `gitea/gitea:28.1.0-rootless` | official binary 28.1.0 (sha256-verified download) |
| Start | `./kit.sh up` | `KIT_MODE=binary` in `.env`, then `./kit.sh up` (or a systemd service) |
| Data | `./data` | `GITEA_HOME` (default `/var/lib/gitea`) |
| Config | `./config/app.ini` | `GITEA_CONF` (default `/etc/gitea/app.ini`) |
## One-command start
```sh
git clone <this repo> gitea-selfhost && cd gitea-selfhost
./kit.sh up
```
The first run creates `.env` from `.env.example`. Edit it (at least `DOMAIN` and `ROOT_URL`), then
run `./kit.sh up` again. It will:
1. render `config/app.ini` from `config/app.ini.template` with fresh random secrets (generated on
your machine, never committed);
2. start Gitea (Docker, or the downloaded binary) and wait for `/api/healthz`;
3. create the admin user (password generated into `secrets/admin.password` unless you set
`ADMIN_PASSWORD`) and an admin API token in `secrets/admin.token` (mode 0600).
Then open `ROOT_URL`. Needs: `bash`, `curl`, `python3` (for `protect-all`), and Docker with the
compose plugin (Docker mode).
### No Docker: binary + systemd
```sh
sed -i 's/^KIT_MODE=.*/KIT_MODE=binary/' .env
sudo ./kit.sh install-service
```
As root, that downloads and verifies the binary, creates a `git` system user, installs
`/usr/local/bin/gitea`, renders the config into `GITEA_CONF`, installs and starts
`systemd/gitea.service`, and creates the admin + token. Run later kit commands with `sudo`.
Without systemd, `./kit.sh up` in binary mode runs Gitea in the background with a pid file under
`GITEA_HOME` (set `GITEA_HOME` / `GITEA_CONF` in `.env` to paths you can write).
## Commands
```
./kit.sh up init + start + admin setup
./kit.sh protect OWNER/REPO [BRANCH] block direct pushes to BRANCH (default main); PRs only
./kit.sh protect-all protect the default branch of every non-empty repo
./kit.sh agent NAME one Gitea user + one scoped token for an agent
./kit.sh backup gitea dump into ./backups
./kit.sh status | down
```
## What the config does (`config/app.ini.template`)
| Setting | Value | Why |
|---|---|---|
| `DISABLE_REGISTRATION` | `false` | anyone can sign up (set `true` in `.env` for invite-only) |
| `ENABLE_CAPTCHA`, `CAPTCHA_TYPE` | `true`, `image` | built-in image captcha on sign-up, no third party |
| `REGISTER_EMAIL_CONFIRM` | `false` | turn on only after `[mailer]` really delivers mail |
| `REQUIRE_SIGNIN_VIEW` | `false` | public repos are browsable without an account |
| `DEFAULT_ALLOW_CREATE_ORGANIZATION` | `false` | new users cannot create organizations |
| `MAX_CREATION_LIMIT` | `10` | repos per regular user (admins are exempt) |
| `DISABLE_MIGRATIONS`, `[mirror] DISABLE_NEW_PULL` | `true` | no importing or mirroring from arbitrary URLs |
| `[security] EGRESS_MODE` | `lax` | webhooks may call public hosts only, not your LAN or loopback |
| `DEFAULT_KEEP_EMAIL_PRIVATE` | `true` | new users' emails hidden by default |
| `DEFAULT_BRANCH` | `main` | |
| `DISABLE_SSH` | `true` | Git over HTTPS with a token; nothing to expose but one HTTP port |
| `SSH_CREATE_AUTHORIZED_KEYS_FILE` | `false` | Gitea never touches the host user's `~/.ssh` |
Gitea has no instance-wide "protect main in every new repo" switch, so the kit applies the rule
per repo: `./kit.sh protect OWNER/REPO`, or `./kit.sh protect-all` from a timer
(`systemd/gitea-protect.timer` runs it every 5 minutes; with cron:
`*/5 * * * * cd /opt/gitea-selfhost && ./kit.sh protect-all`). The rule blocks direct pushes for
everyone, admins included; changes land through a PR. `REQUIRED_APPROVALS` in `.env` sets how many
approvals a PR needs (0 lets a solo admin merge their own PR; Gitea never lets you approve your own).
## Agents and tokens
- One Gitea user per agent: `./kit.sh agent my-agent`. The token (in `secrets/agents/my-agent.token`)
has `write:repository`, `write:issue` and `write:user`: it can create its own repos, push branches and
open PRs, but has no admin scope.
- Git remote: `https://<your host>/<owner>/<repo>.git`, username = agent name, password = token.
Keep it out of URLs and shell history: `git config credential.helper store` with a 0600 file, or
`GIT_ASKPASS`.
- API: `Authorization: token <token>`.
- Give an agent access to someone else's repo by adding it as a collaborator (write), not by sharing
tokens. Never hand out the admin token. Rotate a token by generating a new one and deleting the old
one in the user's settings (Applications).
## Put it behind a tunnel or reverse proxy
Keep Gitea on loopback (`BIND_ADDR=127.0.0.1`) and publish it through a proxy that terminates TLS.
Set `ROOT_URL` to the public URL and `COOKIE_SECURE=true` once it is https, then restart.
**Caddy** (automatic HTTPS):
```
git.example.com {
reverse_proxy 127.0.0.1:3000
}
```
**nginx**:
```nginx
server {
server_name git.example.com;
client_max_body_size 512m;
location / {
proxy_pass /gitea;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Under a sub-path (`https://example.com/git/`), set `ROOT_URL = https://example.com/git/` and strip
the prefix in the proxy (`location /git/ { proxy_pass /gitea/; ... }`).
**Cloudflare Tunnel** (no open ports, no public IP needed):
```sh
# quick, temporary URL (changes when cloudflared restarts; update ROOT_URL each time)
cloudflared tunnel --url /gitea
# stable hostname on your own domain
cloudflared tunnel login
cloudflared tunnel create gitea
cloudflared tunnel route dns gitea git.example.com
cloudflared tunnel run --url /gitea gitea
```
## Backups
```sh
./kit.sh backup # backups/gitea-dump-YYYYmmdd-HHMMSS.zip: repos, database, config
```
The dump contains your `app.ini` secrets and every repo: store it encrypted and off the machine.
Also keep a copy of `.env` and `secrets/`. Restore = stop Gitea, unzip the dump, put the repos,
`gitea.db` and `app.ini` back in place (see the upstream
[backup and restore guide](https://docs.gitea.com/administration/backup-and-restore)), start Gitea.
## Upgrades
1. `./kit.sh backup`.
2. Read the [release notes](https://blog.gitea.com/) for breaking config changes.
3. Docker: change the image tag in `docker-compose.yml`, then `docker compose pull && docker compose up -d`.
Binary: change `GITEA_VERSION` in `kit.sh`, `rm bin/gitea && ./kit.sh up`
(systemd: `sudo install -m 0755 bin/gitea /usr/local/bin/gitea && sudo systemctl restart gitea`).
4. Gitea migrates the database on start. Check `./kit.sh status` and the log.
## Test the kit
```sh
tests/e2e-binary.sh [PORT]
```
Runs the whole flow in a throwaway directory on a spare port (default 3990): download and verify the
binary, start, create the admin, create a repo, protect `main`, check that a direct push to `main` is
rejected (even for the admin) while a PR merges, create an agent token (own repo yes, admin API no),
check the new-user defaults (no org creation, 10-repo limit), migrations off, captcha on sign-up,
backup. Then it stops Gitea and deletes everything.
## Licenses
- **Gitea** is free software under the MIT License, copyright the Gitea authors
(<https://github.com/go-gitea/gitea>). This kit does not include or modify Gitea: it downloads the
official binary or image.
- **This kit** (scripts, templates, docs) is MIT-licensed too; see [LICENSE](LICENSE).
Self-host kit: run your own Gitea with PR-only main, open sign-up behind a captcha, and per-agent tokens. Docker or binary + systemd.

View File

@ -1,99 +0,0 @@
; Gitea app.ini template for gitea-selfhost.
; Rendered by ./kit.sh init into config/app.ini with fresh random secrets.
; Placeholders (dollar + braces) are filled from .env (see .env.example). Do not commit the rendered file.
APP_NAME = ${APP_NAME}
RUN_MODE = prod
WORK_PATH = ${WORK_PATH}
[server]
PROTOCOL = http
DOMAIN = ${DOMAIN}
HTTP_ADDR = ${HTTP_ADDR}
HTTP_PORT = ${HTTP_PORT}
; The public URL people type in the browser (behind your tunnel / reverse proxy).
ROOT_URL = ${ROOT_URL}
; How Gitea reaches itself (hooks, internal API). Always loopback.
LOCAL_ROOT_URL = http://127.0.0.1:${HTTP_PORT}/
APP_DATA_PATH = ${WORK_PATH}/data
; HTTP(S) only: Git over smart HTTP with a username + access token.
DISABLE_SSH = true
START_SSH_SERVER = false
; Never touch the host user's ~/.ssh/authorized_keys.
SSH_CREATE_AUTHORIZED_KEYS_FILE = false
OFFLINE_MODE = true
LFS_START_SERVER = false
[database]
DB_TYPE = sqlite3
PATH = ${WORK_PATH}/data/gitea.db
[repository]
ROOT = ${WORK_PATH}/git/repositories
DEFAULT_BRANCH = main
DEFAULT_PRIVATE = public
; Max repos per regular user (-1 = unlimited). Admins are exempt.
MAX_CREATION_LIMIT = ${MAX_REPOS_PER_USER}
; No importing / mirroring from arbitrary URLs.
DISABLE_MIGRATIONS = true
ENABLE_PUSH_CREATE_USER = false
ENABLE_PUSH_CREATE_ORG = false
[mirror]
; No new pull mirrors either.
DISABLE_NEW_PULL = true
[security]
INSTALL_LOCK = true
SECRET_KEY = ${SECRET_KEY}
INTERNAL_TOKEN = ${INTERNAL_TOKEN}
MIN_PASSWORD_LENGTH = 10
PASSWORD_CHECK_PWN = false
; Outgoing requests (webhooks, OAuth2 clients) may reach public hosts only; loopback / LAN
; targets need an explicit [security] ALLOWED_HOST_LIST entry. Blocks SSRF into your network.
EGRESS_MODE = lax
[oauth2]
JWT_SECRET = ${JWT_SECRET}
[service]
; Public self-registration (set DISABLE_REGISTRATION=true in .env for invite-only).
DISABLE_REGISTRATION = ${DISABLE_REGISTRATION}
SHOW_REGISTRATION_BUTTON = true
ENABLE_CAPTCHA = true
CAPTCHA_TYPE = image
REQUIRE_CAPTCHA_FOR_LOGIN = false
; Only turn this on once [mailer] really delivers mail.
REGISTER_EMAIL_CONFIRM = ${REGISTER_EMAIL_CONFIRM}
REGISTER_MANUAL_CONFIRM = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
; Public repos stay browsable without an account.
REQUIRE_SIGNIN_VIEW = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = false
DEFAULT_KEEP_EMAIL_PRIVATE = true
DEFAULT_ENABLE_TIMINGS = false
ENABLE_NOTIFY_MAIL = false
NO_REPLY_ADDRESS = noreply.${DOMAIN}
ENABLE_TIMELINE = true
ENABLE_USER_HEATMAP = false
AUTO_WATCH_NEW_REPOS = false
AUTO_WATCH_ON_CHANGES = false
[mailer]
ENABLED = ${MAILER_ENABLED}
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[session]
PROVIDER = file
COOKIE_SECURE = ${COOKIE_SECURE}
[log]
MODE = console
LEVEL = Info
[other]
SHOW_FOOTER_VERSION = false
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false

View File

@ -1,15 +0,0 @@
# gitea-selfhost: official Gitea image, pinned. Start with ./kit.sh up (it renders config first).
services:
gitea:
image: gitea/gitea:28.1.0-rootless
container_name: gitea
restart: unless-stopped
user: "${KIT_UID:-1000}:${KIT_GID:-1000}"
environment:
- GITEA_APP_INI=/etc/gitea/app.ini
- GITEA_WORK_DIR=/var/lib/gitea
volumes:
- ./data:/var/lib/gitea
- ./config:/etc/gitea
ports:
- "${BIND_ADDR:-127.0.0.1}:${HTTP_PORT:-3000}:${HTTP_PORT:-3000}"

221
kit.sh
View File

@ -1,221 +0,0 @@
#!/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"
local want got; want="$(curl -fsSL "$url.sha256" | awk '{print $1}')"; 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 systemctl is-enabled gitea >/dev/null 2>&1; 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%/}/<owner>/<repo>.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() {
if [ "$KIT_MODE" = docker ]; then compose down
elif systemctl is-active gitea >/dev/null 2>&1; then sudo systemctl stop gitea
elif [ -f "$WORK_PATH/gitea.pid" ]; then kill "$(cat "$WORK_PATH/gitea.pid")" 2>/dev/null || true; rm -f "$WORK_PATH/gitea.pid"; log "stopped"
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
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

View File

@ -1,8 +0,0 @@
# Re-applies default branch protection to every repo (new repos included).
[Unit]
Description=gitea-selfhost: protect default branches
[Service]
Type=oneshot
WorkingDirectory=/opt/gitea-selfhost
ExecStart=/opt/gitea-selfhost/kit.sh protect-all

View File

@ -1,9 +0,0 @@
[Unit]
Description=gitea-selfhost: protect default branches every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target

View File

@ -1,24 +0,0 @@
# Binary mode: install with sudo ./kit.sh install-service
# Assumes a "git" system user, binary at /usr/local/bin/gitea, data in /var/lib/gitea,
# config in /etc/gitea/app.ini (the defaults in .env.example).
[Unit]
Description=Gitea (gitea-selfhost)
After=network.target
[Service]
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini --work-path /var/lib/gitea
Restart=always
RestartSec=5
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/var/lib/gitea /etc/gitea
[Install]
WantedBy=multi-user.target

View File

@ -1,73 +0,0 @@
#!/usr/bin/env bash
# End-to-end check of the kit in binary mode, in a throwaway directory on a spare port.
# Usage: tests/e2e-binary.sh [PORT] (default 3990). Leaves nothing behind.
set -euo pipefail
PORT="${1:-3990}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"; WORK="$TMP/kit"; CLONE="$TMP/clone"
pass=0; fail=0
ok() { echo "PASS $*"; pass=$((pass+1)); }
bad() { echo "FAIL $*"; fail=$((fail+1)); }
cleanup() { (cd "$WORK" 2>/dev/null && ./kit.sh down >/dev/null 2>&1) || true; rm -rf "$TMP"; }
trap cleanup EXIT
ss -ltn 2>/dev/null | grep -q ":$PORT " && { echo "port $PORT is busy"; exit 2; }
mkdir -p "$WORK"; cp -r "$SRC"/. "$WORK"/; rm -rf "$WORK/.git" "$WORK/.env" "$WORK/secrets" "$WORK/data"
cd "$WORK"
cat > .env <<ENV
KIT_MODE=binary
APP_NAME="Kit E2E"
DOMAIN=127.0.0.1
ROOT_URL=http://127.0.0.1:$PORT/
HTTP_PORT=$PORT
COOKIE_SECURE=false
DISABLE_REGISTRATION=false
MAX_REPOS_PER_USER=10
REGISTER_EMAIL_CONFIRM=false
MAILER_ENABLED=false
ADMIN_USER=kitadmin
ADMIN_EMAIL=kitadmin@example.com
ADMIN_PASSWORD=
PROTECT_BRANCH=main
REQUIRED_APPROVALS=0
GITEA_HOME=$TMP/home
GITEA_CONF=$TMP/home/conf/app.ini
ENV
./kit.sh up && ok "kit.sh up (download + sha256, render config, start, admin + token)" || { bad "kit.sh up"; exit 1; }
A="http://127.0.0.1:$PORT/api/v1"; T="$(cat secrets/admin.token)"; P="$(cat secrets/admin.password)"
H=(-H "Authorization: token $T" -H "Content-Type: application/json")
curl -fsS "${H[@]}" -X POST "$A/user/repos" -d '{"name":"demo","auto_init":true,"default_branch":"main"}' >/dev/null && ok "admin created repo kitadmin/demo" || bad "create repo"
./kit.sh protect kitadmin/demo >/dev/null && ok "branch protection applied to main" || bad "protect"
git -c credential.helper= clone -q "http://kitadmin:$P@127.0.0.1:$PORT/kitadmin/demo.git" "$CLONE"
cd "$CLONE"; git config user.name e2e; git config user.email e2e@example.com
echo direct > direct.txt; git add direct.txt; git commit -qm direct
if git -c credential.helper= push -q origin main 2>/dev/null; then bad "direct push to main was ACCEPTED"; else ok "direct push to main rejected (even for the admin)"; fi
git reset -q --hard origin/main; git checkout -qb feature; echo via-pr > pr.txt; git add pr.txt; git commit -qm "via PR"
git -c credential.helper= push -q origin feature 2>/dev/null && ok "push to a feature branch accepted" || bad "feature push"
cd "$WORK"
N=$(curl -fsS "${H[@]}" -X POST "$A/repos/kitadmin/demo/pulls" -d '{"head":"feature","base":"main","title":"via PR"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["number"])')
curl -fsS "${H[@]}" -X POST "$A/repos/kitadmin/demo/pulls/$N/merge" -d '{"Do":"merge"}' >/dev/null && ok "PR #$N merged into main" || bad "merge"
curl -fsS "${H[@]}" "$A/repos/kitadmin/demo/contents/pr.txt?ref=main" >/dev/null && ok "merged change is on main" || bad "merged file missing"
./kit.sh agent e2e-agent >/dev/null && ok "agent user + scoped token created" || bad "agent"
AT="$(cat secrets/agents/e2e-agent.token)"
curl -fsS -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/user/repos" -d '{"name":"agent-repo","auto_init":true}' >/dev/null && ok "agent token can create its own repo" || bad "agent create repo"
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" "$A/admin/users"); [ "$code" = 403 ] && ok "agent token has no admin access (403)" || bad "agent admin access: $code"
code=$(curl -s -o /dev/null -w '%{http_code}' "${H[@]}" -H "Sudo: e2e-agent" -X POST "$A/orgs" -d '{"username":"e2e-org"}'); [ "$code" = 403 ] && ok "new users cannot create organizations (403)" || bad "org creation by new user: $code"
st=$(curl -fsS "${H[@]}" "$A/settings/repository"); grep -q '"migrations_disabled":true' <<<"$st" && ok "migrations disabled" || bad "migrations"
page=$(curl -fsS "http://127.0.0.1:$PORT/user/sign_up"); grep -q 'captcha' <<<"$page" && ok "public sign-up page shows a captcha" || bad "sign-up captcha"
./kit.sh backup >/dev/null 2>&1 && ls backups/*.zip >/dev/null && ok "backup (gitea dump) written" || bad "backup"
./kit.sh protect-all >/dev/null && ok "protect-all ran" || bad "protect-all"
made=1; for i in $(seq 2 11); do
c=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: token $AT" -H "Content-Type: application/json" -X POST "$A/user/repos" -d "{\"name\":\"r$i\"}")
[ "$c" = 201 ] && made=$((made+1))
done
[ "$made" = 10 ] && ok "repo limit: regular user stops at 10 repos" || bad "repo limit: user made $made repos"
if grep -q "Deprecation" "$TMP/home/log/gitea.out"; then bad "config uses deprecated keys: $(grep -o 'config option `[^`]*`' "$TMP/home/log/gitea.out" | sort -u | tr '\n' ' ')"; else ok "no deprecated config keys in the Gitea log"; fi
echo; echo "$pass passed, $fail failed"
[ "$fail" = 0 ]