Skip to content

Commit 25cf75b

Browse files
TMHSDigitalclaude
andcommitted
ci: summarise licence-field changes on every pull request
The catalog's promise is that each licence fact was read at its source, but a reviewer had to dig through raw frontmatter diffs to see which licence, commercial, attribution, status, preview or verified values a PR changed. On pull requests, CI now fetches the base commit and runs site/pr-diff.mjs, which appends a table to the run's step summary. Each row is an entry, a field, its old and new value, and the entry's newest Evidence line. Added and removed entries are marked. The job keeps its contents: read token. Values are written as inert code spans, with pipes escaped. The step is informational and never fails a PR over content. The diff logic is lib/licence-diff.mjs, with tests. Checked locally from a shallow clone, as CI checks out. Closes #210 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 3079a48 commit 25cf75b

4 files changed

Lines changed: 187 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ jobs:
4545
- name: Build site and check pages
4646
run: node site/build.mjs
4747

48+
# Pull requests only: a table of every changed licence field, old and
49+
# new, beside the entry's newest Evidence line, on the run's summary page.
50+
- name: Summarise licence-field changes
51+
if: github.event_name == 'pull_request'
52+
env:
53+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
54+
run: |
55+
git fetch --no-tags --depth=1 origin "$BASE_SHA"
56+
node site/pr-diff.mjs --base "$BASE_SHA" --out "$GITHUB_STEP_SUMMARY"
57+
4858
- name: Upload Pages artifact
4959
if: ${{ inputs.upload-dist }}
5060
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0

‎site/lib/lib.test.mjs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,6 +1206,31 @@ eq("search: q from the URL is capped", readState(`?q=${"a".repeat(5000)}`, { q:
12061206
has("guide: images refused", threw, "g.md:3: an image is not supported");
12071207
}
12081208

1209+
/* PR licence-field summary (licence-diff.mjs) ------------------------------ */
1210+
{
1211+
const { licenceChanges, licenceDiffMarkdown, newestEvidenceLine } = await import("./licence-diff.mjs");
1212+
const before = { id: "a", license: "CC-BY", commercial: true, attribution_required: true, status: "active", verified: "2026-01-01", tags: ["x"] };
1213+
const after = { ...before, license: "CC0", attribution_required: false, verified: "2026-11-01", tags: ["y"] };
1214+
const ch = licenceChanges(before, after);
1215+
eq("licence diff: only licence fields, in order", ch.map((c) => c.field).join(","), "license,attribution_required,verified");
1216+
eq("licence diff: old and new values", `${ch[0].before}>${ch[0].after}`, "CC-BY>CC0");
1217+
eq("licence diff: booleans as text", ch[1].after, "false");
1218+
eq("licence diff: an added entry lists its set fields", licenceChanges(null, after).map((c) => c.field).join(","), "status,license,commercial,attribution_required,verified");
1219+
eq("licence diff: no change, no rows", licenceChanges(before, { ...before, tags: ["z"] }).length, 0);
1220+
const body = "Intro (2027-01-01).\n\n## Evidence\n\n- Page (2026-01-01): \"old\"\n- [Release](https://x.example/2030-01-01) (2026-11-01): \"CC0\"\n\n## Notes\n\n- 2028-01-01 later note\n";
1221+
eq("licence diff: newest Evidence line, URL dates and other sections ignored", newestEvidenceLine(body), '- [Release](https://x.example/2030-01-01) (2026-11-01): "CC0"');
1222+
eq("licence diff: no Evidence section", newestEvidenceLine("Nothing."), null);
1223+
const md = licenceDiffMarkdown([
1224+
{ file: "catalog/2d/a.md", change: "modified", fields: ch, evidence: "- quote with | pipe and `tick` (2026-11-01)" },
1225+
{ file: "catalog/2d/b.md", change: "modified", fields: [], evidence: null },
1226+
]);
1227+
has("licence diff: table counts changed entries only", md, "1 entry changed.");
1228+
has("licence diff: a row per field", md, "| `catalog/2d/a.md` | license | `CC-BY` | `CC0` |");
1229+
has("licence diff: pipes are escaped and backticks neutralised", md, "quote with \\| pipe and 'tick'");
1230+
lacks("licence diff: unchanged entries are left out", md, "b.md");
1231+
has("licence diff: nothing changed", licenceDiffMarkdown([]), "No licence field changed");
1232+
}
1233+
12091234
/* data.json schema (schema-check.mjs + site/data.schema.json) -------------- */
12101235
{
12111236
const { schemaErrors } = await import("./schema-check.mjs");

‎site/lib/licence-diff.mjs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* What a pull request changes in the licence facts of catalog entries, as a
3+
* Markdown table for the CI step summary (site/pr-diff.mjs). A reviewer sees
4+
* every changed licence field, old and new, beside the newest Evidence line
5+
* that should back it, instead of reading raw frontmatter diffs. Pure.
6+
*/
7+
import { evidenceSection } from "./frontmatter.mjs";
8+
9+
/** The frontmatter fields that carry a licence fact, in table order. */
10+
export const LICENCE_FIELDS = [
11+
"status",
12+
"license",
13+
"license_spdx",
14+
"commercial",
15+
"attribution_required",
16+
"attribution_string",
17+
"engine_lock",
18+
"preview",
19+
"verified",
20+
];
21+
22+
const DATE_RE = /\b(\d{4}-\d{2}-\d{2})\b/g;
23+
24+
const text = (v) => (v === undefined || v === null || v === "" ? null : Array.isArray(v) ? v.join(", ") : String(v));
25+
26+
/** Changed licence fields between two frontmatter objects (either may be null: added or removed). */
27+
export function licenceChanges(before, after) {
28+
const out = [];
29+
for (const field of LICENCE_FIELDS) {
30+
const a = before ? text(before[field]) : null;
31+
const b = after ? text(after[field]) : null;
32+
if (a !== b) out.push({ field, before: a, after: b });
33+
}
34+
return out;
35+
}
36+
37+
/** The Evidence line carrying the newest date (dates inside URLs ignored), or null. */
38+
export function newestEvidenceLine(body) {
39+
const ev = evidenceSection(String(body || ""));
40+
if (!ev) return null;
41+
let best = null;
42+
for (const line of ev.split(/\r?\n/)) {
43+
const prose = line.replace(/\]\([^)]*\)/g, "]").replace(/https?:\/\/\S+/g, "");
44+
for (const m of prose.matchAll(DATE_RE)) {
45+
if (!best || m[1] > best.date) best = { date: m[1], line: line.trim() };
46+
}
47+
}
48+
return best ? best.line : null;
49+
}
50+
51+
/** A value as an inert inline code span (or an em dash for none), safe inside a table cell. */
52+
function cell(v, max = 160) {
53+
if (v === null || v === undefined) return "—";
54+
let s = String(v).replace(/\s+/g, " ").trim();
55+
if (s.length > max) s = `${s.slice(0, max - 1)}…`;
56+
s = s.replaceAll("`", "'").replaceAll("|", "\\|");
57+
return `\`${s}\``;
58+
}
59+
60+
/**
61+
* The step-summary Markdown for a list of `{ file, change: "added"|"removed"|"modified",
62+
* fields: [{ field, before, after }], evidence }`.
63+
*/
64+
export function licenceDiffMarkdown(rows) {
65+
const changed = rows.filter((r) => r.fields.length);
66+
const lines = ["## Licence fields changed in this pull request", ""];
67+
if (!changed.length) {
68+
lines.push("No licence field changed in any catalog entry.", "");
69+
return lines.join("\n");
70+
}
71+
lines.push(
72+
`${changed.length} ${changed.length === 1 ? "entry" : "entries"} changed. Check each new value against the Evidence line beside it.`,
73+
"",
74+
"| Entry | Field | Before | After | Newest Evidence line |",
75+
"| --- | --- | --- | --- | --- |"
76+
);
77+
for (const r of changed) {
78+
const label = `${cell(r.file)}${r.change === "modified" ? "" : ` (${r.change})`}`;
79+
r.fields.forEach((f, i) => {
80+
lines.push(`| ${i ? "" : label} | ${f.field} | ${cell(f.before)} | ${cell(f.after)} | ${i ? "" : cell(r.evidence, 220)} |`);
81+
});
82+
}
83+
lines.push("");
84+
return lines.join("\n");
85+
}

‎site/pr-diff.mjs‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Summarises the licence-field changes between a base commit and HEAD:
4+
*
5+
* node site/pr-diff.mjs --base <sha> [--out <file>]
6+
*
7+
* For every catalog entry the range adds, removes or modifies, it lists each
8+
* changed licence field (lib/licence-diff.mjs) beside the entry's newest
9+
* Evidence line, as Markdown. CI appends it to the pull request's step
10+
* summary ($GITHUB_STEP_SUMMARY). Informational: it never fails a pull
11+
* request over content, only over a missing base commit or bad arguments.
12+
*/
13+
import { execFileSync } from "node:child_process";
14+
import fs from "node:fs";
15+
import path from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
import { parseFrontmatter } from "./lib/frontmatter.mjs";
18+
import { licenceChanges, licenceDiffMarkdown, newestEvidenceLine } from "./lib/licence-diff.mjs";
19+
import { NON_ENTRY_MD } from "./lib/entry-files.mjs";
20+
21+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
22+
const ROOT = path.resolve(__dirname, "..");
23+
24+
function arg(name) {
25+
const i = process.argv.indexOf(name);
26+
return i === -1 ? null : process.argv[i + 1] || null;
27+
}
28+
29+
const git = (args) => execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
30+
31+
function parse(text) {
32+
if (text === null) return { meta: null, body: "" };
33+
const parsed = parseFrontmatter(text);
34+
return parsed ? { meta: parsed.meta, body: parsed.body } : { meta: null, body: "" };
35+
}
36+
37+
function main() {
38+
const base = arg("--base");
39+
if (!base || !/^[0-9a-f]{7,40}$/i.test(base)) {
40+
console.error("usage: node site/pr-diff.mjs --base <commit sha> [--out <file>]");
41+
process.exit(2);
42+
}
43+
const out = arg("--out");
44+
// Renames are reported as a removal and an addition, which shows both ids.
45+
const changes = git(["diff", "--no-renames", "--name-status", "-z", base, "HEAD", "--", "catalog/"]).split("\0").filter(Boolean);
46+
const rows = [];
47+
for (let i = 0; i + 1 < changes.length; i += 2) {
48+
const status = changes[i];
49+
const file = changes[i + 1];
50+
if (!file.endsWith(".md") || NON_ENTRY_MD.has(path.posix.basename(file)) || file.split("/").some((p) => p.startsWith("_") || p.startsWith("."))) continue;
51+
const before = status === "A" ? null : git(["show", `${base}:${file}`]);
52+
const after = status === "D" ? null : fs.readFileSync(path.join(ROOT, file), "utf8");
53+
const a = parse(before);
54+
const b = parse(after);
55+
rows.push({
56+
file,
57+
change: status === "A" ? "added" : status === "D" ? "removed" : "modified",
58+
fields: licenceChanges(a.meta, b.meta),
59+
evidence: newestEvidenceLine(b.body || a.body),
60+
});
61+
}
62+
const md = licenceDiffMarkdown(rows);
63+
if (out) fs.appendFileSync(out, `${md}\n`);
64+
else process.stdout.write(md);
65+
}
66+
67+
main();

0 commit comments

Comments
 (0)