diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c20ae4..6f48f22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,10 @@ Then open the local URL printed by Next.js, usually . For a production-style preview, run `npm run build` followed by `npm run start`. +User-visible changes also need a changelog fragment under `changelog.d/` +(`-.md`, body a `- ` bullet list — see +`changelog.d/README.md`); `npm test` rejects malformed fragments. + `npm run build` fails if a doc-sync step in `prebuild` fails. When working offline, set `DOCS_SYNC_OPTIONAL=1` (for example `DOCS_SYNC_OPTIONAL=1 npm run build`) to keep the committed content instead. diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 0000000..0a480a7 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,27 @@ +# changelog.d — one changelog fragment per PR + +Each user-visible change lands here as **one file per PR** so that two PRs +merging close together never edit the same lines of a shared `CHANGELOG`. +Fragments are grouped into Keep-a-Changelog headings by their filename prefix +when a changelog is rolled up, and `npm test` fails on a malformed fragment +(`scripts/changelog-fragments.test.ts`) so it is caught on the PR instead. + +## Format + +- **Filename:** `-.md`, where `` is one of `added`, + `changed`, `deprecated`, `removed`, `fixed`, `security`, and `` is + lowercase `[a-z0-9-]` — a PR/issue number, a short slug, or both: + `fixed-162-release-channels-raw-markdown.md`. +- **Content:** the entry itself — one or more `- ` bullets (two-space + continuation lines are fine), ending in a newline. No headings and no bare + prose: the roll-up owns the `###` headings, your file is the bullet. +- **What qualifies:** user-visible features, fixes, security changes, + migrations, deprecations. Test-only changes, refactors and dependency churn + with no visible effect need no fragment. + +Example — `changelog.d/fixed-1234-search-index.md`: + +```markdown +- `/api/search` now indexes hive-project pages under `docs/content/hive/` + (hivecommons/docs#1234). Previously they were silently excluded. +``` diff --git a/changelog.d/added-changelog-fragment-guard.md b/changelog.d/added-changelog-fragment-guard.md new file mode 100644 index 0000000..b96b418 --- /dev/null +++ b/changelog.d/added-changelog-fragment-guard.md @@ -0,0 +1 @@ +- `npm test` now fails on a `changelog.d/` fragment whose filename is not `-.md` (category one of added/changed/deprecated/removed/fixed/security) or whose body is empty, not a `- ` bullet list, or missing its trailing newline, and `changelog.d/README.md` documents the format. diff --git a/changelog.d/added-healthz-request-metrics.md b/changelog.d/added-healthz-request-metrics.md index b6ff304..2d26dfe 100644 --- a/changelog.d/added-healthz-request-metrics.md +++ b/changelog.d/added-healthz-request-metrics.md @@ -1 +1 @@ -Record `/api/healthz` request count, status class, and duration in the existing bounded `/api/metrics` registry so readiness failures can be alerted on. +- Record `/api/healthz` request count, status class, and duration in the existing bounded `/api/metrics` registry so readiness failures can be alerted on. diff --git a/changelog.d/fixed-healthz-reason-redaction.md b/changelog.d/fixed-healthz-reason-redaction.md index 86b6d54..1169da8 100644 --- a/changelog.d/fixed-healthz-reason-redaction.md +++ b/changelog.d/fixed-healthz-reason-redaction.md @@ -1 +1 @@ -Return a fixed reason from `/api/healthz` when the content path is unreadable; the underlying filesystem error stays in the server log. +- Return a fixed reason from `/api/healthz` when the content path is unreadable; the underlying filesystem error stays in the server log. diff --git a/changelog.d/security-sharp-0-35-5.md b/changelog.d/security-sharp-0-35-5.md index 1763f3e..02efaf2 100644 --- a/changelog.d/security-sharp-0-35-5.md +++ b/changelog.d/security-sharp-0-35-5.md @@ -1 +1 @@ -Bump `sharp` 0.35.4 → 0.35.5 (lockfile only) so the production `npm audit` gate stays green. +- Bump `sharp` 0.35.4 → 0.35.5 (lockfile only) so the production `npm audit` gate stays green. diff --git a/scripts/changelog-fragments.test.ts b/scripts/changelog-fragments.test.ts new file mode 100644 index 0000000..d35c698 --- /dev/null +++ b/scripts/changelog-fragments.test.ts @@ -0,0 +1,125 @@ +// Guards changelog.d/ fragments so a malformed one fails the PR that adds it. +// Fragments are rolled up into Keep-a-Changelog headings by their filename +// prefix, and each file is expected to BE the bullet entry; a fragment with an +// unknown prefix, an empty body, or a prose (non-bullet) body would be dropped +// or mis-sectioned at roll-up time. See changelog.d/README.md. +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const FRAGMENT_DIR = join(__dirname, "..", "changelog.d"); +const EXEMPT = new Set(["README.md", ".gitkeep"]); + +export const CATEGORIES = [ + "added", + "changed", + "deprecated", + "removed", + "fixed", + "security", +] as const; + +export const FRAGMENT_NAME = new RegExp( + `^(${CATEGORIES.join("|")})-[a-z0-9][a-z0-9-]*\\.md$` +); + +export function fragmentNameErrors(name: string): string[] { + if (EXEMPT.has(name)) return []; + if (!FRAGMENT_NAME.test(name)) { + return [ + `${name}: must be named -.md with category one of ${CATEGORIES.join("/")}`, + ]; + } + return []; +} + +export function fragmentBodyErrors(name: string, body: string): string[] { + if (EXEMPT.has(name)) return []; + const lines = body.split("\n").filter(l => l.trim() !== ""); + if (lines.length === 0) return [`${name}: fragment is empty`]; + const errors: string[] = []; + for (const line of lines) { + if (!/^(- | {2})/.test(line)) { + errors.push( + `${name}: every line must be a "- " bullet or a two-space continuation: ${JSON.stringify(line)}` + ); + break; + } + } + if (!body.endsWith("\n")) errors.push(`${name}: missing trailing newline`); + return errors; +} + +function fragmentEntries(): string[] { + return readdirSync(FRAGMENT_DIR).filter( + n => !statSync(join(FRAGMENT_DIR, n)).isDirectory() + ); +} + +describe("changelog.d fragments", () => { + it("contain no directories", () => { + const dirs = readdirSync(FRAGMENT_DIR).filter(n => + statSync(join(FRAGMENT_DIR, n)).isDirectory() + ); + expect(dirs).toEqual([]); + }); + + it("are named -.md", () => { + const errors = fragmentEntries().flatMap(fragmentNameErrors); + expect(errors).toEqual([]); + }); + + it("are non-empty markdown bullet lists ending in a newline", () => { + const errors = fragmentEntries().flatMap(name => + fragmentBodyErrors(name, readFileSync(join(FRAGMENT_DIR, name), "utf8")) + ); + expect(errors).toEqual([]); + }); +}); + +describe("fragmentNameErrors", () => { + it("accepts valid names and exempt files", () => { + expect(fragmentNameErrors("fixed-147.md")).toEqual([]); + expect(fragmentNameErrors("security-sharp-0-35-5.md")).toEqual([]); + expect(fragmentNameErrors("README.md")).toEqual([]); + expect(fragmentNameErrors(".gitkeep")).toEqual([]); + }); + + it("rejects unknown categories and bad slugs", () => { + for (const bad of [ + "feature-foo.md", + "fixed-Foo.md", + "fixed-.md", + "fixed-foo.txt", + "fixed.md", + "fixed-_foo.md", + ]) { + expect(fragmentNameErrors(bad)).toHaveLength(1); + } + }); +}); + +describe("fragmentBodyErrors", () => { + it("accepts bullet lists with continuations", () => { + expect(fragmentBodyErrors("fixed-a.md", "- One line (#1).\n")).toEqual([]); + expect( + fragmentBodyErrors("fixed-a.md", "- First.\n continued.\n\n- Second.\n") + ).toEqual([]); + expect(fragmentBodyErrors("README.md", "anything goes\n")).toEqual([]); + }); + + it("rejects empty, prose and unterminated fragments", () => { + expect(fragmentBodyErrors("fixed-a.md", "")).toEqual([ + "fixed-a.md: fragment is empty", + ]); + expect(fragmentBodyErrors("fixed-a.md", "\n\n")).toEqual([ + "fixed-a.md: fragment is empty", + ]); + expect(fragmentBodyErrors("fixed-a.md", "Fixed a thing.\n")).toHaveLength( + 1 + ); + expect(fragmentBodyErrors("fixed-a.md", "- No newline")).toEqual([ + "fixed-a.md: missing trailing newline", + ]); + }); +});