init
This commit is contained in:
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user