From 909c1bf0814fd67c3d492fffec2c902389f717bc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 18:38:17 -0700 Subject: [PATCH] CI: fail when a changed upstream file lacks the AGPL 5(a) notice The 53 upstream files this fork changed were marked on 2026-09-19 ("Modified by Coffey Labs in 2026 for INBUXA."), but nothing kept it up: the next edit to an upstream file could ship without the notice. tools/notice-check.mjs does for the console what inbuxa-server's tools/fork/notice-check.py does for the server: it diffs against the upstream release this fork is built on (v1.0.11, dc462b1), takes every changed file whose header names Stalwart Labs as a copyright holder, and fails when one lacks the notice. --fix adds it under the license line (npm run notice:check / notice:fix). Renames count; a pure move doesn't. A new `notices` job runs it on every push and pull request, whatever BUILD_ON says: seconds, no npm install. Today: clean, 52 changed upstream files, all marked. --- .gitea/workflows/ci.yml | 15 +++++++ package.json | 4 +- tools/notice-check.mjs | 97 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tools/notice-check.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 97c6dae..863945a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -68,6 +68,21 @@ jobs: - run: npm test - run: npm run build + # A changed upstream file without the AGPL 5(a) notice, as inbuxa-server's + # fork-checks job catches for the server. Seconds and no npm install, so it + # runs here whatever BUILD_ON says. It diffs against the upstream release + # this fork is built on, hence the full fetch. + notices: + runs-on: light + container: + image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim + steps: + - uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec + with: + fetch-depth: 0 + - run: command -v git >/dev/null || { apt-get update -qq && apt-get install -y -qq --no-install-recommends git >/dev/null; } + - run: git config --global --add safe.directory "$PWD" && node tools/notice-check.mjs + # ----------------------------------------------------------- publish ------ # Port of publish.yml, to the owner's own registry now that GHCR went with # the GitHub account: /inbuxa/inbuxa-admin, the same path the diff --git a/package.json b/package.json index 13c580f..f02eded 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "format": "prettier --write src/", "format:check": "prettier --check src/", "i18n": "node tools/i18n-extract.mjs", - "i18n:check": "node tools/i18n-extract.mjs --check" + "i18n:check": "node tools/i18n-extract.mjs --check", + "notice:check": "node tools/notice-check.mjs", + "notice:fix": "node tools/notice-check.mjs --fix" }, "dependencies": { "@daypicker/react": "^10.0.1", diff --git a/tools/notice-check.mjs b/tools/notice-check.mjs new file mode 100644 index 0000000..92327dd --- /dev/null +++ b/tools/notice-check.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * inbuxa: fails when an upstream file this fork changed doesn't say so + * (AGPL section 5(a)), as inbuxa-server's tools/fork/notice-check.py does + * for the server. + * + * node tools/notice-check.mjs check; exit 1 on a missing notice + * node tools/notice-check.mjs --fix add the notice where it's missing + * + * The AGPL asks a modified work to carry a prominent notice that it was + * modified, with a date. Every upstream file this fork changes carries one + * beneath upstream's own notice: + * + * * Modified by Coffey Labs in 2026 for INBUXA. + * + * "Changed" is measured against BASE, the upstream release this fork is + * built on, so the list is what actually differs rather than a guess. A file + * counts as upstream's when its header names Stalwart Labs as a copyright + * holder; files the fork wrote carry their own copyright and need nothing. + * When a newer upstream release is merged, move BASE to it. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; + +// Upstream's web interface v1.0.11, the release this fork started from. +const BASE = process.env.NOTICE_BASE || 'dc462b137fbee9c2193946a6db554ff20bcc9b07'; + +const HEADER_LINES = 15; +const UPSTREAM_HOLDER = /SPDX-FileCopyrightText:.*Stalwart Labs/; +const NOTICE = /Modified by Coffey Labs in \d{4}/; +const LICENSE_LINE = /^(\s*(?:\*|\/\/|#)\s*)SPDX-License-Identifier:.*$/; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }); + +function changedUpstreamFiles() { + try { + git('cat-file', '-e', `${BASE}^{commit}`); + } catch { + console.error(`notice-check: ${BASE} is not in this history; fetch it in full first`); + process.exit(2); + } + // Against the working tree, so it also checks work not yet committed; in + // CI the two are the same. Renames count: a moved and edited file is + // still upstream's. + const out = []; + for (const line of git('diff', '--name-status', '-M', '--diff-filter=MR', BASE).split('\n')) { + if (!line) continue; + const parts = line.split('\t'); + if (parts[0] === 'R100') continue; // moved, not changed + const path = parts[parts.length - 1]; + let head; + try { + head = readFileSync(path, 'utf8').split('\n').slice(0, HEADER_LINES); + } catch { + continue; + } + if (head.some((l) => UPSTREAM_HOLDER.test(l))) out.push({ path, head }); + } + return out; +} + +/** Put the notice under the license line, in that comment's own style. */ +function addNotice(path) { + const lines = readFileSync(path, 'utf8').split('\n'); + for (let n = 0; n < Math.min(lines.length, HEADER_LINES); n++) { + const m = LICENSE_LINE.exec(lines[n]); + if (m) { + const prefix = m[1]; + const year = new Date().getFullYear(); + lines.splice(n + 1, 0, prefix.trimEnd(), `${prefix}Modified by Coffey Labs in ${year} for INBUXA.`); + writeFileSync(path, lines.join('\n')); + return true; + } + } + return false; +} + +const fix = process.argv.includes('--fix'); +const files = changedUpstreamFiles(); +let missing = files.filter(({ head }) => !head.some((l) => NOTICE.test(l))).map(({ path }) => path); + +if (fix) { + const unfixable = missing.filter((p) => !addNotice(p)); + for (const p of missing.filter((p) => !unfixable.includes(p)).sort()) console.log(`added: ${p}`); + missing = unfixable; +} + +if (missing.length) { + console.log(`${missing.length} upstream file(s) changed against ${BASE.slice(0, 7)} without the modification notice:\n`); + for (const p of missing) console.log(` ${p}`); + console.log('\nAdd "Modified by Coffey Labs in for INBUXA." under the license line, or run npm run notice:fix.'); + process.exit(1); +} +console.log(`notice check: clean (${files.length} changed upstream file(s), all marked; against ${BASE.slice(0, 7)}).`); -- 2.54.0