# 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` / `copyleft+bot@dcntrlized.org` | 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: ```sh 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: 0. **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()'` 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. 1. **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. 2. **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`](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.