-
-
+
-
)
- const timer = window.setTimeout(
- () =>
- announce(`${resultCount} result${resultCount === 1 ? "" : "s"} found`),
- 300
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Changelog
+
+
+ Everything that shipped, newest first.
+
+
+
+
+
+
+
+ )
+ }
+
+ if (!data || data.releases.length === 0) {
+ return (
+
+
+
+
+ Changelog
+
+
+ Everything that shipped, newest first.
+
+
+
+
+
+
+
)
- return () => window.clearTimeout(timer)
- }, [announce, changelog.isSuccess, filteredReleases])
+ }
const showLoadOlder = Boolean(data.hasArchive) && archiveStatus !== "loaded"
diff --git a/apps/web/src/features/changelog/components/ReleaseSection.tsx b/apps/web/src/features/changelog/components/ReleaseSection.tsx
index 3b68d538..d710dd25 100644
--- a/apps/web/src/features/changelog/components/ReleaseSection.tsx
+++ b/apps/web/src/features/changelog/components/ReleaseSection.tsx
@@ -19,12 +19,23 @@ export function ReleaseSection({
isFiltered,
highlight,
}: ReleaseSectionProps) {
- const anchorId = versionToAnchorId(release.version)
- const origin =
- typeof window === "undefined"
- ? "https://so4.market"
- : window.location.origin
- const permalink = releasePermalink(release.version, origin)
+ const [copied, setCopied] = useState(false)
+ const anchor = createAnchor(release.version)
+
+ const handleCopyPermalink = async () => {
+ const origin =
+ typeof window === "undefined"
+ ? "https://so4.market"
+ : window.location.origin
+ const permalink = `${origin}/changelog${anchor}`
+ try {
+ await navigator.clipboard.writeText(permalink)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ // ignore
+ }
+ }
return (
// Yanked releases keep their permanent anchor but render muted — the
diff --git a/apps/web/src/features/changelog/types.ts b/apps/web/src/features/changelog/types.ts
index 2b2a2db6..9aa3645e 100644
--- a/apps/web/src/features/changelog/types.ts
+++ b/apps/web/src/features/changelog/types.ts
@@ -1,15 +1,41 @@
-export type ChangelogEntryType = "added" | "changed" | "deprecated" | "removed" | "fixed" | "security"
-export type ChangelogArea =
- | "trade"
- | "pools"
- | "earn"
- | "referrals"
- | "faucet"
- | "wallet"
- | "docs"
- | "general"
- | "ci"
- | "internal"
+export const CHANGELOG_ENTRY_TYPES = [
+ "added",
+ "changed",
+ "deprecated",
+ "removed",
+ "fixed",
+ "security",
+] as const
+
+export const CHANGELOG_AREAS = [
+ "trade",
+ "pools",
+ "earn",
+ "referrals",
+ "faucet",
+ "wallet",
+ "docs",
+ "general",
+ "ci",
+ "internal",
+] as const
+
+export type ChangelogEntryType = (typeof CHANGELOG_ENTRY_TYPES)[number]
+export type ChangelogArea = (typeof CHANGELOG_AREAS)[number]
+
+export function isChangelogEntryType(val: unknown): val is ChangelogEntryType {
+ return (
+ typeof val === "string" &&
+ (CHANGELOG_ENTRY_TYPES as ReadonlyArray
).includes(val)
+ )
+}
+
+export function isChangelogArea(val: unknown): val is ChangelogArea {
+ return (
+ typeof val === "string" &&
+ (CHANGELOG_AREAS as ReadonlyArray).includes(val)
+ )
+}
export interface ChangelogEntry {
type: ChangelogEntryType
diff --git a/apps/web/src/features/changelog/utils.ts b/apps/web/src/features/changelog/utils.ts
index 3c49dbe9..6f56555b 100644
--- a/apps/web/src/features/changelog/utils.ts
+++ b/apps/web/src/features/changelog/utils.ts
@@ -1,27 +1,10 @@
+import { CHANGELOG_AREAS, CHANGELOG_ENTRY_TYPES, isChangelogEntryType } from "./types"
import type { ChangelogArea, ChangelogEntryType } from "./types"
+import type { StatusVariant } from "@workspace/ui/components/status-badge"
import { formatDate as formatDateShared } from "@/shared/lib/format"
-export const CHANGELOG_TYPES: Array = [
- "added",
- "changed",
- "deprecated",
- "removed",
- "fixed",
- "security",
-]
-
-export const CHANGELOG_AREAS: Array = [
- "trade",
- "pools",
- "earn",
- "referrals",
- "faucet",
- "wallet",
- "docs",
- "general",
- "ci",
- "internal",
-]
+export const CHANGELOG_TYPES = CHANGELOG_ENTRY_TYPES
+export { CHANGELOG_AREAS }
/** Areas hidden behind the "show internal changes" toggle by default (DX-010). */
export const INTERNAL_AREAS: Array = ["ci", "internal"]
@@ -31,8 +14,8 @@ export function publicAreas(): Array {
return CHANGELOG_AREAS.filter((area) => !INTERNAL_AREAS.includes(area))
}
-export function typeToVariant(type: ChangelogEntryType) {
- const map: Record = {
+export function typeToVariant(type: string): StatusVariant {
+ const map: Record = {
added: "success",
changed: "info",
fixed: "info-subtle",
diff --git a/apps/web/src/routes/a11y.test.tsx b/apps/web/src/routes/a11y.test.tsx
index 4ebd124c..91293585 100644
--- a/apps/web/src/routes/a11y.test.tsx
+++ b/apps/web/src/routes/a11y.test.tsx
@@ -1,9 +1,10 @@
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";
import { RouterProvider, createMemoryHistory, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HttpResponse, http } from "msw";
-import { setupServer } from "msw/node";
+import { server } from "../../test/msw/server";
import { FaucetPage } from "../features/faucet/components/faucet-page";
import { TradePage } from "../features/trade/components/TradePage";
import { ReferralsPage } from "../features/referrals/components/referrals-page";
diff --git a/apps/web/src/routes/changelog.tsx b/apps/web/src/routes/changelog.tsx
index 7d6d9fc8..d39be17d 100644
--- a/apps/web/src/routes/changelog.tsx
+++ b/apps/web/src/routes/changelog.tsx
@@ -6,15 +6,3 @@ export const Route = createFileRoute("/changelog")({
component: ChangelogPage,
validateSearch: validateChangelogSearch,
})
-
-function ChangelogRoute() {
- const search = Route.useSearch()
- const navigate = Route.useNavigate()
-
- return (
- void navigate({ search: nextSearch })}
- />
- )
-}
diff --git a/apps/web/src/ui/Navbar.tsx b/apps/web/src/ui/Navbar.tsx
index 4488d9cc..e90cba8a 100644
--- a/apps/web/src/ui/Navbar.tsx
+++ b/apps/web/src/ui/Navbar.tsx
@@ -14,6 +14,8 @@ import {
} from "./nav/primitives"
import { ConnectButton } from "@/features/wallet/components/ConnectButton"
import { WhatsNew } from "@/features/changelog/components/WhatsNew"
+import { WhatsNewDot } from "@/features/changelog/components/WhatsNewDot"
+import { useWhatsNewIndicator } from "@/features/changelog/whats-new"
const LANDING_LINKS = [
{ label: "Trade", href: "/trade" },
@@ -25,7 +27,8 @@ const LANDING_LINKS = [
const APP_LINKS: Array<{
label: string
- to: "/trade" | "/pools" | "/earn" | "/referrals" | "/faucet" | null
+ to: "/trade" | "/pools" | "/earn" | "/referrals" | "/faucet" | "/changelog" | null
+ whatsNew?: boolean
}> = [
{ label: "Trade", to: "/trade" },
{ label: "Pools", to: "/pools" },
diff --git a/apps/web/test/msw/handlers.ts b/apps/web/test/msw/handlers.ts
index 0de294fe..0f94166e 100644
--- a/apps/web/test/msw/handlers.ts
+++ b/apps/web/test/msw/handlers.ts
@@ -15,8 +15,6 @@ const simulateTransactionSuccess = {
}
export const handlers = [
- http.get("/changelog.json", () => HttpResponse.json(changelog)),
-
http.post("https://soroban-testnet.stellar.org", async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as RpcBody
diff --git a/bun.lock b/bun.lock
index d6889f66..ddf38980 100644
--- a/bun.lock
+++ b/bun.lock
@@ -33,14 +33,19 @@
"@shikijs/rehype": "^4.4.3",
"@shikijs/transformers": "^4.4.3",
"@tailwindcss/vite": "^4.1.18",
+ "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/bun": "^1.3.0",
"@types/mdx": "^2.0.14",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
+ "@vitest/coverage-v8": "^3.2.0",
+ "@vitest/expect": "^3.2.7",
"h3": "^2.0.1-rc.29",
"happy-dom": "^20.11.6",
"jsdom": "^30.0.1",
+ "msw": "^2.12.12",
"nitro": "^3.0.260610-beta",
"pagefind": "^1.5.2",
"prettier": "^3.8.1",
@@ -51,6 +56,9 @@
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.2",
+ "vite-tsconfig-paths": "^5.1.4",
+ "vitest": "3",
+ "vitest-axe": "^0.1.0",
},
},
"apps/s03-indexer": {
@@ -1523,7 +1531,7 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
+ "ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="],
"any-signal": ["any-signal@2.1.2", "", { "dependencies": { "abort-controller": "^3.0.0", "native-abort-controller": "^1.0.3" } }, "sha512-B+rDnWasMi/eWcajPcCWSlYc7muXOrcYrqgyzcdKisl2H/WTlQ0gip1KyQfr0ZlxJdsuWCj/LWwQm7fhyhRfIQ=="],
@@ -3679,8 +3687,6 @@
"@near-wallet-selector/core/rxjs": ["rxjs@7.8.1", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg=="],
- "@oclif/core/ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="],
-
"@oclif/core/ejs": ["ejs@6.0.1", "", { "bin": { "ejs": "bin/cli.js" } }, "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA=="],
"@oclif/core/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
diff --git a/package.json b/package.json
index d7d3ec0f..b15147ca 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,8 @@
"check:errors:generated": "bun run scripts/generate-errors-reference.ts --check",
"changelog:validate": "bun run scripts/changelog/validate.ts",
"changelog:release": "bun run scripts/changelog/release.ts",
- "changelog:build": "bun run scripts/changelog/build.ts"
+ "changelog:build": "bun run scripts/changelog/build.ts",
+ "changelog:verify-tag": "bun run scripts/changelog/verify-tag.ts"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8b248689..ce8a144d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -10,7 +10,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
- "test:coverage": "vitest --coverage"
+ "test:coverage": "vitest run --coverage"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
diff --git a/scripts/changelog/verify-tag.test.ts b/scripts/changelog/verify-tag.test.ts
new file mode 100644
index 00000000..c488f314
--- /dev/null
+++ b/scripts/changelog/verify-tag.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { verifyReleaseTag, isWithinOneDay, extractReleaseNotes } from "./verify-tag.ts"
+
+describe("DX-025: verifyReleaseTag", () => {
+ let tempDir: string
+ let changelogPath: string
+ let unreleasedDir: string
+
+ const sampleChangelog = `
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [Unreleased]
+
+## [0.2.0] - 2026-08-30
+
+### Added
+
+- Added support for new perpetual markets. ([#540](https://github.com/SO4-Markets/interface/pull/540))
+
+### Fixed
+
+- Fixed chart theme flickering on page load. ([#542](https://github.com/SO4-Markets/interface/pull/542))
+
+## [0.1.0] - 2026-08-20
+
+### Added
+
+- Initial testnet release. ([#1](https://github.com/SO4-Markets/interface/pull/1))
+
+[Unreleased]: https://github.com/SO4-Markets/interface/compare/v0.2.0...HEAD
+[0.2.0]: https://github.com/SO4-Markets/interface/compare/v0.1.0...v0.2.0
+[0.1.0]: https://github.com/SO4-Markets/interface/releases/tag/v0.1.0
+`
+
+ beforeEach(async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "changelog-verify-test-"))
+ changelogPath = join(tempDir, "CHANGELOG.md")
+ unreleasedDir = join(tempDir, ".changelog", "unreleased")
+ await mkdir(unreleasedDir, { recursive: true })
+ await writeFile(changelogPath, sampleChangelog, "utf-8")
+ // Keep README in unreleased directory
+ await writeFile(join(unreleasedDir, "README.md"), "# Unreleased changes\n", "utf-8")
+ })
+
+ afterEach(async () => {
+ await rm(tempDir, { recursive: true, force: true })
+ })
+
+ test("passes when tag matches current section in CHANGELOG and unreleased is empty", async () => {
+ const result = await verifyReleaseTag({
+ tag: "v0.2.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ })
+
+ expect(result.valid).toBe(true)
+ expect(result.version).toBe("0.2.0")
+ expect(result.date).toBe("2026-08-30")
+ expect(result.notes).toContain("### Added")
+ expect(result.notes).toContain("Added support for new perpetual markets.")
+ expect(result.errors).toHaveLength(0)
+ })
+
+ test("extracts clean release notes for the specific version", () => {
+ const notes = extractReleaseNotes(sampleChangelog, "0.2.0")
+ expect(notes).not.toBeNull()
+ expect(notes).toContain("### Added")
+ expect(notes).toContain("Added support for new perpetual markets.")
+ expect(notes).not.toContain("## [0.1.0]")
+ expect(notes).not.toContain("Initial testnet release")
+ expect(notes).not.toContain("[0.2.0]:")
+ })
+
+ test("fails with descriptive error when tag does not start with 'v'", async () => {
+ const result = await verifyReleaseTag({
+ tag: "0.2.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ })
+
+ expect(result.valid).toBe(false)
+ expect(result.errors.some((e) => e.includes('must start with "v"'))).toBe(true)
+ })
+
+ test("fails with descriptive error when tag is not valid semver", async () => {
+ const result = await verifyReleaseTag({
+ tag: "v0.2",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ })
+
+ expect(result.valid).toBe(false)
+ expect(result.errors.some((e) => e.includes("Must be valid SemVer"))).toBe(true)
+ })
+
+ test("fails when .changelog/unreleased/ has unconsumed entries", async () => {
+ await writeFile(
+ join(unreleasedDir, "001-test-feature.md"),
+ "---\ntype: added\narea: trade\npr: 550\nbreaking: false\n---\nAdded test feature.\n",
+ "utf-8"
+ )
+
+ const result = await verifyReleaseTag({
+ tag: "v0.2.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ })
+
+ expect(result.valid).toBe(false)
+ expect(
+ result.errors.some((e) => e.includes(".changelog/unreleased/ is not empty"))
+ ).toBe(true)
+ expect(
+ result.errors.some((e) => e.includes("001-test-feature.md"))
+ ).toBe(true)
+ })
+
+ test("fails when CHANGELOG.md lacks a section for the tag", async () => {
+ const result = await verifyReleaseTag({
+ tag: "v0.3.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ })
+
+ expect(result.valid).toBe(false)
+ expect(
+ result.errors.some((e) =>
+ e.includes('does not contain a release section for version "0.3.0"')
+ )
+ ).toBe(true)
+ })
+
+ test("fails when release date in CHANGELOG is stale (> 1 day old)", async () => {
+ const result = await verifyReleaseTag({
+ tag: "v0.2.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-09-05", // 6 days later
+ })
+
+ expect(result.valid).toBe(false)
+ expect(
+ result.errors.some((e) => e.includes("is not within 1 day of tag date"))
+ ).toBe(true)
+ })
+
+ test("isWithinOneDay date helper accurately compares dates within 24h window", () => {
+ expect(isWithinOneDay("2026-08-30", "2026-08-30")).toBe(true)
+ expect(isWithinOneDay("2026-08-30", "2026-08-31")).toBe(true)
+ expect(isWithinOneDay("2026-08-31", "2026-08-30")).toBe(true)
+ expect(isWithinOneDay("2026-08-30", "2026-09-02")).toBe(false)
+ expect(isWithinOneDay("invalid", "2026-08-30")).toBe(false)
+ })
+
+ test("writes extracted notes to outputNotesPath when specified", async () => {
+ const notesFile = join(tempDir, "extracted-notes.md")
+ const result = await verifyReleaseTag({
+ tag: "v0.2.0",
+ changelogPath,
+ unreleasedDir,
+ currentDate: "2026-08-30",
+ outputNotesPath: notesFile,
+ })
+
+ expect(result.valid).toBe(true)
+ const written = await Bun.file(notesFile).text()
+ expect(written).toContain("Added support for new perpetual markets.")
+ })
+})
diff --git a/scripts/changelog/verify-tag.ts b/scripts/changelog/verify-tag.ts
new file mode 100644
index 00000000..4b159738
--- /dev/null
+++ b/scripts/changelog/verify-tag.ts
@@ -0,0 +1,210 @@
+/**
+ * DX-025: Tag & Changelog verification for CI release workflows.
+ *
+ * Verifies that when a release tag (v*) is pushed:
+ * 1. .changelog/unreleased/ has no pending entries (all consumed).
+ * 2. CHANGELOG.md contains a release section matching the version in the tag.
+ * 3. The release date in CHANGELOG.md is within 1 day of the tag date.
+ * 4. Extracts the clean markdown release notes for publishing GitHub Releases.
+ *
+ * Usage:
+ * bun run scripts/changelog/verify-tag.ts --tag v0.2.0 [--output-notes /tmp/notes.md]
+ */
+
+import { readdir, readFile, writeFile } from "node:fs/promises"
+import { existsSync } from "node:fs"
+import { join, resolve } from "node:path"
+import { parseChangelog } from "./parse.ts"
+
+export interface VerifyTagOptions {
+ tag: string
+ changelogPath?: string
+ unreleasedDir?: string
+ currentDate?: string // YYYY-MM-DD for testing
+ outputNotesPath?: string
+}
+
+export interface VerifyTagResult {
+ valid: boolean
+ version: string
+ date: string
+ notes: string
+ errors: string[]
+}
+
+const ONE_DAY_MS = 24 * 60 * 60 * 1000
+
+export function extractReleaseNotes(changelogContent: string, version: string): string | null {
+ const lines = changelogContent.split("\n")
+ const headingRegex = /^## \[([^\]]+)\]/
+ let startIndex = -1
+ let endIndex = -1
+
+ for (let i = 0; i < lines.length; i++) {
+ const match = lines[i].match(headingRegex)
+ if (match) {
+ if (match[1] === version) {
+ startIndex = i + 1
+ } else if (startIndex !== -1) {
+ endIndex = i
+ break
+ }
+ } else if (startIndex !== -1 && lines[i].match(/^\[[^\]]+\]: /)) {
+ endIndex = i
+ break
+ }
+ }
+
+ if (startIndex === -1) return null
+ if (endIndex === -1) endIndex = lines.length
+
+ const rawSection = lines.slice(startIndex, endIndex).join("\n").trim()
+ return rawSection
+}
+
+export function isWithinOneDay(date1Str: string, date2Str: string): boolean {
+ const d1 = new Date(`${date1Str}T00:00:00Z`).getTime()
+ const d2 = new Date(`${date2Str}T00:00:00Z`).getTime()
+ if (Number.isNaN(d1) || Number.isNaN(d2)) return false
+ return Math.abs(d1 - d2) <= ONE_DAY_MS
+}
+
+export async function verifyReleaseTag(options: VerifyTagOptions): Promise {
+ const repoRoot = resolve(import.meta.dir, "../..")
+ const changelogPath = options.changelogPath ?? join(repoRoot, "CHANGELOG.md")
+ const unreleasedDir = options.unreleasedDir ?? join(repoRoot, ".changelog", "unreleased")
+ const errors: string[] = []
+
+ // 1. Validate tag format
+ const tag = options.tag.trim()
+ if (!tag.startsWith("v")) {
+ errors.push(
+ `✗ Invalid release tag "${tag}". Release tags must start with "v" (e.g., v1.0.0).`
+ )
+ return { valid: false, version: "", date: "", notes: "", errors }
+ }
+
+ const version = tag.slice(1)
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(version)) {
+ errors.push(
+ `✗ Invalid version "${version}" in tag "${tag}". Must be valid SemVer (e.g., v0.2.0).`
+ )
+ return { valid: false, version, date: "", notes: "", errors }
+ }
+
+ // 2. Check unreleased directory is empty
+ if (existsSync(unreleasedDir)) {
+ try {
+ const files = await readdir(unreleasedDir)
+ const pendingFiles = files.filter(
+ (f) => f.endsWith(".md") && f !== "README.md" && f !== "TEMPLATE.md"
+ )
+ if (pendingFiles.length > 0) {
+ errors.push(
+ `✗ Assertion failed: .changelog/unreleased/ is not empty (found ${pendingFiles.length} pending entry file(s): ${pendingFiles.join(", ")}).\n` +
+ ` Fix: Run 'bun run changelog:release ${version}' in a PR branch to consume pending entries before tagging.`
+ )
+ }
+ } catch (err) {
+ errors.push(`✗ Failed to read unreleased directory "${unreleasedDir}": ${err}`)
+ }
+ }
+
+ // 3. Read and parse CHANGELOG.md
+ let changelogContent = ""
+ try {
+ changelogContent = await readFile(changelogPath, "utf-8")
+ } catch (err) {
+ errors.push(`✗ Could not read CHANGELOG.md at "${changelogPath}": ${err}`)
+ return { valid: false, version, date: "", notes: "", errors }
+ }
+
+ let changelogData
+ try {
+ changelogData = parseChangelog(changelogContent)
+ } catch (err) {
+ errors.push(`✗ Failed to parse CHANGELOG.md: ${err}`)
+ return { valid: false, version, date: "", notes: "", errors }
+ }
+
+ const release = changelogData.releases.find((r) => r.version === version)
+ if (!release) {
+ errors.push(
+ `✗ Assertion failed: CHANGELOG.md does not contain a release section for version "${version}".\n` +
+ ` Fix: Run 'bun run changelog:release ${version}' and merge the resulting PR to main before tagging.`
+ )
+ return { valid: false, version, date: "", notes: "", errors }
+ }
+
+ // 4. Verify release date freshness (within 1 day)
+ const targetDate =
+ options.currentDate ?? new Date().toISOString().slice(0, 10)
+ if (!isWithinOneDay(release.date, targetDate)) {
+ errors.push(
+ `✗ Assertion failed: Release date in CHANGELOG.md (${release.date}) is not within 1 day of tag date (${targetDate}).\n` +
+ ` Fix: Update the release date in CHANGELOG.md for [${version}] to ${targetDate} in a PR before tagging.`
+ )
+ }
+
+ // 5. Extract release notes body
+ const notes = extractReleaseNotes(changelogContent, version) ?? ""
+
+ if (options.outputNotesPath && errors.length === 0) {
+ await writeFile(options.outputNotesPath, notes, "utf-8")
+ }
+
+ return {
+ valid: errors.length === 0,
+ version,
+ date: release.date,
+ notes,
+ errors,
+ }
+}
+
+async function main() {
+ const args = process.argv.slice(2)
+ let tag = process.env.GITHUB_REF_NAME ?? ""
+ let outputNotesPath: string | undefined
+
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === "--tag" && args[i + 1]) {
+ tag = args[++i]
+ } else if (args[i] === "--output-notes" && args[i + 1]) {
+ outputNotesPath = args[++i]
+ } else if (!args[i].startsWith("--") && !tag) {
+ tag = args[i]
+ }
+ }
+
+ if (!tag) {
+ console.error("Usage: bun run scripts/changelog/verify-tag.ts --tag [--output-notes ]")
+ process.exit(1)
+ }
+
+ console.log(`Verifying release tag "${tag}"...`)
+ const result = await verifyReleaseTag({ tag, outputNotesPath })
+
+ if (!result.valid) {
+ console.error("\nRelease verification failed:")
+ for (const error of result.errors) {
+ console.error(`\n${error}`)
+ }
+ process.exit(1)
+ }
+
+ console.log(`✓ Release tag ${tag} matches CHANGELOG.md section [${result.version}] (${result.date})`)
+ console.log(`✓ .changelog/unreleased/ is empty`)
+ console.log(`✓ Release date is current (${result.date})`)
+ if (outputNotesPath) {
+ console.log(`✓ Extracted release notes written to ${outputNotesPath}`)
+ }
+}
+
+const invokedDirectly = process.argv[1]?.endsWith("verify-tag.ts")
+if (invokedDirectly) {
+ main().catch((err) => {
+ console.error(err)
+ process.exit(1)
+ })
+}