From 0af97448c5e15a19c74e94e2db80cee233b345e6 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Wed, 7 Oct 2026 00:44:01 +0100 Subject: [PATCH] ci: add AGPL license bot (amend added files + PR comment) --- .gitea/workflows/license.yml | 174 +++++++++++++++++++++++++++++++++++ .license-header.tpl | 14 +++ .licenseignore | 30 ++++++ 3 files changed, 218 insertions(+) create mode 100644 .gitea/workflows/license.yml create mode 100644 .license-header.tpl create mode 100644 .licenseignore diff --git a/.gitea/workflows/license.yml b/.gitea/workflows/license.yml new file mode 100644 index 0000000..77792f7 --- /dev/null +++ b/.gitea/workflows/license.yml @@ -0,0 +1,174 @@ +name: license-headers + +# Enforce AGPL-3.0 headers on every file ADDED by a pull request: amend the PR +# head commit with the headers and comment the new commit hash on the PR. +# +# `runs-on` must match a registered runner label. The org's only runner offers +# `alpine-latest` (container `node:alpine`), so jobs asking for anything else +# (e.g. `alpine-3.24`) queue forever — that is why this bot never ran before. +# +# No long-lived secret is needed or wanted here: Gitea mints a per-run token +# (`secrets.GITHUB_TOKEN`) scoped to this repository, which is enough to amend +# the PR branch and comment. Never add a PAT/SSH key — an action that runs on +# PRs (including forks) must stay keyless, and Gitea withholds repo/org secrets +# from fork PRs anyway. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + issues: write + pull-requests: read + +concurrency: + group: license-headers-${{ gitea.event.pull_request.number }} + cancel-in-progress: true + +defaults: + run: + shell: sh + +jobs: + addlicense: + runs-on: alpine-latest + steps: + - name: Install prerequisites + run: apk add --no-cache git curl ca-certificates jq + + - name: Checkout pull request head branch + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + REF="${{ gitea.event.pull_request.head.ref }}" + HEAD_REPO="${{ gitea.event.pull_request.head.repo.full_name }}" + BASE_REPO="${{ gitea.repository }}" + if [ "$HEAD_REPO" != "$BASE_REPO" ]; then + echo "Fork PR ($HEAD_REPO -> $BASE_REPO): the bot cannot push to a fork; skipping." + echo "fork_pr=true" >> "$GITHUB_ENV" + exit 0 + fi + # Keep the scheme: the dev Gitea is HTTP-only, so a hardcoded https:// + # here fails with "TLS connect error: wrong version number". + SERVER="${{ gitea.server_url }}" + SERVER="${SERVER%/}" + SCHEME="${SERVER%%://*}" + HOST="${SERVER#*://}" + git clone --depth 1 --branch "$REF" \ + "${SCHEME}://oauth2:${TOKEN}@${HOST}/${BASE_REPO}.git" . + # Lease value for the later force push: the exact remote head we based on. + git ls-remote origin "refs/heads/${REF}" | cut -f1 > /tmp/head_before.txt + echo "checked out ${REF} at $(git rev-parse HEAD)" + + - name: Install addlicense + if: env.fork_pr != 'true' + run: | + set -e + curl -sSL -o /tmp/addlicense.tar.gz \ + https://github.com/google/addlicense/releases/download/v1.2.0/addlicense_v1.2.0_Linux_x86_64.tar.gz + tar -xzf /tmp/addlicense.tar.gz -C /tmp + + - name: Add AGPL-3.0 headers to newly added files + if: env.fork_pr != 'true' + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + SERVER="${{ gitea.server_url }}" + SERVER="${SERVER%/}" + REPO="${{ gitea.repository }}" + PR="${{ gitea.event.pull_request.number }}" + + # Files added by this PR, straight from the API (no base history needed). + : > /tmp/added.txt + page=1 + while : ; do + body="$(curl -sS -H "Authorization: token ${TOKEN}" \ + "${SERVER}/api/v1/repos/${REPO}/pulls/${PR}/files?limit=100&page=${page}")" + n="$(printf '%s' "$body" | jq 'length')" + [ "$n" = "0" ] && break + printf '%s' "$body" | jq -r '.[] | select(.status=="added") | .filename' >> /tmp/added.txt + [ "$n" -lt 100 ] && break + page=$((page + 1)) + done + + # addlicense validates -ignore with doublestar and silently skips file + # types it does not understand, so pass every added file and let + # .licenseignore be the only escape hatch. + set -- -v -f .license-header.tpl -c "Dcntrlized" -y "$(date +%Y)" + if [ -f .licenseignore ]; then + while IFS= read -r line; do + case "$line" in ""|\#*) continue ;; esac + set -- "$@" -ignore "$line" + done < .licenseignore + fi + found=0 + while IFS= read -r f; do + [ -n "$f" ] || continue + [ -f "$f" ] || continue + found=1 + /tmp/addlicense "$@" "$f" + done < /tmp/added.txt + [ "$found" = "1" ] || echo "No newly added files." + + if git diff --quiet; then + echo "no_changes=true" >> "$GITHUB_ENV" + else + echo "no_changes=false" >> "$GITHUB_ENV" + fi + + - name: Amend the commit and force-push + if: env.fork_pr != 'true' && env.no_changes == 'false' + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + REF="${{ gitea.event.pull_request.head.ref }}" + SERVER="${{ gitea.server_url }}" + SERVER="${SERVER%/}" + SCHEME="${SERVER%%://*}" + HOST="${SERVER#*://}" + git config user.name "Dcntrlized Auto CopyLeft Bot" + git config user.email "copyleft+bot@dcntrlized.org" + git add -A + git commit --amend --no-edit + SHA="$(git rev-parse HEAD)" + printf '%s\n' "$SHA" > /tmp/commit_sha.txt + echo "amended to $SHA" + BEFORE="$(cat /tmp/head_before.txt)" + git push --force-with-lease="refs/heads/${REF}:${BEFORE}" \ + "${SCHEME}://oauth2:${TOKEN}@${HOST}/${{ gitea.repository }}.git" \ + "HEAD:refs/heads/${REF}" + + - name: Comment on the pull request + if: env.fork_pr != 'true' && env.no_changes == 'false' + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + SERVER="${{ gitea.server_url }}" + SERVER="${SERVER%/}" + REPO="${{ gitea.repository }}" + PR="${{ gitea.event.pull_request.number }}" + SHA="$(cat /tmp/commit_sha.txt)" + cat > /tmp/pr-comment.md <. diff --git a/.licenseignore b/.licenseignore new file mode 100644 index 0000000..591c418 --- /dev/null +++ b/.licenseignore @@ -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/**