diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml
new file mode 100644
index 0000000..4ad44a9
--- /dev/null
+++ b/.github/workflows/linkcheck.yml
@@ -0,0 +1,79 @@
+name: Link check
+
+on:
+ pull_request:
+ paths:
+ - "**/*.mdx"
+ - "**/*.md"
+ - "docs.json"
+ - "package.json"
+ - "scripts/**"
+ push:
+ branches: [main]
+ paths:
+ - "**/*.mdx"
+ - "**/*.md"
+ - "docs.json"
+ - "package.json"
+ - "scripts/**"
+ schedule:
+ # Weekly, Monday 06:00 UTC — catches external-link rot between PRs.
+ - cron: "0 6 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ lychee:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+ issues: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Check navigation, internal links, anchors, assets and frontmatter
+ run: npm run check
+
+ - name: Restore lychee cache
+ uses: actions/cache@v4
+ with:
+ path: .lycheecache
+ key: cache-lychee-${{ github.sha }}
+ restore-keys: cache-lychee-
+
+ # External links only (--scheme): internal links, anchors and nav are already
+ # covered by `npm run check` above, and Mintlify-style extensionless paths like
+ # /updating are not resolvable as files. --root-dir keeps lychee from erroring
+ # while resolving root-relative links before the scheme filter excludes them.
+ # Do not pass `--base .`: current lychee releases reject it
+ # (base must be a URL or absolute path).
+ - name: Check external links
+ uses: lycheeverse/lychee-action@v2
+ with:
+ args: >-
+ --cache
+ --max-cache-age 1d
+ --no-progress
+ --scheme https
+ --scheme http
+ --root-dir ${{ github.workspace }}
+ --exclude-path images
+ --exclude-path .github
+ --accept 200,206,429
+ './**/*.mdx'
+ './**/*.md'
+ fail: true
+ jobSummary: true
+
+ - name: Open an issue when the scheduled run finds breakage
+ if: failure() && github.event_name == 'schedule'
+ uses: peter-evans/create-issue-from-file@v5
+ with:
+ title: Broken links found by scheduled link check
+ content-filepath: ./lychee/out.md
+ labels: docs, broken-links
diff --git a/.github/workflows/mintlify.yml b/.github/workflows/mintlify.yml
index 3174619..31319b9 100644
--- a/.github/workflows/mintlify.yml
+++ b/.github/workflows/mintlify.yml
@@ -6,12 +6,22 @@ on:
branches:
- main
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
validate:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
+ with:
+ persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml
new file mode 100644
index 0000000..dd18c8c
--- /dev/null
+++ b/.github/workflows/sync-changelog.yml
@@ -0,0 +1,52 @@
+name: Sync changelog
+
+on:
+ schedule:
+ # Daily at 12:00 UTC.
+ - cron: "0 12 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ sync:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ contents: write
+ pull-requests: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+
+ - name: Baseline check
+ run: npm run check
+
+ - name: Regenerate changelog.mdx from GitHub Releases
+ run: npm run sync-changelog
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+
+ - name: Re-check after regeneration
+ run: npm run check
+
+ - name: Open a PR if the changelog moved
+ uses: peter-evans/create-pull-request@v6
+ with:
+ branch: chore/sync-changelog
+ delete-branch: true
+ title: "docs: sync changelog from GitHub Releases"
+ labels: docs, automation
+ commit-message: "docs: sync changelog from GitHub Releases"
+ body: |
+ Regenerated `changelog.mdx` from the product repo's GitHub Releases.
+
+ Reviewer checklist:
+
+ - [ ] Every new entry's summary reads as user-facing prose, not a commit subject.
+ - [ ] Component labels are right — `cli-v*` tags render as CLI, plain `v*` tags as Server.
+ - [ ] Each "Full release notes" link resolves to the right GitHub release.
diff --git a/changelog.mdx b/changelog.mdx
new file mode 100644
index 0000000..87da43a
--- /dev/null
+++ b/changelog.mdx
@@ -0,0 +1,61 @@
+---
+title: Changelog
+description: "Published Mantis releases — CLI and server — what changed and where the full notes live."
+---
+
+{/* GENERATED FILE — do not edit by hand. */}
+{/* Regenerate with `npm run sync-changelog`; the source is GitHub Releases on privacykey/mantis. */}
+
+
+ This page mirrors [GitHub Releases](https://github.com/privacykey/mantis/releases) — `cli-v*` tags publish CLI
+ binaries, plain `v*` tags publish server releases. After updating, run `mantis doctor`
+ to confirm CLI/server compatibility, and see [Updating](/updating) for the per-component
+ update commands.
+
+## CLI v0.1.6
+
+14 May 2026
+
+Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.6)
+
+## CLI v0.1.5
+
+14 May 2026
+
+Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.5)
+
+## CLI v0.1.4
+
+14 May 2026
+
+Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.4)
+
+## CLI v0.1.3
+
+13 May 2026
+
+Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.3)
+
+## CLI v0.1.2
+
+13 May 2026
+
+Standalone CLI binaries for macOS and Linux on arm64 / x86_64. Each binary is built on its own native runner so the bundled native modules (`@napi-rs/keyring`) match the target.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.2)
+
+## CLI v0.1.0
+
+13 May 2026
+
+Cross-compiled standalone CLI binaries for macOS and Linux on arm64 / x86_64.
+
+[Full release notes →](https://github.com/privacykey/mantis/releases/tag/cli-v0.1.0)
diff --git a/docs.json b/docs.json
index 8b65324..2bb6af2 100644
--- a/docs.json
+++ b/docs.json
@@ -54,7 +54,8 @@
"configuration",
"cli",
"cli-backup",
- "updating"
+ "updating",
+ "changelog"
]
},
{
diff --git a/package.json b/package.json
index 135b5a3..5e7cd9f 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,8 @@
"scripts": {
"dev": "npx --yes mint@latest dev",
"validate": "npx --yes mint@latest validate",
- "check-links": "npx --yes mint@latest broken-links"
+ "check-links": "npx --yes mint@latest broken-links",
+ "check": "node scripts/check-docs.mjs",
+ "sync-changelog": "node scripts/sync-changelog.mjs"
}
}
diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs
new file mode 100644
index 0000000..9158200
--- /dev/null
+++ b/scripts/check-docs.mjs
@@ -0,0 +1,441 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const errors = [];
+const warnings = [];
+const anchorCache = new Map();
+const linkedFileCache = new Set();
+const navPageCache = new Set();
+
+/** Frontmatter keys a page may declare. Anything else is a warning. */
+const ALLOWED_FRONTMATTER_KEYS = new Set(["title", "description", "icon", "sidebarTitle"]);
+const REQUIRED_FRONTMATTER_KEYS = ["title", "description"];
+
+/**
+ * Tags that are legitimate JSX in an MDX page. Anything else in angle brackets is
+ * either a Mintlify component (capitalised, so it passes the case test below) or a
+ * stray token that will fail the MDX parse — most often a `` that should
+ * have been backticked, or committed tool-call debris like ``.
+ */
+const KNOWN_HTML_TAGS = new Set([
+ "a", "b", "br", "code", "div", "em", "hr", "i", "img", "kbd", "li", "ol", "p",
+ "pre", "small", "span", "strong", "sub", "sup", "table", "tbody", "td", "th",
+ "thead", "tr", "ul", "details", "summary", "picture", "source", "video",
+]);
+
+function relative(filePath) {
+ return path.relative(root, filePath).replaceAll(path.sep, "/") || ".";
+}
+
+function addError(message) {
+ errors.push(message);
+}
+
+function addWarning(message) {
+ warnings.push(message);
+}
+
+function exists(filePath) {
+ return fs.existsSync(filePath);
+}
+
+function readJson(filePath) {
+ try {
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+ } catch (error) {
+ addError(`${relative(filePath)} could not be parsed as JSON: ${error.message}`);
+ return null;
+ }
+}
+
+function stripCodeFences(source) {
+ return source
+ .replace(/```[\s\S]*?```/g, (block) => "\n".repeat(block.split("\n").length - 1))
+ .replace(/~~~[\s\S]*?~~~/g, (block) => "\n".repeat(block.split("\n").length - 1));
+}
+
+function stripInlineCode(source) {
+ return source.replace(/`[^`\n]*`/g, (span) => " ".repeat(span.length));
+}
+
+function splitTarget(target) {
+ const withoutQuery = target.split("?")[0];
+ const hashIndex = withoutQuery.indexOf("#");
+
+ if (hashIndex === -1) {
+ return { targetPath: withoutQuery, anchor: "" };
+ }
+
+ return {
+ targetPath: withoutQuery.slice(0, hashIndex),
+ anchor: decodeURIComponent(withoutQuery.slice(hashIndex + 1)),
+ };
+}
+
+function isExternal(target) {
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(target);
+}
+
+function trimMarkdownTarget(target) {
+ const trimmed = target.trim();
+ const angleWrapped = trimmed.match(/^<([^>]+)>$/);
+
+ if (angleWrapped) {
+ return angleWrapped[1];
+ }
+
+ return trimmed.split(/\s+/)[0];
+}
+
+function slugify(heading) {
+ return heading
+ .replace(/<[^>]+>/g, "")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/[*_~]/g, "")
+ .replace(/&/g, "and")
+ .replace(/&/g, "and")
+ .normalize("NFKD")
+ .replace(/[̀-ͯ]/g, "")
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-");
+}
+
+function collectAnchors(filePath) {
+ if (anchorCache.has(filePath)) {
+ return anchorCache.get(filePath);
+ }
+
+ const source = stripCodeFences(fs.readFileSync(filePath, "utf8"));
+ const anchors = new Set();
+ const seen = new Map();
+ const headingPattern = /^#{1,6}\s+(.+?)\s*#*\s*$/gm;
+
+ for (const match of source.matchAll(headingPattern)) {
+ const baseSlug = slugify(match[1]);
+
+ if (!baseSlug) {
+ continue;
+ }
+
+ const seenCount = seen.get(baseSlug) ?? 0;
+ const slug = seenCount === 0 ? baseSlug : `${baseSlug}-${seenCount}`;
+ seen.set(baseSlug, seenCount + 1);
+ anchors.add(slug);
+ }
+
+ anchorCache.set(filePath, anchors);
+ return anchors;
+}
+
+function resolveFile(targetPath, sourceFile) {
+ const base = targetPath.startsWith("/")
+ ? path.join(root, targetPath.slice(1))
+ : path.resolve(path.dirname(sourceFile), targetPath);
+
+ if (exists(base)) {
+ return base;
+ }
+
+ if (!path.extname(base)) {
+ for (const extension of [".mdx", ".md"]) {
+ const candidate = `${base}${extension}`;
+ if (exists(candidate)) {
+ return candidate;
+ }
+ }
+ }
+
+ return base;
+}
+
+function isInsideRoot(filePath) {
+ const relativePath = path.relative(root, filePath);
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
+}
+
+function checkAssetReference(target, context) {
+ if (!target || !target.startsWith("/")) {
+ return;
+ }
+
+ const { targetPath } = splitTarget(target);
+ const filePath = path.join(root, targetPath.slice(1));
+
+ if (!exists(filePath)) {
+ addError(`${context} references missing asset ${target}`);
+ }
+}
+
+function checkPageSlug(slug, context) {
+ for (const extension of [".mdx", ".md"]) {
+ const filePath = path.join(root, `${slug}${extension}`);
+
+ if (exists(filePath)) {
+ linkedFileCache.add(filePath);
+ navPageCache.add(filePath);
+ return;
+ }
+ }
+
+ addError(`${context} references missing page ${slug}.mdx`);
+}
+
+function checkOpenApiReference(openapiPath, context) {
+ const filePath = path.join(root, openapiPath);
+
+ if (!exists(filePath)) {
+ addError(`${context} references missing OpenAPI file ${openapiPath}`);
+ return;
+ }
+
+ const source = fs.readFileSync(filePath, "utf8");
+ if (!/^openapi:\s*3\./m.test(source)) {
+ addError(`${openapiPath} does not look like an OpenAPI 3.x spec`);
+ }
+ if (!/^paths:\s*$/m.test(source)) {
+ addError(`${openapiPath} is missing a top-level paths section`);
+ }
+}
+
+function checkNavigationGroups(groups, prefix) {
+ for (const [groupIndex, group] of (groups ?? []).entries()) {
+ const groupLabel = group.group ?? `group ${groupIndex + 1}`;
+ const context = `docs.json navigation ${prefix}${groupLabel}`;
+
+ if (typeof group.root === "string") {
+ checkPageSlug(group.root, context);
+ }
+
+ for (const page of group.pages ?? []) {
+ if (typeof page === "string") {
+ checkPageSlug(page, context);
+ }
+ }
+
+ if (group.openapi) {
+ checkOpenApiReference(group.openapi, context);
+ }
+ }
+}
+
+function checkDocsJson() {
+ const docsPath = path.join(root, "docs.json");
+ const docs = readJson(docsPath);
+
+ if (!docs) {
+ return;
+ }
+
+ checkAssetReference(docs.favicon, "docs.json favicon");
+ checkAssetReference(docs.logo?.light, "docs.json logo.light");
+ checkAssetReference(docs.logo?.dark, "docs.json logo.dark");
+
+ checkNavigationGroups(docs.navigation?.groups, "");
+
+ for (const [tabIndex, tab] of (docs.navigation?.tabs ?? []).entries()) {
+ const tabLabel = tab.tab ?? `tab ${tabIndex + 1}`;
+ checkNavigationGroups(tab.groups, `${tabLabel} > `);
+ }
+}
+
+function shouldIgnoreLink(target) {
+ return (
+ !target ||
+ target.startsWith("#") ||
+ isExternal(target) ||
+ target.startsWith("mailto:") ||
+ target.startsWith("tel:") ||
+ target.includes("{{") ||
+ target.includes("<")
+ );
+}
+
+function checkInternalLink(target, sourceFile, lineNumber) {
+ if (shouldIgnoreLink(target)) {
+ return;
+ }
+
+ const { targetPath, anchor } = splitTarget(target);
+ const targetFile = resolveFile(targetPath || relative(sourceFile), sourceFile);
+ const sourceLabel = `${relative(sourceFile)}:${lineNumber}`;
+
+ if (!isInsideRoot(targetFile)) {
+ addError(`${sourceLabel} points outside the docs root: ${target}`);
+ return;
+ }
+
+ if (!exists(targetFile)) {
+ addError(`${sourceLabel} points to missing file or page: ${target}`);
+ return;
+ }
+
+ linkedFileCache.add(targetFile);
+
+ if (anchor && [".md", ".mdx"].includes(path.extname(targetFile))) {
+ const anchors = collectAnchors(targetFile);
+ if (!anchors.has(anchor)) {
+ addError(`${sourceLabel} points to missing anchor #${anchor} in ${relative(targetFile)}`);
+ }
+ }
+}
+
+function lineNumberForIndex(source, index) {
+ return source.slice(0, index).split("\n").length;
+}
+
+/**
+ * Every .mdx page must open with a frontmatter block declaring exactly the keys
+ * CONTRIBUTING.md promises. Mintlify renders `title` as the H1 and `description` as the
+ * sidebar tooltip and SEO description, so a page missing either ships broken.
+ */
+function checkFrontmatter(filePath, source) {
+ if (path.extname(filePath) !== ".mdx") {
+ return;
+ }
+
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+
+ if (!match) {
+ addError(`${relative(filePath)} has no frontmatter block`);
+ return;
+ }
+
+ const keys = new Set();
+
+ for (const line of match[1].split("\n")) {
+ const keyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):/);
+ if (keyMatch) {
+ keys.add(keyMatch[1]);
+ }
+ }
+
+ for (const required of REQUIRED_FRONTMATTER_KEYS) {
+ if (!keys.has(required)) {
+ addError(`${relative(filePath)} frontmatter is missing "${required}"`);
+ }
+ }
+
+ for (const key of keys) {
+ if (!ALLOWED_FRONTMATTER_KEYS.has(key)) {
+ addWarning(`${relative(filePath)} frontmatter has unexpected key "${key}"`);
+ }
+ }
+}
+
+/**
+ * MDX parses anything in angle brackets as JSX. A lowercase tag that isn't valid HTML is
+ * either an unbackticked placeholder (``, ``) or committed tool-call debris
+ * (``) — both fail the Mintlify build with an unhelpful parse error, so catch
+ * them here where the message can name the file and line.
+ */
+function checkStrayJsx(filePath, searchable) {
+ // Only .mdx is parsed as MDX. Plain .md files here (README, CONTRIBUTING, SPEC) are
+ // rendered by GitHub, where angle brackets are harmless.
+ if (path.extname(filePath) !== ".mdx") {
+ return;
+ }
+
+ const withoutInlineCode = stripInlineCode(searchable);
+ const tagPattern = /<\/?([A-Za-z][A-Za-z0-9._-]*)(\s[^<>]*)?\/?>/g;
+
+ for (const match of withoutInlineCode.matchAll(tagPattern)) {
+ const tag = match[1];
+
+ // Capitalised tags are Mintlify/React components — assume they are intentional.
+ if (tag[0] === tag[0].toUpperCase() && tag[0] !== tag[0].toLowerCase()) {
+ continue;
+ }
+
+ if (KNOWN_HTML_TAGS.has(tag.toLowerCase())) {
+ continue;
+ }
+
+ const line = lineNumberForIndex(withoutInlineCode, match.index ?? 0);
+ addError(
+ `${relative(filePath)}:${line} has stray JSX "${match[0]}" — backtick the placeholder or delete the tag`,
+ );
+ }
+}
+
+function checkContentFile(filePath) {
+ const source = fs.readFileSync(filePath, "utf8");
+ const searchable = stripCodeFences(source);
+ // Inline code spans hold syntax examples (``, `
`), not links.
+ // stripInlineCode replaces them with equal-length whitespace, so line numbers hold.
+ const linkable = stripInlineCode(searchable);
+ const markdownLinkPattern = /!?\[[^\]]*?\]\(([^)]+)\)/g;
+ const jsxAttributePattern = /\b(?:href|src)=["']([^"']+)["']/g;
+
+ checkFrontmatter(filePath, source);
+ checkStrayJsx(filePath, searchable);
+
+ for (const match of linkable.matchAll(markdownLinkPattern)) {
+ const target = trimMarkdownTarget(match[1]);
+ checkInternalLink(target, filePath, lineNumberForIndex(linkable, match.index ?? 0));
+ }
+
+ for (const match of linkable.matchAll(jsxAttributePattern)) {
+ checkInternalLink(match[1], filePath, lineNumberForIndex(linkable, match.index ?? 0));
+ }
+}
+
+function walkFiles(directory, predicate, files = []) {
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ if ([".git", ".mintlify", "node_modules"].includes(entry.name)) {
+ continue;
+ }
+
+ const filePath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ walkFiles(filePath, predicate, files);
+ } else if (predicate(filePath)) {
+ files.push(filePath);
+ }
+ }
+
+ return files;
+}
+
+function checkHiddenPages() {
+ const contentPages = walkFiles(root, (filePath) => filePath.endsWith(".mdx"));
+
+ for (const page of contentPages) {
+ if (relative(page).startsWith("snippets/")) {
+ continue;
+ }
+
+ if (!navPageCache.has(page)) {
+ addWarning(`${relative(page)} is not listed in docs.json navigation`);
+ }
+ }
+}
+
+checkDocsJson();
+
+for (const filePath of walkFiles(root, (candidate) => [".md", ".mdx"].includes(path.extname(candidate)))) {
+ checkContentFile(filePath);
+}
+
+checkHiddenPages();
+
+for (const warning of warnings) {
+ console.warn(`warning: ${warning}`);
+}
+
+if (errors.length > 0) {
+ console.error("Docs check failed:");
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log(`Docs check passed (${linkedFileCache.size} linked files checked).`);
diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs
new file mode 100644
index 0000000..a480889
--- /dev/null
+++ b/scripts/sync-changelog.mjs
@@ -0,0 +1,227 @@
+#!/usr/bin/env node
+
+/**
+ * Regenerate changelog.mdx from the product repo's GitHub Releases.
+ *
+ * The release manifest is GitHub Releases on
+ * privacykey/mantis, the same feed /updating tells operators to watch: `cli-v*` tags
+ * publish CLI binaries and plain `v*` tags publish server releases. The tag prefix
+ * becomes a component label, the published date becomes the entry's date line, and the
+ * first prose paragraph of the release body becomes the summary.
+ *
+ * Usage:
+ * node scripts/sync-changelog.mjs # fetch from the GitHub API
+ * MANIFEST_URL=... node scripts/sync-changelog.mjs
+ * MANIFEST_FILE=releases.json node scripts/sync-changelog.mjs
+ *
+ * GITHUB_TOKEN (or GH_TOKEN), when set, is sent as a bearer token — the workflow passes
+ * its built-in token so runner-IP rate limits never bite. Unauthenticated works locally.
+ *
+ * Writes changelog.mdx and exits 0 whether or not anything changed. The workflow diffs
+ * the working tree afterwards and opens a PR only when the file actually moved.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const OUTPUT = path.join(root, "changelog.mdx");
+
+const MANIFEST_URL =
+ process.env.MANIFEST_URL ??
+ "https://api.github.com/repos/privacykey/mantis/releases?per_page=100";
+const MANIFEST_FILE = process.env.MANIFEST_FILE ?? "";
+const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
+
+const RELEASES_PAGE = "https://github.com/privacykey/mantis/releases";
+
+/** Tag prefix → the component label rendered in the entry heading. */
+const COMPONENT_LABELS = {
+ cli: "CLI",
+ edge: "Edge worker",
+ "iot-helper": "IoT helper",
+};
+
+function fail(message) {
+ console.error(`sync-changelog: ${message}`);
+ process.exit(1);
+}
+
+async function loadManifest() {
+ if (MANIFEST_FILE) {
+ const filePath = path.resolve(root, MANIFEST_FILE);
+ if (!fs.existsSync(filePath)) {
+ fail(`MANIFEST_FILE not found: ${filePath}`);
+ }
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+ }
+
+ const headers = { accept: "application/vnd.github+json" };
+ if (TOKEN) {
+ headers.authorization = `Bearer ${TOKEN}`;
+ }
+
+ const response = await fetch(MANIFEST_URL, { headers });
+
+ if (!response.ok) {
+ fail(`could not fetch ${MANIFEST_URL} (HTTP ${response.status})`);
+ }
+
+ return response.json();
+}
+
+/**
+ * `cli-v0.1.6` → { component: "CLI", version: "0.1.6" }; `v0.2.0` → Server.
+ * Unrecognised prefixes pass through with their first letter capitalised.
+ */
+function parseTag(tagName) {
+ const tag = String(tagName ?? "");
+ const prefixed = tag.match(/^([a-z][a-z0-9-]*)-v(.+)$/);
+
+ if (prefixed) {
+ const [, prefix, version] = prefixed;
+ const component =
+ COMPONENT_LABELS[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1);
+ return { component, version };
+ }
+
+ const plain = tag.match(/^v(.+)$/);
+ if (plain) {
+ return { component: "Server", version: plain[1] };
+ }
+
+ return { component: "", version: tag };
+}
+
+/** Newest first, by published date; created date and tag break ties. */
+function comparePublishedDesc(a, b) {
+ const left = Date.parse(a.published_at ?? a.created_at ?? "") || 0;
+ const right = Date.parse(b.published_at ?? b.created_at ?? "") || 0;
+
+ if (left !== right) {
+ return right - left;
+ }
+
+ return String(b.tag_name ?? "").localeCompare(String(a.tag_name ?? ""));
+}
+
+function formatDate(value) {
+ if (!value) {
+ return "";
+ }
+
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return String(value);
+ }
+
+ return date.toLocaleDateString("en-AU", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+}
+
+/**
+ * First prose paragraph of the release body — skip headings, code fences, lists,
+ * tables and block HTML, and collapse the paragraph's line wraps to spaces.
+ */
+function summarise(body) {
+ const blocks = String(body ?? "")
+ .replace(/\r\n/g, "\n")
+ .split(/\n{2,}/);
+
+ for (const block of blocks) {
+ const trimmed = block.trim();
+
+ if (!trimmed || /^(#|`{3}|~{3}|<|[-*+] |\d+\. |\|)/.test(trimmed)) {
+ continue;
+ }
+
+ return trimmed.replace(/\s*\n\s*/g, " ");
+ }
+
+ return "";
+}
+
+function renderRelease(release) {
+ const { component, version } = parseTag(release.tag_name);
+ const heading = component ? `## ${component} v${version}` : `## ${version}`;
+ const lines = [heading, ""];
+
+ const meta = [
+ formatDate(release.published_at ?? release.created_at),
+ release.prerelease ? "`Pre-release`" : "",
+ ].filter(Boolean);
+ if (meta.length > 0) {
+ lines.push(meta.join(" · "), "");
+ }
+
+ const summary = summarise(release.body);
+ if (summary) {
+ lines.push(summary, "");
+ }
+
+ if (release.html_url) {
+ lines.push(`[Full release notes →](${release.html_url})`, "");
+ }
+
+ return lines.join("\n");
+}
+
+function renderPage(releases) {
+ const header = [
+ "---",
+ "title: Changelog",
+ 'description: "Published Mantis releases — CLI and server — what changed and where the full notes live."',
+ "---",
+ "",
+ "{/* GENERATED FILE — do not edit by hand. */}",
+ "{/* Regenerate with `npm run sync-changelog`; the source is GitHub Releases on privacykey/mantis. */}",
+ "",
+ "",
+ ` This page mirrors [GitHub Releases](${RELEASES_PAGE}) — \`cli-v*\` tags publish CLI`,
+ " binaries, plain `v*` tags publish server releases. After updating, run `mantis doctor`",
+ " to confirm CLI/server compatibility, and see [Updating](/updating) for the per-component",
+ " update commands.",
+ "",
+ "",
+ ];
+
+ if (releases.length === 0) {
+ header.push(
+ "No releases have been published yet.",
+ "",
+ `Releases will appear here and at [GitHub Releases](${RELEASES_PAGE}).`,
+ "",
+ );
+
+ return header.join("\n");
+ }
+
+ const body = releases.map(renderRelease).join("\n");
+ return `${header.join("\n")}${body}`;
+}
+
+const manifest = await loadManifest();
+
+if (!Array.isArray(manifest)) {
+ fail("manifest is not an array of releases");
+}
+
+const releases = manifest
+ .filter((release) => release && release.tag_name && !release.draft)
+ .sort(comparePublishedDesc);
+
+const page = `${renderPage(releases).trimEnd()}\n`;
+const previous = fs.existsSync(OUTPUT) ? fs.readFileSync(OUTPUT, "utf8") : "";
+
+fs.writeFileSync(OUTPUT, page, "utf8");
+
+if (previous === page) {
+ console.log(`changelog.mdx is already up to date (${releases.length} release(s)).`);
+} else {
+ console.log(`changelog.mdx regenerated from ${releases.length} release(s).`);
+}