12 KiB
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
- On startup it reconciles once: every open pull request of every allowlisted
repository whose head does not already carry a successful
license-bot / addlicensestatus is checked. The status is the durable record of what the bot has already resolved, so the scan is self-healing without a cursor. - While running it receives Gitea
pull_requestwebhooks (HMAC-SHA256 verified againstWEBHOOK_SECRET, duplicate delivery ids ignored) and processesopened,synchronize,reopenedandready_for_review. - 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,renamedandcopiedare 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 reportedsuccess("no changed files need a license header") with no comment and no commit. Otherwise it postspendingfor those files and logs what it is about to check. - It clones the head branch (in-repo or fork), runs
google/addlicensev1.2.0 with the repository's own.license-header.tpland.licenseignoreover exactly those files. Because the scope includes modified files, a.licenseignorethat is missing an entry makes the bot rewrite a file the contributor only touched — keep vendored and generated paths listed there. - 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.
- 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. - 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:
successwhen the head carries the headers,failurewhen the contributor has to add them by hand (maintainer edits disabled),errorif 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 statuspending, 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
pendingby a crash. - A failed check (push refused, API error) resolves the status to
failureorerrorand 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:
- 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 withwebhook 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-botto Gitea'sapp.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. - 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. - 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
REPOSare 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 successfulwhen the required context is absent, still405when it ispending, and allowed (200) onsuccess. A fork PR run cannot post any status (403 user should have a permission to write to a repo). Requiring the exact contextlicense-bot / addlicenseis 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}/mergereturned200— 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.sockto job containers. It now pinscontainer.docker_host: "-",privileged: false,valid_volumes: []and an--internaljob network; a real job measuredSOCKET_ABSENT, no internet, the host IP unreachable andgitea:3000reachable. Configs + deploy script:infra/gitea-runner/. - STILL OPEN: the org
TOKENActions secret. It is the site-admin token (sha256matched, T2.4). The bot no longer has anything to do with it, so it can be deleted as soon asrelease.yamlgets its own least-privilegewrite:packagetoken, and the bot account's own token should be rotated onto a dedicated non-admin account scoped to the five repositories.