From c40d663b70e9c9e3d3d7be5888b74507f11056d6 Mon Sep 17 00:00:00 2001 From: JATMN Date: Tue, 11 Aug 2026 17:26:03 -0700 Subject: [PATCH] fix(web): link release notes to GitHub (#2114) * fix(web): link release notes to GitHub * test(web): verify release link safety contract --- AGENTS.md | 4 +- CONTRIBUTING.md | 5 +- web/astro.config.mjs | 3 + web/scripts/verify-dist.test.ts | 142 +++++++++----------------- web/scripts/verify-dist.ts | 68 ++++--------- web/src/components/DocsSidebar.astro | 2 +- web/src/components/Footer.astro | 5 +- web/src/components/Nav.astro | 6 +- web/src/data/docsNav.ts | 7 +- web/src/data/releases.ts | 145 --------------------------- web/src/data/site.ts | 4 +- web/src/pages/changelog.astro | 144 -------------------------- web/src/pages/docs/index.astro | 2 +- web/src/pages/index.astro | 3 +- 14 files changed, 89 insertions(+), 451 deletions(-) delete mode 100644 web/src/data/releases.ts delete mode 100644 web/src/pages/changelog.astro diff --git a/AGENTS.md b/AGENTS.md index ddb3fab85..21a0bbdb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ bun run web:typecheck bun run web:build ``` -Do not “fix” a web CI npm-freshness failure by editing `web/src/data/releases.ts` in an unrelated PR. That file is release/web-owned; leave version drift for the dedicated release/web path. +Website release notes live on GitHub Releases; do not add a manually maintained release-notes data source to the static site. Diagnostics and PR hygiene: @@ -101,4 +101,4 @@ When modifying provider behavior: - Do not skip tests for behavior changes. - Do not silently change provider tags; maintainers control them during review. - Do not ignore CodeRabbit or maintainer feedback; address it before requesting more review. -- Do not edit `web/src/data/releases.ts` in ordinary feature or bugfix PRs. That curated `/changelog` list is owned by the release/web process (release automation and dedicated web release PRs). If web CI fails because npm is ahead of the site version, leave `releases.ts` alone in your unrelated PR and let the release/web path update it. +- Do not add a manually maintained release-notes data source to the static site; link to GitHub Releases instead. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97918ba06..d64c3aa1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,6 @@ PRs may be closed without review if they: - add features, refactors, or dependency changes that were not discussed first - drift from the approved scope of a linked issue - change the project's language, core runtime, or dependency stack without prior maintainer agreement -- edit `web/src/data/releases.ts` as a drive-by fix for web CI version drift in an unrelated feature or bugfix PR — that curated changelog is owned by the release/web process - are drive-by contributions with no context, no tests, and no clear purpose - are automated bounty-hunting or mass-submitted PRs that provide little meaningful value to the codebase - are advertisements, sales pitches, or promotional submissions for a product or service — open an issue first to discuss with maintainers if you believe your product or service is relevant to this project @@ -134,7 +133,7 @@ Dependency changes need a clear project benefit — fixing a bug, addressing a s - Preserve existing repo patterns unless the change is intentionally refactoring them. - Add or update tests when the change affects behavior. - Update docs when setup, commands, or user-facing behavior changes. -- Do not edit `web/src/data/releases.ts` unless you are on an explicit release/web changelog PR. Release automation and dedicated web release updates own that file. Unrelated PRs must not patch it to silence web CI when npm publishes ahead of the site changelog. +- Website release notes live on GitHub Releases. Do not add manually maintained release-note data to the static site. AI-assisted and vibe-coded contributions are welcome, but please review your own changes thoroughly before opening a PR. Even frontier models produce subtle bugs, incorrect assumptions, and code that looks right but isn't. @@ -152,7 +151,7 @@ Self-review up front saves everyone time and reduces back-and-forth during maint If you are an AI agent (Copilot, Cursor, Claude, etc.) working on this codebase, refer to [AGENTS.md](AGENTS.md) for project-specific coding guidelines, conventions, and validation commands. Following these guidelines will help your contributions align with the project's patterns and reduce review friction. -In particular: do not edit `web/src/data/releases.ts` from ordinary feature or bugfix work. Treat web CI failures about npm being ahead of the site version as out of scope unless you are on an explicit release/web changelog PR. +In particular: link release-notes navigation to GitHub Releases rather than adding a local changelog page or manually maintained release data. ## Code Style diff --git a/web/astro.config.mjs b/web/astro.config.mjs index c6c96a957..caf404bad 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -4,5 +4,8 @@ import sitemap from '@astrojs/sitemap' export default defineConfig({ site: 'https://openclaude.gitlawb.com', trailingSlash: 'always', + redirects: { + '/changelog/': 'https://github.com/Gitlawb/openclaude/releases', + }, integrations: [sitemap()], }) diff --git a/web/scripts/verify-dist.test.ts b/web/scripts/verify-dist.test.ts index e83455966..c7168172e 100644 --- a/web/scripts/verify-dist.test.ts +++ b/web/scripts/verify-dist.test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { verifyDist, npmFreshnessFailure } from './verify-dist' +import { verifyDist } from './verify-dist' import { SITE } from '../src/data/site' import { docsPages } from '../src/data/docsNav' -import { releases, releaseUrl } from '../src/data/releases' import { heroes } from '../src/data/buddy' import { partners, community } from '../src/data/partners' @@ -18,23 +17,17 @@ function writePage(dist: string, route: string, html: string): void { /** Build a minimal dist/ that satisfies every verifyDist assertion. */ function writeValidFixture(dist: string): void { - const navLinks = ['/buddy/', '/changelog/'].map(h => `x`).join('') + const navLinks = [`x`, `x`].join('') writePage( dist, '/', - `v${SITE.version}${navLinks}${partners + `${navLinks}${partners .map(p => ``) .join('')}${community.map(c => `x`).join('')}`, ) - const sidebar = docsPages.map(p => `x`).join('') + const sidebar = `${docsPages.map(p => `x`).join('')}x` for (const p of docsPages) writePage(dist, p.href, `${sidebar}`) - writePage( - dist, - '/changelog/', - `v${SITE.version}${releases - .map(r => `v${r.version}`) - .join('')}`, - ) + writePage(dist, '/changelog/', `${SITE.releasesUrl}`) writePage( dist, '/buddy/', @@ -44,7 +37,7 @@ function writeValidFixture(dist: string): void { for (const h of heroes) writeFileSync(join(dist, 'buddy', `${h.id}.svg`), '') writeFileSync( join(dist, 'sitemap-0.xml'), - `${['/buddy/', '/changelog/'].map(r => `${SITE.url}${r}`).join('')}`, + `${SITE.url}/buddy/`, ) } @@ -59,35 +52,6 @@ function withFixture(mutate: (dist: string) => void): string[] { } } -describe('release data', () => { - test('keeps releases newest first', () => { - const versions = releases.map(release => release.version.split('.').map(Number)) - const sorted = [...versions].sort((a, b) => { - for (let i = 0; i < 3; i++) { - if (a[i] !== b[i]) return (b[i] ?? 0) - (a[i] ?? 0) - } - return 0 - }) - - expect(versions).toEqual(sorted) - }) - - test('lists the 0.27.0 release with its curated highlights', () => { - expect(releases.find(release => release.version === '0.27.0')).toEqual({ - version: '0.27.0', - date: '2026-07-30', - theme: 'auth-ready local proxies and a refreshed web identity', - highlights: [ - 'opt-in loopback proxy hosts preserve subscription OAuth authentication', - 'new Ling 3.0 Flash and Macaron V1 Tall catalog entries', - 'centered startup logo and updated Ember Block O web branding', - 'agents can spawn subagents from multi-repository parent sessions', - 'more reliable tool-failure guard, SDK permission-timeout reporting, stats, and status UI', - ], - }) - }) -}) - describe('verifyDist', () => { test('passes on a complete fixture', () => { expect(withFixture(() => {})).toEqual([]) @@ -107,13 +71,50 @@ describe('verifyDist', () => { test('flags a docs sidebar that lost a navigation link', () => { const failures = withFixture(dist => { - const sidebar = docsPages - .filter(p => p.href !== '/changelog/') - .map(p => `x`) - .join('') + const sidebar = docsPages.map(p => `x`).join('') writePage(dist, '/docs/', `${sidebar}`) }) - expect(failures).toContain('docs sidebar link /changelog/: missing "href=\\"/changelog/\\""') + expect(failures).toContain('docs release notes link: missing safe new-tab release link') + }) + + test('flags a landing release link that lost target=_blank', () => { + const failures = withFixture(dist => { + const index = join(dist, 'index.html') + writeFileSync(index, readFileSync(index, 'utf8').replace(' target="_blank" rel="noopener"', ' rel="noopener"')) + }) + expect(failures).toContain('landing release notes link: missing safe new-tab release link') + }) + + test('flags a docs release link that lost rel=noopener', () => { + const failures = withFixture(dist => { + const docs = join(dist, 'docs', 'index.html') + writeFileSync(docs, readFileSync(docs, 'utf8').replace(' target="_blank" rel="noopener"', ' target="_blank"')) + }) + expect(failures).toContain('docs release notes link: missing safe new-tab release link') + }) + + test('flags an unsafe release link even when another release link is safe', () => { + const failures = withFixture(dist => { + const index = join(dist, 'index.html') + writeFileSync(index, `${readFileSync(index, 'utf8')}x`) + }) + expect(failures).toContain('landing release notes link: missing safe new-tab release link') + }) + + test('flags a legacy changelog page that lost its redirect', () => { + const failures = withFixture(dist => { + const changelog = join(dist, 'changelog', 'index.html') + writeFileSync(changelog, readFileSync(changelog, 'utf8').replace(``, '')) + }) + expect(failures).toContain(`legacy changelog redirect: missing ""`) + }) + + test('flags a legacy changelog redirect that lost noindex', () => { + const failures = withFixture(dist => { + const changelog = join(dist, 'changelog', 'index.html') + writeFileSync(changelog, readFileSync(changelog, 'utf8').replace('', '')) + }) + expect(failures).toContain('legacy changelog noindex: missing ""') }) test('flags a missing sprite asset', () => { @@ -124,7 +125,7 @@ describe('verifyDist', () => { test('flags a stale landing page missing a partner link', () => { const failures = withFixture(dist => { - const html = `v${SITE.version}xx${community + const html = `xx${community .map(c => `x`) .join('')}` writeFileSync(join(dist, 'index.html'), html) @@ -137,7 +138,6 @@ describe('verifyDist', () => { writeFileSync(join(dist, 'sitemap-0.xml'), `${SITE.url}/`), ) expect(failures.some(f => f.startsWith('sitemap entry /buddy/'))).toBe(true) - expect(failures.some(f => f.startsWith('sitemap entry /changelog/'))).toBe(true) }) test('flags a missing sitemap', () => { @@ -145,46 +145,4 @@ describe('verifyDist', () => { expect(failures).toContain('missing dist/sitemap-0.xml') }) - test('flags a changelog entry that lost its release URL', () => { - const failures = withFixture(dist => { - const html = `v${SITE.version}${releases.map(r => `v${r.version}`).join(' ')}` - writeFileSync(join(dist, 'changelog', 'index.html'), html) - }) - expect(failures.some(f => f.startsWith('changelog release URL'))).toBe(true) - }) -}) - -describe('npmFreshnessFailure', () => { - function fetchReturning(body: unknown, ok = true): typeof fetch { - return (() => - Promise.resolve({ ok, json: () => Promise.resolve(body) } as Response)) as typeof fetch - } - - test('passes when npm matches the site version', async () => { - expect(await npmFreshnessFailure(fetchReturning({ version: SITE.version }))).toBeNull() - }) - - test('passes when the site is ahead of npm (release PR before publish)', async () => { - expect(await npmFreshnessFailure(fetchReturning({ version: '0.1.0' }))).toBeNull() - }) - - test('fails when npm has a newer release than releases.ts', async () => { - const failure = await npmFreshnessFailure(fetchReturning({ version: '999.0.0' })) - expect(failure).toContain('999.0.0') - expect(failure).toContain('web/src/data/releases.ts') - expect(failure).toContain('do not patch it from unrelated PRs') - }) - - test('skips on network failure instead of breaking the build', async () => { - const offline = (() => Promise.reject(new Error('offline'))) as typeof fetch - expect(await npmFreshnessFailure(offline)).toBeNull() - }) - - test('skips on a malformed registry response', async () => { - expect(await npmFreshnessFailure(fetchReturning({}))).toBeNull() - expect(await npmFreshnessFailure(fetchReturning({ version: 'not-semver' }))).toBeNull() - expect(await npmFreshnessFailure(fetchReturning({ version: '01.2.3' }))).toBeNull() - expect(await npmFreshnessFailure(fetchReturning({ version: '999.00.0' }))).toBeNull() - expect(await npmFreshnessFailure(fetchReturning({}, false))).toBeNull() - }) }) diff --git a/web/scripts/verify-dist.ts b/web/scripts/verify-dist.ts index 3fff411e5..93e175df1 100644 --- a/web/scripts/verify-dist.ts +++ b/web/scripts/verify-dist.ts @@ -1,13 +1,12 @@ // Post-build guard, run by `bun run build` after `astro build`. // Asserts that the typed data files actually drive the rendered output: -// version propagation, navigation, and the /, /buddy/, /changelog/ routes. +// navigation and the / and /buddy/ routes. // Kept as a pure function so verify-dist.test.ts can exercise it on fixtures. import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { SITE } from '../src/data/site' import { docsPages } from '../src/data/docsNav' -import { releases, releaseUrl } from '../src/data/releases' import { heroes } from '../src/data/buddy' import { partners, community } from '../src/data/partners' @@ -34,9 +33,13 @@ export function verifyDist(dist: string): string[] { if (html !== '' && !html.includes(needle)) failures.push(`${why}: missing ${JSON.stringify(needle)}`) } - // ── version propagation (site.ts derives from the newest releases entry) ─ + function expectReleaseLinks(html: string, why: string): void { + const releaseLinks = (html.match(/]*>/g) ?? []).filter(link => link.includes(`href="${SITE.releasesUrl}"`)) + if (html !== '' && (releaseLinks.length === 0 || releaseLinks.some(link => !link.includes('target="_blank"') || !link.includes('rel="noopener"')))) + failures.push(`${why}: missing safe new-tab release link`) + } + const index = page('/') - expect(index, `v${SITE.version}`, 'landing version') // ── navigation exposes every docsNav route, in data AND rendered output ── const docsIndex = page('/docs/') @@ -44,18 +47,16 @@ export function verifyDist(dist: string): string[] { page(p.href) // records a failure if the route didn't build expect(docsIndex, `href="${p.href}"`, `docs sidebar link ${p.href}`) } - for (const href of ['/buddy/', '/changelog/'] as const) { - if (!docsPages.some(p => p.href === href)) failures.push(`docsNav missing ${href}`) - expect(index, `href="${href}"`, `landing nav link ${href}`) - } + if (!docsPages.some(p => p.href === '/buddy/')) failures.push('docsNav missing /buddy/') + expect(index, 'href="/buddy/"', 'landing nav link /buddy/') + expectReleaseLinks(index, 'landing release notes link') + expectReleaseLinks(docsIndex, 'docs release notes link') - // ── /changelog/: every release renders with its GitHub release URL ─────── - const changelog = page('/changelog/') - for (const r of releases) { - expect(changelog, `v${r.version}`, `changelog release ${r.version}`) - expect(changelog, releaseUrl(r.version), `changelog release URL ${r.version}`) - } - expect(changelog, `v${SITE.version}`, 'changelog current-version pill') + // ── legacy route continues on the canonical GitHub Releases page ───────── + const legacyChangelog = page('/changelog/') + expect(legacyChangelog, SITE.releasesUrl, 'legacy changelog redirect') + expect(legacyChangelog, ``, 'legacy changelog redirect') + expect(legacyChangelog, '', 'legacy changelog noindex') // ── /buddy/: every hero renders with its sprite ────────────────────────── const buddy = page('/buddy/') @@ -77,7 +78,7 @@ export function verifyDist(dist: string): string[] { const sitemapFile = join(dist, 'sitemap-0.xml') if (existsSync(sitemapFile)) { const sitemap = readFileSync(sitemapFile, 'utf8') - for (const route of ['/buddy/', '/changelog/']) + for (const route of ['/buddy/']) expect(sitemap, `${SITE.url}${route}`, `sitemap entry ${route}`) } else { failures.push('missing dist/sitemap-0.xml') @@ -86,45 +87,12 @@ export function verifyDist(dist: string): string[] { return [...new Set(failures)] } -function newerThan(a: string, b: string): boolean { - const pa = a.split('.').map(Number) - const pb = b.split('.').map(Number) - for (let i = 0; i < 3; i++) { - if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0) - } - return false -} - -// With web/ standalone, releases.ts is the only version source — this is the -// guard against it silently going stale. Best-effort by design: an unreachable -// registry (offline CI, npm outage) skips the check rather than failing the -// build; only a *confirmed newer* npm release fails. A site version ahead of -// npm is allowed so a release PR can land before the publish completes. -export async function npmFreshnessFailure(fetchImpl: typeof fetch = fetch): Promise { - let published: string - try { - const res = await fetchImpl('https://registry.npmjs.org/@gitlawb/openclaude/latest', { - signal: AbortSignal.timeout(5000), - }) - if (!res.ok) return null - published = ((await res.json()) as { version?: string }).version ?? '' - } catch { - return null - } - if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(published)) return null - if (newerThan(published, SITE.version)) - return `npm latest ${published} is newer than site version ${SITE.version} — releases.ts is stale; do not patch it from unrelated PRs (release/web process owns web/src/data/releases.ts)` - return null -} - if (import.meta.main) { const failures = verifyDist(join(import.meta.dir, '..', 'dist')) - const stale = await npmFreshnessFailure() - if (stale) failures.push(stale) if (failures.length > 0) { console.error(`verify-dist: ${failures.length} failure(s)`) for (const f of failures) console.error(` ✗ ${f}`) process.exit(1) } - console.log('verify-dist: ok — version, nav, buddy, changelog, partners all verified') + console.log('verify-dist: ok — navigation, buddy, and partners verified') } diff --git a/web/src/components/DocsSidebar.astro b/web/src/components/DocsSidebar.astro index 630c49dff..e5f610dd3 100644 --- a/web/src/components/DocsSidebar.astro +++ b/web/src/components/DocsSidebar.astro @@ -12,7 +12,7 @@ const path = Astro.url.pathname
    {group.items.map(item => (
  • - + {item.title}
  • diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index cde314fbd..0d2c7ebee 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -24,7 +24,7 @@ const columns = [ { title: 'project', links: [ - { href: '/changelog/', label: "what's new" }, + { href: SITE.releasesUrl, label: "what's new", newTab: true }, { href: '/buddy/', label: 'buddy' }, { href: SITE.github, label: 'github' }, { href: SITE.npmUrl, label: 'npm' }, @@ -54,7 +54,7 @@ const columns = [ @@ -65,7 +65,6 @@ const columns = [ openclaude - v{SITE.version} · open source coding agent, aligned with gitlawb diff --git a/web/src/components/Nav.astro b/web/src/components/Nav.astro index f8b274703..0184e67cf 100644 --- a/web/src/components/Nav.astro +++ b/web/src/components/Nav.astro @@ -5,7 +5,7 @@ const links = [ { href: '/docs/', label: 'docs' }, { href: '/docs/providers/', label: 'providers' }, { href: '/buddy/', label: 'buddy' }, - { href: '/changelog/', label: "what's new" }, + { href: SITE.releasesUrl, label: "what's new", newTab: true }, { href: SITE.github, label: 'github' }, { href: SITE.gitlawb, label: 'gitlawb' }, ] @@ -18,7 +18,6 @@ const path = Astro.url.pathname openclaude - v{SITE.version}
diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 25817e53d..25a377398 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -57,7 +57,6 @@ const jsonLd = [ name: 'openclaude', applicationCategory: 'DeveloperApplication', operatingSystem: 'macOS, Linux, Windows', - softwareVersion: SITE.version, url: SITE.url, downloadUrl: SITE.npmUrl, description: SITE.description, @@ -102,7 +101,7 @@ const jsonLd = [ $ openclaude - openclaude v{SITE.version} · provider: ollama · model: qwen3-coder + openclaude · provider: ollama · model: qwen3-coder   > add retry with backoff to the fetch client