#!/usr/bin/env bash # Copyright (C) 2026 Dcntrlized # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # Deploy the license bot to any Gitea instance. # # cp .env.example .env # set GITEA_URL, GITEA_TOKEN, WEBHOOK_SECRET # ./deploy.sh # # Idempotent: builds the image, starts the container and verifies it from # inside Gitea's own network. There is deliberately no admin credential # anywhere in this script. # # The bot is useless unless it lands on the Docker network Gitea already runs # on: Gitea must resolve `$CONTAINER_NAME` and open `$CONTAINER_NAME:8080`. # NETWORK_NAME is the REAL (project-prefixed) network name -- a network defined # inside Gitea's own compose project is named `_`, e.g. prod's # /docker/org.dcntrlized.git defines `gitea_dcntrlized`, which Docker names # `orgdcntrlizedgit_gitea_dcntrlized`. When NETWORK_NAME is unset the script # derives it from the Gitea container; when it points somewhere Gitea is not, # the deployment FAILS loudly instead of silently printing a webhook URL that # nothing can reach. Escape hatches: CREATE_NETWORK=true creates a missing # network (fresh bootstrap), SKIP_BUILD=true uses a pre-built image, and # SKIP_VERIFY=true skips the reachability checks (remote/HTTPS instance). set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$HERE" ENV_FILE="${ENV_FILE:-$HERE/.env}" if [[ -f "$ENV_FILE" ]]; then # shellcheck disable=SC1090 set -a; . "$ENV_FILE"; set +a else echo "no $ENV_FILE; copy .env.example and fill it in" >&2 exit 2 fi GITEA_URL="${GITEA_URL:?set GITEA_URL to the target instance -- the Gitea container name on NETWORK_NAME, e.g. http://gitea_dcntrlized:3000}" GITEA_TOKEN="${GITEA_TOKEN:?set GITEA_TOKEN to a bot-account token}" WEBHOOK_SECRET="${WEBHOOK_SECRET:?set WEBHOOK_SECRET: webhooks are the only trigger}" CONTAINER_NAME="${CONTAINER_NAME:-license-bot}" STATUS_CONTEXT="${STATUS_CONTEXT:-license-bot / addlicense}" GITEA_PUBLIC_URL="${GITEA_PUBLIC_URL:-$GITEA_URL}" BOT_IMAGE="${LICENSE_BOT_IMAGE:-license-bot:dev}" BOT_PORT="${BOT_PORT:-18080}" # gitea_networks prints every Docker network attached to a running container # whose name contains "gitea", one per line. gitea_networks() { local ids ids="$(docker ps -q --filter 'name=gitea' 2>/dev/null)" [[ -z "$ids" ]] && return 0 # shellcheck disable=SC2086 # ids is a deliberate word list of container ids docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' $ids 2>/dev/null \ | sed '/^[[:space:]]*$/d' | sort -u } if [[ -z "${NETWORK_NAME:-}" ]]; then NETWORK_NAME="$(gitea_networks | head -n1)" if [[ -z "$NETWORK_NAME" ]]; then echo "no NETWORK_NAME in .env, and no running container with 'gitea' in its" >&2 echo "name to derive it from. Set NETWORK_NAME to the Docker network Gitea" >&2 echo "runs on (see 'docker network ls')." >&2 exit 2 fi echo "==> NETWORK_NAME unset; using '$NETWORK_NAME' (from the Gitea container)" fi # Export so Compose interpolates the detected value too (set -a is off here). export NETWORK_NAME echo "==> instance: $GITEA_URL" echo "==> links: $GITEA_PUBLIC_URL (URL used in the bot's comments)" echo "==> network: $NETWORK_NAME" echo "==> container: $CONTAINER_NAME (webhook URL http://$CONTAINER_NAME:8080/hook)" echo "==> webhook: use the CONTAINER port 8080, never the host publish ($BOT_PORT)" echo " -- Gitea delivers over $NETWORK_NAME, not through the host." echo "==> gate: $STATUS_CONTEXT" echo "==> allowlist: ${REPOS:-}" if ! docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then if [[ "${CREATE_NETWORK:-false}" == "true" ]]; then echo "==> network $NETWORK_NAME does not exist; creating it (CREATE_NETWORK=true)" docker network create "$NETWORK_NAME" >/dev/null else echo "FATAL: network '$NETWORK_NAME' does not exist." >&2 echo " It must be the network Gitea already runs on, so 'external: true'" >&2 echo " means Compose will not create it. Find the real name with:" >&2 echo " docker ps --filter name=gitea --format '{{.Names}}'" >&2 echo " docker inspect -f '{{range \$k,\$v := .NetworkSettings.Networks}}{{\$k}} {{end}}' " >&2 echo " then set NETWORK_NAME in .env. (CREATE_NETWORK=true only for a" >&2 echo " fresh bootstrap where Gitea does not exist yet.)" >&2 exit 1 fi fi # A network that exists but does not carry Gitea is the failure mode this script # used to hide: the bot joins an empty network and every delivery dies with # "no such host" / "connection refused". Warn here, fail after the probe. if ! docker network inspect -f '{{range .Containers}}{{.Name}} {{end}}' "$NETWORK_NAME" 2>/dev/null \ | grep -qi gitea; then echo "==> WARNING: no container with 'gitea' in its name is on $NETWORK_NAME." >&2 nets="$(gitea_networks)" if [[ -n "$nets" ]]; then echo " Gitea is attached to: $(echo "$nets" | tr '\n' ' ')" >&2 echo " Set NETWORK_NAME to that value." >&2 fi fi if [[ "${SKIP_BUILD:-false}" == "true" ]]; then echo "==> start (SKIP_BUILD=true; using the existing $BOT_IMAGE)" docker compose up -d --no-build else echo "==> build + start" docker compose up -d --build fi docker compose ps echo "==> wait for the healthcheck" cid="$(docker compose ps -q license-bot)" for _ in $(seq 1 20); do status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")" [[ "$status" == "healthy" || "$status" == "running" ]] && break sleep 2 done echo "container state: $status" docker logs --tail 5 "$cid" 2>&1 | sed 's/^/ /' if [[ "${SKIP_VERIFY:-false}" == "true" ]]; then echo "==> SKIP_VERIFY=true; not probing Gitea from $NETWORK_NAME" else echo "==> verify the instance is reachable and the token is accepted" # --entrypoint: the image's entrypoint is the bot, so `docker run image wget …` # would run `license-bot wget …` and fail. if docker run --rm --network "$NETWORK_NAME" --entrypoint wget "$BOT_IMAGE" \ -q -T5 -O- --header="Authorization: token $GITEA_TOKEN" \ "${GITEA_URL%/}/api/v1/user" 2>/dev/null | grep -q '"login"'; then echo "TOKEN_OK $GITEA_URL accepted the bot token from $NETWORK_NAME" else echo "TOKEN_FAIL could not authenticate against $GITEA_URL from $NETWORK_NAME" >&2 echo " NETWORK_NAME=$NETWORK_NAME is attached to:" >&2 docker network inspect -f '{{range .Containers}} {{.Name}}{{"\n"}}{{end}}' \ "$NETWORK_NAME" >&2 2>/dev/null || true echo " No Gitea above means NETWORK_NAME is the wrong network" >&2 echo " (a network defined in Gitea's own compose is named" >&2 echo " '_', not just the key). Otherwise check" >&2 echo " GITEA_URL (Gitea's container name on that network) and" >&2 echo " the token scopes. Re-run with SKIP_VERIFY=true only if" >&2 echo " Gitea genuinely lives elsewhere." >&2 exit 1 fi echo "==> verify Gitea can reach the webhook endpoint by its DNS name" if docker run --rm --network "$NETWORK_NAME" --entrypoint wget "$BOT_IMAGE" \ -q -T5 -O- "http://$CONTAINER_NAME:8080/healthz" 2>/dev/null | grep -q ok; then echo "WEBHOOK_OK http://$CONTAINER_NAME:8080/hook resolves from $NETWORK_NAME" else echo "WEBHOOK_FAIL Gitea would not reach the webhook: is the container on" >&2 echo " $NETWORK_NAME? The org webhook must point at" >&2 echo " http://$CONTAINER_NAME:8080/hook (container port, not $BOT_PORT)." >&2 exit 1 fi fi cat <)' even though the URL is reachable. Set it in the instance's environment (no app.ini edit needed) and restart Gitea: GITEA__security__ALLOWED_HOST_LIST=external,$CONTAINER_NAME (or the private subnet). Keep 'external' if the instance delivers other webhooks to public hosts. The older GITEA__webhook__ALLOWED_HOST_LIST / [webhook] key still works on 1.27 because Gitea reads it as a deprecated fallback, but it is REMOVED in v28 -- move it to [security] before upgrading. Names are matched before IPs, so the container name covers the private Docker address. 1. THE WEBHOOK (required -- webhooks are the only trigger). Register an ORGANIZATION webhook pointing at http://$CONTAINER_NAME:8080/hook, content type application/json, secret = the WEBHOOK_SECRET from .env, with the pull-request events. As an org admin (substitute the secret): ADMIN_TOKEN=... WEBHOOK_SECRET=... curl -fsS -X POST "${GITEA_URL%/}/api/v1/orgs/Dcntrlized/hooks" \ -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ -d "{\"type\":\"gitea\",\"active\":true,\"config\":{\"url\":\"http://$CONTAINER_NAME:8080/hook\",\"content_type\":\"json\",\"secret\":\"\$WEBHOOK_SECRET\"},\"events\":[\"pull_request\"]}" Fixing an existing hook whose URL is wrong (e.g. the host publish port): GET /api/v1/orgs/Dcntrlized/hooks to find the id, then PATCH it. Pass BOTH config.url and events -- omitting "events" rewrites the hook to push-only: curl -fsS -X PATCH "${GITEA_URL%/}/api/v1/orgs/Dcntrlized/hooks/" \ -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ -d '{"config":{"url":"http://$CONTAINER_NAME:8080/hook","content_type":"json"},"events":["pull_request"]}' 2. THE GATE. Branch protection on each default branch requiring the exact context '$STATUS_CONTEXT' -- nothing merges without it, so a pull request parked while the bot is down stays parked -- AND at least one human approval, so neither the author nor the bot alone can merge. As an org admin, per repository: ADMIN_TOKEN=... curl -fsS -X PATCH "${GITEA_URL%/}/api/v1/repos/Dcntrlized//branch_protections/main" \ -H "Authorization: token \$ADMIN_TOKEN" -H 'Content-Type: application/json' \ -d '{"branch_name":"main","enable_status_check":true,"status_check_contexts":["$STATUS_CONTEXT"],"required_approvals":1,"dismiss_stale_approvals":true,"block_on_outdated_branch":true}' Gitea rejects self-approval outright ("approve your own pull is not allowed", 422), so required_approvals=1 forces a second account with write access to approve. dismiss_stale_approvals is what makes the approval cover the tree the bot produced: the bot's own commit invalidates an approval given before it. Use POST .../branch_protections instead of PATCH to create protection on a branch that has none. The context string must match byte for byte or the gate silently never matches; verify with: curl -fsS "${GITEA_URL%/}/api/v1/repos/Dcntrlized//branch_protections" \ -H "Authorization: token \$ADMIN_TOKEN" 3. DRY RUN before trusting it: DRY_RUN=true docker compose up -d --force-recreate then open a test pull request, and remember only these payload actions start a check: opened, synchronize/synchronized, reopened, ready_for_review. A review request or a comment is ignored by design. 4. IPv6-only / no-egress host (the image cannot be built by Compose there): docker build --network=host -t $BOT_IMAGE . SKIP_BUILD=true ./deploy.sh Compose cannot grant the network.host build entitlement. Do not use an administrator token for the bot; give it a dedicated account with write:repository + read:issue + write:issue on the allowlisted repositories. NEXT