Files
2026-10-20 02:43:27 +01:00

252 lines
12 KiB
Bash
Executable File

#!/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 <https://www.gnu.org/licenses/>.
# 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 `<project>_<key>`, 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:-<default five 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}}' <gitea-container>" >&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 " '<project>_<key>', 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 <<NEXT
Deployed. Remaining instance-side steps:
0. PREREQUISITE: let Gitea call this service. With its default
security.ALLOWED_HOST_LIST, Gitea refuses webhook deliveries to an internal
Docker alias -- the delivery is recorded as failed with
webhook can only call allowed HTTP servers ... deny '$CONTAINER_NAME(<ip>)'
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/<id>" \
-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/<repo>/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/<repo>/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