diff --git a/CHANGELOG.md b/CHANGELOG.md index 068522e9..f372e9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Auto-parsed /releases page**: `site/lib/parseChangelog.ts` now extracts release metadata from CHANGELOG.md at build time. The releases page dynamically renders all versions, sections, and summaries - no manual duplication required. Sitemap includes /releases with 0.8 priority. - **NuGet downloads badge**: Added to README linking to the GauntletCI NuGet package page. - **Self-contained binary releases**: `release.yml` now publishes self-contained single-file executables for win-x64, win-arm64, osx-x64, osx-arm64, linux-x64, and linux-arm64, plus a `checksums.txt`, as GitHub release assets alongside the NuGet package. - **Homebrew tap**: Created `EricCogen/homebrew-gauntletci` tap. Install with `brew tap EricCogen/gauntletci && brew install gauntletci`. SHA256 values auto-updated after each release via `update-homebrew-tap.yml`. diff --git a/HISTORY.md b/HISTORY.md index 7ef8613f..648777b8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -31,6 +31,8 @@ static export). Work in this phase: - Pagefind full-text search (client-side, zero backend, build-time indexed) - Playwright e2e test suite including a link-graph test that enforces every page has at least one inbound and one outbound content link +- Releases page (`/releases`) auto-parses CHANGELOG.md at build time, eliminating + manual duplication of version metadata --- diff --git a/site/app/releases/page.tsx b/site/app/releases/page.tsx index 80907726..3cb6967a 100644 --- a/site/app/releases/page.tsx +++ b/site/app/releases/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; +import { parseChangelog } from "@/lib/parseChangelog"; export const metadata: Metadata = { title: "Releases | GauntletCI", @@ -10,181 +11,7 @@ export const metadata: Metadata = { alternates: { canonical: "/releases" }, }; -type Entry = { text: string; link?: string }; -type Section = { label: "Added" | "Changed" | "Fixed"; entries: Entry[] }; -type Release = { - version: string; - date: string; - tag: string; - summary: string; - sections: Section[]; - compareUrl?: string; -}; - -const releases: Release[] = [ - { - version: "2.1.1", - date: "2026-04-28", - tag: "latest", - summary: "NuGet README improvements and packaging cleanup.", - compareUrl: "https://github.com/EricCogen/GauntletCI/compare/v2.0.4...HEAD", - sections: [ - { - label: "Changed", - entries: [ - { - text: "NuGet package now uses a dedicated nuget-readme.md with plain markdown and absolute image URLs for correct rendering on NuGet.org.", - }, - { - text: "Resized NuGet logo (200x262) added as GauntletCI-nuget.png.", - }, - { - text: "30 precision-hardened detection rules: per-rule corpus validation against 618 real .NET OSS pull requests. Rules GCI0003-GCI0047 updated with tighter guards and labeler alignment.", - }, - { - text: "action.yml marketplace readiness: input defaults corrected, gauntletci-version bumped to 2.1.0.", - }, - ], - }, - { - label: "Added", - entries: [ - { - text: "GitLab CI and Bitbucket Pipelines integration snippets in the docs.", - link: "/docs/integrations", - }, - { - text: "Per-rule detail pages at /docs/rules with real-world case study links.", - link: "/docs/rules", - }, - ], - }, - ], - }, - { - version: "2.0.4", - date: "2026-04-25", - tag: "", - summary: "Site infrastructure: search, rule detail pages, E2E tests, and author attribution.", - compareUrl: "https://github.com/EricCogen/GauntletCI/compare/v2.0.3...v2.0.4", - sections: [ - { - label: "Added", - entries: [ - { text: "Full-text search with Cmd/Ctrl+K shortcut across all 53 pages." }, - { text: "30 per-rule detail pages at /docs/rules/[ruleId].", link: "/docs/rules" }, - { text: "/about page with founder bio and E-E-A-T author attribution.", link: "/about" }, - { text: "Playwright E2E test suite with smoke, article, rule detail, and link-graph tests." }, - { text: "JSON-LD schemas on all docs and rule detail pages." }, - ], - }, - { - label: "Changed", - entries: [ - { text: "Header nav simplified to 3 items: Product dropdown, Docs, About." }, - ], - }, - { - label: "Fixed", - entries: [ - { text: "/docs pages had zero outbound content links - link-graph test now catches this." }, - { text: "/pricing had no inbound content links from any other page." }, - ], - }, - ], - }, - { - version: "2.0.3", - date: "2026-04-24", - tag: "", - summary: "Rich PR review summaries with Why, Action, and Evidence sections.", - compareUrl: "https://github.com/EricCogen/GauntletCI/compare/v2.0.2...v2.0.3", - sections: [ - { - label: "Added", - entries: [ - { - text: "GitHub PR review comments now include Why, Action, and Evidence sections in collapsible details blocks.", - }, - { - text: "--with-llm enrichment attaches plain-English explanations to high-confidence findings.", - }, - ], - }, - ], - }, - { - version: "2.0.2", - date: "2026-04-24", - tag: "", - summary: "Finding grouping and structured GitHub Actions output.", - compareUrl: "https://github.com/EricCogen/GauntletCI/compare/v2.0.1...v2.0.2", - sections: [ - { - label: "Added", - entries: [ - { - text: "Duplicate findings across multiple files are collapsed into a single annotated entry.", - }, - { - text: "Structured Markdown output for GitHub Actions annotations and Checks summaries.", - }, - ], - }, - ], - }, - { - version: "2.0.1", - date: "2026-04-24", - tag: "", - summary: "Demo links and footer fixes.", - compareUrl: "https://github.com/EricCogen/GauntletCI/compare/v2.0.0...v2.0.1", - sections: [ - { - label: "Added", - entries: [ - { - text: "GauntletCI-Demo links in header, footer, and README.", - link: "https://github.com/EricCogen/GauntletCI-Demo", - }, - { text: "Footer added to all /docs pages." }, - ], - }, - { - label: "Fixed", - entries: [{ text: "Footer anchor links now use / prefix for correct cross-page navigation." }], - }, - ], - }, - { - version: "2.0.0", - date: "2026-04-14", - tag: "initial", - summary: "Initial public release. 30 detection rules, local LLM support, MCP server, GitHub Actions integration.", - compareUrl: "https://github.com/EricCogen/GauntletCI/releases/tag/v2.0.0", - sections: [ - { - label: "Added", - entries: [ - { text: "30 built-in deterministic detection rules (GCI0001-GCI0050)." }, - { text: "Local LLM enrichment via Ollama - no data leaves your machine." }, - { text: "MCP server for AI assistant integration." }, - { text: "GitHub Actions integration with inline PR comments and Checks annotations." }, - { text: "Baseline mode: suppress pre-existing findings, surface only new risks." }, - { text: "NuGet packaging and publish workflow." }, - ], - }, - { - label: "Fixed", - entries: [ - { - text: "Culture-invariant percent formatting in MarkdownReportExporter (non-en-US locales).", - }, - ], - }, - ], - }, -]; +const releases = parseChangelog(); const sectionColors: Record = { Added: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10", diff --git a/site/app/sitemap.ts b/site/app/sitemap.ts index c1844cb2..231da19c 100644 --- a/site/app/sitemap.ts +++ b/site/app/sitemap.ts @@ -24,6 +24,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${BASE_URL}/docs/custom-rules`, changeFrequency: "monthly", priority: 0.7 }, { url: `${BASE_URL}/detections`, changeFrequency: "monthly", priority: 0.9 }, { url: `${BASE_URL}/pricing`, changeFrequency: "monthly", priority: 0.8 }, + { url: `${BASE_URL}/releases`, changeFrequency: "monthly", priority: 0.8 }, { url: `${BASE_URL}/about`, changeFrequency: "monthly", priority: 0.7 }, { url: `${BASE_URL}/why-tests-miss-bugs`, changeFrequency: "monthly", priority: 0.8 }, { url: `${BASE_URL}/why-code-review-misses-bugs`, changeFrequency: "monthly", priority: 0.8 }, diff --git a/site/lib/parseChangelog.ts b/site/lib/parseChangelog.ts new file mode 100644 index 00000000..6c53247d --- /dev/null +++ b/site/lib/parseChangelog.ts @@ -0,0 +1,159 @@ +import fs from "fs"; +import path from "path"; + +export type Section = "Added" | "Changed" | "Fixed" | "Deprecated" | "Removed" | "Security"; + +export interface ChangeEntry { + text: string; + link?: string; +} + +export interface ReleaseSection { + label: Section; + entries: ChangeEntry[]; +} + +export interface Release { + version: string; + date: string; + tag: string; + summary: string; + sections: ReleaseSection[]; + compareUrl?: string; +} + +const VALID_SECTIONS: Set = new Set(["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"]); + +export function parseChangelog(): Release[] { + // Try multiple paths to find CHANGELOG.md + const possiblePaths = [ + path.join(process.cwd(), "..", "..", "CHANGELOG.md"), + path.join(process.cwd(), "../../CHANGELOG.md"), + path.join(process.cwd(), "../CHANGELOG.md"), + path.join(process.cwd(), "CHANGELOG.md"), + ]; + + let content: string | null = null; + let foundPath = ""; + + for (const filePath of possiblePaths) { + try { + if (fs.existsSync(filePath)) { + content = fs.readFileSync(filePath, "utf-8"); + foundPath = filePath; + break; + } + } catch { + // Continue to next path + } + } + + if (!content) { + console.warn("CHANGELOG.md not found at any expected path"); + return []; + } + + const releases: Release[] = []; + const lines = content.split("\n"); + + let currentVersion = ""; + let currentDate = ""; + let currentSectionLabel: Section | null = null; + let currentSectionEntries: ChangeEntry[] = []; + let allSections: ReleaseSection[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Match version header: ## [1.2.3] - YYYY-MM-DD or ## [Unreleased] + const versionMatch = trimmed.match(/^##\s+\[([^\]]+)\](?:\s+-\s+(\d{4}-\d{2}-\d{2}))?$/); + if (versionMatch) { + // Save previous release if any + if (currentVersion && allSections.length > 0) { + releases.push({ + version: currentVersion, + date: currentDate, + tag: currentVersion === "Unreleased" ? "unreleased" : releases.length === 0 ? "latest" : "", + summary: generateSummary(allSections), + sections: allSections, + compareUrl: generateCompareUrl(currentVersion), + }); + } + + currentVersion = versionMatch[1]; + currentDate = versionMatch[2] || ""; + allSections = []; + currentSectionLabel = null; + currentSectionEntries = []; + continue; + } + + // Match section header: ### Added, ### Changed, etc. + const sectionMatch = trimmed.match(/^###\s+(\w+)$/); + if (sectionMatch && VALID_SECTIONS.has(sectionMatch[1])) { + // Save previous section if any + if (currentSectionLabel && currentSectionEntries.length > 0) { + allSections.push({ + label: currentSectionLabel, + entries: currentSectionEntries, + }); + } + + currentSectionLabel = sectionMatch[1] as Section; + currentSectionEntries = []; + continue; + } + + // Match bullet point: - **Title**: Description or - Description + if (trimmed.startsWith("- ") && currentSectionLabel) { + const content = trimmed.substring(2).trim(); + const entry: ChangeEntry = { + text: formatEntryText(content), + }; + currentSectionEntries.push(entry); + continue; + } + } + + // Save final release + if (currentVersion && allSections.length > 0) { + releases.push({ + version: currentVersion, + date: currentDate, + tag: currentVersion === "Unreleased" ? "unreleased" : releases.length === 0 ? "latest" : "", + summary: generateSummary(allSections), + sections: allSections, + compareUrl: generateCompareUrl(currentVersion), + }); + } + + return releases.filter((r) => r.version !== "Unreleased"); +} + +function formatEntryText(content: string): string { + // Remove markdown bold markers but keep the text: **Title** -> Title: + return content.replace(/\*\*([^*]+)\*\*:\s*/, "$1: "); +} + +function generateSummary(sections: ReleaseSection[]): string { + // Create a brief summary from the first entry of the first section + if (sections.length === 0) return "Release"; + + const firstEntry = sections[0].entries[0]; + if (!firstEntry) return "Release"; + + let text = firstEntry.text; + if (text.length > 100) { + text = text.substring(0, 97) + "..."; + } + return text; +} + +function generateCompareUrl(version: string): string { + if (version === "Unreleased") return ""; + + // Find the next version to create a comparison URL + // For now, use the tag-based comparison + return `https://github.com/EricCogen/GauntletCI/releases/tag/v${version}`; +}