feat(changelog): serve /changelog from a standalone worker (#54906)

This commit is contained in:
Dax
2026-10-12 14:56:09 -04:00
committed by GitHub
Unverified
parent c57c448d31
commit 22326d4bc3
49 changed files with 1580 additions and 1259 deletions
+40
View File
@@ -0,0 +1,40 @@
name: deploy-changelog
on:
push:
branches:
- dev
paths:
- services/changelog/**
- .github/workflows/deploy-changelog.yml
workflow_dispatch:
concurrency:
group: deploy-changelog
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Typecheck
working-directory: services/changelog
run: bun typecheck
- name: Test
working-directory: services/changelog
run: bun test
- name: Deploy
working-directory: services/changelog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+362 -816
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -20,7 +20,6 @@
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"aws4fetch": "1.0.20",
"marked": "catalog:",
"nitro": "3.0.1-alpha.1",
"number-flow": "0.6.2",
"solid-js": "catalog:",
-4
View File
@@ -853,10 +853,6 @@ export const dict = {
"changelog.hero.subtitle": "تحديثات وتحسينات جديدة لـ OpenCode",
"changelog.empty": "لم يتم العثور على مدخلات في سجل التغييرات.",
"changelog.viewJson": "عرض JSON",
"changelog.highlights": "أبرز التغييرات",
"changelog.error": "تعذر تحميل سجل التغييرات حاليًا.",
"changelog.viewOnGithub": "عرضه على GitHub",
"changelog.v1": "ملاحظات إصدارات OpenCode 1 موجودة في GitHub Releases",
"bench.list.title": "المعيار",
"bench.list.heading": "المعايير",
-4
View File
@@ -868,10 +868,6 @@ export const dict = {
"changelog.hero.subtitle": "Novas atualizações e melhorias no OpenCode",
"changelog.empty": "Nenhuma entrada de changelog encontrada.",
"changelog.viewJson": "Ver JSON",
"changelog.highlights": "Destaques",
"changelog.error": "Não foi possível carregar o changelog agora.",
"changelog.viewOnGithub": "Ver no GitHub",
"changelog.v1": "As notas de versão do OpenCode 1 estão no GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -861,10 +861,6 @@ export const dict = {
"changelog.hero.subtitle": "Nye opdateringer og forbedringer til OpenCode",
"changelog.empty": "Ingen changelog-indlæg fundet.",
"changelog.viewJson": "Se JSON",
"changelog.highlights": "Højdepunkter",
"changelog.error": "Changeloggen kunne ikke indlæses lige nu.",
"changelog.viewOnGithub": "Se den på GitHub",
"changelog.v1": "Udgivelsesnoterne for OpenCode 1 findes på GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -868,10 +868,6 @@ export const dict = {
"changelog.hero.subtitle": "Neue Updates und Verbesserungen für OpenCode",
"changelog.empty": "Keine Changelog-Einträge gefunden.",
"changelog.viewJson": "JSON ansehen",
"changelog.highlights": "Highlights",
"changelog.error": "Der Changelog konnte gerade nicht geladen werden.",
"changelog.viewOnGithub": "Auf GitHub ansehen",
"changelog.v1": "Die Release Notes für OpenCode 1 findest du auf GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -866,10 +866,6 @@ export const dict = {
"changelog.hero.subtitle": "New updates and improvements to OpenCode",
"changelog.empty": "No changelog entries found.",
"changelog.viewJson": "View JSON",
"changelog.highlights": "Highlights",
"changelog.error": "The changelog could not be loaded right now.",
"changelog.viewOnGithub": "View it on GitHub",
"changelog.v1": "OpenCode 1 release notes are on GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -868,10 +868,6 @@ export const dict = {
"changelog.hero.subtitle": "Nuevas actualizaciones y mejoras para OpenCode",
"changelog.empty": "No se encontraron entradas en el registro de cambios.",
"changelog.viewJson": "Ver JSON",
"changelog.highlights": "Destacados",
"changelog.error": "No se pudo cargar el registro de cambios en este momento.",
"changelog.viewOnGithub": "Verlo en GitHub",
"changelog.v1": "Las notas de la versión de OpenCode 1 están en GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -877,10 +877,6 @@ export const dict = {
"changelog.hero.subtitle": "Nouvelles mises à jour et améliorations pour OpenCode",
"changelog.empty": "Aucune entrée de changelog trouvée.",
"changelog.viewJson": "Voir le JSON",
"changelog.highlights": "Points forts",
"changelog.error": "Impossible de charger le journal des modifications pour le moment.",
"changelog.viewOnGithub": "Le consulter sur GitHub",
"changelog.v1": "Les notes de version d’OpenCode 1 sont sur GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -865,10 +865,6 @@ export const dict = {
"changelog.hero.subtitle": "Nuovi aggiornamenti e miglioramenti a OpenCode",
"changelog.empty": "Nessuna voce del changelog trovata.",
"changelog.viewJson": "Vedi JSON",
"changelog.highlights": "In evidenza",
"changelog.error": "Al momento non è possibile caricare il changelog.",
"changelog.viewOnGithub": "Visualizzalo su GitHub",
"changelog.v1": "Le note di rilascio di OpenCode 1 sono su GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmark",
-4
View File
@@ -863,10 +863,6 @@ export const dict = {
"changelog.hero.subtitle": "OpenCodeの新しいアップデートと改善",
"changelog.empty": "変更履歴が見つかりませんでした。",
"changelog.viewJson": "JSONを表示",
"changelog.highlights": "ハイライト",
"changelog.error": "現在、変更履歴を読み込めません。",
"changelog.viewOnGithub": "GitHub で見る",
"changelog.v1": "OpenCode 1 のリリースノートは GitHub Releases にあります",
"bench.list.title": "ベンチマーク",
"bench.list.heading": "ベンチマーク",
-4
View File
@@ -853,10 +853,6 @@ export const dict = {
"changelog.hero.subtitle": "OpenCode의 새로운 업데이트 및 개선 사항",
"changelog.empty": "변경 내역 항목을 찾을 수 없습니다.",
"changelog.viewJson": "JSON 보기",
"changelog.highlights": "하이라이트",
"changelog.error": "지금은 변경 로그를 불러올 수 없습니다.",
"changelog.viewOnGithub": "GitHub에서 보기",
"changelog.v1": "OpenCode 1 릴리스 노트는 GitHub Releases에 있습니다",
"bench.list.title": "벤치마크",
"bench.list.heading": "벤치마크",
-4
View File
@@ -862,10 +862,6 @@ export const dict = {
"changelog.hero.subtitle": "Nye oppdateringer og forbedringer for OpenCode",
"changelog.empty": "Ingen endringsloggoppføringer funnet.",
"changelog.viewJson": "Vis JSON",
"changelog.highlights": "Høydepunkter",
"changelog.error": "Endringsloggen kunne ikke lastes inn akkurat nå.",
"changelog.viewOnGithub": "Se den på GitHub",
"changelog.v1": "Utgivelsesnotatene for OpenCode 1 finnes på GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -865,10 +865,6 @@ export const dict = {
"changelog.hero.subtitle": "Nowe aktualizacje i ulepszenia OpenCode",
"changelog.empty": "Nie znaleziono wpisów w dzienniku zmian.",
"changelog.viewJson": "Zobacz JSON",
"changelog.highlights": "Najważniejsze",
"changelog.error": "Nie udało się teraz wczytać listy zmian.",
"changelog.viewOnGithub": "Zobacz w serwisie GitHub",
"changelog.v1": "Informacje o wydaniach OpenCode 1 znajdziesz w GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarki",
-4
View File
@@ -873,10 +873,6 @@ export const dict = {
"changelog.hero.subtitle": "Новые обновления и улучшения OpenCode",
"changelog.empty": "Записи в списке изменений не найдены.",
"changelog.viewJson": "Посмотреть JSON",
"changelog.highlights": "Главное",
"changelog.error": "Сейчас не удалось загрузить список изменений.",
"changelog.viewOnGithub": "Посмотреть на GitHub",
"changelog.v1": "Примечания к выпускам OpenCode 1 находятся в GitHub Releases",
"bench.list.title": "Бенчмарк",
"bench.list.heading": "Бенчмарки",
-4
View File
@@ -858,10 +858,6 @@ export const dict = {
"changelog.hero.subtitle": "การอัปเดตและการปรับปรุงใหม่ใน OpenCode",
"changelog.empty": "ไม่พบรายการบันทึกการเปลี่ยนแปลง",
"changelog.viewJson": "ดู JSON",
"changelog.highlights": "ไฮไลต์",
"changelog.error": "ไม่สามารถโหลดบันทึกการเปลี่ยนแปลงได้ในขณะนี้",
"changelog.viewOnGithub": "ดูบน GitHub",
"changelog.v1": "บันทึกการเผยแพร่ของ OpenCode 1 อยู่ที่ GitHub Releases",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarks",
-4
View File
@@ -868,10 +868,6 @@ export const dict = {
"changelog.hero.subtitle": "OpenCode için yeni güncellemeler ve iyileştirmeler",
"changelog.empty": "Değişiklik günlüğü kaydı bulunamadı.",
"changelog.viewJson": "JSON'u görüntüle",
"changelog.highlights": "Öne çıkanlar",
"changelog.error": "Değişiklik günlüğü şu anda yüklenemedi.",
"changelog.viewOnGithub": "GitHub'da görüntüle",
"changelog.v1": "OpenCode 1 sürüm notları GitHub Releases'ta",
"bench.list.title": "Benchmark",
"bench.list.heading": "Benchmarklar",
-4
View File
@@ -825,10 +825,6 @@ export const dict = {
"changelog.hero.subtitle": "Нові оновлення та покращення OpenCode",
"changelog.empty": "Записів у журналі змін не знайдено.",
"changelog.viewJson": "Переглянути JSON",
"changelog.highlights": "Головне",
"changelog.error": "Зараз не вдалося завантажити список змін.",
"changelog.viewOnGithub": "Переглянути на GitHub",
"changelog.v1": "Примітки до випусків OpenCode 1 є в GitHub Releases",
"bench.list.title": "Бенчмарк",
"bench.list.heading": "Бенчмарки",
-4
View File
@@ -831,10 +831,6 @@ export const dict = {
"changelog.hero.subtitle": "OpenCode 的新更新和改进",
"changelog.empty": "未找到更新日志条目。",
"changelog.viewJson": "查看 JSON",
"changelog.highlights": "亮点",
"changelog.error": "暂时无法加载更新日志。",
"changelog.viewOnGithub": "在 GitHub 上查看",
"changelog.v1": "OpenCode 1 的发布说明位于 GitHub Releases",
"bench.list.title": "基准测试",
"bench.list.heading": "基准测试",
-4
View File
@@ -829,10 +829,6 @@ export const dict = {
"changelog.hero.subtitle": "OpenCode 的新更新與改善",
"changelog.empty": "找不到更新日誌項目。",
"changelog.viewJson": "檢視 JSON",
"changelog.highlights": "亮點",
"changelog.error": "暫時無法載入更新日誌。",
"changelog.viewOnGithub": "在 GitHub 上查看",
"changelog.v1": "OpenCode 1 的發布說明位於 GitHub Releases",
"bench.list.title": "評測",
"bench.list.heading": "評測",
@@ -1,55 +0,0 @@
import { query } from "@solidjs/router"
import { waitUntil } from "@opencode/console-resource"
import { parseChangelog, renderEntry, type ChangelogEntry } from "~/lib/changelog-markdown"
export const CHANGELOG_FILE_URL = "https://github.com/anomalyco/opencode/blob/dev/CHANGELOG.md"
const SOURCE_URL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"
// Fresh for five minutes, then served stale for up to a day while requests revalidate it in the background. A failed
// revalidation keeps serving the stale copy, so a GitHub outage only shows an error once the day-old copy is evicted.
const CACHE_KEY = "https://opencode.ai/changelog/source.md"
const FRESH_SECONDS = 300
const STALE_SECONDS = 86400
const FETCHED_AT = "x-opencode-changelog-fetched-at"
export type ChangelogPage = { ok: boolean; entries: ChangelogEntry[] }
function defaultCache() {
if (typeof caches === "undefined" || !("default" in caches)) return
return (caches as CacheStorage & { default: Cache }).default
}
async function refresh(cache: Cache | undefined) {
const response = await fetch(SOURCE_URL, {
headers: { "User-Agent": "OpenCode-Console" },
signal: AbortSignal.timeout(5000),
}).catch(() => undefined)
if (!response?.ok) return
const markdown = await response.text()
await cache?.put(
CACHE_KEY,
new Response(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": `public, s-maxage=${FRESH_SECONDS + STALE_SECONDS}`,
[FETCHED_AT]: String(Date.now()),
},
}),
)
return markdown
}
async function loadSource() {
const cache = defaultCache()
const cached = await cache?.match(CACHE_KEY)
if (!cached) return refresh(cache)
if (Date.now() - Number(cached.headers.get(FETCHED_AT)) > FRESH_SECONDS * 1000) void waitUntil(refresh(cache))
return cached.text()
}
export const changelogPage = query(async (): Promise<ChangelogPage> => {
"use server"
const markdown = await loadSource().catch(() => undefined)
if (markdown === undefined) return { ok: false, entries: [] }
return { ok: true, entries: parseChangelog(markdown).map(renderEntry) }
}, "changelog-page")
@@ -1,3 +1,5 @@
import { query } from "@solidjs/router"
type Release = {
tag_name: string
name: string
@@ -69,6 +71,12 @@ export async function loadChangelog(): Promise<ChangelogData> {
return { ok: true, releases }
}
export const changelog = query(async () => {
"use server"
const result = await loadChangelog()
return result.releases
}, "changelog")
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
@@ -9,31 +9,24 @@
}
[data-page="changelog"] {
/* The stone-paper palette of the Anomaly notes (anoma.ly/notes), mapped onto the site's colour roles. */
--color-background: hsl(48, 7%, 90%);
--color-background-weak: hsl(45, 5%, 86%);
--color-background-weak-hover: hsl(45, 5%, 82%);
--color-background-strong: hsl(51, 10%, 13%);
--color-background-strong-hover: hsl(40, 6%, 7%);
--color-background-interactive: hsl(40, 19%, 82%);
--color-background-interactive-weaker: hsl(40, 19%, 88%);
--color-background: hsl(0, 20%, 99%);
--color-background-weak: hsl(0, 8%, 97%);
--color-background-weak-hover: hsl(0, 8%, 94%);
--color-background-strong: hsl(0, 5%, 12%);
--color-background-strong-hover: hsl(0, 5%, 18%);
--color-background-interactive: hsl(62, 84%, 88%);
--color-background-interactive-weaker: hsl(64, 74%, 95%);
--color-text: hsl(51, 10%, 13%);
--color-text-weak: hsl(39, 5%, 51%);
--color-text-weaker: hsl(39, 6%, 63%);
--color-text-strong: hsl(40, 6%, 7%);
--color-text-inverted: hsl(48, 7%, 90%);
--color-text-label: hsl(39, 6%, 40%);
--color-text-code: hsl(32, 13%, 31%);
--color-text: hsl(0, 1%, 39%);
--color-text-weak: hsl(0, 1%, 60%);
--color-text-weaker: hsl(30, 2%, 81%);
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-border: hsl(48, 5%, 70%);
--color-border-weak: hsl(48, 6%, 79%);
--color-link-underline: hsla(38, 21%, 29%, 0.4);
--color-link-underline-hover: hsl(38, 27%, 33%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(39, 5%, 51%);
--font-serif: "IBM Plex Serif", Georgia, serif;
--color-icon: hsl(0, 1%, 55%);
background: var(--color-background);
font-family: var(--font-mono);
@@ -41,28 +34,24 @@
padding-bottom: 5rem;
@media (prefers-color-scheme: dark) {
--color-background: hsl(60, 7%, 3%);
--color-background-weak: hsl(0, 0%, 7%);
--color-background-weak-hover: hsl(0, 0%, 10%);
--color-background-strong: hsl(37, 15%, 80%);
--color-background-strong-hover: hsl(60, 9%, 98%);
--color-background-interactive: hsl(40, 5%, 20%);
--color-background-interactive-weaker: hsl(40, 5%, 12%);
--color-background: hsl(0, 9%, 7%);
--color-background-weak: hsl(0, 6%, 10%);
--color-background-weak-hover: hsl(0, 6%, 15%);
--color-background-strong: hsl(0, 15%, 94%);
--color-background-strong-hover: hsl(0, 15%, 97%);
--color-background-interactive: hsl(62, 100%, 90%);
--color-background-interactive-weaker: hsl(60, 20%, 8%);
--color-text: hsl(37, 15%, 80%);
--color-text-weak: hsl(0, 0%, 48%);
--color-text-weaker: hsl(0, 0%, 40%);
--color-text-strong: hsl(60, 9%, 98%);
--color-text-inverted: hsl(60, 7%, 3%);
--color-text-label: hsl(0, 0%, 67%);
--color-text-code: hsl(33, 26%, 79%);
--color-text: hsl(0, 4%, 71%);
--color-text-weak: hsl(0, 2%, 49%);
--color-text-weaker: hsl(0, 3%, 28%);
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-border: hsl(0, 0%, 22%);
--color-border-weak: hsl(0, 0%, 16%);
--color-link-underline: hsla(37, 15%, 58%, 0.4);
--color-link-underline-hover: hsl(38, 25%, 68%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
--color-icon: hsl(0, 0%, 48%);
--color-icon: hsl(10, 3%, 43%);
}
/* Header styles - copied from download */
@@ -75,18 +64,9 @@
justify-content: space-between;
align-items: center;
background: var(--color-background);
border-bottom: 1px solid var(--color-border-weak);
z-index: 10;
/* The notes' top fade: content dissolves into the paper under the sticky header instead of meeting a rule. */
&::after {
content: "";
position: absolute;
inset: 100% 0 auto;
height: 24px;
background: linear-gradient(to bottom, var(--color-background), transparent);
pointer-events: none;
}
@media (max-width: 60rem) {
padding: 24px 1.5rem;
}
@@ -236,7 +216,7 @@
border-top: 1px solid var(--color-border-weak);
display: flex;
flex-direction: row;
margin-top: 8rem;
margin-top: 4rem;
@media (max-width: 65rem) {
border-bottom: 1px solid var(--color-border-weak);
@@ -295,16 +275,20 @@
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
border: 1px solid var(--color-border-weak);
border-top: none;
@media (max-width: 65rem) {
border: none;
}
}
[data-component="content"] {
max-width: calc(740px + 3rem);
margin: 0 auto;
padding: 14vh 1.5rem 0;
font-family: var(--font-serif);
font-variant-numeric: oldstyle-nums proportional-nums;
font-feature-settings: "kern", "liga", "calt";
text-rendering: optimizeLegibility;
padding: 6rem 5rem;
@media (max-width: 60rem) {
padding: 4rem 1.5rem;
}
}
[data-component="legal"] {
@@ -330,240 +314,291 @@
}
}
/* Changelog Hero */
[data-component="changelog-hero"] {
margin-bottom: 96px;
margin-bottom: 4rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--color-border-weak);
@media (max-width: 50rem) {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
}
h1 {
font: 400 48px/1.2 var(--font-serif);
letter-spacing: -0.015em;
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-strong);
margin-bottom: 12px;
@media (max-width: 500px) {
font-size: 36px;
}
margin-bottom: 8px;
}
p {
font-size: 20px;
line-height: 1.5;
color: var(--color-text-weak);
color: var(--color-text);
}
}
/* Releases */
[data-component="releases"] {
display: flex;
flex-direction: column;
gap: 112px;
@media (max-width: 500px) {
gap: 80px;
}
gap: 0;
}
[data-component="release"] {
scroll-margin-top: 104px;
display: grid;
grid-template-columns: 180px 1fr;
gap: 3rem;
padding: 2rem 0;
border-bottom: 1px solid var(--color-border-weak);
> header {
margin-bottom: 40px;
@media (max-width: 50rem) {
grid-template-columns: 1fr;
gap: 1rem;
}
h2 {
font: 400 32px/1.3 var(--font-serif);
letter-spacing: -0.01em;
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
}
header {
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 80px;
align-self: start;
background: var(--color-background);
padding: 44px 0 8px;
@media (max-width: 50rem) {
position: static;
flex-direction: row;
align-items: center;
gap: 12px;
padding: 0;
}
[data-slot="version"] {
a {
font-weight: 600;
color: var(--color-text-strong);
text-decoration: none;
&:hover {
text-decoration: underline;
text-decoration-color: var(--color-link-underline);
text-underline-offset: 2px;
text-decoration-thickness: 1px;
text-underline-offset: 0.17em;
}
}
}
time {
display: block;
margin-top: 6px;
font-size: 16px;
line-height: 1.4;
color: var(--color-text-weak);
font-size: 14px;
}
}
[data-slot="content"] > [data-slot="markdown"] {
margin-bottom: 40px;
[data-slot="content"] {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
[data-slot="label"] {
font: 400 12px/1.5 var(--font-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-weak);
}
[data-component="highlights"] {
margin-bottom: 48px;
[data-slot="label"] {
margin-bottom: 20px;
[data-component="section"] {
h3 {
font-size: 13px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 6px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding-left: 16px;
display: flex;
flex-direction: column;
gap: 28px;
}
li {
font-size: 20px;
line-height: 1.65;
color: var(--color-text);
@media (max-width: 500px) {
font-size: 18px;
}
p {
text-wrap: pretty;
}
p[data-slot="title"] {
font-style: italic;
color: var(--color-text-strong);
}
}
}
[data-component="sections"] {
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-border-weak);
}
[data-component="section"] {
border-bottom: 1px solid var(--color-border-weak);
summary {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 14px 0;
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
&::after {
content: "+";
font: 400 14px/1 var(--font-mono);
color: var(--color-text-weak);
}
&:hover [data-slot="label"],
&:hover::after {
color: var(--color-text-strong);
}
}
&[open] summary::after {
content: "−";
}
[data-slot="markdown"] {
padding: 4px 0 28px;
}
}
[data-slot="markdown"] {
font-size: 17px;
line-height: 1.6;
color: var(--color-text);
h4 {
font: italic 400 17px/1.4 var(--font-serif);
color: var(--color-text-label);
margin: 28px 0 12px;
&:first-child {
margin-top: 0;
}
}
p {
margin-bottom: 12px;
text-wrap: pretty;
}
ul {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 4px;
li {
padding-left: 1.25em;
color: var(--color-text);
font-size: 13px;
line-height: 1.5;
padding-left: 12px;
position: relative;
text-wrap: pretty;
&::before {
content: "–";
content: "-";
position: absolute;
left: 0;
color: var(--color-text-weaker);
color: var(--color-text-weak);
}
[data-slot="author"] {
color: var(--color-text-weak);
font-size: 12px;
margin-left: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
}
code {
font-family: var(--font-mono);
font-size: 0.83em;
font-variant-numeric: lining-nums tabular-nums;
color: var(--color-text-code);
white-space: nowrap;
[data-component="contributors"] {
font-size: 13px;
color: var(--color-text-weak);
padding-top: 0.5rem;
span {
color: var(--color-text-weak);
}
a {
color: var(--color-text);
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
strong {
font-weight: 600;
[data-component="highlights"] {
display: flex;
flex-direction: column;
gap: 3rem;
margin-bottom: 0.75rem;
}
}
[data-slot="notice"] {
font-size: 20px;
line-height: 1.65;
color: var(--color-text-weak);
}
[data-component="collapsible-sections"] {
display: flex;
flex-direction: column;
gap: 0;
}
[data-slot="v1"] {
margin-top: 112px;
font-size: 16px;
color: var(--color-text-weak);
}
[data-component="collapsible-section"] {
[data-slot="toggle"] {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 6px 0;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--color-text-weak);
[data-component="content"] a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--color-link-underline);
text-decoration-thickness: 1px;
text-underline-offset: 0.17em;
&:hover {
color: var(--color-text);
}
&:hover {
color: var(--color-text-strong);
text-decoration-color: var(--color-link-underline-hover);
[data-slot="icon"] {
font-size: 10px;
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding-left: 16px;
padding-bottom: 8px;
li {
color: var(--color-text);
font-size: 13px;
line-height: 1.5;
padding-left: 12px;
position: relative;
&::before {
content: "-";
position: absolute;
left: 0;
color: var(--color-text-weak);
}
[data-slot="author"] {
color: var(--color-text-weak);
font-size: 12px;
margin-left: 4px;
text-decoration: none;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
}
}
[data-component="highlight"] {
h4 {
font-size: 14px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 8px;
}
hr {
border: none;
border-top: 1px solid var(--color-border-weak);
margin-bottom: 16px;
}
[data-slot="highlight-item"] {
margin-bottom: 48px;
&:last-child {
margin-bottom: 0;
}
p[data-slot="title"] {
font-weight: 600;
font-size: 16px;
margin-bottom: 4px;
}
p {
font-size: 14px;
margin-bottom: 12px;
}
}
img,
video {
max-width: 100%;
height: auto;
border-radius: 4px;
}
}
}
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
text-decoration-thickness: 2px;
}
}
}
@@ -1,84 +1,108 @@
import "./index.css"
import { Title, Meta } from "@solidjs/meta"
import { createAsync } from "@solidjs/router"
import { HttpStatusCode } from "@solidjs/start"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { CHANGELOG_FILE_URL, changelogPage } from "~/lib/changelog-source"
import type { ChangelogEntry } from "~/lib/changelog-markdown"
import { For, Index, Show } from "solid-js"
import { changelog } from "~/lib/changelog"
import type { HighlightGroup } from "~/lib/changelog"
import { For, Show, createSignal } from "solid-js"
import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { LocaleLinks } from "~/component/locale-links"
function formatDate(date: string, locale: string) {
return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, {
function formatDate(dateString: string, locale: string) {
const date = new Date(dateString)
return date.toLocaleDateString(locale, {
year: "numeric",
month: "long",
month: "short",
day: "numeric",
timeZone: "UTC",
})
}
function Release(props: { entry: ChangelogEntry; open: boolean }) {
const i18n = useI18n()
const language = useLanguage()
function ReleaseItem(props: { item: string }) {
const parts = () => {
const match = props.item.match(/^(.+?)(\s*\(@([\w-]+)\))?$/)
if (match) {
return {
text: match[1],
username: match[3],
}
}
return { text: props.item, username: undefined }
}
return (
<article data-component="release" id={props.entry.anchor}>
<header>
<h2>
<a href={`#${props.entry.anchor}`}>{props.entry.tag}</a>
</h2>
<Show when={props.entry.date}>
{(date) => <time dateTime={date()}>{formatDate(date(), language.tag(language.locale()))}</time>}
</Show>
</header>
<div data-slot="content">
<Show when={props.entry.intro}>
<div data-slot="markdown" innerHTML={props.entry.intro} />
</Show>
<Show when={props.entry.highlights.length > 0}>
<section data-component="highlights">
<h3 data-slot="label">{i18n.t("changelog.highlights")}</h3>
<ul>
<For each={props.entry.highlights}>
{(item) => (
<li>
<Show when={item.title}>
<p data-slot="title" innerHTML={item.title} />
</Show>
<p innerHTML={item.body} />
</li>
)}
</For>
</ul>
</section>
</Show>
<Show when={props.entry.sections.length > 0}>
<div data-component="sections">
<For each={props.entry.sections}>
{(section) => (
<details data-component="section" open={props.open}>
<summary>
<span data-slot="label">{section.title}</span>
</summary>
<div data-slot="markdown" innerHTML={section.body} />
</details>
)}
</For>
<li>
<span>{parts().text}</span>
<Show when={parts().username}>
<a data-slot="author" href={`https://github.com/${parts().username}`} target="_blank" rel="noopener noreferrer">
(@{parts().username})
</a>
</Show>
</li>
)
}
function HighlightSection(props: { group: HighlightGroup }) {
return (
<div data-component="highlight">
<h4>{props.group.source}</h4>
<hr />
<For each={props.group.items}>
{(item) => (
<div data-slot="highlight-item">
<p data-slot="title">{item.title}</p>
<p>{item.description}</p>
<Show when={item.media.type === "video"}>
<video src={item.media.src} controls autoplay loop muted playsinline />
</Show>
<Show when={item.media.type === "image"}>
<img
src={item.media.src}
alt={item.title}
width={(item.media as { width: string }).width}
height={(item.media as { height: string }).height}
/>
</Show>
</div>
</Show>
</div>
</article>
)}
</For>
</div>
)
}
function CollapsibleSection(props: { section: { title: string; items: string[] } }) {
const [open, setOpen] = createSignal(false)
return (
<div data-component="collapsible-section">
<button data-slot="toggle" onClick={() => setOpen(!open())}>
<span data-slot="icon">{open() ? "▾" : "▸"}</span>
<span>{props.section.title}</span>
</button>
<Show when={open()}>
<ul>
<For each={props.section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</Show>
</div>
)
}
function CollapsibleSections(props: { sections: { title: string; items: string[] }[] }) {
return (
<div data-component="collapsible-sections">
<For each={props.sections}>{(section) => <CollapsibleSection section={section} />}</For>
</div>
)
}
export default function Changelog() {
const i18n = useI18n()
const language = useLanguage()
const data = createAsync(() => changelogPage())
const data = createAsync(() => changelog())
const releases = () => data() ?? []
return (
<main data-page="changelog">
@@ -96,34 +120,51 @@ export default function Changelog() {
</section>
<section data-component="releases">
<Show when={data()}>
{(page) => (
<>
<Show when={!page().ok}>
<HttpStatusCode code={503} />
<p data-slot="notice">
{i18n.t("changelog.error")}{" "}
<a href={CHANGELOG_FILE_URL} target="_blank" rel="noopener noreferrer">
{i18n.t("changelog.viewOnGithub")}
</a>
</p>
</Show>
<Show when={page().ok && page().entries.length === 0}>
<p data-slot="notice">{i18n.t("changelog.empty")}</p>
</Show>
<Index each={page().entries}>
{(entry, index) => <Release entry={entry()} open={index === 0} />}
</Index>
</>
)}
<Show when={releases().length === 0}>
<p>
{i18n.t("changelog.empty")}{" "}
<a href={language.route("/changelog.json")}>{i18n.t("changelog.viewJson")}</a>
</p>
</Show>
<For each={releases()}>
{(release) => {
return (
<article data-component="release">
<header>
<div data-slot="version">
<a href={release.url} target="_blank" rel="noopener noreferrer">
{release.tag}
</a>
</div>
<time dateTime={release.date}>{formatDate(release.date, language.tag(language.locale()))}</time>
</header>
<div data-slot="content">
<Show when={release.highlights.length > 0}>
<div data-component="highlights">
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
</div>
</Show>
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
<CollapsibleSections sections={release.sections} />
</Show>
<Show when={release.highlights.length === 0}>
<For each={release.sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
<ul>
<For each={section.items}>{(item) => <ReleaseItem item={item} />}</For>
</ul>
</div>
)}
</For>
</Show>
</div>
</article>
)
}}
</For>
</section>
<p data-slot="v1">
<a href="https://github.com/anomalyco/opencode/releases" target="_blank" rel="noopener noreferrer">
{i18n.t("changelog.v1")}
</a>
</p>
</div>
<Footer />
+6 -5
View File
@@ -3,11 +3,12 @@
Independently deployed services supporting OpenCode live here. Runtime packages
and clients live in `packages/`.
| Service | Purpose | Deployment |
| ---------------------- | -------------------------------------- | ---------------------------- |
| [`www`](./www) | Website and V2 documentation | `deploy-www.yml`, Wrangler |
| [`updates`](./updates) | Release metadata and update selection | `publish.yml`, Wrangler |
| [`files`](./files) | Public file distribution at `/files/*` | `deploy-files.yml`, Wrangler |
| Service | Purpose | Deployment |
| -------------------------- | -------------------------------------- | -------------------------------- |
| [`www`](./www) | Website and V2 documentation | `deploy-www.yml`, Wrangler |
| [`updates`](./updates) | Release metadata and update selection | `publish.yml`, Wrangler |
| [`files`](./files) | Public file distribution at `/files/*` | `deploy-files.yml`, Wrangler |
| [`changelog`](./changelog) | Release notes at `/changelog` | `deploy-changelog.yml`, Wrangler |
These directories are Bun workspaces. Run package commands from each service's
directory. Repository-wide Turbo commands discover them through `services/*`.
@@ -0,0 +1,117 @@
# Changelog
Release notes for OpenCode V2, newest first.
## v2.0.27 — 2026-10-12
### Highlights
- **Built-In Worktree Tools**: Ask OpenCode to use `worktree_create`, `worktree_list`, and `worktree_remove` in Code Mode. You can create isolated worktrees, move sessions into them, and remove them with normal edit approval. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
- **Safer Remote Pairing**: The first `opencode pair --remote` now warns that it will expose your server and asks for confirmation. For non-interactive use, pass `opencode pair --remote --yes`. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
- **Installable Web App**: Use Chrome or Edge’s **Install** button to install the OpenCode web app, including when it runs behind an authenticating proxy. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
- **Clear Session Failures**: Desktop and TUI timelines now show execution errors that happen before or between model steps instead of ending without an explanation. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- **Automatic Project Cleanup**: Projects whose directories no longer exist are automatically hidden from the project list. Opening the directory again restores the project. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
### Core
#### Improvements
- Added the built-in Code Mode tools `opencode.worktree_create`, `opencode.worktree_list`, and `opencode.worktree_remove`. Removing a worktree requires edit approval, and `force: true` can discard modified or untracked files. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
- Added confirmation before `opencode pair --remote` first enables internet access, plus `--yes`/`-y` for scripts and other non-interactive use. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
- Automatically archived projects when all known directories disappear. Reopening a project restores it, while surviving clones and linked worktrees keep it active. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
- Raised the ChatGPT OAuth input limit for `gpt-6.1-sol` and `gpt-6.1-sol-fast` to 500,000 tokens. ([#54668](https://github.com/anomalyco/opencode/pull/54668))
- Added Vertex AI routes for Interactions and Mistral models. ([#54175](https://github.com/anomalyco/opencode/pull/54175), [#54058](https://github.com/anomalyco/opencode/pull/54058))
#### Bug fixes
- Fixed updater detection for symlinked home directories and package-manager installations, increased probe timeouts, and now report unavailable updates clearly when the installation method cannot be detected. ([#54609](https://github.com/anomalyco/opencode/pull/54609), [#54622](https://github.com/anomalyco/opencode/pull/54622), [#54672](https://github.com/anomalyco/opencode/pull/54672))
- Fixed GitHub Copilot routing for Gemini, Anthropic, and Responses models when live model metadata is unavailable, and accepted interleaved Claude reasoning values. ([#54208](https://github.com/anomalyco/opencode/pull/54208), [#54210](https://github.com/anomalyco/opencode/pull/54210), [#54201](https://github.com/anomalyco/opencode/pull/54201))
- Fixed Google Vertex Application Default Credentials when `CLOUDSDK_CONFIG` points to a custom gcloud configuration directory. Thanks [@H-Max](https://github.com/H-Max). ([#54164](https://github.com/anomalyco/opencode/pull/54164))
- Prevented ChatGPT token-sharing sessions from sending unsupported mid-session reasoning-effort updates. ([#54085](https://github.com/anomalyco/opencode/pull/54085))
- Blocked `file://` access through Code Mode’s `fetch`, which now returns a clear filesystem-access error. Thanks [@ferdinandyb](https://github.com/ferdinandyb). ([#54510](https://github.com/anomalyco/opencode/pull/54510))
- Fixed remote workspace file operations so driver-provided filesystem overrides are used correctly.
- Preserved subagent notifications when location services are evicted, and propagated well-known configuration updates across locations with bounded HTTP timeouts. ([#54072](https://github.com/anomalyco/opencode/pull/54072), [#53821](https://github.com/anomalyco/opencode/pull/53821))
### TUI
#### Bug fixes
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- Preserved a saved session’s unavailable model selection instead of silently replacing it with a fallback. ([#53822](https://github.com/anomalyco/opencode/pull/53822))
- Added syntax highlighting for C++ module interface files such as `.cppm`, `.ixx`, and `.cxxm`. ([#53853](https://github.com/anomalyco/opencode/pull/53853))
- Summarized integrations with multiple credentials as a connection count, preventing long labels from crowding the integration name. ([#54054](https://github.com/anomalyco/opencode/pull/54054))
### Desktop
#### Improvements
- Made the web app installable from Chrome and Edge, including deployments behind cookie-based authenticating proxies. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
- Updated the session column layout and text wrapping for clearer long content. ([#54020](https://github.com/anomalyco/opencode/pull/54020))
#### Bug fixes
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
- Fixed command-palette selection so newly arriving higher-ranked results become active until you explicitly choose a result. ([#54275](https://github.com/anomalyco/opencode/pull/54275))
- Preserved bold, code, emphasis, and strike-through formatting inside Markdown link labels. ([#54451](https://github.com/anomalyco/opencode/pull/54451))
- Fixed timeline file links and complete patch rendering, including unusual filenames and patches whose original alignment can be reused. ([#53641](https://github.com/anomalyco/opencode/pull/53641), [#54017](https://github.com/anomalyco/opencode/pull/54017))
- Showed complete tool error text when an error is expanded. ([#53816](https://github.com/anomalyco/opencode/pull/53816))
- Fixed browser-panel controls, page visibility, corner masks, and closed side-region overlays. ([#53907](https://github.com/anomalyco/opencode/pull/53907), [#54038](https://github.com/anomalyco/opencode/pull/54038), [#52461](https://github.com/anomalyco/opencode/pull/52461), [#54052](https://github.com/anomalyco/opencode/pull/54052))
- Made the third-party notices dialog scrollable. ([#53911](https://github.com/anomalyco/opencode/pull/53911))
### SDK
#### Improvements
- Added startup plugin seeding to Effect embedded hosts and workerd through `OpenCode.create({ plugins })` and `OpenCodeWorkerd.create({ plugins })`.
- Re-exported `WorkspaceDriver`, `EnvironmentDriver`, and `EnvironmentFiles` from `@opencode/sdk/effect`.
#### Bug fixes
- Ensured embedded hosts activate supplied plugins before model discovery, generation, and recovery begin. ([#54219](https://github.com/anomalyco/opencode/pull/54219))
### Extensions
#### Improvements
- Extended `BridgeLayout` with viewport dimensions and optional border information so native extension surfaces can track window resizing and preserve rounded card borders.
#### Bug fixes
- Kept desktop browser-panel corner masks and borders aligned while resizing or using custom themes. ([#52461](https://github.com/anomalyco/opencode/pull/52461))
- Kept the browser site button clickable after the embedded page takes focus. ([#53907](https://github.com/anomalyco/opencode/pull/53907))
## v2.0.26 — 2026-10-08
### Highlights
- **Google Cloud Credentials for Vertex**: Run `/connect`, choose **Vertex**, then select **Google Cloud credentials (gcloud auth or environment)** to use Application Default Credentials. You can choose or enter the Google Cloud project and location. ([#53798](https://github.com/anomalyco/opencode/pull/53798))
- **Faster, Safer Session Snapshots**: Session snapshots now add less overhead, recover from interrupted or concurrent captures, restore large selections faster, and pack stored objects to reduce disk usage. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
- **Working Transcript Links**: You can click links anywhere in a transcript, including any row of a wrapped URL. Hyperlinks also work in more terminals, and `FORCE_HYPERLINK=1` can enable them when automatic detection does not. ([#53903](https://github.com/anomalyco/opencode/pull/53903), [#53904](https://github.com/anomalyco/opencode/pull/53904))
- **Step 5 Preview Free**: You can now select **Step 5 Preview Free** from OpenCode’s free model lineup for a limited time.
### Core
#### Improvements
- Added **Google Cloud credentials (gcloud auth or environment)** to the Vertex connection flow, with project and location choices discovered from environment variables, gcloud configurations, and Application Default Credentials. ([#53798](https://github.com/anomalyco/opencode/pull/53798))
- Reduced session snapshot overhead, accelerated multi-file restores, made capture resilient to crashes and concurrent processes, and added lossless background packing of snapshot objects. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
- Added **Step 5 Preview Free** to OpenCode’s free models for a limited time and removed the retired **Fledge Alpha Free** listing.
#### Bug fixes
- Prevented working-tree diffs from timing out or stalling the server in repositories with many untracked files by batching Git operations. ([#53449](https://github.com/anomalyco/opencode/pull/53449))
- Fixed large revert selections failing when their paths exceeded the operating system’s command-line limit. ([#53956](https://github.com/anomalyco/opencode/pull/53956))
- Fixed snapshot and undo handling for literal filenames containing Git pathspec characters, corrupted or locked snapshot indexes, concurrent captures, and project directories whose names begin with `..`. ([#51996](https://github.com/anomalyco/opencode/pull/51996))
- Changed `opencode service` remote-access URLs to use a persistent random subdomain rather than a predictable route. Disabling and re-enabling remote access generates a new address. ([#53962](https://github.com/anomalyco/opencode/pull/53962))
### TUI
#### Improvements
- Enabled OSC 8 hyperlinks in more terminals, including VS Code and other xterm.js terminals, Zed, mintty, and newer VTE terminals; `FORCE_HYPERLINK=1` now overrides terminal detection. ([#53904](https://github.com/anomalyco/opencode/pull/53904))
#### Bug fixes
- Fixed transcript links not opening when clicked, including URLs wrapped across multiple rows. ([#53903](https://github.com/anomalyco/opencode/pull/53903))
- Kept activity summaries in place when expanding or collapsing details with `session.verbosity: "low"`. ([#52357](https://github.com/anomalyco/opencode/pull/52357))
- Prevented the home-screen version footer from overlapping keyboard hints in short terminals. Thanks [@fihaaade](https://github.com/fihaaade). ([#53891](https://github.com/anomalyco/opencode/pull/53891))
+34
View File
@@ -0,0 +1,34 @@
# OpenCode Changelog
The changelog Worker renders the root `CHANGELOG.md` at `https://opencode.ai/changelog`.
It owns two routes, `opencode.ai/changelog` and `opencode.ai/changelog/*`. Requests for
`/changelog.json` and localized pages such as `/de/changelog` still reach the Console.
## Content
Each request reads `CHANGELOG.md` from the `dev` branch on raw GitHub through the Workers
cache. A copy stays fresh for five minutes. After that, the Worker serves the stale copy for up
to a day and refreshes it in the background. If GitHub fails, the stale copy is kept. Releases
appear without a deploy once the version-sync commit lands on `dev`.
Entries split on lines matching `^## v\d`. `### Highlights` items render first, and the
remaining `###` sections render as collapsible details. Raw HTML in the notes is escaped.
`/changelog/<version>` redirects to that release's anchor.
## Fonts
`public/changelog/fonts` holds the Latin-1 subsets of IBM Plex Serif and IBM Plex Mono from
`@ibm/plex`, licensed under the SIL Open Font License (`LICENSE.txt`).
## Development
```sh
bun dev
bun test
bun typecheck
bun run build
```
`deploy-changelog.yml` deploys with Wrangler on pushes to `dev` that touch this directory.
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/changelog",
"private": true,
"type": "module",
"scripts": {
"build": "wrangler deploy --dry-run --outdir dist",
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"test": "bun test",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"marked": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"wrangler": "4.110.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
/changelog/fonts/*
Cache-Control: public, max-age=31536000, immutable
@@ -0,0 +1,93 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+88
View File
@@ -0,0 +1,88 @@
import { anchor, parseChangelog, renderEntry } from "./markdown"
import { renderPage, type PageState } from "./page"
const SOURCE_URL = "https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"
// Fresh for five minutes, then served stale for up to a day while requests revalidate it in the background. A failed
// revalidation keeps serving the stale copy, so a GitHub outage only shows an error once the day-old copy is evicted.
const CACHE_KEY = "https://opencode.ai/changelog/source.md"
const FRESH_SECONDS = 300
const STALE_SECONDS = 86400
const FETCHED_AT = "x-opencode-changelog-fetched-at"
const PAGE_CACHE = `public, max-age=60, s-maxage=${FRESH_SECONDS}, stale-while-revalidate=${STALE_SECONDS}`
const SECURITY = {
"Content-Security-Policy":
"default-src 'none'; style-src 'unsafe-inline'; font-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
type Context = Pick<ExecutionContext, "waitUntil">
async function refresh(cache: Cache | undefined) {
const response = await fetch(SOURCE_URL, {
headers: { "User-Agent": "opencode-changelog" },
signal: AbortSignal.timeout(5000),
}).catch(() => undefined)
if (!response?.ok) return
const markdown = await response.text()
await cache?.put(
CACHE_KEY,
new Response(markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": `public, s-maxage=${FRESH_SECONDS + STALE_SECONDS}`,
[FETCHED_AT]: String(Date.now()),
},
}),
)
return markdown
}
/** Reads CHANGELOG.md through the colo's Workers cache; resolves undefined when no copy is available at all. */
export async function loadSource(ctx: Context, cache: Cache | undefined = defaultCache()) {
const cached = await cache?.match(CACHE_KEY)
if (!cached) return refresh(cache)
if (Date.now() - Number(cached.headers.get(FETCHED_AT)) > FRESH_SECONDS * 1000) ctx.waitUntil(refresh(cache))
return cached.text()
}
function defaultCache() {
if (typeof caches === "undefined" || !("default" in caches)) return
return (caches as CacheStorage & { default: Cache }).default
}
function html(state: PageState, status: number, cacheControl: string, method: string) {
return new Response(method === "HEAD" ? null : renderPage(state), {
status,
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": cacheControl, ...SECURITY },
})
}
function redirect(url: URL, location: string, status: number) {
return Response.redirect(new URL(location, url).toString(), status)
}
export async function handle(request: Request, load: () => Promise<string | undefined>) {
if (request.method !== "GET" && request.method !== "HEAD")
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } })
const url = new URL(request.url)
const path = url.pathname
if (path === "/changelog/") return redirect(url, "/changelog", 308)
const version = /^\/changelog\/(v\d[\w.-]*)$/.exec(path)
if (version) return redirect(url, `/changelog#${anchor(version[1])}`, 302)
if (path.startsWith("/changelog/")) return html({ type: "not-found" }, 404, "public, max-age=300", request.method)
if (path !== "/changelog") return redirect(url, "/changelog", 302)
const markdown = await load().catch(() => undefined)
if (markdown === undefined) return html({ type: "error" }, 503, "no-store", request.method)
return html({ type: "ok", entries: parseChangelog(markdown).map(renderEntry) }, 200, PAGE_CACHE, request.method)
}
export default {
fetch(request, _env, ctx) {
return handle(request, () => loadSource(ctx))
},
} satisfies ExportedHandler
@@ -72,7 +72,7 @@ export function anchor(tag: string) {
.replace(/^-|-$/g, "")
}
function escape(value: string) {
export function escape(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
+109
View File
@@ -0,0 +1,109 @@
import { escape, type ChangelogEntry } from "./markdown"
import { style } from "./style"
const SITE = "https://opencode.ai"
const REPO = "https://github.com/anomalyco/opencode"
export const CHANGELOG_FILE_URL = `${REPO}/blob/dev/CHANGELOG.md`
const logo =
'<svg viewBox="0 0 234 42" role="img" aria-label="OpenCode" xmlns="http://www.w3.org/2000/svg"><path d="M18 30H6V18H18V30Z" class="i"/><path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" class="o"/><path d="M48 30H36V18H48V30Z" class="i"/><path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" class="o"/><path d="M84 24V30H66V24H84Z" class="i"/><path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" class="o"/><path d="M108 36H96V18H108V36Z" class="i"/><path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" class="o"/><path d="M144 30H126V18H144V30Z" class="i"/><path d="M144 12H126V30H144V36H120V6H144V12Z" class="c"/><path d="M168 30H156V18H168V30Z" class="i"/><path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" class="c"/><path d="M198 30H186V18H198V30Z" class="i"/><path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" class="c"/><path d="M234 24V30H216V24H234Z" class="i"/><path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" class="c"/></svg>'
const download =
'<svg viewBox="0 0 18 18" fill="none" aria-hidden="true"><path d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/></svg>'
const external = 'target="_blank" rel="noopener noreferrer"'
function formatDate(date: string) {
return new Date(`${date}T00:00:00Z`).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
})
}
function release(entry: ChangelogEntry, open: boolean) {
const date = entry.date ? `<time datetime="${entry.date}">${formatDate(entry.date)}</time>` : ""
const intro = entry.intro ? `<div class="markdown intro">${entry.intro}</div>` : ""
const highlights =
entry.highlights.length === 0
? ""
: `<section class="highlights"><h3 class="label">Highlights</h3><ul>${entry.highlights
.map((item) => `<li>${item.title ? `<p class="title">${item.title}</p>` : ""}<p>${item.body}</p></li>`)
.join("")}</ul></section>`
const sections =
entry.sections.length === 0
? ""
: `<div class="sections">${entry.sections
.map(
(section) =>
`<details class="section"${open ? " open" : ""}><summary><span class="label">${escape(section.title)}</span></summary><div class="markdown">${section.body}</div></details>`,
)
.join("")}</div>`
return `<article class="release" id="${escape(entry.anchor)}"><header><h2><a href="#${escape(entry.anchor)}">${escape(entry.tag)}</a></h2>${date}</header>${intro}${highlights}${sections}</article>`
}
export type PageState = { type: "ok"; entries: ChangelogEntry[] } | { type: "error" } | { type: "not-found" }
/** Renders the whole page; entries arrive with their Markdown already rendered to safe HTML. */
export function renderPage(state: PageState) {
const body = (() => {
if (state.type === "error")
return `<p class="notice">The changelog could not be loaded right now. <a href="${CHANGELOG_FILE_URL}" ${external}>View it on GitHub</a></p>`
if (state.type === "not-found")
return `<p class="notice">That page does not exist. <a href="/changelog">See all releases</a></p>`
if (state.entries.length === 0) return `<p class="notice">No changelog entries found.</p>`
return state.entries.map((entry, index) => release(entry, index === 0)).join("")
})()
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenCode | Changelog</title>
<meta name="description" content="OpenCode release notes and changelog">
<link rel="canonical" href="${SITE}/changelog">
<link rel="icon" href="/favicon.ico">
<link rel="preload" href="/changelog/fonts/IBMPlexSerif-Regular-Latin1.woff2" as="font" type="font/woff2" crossorigin>
<meta property="og:title" content="OpenCode | Changelog">
<meta property="og:description" content="OpenCode release notes and changelog">
<meta property="og:url" content="${SITE}/changelog">
<style>${style}</style>
</head>
<body>
<header class="top"><div class="top-inner">
<a class="logo" href="${SITE}/">${logo}</a>
<nav><ul class="nav">
<li><a href="${REPO}" ${external}>GitHub</a></li>
<li><a href="${SITE}/v2/docs">Docs</a></li>
<li><a href="${SITE}/data">Data</a></li>
<li><a href="${SITE}/zen">Zen</a></li>
<li><a href="${SITE}/go">Go</a></li>
<li><a href="${SITE}/enterprise">Enterprise</a></li>
<li><a class="cta" href="${SITE}/download">${download}Download</a></li>
</ul></nav>
</div></header>
<main>
<section class="hero"><h1>Changelog</h1><p>New updates and improvements to OpenCode</p></section>
<div class="releases">${body}</div>
<p class="v1"><a href="${REPO}/releases" ${external}>OpenCode 1 release notes are on GitHub Releases</a></p>
</main>
<footer>
<a href="${REPO}" ${external}>GitHub</a>
<a href="${SITE}/docs">Docs</a>
<a href="${SITE}/changelog">Changelog</a>
<a href="${SITE}/discord">Discord</a>
<a href="https://x.com/opencode">X</a>
</footer>
<div class="legal">
<span>©${new Date().getUTCFullYear()} <a href="https://anoma.ly">Anomaly</a></span>
<a href="${SITE}/brand">Brand</a>
<a href="${SITE}/legal/privacy-policy">Privacy</a>
<a href="${SITE}/legal/terms-of-service">Terms</a>
</div>
</body>
</html>
`
}
+144
View File
@@ -0,0 +1,144 @@
// The stone-paper palette, serif reading column and quiet chrome follow the Anomaly notes (anoma.ly/notes); the header,
// footer and mono face follow opencode.ai.
export const style = `
@font-face { font-family: "IBM Plex Serif"; font-style: normal; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-Regular-Latin1.woff2") format("woff2"); }
@font-face { font-family: "IBM Plex Serif"; font-style: italic; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-Italic-Latin1.woff2") format("woff2"); }
@font-face { font-family: "IBM Plex Serif"; font-style: normal; font-weight: 600; font-display: swap; src: url("/changelog/fonts/IBMPlexSerif-SemiBold-Latin1.woff2") format("woff2"); }
@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap; src: url("/changelog/fonts/IBMPlexMono-Regular-Latin1.woff2") format("woff2"); }
:root {
color-scheme: light dark;
--paper: hsl(48, 7%, 90%);
--paper-hover: hsl(45, 5%, 86%);
--ink: hsl(51, 10%, 13%);
--ink-strong: hsl(40, 6%, 7%);
--ink-label: hsl(39, 6%, 40%);
--ink-weak: hsl(39, 5%, 51%);
--ink-weaker: hsl(39, 6%, 63%);
--ink-code: hsl(32, 13%, 31%);
--rule: hsl(48, 6%, 79%);
--underline: hsla(38, 21%, 29%, 0.4);
--underline-hover: hsl(38, 27%, 33%);
--button: hsl(51, 10%, 13%);
--button-hover: hsl(40, 6%, 7%);
--button-ink: hsl(48, 7%, 90%);
--logo-inner: #cfcecd;
--logo-open: #656363;
--logo-code: #211e1e;
--serif: "IBM Plex Serif", Georgia, serif;
--mono: "Berkeley Mono", "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: hsl(60, 7%, 3%);
--paper-hover: hsl(0, 0%, 7%);
--ink: hsl(37, 15%, 80%);
--ink-strong: hsl(60, 9%, 98%);
--ink-label: hsl(0, 0%, 67%);
--ink-weak: hsl(0, 0%, 48%);
--ink-weaker: hsl(0, 0%, 40%);
--ink-code: hsl(33, 26%, 79%);
--rule: hsl(0, 0%, 16%);
--underline: hsla(37, 15%, 58%, 0.4);
--underline-hover: hsl(38, 25%, 68%);
--button: hsl(37, 15%, 80%);
--button-hover: hsl(60, 9%, 98%);
--button-ink: hsl(60, 7%, 3%);
--logo-inner: #4b4646;
--logo-open: #b7b1b1;
--logo-code: #f1ecec;
}
}
*, *::before, *::after { box-sizing: border-box; }
html { background: var(--paper); }
body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--mono); font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
h1, h2, h3, h4, p, ul { margin: 0; }
::selection { background: hsl(40, 19%, 78%); color: var(--ink-strong); }
.top { position: sticky; top: 0; z-index: 10; background: var(--paper); }
.top::after { content: ""; position: absolute; inset: 100% 0 auto; height: 24px; background: linear-gradient(to bottom, var(--paper), transparent); pointer-events: none; }
.top-inner { max-width: 67.5rem; height: 80px; margin: 0 auto; padding: 0 5rem; display: flex; align-items: center; justify-content: space-between; gap: 2rem; }
.logo { display: block; line-height: 0; }
.logo svg { height: 34px; width: auto; }
.logo .i { fill: var(--logo-inner); }
.logo .o { fill: var(--logo-open); }
.logo .c { fill: var(--logo-code); }
.nav { display: flex; align-items: center; gap: 32px; list-style: none; padding: 0; }
.nav a { color: var(--ink); text-decoration: none; white-space: nowrap; }
.nav a:hover { text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
.nav a.cta { display: flex; align-items: center; gap: 8px; padding: 8px 16px 8px 10px; border-radius: 4px; background: var(--button); color: var(--button-ink); font-weight: 500; }
.nav a.cta:hover { background: var(--button-hover); text-decoration: none; }
.nav a.cta svg { width: 16px; height: 16px; }
@media (max-width: 60rem) { .top-inner { padding: 0 1.5rem; } .nav { gap: 24px; } }
@media (max-width: 48rem) { .nav li:not(:last-child) { display: none; } }
main { max-width: calc(740px + 3rem); margin: 0 auto; padding: 14vh 1.5rem 0; font-family: var(--serif); font-variant-numeric: oldstyle-nums proportional-nums; font-feature-settings: "kern", "liga", "calt"; text-rendering: optimizeLegibility; }
main a { color: inherit; text-decoration: underline; text-decoration-color: var(--underline); text-decoration-thickness: 1px; text-underline-offset: 0.17em; }
main a:hover { color: var(--ink-strong); text-decoration-color: var(--underline-hover); }
.hero { margin-bottom: 96px; }
.hero h1 { font: 400 48px/1.2 var(--serif); letter-spacing: -0.015em; color: var(--ink-strong); margin-bottom: 12px; }
.hero p { font-size: 20px; line-height: 1.5; color: var(--ink-weak); }
.releases { display: flex; flex-direction: column; gap: 112px; }
.release { scroll-margin-top: 104px; }
.release > header { margin-bottom: 40px; }
.release h2 { font: 400 32px/1.3 var(--serif); letter-spacing: -0.01em; }
.release h2 a { color: var(--ink-strong); text-decoration: none; }
.release h2 a:hover { text-decoration: underline; text-decoration-color: var(--underline); }
.release time { display: block; margin-top: 6px; font-size: 16px; line-height: 1.4; color: var(--ink-weak); }
.label { font: 400 12px/1.5 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-weak); }
.intro { margin-bottom: 40px; }
.highlights { margin-bottom: 48px; }
.highlights .label { margin-bottom: 20px; }
.highlights ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 28px; }
.highlights li { font-size: 20px; line-height: 1.65; }
.highlights p { text-wrap: pretty; }
.highlights .title { font-style: italic; color: var(--ink-strong); }
.sections { border-top: 1px solid var(--rule); }
.section { border-bottom: 1px solid var(--rule); }
.section summary { display: flex; align-items: baseline; justify-content: space-between; padding: 14px 0; cursor: pointer; list-style: none; }
.section summary::-webkit-details-marker { display: none; }
.section summary::after { content: "+"; font: 400 14px/1 var(--mono); color: var(--ink-weak); }
.section[open] summary::after { content: "\\2212"; }
.section summary:hover .label, .section summary:hover::after { color: var(--ink-strong); }
.section .markdown { padding: 4px 0 28px; }
.markdown { font-size: 17px; line-height: 1.6; }
.markdown h4 { font: italic 400 17px/1.4 var(--serif); color: var(--ink-label); margin: 28px 0 12px; }
.markdown h4:first-child { margin-top: 0; }
.markdown p { margin-bottom: 12px; text-wrap: pretty; }
.markdown ul { list-style: none; padding: 0; margin: 0 0 12px; display: flex; flex-direction: column; gap: 10px; }
.markdown li { position: relative; padding-left: 1.25em; text-wrap: pretty; }
.markdown li::before { content: "\\2013"; position: absolute; left: 0; color: var(--ink-weaker); }
main code { font-family: var(--mono); font-size: 0.83em; font-variant-numeric: lining-nums tabular-nums; color: var(--ink-code); white-space: nowrap; }
main strong { font-weight: 600; }
.notice { font-size: 20px; line-height: 1.65; color: var(--ink-weak); }
.v1 { margin-top: 112px; font-size: 16px; color: var(--ink-weak); }
footer { max-width: 67.5rem; margin: 8rem auto 0; display: flex; border-top: 1px solid var(--rule); }
footer a { flex: 1; display: block; padding: 2rem 0; text-align: center; color: var(--ink-strong); text-decoration: none; }
footer a + a { border-left: 1px solid var(--rule); }
footer a:hover { background: var(--paper-hover); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
.legal { display: flex; justify-content: center; flex-wrap: wrap; gap: 32px; padding: 2rem 1.5rem 5rem; color: var(--ink-weak); }
.legal a { color: var(--ink-weak); text-decoration: none; }
.legal a:hover { color: var(--ink); text-decoration: underline; }
@media (max-width: 40rem) {
footer { flex-wrap: wrap; }
footer a { flex: 1 0 50%; border-top: 1px solid var(--rule); }
footer a + a { border-left: none; }
}
@media (max-width: 500px) {
.hero h1 { font-size: 36px; }
.highlights li { font-size: 18px; }
.releases { gap: 80px; }
}
`
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { parseChangelog, renderEntry, renderInline, renderMarkdown } from "../src/lib/changelog-markdown"
import { parseChangelog, renderEntry, renderInline, renderMarkdown } from "../src/markdown"
const fixture = `# Changelog
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, test } from "bun:test"
import { handle, loadSource } from "../src/index"
const changelog = `# Changelog
## v2.0.27 — 2026-10-12
### Highlights
- **Built-In Worktree Tools**: Use \`worktree_create\`. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
### Core
#### Bug fixes
- Fixed updater detection. (#54609)
## v2.0.26 — 2026-10-08
### Core
- Reduced snapshot overhead.
`
const request = (path: string, method = "GET") => new Request(`https://opencode.ai${path}`, { method })
describe("handle", () => {
test("renders every release with anchors, highlights and cache headers", async () => {
const response = await handle(request("/changelog"), async () => changelog)
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8")
expect(response.headers.get("cache-control")).toBe("public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
expect(body.indexOf('id="v2-0-27"')).toBeLessThan(body.indexOf('id="v2-0-26"'))
expect(body).toContain('<a href="#v2-0-27">v2.0.27</a>')
expect(body).toContain('<time datetime="2026-10-12">October 12, 2026</time>')
expect(body).toContain('<p class="title">Built-In Worktree Tools</p>')
expect(body).toContain('<details class="section" open>')
expect(body).toContain('<details class="section"><summary>')
})
test("answers HEAD without a body", async () => {
const response = await handle(request("/changelog", "HEAD"), async () => changelog)
expect(response.status).toBe(200)
expect(await response.text()).toBe("")
})
test("shows the empty state for a changelog without releases", async () => {
const response = await handle(request("/changelog"), async () => "# Changelog\n")
expect(response.status).toBe(200)
expect(await response.text()).toContain("No changelog entries found.")
})
test("returns a friendly 503 when no copy can be loaded", async () => {
const response = await handle(request("/changelog"), async () => undefined)
expect(response.status).toBe(503)
expect(response.headers.get("cache-control")).toBe("no-store")
expect(await response.text()).toContain("could not be loaded right now")
})
test("redirects the trailing slash and version paths", async () => {
const load = async () => changelog
expect((await handle(request("/changelog/"), load)).headers.get("location")).toBe("https://opencode.ai/changelog")
const version = await handle(request("/changelog/v2.0.26"), load)
expect(version.status).toBe(302)
expect(version.headers.get("location")).toBe("https://opencode.ai/changelog#v2-0-26")
})
test("returns 404 for other subpaths and 405 for writes", async () => {
expect((await handle(request("/changelog/nope"), async () => changelog)).status).toBe(404)
expect((await handle(request("/changelog", "POST"), async () => changelog)).status).toBe(405)
})
})
describe("loadSource", () => {
const original = globalThis.fetch
afterEach(() => {
globalThis.fetch = original
})
function memoryCache() {
const entries = new Map<string, Response>()
return {
entries,
cache: {
match: async (key: string) => entries.get(key)?.clone(),
put: async (key: string, response: Response) => void entries.set(key, response),
} as unknown as Cache,
}
}
function stub(body: string | undefined) {
const calls: string[] = []
globalThis.fetch = (async (input: string) => {
calls.push(input)
return body === undefined ? new Response("", { status: 500 }) : new Response(body)
}) as unknown as typeof fetch
return calls
}
test("fetches dev's CHANGELOG.md and serves the cached copy while fresh", async () => {
const calls = stub(changelog)
const { cache } = memoryCache()
const waits: Promise<unknown>[] = []
const ctx = { waitUntil: (promise: Promise<unknown>) => void waits.push(promise) }
expect(await loadSource(ctx, cache)).toBe(changelog)
expect(await loadSource(ctx, cache)).toBe(changelog)
expect(calls).toEqual(["https://raw.githubusercontent.com/anomalyco/opencode/dev/CHANGELOG.md"])
expect(waits).toHaveLength(0)
})
test("serves a stale copy and keeps it when revalidation fails", async () => {
const { cache, entries } = memoryCache()
entries.set(
"https://opencode.ai/changelog/source.md",
new Response("stale", { headers: { "x-opencode-changelog-fetched-at": String(Date.now() - 301_000) } }),
)
const calls = stub(undefined)
const waits: Promise<unknown>[] = []
expect(await loadSource({ waitUntil: (promise) => void waits.push(promise) }, cache)).toBe("stale")
await Promise.all(waits)
expect(calls).toHaveLength(1)
expect(await entries.get("https://opencode.ai/changelog/source.md")?.clone().text()).toBe("stale")
})
test("resolves undefined when GitHub fails and nothing is cached", async () => {
stub(undefined)
expect(await loadSource({ waitUntil: () => {} }, memoryCache().cache)).toBeUndefined()
})
})
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"noUnusedLocals": true,
"lib": ["ES2023", "WebWorker"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"types": ["@cloudflare/workers-types", "bun"]
},
"include": ["src", "test"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "opencode-changelog",
"main": "src/index.ts",
"compatibility_date": "2026-07-15",
"workers_dev": false,
"preview_urls": false,
"assets": {
"directory": "./public",
},
// Exact path plus subpaths, so the Console keeps /changelog.json and the localized /<locale>/changelog pages.
"routes": [
{
"pattern": "opencode.ai/changelog",
"zone_name": "opencode.ai",
},
{
"pattern": "opencode.ai/changelog/*",
"zone_name": "opencode.ai",
},
],
"observability": {
"enabled": true,
},
}