Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Then open the local URL printed by Next.js, usually
<http://localhost:3000>. 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/`
(`<category>-<slug>.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.
Expand Down
27 changes: 27 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -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:** `<category>-<slug>.md`, where `<category>` is one of `added`,
`changed`, `deprecated`, `removed`, `fixed`, `security`, and `<slug>` 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.
```
1 change: 1 addition & 0 deletions changelog.d/added-changelog-fragment-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `npm test` now fails on a `changelog.d/` fragment whose filename is not `<category>-<slug>.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.
2 changes: 1 addition & 1 deletion changelog.d/added-healthz-request-metrics.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion changelog.d/fixed-healthz-reason-redaction.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion changelog.d/security-sharp-0-35-5.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions scripts/changelog-fragments.test.ts
Original file line number Diff line number Diff line change
@@ -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 <category>-<slug>.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 <category>-<slug>.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",
]);
});
});
Loading