Files
infra/license-bot
2026-10-20 02:43:27 +01:00
..
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00
2026-10-20 02:43:27 +01:00

license-bot

Adds the AGPL-3.0 license header to files a pull request adds without one, in a separate, revertable commit on the pull request head, comments on the pull request, and posts the license-bot / addlicense commit status that branch protection requires before merging. Works for same-repository pull requests and for fork pull requests.

Version v0.0.1 takes no arguments and has one trigger: Gitea webhooks.

Why a service instead of a Gitea Actions workflow

  • Gitea evaluates a pull request's workflows from the head branch, so a contributor (or a fork) can edit, delete or fake the job — including reporting a green status. A workflow therefore cannot be an enforcement boundary.
  • Actions runs triggered by fork pull requests get no repository secrets and a read-only token, so a workflow can never hold the credentials needed to push a commit back to a contributor's fork.
  • Nothing permissive lives in Actions. The bot-account token exists only in this service's environment on the Gitea host. An in-repo PR workflow can read every repo and org Actions secret (measured: the value left the host over HTTP while the log showed ***), so the fewer credentials reachable from CI, the smaller the blast radius.
  • The merge gate is the commit status, not a workflow run: a required branch-protection check that was never posted blocks the merge (405), while a required scoped workflow that never ran does not (200, measured on 1.27.2).

How it works

  1. On startup it reconciles once: every open pull request of every allowlisted repository whose head does not already carry a successful license-bot / addlicense status is checked. The status is the durable record of what the bot has already resolved, so the scan is self-healing without a cursor.
  2. While running it receives Gitea pull_request webhooks (HMAC-SHA256 verified against WEBHOOK_SECRET, duplicate delivery ids ignored) and processes opened, synchronize, reopened and ready_for_review.
  3. It re-reads the pull request from the API (the payload is only a hint) and lists the files the pull request changed that still exist at the head: added, modified, renamed and copied are all in scope, so an existing unlicensed file gets its header the first time any pull request touches it. Removals are skipped (there is no content left to license), and a deletion-only pull request is reported success ("no changed files need a license header") with no comment and no commit. Otherwise it posts pending for those files and logs what it is about to check.
  4. It clones the head branch (in-repo or fork), runs google/addlicense v1.2.0 with the repository's own .license-header.tpl and .licenseignore over exactly those files. Because the scope includes modified files, a .licenseignore that is missing an entry makes the bot rewrite a file the contributor only touched — keep vendored and generated paths listed there.
  5. If a header was added: it creates its own commit on top of the pull request head — never an amend, never a force-push. The contributor's commit keeps its sha, message and author; the bot is the committer of the extra commit, whose body says it can be reverted or dropped freely.
  6. It pushes with a plain fast-forward git push. If the branch moved meanwhile the push is rejected; the bot drops its local work, re-reads the tip, re-applies the headers and retries (up to 5 attempts, then the next event retries). It never rewrites somebody else's history.
  7. It replaces its comment — the previous one is deleted and a fresh one posted, never edited in place, so the message always sits at the bottom of the timeline next to the latest status — and resolves the status: success when the head carries the headers, failure when the contributor has to add them by hand (maintainer edits disabled), error if the bot itself failed. A pass that finds nothing left to fix deletes a stale problem comment so a resolved failure never sits next to a green status. A crash leaves the status pending, which keeps the gate closed until the startup reconcile resolves it.

Loop safety: the bot's own commit produces one more synchronize event; that run finds nothing to fix and reports success. Processed (repo, PR, sha) triples are cached in memory and already-green heads are skipped.

Failure semantics

Webhooks are the only trigger, so a check that did not run means the service was down, not that some other component failed. The consequences are bounded by design:

  • If the service is down when a pull request opens, no status is posted; the gate (if enabled) refuses to merge — nothing unlicensed slips through.
  • When the service starts, the reconciliation pass processes every open pull request that is not green, including ones left pending by a crash.
  • A failed check (push refused, API error) resolves the status to failure or error and comments, so a parked pull request is visible rather than silently green.

Configuration

All configuration is environment driven — the image contains no secrets and the process takes no arguments.

Variable Default Meaning
GITEA_URL dev-VM fallback, required by the compose Target instance base URL. On an instance whose Gitea runs on the same Docker daemon use the network alias (e.g. http://gitea:3000) so the bot needs no egress and no published port.
GITEA_PUBLIC_URL GITEA_URL URL used for the links in the bot's comments. Set it when GITEA_URL is the internal Docker alias, otherwise a reader's browser cannot resolve http://gitea:3000.
GITEA_TOKEN — (required) Bot-account token with write access to the target repositories. Needs write:repository (clone/push/commit status) plus read:issue + write:issue (read the files list, post/update the comment) — a repository-only token authenticates but cannot comment.
WEBHOOK_SECRET — (required) HMAC-SHA256 secret of the org webhook. Every delivery is rejected unless X-Gitea-Signature matches.
REPOS the five Dcntrlized repos Comma-separated owner/name allowlist. Nothing outside it is ever touched.
STATUS_CONTEXT license-bot / addlicense Commit-status context; must equal the branch-protection string byte for byte.
LISTEN :8080 Webhook and health listen address.
DRY_RUN false Inspect and report but never commit or push.
BOT_NAME / BOT_EMAIL Dcntrlized Auto CopyLeft Bot / [email protected] Committer identity of the bot's commit.
HOLDER Dcntrlized addlicense -c value.
ADDLICENSE_BIN addlicense addlicense executable.
WORK_ROOT $TMPDIR Where per-pull-request worktrees are created.
LOG_LEVEL info debug, info, warn or error.

Copy .env.example and fill in GITEA_TOKEN and WEBHOOK_SECRET. Never commit that file.

Routes: POST /hook (webhook, POST /webhook is accepted as an alias) and GET /healthz (container healthcheck).

Deploying to an instance

Three values plus one command:

cp .env.example .env
# GITEA_URL      = the instance (network alias when Gitea shares the daemon)
# GITEA_TOKEN    = a DEDICATED BOT ACCOUNT token, never an admin token
# WEBHOOK_SECRET = openssl rand -hex 32
# NETWORK_NAME   = the network the Gitea container already runs on
./deploy.sh

deploy.sh creates the network if missing, builds and starts the container, waits for the healthcheck, verifies the token against GET /api/v1/user and verifies that http://license-bot:8080/hook resolves from Gitea's own network — the exact URL the webhook has to reach. It then prints the two instance-side steps with ready-to-paste commands:

  1. Let Gitea reach the service (prerequisite). With its default security.ALLOWED_HOST_LIST, Gitea refuses to deliver a webhook to an internal Docker alias: the delivery is recorded as failed with webhook can only call allowed HTTP servers … deny 'license-bot(<ip>)' even though the URL is perfectly reachable from inside the container. Add [security] → ALLOWED_HOST_LIST = license-bot to Gitea's app.ini (or list the private subnet) and restart Gitea. This is a Gitea-side allowlist, not a networking problem — check it first if deliveries never arrive.
  2. The webhook (required). An organization webhook to http://license-bot:8080/hook, content type JSON, secret = WEBHOOK_SECRET, events = pull_request. Without it nothing is ever checked.
  3. The gate. Branch protection on each default branch requiring the exact context license-bot / addlicense, plus at least one approval so the author cannot self-merge. Verify afterwards that the context string matches exactly: a mismatch silently never blocks anything.

A safe first run is DRY_RUN=true docker compose up -d --force-recreate: the bot comments and posts statuses but never commits.

There is no Actions runner dependency anywhere in this path: the bot only needs to be reachable from Gitea and to reach Gitea.

Security notes

  • The token is only ever read from the environment. It is never logged: URLs embedded in git errors are redacted before they reach a log line or a PR comment.
  • Webhook deliveries are rejected unless the HMAC-SHA256 signature matches, and duplicate delivery ids are ignored.
  • Only repositories in REPOS are processed.
  • The bot never rewrites history: it adds a commit and uses a plain fast-forward push, so a push that lost a race against a new contributor commit is rejected. The bot then discards its local commit, re-reads the branch, re-runs the check on the new tip and pushes again, repeating until it succeeds or finds nothing left to change (bounded at 5 attempts, after which the next event retries). Nothing is ever overwritten.
  • If the contributor disabled maintainer edits on the pull request, the push is refused by the server; the bot then comments that the header must be added by hand and reports a failing status. It never bypasses that.

Threat model (measured on Gitea 1.27.2)

The full per-test methodology and raw observations are in SECURITY.md. Summary:

  • Merge gate. Branch-protection checks are enforced on the merge API: a normal writer's merge is refused 405 Not all required status checks successful when the required context is absent, still 405 when it is pending, and allowed (200) on success. A fork PR run cannot post any status (403 user should have a permission to write to a repo). Requiring the exact context license-bot / addlicense is therefore a fail-closed gate: if the bot is down and never posts, nothing merges.
  • A repository writer can forge any context (201); write access is the trust boundary, as it is for any other repository change.
  • Do not use required scoped workflows as the gate on 1.27.2. With a scoped workflow marked required and failing, a writer's POST /pulls/{n}/merge returned 200 — the requirement is UI-only.
  • Log masking is not protection. An in-repo PR workflow exfiltrated a repo secret and an org secret over HTTP (201) although the log showed ***. The only real question is reachability plus egress — which is why the bot's token stays on the host.
  • RESOLVED — job-container host read (T2.3). The dev runner used to expose /var/run/docker.sock to job containers. It now pins container.docker_host: "-", privileged: false, valid_volumes: [] and an --internal job network; a real job measured SOCKET_ABSENT, no internet, the host IP unreachable and gitea:3000 reachable. Configs + deploy script: infra/gitea-runner/.
  • STILL OPEN: the org TOKEN Actions secret. It is the site-admin token (sha256 matched, T2.4). The bot no longer has anything to do with it, so it can be deleted as soon as release.yaml gets its own least-privilege write:package token, and the bot account's own token should be rotated onto a dedicated non-admin account scoped to the five repositories.