This commit is contained in:
2026-10-20 02:43:27 +01:00
commit 545a9b6b88
21 changed files with 3654 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# infra
This repo holds all Dcntrlized organization infrastructure that could benefit from having version control (scripts, docker compose files, non-repo-bound actions, etc)
Note that a cleanup might be necessary to remove dev vm references (tests expect vm to be up and at a specific IP, same applies to core Dcntrlized repos)
| Directory | What it is |
|---|---|
| `license-bot/` | The AGPL header bot that enforces the branch-protection gate. |
+4
View File
@@ -0,0 +1,4 @@
.git
.env
license-bot
*.log
+68
View File
@@ -0,0 +1,68 @@
# Copy to .env and fill in the secrets. Never commit .env.
#
# Three values are what a new instance needs:
# 1. GITEA_URL -- the instance the bot manages
# 2. GITEA_TOKEN -- a DEDICATED BOT ACCOUNT's token (never an admin token)
# 3. WEBHOOK_SECRET -- the HMAC secret of the org webhook (webhooks are the
# only trigger)
# plus NETWORK_NAME, the Docker network Gitea already runs on.
#
# On an instance whose Gitea runs on the same Docker daemon, point GITEA_URL at
# the name Gitea's container has ON that network, so the bot needs no egress and
# no published port. The alias is instance-specific -- it is the Gitea
# container's name plus its Compose service name:
# GITEA_URL=http://gitea:3000 # a Compose service named `gitea`
# GITEA_URL=http://gitea_dcntrlized:3000 # prod: /docker/org.dcntrlized.git
#
# NETWORK_NAME is the REAL (project-prefixed) Docker network name, not just the
# key in Gitea's compose file -- a Compose-defined network is named
# `<project>_<key>`. Derive it from the running Gitea container:
# docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' gitea_dcntrlized
# -> orgdcntrlizedgit_gitea_dcntrlized
# deploy.sh does this for you when NETWORK_NAME is empty.
#
# Remote/HTTPS instance (e.g. behind a tunnel or reverse proxy):
# GITEA_URL=https://git.example.org
# NETWORK_NAME=dcntrlized_net
GITEA_URL=
# The URL a HUMAN follows from the bot's comments (commit/file links). Set this
# whenever GITEA_URL is the internal alias so the links are clickable:
# GITEA_URL=http://gitea:3000
# GITEA_PUBLIC_URL=https://git.example.org
# Defaults to GITEA_URL when empty.
GITEA_PUBLIC_URL=
# Bot-account token with write access to the allowlisted repositories. Scopes:
# write:repository (clone/push/commit status) + read:issue + write:issue
# (read the PR file list, post/update the comment).
GITEA_TOKEN=
# HMAC-SHA256 secret configured on the Gitea webhook. Generate one with:
# openssl rand -hex 32
# The bot rejects any delivery whose X-Gitea-Signature does not match.
WEBHOOK_SECRET=
# Docker network that already contains Gitea (see docker-compose.yaml). This is
# the real Docker name, project-prefixed if Gitea's compose defines it. Leave
# empty and deploy.sh derives it from the Gitea container; set it explicitly
# (recommended) once you know it.
NETWORK_NAME=
# Comma-separated owner/name allowlist. Nothing outside it is ever touched.
REPOS=Dcntrlized/Maestro,Dcntrlized/KeepNote,Dcntrlized/Calendar,Dcntrlized/dcntrlized-auth,Dcntrlized/dcntrlized-db
# Commit-status context. It must equal the string configured in branch
# protection, byte for byte, or the merge gate silently never matches.
STATUS_CONTEXT="license-bot / addlicense"
LISTEN=:8080
DRY_RUN=false
LOG_LEVEL=info
# Optional host binding for the webhook/health port (loopback by default).
BOT_BIND=127.0.0.1
BOT_PORT=18080
# Optional: pin the image tag (default license-bot:dev, built locally).
# LICENSE_BOT_IMAGE=registry.example/owner/license-bot:1
+4
View File
@@ -0,0 +1,4 @@
.env
/bin/
/license-bot
*.log
+14
View File
@@ -0,0 +1,14 @@
Copyright (C) {{.Year}} {{.Holder}}
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/>.
+30
View File
@@ -0,0 +1,30 @@
# addlicense ignore patterns (doublestar globs, relative to the repo root).
# Blank lines and lines starting with '#' are skipped. addlicense validates every
# pattern and skips file types it does not understand on its own, so this file is
# only the escape hatch for files that must not carry an AGPL header.
# generated / vendored / build output
**/node_modules/**
**/vendor/**
**/tmp/**
**/dist/**
**/build/**
# non-code and config files
**/*.md
**/*.json
**/*.yaml
**/*.yml
**/*.toml
**/*.lock
**/*.sum
**/*.mod
**/*.log
**/*.txt
**/*.debug
# repo plumbing
.licenseignore
.gitignore
.gitmodules
.vscode/**
+36
View File
@@ -0,0 +1,36 @@
# 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/>.
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
RUN apk add --no-cache git ca-certificates
# addlicense is deliberately the same binary/version the Gitea workflow uses.
RUN go install github.com/google/[email protected]
WORKDIR /src
COPY go.mod ./
COPY . .
ARG VERSION=0.0.1
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/license-bot .
FROM alpine:3.22
RUN apk add --no-cache git ca-certificates \
&& adduser -D -u 10001 -h /home/bot bot
COPY --from=build /out/license-bot /usr/local/bin/license-bot
COPY --from=build /go/bin/addlicense /usr/local/bin/addlicense
USER bot
WORKDIR /home/bot
EXPOSE 8080
ENTRYPOINT ["license-bot"]
+204
View File
@@ -0,0 +1,204 @@
# 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:
```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(<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.
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.
+302
View File
@@ -0,0 +1,302 @@
# Verification — AGPL license bot, merge gate and key exposure
Measured on the development VM (Gitea **1.27.2**, runner `alpine-runner`,
label `alpine-latest:docker://node:alpine`) on 2026-10-07. Everything below was
observed, not inferred; each test lists the method, the raw observation and the
verdict. Where a result is version- or host-specific it is called out.
Two questions are answered:
1. **Is the bot's `license-bot / addlicense` status authoritative — can a PR that
is not supposed to be trusted fake a green?**
2. **Can anyone get a key out?** Masking in logs is not evidence of safety: the
only question is whether a value is reachable by an actor and can leave the
host.
---
## Test 1 — authoritativeness / "fake greens"
### T1.1 An untrusted (fork) PR run cannot post a commit status
- **Hypothesis.** A fork PR run's `secrets.GITHUB_TOKEN` has no write access, so
it cannot create the status the gate depends on.
- **Method.** Workflow on a fork branch (`admin/license-bot-lab:forge-test`)
running as a fork PR:
`POST /api/v1/repos/Dcntrlized/license-bot-lab/statuses/<head_sha>` with
`{"context":"license-bot / addlicense","state":"success"}` using
`${{ secrets.GITHUB_TOKEN }}`; the same call again against the fork's own repo;
then `GET /repos/{repo}/commits/{sha}/statuses` read back by the admin API.
- **Observation.** `BASE_HTTP_403` and `FORK_HTTP_403`, body
`{"message":"user should have a permission to write to a repo"}`; no
`license-bot / addlicense` status ever appeared on the PR head. The same fork
run *can* post a comment (`201`).
- **Verdict.** PASS for the platform mechanism: a fork PR cannot produce the
suffix-free context the bot owns. It can still post *comments*.
### T1.2 A same-repo (in-repo) PR run can post any context
- **Hypothesis.** A contributor with repository write access can forge the bot's
exact context through their own workflow.
- **Method.** In-repo branch workflow, `POST .../statuses/<head_sha>` with
`{"context":"license-bot / addlicense","state":"success"}`.
- **Observation.** `FORGE_HTTP_201`; `GET .../statuses` then listed
`success license-bot / addlicense` with the description the workflow set
(`forged by a contributor workflow`).
- **Verdict.** FAIL by design: any writer can forge any context. Write access is
the trust boundary (a writer can equally edit the workflow or push directly);
the gate protects against *untrusted* actors and honest mistakes, not against
collaborators.
### T1.3 Required branch-protection checks are enforced on the merge API
- **Hypothesis.** With branch protection requiring a context that nobody posts, a
normal writer's merge is refused — i.e. required checks are a real gate.
- **Method.** `POST /repos/{r}/branch_protections`
(`branch_name=main`, `enable_status_check=true`,
`status_check_contexts=["nobody-posted-this-context"]`), then
`POST /repos/{r}/pulls/{n}/merge` as the non-admin collaborator `gateuser`
(`Authorization: token <gateuser>`), `{"Do":"merge"}`.
- **Observation.** `405 {"message":"Not all required status checks successful"}`.
Removing the requirement and re-merging succeeded (`200`).
- **Verdict.** PASS: branch protection is enforced on the REST merge path for a
normal writer. Caveat: admins can override, and contexts match by exact string.
- **Re-measured 2026-10-08 on a protected throwaway branch** (`gate-target`,
context `license-bot / addlicense`), which pins the fail-closed property the
webhook-only design relies on:
| State of the required context | `POST /pulls/57/merge` |
|---|---|
| **absent** (bot down, never posted) | `405 Not all required status checks successful` |
| `pending` | `405` |
| `success` | `200` |
Cleanup: protection removed, both probe branches deleted, `main` untouched.
So "the bot is down" means "no status" means "no merge" — an unlicensed pull
request cannot be merged by accident.
### T1.4 A *required scoped workflow* is NOT enforced on the merge API (1.27.2)
- **Hypothesis.** If a scoped workflow is registered from a source repo and marked
required, a failing run blocks merges — making it the natural public gate.
- **Method.** Source repo `Dcntrlized/ci-workflows` with
`.gitea/scoped_workflows/gate.yml` (a job that always exits 1). Registered on
`/org/Dcntrlized/settings/actions/scoped-workflows` via
`POST .../add` `repo_name=ci-workflows` (bare name only — `owner/name` and the
numeric id are rejected `400 Repository not found.`), then marked required via
`POST .../required` with `repo_id`, `workflow_ids=gate.yml`,
`required_workflow_ids=gate.yml`,
`required_patterns[gate.yml]="Dcntrlized/ci-workflows: Gate / *"`. Consumer:
throwaway `Dcntrlized/gate-lab`, PR with a real failing status
`Dcntrlized/ci-workflows: Gate / check (pull_request) = failure` on the head.
- **Observation.** The PR page showed *"Some required checks failed"*, but
`POST /pulls/{n}/merge` returned **200** — both for the site admin (PR B) and
for the non-admin writer `gateuser` (PR 4, head `7c00336c`). A required pattern
that never posts does not block either.
- **Verdict.** FAIL: on 1.27.2 required scoped workflows are a UI-only signal,
not an API-enforced gate. Do not rely on them. (Upstream 28.1.0 not tested.)
### T1.5 Scoped workflows do not leak consumer secrets to fork PRs
- **Hypothesis.** A scoped workflow sourced from another repo might receive the
consumer's repo/org secrets even on a fork PR.
- **Method.** A scoped `secret-probe.yml` registered from `ci-workflows` tested
presence of the consumer's repo secret, an org secret and `secrets.GITHUB_TOKEN`
on a fork PR and on an in-repo PR (presence via `[ -n "$VAR" ]`, so no value is
needed).
- **Observation.** Fork PR: `PROBE_secret=[]`,
`PROBE_org_secret_present=[false]`, `PROBE_token_present=[true]`. In-repo PR:
`PROBE_secret=[***]`, `PROBE_org_secret_present=[true]` (values masked in the
log).
- **Verdict.** PASS: scoped workflows inherit the consumer's secret rules; forks
get none. (Masking of the in-repo log is cosmetic — see Test 2.)
### T1.6 Positive control — the bot's own status
- **Method.** The real bot (own account token) posts
`POST /repos/{r}/statuses/<sha>` with its context in the same lab and the status
is read back via the API.
- **Observation.** Context is exactly `license-bot / addlicense`, no `(event)`
suffix, description `license headers added by the bot`.
- **Verdict.** The context the gate should require is the one T1.1 shows forks
cannot create.
### Test 1 conclusions
| Test | Result |
|---|---|
| T1.1 fork cannot post a status | 403 — PASS |
| T1.2 in-repo writer can forge any context | 201 — FAIL by design (write = trust) |
| T1.3 required branch-protection check blocks a writer's merge | 405 — PASS |
| T1.4 required scoped workflow blocks a merge | 200 — FAIL (UI-only on 1.27.2) |
| T1.5 scoped fork runs get no consumer secrets | no secret present — PASS |
| T1.6 bot context identifier | `license-bot / addlicense` (suffix-free) |
**Gate to use:** branch protection on the default branch requiring the exact
context `license-bot / addlicense`, plus at least one approval so an author cannot
self-merge. It is enforced on the merge API (T1.3) and unforgeable by fork PRs
(T1.1); it does not stop a collaborator (T1.2) or an admin.
**Not covered by Test 1:** whether a run *without* write access can reach secrets
by other means — that is Test 2, and the answer changes the conclusion above,
because an attacker who obtains the admin token can forge any status.
---
## Test 2 — key exposure (reachability and egress, not masking)
Methodology: unique canary values are planted as a repo secret and an org secret,
a workflow prints their **lengths** and posts them **base64-encoded to the PR as a
comment** through the run token (a real outbound HTTP POST, the "malicious site"
model). The same probe runs from an in-repo PR and from a fork PR. A control step
proves the network path and the runner's capabilities, so a "nothing received"
result means "nothing to send", not "no channel".
### T2.1 An in-repo PR exfiltrates a repo secret and an org secret
- **Observation.** `REPO_CANARY_LEN=23 ORG_CANARY_LEN=26 BOT_TOKEN_LEN=0
RUN_TOKEN_LEN=40`; the log lines `REPO_CANARY_B64=***` /
`ORG_CANARY_B64=***` (masked) but `EXFIL_POST_HTTP=201`; the created comment
body decoded to exactly `CANARY-61ca3f58dd2d9ae3` and
`ORGCANARY-d0c7e3ad5b14af5d`.
- **Verdict.** FAIL (severe). Log masking protects nothing; the values left the
host. Any collaborator who can open an in-repo PR reads every repo/org secret.
### T2.2 A fork PR gets no repo/org secrets
- **Observation.** `LEN repo=0 org=0 run_token=40`,
`EXFIL_POST_HTTP=201`, comment
`FORK EXFIL probe: repo_canary_len=0 org_canary_len=0 run_token_len=40`.
- **Verdict.** PASS for direct secret access: the platform withholds repo/org
secrets from fork runs; the run token present is base-repo read-only.
### T2.3 CRITICAL — a fork PR escapes the runner sandbox through the Docker socket
- **Hypothesis.** Untrusted builds might still reach host resources through the
runner.
- **Method.** From the *fork* PR job: `ls -l /var/run/docker.sock`;
`apk add --no-cache docker-cli`; `docker ps --format '{{.Names}}'`; then
`docker run --rm -v /:/host alpine sh -c 'echo HOST_READ_OK;
cat /host/etc/hostname; ls /host | head -5'`.
- **Observation.** Socket present: `srw-rw---- 1 root 969 /var/run/docker.sock`.
`docker ps` listed `GITEA-ACTIONS-TASK-…` (the job), `gitea_runner-runner-1`
and **`gitea`**. The host-root bind mount worked: `HOST_READ_OK`,
`dcntrlized_dev`, `bin boot dev etc home`.
- **Verdict.** CRITICAL FAIL. A fork PR — no write access, no secrets — has
root-equivalent access to the runner host: read/write the host filesystem
(including Gitea's data directory and database, i.e. every org/repo secret and
admin token), `docker exec` any container (including `gitea`) and start
privileged containers. This defeats T2.2 in practice: secrets are reachable
indirectly, and the T1.1 "forks cannot fake a status" property is moot once the
attacker steals an admin token.
- **Remediation (required before opening the repos).** In the runner config
(`/home/admin/gitea_runner/config.yaml` on the dev VM) set
`container.docker_host: "-"`. Upstream documents this exact value as "runner
will find an available docker host automatically, but won't mount it into the
job and action containers" (gitea runner `config.example.yaml`, and
`run_context.go`: the socket bind is skipped when `containerDaemonSocket()`
returns `-`). Keep `container.valid_volumes: []` (an empty list means no
workflow-declared volume can be mounted) and leave `container.privileged:
false`. Then restart the runner and re-run T2.3: `ls /var/run/docker.sock` must
fail and a `docker` CLI in the job must not reach a daemon. Until that holds,
no other mitigation matters. A rootless/DIND or VM-isolated runner is the
stronger option for public repositories.
- **Status: FIXED and verified 2026-10-07.** The hardened runner configs are
versioned in the root repo at `infra/gitea-runner/` and deployed on the dev VM
(`container.docker_host: "-"`, an `--internal` job network with Gitea as its
only attached service, `privileged: false`, `valid_volumes: []`, and a job
image with the tools baked in so no workflow needs egress). Measured from a
job after the change: `SOCKET_ABSENT`; `example.com` does not even resolve;
the host (`192.168.122.183:3000`) is `Network unreachable`; `gitea:3000`
answers `200`; the job route is only `172.31.240.0/24 dev eth0 scope link`;
and workflows declaring `--network host`, `--privileged` or `--volume /etc/...`
are ignored by the runner. The runner was re-registered so the job context URL
is `gitea:3000` (the host address is unreachable by design). The fork probe
that previously read the host now finds neither the socket nor a network path.
Verification runs on the lab repo: isolation probe run 179; scoped
`license-check.yml` run 180 and repo `.gitea/workflows/license.yml` run 201
both succeed with `SERVER: http://gitea:3000`. The two advisory files share
`concurrency.group: license-headers-<PR>`, so exactly one of them runs per PR
(the scoped one normally wins; the repo file takes over if the scoped workflow
is unregistered) — a `cancelled` repo run is the expected dedup, not a
failure.
### T2.4 CRITICAL — the org `TOKEN` secret is the admin/bot token
- **Hypothesis.** The token the bot uses might also sit in Actions secrets, where
any PR workflow can read it.
- **Method.** An in-repo PR workflow printed `len` and `sha256` of
`secrets.TOKEN` (no plaintext); the same hash was computed locally for the
token in `Maestro/.env` that drives the bot.
- **Observation.** `ORG_TOKEN_LEN=40 SHA=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad`;
local bot token: `len=40
sha=fd539c2ffd50376f6747a05424d4e5fa6e02c67da35cebf3001a2a19524418ad` —
identical. (`BOT_TOKEN_LEN=0`: it is not duplicated under another name.)
- **Verdict.** CRITICAL FAIL. The site-admin token that authenticates the bot is
readable by every in-repo PR workflow, and by any fork PR through T2.3.
- **Remediation.** Rotate it; give the bot a dedicated least-privilege account
(`write:repository` + `read:issue` + `write:issue`, scoped to the five repos);
stop storing that token as an org/repo Actions secret (release publishing needs
a separate, approval-gated or push-only path).
- **Update 2026-10-08.** The bot no longer has any Actions dependency: it is the
webhook-only service described below, and its token lives only in the
container's environment on the Gitea host. The org `TOKEN` secret is now used
by nothing except `release.yaml`'s registry auth, so it can be deleted once
that workflow gets its own `write:package` token. The remaining work is the
**rotation** of the bot token onto a dedicated non-admin account.
### T2.5 Egress and runner capability control
- **Observation.** From the job: `NET_GITEA=200`, `NET_INTERNET=200`
(`http://example.com` reachable), `HAS_DOCKER=none` before installing the CLI.
- **Verdict.** A working outbound channel exists, so the negative result in T2.2
is meaningful, and the positive results in T2.1/T2.4 are exfiltration, not a
logging artifact.
### Test 2 conclusions
| Test | Reachable | Left the host | Result |
|---|---|---|---|
| T2.1 in-repo PR, repo+org secrets | yes | yes (HTTP 201, comment) | FAIL — masking is cosmetic |
| T2.2 fork PR, repo/org secrets | no | n/a (channel worked, 201) | PASS for direct access |
| T2.3 fork PR, Docker socket / host root | yes | yes (host FS read) | CRITICAL — runner sandbox escape |
| T2.4 org `TOKEN` == bot/admin token | yes (in-repo) | yes | CRITICAL — rotate and relocate |
| T2.5 egress control | yes | — | confirms the tests above |
**Net:** the bot's design is right (separate revertable commit, exact suffix-free
status context, no token in the repo), but on this host an untrusted pull request
can read the whole machine, so no claim of "no key leakage" or "unforgeable gate"
holds until the runner is fixed. Order of work: **T2.3 remediation → token
rotation/T2.4 → branch protection on `license-bot / addlicense` → re-test.**
---
## Update 2026-10-08 — webhook-only v0.0.1
The service was folded down to one trigger and one process, which removes the
last piece of the exposure story that lived in Actions:
- **Webhooks are the only trigger.** Gitea delivers HMAC-signed `pull_request`
events to `http://license-bot:8080/hook`; the bot verifies
`X-Gitea-Signature` and ignores duplicate `X-Gitea-Delivery` ids. A check that
did not run therefore means the service was down, not that some other
component failed.
- **Startup reconciliation replaces polling.** On boot the bot scans the open
pull requests of every allowlisted repository and processes those whose head
does not already carry a successful `license-bot / addlicense` status. The
commit status is the durable record of what the bot has resolved, so a
delivery lost while it was down, or a `pending` left by a crash, is repaired
without a cursor file and without a polling loop.
- **No arguments.** The binary takes no flags; everything is environment driven
(`v0.0.1`).
- **Reachability, measured on this instance.** A job/pod on the runner's
`--internal` job network reached a sidecar published under a name alias
(`HEALTH=ok`) and an unauthenticated request was rejected (`401`), while the
host IP and the internet stayed unreachable — so a sidecar/service on Gitea's
own Docker network is reachable by Gitea without widening the job network's
egress.
- **Advisory workflows are gone.** The read-only per-repository
`.gitea/workflows/license.yml` and the scoped `license-check.yml` were
removed: they were never the gate (T1.4) and the status is what branch
protection enforces (T1.3).
- **The gate** is still branch protection requiring the exact context
`license-bot / addlicense`; the absent-status case measured above is what makes
it fail-closed. Required approvals are configured separately by the operator.
+251
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
# 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/>.
# Deployment compose for the license bot. Instance-agnostic: every value that
# differs between instances comes from the environment (see .env.example);
# ./deploy.sh fills in the common defaults and verifies the result.
#
# cp .env.example .env # set GITEA_URL, GITEA_TOKEN, WEBHOOK_SECRET
# ./deploy.sh
#
# The bot is webhook-only, so it must share a Docker network with Gitea: the
# org webhook points at http://license-bot:8080/hook, using the CONTAINER port
# 8080 (the `ports:` publish below is the host binding and is never the webhook
# target). The token belongs to a bot account with write access to the
# allowlisted repositories and lives only in this container's environment --
# never in a workflow, never in this file.
services:
license-bot:
build:
context: .
args:
VERSION: ${VERSION:-0.0.1}
image: ${LICENSE_BOT_IMAGE:-license-bot:dev}
# The container name is the DNS name Gitea's webhook has to resolve.
container_name: ${CONTAINER_NAME:-license-bot}
restart: unless-stopped
environment:
# The instance base URL, addressed by the name Gitea's container has ON
# NETWORK_NAME. The alias is instance-specific -- it is the Gitea
# container name plus its Compose service name:
# http://gitea:3000 a Compose service named `gitea`
# http://gitea_dcntrlized:3000 prod (/docker/org.dcntrlized.git)
# Use the internal alias when Gitea runs on this Docker daemon, so the bot
# needs neither egress nor a published port.
GITEA_URL: ${GITEA_URL:?set GITEA_URL to the target instance -- the Gitea container name on NETWORK_NAME}
GITEA_TOKEN: ${GITEA_TOKEN:?set GITEA_TOKEN to a bot-account token}
# The name a human follows from the bot's PR comments. When GITEA_URL is
# the internal Docker alias (`http://gitea:3000`), set this to the public
# URL (`https://git.example.org`) or the links in the comment are dead.
GITEA_PUBLIC_URL: ${GITEA_PUBLIC_URL:-}
# HMAC secret of the org webhook. Required: webhooks are the only trigger.
WEBHOOK_SECRET: ${WEBHOOK_SECRET:?set WEBHOOK_SECRET to the org webhook secret}
REPOS: ${REPOS:-Dcntrlized/Maestro,Dcntrlized/KeepNote,Dcntrlized/Calendar,Dcntrlized/dcntrlized-auth,Dcntrlized/dcntrlized-db}
STATUS_CONTEXT: "${STATUS_CONTEXT:-license-bot / addlicense}"
LISTEN: ${LISTEN:-:8080}
DRY_RUN: ${DRY_RUN:-false}
LOG_LEVEL: ${LOG_LEVEL:-info}
ports:
# Optional: only for a manual curl or a health probe from the host. Gitea
# delivers webhooks over the shared Docker network, not through this.
- "${BOT_BIND:-127.0.0.1}:${BOT_PORT:-18080}:8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
networks:
- instance
networks:
# The REAL Docker network that already contains Gitea -- NOT necessarily the
# key in Gitea's own compose file. A network defined inside a Compose project
# is named `<project>_<key>`: prod's /docker/org.dcntrlized.git defines
# `gitea_dcntrlized`, which Docker names
# `orgdcntrlizedgit_gitea_dcntrlized`. `external: true` means Compose never
# creates or renames it, so a wrong value silently lands the bot on an empty
# network and every delivery fails with "no such host" / "connection
# refused". Find it with:
# docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' gitea_dcntrlized
# deploy.sh derives it from the Gitea container when unset, and fails loudly
# when the two are not on the same network.
instance:
external: true
name: ${NETWORK_NAME:?set NETWORK_NAME to the Docker network Gitea runs on}
+3
View File
@@ -0,0 +1,3 @@
module git.dcntrlized.org/Dcntrlized/license-bot
go 1.24
+642
View File
@@ -0,0 +1,642 @@
// 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/>.
// Package bot wires Gitea pull-request webhooks to the license fixer.
//
// The service is webhook-only: every run is caused by an HMAC-verified
// delivery, so a run that does not happen can only mean the service itself is
// down. On startup it reconciles every open pull request of the allowlisted
// repositories once, so a delivery lost while the service was down -- or a
// check left pending by a crash -- is replaired without a polling loop.
package bot
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/config"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser"
)
const (
// deliveryTTL bounds the replay cache for webhook delivery ids.
deliveryTTL = 5 * time.Minute
// maxDeliveries caps the replay cache; expired entries are pruned first.
maxDeliveries = 4096
// reconcileTimeout bounds the startup scan.
reconcileTimeout = 15 * time.Minute
// handleTimeout bounds one pull-request processing run.
handleTimeout = 5 * time.Minute
// problemMarker tags a comment that reports a problem the contributor has
// to act on. A pass that finds nothing left to fix deletes such a comment,
// so a resolved failure never sits next to a green status. The
// informational "the bot added the headers" comment carries no marker and
// is left in place.
problemMarker = "<!-- dcntrlized-copyleft-bot:problem -->"
)
// Bot reacts to pull requests by adding missing AGPL headers in a separate,
// revertable commit on the pull request head.
type Bot struct {
cfg config.Config
api *gitea.Client
log *slog.Logger
mu sync.Mutex
handled map[string]bool // repo#pr@sha already processed (or in progress)
seen map[string]time.Time // webhook delivery ids already accepted
}
// New builds a bot from configuration.
func New(cfg config.Config, log *slog.Logger) *Bot {
return &Bot{
cfg: cfg,
api: gitea.New(cfg.GiteaURL, cfg.Token),
log: log,
handled: make(map[string]bool),
seen: make(map[string]time.Time),
}
}
type webhookPayload struct {
Action string `json:"action"`
Number int `json:"number"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
PullRequest *struct {
Number int `json:"number"`
} `json:"pull_request"`
}
// allowedActions are the pull_request payload actions that start a check.
// Gitea 1.27.2 spells a head-branch push "synchronized" (measured: its
// X-Gitea-Event is pull_request with action synchronized), while the API/docs
// usually say "synchronize" -- accept both spellings.
var allowedActions = map[string]bool{
"opened": true,
"synchronize": true,
"synchronized": true,
"reopened": true,
"ready_for_review": true,
}
// SignatureValid verifies Gitea's X-Gitea-Signature (hex HMAC-SHA256 of the raw body).
func (b *Bot) SignatureValid(sig string, body []byte) bool {
sig = strings.TrimPrefix(strings.TrimSpace(sig), "sha256=")
if sig == "" || b.cfg.WebhookSecret == "" {
return false
}
mac := hmac.New(sha256.New, []byte(b.cfg.WebhookSecret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.ToLower(sig)), []byte(want))
}
// ServeHTTP routes the webhook endpoint and the health route.
func (b *Bot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/hook", "/webhook":
b.handleHook(w, r)
case "/healthz":
b.handleHealth(w, r)
default:
http.NotFound(w, r)
}
}
// handleHealth is the container health route: it answers as soon as the process
// is accepting connections.
func (b *Bot) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, "ok\n")
}
func (b *Bot) handleHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
if !b.SignatureValid(r.Header.Get("X-Gitea-Signature"), body) {
b.log.Warn("rejected webhook with bad signature", "remote", r.RemoteAddr)
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
if id := r.Header.Get("X-Gitea-Delivery"); b.duplicateDelivery(id) {
b.log.Info("ignoring duplicate webhook delivery", "delivery", id)
w.WriteHeader(http.StatusAccepted)
return
}
// Gitea's X-Gitea-Event carries the concrete sub-event, not the family:
// a push to a pull request head arrives as `pull_request_sync`, a review
// comment as `pull_request_comment`. Match the whole family and let the
// payload's `action` decide -- that is the field Gitea actually varies
// (opened/synchronize/reopened/ready_for_review), and every other
// sub-event (comment, review, label, milestone, assign) carries an action
// outside the allowlist. Measured on 1.27.2: a synchronize delivery has
// X-Gitea-Event: pull_request_sync with action: synchronize.
event := r.Header.Get("X-Gitea-Event")
if event != "" && !strings.HasPrefix(event, "pull_request") {
w.WriteHeader(http.StatusAccepted)
return
}
var p webhookPayload
if err := json.Unmarshal(body, &p); err != nil {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
if !allowedActions[p.Action] {
b.log.Debug("ignoring webhook", "event", event, "action", p.Action,
"delivery", r.Header.Get("X-Gitea-Delivery"))
w.WriteHeader(http.StatusAccepted)
return
}
b.log.Debug("webhook accepted", "event", event, "action", p.Action,
"delivery", r.Header.Get("X-Gitea-Delivery"))
number := p.Number
if p.PullRequest != nil && p.PullRequest.Number != 0 {
number = p.PullRequest.Number
}
repo := p.Repository.FullName
if number == 0 || !b.cfg.Allows(repo) {
w.WriteHeader(http.StatusAccepted)
return
}
// Acknowledge immediately: Gitea should not wait for git or API round-trips.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), handleTimeout)
defer cancel()
if err := b.HandlePull(ctx, repo, number); err != nil {
b.log.Error("handle pull failed", "repo", repo, "pr", number, "err", err)
}
}()
w.WriteHeader(http.StatusAccepted)
}
// duplicateDelivery reports whether a delivery id was already accepted inside
// the replay window, recording it otherwise. An empty id is never a duplicate.
func (b *Bot) duplicateDelivery(id string) bool {
if id == "" {
return false
}
now := time.Now()
b.mu.Lock()
defer b.mu.Unlock()
if t, ok := b.seen[id]; ok && now.Sub(t) < deliveryTTL {
return true
}
if len(b.seen) >= maxDeliveries {
for k, t := range b.seen {
if now.Sub(t) >= deliveryTTL {
delete(b.seen, k)
}
}
}
b.seen[id] = now
return false
}
// Run serves the webhook endpoint and reconciles every allowlisted repository
// once at startup, until ctx is done.
func (b *Bot) Run(ctx context.Context) error {
srv := &http.Server{Addr: b.cfg.Listen, Handler: b, ReadHeaderTimeout: 10 * time.Second}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
recCtx, recCancel := context.WithTimeout(runCtx, reconcileTimeout)
defer recCancel()
b.log.Info("startup reconciliation: scanning allowlisted repositories", "repos", len(b.cfg.Repos))
if err := b.Reconcile(recCtx); err != nil {
b.log.Error("startup reconciliation finished with errors", "err", err)
return
}
b.log.Info("startup reconciliation complete")
}()
go func() {
<-runCtx.Done()
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
}()
b.log.Info("webhook listening", "addr", b.cfg.Listen)
serveErr := srv.ListenAndServe()
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
cancel()
}
wg.Wait()
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
}
return nil
}
// Reconcile scans every allowlisted repository for open pull requests that do
// not yet carry a successful status from this bot and processes them. The
// commit status is the durable record of what the bot is already aware of, so
// the scan is self-healing without a separate cursor: a PR left pending by a
// crash, or whose check failed while the service was down, is picked up again.
func (b *Bot) Reconcile(ctx context.Context) error {
var firstErr error
for _, repo := range b.cfg.Repos {
if err := ctx.Err(); err != nil {
return err
}
pulls, err := b.api.ListOpenPulls(ctx, repo)
if err != nil {
b.log.Error("list pulls failed", "repo", repo, "err", err)
if firstErr == nil {
firstErr = err
}
continue
}
for _, pr := range pulls {
green, err := b.hasSuccess(ctx, repo, pr.Head.SHA)
if err != nil {
// Reading statuses failed; processing is idempotent, so it is
// safer to check the pull request than to skip it.
b.log.Warn("read statuses failed; checking anyway", "repo", repo, "pr", pr.Number, "err", err)
} else if green {
b.log.Debug("already green; skipping", "repo", repo, "pr", pr.Number, "sha", short(pr.Head.SHA))
continue
}
if err := b.HandlePull(ctx, repo, pr.Number); err != nil {
b.log.Error("handle pull failed", "repo", repo, "pr", pr.Number, "err", err)
if firstErr == nil {
firstErr = err
}
}
}
}
return firstErr
}
// hasSuccess reports whether sha already carries a successful status with the
// bot's context.
func (b *Bot) hasSuccess(ctx context.Context, repo, sha string) (bool, error) {
if sha == "" {
return false, nil
}
statuses, err := b.api.ListStatuses(ctx, repo, sha)
if err != nil {
return false, err
}
for _, s := range statuses {
if s.Context == b.cfg.StatusContext && s.Status == "success" {
return true, nil
}
}
return false, nil
}
// HandlePull re-reads the pull request and processes it.
func (b *Bot) HandlePull(ctx context.Context, repo string, number int) error {
if !b.cfg.Allows(repo) {
return nil
}
pr, err := b.api.GetPull(ctx, repo, number)
if err != nil {
return fmt.Errorf("get pull %s#%d: %w", repo, number, err)
}
return b.ProcessPull(ctx, repo, pr)
}
// fixableFiles returns the paths the bot may touch: every file a pull request
// changed that still exists at the head, so an existing unlicensed file gets the
// header the first time a pull request touches it. "removed" has no content at
// the head to license (addlicense would fail on the path) and "unchanged" is not
// part of the delta. Unknown statuses are kept on purpose: Gitea only reports a
// path here if it changed, and if it turns out not to exist the bot fails
// visibly (error status + comment) instead of silently skipping the file.
func fixableFiles(files []gitea.File) []string {
out := make([]string, 0, len(files))
for _, f := range files {
switch f.Status {
case "removed", "unchanged":
continue
}
out = append(out, f.Filename)
}
return out
}
// ProcessPull is the whole per-PR flow: detect changed files, fix headers, add a
// separate revertable commit, comment and report a commit status.
func (b *Bot) ProcessPull(ctx context.Context, repo string, pr *gitea.Pull) error {
if pr == nil || pr.Number == 0 {
return errors.New("invalid pull request")
}
if pr.State != "open" {
return nil
}
key := fmt.Sprintf("%s#%d@%s", repo, pr.Number, pr.Head.SHA)
if !b.claim(key) {
return nil
}
files, err := b.api.ListPullFiles(ctx, repo, pr.Number)
if err != nil {
b.release(key)
return fmt.Errorf("list files %s#%d: %w", repo, pr.Number, err)
}
targets := fixableFiles(files)
if len(targets) == 0 {
// Only deletions (or no files at all), so there is nothing to license.
// Log it: with no comment, no pending status and no error, this path is
// otherwise invisible.
b.log.Info("no changed files to check (removals only)",
"repo", repo, "pr", pr.Number, "sha", pr.Head.SHA, "files", len(files))
b.status(ctx, repo, pr.Head.SHA, "success", "no changed files need a license header")
b.clearProblemComment(ctx, repo, pr)
return nil
}
b.status(ctx, repo, pr.Head.SHA, "pending", "checking changed files for AGPL headers")
b.log.Info("processing pull request", "repo", repo, "pr", pr.Number, "sha", pr.Head.SHA,
"files", len(files), "targets", len(targets))
res, err := licenser.EnsureHeaders(ctx, licenser.Options{
RemoteURL: b.headRemoteURL(pr),
Ref: pr.Head.Ref,
SHA: pr.Head.SHA,
Files: targets,
AddlicenseBin: b.cfg.AddlicenseBin,
Holder: b.cfg.Holder,
BotName: b.cfg.BotName,
BotEmail: b.cfg.BotEmail,
WorkRoot: b.cfg.WorkRoot,
DryRun: b.cfg.DryRun,
Logger: b.log,
})
if err != nil {
b.release(key)
if errors.Is(err, licenser.ErrRemoteMoved) {
b.log.Warn("branch kept moving after the retry budget; will retry on the next event", "repo", repo, "pr", pr.Number)
return err
}
b.status(ctx, repo, pr.Head.SHA, "error", "license bot failed, see the pull request comment")
b.comment(ctx, repo, pr, failureComment(err), true)
return err
}
switch {
case res.Blocked:
b.status(ctx, repo, pr.Head.SHA, "failure", "could not add the header: maintainer edits are disabled")
b.comment(ctx, repo, pr, b.blockedComment(pr, targets, res.BlockedReason), true)
case len(res.Changed) == 0:
b.status(ctx, repo, pr.Head.SHA, "success", "all changed files carry a license header")
b.clearProblemComment(ctx, repo, pr)
case b.cfg.DryRun:
b.log.Info("dry run: would have committed", "repo", repo, "pr", pr.Number, "files", res.Changed)
default:
if res.Pushed {
b.comment(ctx, repo, pr, b.caughtComment(pr, res), false)
b.status(ctx, repo, res.NewSHA, "success", "license headers added by the bot")
b.claim(fmt.Sprintf("%s#%d@%s", repo, pr.Number, res.NewSHA))
b.log.Info("committed and pushed", "repo", repo, "pr", pr.Number, "head", short(pr.Head.SHA), "commit", short(res.NewSHA))
} else {
b.status(ctx, repo, pr.Head.SHA, "failure", "license headers are missing")
b.comment(ctx, repo, pr, b.missingComment(pr, targets), true)
}
}
return nil
}
func (b *Bot) headRemoteURL(pr *gitea.Pull) string {
full := pr.Head.Repo.FullName
if full == "" {
full = pr.Base.Repo.FullName
}
host := strings.TrimPrefix(strings.TrimPrefix(b.cfg.GiteaURL, "https://"), "http://")
scheme := "http"
if strings.HasPrefix(b.cfg.GiteaURL, "https://") {
scheme = "https"
}
return fmt.Sprintf("%s://oauth2:%s@%s/%s.git", scheme, b.cfg.Token, host, full)
}
func (b *Bot) status(ctx context.Context, repo, sha, state, description string) {
if sha == "" {
return
}
if err := b.api.CreateStatus(ctx, repo, sha, state, b.cfg.StatusContext, description, ""); err != nil {
b.log.Warn("post status failed", "repo", repo, "sha", short(sha), "state", state, "err", err)
}
}
// comment replaces the bot's comment on the pull request. The previous comment
// is deleted and a new one is posted rather than edited in place: Gitea keeps a
// comment's position, so an edited message stayed buried near the top of the
// timeline while the pull request moved on (a resolved failure kept sitting
// next to a green status). Posting fresh puts the message where it belongs, at
// the bottom, next to the latest status.
func (b *Bot) comment(ctx context.Context, repo string, pr *gitea.Pull, body string, problem bool) {
body += "\n\n" + b.cfg.CommentMarker
if problem {
body += " " + problemMarker
}
b.deleteComments(ctx, repo, pr, func(gitea.Comment) bool { return true })
if _, err := b.api.CreateIssueComment(ctx, repo, pr.Number, body); err != nil {
b.log.Error("post comment failed", "repo", repo, "pr", pr.Number, "err", err)
}
}
// clearProblemComment deletes the bot's own problem comments without posting
// anything. Called when a pass finds nothing left to fix: the failure comment
// is stale, and the commit status already tells the real story.
func (b *Bot) clearProblemComment(ctx context.Context, repo string, pr *gitea.Pull) {
b.deleteComments(ctx, repo, pr, func(c gitea.Comment) bool {
return strings.Contains(c.Body, problemMarker)
})
}
// deleteComments removes the bot's own comments for which del returns true.
// Failures are logged and never fatal: the check result does not depend on
// comment bookkeeping.
func (b *Bot) deleteComments(ctx context.Context, repo string, pr *gitea.Pull, del func(gitea.Comment) bool) {
comments, err := b.api.ListIssueComments(ctx, repo, pr.Number)
if err != nil {
b.log.Warn("list comments failed", "repo", repo, "pr", pr.Number, "err", err)
return
}
for _, c := range comments {
if !strings.Contains(c.Body, b.cfg.CommentMarker) || !del(c) {
continue
}
if err := b.api.DeleteIssueComment(ctx, repo, c.ID); err != nil {
b.log.Warn("delete comment failed", "repo", repo, "pr", pr.Number, "comment", c.ID, "err", err)
}
}
}
// caughtComment is the message the contributor sees when the bot fixed things.
// Body text is the wording this project already used in the Gitea workflow,
// kept verbatim except for the clause that used to say the bot amended the
// commit — the bot now adds a separate, revertable commit and never rewrites
// the contributor's own.
func (b *Bot) caughtComment(pr *gitea.Pull, res *licenser.Result) string {
return fmt.Sprintf(`## Uh oh! Unlicensed code detected!
You just tried to push **unlicensed code**! This repository is covered by the
[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code
requires a license header.
Thankfully, our helpful bot caught it and added the missing licensing in a separate
commit ([`+"`%s`"+`](%s)) — your own commit was left untouched, so you can
revert or drop the bot's commit if you would rather add the headers yourself.
If this looks wrong, tag **@Admin** so we can take a look!`,
short(res.NewSHA), b.commitURL(pr, res.NewSHA))
}
// blockedComment is used when the server refuses the bot's push (maintainer
// edits disabled for the pull request, protected branch, missing permission):
// the bot must not bypass that, so it asks the contributor to add the header.
func (b *Bot) blockedComment(pr *gitea.Pull, targets []string, reason string) string {
msg := fmt.Sprintf(`## Uh oh! Unlicensed code detected!
You just tried to push **unlicensed code**! This repository is covered by the
[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code
requires a license header.
The bot could not add the header for you: maintainer edits are disabled for this
pull request (or the fork does not allow them). These files still need one:
%s
Copy the header from [`+"`.license-header.tpl`"+`](%s) — or enable
"allow edits by maintainers" on this pull request — and push again.
If this looks wrong, tag **@Admin** so we can take a look!`,
fileList(targets), b.treeURL(pr, ".license-header.tpl"))
if reason != "" {
msg += "\n\n<sub>server said: " + reason + "</sub>"
}
return msg
}
// missingComment is the fallback when the bot produced a commit it did not
// publish (dry run, or a push that neither succeeded nor reported a refusal).
func (b *Bot) missingComment(pr *gitea.Pull, targets []string) string {
return fmt.Sprintf(`## Uh oh! Unlicensed code detected!
You just tried to push **unlicensed code**! This repository is covered by the
[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html) license, and all code
requires a license header.
These files still need one:
%s
Copy the header from [`+"`.license-header.tpl`"+`](%s) and push again.
If this looks wrong, tag **@Admin** so we can take a look!`,
fileList(targets), b.treeURL(pr, ".license-header.tpl"))
}
func fileList(files []string) string {
var sb strings.Builder
for _, f := range files {
sb.WriteString(fmt.Sprintf("- `%s`\n", f))
}
return sb.String()
}
func failureComment(err error) string {
return "The license bot could not finish checking this pull request: `" + licenser.Redact(err.Error()) + "`.\n\n" +
"A maintainer should look at the bot logs."
}
// commitURL links the bot's commit on the repository that actually holds it
// (the fork, for cross-repository pull requests).
func (b *Bot) commitURL(pr *gitea.Pull, sha string) string {
return fmt.Sprintf("%s/%s/commit/%s", b.instanceURL(), b.headRepo(pr), sha)
}
// treeURL links a file on the head repository.
func (b *Bot) treeURL(pr *gitea.Pull, path string) string {
return fmt.Sprintf("%s/%s/src/commit/%s/%s", b.instanceURL(), b.headRepo(pr), pr.Head.SHA, path)
}
// instanceURL is the URL used for links a human clicks, so it may differ from
// the URL the bot calls (the internal Docker alias is not resolvable from a
// browser).
func (b *Bot) instanceURL() string {
if b.cfg.PublicURL != "" {
return strings.TrimSuffix(b.cfg.PublicURL, "/")
}
return strings.TrimSuffix(b.cfg.GiteaURL, "/")
}
func (b *Bot) headRepo(pr *gitea.Pull) string {
if pr.Head.Repo.FullName != "" {
return pr.Head.Repo.FullName
}
return pr.Base.Repo.FullName
}
func short(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}
// claim marks key as handled, returning false when it already was. It
// serialises concurrent work on one head sha and stops the bot from re-running
// the check for a head it has already resolved.
func (b *Bot) claim(key string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.handled[key] {
return false
}
b.handled[key] = true
return true
}
// release forgets key so a failed run can be retried on the next event.
func (b *Bot) release(key string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.handled, key)
}
+558
View File
@@ -0,0 +1,558 @@
// 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/>.
package bot
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/config"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/gitea"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/licenser"
)
func newTestBot(t *testing.T, apiURL string) *Bot {
t.Helper()
return New(config.Config{
GiteaURL: apiURL,
Token: "tok",
WebhookSecret: "s3cret",
Repos: []string{"Dcntrlized/Maestro"},
BotName: "Bot",
BotEmail: "[email protected]",
Holder: "Dcntrlized",
CommentMarker: "<!-- marker -->",
StatusContext: "license-bot / addlicense",
AddlicenseBin: "addlicense",
WorkRoot: t.TempDir(),
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
func sign(secret string, body []byte) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write(body)
return hex.EncodeToString(m.Sum(nil))
}
func TestSignatureValid(t *testing.T) {
b := newTestBot(t, "http://127.0.0.1:1")
body := []byte(`{"action":"opened"}`)
if !b.SignatureValid(sign("s3cret", body), body) {
t.Fatal("correct signature rejected")
}
if !b.SignatureValid("sha256="+sign("s3cret", body), body) {
t.Fatal("sha256= prefixed signature rejected")
}
if b.SignatureValid(sign("other", body), body) {
t.Fatal("signature from the wrong secret accepted")
}
if b.SignatureValid("", body) {
t.Fatal("empty signature accepted")
}
}
func TestWebhookRejectsBadSignature(t *testing.T) {
b := newTestBot(t, "http://127.0.0.1:1")
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(`{}`))
req.Header.Set("X-Gitea-Signature", "deadbeef")
rec := httptest.NewRecorder()
b.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}
// TestWebhookIgnores covers repositories outside the allowlist and non
// pull-request actions: neither may touch the Gitea API.
func TestWebhookIgnores(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
cases := []string{
`{"action":"opened","number":1,"repository":{"full_name":"Dcntrlized/Other"},"pull_request":{"number":1}}`,
`{"action":"closed","number":1,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":1}}`,
}
for _, body := range cases {
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body))
req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body)))
req.Header.Set("X-Gitea-Event", "pull_request")
rec := httptest.NewRecorder()
b.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
}
time.Sleep(150 * time.Millisecond)
if n := atomic.LoadInt32(&hits); n != 0 {
t.Fatalf("API was called %d times for ignored events", n)
}
}
// TestWebhookDispatches verifies the full hop: valid signature, allowlisted
// repo, pull_request/opened with a PR number, dispatched to the API.
func TestWebhookDispatches(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"number":7,"state":"closed","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`)
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body))
req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body)))
req.Header.Set("X-Gitea-Event", "pull_request")
rec := httptest.NewRecorder()
b.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", rec.Code)
}
deadline := time.Now().Add(2 * time.Second)
for atomic.LoadInt32(&hits) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if n := atomic.LoadInt32(&hits); n == 0 {
t.Fatal("webhook was not dispatched to the API")
}
}
func TestShortSHA(t *testing.T) {
if got := short("0123456789abcdef"); got != "0123456789ab" {
t.Fatalf("short = %q", got)
}
if got := short("abc"); got != "abc" {
t.Fatalf("short = %q", got)
}
}
// TestCaughtCommentWording pins the comment text this project has always used,
// so it cannot silently drift again. It must keep the original pretty markdown
// (heading, bold warning, AGPL link, @Admin tag) and link the bot's commit.
func TestCaughtCommentWording(t *testing.T) {
b := newTestBot(t, "http://192.168.122.183:3000")
pr := &gitea.Pull{
Number: 4,
HTMLURL: "http://192.168.122.183:3000/Dcntrlized/license-bot-lab/pulls/4",
}
pr.Head.SHA = "78a4a2e1ad20ed316eaa58399901e3e37d7496b0"
pr.Head.Repo.FullName = "admin/license-bot-lab"
pr.Base.Repo.FullName = "Dcntrlized/license-bot-lab"
got := b.caughtComment(pr, &licenser.Result{
Changed: []string{"src/adv-unlicensed.go"},
NewSHA: "78a4a2e1ad20ed316eaa58399901e3e37d7496b0",
Pushed: true,
})
for _, want := range []string{
"## Uh oh! Unlicensed code detected!",
"You just tried to push **unlicensed code**!",
"[AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.html)",
"Thankfully, our helpful bot caught it and added the missing licensing",
"([`78a4a2e1ad20`](http://192.168.122.183:3000/admin/license-bot-lab/commit/78a4a2e1ad20ed316eaa58399901e3e37d7496b0))",
"tag **@Admin**",
} {
if !strings.Contains(got, want) {
t.Fatalf("comment is missing %q:\n%s", want, got)
}
}
// It must not claim the contributor's commit was rewritten.
if strings.Contains(strings.ToLower(got), "amended") || strings.Contains(got, "force-push") {
t.Fatalf("comment must not mention amending or force-pushing:\n%s", got)
}
if !strings.Contains(got, "your own commit was left untouched") {
t.Fatalf("comment must state the contributor's commit is untouched:\n%s", got)
}
}
func TestHealthRoute(t *testing.T) {
b := newTestBot(t, "http://127.0.0.1:1")
rec := httptest.NewRecorder()
b.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
t.Fatalf("health = %d %q", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
b.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/healthz", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST /healthz = %d, want 405", rec.Code)
}
}
// TestWebhookDeliveryDedup verifies that Gitea redelivering the same webhook
// (same X-Gitea-Delivery id) is accepted but only dispatched once.
func TestWebhookDeliveryDedup(t *testing.T) {
var fetches int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/pulls/7") {
atomic.AddInt32(&fetches, 1)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`)
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
body := `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`
for i := 0; i < 3; i++ {
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body))
req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body)))
req.Header.Set("X-Gitea-Event", "pull_request")
req.Header.Set("X-Gitea-Delivery", "delivery-1")
rec := httptest.NewRecorder()
b.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("attempt %d: status = %d, want 202", i, rec.Code)
}
}
time.Sleep(200 * time.Millisecond)
if n := atomic.LoadInt32(&fetches); n != 1 {
t.Fatalf("pull fetched %d times for one delivery id, want 1", n)
}
}
// TestReconcileSkipsGreenAndProcessesOthers covers the startup scan contract:
// a pull request whose head already carries the bot's success status is left
// alone, while one without it is checked.
func TestReconcileSkipsGreenAndProcessesOthers(t *testing.T) {
var greenFiles, redFiles int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasSuffix(r.URL.Path, "/pulls") && r.URL.Query().Get("state") == "open":
io.WriteString(w, `[
{"number":1,"state":"open","head":{"ref":"green","sha":"green","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}},
{"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}
]`)
case strings.Contains(r.URL.Path, "/commits/green/statuses"):
io.WriteString(w, `[{"context":"license-bot / addlicense","status":"success"}]`)
case strings.Contains(r.URL.Path, "/commits/red/statuses"):
io.WriteString(w, `[]`)
case strings.HasSuffix(r.URL.Path, "/pulls/2"):
io.WriteString(w, `{"number":2,"state":"open","head":{"ref":"red","sha":"red","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`)
case strings.HasSuffix(r.URL.Path, "/pulls/2/files"):
atomic.AddInt32(&redFiles, 1)
io.WriteString(w, `[]`)
case strings.HasSuffix(r.URL.Path, "/pulls/1/files"):
atomic.AddInt32(&greenFiles, 1)
io.WriteString(w, `[]`)
default:
io.WriteString(w, `[]`)
}
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
if err := b.Reconcile(context.Background()); err != nil {
t.Fatalf("reconcile: %v", err)
}
if n := atomic.LoadInt32(&greenFiles); n != 0 {
t.Fatalf("green pull request was checked %d times, want 0", n)
}
if n := atomic.LoadInt32(&redFiles); n != 1 {
t.Fatalf("red pull request files listed %d times, want 1", n)
}
}
// TestWebhookSubEvents pins Gitea's real header/action behavior (measured on
// 1.27.2): a push to a pull request head arrives as X-Gitea-Event:
// pull_request with action "synchronized" -- NOT "synchronize" -- and every
// other pull-request sub-event (comments, reviews, labels) carries an action
// outside the allowlist and must not start a check.
func TestWebhookSubEvents(t *testing.T) {
var fetches int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/pulls/7") {
atomic.AddInt32(&fetches, 1)
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"number":7,"state":"open","head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`)
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
post := func(event, body string) {
req := httptest.NewRequest(http.MethodPost, "/hook", strings.NewReader(body))
req.Header.Set("X-Gitea-Signature", sign("s3cret", []byte(body)))
req.Header.Set("X-Gitea-Event", event)
req.Header.Set("X-Gitea-Delivery", event+body)
rec := httptest.NewRecorder()
b.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("event %q: status = %d, want 202", event, rec.Code)
}
}
cases := []struct {
event, body string
checked bool
}{
{"pull_request", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true},
{"pull_request_sync", `{"action":"synchronize","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true},
{"pull_request", `{"action":"opened","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, true},
{"pull_request", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false},
{"pull_request", `{"action":"closed","number":7,"repository":{"full_name":"Dcntrlized/Maestro"},"pull_request":{"number":7}}`, false},
{"pull_request_comment", `{"action":"created","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false},
{"push", `{"action":"synchronized","number":7,"repository":{"full_name":"Dcntrlized/Maestro"}}`, false},
}
for _, c := range cases {
before := atomic.LoadInt32(&fetches)
post(c.event, c.body)
time.Sleep(150 * time.Millisecond)
got := atomic.LoadInt32(&fetches) > before
if got != c.checked {
t.Fatalf("event %q action %s: checked = %v, want %v", c.event, c.body, got, c.checked)
}
}
}
// TestCommentLinksUsePublicURL keeps the browser-facing links in the bot's
// comment on the public name even when the API is reached over the internal
// Docker alias (GITEA_URL=http://gitea:3000 is not resolvable in a browser).
func TestCommentLinksUsePublicURL(t *testing.T) {
b := newTestBot(t, "http://gitea:3000")
b.cfg.PublicURL = "https://git.example.org"
var pr gitea.Pull
if err := json.Unmarshal([]byte(`{"head":{"ref":"f","sha":"abc","repo":{"full_name":"Dcntrlized/Maestro"}},"base":{"ref":"main","repo":{"full_name":"Dcntrlized/Maestro"}}}`), &pr); err != nil {
t.Fatal(err)
}
if got := b.commitURL(&pr, "abc"); got != "https://git.example.org/Dcntrlized/Maestro/commit/abc" {
t.Fatalf("commitURL = %q", got)
}
b.cfg.PublicURL = ""
if got := b.commitURL(&pr, "abc"); got != "http://gitea:3000/Dcntrlized/Maestro/commit/abc" {
t.Fatalf("fallback commitURL = %q", got)
}
}
// TestCommentReplacesInsteadOfEditing pins the replacement semantics: the bot's
// previous comment is deleted and a fresh one is posted, never edited in place.
// Gitea keeps a comment's position, so editing left a resolved failure buried
// near the top of the timeline while the status at the head was green.
func TestCommentReplacesInsteadOfEditing(t *testing.T) {
var mu sync.Mutex
var calls []string
var posted string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.Path)
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
io.WriteString(w, `[
{"id":41,"body":"stale failure\n\n<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"}
]`)
case http.MethodPost:
raw, _ := io.ReadAll(r.Body)
mu.Lock()
posted = string(raw)
mu.Unlock()
io.WriteString(w, `{"id":42,"body":"fresh"}`)
default:
w.WriteHeader(http.StatusNoContent)
}
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
b.comment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7}, "fresh message", false)
mu.Lock()
got := append([]string(nil), calls...)
raw := posted
mu.Unlock()
// The client JSON-encodes the request, so decode it before asserting on the
// markdown the server would actually store.
var sent struct {
Body string `json:"body"`
}
if err := json.Unmarshal([]byte(raw), &sent); err != nil {
t.Fatalf("posted body is not JSON: %v (%s)", err, raw)
}
body := sent.Body
var deletes, posts, patches int
for _, c := range got {
switch {
case strings.HasPrefix(c, http.MethodDelete+" ") && strings.HasSuffix(c, "/issues/comments/41"):
deletes++
case strings.HasPrefix(c, http.MethodPost+" "):
posts++
case strings.HasPrefix(c, http.MethodPatch+" "):
patches++
}
}
if patches != 0 {
t.Fatalf("comment was edited in place; calls: %v", got)
}
if deletes != 1 || posts != 1 {
t.Fatalf("deletes=%d posts=%d, want 1 and 1; calls: %v", deletes, posts, got)
}
if !strings.Contains(body, "<!-- marker -->") {
t.Fatalf("posted comment lost the marker: %s", body)
}
if strings.Contains(body, problemMarker) {
t.Fatalf("informational comment must not carry the problem marker: %s", body)
}
}
// TestClearProblemCommentKeepsInformational verifies that a pass finding
// nothing left to fix removes a stale problem comment but leaves the
// informational "the bot added the headers" comment in place.
func TestClearProblemCommentKeepsInformational(t *testing.T) {
var mu sync.Mutex
var deletes []string
var posts int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
io.WriteString(w, `[
{"id":41,"body":"stale failure\n\n<!-- marker --> <!-- dcntrlized-copyleft-bot:problem -->"},
{"id":42,"body":"the bot added the headers\n\n<!-- marker -->"},
{"id":43,"body":"a human's comment"}
]`)
case http.MethodDelete:
mu.Lock()
deletes = append(deletes, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case http.MethodPost:
mu.Lock()
posts++
mu.Unlock()
io.WriteString(w, `{"id":44,"body":""}`)
}
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
b.clearProblemComment(context.Background(), "Dcntrlized/Maestro", &gitea.Pull{Number: 7})
mu.Lock()
got := append([]string(nil), deletes...)
n := posts
mu.Unlock()
if n != 0 {
t.Fatalf("clearProblemComment posted %d comments, want 0", n)
}
if len(got) != 1 || !strings.HasSuffix(got[0], "/issues/comments/41") {
t.Fatalf("deleted %v, want only the stale problem comment 41", got)
}
}
// TestFixableFiles pins the scope of the check: every changed file that still
// exists at the head is fair game, so an existing unlicensed file gets its
// header the first time a pull request touches it. A removal has no content left
// to license, and "unchanged" is not part of the delta.
func TestFixableFiles(t *testing.T) {
files := []gitea.File{
{Filename: "added.go", Status: "added"},
{Filename: "modified.go", Status: "modified"},
{Filename: "renamed.go", Status: "renamed"},
{Filename: "copied.go", Status: "copied"},
{Filename: "gone.go", Status: "removed"},
{Filename: "same.go", Status: "unchanged"},
}
got := strings.Join(fixableFiles(files), ",")
if want := "added.go,modified.go,renamed.go,copied.go"; got != want {
t.Fatalf("fixableFiles = %q, want %q", got, want)
}
if len(fixableFiles(nil)) != 0 {
t.Fatalf("fixableFiles(nil) = %v, want empty", fixableFiles(nil))
}
}
// TestProcessPullChecksModifiedFiles is the regression test for "a +3 -1 pull
// request that only edits an existing unlicensed file got no header". A modified
// file is no longer short-circuited to success: the run moves on to clone the
// head, which fails here (the fake API is not a git server), so the flow must
// reach the "pending" status and must never post the "no changed files" success.
func TestProcessPullChecksModifiedFiles(t *testing.T) {
var mu sync.Mutex
var statuses []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/") {
http.Error(w, "not a git server", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/pulls/2/files"):
io.WriteString(w, `[{"filename":"test.go","status":"modified"}]`)
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/issues/2/comments"):
io.WriteString(w, `[]`)
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/statuses/"):
raw, _ := io.ReadAll(r.Body)
mu.Lock()
statuses = append(statuses, string(raw))
mu.Unlock()
io.WriteString(w, `{"id":1}`)
default:
io.WriteString(w, `{}`)
}
}))
defer srv.Close()
b := newTestBot(t, srv.URL)
var pr gitea.Pull
if err := json.Unmarshal([]byte(`{"number":2,"state":"open",`+
`"head":{"ref":"licensebot-test","sha":"4ac2f5d","repo":{"full_name":"Dcntrlized/licensebot-test"}},`+
`"base":{"ref":"licensebot-test","repo":{"full_name":"Dcntrlized/licensebot-test"}}}`), &pr); err != nil {
t.Fatal(err)
}
// The clone against this fake API cannot succeed; that error is expected and
// is not what this test asserts.
_ = b.ProcessPull(context.Background(), "Dcntrlized/licensebot-test", &pr)
mu.Lock()
defer mu.Unlock()
var pending bool
for _, s := range statuses {
if strings.Contains(s, "no changed files need a license header") {
t.Fatalf("a modified file was short-circuited: %s", s)
}
if strings.Contains(s, `"state":"pending"`) &&
strings.Contains(s, "checking changed files for AGPL headers") {
pending = true
}
}
if !pending {
t.Fatalf("never posted the pending status, got %v", statuses)
}
}
+133
View File
@@ -0,0 +1,133 @@
// 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/>.
// Package config is the complete, environment-driven runtime configuration of
// the license bot. The binary takes no arguments: the container image carries
// no secrets and the deployment decides everything through the environment.
package config
import (
"fmt"
"os"
"strings"
)
// Config is the complete runtime configuration of the bot.
type Config struct {
// GiteaURL is the instance base URL the bot calls, e.g. http://gitea:3000
// on an instance whose Gitea shares this Docker daemon. Deliberately
// configured here instead of taken from webhook payloads: the payload's
// clone_url follows the instance ROOT_URL, which may be stale.
GiteaURL string
// PublicURL is the instance URL a *human* should follow, used only for the
// links in the bot's PR comments. On an instance reached over the internal
// alias (GITEA_URL=http://gitea:3000) that alias is not resolvable from a
// browser, so the comment must link the public name instead. Defaults to
// GiteaURL.
PublicURL string
// Token is a bot-account token with write access to the target repositories.
// It lives only in this process's environment -- never in a workflow.
Token string
// WebhookSecret verifies X-Gitea-Signature on incoming webhook deliveries.
// Webhooks are the only trigger, so this is required.
WebhookSecret string
// Repos is the allowlist of owner/name repositories the bot may touch.
Repos []string
// Listen is the HTTP listen address (webhook + health route).
Listen string
// BotName/BotEmail identify the licensing commit's committer.
BotName string
BotEmail string
// Holder is passed to addlicense -c.
Holder string
// AddlicenseBin is the addlicense executable (in PATH by default).
AddlicenseBin string
// WorkRoot is where per-PR git worktrees are created.
WorkRoot string
// DryRun inspects and comments but never commits or pushes.
DryRun bool
// CommentMarker identifies the bot's own PR comments so they can be
// replaced (deleted and reposted, never edited in place: Gitea keeps a
// comment's position, so an edited message stays buried in the timeline).
CommentMarker string
// StatusContext is the commit-status context the bot posts and the merge
// gate requires. It must match the branch-protection string byte for byte.
StatusContext string
}
// FromEnv builds the configuration from the process environment.
func FromEnv() (Config, error) {
c := Config{
GiteaURL: strings.TrimRight(env("GITEA_URL", ""), "/"),
PublicURL: strings.TrimRight(env("GITEA_PUBLIC_URL", ""), "/"),
Token: os.Getenv("GITEA_TOKEN"),
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
Repos: splitList(os.Getenv("REPOS")),
Listen: env("LISTEN", ":8080"),
BotName: env("BOT_NAME", "Dcntrlized Auto CopyLeft Bot"),
BotEmail: env("BOT_EMAIL", "[email protected]"),
Holder: env("HOLDER", "Dcntrlized"),
AddlicenseBin: env("ADDLICENSE_BIN", "addlicense"),
WorkRoot: env("WORK_ROOT", os.TempDir()),
DryRun: truthy(os.Getenv("DRY_RUN")),
CommentMarker: env("COMMENT_MARKER", "<!-- dcntrlized-copyleft-bot -->"),
StatusContext: env("STATUS_CONTEXT", "license-bot / addlicense"),
}
if c.Token == "" {
return c, fmt.Errorf("GITEA_TOKEN is required")
}
if len(c.Repos) == 0 {
return c, fmt.Errorf("REPOS is required (comma-separated owner/name list)")
}
if c.WebhookSecret == "" {
return c, fmt.Errorf("WEBHOOK_SECRET is required: webhooks are the only trigger")
}
if c.PublicURL == "" {
c.PublicURL = c.GiteaURL
}
return c, nil
}
// Allows reports whether repo (owner/name) is on the allowlist.
func (c Config) Allows(repo string) bool {
for _, r := range c.Repos {
if strings.EqualFold(r, repo) {
return true
}
}
return false
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func truthy(v string) bool {
return v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
}
func splitList(v string) []string {
var out []string
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
@@ -0,0 +1,52 @@
// 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/>.
package config
import "testing"
func TestFromEnvRequiresTokenReposAndSecret(t *testing.T) {
t.Setenv("GITEA_TOKEN", "")
t.Setenv("REPOS", "")
t.Setenv("WEBHOOK_SECRET", "")
if _, err := FromEnv(); err == nil {
t.Fatal("empty environment accepted")
}
t.Setenv("GITEA_TOKEN", "tok")
if _, err := FromEnv(); err == nil {
t.Fatal("missing REPOS accepted")
}
t.Setenv("REPOS", "Dcntrlized/Maestro")
if _, err := FromEnv(); err == nil {
t.Fatal("missing WEBHOOK_SECRET accepted (webhooks are the only trigger)")
}
t.Setenv("WEBHOOK_SECRET", "s3cret")
cfg, err := FromEnv()
if err != nil {
t.Fatalf("valid environment rejected: %v", err)
}
if cfg.StatusContext != "license-bot / addlicense" {
t.Fatalf("StatusContext = %q", cfg.StatusContext)
}
if !cfg.Allows("dcntrlized/maestro") {
t.Fatal("allowlist is not case-insensitive")
}
if cfg.Allows("Dcntrlized/Other") {
t.Fatal("allowlist accepted a repository outside it")
}
}
+241
View File
@@ -0,0 +1,241 @@
// 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/>.
// Package gitea is a minimal Gitea REST client covering exactly the endpoints
// the license bot needs. It intentionally has no third-party dependencies.
package gitea
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Client talks to one Gitea instance.
type Client struct {
Base string
Token string
HTTP *http.Client
}
// New returns a client for baseURL authenticated with token.
func New(baseURL, token string) *Client {
return &Client{
Base: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: &http.Client{Timeout: 60 * time.Second},
}
}
// File is one entry of a pull request's changed-file list.
type File struct {
Filename string `json:"filename"`
Status string `json:"status"`
}
// RepoRef is the subset of a repository object carried by a pull request.
type RepoRef struct {
FullName string `json:"full_name"`
}
// Pull is the subset of a pull request the bot relies on.
type Pull struct {
Number int `json:"number"`
State string `json:"state"`
HTMLURL string `json:"html_url"`
Title string `json:"title"`
Head struct {
Ref string `json:"ref"`
SHA string `json:"sha"`
Repo RepoRef `json:"repo"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
Repo RepoRef `json:"repo"`
} `json:"base"`
AllowMaintainerEdit bool `json:"allow_maintainer_edit"`
}
// Comment is a PR/issue comment.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
}
// Status is one commit status entry.
type Status struct {
Context string `json:"context"`
Status string `json:"status"`
}
// APIError is a non-2xx response from the Gitea API.
type APIError struct {
Status int
Method string
Path string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.Status, e.Body)
}
func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+"/api/v1"+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Accept", "application/json")
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return &APIError{Status: resp.StatusCode, Method: method, Path: path, Body: strings.TrimSpace(string(raw))}
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s: decode response: %w", method, path, err)
}
}
return nil
}
const pageSize = 50
// GetPull returns one pull request.
func (c *Client) GetPull(ctx context.Context, repo string, index int) (*Pull, error) {
var p Pull
if err := c.do(ctx, http.MethodGet, "/repos/"+repo+"/pulls/"+strconv.Itoa(index), nil, &p); err != nil {
return nil, err
}
return &p, nil
}
// ListOpenPulls returns every open pull request of repo.
func (c *Client) ListOpenPulls(ctx context.Context, repo string) ([]Pull, error) {
var all []Pull
for page := 1; ; page++ {
var batch []Pull
p := fmt.Sprintf("/repos/%s/pulls?state=open&limit=%d&page=%d", repo, pageSize, page)
if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < pageSize {
return all, nil
}
}
}
// ListPullFiles returns every changed file of a pull request.
func (c *Client) ListPullFiles(ctx context.Context, repo string, index int) ([]File, error) {
var all []File
for page := 1; ; page++ {
var batch []File
p := fmt.Sprintf("/repos/%s/pulls/%d/files?limit=%d&page=%d", repo, index, pageSize, page)
if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < pageSize {
return all, nil
}
}
}
// ListIssueComments returns all comments of an issue or pull request.
func (c *Client) ListIssueComments(ctx context.Context, repo string, index int) ([]Comment, error) {
var all []Comment
for page := 1; ; page++ {
var batch []Comment
p := fmt.Sprintf("/repos/%s/issues/%d/comments?limit=%d&page=%d", repo, index, pageSize, page)
if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < pageSize {
return all, nil
}
}
}
// CreateIssueComment posts a comment on an issue or pull request.
func (c *Client) CreateIssueComment(ctx context.Context, repo string, index int, body string) (*Comment, error) {
var out Comment
in := map[string]string{"body": body}
if err := c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/issues/%d/comments", repo, index), in, &out); err != nil {
return nil, err
}
return &out, nil
}
// EditIssueComment updates an existing comment in place.
func (c *Client) EditIssueComment(ctx context.Context, repo string, id int64, body string) error {
in := map[string]string{"body": body}
return c.do(ctx, http.MethodPatch, fmt.Sprintf("/repos/%s/issues/comments/%d", repo, id), in, nil)
}
// DeleteIssueComment removes a comment.
func (c *Client) DeleteIssueComment(ctx context.Context, repo string, id int64) error {
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/repos/%s/issues/comments/%d", repo, id), nil, nil)
}
// ListStatuses returns every commit status of one commit.
func (c *Client) ListStatuses(ctx context.Context, repo, sha string) ([]Status, error) {
var all []Status
for page := 1; ; page++ {
var batch []Status
p := fmt.Sprintf("/repos/%s/commits/%s/statuses?limit=%d&page=%d", repo, url.PathEscape(sha), pageSize, page)
if err := c.do(ctx, http.MethodGet, p, nil, &batch); err != nil {
return nil, err
}
all = append(all, batch...)
if len(batch) < pageSize {
return all, nil
}
}
}
// CreateStatus posts a commit status. state is one of pending, success,
// failure, error, warning.
func (c *Client) CreateStatus(ctx context.Context, repo, sha, state, context, description, targetURL string) error {
in := map[string]string{"state": state, "context": context, "description": description}
if targetURL != "" {
in["target_url"] = targetURL
}
return c.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/statuses/%s", repo, url.PathEscape(sha)), in, nil)
}
+147
View File
@@ -0,0 +1,147 @@
// 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/>.
package gitea
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestAuthHeaderAndListPullFilesPagination(t *testing.T) {
var pages []int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/o/r/pulls/3/files" {
t.Errorf("unexpected path %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "token tok" {
t.Errorf("Authorization = %q", got)
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pages = append(pages, page)
n := pageSize
if page == 2 {
n = 3
}
files := make([]File, 0, n)
for i := 0; i < n; i++ {
files = append(files, File{Filename: fmt.Sprintf("f%d-%d.go", page, i), Status: "added"})
}
_ = json.NewEncoder(w).Encode(files)
}))
defer srv.Close()
got, err := New(srv.URL, "tok").ListPullFiles(context.Background(), "o/r", 3)
if err != nil {
t.Fatal(err)
}
if len(got) != pageSize+3 {
t.Fatalf("got %d files, want %d", len(got), pageSize+3)
}
if len(pages) != 2 || pages[0] != 1 || pages[1] != 2 {
t.Fatalf("pages = %v, want [1 2]", pages)
}
}
func TestCreateStatus(t *testing.T) {
var gotMethod, gotPath, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath = r.Method, r.URL.Path
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
err := New(srv.URL, "tok").CreateStatus(context.Background(), "o/r", "abc123", "success", "license-bot / addlicense", "ok", "")
if err != nil {
t.Fatal(err)
}
if gotMethod != http.MethodPost || gotPath != "/api/v1/repos/o/r/statuses/abc123" {
t.Fatalf("%s %s", gotMethod, gotPath)
}
for _, want := range []string{`"state":"success"`, `"context":"license-bot / addlicense"`, `"description":"ok"`} {
if !strings.Contains(gotBody, want) {
t.Fatalf("body %s missing %s", gotBody, want)
}
}
}
func TestCommentLifecyclePaths(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
_ = json.NewEncoder(w).Encode([]Comment{{ID: 42, Body: "hi"}})
return
}
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(Comment{ID: 43})
return
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "tok")
ctx := context.Background()
if _, err := c.ListIssueComments(ctx, "o/r", 9); err != nil {
t.Fatal(err)
}
if _, err := c.CreateIssueComment(ctx, "o/r", 9, "body"); err != nil {
t.Fatal(err)
}
if err := c.EditIssueComment(ctx, "o/r", 42, "body"); err != nil {
t.Fatal(err)
}
want := []string{
"GET /api/v1/repos/o/r/issues/9/comments",
"POST /api/v1/repos/o/r/issues/9/comments",
"PATCH /api/v1/repos/o/r/issues/comments/42",
}
if len(paths) != len(want) {
t.Fatalf("paths = %v", paths)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths = %v, want %v", paths, want)
}
}
}
func TestAPIErrorCarriesStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusForbidden)
}))
defer srv.Close()
_, err := New(srv.URL, "tok").GetPull(context.Background(), "o/r", 1)
if err == nil {
t.Fatal("expected an error")
}
apiErr, ok := err.(*APIError)
if !ok || apiErr.Status != http.StatusForbidden {
t.Fatalf("err = %#v", err)
}
}
+355
View File
@@ -0,0 +1,355 @@
// 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/>.
// Package licenser applies AGPL headers to a pull request's added files and
// pushes them back as its own commit on top of the head — to the base
// repository for same-repo PRs and to the contributor's fork for cross-repo PRs
// (Gitea's allow_maintainer_edit, on by default). The contributor's commit is
// never rewritten, and a branch that advances while the bot works is re-read and
// re-processed instead of being abandoned.
package licenser
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// Options describes one pull-request head to fix.
type Options struct {
// RemoteURL is the credential-bearing clone/push URL of the HEAD repository
// (the fork, or the base repository for same-repo PRs).
RemoteURL string
// Ref is the head branch name; SHA is the head commit the PR was read at.
// A later attempt works from the branch's current tip instead.
Ref string
SHA string
// Files are repository-relative paths of changed files to check.
Files []string
// AddlicenseBin is the addlicense executable.
AddlicenseBin string
// Holder is the copyright holder passed to addlicense -c.
Holder string
// BotName/BotEmail are the committer identity of the amended commit.
BotName string
BotEmail string
// WorkRoot is the parent directory for the temporary worktree.
WorkRoot string
// DryRun stops before committing/pushing.
DryRun bool
// Logger receives progress; may be nil.
Logger *slog.Logger
}
// Result reports what happened.
type Result struct {
// Changed lists files that received a header.
Changed []string
// NewSHA is the bot's commit on top of the pull request head, empty when
// nothing changed.
NewSHA string
// Pushed reports whether that commit reached the remote.
Pushed bool
// Blocked reports a push refused by the server (typically
// allow_maintainer_edit disabled on the PR, or a protected branch).
Blocked bool
// BlockedReason carries the redacted server message for Blocked.
BlockedReason string
}
// ErrRemoteMoved means the remote head kept advancing for every attempt: each
// rejection makes the bot drop its local work, re-read the branch, re-apply the
// headers and push again, and this error is only returned once that loop is
// exhausted. Nothing is ever force-pushed or rewritten, so the caller can simply
// try again later.
var ErrRemoteMoved = errors.New("remote branch kept moving; will retry later")
// maxPushAttempts bounds the re-read/re-apply/push loop. A normal race is
// resolved on the second attempt; the bound only stops a pathological
// push storm from spinning forever.
const maxPushAttempts = 5
const cloneDepth = 50
// commitSubject/commitBody describe the bot's own commit. It is deliberately a
// new commit on top of the pull request head — never an amend — so the
// contributor's work and message stay byte-for-byte intact and the bot's change
// can be reverted or dropped with a single `git revert` / `git reset`.
const commitSubject = "chore(license): add AGPL-3.0 headers"
// EnsureHeaders runs addlicense over opts.Files in a fresh worktree of the PR
// head, records the result in a separate commit and pushes it on top of the
// head branch (fast-forward, no force).
func EnsureHeaders(ctx context.Context, opts Options) (*Result, error) {
log := opts.Logger
if log == nil {
log = slog.Default()
}
if opts.RemoteURL == "" {
return nil, errors.New("RemoteURL is required")
}
if opts.Ref == "" || opts.SHA == "" {
return nil, errors.New("Ref and SHA are required")
}
var last *Result
for attempt := 1; attempt <= maxPushAttempts; attempt++ {
res, moved, err := ensureHeadersOnce(ctx, opts, log, attempt == 1)
if err != nil {
return res, err
}
if !moved {
return res, nil
}
// The branch advanced under us: drop the local work (the temp clone is
// discarded), re-read the branch, re-run the check and try again.
last = res
log.Warn("branch advanced while working; re-reading it and re-applying the headers",
"ref", opts.Ref, "attempt", attempt, "of", maxPushAttempts)
}
if last != nil {
last.NewSHA = "" // nothing reached the remote
}
return last, fmt.Errorf("%w: %s moved on every one of %d attempts", ErrRemoteMoved, opts.Ref, maxPushAttempts)
}
// ensureHeadersOnce performs one clone/apply/commit/push round. first selects
// the starting point: the head commit the pull request was read at on the first
// attempt, the branch's current tip afterwards. moved reports a rejected
// non-fast-forward push, which the caller answers by starting over.
func ensureHeadersOnce(ctx context.Context, opts Options, log *slog.Logger, first bool) (res *Result, moved bool, err error) {
dir, err := os.MkdirTemp(opts.WorkRoot, "license-bot-*")
if err != nil {
return nil, false, fmt.Errorf("create worktree: %w", err)
}
defer os.RemoveAll(dir)
if _, err := git(ctx, dir, "clone", "--quiet", "--depth", strconv.Itoa(cloneDepth), "--single-branch", "--branch", opts.Ref, opts.RemoteURL, dir); err != nil {
return nil, false, fmt.Errorf("clone %s: %w", Redact(opts.RemoteURL), err)
}
target := opts.SHA
if !first {
// Re-read the branch: the previous attempt lost a race, so the work must
// be redone on top of whatever is there now.
if out, err := git(ctx, dir, "rev-parse", "origin/"+opts.Ref); err == nil {
if tip := strings.TrimSpace(out); tip != "" {
target = tip
}
}
}
if !commitExists(ctx, dir, target) {
if _, err := git(ctx, dir, "fetch", "--quiet", "--unshallow", "origin"); err != nil {
log.Warn("unshallow failed, using fetched history", "err", err)
}
}
if !commitExists(ctx, dir, target) {
return nil, false, fmt.Errorf("head commit %s not present in %s", target, Redact(opts.RemoteURL))
}
if _, err := git(ctx, dir, "checkout", "--quiet", target); err != nil {
return nil, false, fmt.Errorf("checkout %s: %w", target, err)
}
tpl := filepath.Join(dir, ".license-header.tpl")
if _, err := os.Stat(tpl); err != nil {
return nil, false, fmt.Errorf(".license-header.tpl missing at %s: %w", target, err)
}
patterns := ReadIgnorePatterns(filepath.Join(dir, ".licenseignore"))
res = &Result{}
year := strconv.Itoa(time.Now().Year())
for _, f := range opts.Files {
if f == "" {
continue
}
abs := filepath.Join(dir, filepath.FromSlash(f))
if st, err := os.Stat(abs); err != nil || st.IsDir() {
continue // removed again or not a regular file
}
args := []string{"-v", "-f", ".license-header.tpl", "-c", opts.Holder, "-y", year}
for _, p := range patterns {
args = append(args, "-ignore", p)
}
args = append(args, f)
out, err := run(ctx, dir, opts.AddlicenseBin, args...)
if err != nil {
return nil, false, fmt.Errorf("addlicense %s: %w: %s", f, err, strings.TrimSpace(out))
}
}
porcelain, err := git(ctx, dir, "status", "--porcelain")
if err != nil {
return nil, false, fmt.Errorf("git status: %w", err)
}
res.Changed = ChangedFiles(porcelain)
if len(res.Changed) == 0 {
// Everything this pull request added is already licensed, so there is
// nothing to publish — including on a retry after a concurrent push
// fixed the files itself.
return res, false, nil
}
log.Info("adding license headers", "files", len(res.Changed), "dry_run", opts.DryRun)
if opts.DryRun {
return res, false, nil
}
if _, err := git(ctx, dir, "add", "-A"); err != nil {
return nil, false, fmt.Errorf("git add: %w", err)
}
body := "Added by the Dcntrlized license bot to the files this pull request\n" +
"introduced without a license header. Only the files this pull request adds\n" +
"are touched, and the header text comes from .license-header.tpl.\n\n" +
"This commit is separate from the contributor's work, so it can be reverted\n" +
"or dropped freely.\n\nFiles:\n"
for _, f := range res.Changed {
body += "- " + f + "\n"
}
if _, err := git(ctx, dir, "-c", "user.name="+opts.BotName, "-c", "user.email="+opts.BotEmail,
"commit", "--quiet", "-m", commitSubject, "-m", body); err != nil {
return nil, false, fmt.Errorf("git commit: %w", err)
}
newSHA, err := git(ctx, dir, "rev-parse", "HEAD")
if err != nil {
return nil, false, fmt.Errorf("rev-parse: %w", err)
}
res.NewSHA = strings.TrimSpace(newSHA)
// Fast-forward push only: no --force, no --force-with-lease. If anyone
// pushed in the meantime the push is rejected and the caller starts over
// from the new tip rather than rewriting their history.
out, err := git(ctx, dir, "push", "--quiet", opts.RemoteURL, "HEAD:refs/heads/"+opts.Ref)
if err != nil {
msg := strings.TrimSpace(out)
if isPushRefused(msg) {
res.Blocked = true
res.BlockedReason = Redact(msg)
return res, false, nil
}
if isRemoteMoved(msg) {
res.NewSHA = ""
return res, true, nil
}
return res, false, fmt.Errorf("git push %s: %w: %s", Redact(opts.RemoteURL), err, Redact(msg))
}
res.Pushed = true
return res, false, nil
}
func commitExists(ctx context.Context, dir, sha string) bool {
_, err := git(ctx, dir, "cat-file", "-e", sha+"^{commit}")
return err == nil
}
func git(ctx context.Context, dir string, args ...string) (string, error) {
return runEnv(ctx, dir, []string{"GIT_TERMINAL_PROMPT=0"}, "git", args...)
}
func run(ctx context.Context, dir, bin string, args ...string) (string, error) {
return runEnv(ctx, dir, nil, bin, args...)
}
// runEnv runs a command and, on failure, returns an error carrying the
// redacted combined output so diagnostics never leak the bot token.
func runEnv(ctx context.Context, dir string, extraEnv []string, bin string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), extraEnv...)
var buf strings.Builder
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
out := buf.String()
if ctx.Err() != nil {
return out, ctx.Err()
}
if err != nil {
if msg := strings.TrimSpace(Redact(out)); msg != "" {
return out, fmt.Errorf("%w: %s", err, msg)
}
}
return out, err
}
var (
// A refusal the bot cannot fix by retrying: permissions, disabled
// maintainer edits, protected branch rules.
rePushDenied = regexp.MustCompile(`(?i)(403|forbidden|denied|not allowed|not permitted|permission|protected)`)
// The remote advanced under us: a plain non-fast-forward rejection.
reRemoteMoved = regexp.MustCompile(`(?i)(non-fast-forward|fetch first|stale info|\[remote rejected\]|behind)`)
)
func isPushRefused(msg string) bool { return rePushDenied.MatchString(msg) }
func isRemoteMoved(msg string) bool { return reRemoteMoved.MatchString(msg) }
// ChangedFiles extracts paths from `git status --porcelain` output.
func ChangedFiles(porcelain string) []string {
var out []string
for _, line := range strings.Split(porcelain, "\n") {
if len(line) < 4 {
continue
}
p := line[3:]
if strings.HasPrefix(p, "\"") {
if uq, err := strconv.Unquote(p); err == nil {
p = uq
}
}
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// ReadIgnorePatterns returns the non-empty, non-comment lines of a
// .licenseignore file. A missing file yields no patterns.
func ReadIgnorePatterns(path string) []string {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
return ParseIgnore(string(b))
}
// ParseIgnore splits .licenseignore content into addlicense -ignore patterns.
// addlicense's patterns are doublestar globs, not prefixes, so lines are used
// verbatim.
func ParseIgnore(body string) []string {
var out []string
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out
}
// Redact removes credentials embedded in a URL or message so logs and PR
// comments never leak the bot token.
func Redact(s string) string {
// scheme://user:pass@host -> scheme://host
re := regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]*@`)
return re.ReplaceAllString(s, "$1")
}
@@ -0,0 +1,460 @@
// 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/>.
package licenser
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestParseIgnore(t *testing.T) {
got := ParseIgnore("# comment\n\n**/*.md\n vendor/** \n*_test.go\n")
want := []string{"**/*.md", "vendor/**", "*_test.go"}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v want %v", got, want)
}
}
}
func TestChangedFiles(t *testing.T) {
porcelain := " M internal/a.go\n?? new file.go\nA added.go\n M \"docs/with space.md\"\n"
got := ChangedFiles(porcelain)
want := []string{"internal/a.go", "new file.go", "added.go", "docs/with space.md"}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v want %v", got, want)
}
}
}
func TestRedact(t *testing.T) {
in := "fatal: unable to access 'http://oauth2:[email protected]:3000/o/r.git/'"
got := Redact(in)
if strings.Contains(got, "supersecret") {
t.Fatalf("token leaked: %s", got)
}
if !strings.Contains(got, "http://192.168.122.183:3000/o/r.git") {
t.Fatalf("host lost: %s", got)
}
}
// TestEnsureHeadersEndToEnd exercises the real flow — clone, addlicense, commit
// a separate commit, fast-forward push — against a local bare repository
// standing in for the fork or base repo.
func TestEnsureHeadersEndToEnd(t *testing.T) {
bin := os.Getenv("ADDLICENSE_BIN")
if bin == "" {
bin = "addlicense"
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("addlicense binary not available: %v", err)
}
root := t.TempDir()
work := filepath.Join(root, "work")
bare := filepath.Join(root, "remote.git")
mustGit(t, root, "init", "-q", "--initial-branch=main", work)
mustGit(t, work, "config", "user.name", "Tester")
mustGit(t, work, "config", "user.email", "[email protected]")
writeFile(t, filepath.Join(work, ".license-header.tpl"),
"Copyright (C) {{.Year}} {{.Holder}}\n\nGNU Affero General Public License v3.\n")
writeFile(t, filepath.Join(work, ".licenseignore"), "# skip docs\n**/*.md\n")
writeFile(t, filepath.Join(work, "main.go"), "package main\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "initial")
mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare)
mustGit(t, work, "remote", "add", "origin", bare)
mustGit(t, work, "push", "-q", "-u", "origin", "main")
mustGit(t, work, "checkout", "-qb", "feature")
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n")
writeFile(t, filepath.Join(work, "README.md"), "documentation without a header\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "add an unlicensed file")
sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "-u", "origin", "feature")
res, err := EnsureHeaders(context.Background(), Options{
RemoteURL: bare,
Ref: "feature",
SHA: sha,
Files: []string{"new.go", "README.md"},
AddlicenseBin: bin,
Holder: "Dcntrlized",
BotName: "Dcntrlized Auto CopyLeft Bot",
BotEmail: "[email protected]",
WorkRoot: root,
})
if err != nil {
t.Fatalf("EnsureHeaders: %v", err)
}
if !res.Pushed {
t.Fatalf("expected the amend to be pushed, got %+v", res)
}
if len(res.Changed) != 1 || res.Changed[0] != "new.go" {
t.Fatalf("changed = %v, want [new.go]", res.Changed)
}
if res.NewSHA == "" || res.NewSHA == sha {
t.Fatalf("expected a new commit, got %q (old %q)", res.NewSHA, sha)
}
verify := filepath.Join(root, "verify")
mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify)
body := readFile(t, filepath.Join(verify, "new.go"))
if !strings.Contains(body, "GNU Affero General Public License") {
t.Fatalf("header not added:\n%s", body)
}
if strings.Contains(readFile(t, filepath.Join(verify, "README.md")), "Affero") {
t.Fatal("README.md must stay untouched (ignored)")
}
// The contributor's commit must be preserved byte-for-byte: same message,
// same author, same sha. The bot only adds a commit on top of it.
if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%H")); got != sha {
t.Fatalf("contributor commit sha changed: %s, want %s", got, sha)
}
if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%s")); got != "add an unlicensed file" {
t.Fatalf("contributor commit message changed: %q", got)
}
if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%an <%ae>")); got != "Tester <[email protected]>" {
t.Fatalf("contributor commit author changed: %q", got)
}
if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%cn <%ce>")); got != "Dcntrlized Auto CopyLeft Bot <[email protected]>" {
t.Fatalf("committer should be the bot: %q", got)
}
if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%s")); got != "chore(license): add AGPL-3.0 headers" {
t.Fatalf("bot commit subject = %q", got)
}
if body := mustGit(t, verify, "log", "-1", "--pretty=%b"); !strings.Contains(body, "reverted") {
t.Fatalf("bot commit body should say the commit is revertable: %q", body)
}
// initial + contributor + bot, and the bot's commit sits directly on top.
if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" {
t.Fatalf("expected initial + contributor + bot commit, got %s", got)
}
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != sha {
t.Fatalf("bot commit parent = %s, want %s", got, sha)
}
}
// TestEnsureHeadersRemoteMoved pins the self-healing race behavior: when the
// branch advances after the bot read it, the fast-forward push is rejected, the
// bot drops its local work, re-reads the branch, re-runs the check on top of the
// new tip and pushes again. Nothing is overwritten and nothing is lost.
func TestEnsureHeadersRemoteMoved(t *testing.T) {
bin := os.Getenv("ADDLICENSE_BIN")
if bin == "" {
bin = "addlicense"
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("addlicense binary not available: %v", err)
}
root := t.TempDir()
work := filepath.Join(root, "work")
bare := filepath.Join(root, "remote.git")
mustGit(t, root, "init", "-q", "--initial-branch=main", work)
mustGit(t, work, "config", "user.name", "Tester")
mustGit(t, work, "config", "user.email", "[email protected]")
writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n")
writeFile(t, filepath.Join(work, "main.go"), "package main\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "initial")
mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare)
mustGit(t, work, "remote", "add", "origin", bare)
mustGit(t, work, "push", "-q", "-u", "origin", "main")
mustGit(t, work, "checkout", "-qb", "feature")
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "add an unlicensed file")
staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "-u", "origin", "feature")
// Someone else pushes to the same branch while the bot is working.
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n\nfunc Later() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "work pushed concurrently")
movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "origin", "feature")
res, err := EnsureHeaders(context.Background(), Options{
RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"},
AddlicenseBin: bin, Holder: "Dcntrlized",
BotName: "Bot", BotEmail: "[email protected]", WorkRoot: root,
})
if err != nil {
t.Fatalf("the bot must self-heal a moved branch, got %v (res %+v)", err, res)
}
if !res.Pushed || res.NewSHA == "" {
t.Fatalf("expected the retried commit to be pushed, got %+v", res)
}
if len(res.Changed) != 1 || res.Changed[0] != "new.go" {
t.Fatalf("changed = %v, want [new.go]", res.Changed)
}
verify := filepath.Join(root, "verify")
mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify)
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != res.NewSHA {
t.Fatalf("remote head = %s, want the bot commit %s", got, res.NewSHA)
}
// The bot's commit sits on top of the CONCURRENT commit, not the stale one.
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != movedSHA {
t.Fatalf("bot commit parent = %s, want the concurrent commit %s", got, movedSHA)
}
// Nothing may be lost: the concurrent change and both original commits stay.
if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "4" {
t.Fatalf("expected initial + contributor + concurrent + bot, got %s commits", got)
}
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD~2")); got != staleSHA {
t.Fatalf("the contributor commit changed: %s, want %s", got, staleSHA)
}
body := readFile(t, filepath.Join(verify, "new.go"))
if !strings.Contains(body, "Copyright (C)") {
t.Fatalf("header not applied after the retry:\n%s", body)
}
if !strings.Contains(body, "func Later()") {
t.Fatalf("the concurrent change was lost:\n%s", body)
}
}
// TestEnsureHeadersRemoteMovedAlreadyFixed covers the retry that finds nothing
// left to do because the concurrent push added the header itself: no commit is
// published and the branch is left exactly as the other writer left it.
func TestEnsureHeadersRemoteMovedAlreadyFixed(t *testing.T) {
bin := os.Getenv("ADDLICENSE_BIN")
if bin == "" {
bin = "addlicense"
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("addlicense binary not available: %v", err)
}
root := t.TempDir()
work := filepath.Join(root, "work")
bare := filepath.Join(root, "remote.git")
mustGit(t, root, "init", "-q", "--initial-branch=main", work)
mustGit(t, work, "config", "user.name", "Tester")
mustGit(t, work, "config", "user.email", "[email protected]")
writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n")
writeFile(t, filepath.Join(work, "main.go"), "package main\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "initial")
mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare)
mustGit(t, work, "remote", "add", "origin", bare)
mustGit(t, work, "push", "-q", "-u", "origin", "main")
mustGit(t, work, "checkout", "-qb", "feature")
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "add an unlicensed file")
staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "-u", "origin", "feature")
// The concurrent writer licenses the file itself.
writeFile(t, filepath.Join(work, "new.go"), "// Copyright (C) 2026 Dcntrlized\n\npackage main\n\nfunc New() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "license the file by hand")
movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "origin", "feature")
res, err := EnsureHeaders(context.Background(), Options{
RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"},
AddlicenseBin: bin, Holder: "Dcntrlized",
BotName: "Bot", BotEmail: "[email protected]", WorkRoot: root,
})
if err != nil {
t.Fatalf("EnsureHeaders: %v", err)
}
if res.Pushed || res.NewSHA != "" {
t.Fatalf("nothing to publish, got %+v", res)
}
if len(res.Changed) != 0 {
t.Fatalf("changed = %v, want none", res.Changed)
}
verify := filepath.Join(root, "verify")
mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify)
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != movedSHA {
t.Fatalf("remote head = %s, want %s (untouched)", got, movedSHA)
}
if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" {
t.Fatalf("no commit may be added, got %s", got)
}
}
// TestEnsureHeadersGivesUpWhenBranchKeepsMoving bounds the retry loop: if every
// push is rejected as a non-fast-forward, the attempt budget is exhausted, the
// error is ErrRemoteMoved and nothing is published.
func TestEnsureHeadersGivesUpWhenBranchKeepsMoving(t *testing.T) {
bin := os.Getenv("ADDLICENSE_BIN")
if bin == "" {
bin = "addlicense"
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("addlicense binary not available: %v", err)
}
root := t.TempDir()
work := filepath.Join(root, "work")
bare := filepath.Join(root, "remote.git")
mustGit(t, root, "init", "-q", "--initial-branch=main", work)
mustGit(t, work, "config", "user.name", "Tester")
mustGit(t, work, "config", "user.email", "[email protected]")
writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n")
writeFile(t, filepath.Join(work, "main.go"), "package main\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "initial")
mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare)
mustGit(t, work, "remote", "add", "origin", bare)
mustGit(t, work, "push", "-q", "-u", "origin", "main")
mustGit(t, work, "checkout", "-qb", "feature")
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "add an unlicensed file")
sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "-u", "origin", "feature")
hook := filepath.Join(bare, "hooks", "pre-receive")
writeFile(t, hook, "#!/bin/sh\necho '! [rejected] feature -> feature (non-fast-forward)' >&2\nexit 1\n")
if err := os.Chmod(hook, 0o755); err != nil {
t.Fatal(err)
}
res, err := EnsureHeaders(context.Background(), Options{
RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"},
AddlicenseBin: bin, Holder: "Dcntrlized",
BotName: "Bot", BotEmail: "[email protected]", WorkRoot: root,
})
if !errors.Is(err, ErrRemoteMoved) {
t.Fatalf("err = %v, want ErrRemoteMoved (res %+v)", err, res)
}
if res == nil || res.Pushed {
t.Fatalf("nothing may be published when the branch keeps moving: %+v", res)
}
if res.NewSHA != "" {
t.Fatalf("no remote commit may be reported, got %q", res.NewSHA)
}
verify := filepath.Join(root, "verify")
mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify)
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha {
t.Fatalf("remote head = %s, want %s", got, sha)
}
}
// TestEnsureHeadersPushRefused covers the allow_maintainer_edit=false case: the
// server refuses the force-push, and the bot must report Blocked instead of an
// error so it can tell the contributor to add the header by hand.
func TestEnsureHeadersPushRefused(t *testing.T) {
bin := os.Getenv("ADDLICENSE_BIN")
if bin == "" {
bin = "addlicense"
}
if _, err := exec.LookPath(bin); err != nil {
t.Skipf("addlicense binary not available: %v", err)
}
root := t.TempDir()
work := filepath.Join(root, "work")
bare := filepath.Join(root, "remote.git")
mustGit(t, root, "init", "-q", "--initial-branch=main", work)
mustGit(t, work, "config", "user.name", "Tester")
mustGit(t, work, "config", "user.email", "[email protected]")
writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n")
writeFile(t, filepath.Join(work, "main.go"), "package main\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "initial")
mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare)
mustGit(t, work, "remote", "add", "origin", bare)
mustGit(t, work, "push", "-q", "-u", "origin", "main")
mustGit(t, work, "checkout", "-qb", "feature")
writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n")
mustGit(t, work, "add", "-A")
mustGit(t, work, "commit", "-qm", "add an unlicensed file")
sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD"))
mustGit(t, work, "push", "-q", "-u", "origin", "feature")
hook := filepath.Join(bare, "hooks", "pre-receive")
writeFile(t, hook, "#!/bin/sh\necho 'Forbidden: maintainer edits are disabled for this pull request' >&2\nexit 1\n")
if err := os.Chmod(hook, 0o755); err != nil {
t.Fatal(err)
}
res, err := EnsureHeaders(context.Background(), Options{
RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"},
AddlicenseBin: bin, Holder: "Dcntrlized",
BotName: "Bot", BotEmail: "[email protected]", WorkRoot: root,
})
if err != nil {
t.Fatalf("a refused push must not be a hard error: %v", err)
}
if !res.Blocked || res.Pushed {
t.Fatalf("expected Blocked, got %+v", res)
}
if res.NewSHA == "" || res.NewSHA == sha {
t.Fatalf("the amended commit should still be reported: %+v", res)
}
if !strings.Contains(res.BlockedReason, "Forbidden") {
t.Fatalf("reason = %q", res.BlockedReason)
}
// The refusal must leave the remote branch untouched.
verify := filepath.Join(root, "verify")
mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify)
if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha {
t.Fatalf("remote head = %s, want %s", got, sha)
}
}
func mustGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return string(out)
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/bot"
"git.dcntrlized.org/Dcntrlized/license-bot/internal/config"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "0.0.1"
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel()}))
cfg, err := config.FromEnv()
if err != nil {
log.Error("invalid configuration", "err", err)
os.Exit(2)
}
log.Info("starting license-bot",
"version", version,
"repos", strings.Join(cfg.Repos, ","),
"listen", cfg.Listen,
"dry_run", cfg.DryRun,
)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := bot.New(cfg, log).Run(ctx); err != nil {
log.Error("license-bot stopped", "err", err)
os.Exit(1)
}
}
func logLevel() slog.Level {
switch strings.ToLower(os.Getenv("LOG_LEVEL")) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}