diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6a8c3e3..6220d13 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,7 +1,8 @@ name: Docs # Checks packages/docs on every pull request and push to main that can change the built site: the docs -# themselves, the SDK they import, the skills they publish under /skills/, and the lockfile. +# themselves, the SDK they import, the skills they publish under /skills/, the changelogs the release +# notes are built from, and the lockfile. # Same-repo pull requests also get a preview: a Worker version uploaded under the alias pr-. # Production deploys are not done here; see docs-deploy.yml. on: @@ -9,6 +10,7 @@ on: paths: - packages/docs/** - packages/sdk/** + - packages/cli/CHANGELOG.md - plugins/radius/** - .claude-plugin/** - pnpm-lock.yaml @@ -18,6 +20,7 @@ on: paths: - packages/docs/** - packages/sdk/** + - packages/cli/CHANGELOG.md - plugins/radius/** - .claude-plugin/** - pnpm-lock.yaml @@ -43,6 +46,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Release notes list a radius-sdk / radius-cli version once its release tag exists, dated by + # the tag; docs-deploy.yml checks out the same way. + fetch-depth: 0 + fetch-tags: true - name: Set up pnpm uses: pnpm/action-setup@v6 @@ -75,6 +82,9 @@ jobs: - name: Test flat skill generation run: pnpm test:skills + - name: Test release notes generation + run: pnpm test:release-notes + - name: Markdown audit run: pnpm docs:markdown-audit diff --git a/AGENTS.md b/AGENTS.md index 96a5cb6..ff31783 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ From the repository root, run whichever of these apply to what you changed. Ever - Every PR that changes `packages/cli` or `packages/sdk` adds a changeset (`pnpm changeset`, or a file in `.changeset/`). Never edit `CHANGELOG.md` or package versions by hand. - Document an SDK or CLI change in `packages/docs` in the same PR, and audit the skills under `plugins/radius/skills` that cover it. - A skill may name a new SDK or CLI API only once that version is on npm. State the minimum package version in the skill. -- Bump `plugins/radius/.claude-plugin/plugin.json` in any PR that changes a skill, the plugin manifest or the plugin README: patch for corrections, minor for new capabilities. The plugin is not part of Changesets. +- Bump `plugins/radius/.claude-plugin/plugin.json` in any PR that changes a skill, the plugin manifest or the plugin README: patch for corrections, minor for new capabilities. The plugin is not part of Changesets. Each bump adds a `## ()` entry to `plugins/radius/CHANGELOG.md`; the docs release notes are built from it, so write the first sentence of each change for developers who use the skills. - In skill examples, write prices as decimal strings such as `'0.001'`. Claude substitutes `$0` and other positional placeholders even inside code fences. - No eval case, test or example you run may spend from a funded wallet. The funded testnet proof (`pnpm eval:payments:live`) runs only when a person asks for it. - Never print, log or commit private keys, `RADIUS_*` secrets or API keys. diff --git a/README.md b/README.md index 8523682..96484d0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ The [Radius Claude Code plugin](plugins/radius) contains the `radius-dev`, `x402 [`.claude-plugin/marketplace.json`](.claude-plugin/marketplace.json). These files live outside `packages/*`. The plugin has its own version in [`plugins/radius/.claude-plugin/plugin.json`](plugins/radius/.claude-plugin/plugin.json) -and does not enter the npm Changesets release flow. The marketplace's +and does not enter the npm Changesets release flow. Each version bump adds an entry to +[`plugins/radius/CHANGELOG.md`](plugins/radius/CHANGELOG.md), which the docs release notes are built from. The marketplace's `metadata.version` describes the catalog, not the plugin; the marketplace entry does not duplicate the plugin version. diff --git a/packages/docs/.gitignore b/packages/docs/.gitignore index 32d36f2..e819379 100644 --- a/packages/docs/.gitignore +++ b/packages/docs/.gitignore @@ -4,8 +4,10 @@ dist/ .vocs/ tmp/ -# Generated at build time (scripts/generate-skills-md.ts, scripts/generate-sitemap.ts) +# Generated at build time (scripts/generate-skills-md.ts, scripts/generate-sitemap.ts, +# scripts/generate-release-notes.ts) public/skills/ +docs/pages/release-notes.mdx public/sitemap.txt public/*.xml diff --git a/packages/docs/.prettierignore b/packages/docs/.prettierignore index 2123c44..e082a87 100644 --- a/packages/docs/.prettierignore +++ b/packages/docs/.prettierignore @@ -6,3 +6,5 @@ tmp/ public/**/*.md public/**/*.txt public/**/*.xml +# Generated by scripts/generate-release-notes.ts +docs/pages/release-notes.mdx diff --git a/packages/docs/CLAUDE.md b/packages/docs/CLAUDE.md index beda292..0cf3026 100644 --- a/packages/docs/CLAUDE.md +++ b/packages/docs/CLAUDE.md @@ -126,8 +126,9 @@ packages/docs/ │ ├── constants.ts # Network constants used in MDX and generated Markdown │ └── remark-llm-output.ts # Output-only remark plugin for agent Markdown ├── worker/index.ts # Worker entry: /api/try/* ("Try Radius"), Markdown negotiation, agent logging -├── public/ # Static assets (logos, robots.txt, _redirects, _headers, generated skills/sitemap) -├── scripts/ # Build utilities (skills, sitemap, tx cost, postbuild mirror, validators) +├── public/ # Static assets (logos, robots.txt, _redirects, _headers, generated skills/sitemap/feed) +├── release-notes/network.md # Radius Network release notes, by hand (see Release notes) +├── scripts/ # Build utilities (skills, sitemap, release notes, tx cost, postbuild mirror, validators) ├── vocs.config.ts # Site configuration ├── wrangler.jsonc # Worker, static assets, TRY_PAY_TO, logs, Analytics Engine ├── CLAUDE.md # This file @@ -165,7 +166,7 @@ Network values (chain IDs, RPC URLs, asset names and decimals, contract addresse ## Deployment -The site is the `radius-docs` Cloudflare Worker (`wrangler.jsonc`). Workers static assets serve `dist/public`, including `public/_redirects` and `public/_headers` (which marks every `workers.dev` host, previews included, `noindex`); `worker/index.ts` runs only for the paths in `assets.run_worker_first`. Under `/api/try/*` it is the backend of the interactive "Try Radius" components (`docs/components/try/`, used on `/try`), a testnet faucet proxy (the faucet API sends no CORS headers) and priced testnet routes built with `radius-sdk/hono`. Payments go to `TRY_PAY_TO` in `wrangler.jsonc`. Every other path except scripts, styles and images (excluded in `run_worker_first`) also reaches it, because Cloudflare Web Analytics only counts browsers that run its script. It serves pages and agent-facing files (`*.md`, `llms.txt`, `llms-full.txt`, `robots.txt`, sitemaps) from static assets, serves a page's Markdown twin to requests whose `Accept` header prefers `text/markdown`, and records agent requests twice: an `agent fetch` line in Workers Logs (kept 3 to 7 days, for debugging) and a data point in the `docs_agent_fetches` Analytics Engine dataset (for weekly KPIs; `worker/index.ts` documents its columns, which are append-only, including the host to filter previews out). A request is recorded when it negotiated Markdown or has no `Sec-Fetch-Mode` header, which every current browser sends, including on the page's own fetch of its `.md` twin (PageActions prefetches it on every page view). Neither record holds an IP address, query string or body. New static asset folders or file types go in the `run_worker_first` exclusions, so they stay asset-first: every Worker invocation is billed. Run it locally with `pnpm build && pnpm wrangler dev`; `vocs dev` serves the pages only, so the components' API calls fail there. +The site is the `radius-docs` Cloudflare Worker (`wrangler.jsonc`). Workers static assets serve `dist/public`, including `public/_redirects` and `public/_headers` (which marks every `workers.dev` host, previews included, `noindex`); `worker/index.ts` runs only for the paths in `assets.run_worker_first`. Under `/api/try/*` it is the backend of the interactive "Try Radius" components (`docs/components/try/`, used on `/try`), a testnet faucet proxy (the faucet API sends no CORS headers) and priced testnet routes built with `radius-sdk/hono`. Payments go to `TRY_PAY_TO` in `wrangler.jsonc`. Every other path except scripts, styles and images (excluded in `run_worker_first`) also reaches it, because Cloudflare Web Analytics only counts browsers that run its script. It serves pages and agent-facing files (`*.md`, `llms.txt`, `llms-full.txt`, `robots.txt`, sitemaps, `release-notes.xml`) from static assets, serves a page's Markdown twin to requests whose `Accept` header prefers `text/markdown`, and records agent requests twice: an `agent fetch` line in Workers Logs (kept 3 to 7 days, for debugging) and a data point in the `docs_agent_fetches` Analytics Engine dataset (for weekly KPIs; `worker/index.ts` documents its columns, which are append-only, including the host to filter previews out). A request is recorded when it negotiated Markdown or has no `Sec-Fetch-Mode` header, which every current browser sends, including on the page's own fetch of its `.md` twin (PageActions prefetches it on every page view). Neither record holds an IP address, query string or body. New static asset folders or file types go in the `run_worker_first` exclusions, so they stay asset-first: every Worker invocation is billed. Run it locally with `pnpm build && pnpm wrangler dev`; `vocs dev` serves the pages only, so the components' API calls fail there. The `docs.radiustech.xyz` custom domain is managed in the Cloudflare dashboard, not in `wrangler.jsonc`: listing it there makes `wrangler deploy` call the custom-domains API, which needs zone and DNS permissions the CI token deliberately lacks. @@ -174,6 +175,18 @@ The `docs.radiustech.xyz` custom domain is managed in the Cloudflare dashboard, --- +## Release notes + +`docs/pages/release-notes.mdx` and the Atom feed `public/release-notes.xml` are generated (and gitignored) by `scripts/generate-release-notes.ts`, which `prebuild`, `predev`, `docs:validate` and `docs:markdown-audit` run. Do not edit the page; edit its sources: + +- **Radius Network:** `release-notes/network.md`, by hand. `## ()` per release; the text is MDX. +- **radius-sdk, radius-cli:** their `CHANGELOG.md`, written by Changesets. A version is listed once its `@` git tag exists, dated by the tag, so the docs never announce a version that is not on npm. The generator fails when a checkout has no such tags: CI checks out with `fetch-depth: 0` and `fetch-tags: true`. +- **Radius skills:** `plugins/radius/CHANGELOG.md`, one `## ()` entry per plugin version bump. + +Each release is a `## : ` heading, so its anchor (`#2026-10-15-radius-sdk-060`) is a stable link to share. Each change's first sentence is bolded as its headline, and the page description names the latest release for link previews and `llms.txt`. Production picks up new entries on the next docs deploy: after every npm release, or a docs-only deploy run by hand. + +--- + ## Content Pages Three areas, each with its own sidebar in `vocs.config.ts`; the area list is in `docs/areas.ts`. The top nav and a Discover / Build / Reference switcher at the top of the desktop sidebar (`SidebarHeader` slot, `docs/components/AreaSwitcher.tsx`) link them; on mobile, Vocs shows the areas as a menu drop-down. Vocs picks the sidebar whose key is the longest prefix of the page path. Moved URLs redirect in `public/_redirects`. diff --git a/packages/docs/README.md b/packages/docs/README.md index 26c0bfe..a289cca 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -6,23 +6,23 @@ Source of [docs.radiustech.xyz](https://docs.radiustech.xyz), built with [Vocs]( Run from `packages/docs` (Node 22.15+, pnpm). Build the SDK once first: `pnpm --filter radius-sdk build` (the docs resolve it from its `dist/`). -| Command | Description | -| -------------------------- | ------------------------------------------------------------------------------ | -| `pnpm dev` | Development server at `http://localhost:5173` (pages only, no `/api/try`) | -| `pnpm build` | Generate skills and sitemap, build to `dist/public`, mirror Markdown twins | -| `pnpm wrangler dev` | After a build: serve the site and the Worker (`/api/try/*`) like production | -| `pnpm typecheck:docs` | Type-check the site, scripts and Worker | -| `pnpm docs:validate` | Frontmatter, links, headings and JSON blocks in `docs/pages` | -| `pnpm docs:markdown-audit` | Fail if an MDX component would be left unrendered in agent Markdown | -| `pnpm docs:agent-check` | After a build: fail on unparsed callouts or `.md` links with no file | -| `pnpm docs:sitemap-check` | After a build: validate `sitemap.xml` | -| `pnpm fetch-cost` | Refresh the fee constants in `docs/constants.ts` (the deploy workflow does it) | +| Command | Description | +| -------------------------- | -------------------------------------------------------------------------------- | +| `pnpm dev` | Development server at `http://localhost:5173` (pages only, no `/api/try`) | +| `pnpm build` | Generate skills, release notes and sitemap, build to `dist/public`, mirror twins | +| `pnpm wrangler dev` | After a build: serve the site and the Worker (`/api/try/*`) like production | +| `pnpm typecheck:docs` | Type-check the site, scripts and Worker | +| `pnpm docs:validate` | Frontmatter, links, headings and JSON blocks in `docs/pages` | +| `pnpm docs:markdown-audit` | Fail if an MDX component would be left unrendered in agent Markdown | +| `pnpm docs:agent-check` | After a build: fail on unparsed callouts or `.md` links with no file | +| `pnpm docs:sitemap-check` | After a build: validate `sitemap.xml` | +| `pnpm fetch-cost` | Refresh the fee constants in `docs/constants.ts` (the deploy workflow does it) | CI (`.github/workflows/docs.yml`) runs format, lint, typecheck, the validators and the build on every pull request that touches the docs, the SDK or the skills, and uploads a preview version of the Worker for same-repo pull requests. ## Hosting and releases -The site is a Cloudflare Worker (`wrangler.jsonc`): Workers static assets serve `dist/public`, including `public/_redirects` and `public/_headers` (`noindex` on `workers.dev` hosts, so previews and the pre-cutover site stay out of search results), and `worker/index.ts` handles `/api/try/*`, the backend of the interactive "Try Radius" components (a testnet faucet proxy and priced testnet routes built with `radius-sdk/hono`). Pages and the agent-facing files (`*.md`, `llms.txt`, `llms-full.txt`, `robots.txt`, sitemaps) also pass through the Worker, which serves them from static assets, gives a page's Markdown twin to requests that ask for `text/markdown`, and records agent requests in Workers Logs and the `docs_agent_fetches` Analytics Engine dataset: browser analytics never see agents. Scripts, styles and images stay asset-first. +The site is a Cloudflare Worker (`wrangler.jsonc`): Workers static assets serve `dist/public`, including `public/_redirects` and `public/_headers` (`noindex` on `workers.dev` hosts, so previews and the pre-cutover site stay out of search results), and `worker/index.ts` handles `/api/try/*`, the backend of the interactive "Try Radius" components (a testnet faucet proxy and priced testnet routes built with `radius-sdk/hono`). Pages and the agent-facing files (`*.md`, `llms.txt`, `llms-full.txt`, `robots.txt`, sitemaps, the `release-notes.xml` feed) also pass through the Worker, which serves them from static assets, gives a page's Markdown twin to requests that ask for `text/markdown`, and records agent requests in Workers Logs and the `docs_agent_fetches` Analytics Engine dataset: browser analytics never see agents. Scripts, styles and images stay asset-first. Production follows releases, not `main`, so the docs never describe a package version that is not on npm. `.github/workflows/docs-deploy.yml` builds and deploys, then moves the `docs-live` tag to the deployed commit: diff --git a/packages/docs/docs/pages/_root.css b/packages/docs/docs/pages/_root.css index 5239b07..8410446 100644 --- a/packages/docs/docs/pages/_root.css +++ b/packages/docs/docs/pages/_root.css @@ -135,6 +135,12 @@ h1 { font-weight: 600; } +/* A link to a heading (a shared release, a section) scrolls it below both fixed bars: Vocs offsets + * headings for the top nav only, so the "On this page" bar covered them. */ +[data-v-content] :is(h2, h3, h4)[id] { + scroll-margin-top: 2.5rem; +} + /* Inline code only; code blocks are `pre > code`. */ [data-v-content] :not(pre) > code { color: #eb6359; diff --git a/packages/docs/package.json b/packages/docs/package.json index 7d8a970..a711647 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -7,18 +7,21 @@ "generate-skills": "tsx scripts/generate-skills-md.ts", "test:skills": "node --import tsx --test scripts/generate-skills-md.test.ts", "generate-sitemap": "tsx scripts/generate-sitemap.ts", + "generate-release-notes": "tsx scripts/generate-release-notes.ts", + "test:release-notes": "node --import tsx --test scripts/generate-release-notes.test.ts", "fetch-cost": "tsx scripts/fetch-transaction-cost.ts", + "predev": "tsx scripts/generate-release-notes.ts", "dev": "vocs dev", - "prebuild": "tsx scripts/generate-skills-md.ts && tsx scripts/generate-sitemap.ts", + "prebuild": "tsx scripts/generate-skills-md.ts && tsx scripts/generate-release-notes.ts && tsx scripts/generate-sitemap.ts", "build": "vocs build", "preview": "vocs preview", "format:check": "prettier --check .", "format:write": "prettier --write .", "lint": "eslint .", "lint:fix": "eslint --fix .", - "docs:validate": "tsx scripts/validate-docs-v2.ts", + "docs:validate": "tsx scripts/generate-release-notes.ts && tsx scripts/validate-docs-v2.ts", "postbuild": "tsx scripts/postbuild-static-markdown.ts", - "docs:markdown-audit": "vocs markdown-audit", + "docs:markdown-audit": "tsx scripts/generate-release-notes.ts && vocs markdown-audit", "docs:agent-check": "tsx scripts/validate-agent-markdown.ts", "docs:sitemap-check": "tsx scripts/validate-sitemap.ts", "typecheck:docs": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p worker/tsconfig.json" diff --git a/packages/docs/public/_headers b/packages/docs/public/_headers index edf95c8..4e0e885 100644 --- a/packages/docs/public/_headers +++ b/packages/docs/public/_headers @@ -2,3 +2,7 @@ # only docs.radiustech.xyz should be indexed. https://:worker.:account.workers.dev/* X-Robots-Tag: noindex + +# The release notes feed (scripts/generate-release-notes.ts) is Atom, not generic XML. +/release-notes.xml + Content-Type: application/atom+xml; charset=utf-8 diff --git a/packages/docs/docs/pages/release-notes.mdx b/packages/docs/release-notes/network.md similarity index 93% rename from packages/docs/docs/pages/release-notes.mdx rename to packages/docs/release-notes/network.md index 8483bbb..594f560 100644 --- a/packages/docs/docs/pages/release-notes.mdx +++ b/packages/docs/release-notes/network.md @@ -1,11 +1,9 @@ ---- -title: Release notes -description: Read the latest Radius release notes, including product updates, feature launches, fixes, and important developer changes. ---- - -# Release notes [Latest product and developer changes] - -Latest changes to the Radius Network. New features, improvements and bug fixes will be posted here. + ## 2026-09-28 (099a1b5b) diff --git a/packages/docs/scripts/generate-release-notes.test.ts b/packages/docs/scripts/generate-release-notes.test.ts new file mode 100644 index 0000000..6fee2f3 --- /dev/null +++ b/packages/docs/scripts/generate-release-notes.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + anchor, + collectReleases, + describe, + emphasizeHeadline, + escapeMdx, + firstSentence, + parseChangesetsVersion, + parseNetwork, + parsePackage, + parseSkills, + renderFeed, + renderPage, + sortReleases, +} from './generate-release-notes'; + +const repoRoot = join(import.meta.dirname, '..', '..', '..'); +const read = (path: string) => readFileSync(join(repoRoot, path), 'utf8'); + +test('the first sentence ends outside code and abbreviations', () => { + assert.equal(firstSentence('Use `a.b. c` here, e.g. now. Then more.'), 'Use `a.b. c` here, e.g. now.'); + assert.equal(firstSentence('New `radius-sdk/server` entry point:'), undefined); + assert.equal(emphasizeHeadline('One. Two.\n more'), '**One.** Two.\n more'); + assert.equal(emphasizeHeadline('**Breaking:** kept as written.'), '**Breaking:** kept as written.'); +}); + +test('MDX syntax is escaped outside code only', () => { + assert.equal(escapeMdx('a {b} `{d} `'), 'a \\{b\\} <c> `{d} `'); + assert.equal(escapeMdx('```ts\nconst x = {a: 1} < 2\n```'), '```ts\nconst x = {a: 1} < 2\n```'); +}); + +test('anchors match Vocs heading ids', () => { + assert.equal(anchor('2026-10-15: radius-sdk 0.6.0'), '2026-10-15-radius-sdk-060'); + assert.equal(anchor('2026-09-28: Radius Network'), '2026-09-28-radius-network'); +}); + +const changelog = `# radius-cli + +## 0.2.0 + +### Major Changes + +- abc1234: Removed the old command. It is gone. + +### Minor Changes + +- def5678: Add \`thing\`. It does more. + - **Breaking:** a sub-change. +- 1234567: Add \`other\`. +- 2345678: Move a dependency. + + **Breaking for installs:** add it yourself. + +### Patch Changes + +- 7654321: Fix a crash. +- Updated dependencies [7654321] + - radius-sdk@0.6.0 + +## 0.1.0 + +### Patch Changes + +- Updated dependencies [aaaaaaa] + - radius-sdk@0.5.0 +`; + +test('Changesets sections map to breaking, new and fix; dependency bumps are pulled out', () => { + const body = changelog.split('## 0.2.0')[1]!.split('## 0.1.0')[0]!; + const { changes, dependencies } = parseChangesetsVersion(body); + assert.deepEqual( + changes.map((change) => change.kind), + ['breaking', 'breaking', 'new', 'breaking', 'fix'], + ); + assert.equal(changes[1]!.text, 'Add `thing`. It does more.\n - **Breaking:** a sub-change.'); + assert.deepEqual(dependencies, ['radius-sdk@0.6.0']); +}); + +test('only tagged package versions are listed, dated by their tag', () => { + const releases = parsePackage('radius-cli', changelog, new Map([['radius-cli@0.1.0', '2026-09-01']])); + assert.equal(releases.length, 1); + assert.equal(releases[0]!.title, 'radius-cli 0.1.0'); + assert.equal(releases[0]!.date, '2026-09-01'); + assert.match(releases[0]!.body, /Uses `radius-sdk@0\.5\.0`\./); + assert.match(releases[0]!.body, /releases\/tag\/radius-cli%400\.1\.0/); +}); + +test('network and skills sections parse; malformed headings fail', () => { + const network = parseNetwork( + '\n\n## 2026-09-24 (10eea1f9)\n\n### Improvements\n\n- Faster. More.\n\n### API / RPC changes\n\n#### `eth_call` change\n\n- **Before:** x\n', + ); + assert.equal(network[0]!.date, '2026-09-24'); + assert.deepEqual(network[0]!.headlines, ['Faster.', 'eth_call change']); + assert.throws(() => parseNetwork('## September (abc)\n'), /heading must be/); + + const skills = parseSkills('# Title\n\n## 0.2.0 (2026-10-16)\n\n- **Breaking:** renamed.\n- Added. More.\n'); + assert.equal(skills[0]!.title, 'Radius skills 0.2.0'); + assert.deepEqual(skills[0]!.headlines, ['Breaking: renamed.', 'Added.']); + assert.throws(() => parseSkills('## 0.2.0\n'), /heading must be/); +}); + +test('releases sort newest first, network before packages on the same day', () => { + const releases = sortReleases([ + { title: 'Radius skills 0.1.0', date: '2026-10-15', body: '', headlines: [] }, + { title: 'radius-sdk 0.6.0', date: '2026-10-15', body: '', headlines: [] }, + { title: 'Radius Network', date: '2026-10-15', body: '', headlines: [] }, + { title: 'radius-cli 0.5.0', date: '2026-10-16', body: '', headlines: [] }, + ]); + assert.deepEqual( + releases.map((release) => release.title), + ['radius-cli 0.5.0', 'Radius Network', 'radius-sdk 0.6.0', 'Radius skills 0.1.0'], + ); +}); + +test('the description names the latest release within 160 characters', () => { + const description = describe([ + { title: 'radius-sdk 1.0.0', date: '2026-11-01', body: '', headlines: ['x'.repeat(300)] }, + ]); + assert.ok(description.startsWith('Latest: radius-sdk 1.0.0 (2026-11-01).')); + assert.ok(description.length <= 160); +}); + +test('the real sources build a page and feed with every released version', () => { + const tags = new Map(); + for (const [name, path] of [ + ['radius-sdk', 'packages/sdk/CHANGELOG.md'], + ['radius-cli', 'packages/cli/CHANGELOG.md'], + ] as const) { + for (const [, version] of read(path).matchAll(/^## (\S+)$/gm)) tags.set(`${name}@${version}`, '2026-10-01'); + } + const releases = collectReleases(read, tags); + const page = renderPage(releases); + const feed = renderFeed(releases); + for (const tag of tags.keys()) { + const title = tag.replace('@', ' '); + assert.ok(page.includes(`: ${title}\n`), `${title} missing from the page`); + assert.ok(feed.includes(`${title}`), `${title} missing from the feed`); + } + const version = JSON.parse(read('plugins/radius/.claude-plugin/plugin.json')).version; + assert.ok(page.includes(`: Radius skills ${version}\n`)); + assert.ok(page.includes(': Radius Network\n')); + assert.doesNotMatch(page, /Updated dependencies/); + assert.equal((feed.match(//g) ?? []).length, releases.length); +}); + +test('a checkout without release tags fails instead of dropping the packages', () => { + assert.throws(() => collectReleases(read, new Map()), /no radius-sdk@ git tags found/); +}); diff --git a/packages/docs/scripts/generate-release-notes.ts b/packages/docs/scripts/generate-release-notes.ts new file mode 100644 index 0000000..6915a1d --- /dev/null +++ b/packages/docs/scripts/generate-release-notes.ts @@ -0,0 +1,415 @@ +#!/usr/bin/env tsx +/** + * generate-release-notes.ts + * + * Builds the release notes page and its Atom feed from four sources, newest first: + * + * - release-notes/network.md: Radius Network notes, written by hand + * - packages/sdk/CHANGELOG.md and packages/cli/CHANGELOG.md: written by Changesets. A version is + * listed only once its `@` git tag exists (the release workflow pushes it after + * the npm publish), and dated by that tag + * - plugins/radius/CHANGELOG.md: the Radius skills, `## ()` per version + * + * Output (both gitignored): + * - docs/pages/release-notes.mdx, one `## : ` section per release, so every + * release has a stable anchor to share. Its description names the latest release, for link + * previews and llms.txt. + * - public/release-notes.xml, an Atom feed with one entry per release. + * + * Usage: + * pnpm generate-release-notes (also run by prebuild, predev, docs:validate and docs:markdown-audit) + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const PROJECT_ROOT = join(import.meta.dirname, '..'); +const REPO_ROOT = join(PROJECT_ROOT, '..', '..'); +const SITE_BASE_URL = process.env.SITE_BASE_URL ?? 'https://docs.radiustech.xyz'; +const PAGE_URL = `${SITE_BASE_URL}/release-notes`; +const FEED_URL = `${SITE_BASE_URL}/release-notes.xml`; +const REPOSITORY = 'https://github.com/radiustechsystems/tools'; + +const PACKAGES = [ + { name: 'radius-sdk', changelog: 'packages/sdk/CHANGELOG.md' }, + { name: 'radius-cli', changelog: 'packages/cli/CHANGELOG.md' }, +] as const; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ChangeKind = 'breaking' | 'new' | 'fix'; + +export interface Change { + kind: ChangeKind; + /** Markdown, first line unindented, continuation lines as written (two-space indent). */ + text: string; +} + +export interface Release { + /** Display name: `Radius Network`, `radius-sdk 0.6.0`, `Radius skills 0.2.2`. */ + title: string; + /** YYYY-MM-DD */ + date: string; + /** Markdown under the heading. */ + body: string; + /** One line per change, plain text, for the feed summary and the page description. */ + headlines: string[]; +} + +// --------------------------------------------------------------------------- +// Markdown helpers +// --------------------------------------------------------------------------- + +/** The first sentence of a line, outside inline code, or undefined when it has no sentence end. */ +export function firstSentence(line: string): string | undefined { + let inCode = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '`') inCode = !inCode; + if (inCode || !'.!?'.includes(char!)) continue; + const next = line[i + 1]; + if (next !== undefined && next !== ' ') continue; + // Abbreviations such as "e.g." and "i.e." do not end a sentence. + if (/(?:^|\s)(?:e\.g|i\.e|vs|etc)$/i.test(line.slice(0, i))) continue; + return line.slice(0, i + 1); + } + return undefined; +} + +/** Bolds a change's first sentence (its headline), unless it already carries emphasis. */ +export function emphasizeHeadline(text: string): string { + const [first = '', ...rest] = text.split('\n'); + if (first.includes('**')) return text; + const sentence = firstSentence(first); + const bold = sentence ? `**${sentence}**${first.slice(sentence.length)}` : `**${first}**`; + return [bold, ...rest].join('\n'); +} + +/** Plain text of a change's headline: no code ticks, emphasis or link targets. */ +export function plainHeadline(text: string): string { + const first = text.split('\n')[0] ?? ''; + return (firstSentence(first) ?? first) + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[`*]/g, '') + .trim(); +} + +/** Escapes text that MDX would read as JSX or expressions, outside inline code and code fences. */ +export function escapeMdx(markdown: string): string { + let inFence = false; + return markdown + .split('\n') + .map((line) => { + if (/^\s*```/.test(line)) { + inFence = !inFence; + return line; + } + if (inFence) return line; + return line + .split(/(`[^`]*`)/) + .map((part) => (part.startsWith('`') ? part : part.replace(/[{}]/g, '\\$&').replace(/): string { + return `${release.date}: ${release.title}`; +} + +// --------------------------------------------------------------------------- +// Sources +// --------------------------------------------------------------------------- + +/** Splits a document into `## ` sections: [heading text, body]. */ +function sections(markdown: string): [string, string][] { + const parts = markdown.split(/^## /m).slice(1); + return parts.map((part) => { + const newline = part.indexOf('\n'); + return [part.slice(0, newline).trim(), part.slice(newline + 1).trim()]; + }); +} + +/** Top-level list items of a block, with their indented continuation lines. */ +function listItems(block: string): string[] { + const items: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('- ')) items.push(line.slice(2)); + else if (items.length > 0) items[items.length - 1] += `\n${line}`; + } + return items.map((item) => item.replace(/\s+$/, '')); +} + +/** + * Headlines of a network release: each `####` heading (an API change) and the first sentence of + * each top-level bullet outside them. + */ +function networkHeadlines(body: string): string[] { + const headlines: string[] = []; + let inApiChange = false; + for (const line of body.split('\n')) { + if (line.startsWith('#### ')) { + inApiChange = true; + headlines.push(plainHeadline(line.slice(5))); + } else if (line.startsWith('### ')) { + inApiChange = false; + } else if (line.startsWith('- ') && !inApiChange) { + headlines.push(plainHeadline(line.slice(2))); + } + } + return headlines.filter(Boolean); +} + +/** Network notes: `## ()` sections, kept as written. */ +export function parseNetwork(markdown: string): Release[] { + return sections(markdown.replace(//g, '')).map(([title, body]) => { + const match = title.match(/^(\d{4}-\d{2}-\d{2}) \(([0-9a-f]+)\)$/); + if (!match) throw new Error(`release-notes/network.md: heading must be "## ()": ${title}`); + return { + title: 'Radius Network', + date: match[1]!, + body: `Network build \`${match[2]}\`.\n\n${body}`, + headlines: networkHeadlines(body), + }; + }); +} + +const KIND_OF_SECTION: Record = { + 'Major Changes': 'breaking', + 'Minor Changes': 'new', + 'Patch Changes': 'fix', +}; + +const KIND_HEADING: Record = { breaking: 'Breaking changes', new: 'New', fix: 'Fixes' }; + +/** One version of a Changesets CHANGELOG.md: its changes and the workspace packages it updated. */ +export function parseChangesetsVersion(body: string): { changes: Change[]; dependencies: string[] } { + const changes: Change[] = []; + const dependencies: string[] = []; + for (const [title, block] of body + .split(/^### /m) + .slice(1) + .map((part) => { + const newline = part.indexOf('\n'); + return [part.slice(0, newline).trim(), part.slice(newline + 1)] as const; + })) { + const kind = KIND_OF_SECTION[title]; + if (!kind) throw new Error(`unknown changelog section: ${title}`); + for (const item of listItems(block)) { + if (item.startsWith('Updated dependencies')) { + dependencies.push(...[...item.matchAll(/^\s+- (\S+@\S+)$/gm)].map((match) => match[1]!)); + continue; + } + // ": "; continuation lines are indented two spaces under the item. + const text = item.replace(/^[0-9a-f]{7,40}: /, ''); + // A minor change that says it breaks something (`**Breaking:**`, `**Breaking for installs:**`) is + // listed with the breaking changes. + changes.push({ kind: kind === 'new' && /\*\*Breaking\b[^*]*\*\*/.test(text) ? 'breaking' : kind, text }); + } + } + return { changes, dependencies }; +} + +function changeList(changes: Change[]): string { + return changes.map((change) => `- ${escapeMdx(emphasizeHeadline(change.text))}`).join('\n'); +} + +/** Changesets CHANGELOG.md versions that have a release tag. */ +export function parsePackage(name: string, markdown: string, tagDates: Map): Release[] { + const releases: Release[] = []; + for (const [version, body] of sections(markdown)) { + const tag = `${name}@${version}`; + const date = tagDates.get(tag); + if (!date) continue; // Not published yet (the Version Packages PR), or tags not fetched. + const { changes, dependencies } = parseChangesetsVersion(body); + const blocks = [ + `[npm](https://www.npmjs.com/package/${name}/v/${version}) · [GitHub release](${REPOSITORY}/releases/tag/${encodeURIComponent(tag)})`, + ]; + for (const kind of ['breaking', 'new', 'fix'] as const) { + const ofKind = changes.filter((change) => change.kind === kind); + if (ofKind.length > 0) blocks.push(`### ${KIND_HEADING[kind]}\n\n${changeList(ofKind)}`); + } + if (dependencies.length > 0) blocks.push(`Uses ${dependencies.map((dep) => `\`${dep}\``).join(', ')}.`); + releases.push({ + title: `${name} ${version}`, + date, + body: blocks.join('\n\n'), + headlines: + changes.length > 0 ? changes.map((change) => plainHeadline(change.text)) : [`Uses ${dependencies.join(', ')}.`], + }); + } + return releases; +} + +/** plugins/radius/CHANGELOG.md: `## ()` sections of bullets. */ +export function parseSkills(markdown: string): Release[] { + return sections(markdown).map(([title, body]) => { + const match = title.match(/^(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$/); + if (!match) throw new Error(`plugins/radius/CHANGELOG.md: heading must be "## ()": ${title}`); + const items = listItems(body); + return { + title: `Radius skills ${match[1]}`, + date: match[2]!, + body: [ + '`claude plugin update radius@tools` · [Install the skills](/build/coding-assistants)', + items.map((item) => `- ${escapeMdx(emphasizeHeadline(item))}`).join('\n'), + ].join('\n\n'), + headlines: items.map(plainHeadline), + }; + }); +} + +/** Newest first; on the same day, the network, then the SDK, the CLI and the skills. */ +export function sortReleases(releases: Release[]): Release[] { + const rank = (release: Release) => + ['Radius Network', 'radius-sdk', 'radius-cli', 'Radius skills'].findIndex((prefix) => + release.title.startsWith(prefix), + ); + return [...releases].sort((a, b) => b.date.localeCompare(a.date) || rank(a) - rank(b)); +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +const PRODUCTS = ['Radius Network', 'radius-sdk', 'radius-cli', 'Radius skills'] as const; + +/** Meta description: the latest release and its first change, within 160 characters. */ +export function describe(releases: Release[]): string { + const latest = releases[0]; + if (!latest) return 'Radius release notes: the network, radius-sdk, radius-cli and the Radius skills.'; + const description = `Latest: ${latest.title} (${latest.date}). ${latest.headlines[0] ?? ''}`.trim(); + if (description.length <= 160) return description; + return `${description.slice(0, 157).replace(/\s+\S*$/, '')}...`; +} + +export function renderPage(releases: Release[]): string { + const latestRows = PRODUCTS.map((product) => { + const release = releases.find((candidate) => candidate.title.startsWith(product)); + if (!release) return undefined; + // The network has builds, not versions. + const version = release.title.slice(product.length).trim() || `build ${release.body.match(/`([0-9a-f]+)`/)?.[1]}`; + return `| ${product} | [${version}](#${anchor(heading(release))}) | ${release.date} |`; + }).filter(Boolean); + + return [ + '---', + 'title: Release notes', + `description: ${JSON.stringify(describe(releases))}`, + '---', + '', + '{/* Generated by scripts/generate-release-notes.ts. Edit release-notes/network.md or a changelog instead. */}', + '', + '# Release notes [What changed in the Radius Network, radius-sdk, radius-cli and the Radius skills]', + '', + 'Every release, newest first. Each heading is a link you can share. Follow new releases with the', + `[Atom feed](${FEED_URL}), or read this page as [Markdown](${SITE_BASE_URL}/release-notes.md).`, + '', + '| Product | Latest | Released |', + '| --- | --- | --- |', + ...latestRows, + '', + ...releases.flatMap((release) => [`## ${heading(release)}`, '', release.body, '']), + ].join('\n'); +} + +function escapeXml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export function renderFeed(releases: Release[]): string { + const updated = `${releases[0]?.date ?? '1970-01-01'}T00:00:00Z`; + const entries = releases.map((release) => { + const link = `${PAGE_URL}#${anchor(heading(release))}`; + const id = `tag:${new URL(SITE_BASE_URL).host},${release.date}:${release.title.replace(/ /g, '-')}`; + return [ + ' ', + ` ${escapeXml(release.title)}`, + ` `, + ` ${escapeXml(id)}`, + ` ${release.date}T00:00:00Z`, + ` ${escapeXml(release.headlines.join('\n'))}`, + ` ${escapeXml(release.body.replace(/\]\(\//g, `](${SITE_BASE_URL}/`))}`, + ' ', + ].join('\n'); + }); + return [ + '', + '', + ' Radius release notes', + ' The Radius Network, radius-sdk, radius-cli and the Radius skills.', + ` `, + ` `, + ` ${PAGE_URL}`, + ` ${updated}`, + ' Radius', + ...entries, + '', + '', + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +/** `@` tag -> YYYY-MM-DD it was created. */ +function readTagDates(): Map { + const output = execFileSync( + 'git', + ['for-each-ref', '--format=%(refname:short) %(creatordate:short)', 'refs/tags/radius-*'], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ); + return new Map( + output + .split('\n') + .filter(Boolean) + .map((line) => line.split(' ') as [string, string]), + ); +} + +export function collectReleases(read: (path: string) => string, tagDates: Map): Release[] { + const releases = [ + ...parseNetwork(read('packages/docs/release-notes/network.md')), + ...PACKAGES.flatMap((pkg) => { + const markdown = read(pkg.changelog); + const listed = parsePackage(pkg.name, markdown, tagDates); + if (listed.length === 0 && sections(markdown).length > 0) { + throw new Error( + `no ${pkg.name}@ git tags found: fetch tags (actions/checkout fetch-tags: true) so released versions are listed`, + ); + } + return listed; + }), + ...parseSkills(read('plugins/radius/CHANGELOG.md')), + ]; + return sortReleases(releases); +} + +function main() { + const releases = collectReleases((path) => readFileSync(join(REPO_ROOT, path), 'utf8'), readTagDates()); + const page = join(PROJECT_ROOT, 'docs/pages/release-notes.mdx'); + const feed = join(PROJECT_ROOT, 'public/release-notes.xml'); + mkdirSync(dirname(feed), { recursive: true }); + writeFileSync(page, renderPage(releases)); + writeFileSync(feed, renderFeed(releases)); + console.log(` ✓ ${releases.length} releases → docs/pages/release-notes.mdx, public/release-notes.xml`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/packages/docs/vocs.config.ts b/packages/docs/vocs.config.ts index c0cd6f9..24602f6 100644 --- a/packages/docs/vocs.config.ts +++ b/packages/docs/vocs.config.ts @@ -397,6 +397,15 @@ function headImpl(path: string, context: HeadContext, data: HeadData): HeadResul return { ...base, + // Feed autodiscovery on every page: readers and agents find release notes from any docs URL. + link: [ + { + rel: 'alternate', + type: 'application/atom+xml', + title: 'Radius release notes', + href: `${data.siteUrl}/release-notes.xml`, + }, + ], script: jsonLd.map((entry) => ({ type: 'application/ld+json', innerHTML: JSON.stringify(entry).replace(/ c.json({ message: 'Hello, world!' })); app.notFound((c) => c.json({ error: 'not found' }, 404)); -// Files written for agents. -const AGENT_FILE = /\.md$|^\/(?:llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|sitemap\.txt)$/; +// Files written for agents and feed readers. +const AGENT_FILE = /\.md$|^\/(?:llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|sitemap\.txt|release-notes\.xml)$/; // A page URL: no file extension in the last segment (`/`, `/build/`, `/build/x402`). const PAGE = /(?:^|\/)[^/.]*$/; diff --git a/plugins/radius/CHANGELOG.md b/plugins/radius/CHANGELOG.md new file mode 100644 index 0000000..0efe000 --- /dev/null +++ b/plugins/radius/CHANGELOG.md @@ -0,0 +1,53 @@ +# Radius plugin changelog + +The `radius` Claude Code plugin and its skills (`radius-dev`, `x402`, `sell-api`, `dripping-faucet`). +The version is `plugins/radius/.claude-plugin/plugin.json`. Every version bump adds an entry here, +newest first, as `## ()` with the date it reached `main`. The docs release +notes (docs.radiustech.xyz/release-notes) are built from this file: write each change's first +sentence for developers who use the skills. + +## 0.2.2 (2026-10-26) + +- The buyer install commands in the `x402` and `radius-dev` skills install both x402 peer packages, `@x402/core` and `@x402/evm`. `@x402/evm` is an optional peer that npm and pnpm skip, which left `radius-sdk/client` unable to load. The `sell-api` skill names `@x402/core` for sellers, and the plugin README says which peer each entry point needs. + +## 0.2.1 (2026-10-21) + +- The `sell-api` skill says Radius SBC offers use EIP-3009, not Permit2, and that MPP support is the `evm` charge method only. + +## 0.2.0 (2026-10-16) + +- **Breaking:** the plugin is now `radius` in the `tools` marketplace. Install it with `claude plugin marketplace add radiustechsystems/tools` and `claude plugin install radius@tools`. It replaces `radius-dev@radius-cli`; an existing install needs a one-time migration ([commands](https://docs.radiustech.xyz/build/coding-assistants#claude-code)). Skill names are unchanged, and slash commands take the `radius` prefix, for example `/radius:sell-api`. +- The plugin README shows how to install the skills in Codex with `npx skills add`. + +## 0.1.2 (2026-10-15) + +- The `sell-api` proxy snippet drops `Content-Encoding` and `Content-Length` when Node's `fetch` has already decoded a compressed origin response, and passes the request's `signal` so a client disconnect aborts the origin request. + +## 0.1.1 (2026-10-15) + +- The `sell-api` skill covers a Hono proxy on Node in front of an origin (with a Dockerfile), checks that free routes stay free, and ends with a minimum verify checklist. Its gotchas add the `duplex` option a stream body needs on Node, and the immutable-headers error a raw `fetch()` response hit after settlement under `@hono/node-server`. + +## 0.1.0 (2026-10-15) + +- New `sell-api` skill: charge for an API with `radius-sdk`. It detects the framework, installs and configures the SDK, runs the server locally, verifies the `402` with `radius-cli sell check`, and hands off deployment, with references for frameworks, protocols, networks and facilitators, and settlement. +- The `x402` skill covers buying and the protocol, and points sellers to `sell-api`. + +## 0.0.6 (2026-10-14) + +- Links and install commands point at the renamed repository, github.com/radiustechsystems/tools. + +## 0.0.5 (2026-10-13) + +- The `radius-dev` skill requires Node.js 22 or later for `radius-sdk`. + +## 0.0.4 (2026-10-13) + +- The skills use `radius-cli wallet pay` and `--threshold`, the new names for `wallet x402` and `--x402-threshold` in radius-cli 0.4.0. + +## 0.0.3 (2026-10-07) + +- The `radius-dev` and `x402` skills build with `radius-sdk` (`radius-sdk/client` for buyers, `radius-sdk/hono` for sellers) and `radius-cli`, with viem for other EVM work, instead of hand-written viem payment code. + +## 0.0.2 (2026-10-02) + +- The `radius-dev`, `x402` and `dripping-faucet` skills move into this repository as the `radius-dev` Claude Code plugin, with eval cases that run in CI. diff --git a/scripts/validate_plugin.py b/scripts/validate_plugin.py index 6afe953..9aa514d 100644 --- a/scripts/validate_plugin.py +++ b/scripts/validate_plugin.py @@ -45,6 +45,23 @@ def check_release_version(base_ref, current_version): ) +def check_changelog(current_version): + # The docs release notes are built from this file, so every released version needs an entry. + changelog = PLUGIN / "CHANGELOG.md" + assert changelog.is_file(), "plugin CHANGELOG.md missing" + content = changelog.read_text(encoding="utf-8") + entries = re.findall(r"^## (\S+) \((\d{4}-\d{2}-\d{2})\)$", content, re.MULTILINE) + headings = re.findall(r"^## .*$", content, re.MULTILINE) + assert len(entries) == len(headings), "plugin CHANGELOG.md headings must read `## ()`" + versions = [version_tuple(version) for version, _ in entries] + assert versions == sorted(set(versions), reverse=True), "plugin CHANGELOG.md versions must be unique, newest first" + dates = [date for _, date in entries] + assert dates == sorted(dates, reverse=True), "plugin CHANGELOG.md dates must be newest first" + assert current_version in [version for version, _ in entries], ( + f"plugin {current_version} has no entry in plugins/radius/CHANGELOG.md" + ) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base-ref", help="require a plugin version bump for release content changed since this ref") @@ -61,6 +78,7 @@ def main(): version_tuple(manifest["version"]) assert (PLUGIN / "README.md").is_file(), "plugin README missing" assert manifest["license"] == "MIT" + check_changelog(manifest["version"]) if args.base_ref: check_release_version(args.base_ref, manifest["version"])