Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions web/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
})
142 changes: 50 additions & 92 deletions web/scripts/verify-dist.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 => `<a href="${h}">x</a>`).join('')
const navLinks = [`<a href="/buddy/">x</a>`, `<a href="${SITE.releasesUrl}" target="_blank" rel="noopener">x</a>`].join('')
writePage(
dist,
'/',
`<html>v${SITE.version}${navLinks}${partners
`<html>${navLinks}${partners
.map(p => `<a href="${p.url}"><img src="${p.logo}"></a>`)
.join('')}${community.map(c => `<a href="${c.url}">x</a>`).join('')}</html>`,
)
const sidebar = docsPages.map(p => `<a href="${p.href}">x</a>`).join('')
const sidebar = `${docsPages.map(p => `<a href="${p.href}">x</a>`).join('')}<a href="${SITE.releasesUrl}" target="_blank" rel="noopener">x</a>`
for (const p of docsPages) writePage(dist, p.href, `<html>${sidebar}</html>`)
writePage(
dist,
'/changelog/',
`<html>v${SITE.version}${releases
.map(r => `<a href="${releaseUrl(r.version)}">v${r.version}</a>`)
.join('')}</html>`,
)
writePage(dist, '/changelog/', `<html><meta http-equiv="refresh" content="0;url=${SITE.releasesUrl}"><meta name="robots" content="noindex">${SITE.releasesUrl}</html>`)
writePage(
dist,
'/buddy/',
Expand All @@ -44,7 +37,7 @@ function writeValidFixture(dist: string): void {
for (const h of heroes) writeFileSync(join(dist, 'buddy', `${h.id}.svg`), '<svg/>')
writeFileSync(
join(dist, 'sitemap-0.xml'),
`<urlset>${['/buddy/', '/changelog/'].map(r => `<loc>${SITE.url}${r}</loc>`).join('')}</urlset>`,
`<urlset><loc>${SITE.url}/buddy/</loc></urlset>`,
)
}

Expand All @@ -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([])
Expand All @@ -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 => `<a href="${p.href}">x</a>`)
.join('')
const sidebar = docsPages.map(p => `<a href="${p.href}">x</a>`).join('')
writePage(dist, '/docs/', `<html>${sidebar}</html>`)
})
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')}<a href="${SITE.releasesUrl}" target="_blank">x</a>`)
})
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(`<meta http-equiv="refresh" content="0;url=${SITE.releasesUrl}">`, ''))
})
expect(failures).toContain(`legacy changelog redirect: missing "<meta http-equiv=\\"refresh\\" content=\\"0;url=${SITE.releasesUrl}\\">"`)
})

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('<meta name="robots" content="noindex">', ''))
})
expect(failures).toContain('legacy changelog noindex: missing "<meta name=\\"robots\\" content=\\"noindex\\">"')
})

test('flags a missing sprite asset', () => {
Expand All @@ -124,7 +125,7 @@ describe('verifyDist', () => {

test('flags a stale landing page missing a partner link', () => {
const failures = withFixture(dist => {
const html = `<html>v${SITE.version}<a href="/buddy/">x</a><a href="/changelog/">x</a>${community
const html = `<html><a href="/buddy/">x</a><a href="${SITE.releasesUrl}" target="_blank">x</a>${community
.map(c => `<a href="${c.url}">x</a>`)
.join('')}</html>`
writeFileSync(join(dist, 'index.html'), html)
Expand All @@ -137,54 +138,11 @@ describe('verifyDist', () => {
writeFileSync(join(dist, 'sitemap-0.xml'), `<urlset><loc>${SITE.url}/</loc></urlset>`),
)
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', () => {
const failures = withFixture(dist => rmSync(join(dist, 'sitemap-0.xml')))
expect(failures).toContain('missing dist/sitemap-0.xml')
})

test('flags a changelog entry that lost its release URL', () => {
const failures = withFixture(dist => {
const html = `<html>v${SITE.version}${releases.map(r => `v${r.version}`).join(' ')}</html>`
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()
})
})
68 changes: 18 additions & 50 deletions web/scripts/verify-dist.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -34,28 +33,30 @@ 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(/<a\b[^>]*>/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/')
for (const p of docsPages) {
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, `<meta http-equiv="refresh" content="0;url=${SITE.releasesUrl}">`, 'legacy changelog redirect')
expect(legacyChangelog, '<meta name="robots" content="noindex">', 'legacy changelog noindex')

// ── /buddy/: every hero renders with its sprite ──────────────────────────
const buddy = page('/buddy/')
Expand All @@ -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')
Expand All @@ -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<string | null> {
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')
}
2 changes: 1 addition & 1 deletion web/src/components/DocsSidebar.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const path = Astro.url.pathname
<ul>
{group.items.map(item => (
<li>
<a href={item.href} aria-current={path === item.href ? 'page' : undefined}>
<a href={item.href} aria-current={path === item.href ? 'page' : undefined} rel={item.newTab ? 'noopener' : undefined} target={item.newTab ? '_blank' : undefined}>
{item.title}
</a>
</li>
Expand Down
Loading
Loading