From 365d679754a52e2b1e32ee95c426c3e08c97ef05 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:37:17 +1000 Subject: [PATCH 1/4] ci: add link check and changelog sync, harden Mintlify workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring docs-mantis up to parity with docs-privacysentinel (the surviving canonical copy — docs-privacytracker is an empty repo): - linkcheck.yml: npm run check (nav/internal links/anchors/frontmatter) plus cached lychee external-link check on PR/push/weekly schedule, with an auto-filed issue when the scheduled run finds breakage. - sync-changelog.yml: daily cron regenerates changelog.mdx and opens a PR via peter-evans/create-pull-request when it moved. - scripts/check-docs.mjs: ported from the sibling; nav pages may be .md or .mdx, navigation.groups (this repo's shape) is walked in addition to tabs[].groups[], and inline code spans are excluded from link scanning so syntax examples in tables don't false-positive. - scripts/sync-changelog.mjs: mantis has no releases.json — its release manifest is GitHub Releases (cli-v* = CLI, v* = server), so the script reads the Releases API with the workflow's built-in token, keeping the sibling's MANIFEST_URL/MANIFEST_FILE overrides. - changelog.mdx: initial generated page, added to docs.json nav so the nav check passes from the first run. - mintlify.yml hygiene: top-level contents: read, concurrency with cancel-in-progress, timeout-minutes, persist-credentials: false. Co-Authored-By: Claude Fable 5 --- .github/workflows/linkcheck.yml | 71 +++++ .github/workflows/mintlify.yml | 10 + .github/workflows/sync-changelog.yml | 52 ++++ changelog.mdx | 61 ++++ docs.json | 3 +- package.json | 4 +- scripts/check-docs.mjs | 441 +++++++++++++++++++++++++++ scripts/sync-changelog.mjs | 227 ++++++++++++++ 8 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/linkcheck.yml create mode 100644 .github/workflows/sync-changelog.yml create mode 100644 changelog.mdx create mode 100644 scripts/check-docs.mjs create mode 100644 scripts/sync-changelog.mjs diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml new file mode 100644 index 0000000..d19ecff --- /dev/null +++ b/.github/workflows/linkcheck.yml @@ -0,0 +1,71 @@ +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- + + - name: Check external links + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --cache + --max-cache-age 1d + --no-progress + --base . + --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 (`![](URL)`, ``), 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..a2d8b54 --- /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. + * + * Mantis has no releases.json — its 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).`); +} From 72c3a887542e0507aba28785f52e43cf9a2f376c Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:40:19 +1000 Subject: [PATCH 2/4] ci: scope lychee to external links; current lychee rejects --base . MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sibling's canonical args pass --base ., which lychee now rejects (base must be a full URL or an absolute path). Internal links, anchors and nav are already covered by npm run check, so restrict lychee to http/https schemes — external links only, as the step name says. Co-Authored-By: Claude Fable 5 --- .github/workflows/linkcheck.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index d19ecff..87e2235 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -46,6 +46,10 @@ jobs: 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. The sibling repos pass `--base .`, which + # current lychee releases reject (base must be a URL or absolute path). - name: Check external links uses: lycheeverse/lychee-action@v2 with: @@ -53,7 +57,8 @@ jobs: --cache --max-cache-age 1d --no-progress - --base . + --scheme https + --scheme http --exclude-path images --exclude-path .github --accept 200,206,429 From ad4d34ac2e3157e24203e420e4aeb12c5a37d1dc Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:46:09 +1000 Subject: [PATCH 3/4] ci: give lychee a --root-dir so root-relative links resolve The scheme filter excludes local files from checking, but lychee still resolves root-relative links first and errors without a root dir. Verified locally with lychee 0.24.2: 34 external links OK, 0 errors. Co-Authored-By: Claude Fable 5 --- .github/workflows/linkcheck.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index 87e2235..c005e9c 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -48,8 +48,10 @@ jobs: # 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. The sibling repos pass `--base .`, which - # current lychee releases reject (base must be a URL or absolute path). + # /updating are not resolvable as files. --root-dir keeps lychee from erroring + # while resolving root-relative links before the scheme filter excludes them. + # The sibling repos pass `--base .`, which current lychee releases reject + # (base must be a URL or absolute path). - name: Check external links uses: lycheeverse/lychee-action@v2 with: @@ -59,6 +61,7 @@ jobs: --no-progress --scheme https --scheme http + --root-dir ${{ github.workspace }} --exclude-path images --exclude-path .github --accept 200,206,429 From bb3e0ae10fe0468433c567fd3e4bd155f5dee947 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:56:08 +1000 Subject: [PATCH 4/4] chore: strip internal authoring commentary from comments Co-Authored-By: Claude Fable 5 --- .github/workflows/linkcheck.yml | 2 +- scripts/sync-changelog.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linkcheck.yml b/.github/workflows/linkcheck.yml index c005e9c..4ad44a9 100644 --- a/.github/workflows/linkcheck.yml +++ b/.github/workflows/linkcheck.yml @@ -50,7 +50,7 @@ jobs: # 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. - # The sibling repos pass `--base .`, which current lychee releases reject + # 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 diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs index a2d8b54..a480889 100644 --- a/scripts/sync-changelog.mjs +++ b/scripts/sync-changelog.mjs @@ -3,7 +3,7 @@ /** * Regenerate changelog.mdx from the product repo's GitHub Releases. * - * Mantis has no releases.json — its release manifest IS GitHub Releases on + * 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