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
142 changes: 142 additions & 0 deletions .github/workflows/announce-changelog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
name: Announce changelog

# Fans a merged changelog entry out to Discord / X / Resend. Channels come from
# the entry's `significance` (lib/announce-tiers.mjs), so an alpha bump reaches
# Discord and nothing else, while a 1.0 reaches everything.
#
# Three ways in:
# • workflow_call — sync-changelog.yml calls this after it publishes routine
# entries. It has to: that job pushes with GITHUB_TOKEN, and GitHub does not
# fire workflows for such a push, so a `push:` trigger alone would silently
# never announce a routine release.
# • push — a human merging a curation PR does trigger workflows.
# • workflow_dispatch — manual re-run for a specific entry.
#
# Two independent brakes, because this writes in public and a mistake is not
# retractable:
# 1. `ANNOUNCE_ENABLED` must be the string "true". Landing this file therefore
# posts nothing until someone deliberately turns it on.
# 2. The `announce` environment. Add required reviewers to it in repo settings
# and every run waits for a human before any request goes out.
# Beyond those: email is only ever *drafted* (see scripts/announce-changelog.mjs),
# and a channel with no secret is skipped rather than failing the run.

on:
workflow_call:
inputs:
files:
description: "Newline-separated content/changelog/*.mdx paths"
required: true
type: string
workflow_dispatch:
inputs:
files:
description: "Newline-separated content/changelog/*.mdx paths"
required: true
type: string
push:
branches: [main]
paths: ["content/changelog/**"]

permissions:
contents: write # push the announced/<slug> marker tags

concurrency: announce-changelog

jobs:
announce:
if: vars.ANNOUNCE_ENABLED == 'true'
runs-on: ubuntu-latest
environment: announce
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Collect entries to announce
id: collect
env:
INPUT_FILES: ${{ inputs.files }}
run: |
set -euo pipefail
if [ -n "${INPUT_FILES:-}" ]; then
candidates="$INPUT_FILES"
else
before="${{ github.event.before }}"
if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then
before="${{ github.event.after }}^"
fi
candidates=$(git diff --name-only --diff-filter=A \
"$before" "${{ github.event.after }}" \
-- 'content/changelog/*.mdx' || true)
fi

entries=""
while IFS= read -r f; do
[ -z "$f" ] && continue
[ -f "$f" ] || { echo "missing, skipping: $f"; continue; }
slug=$(basename "$f" .mdx)
# Marker tags make this idempotent: a re-run, or a second push
# touching the same file, must not post twice.
if git rev-parse -q --verify "refs/tags/announced/$slug" >/dev/null 2>&1; then
echo "already announced: $slug"; continue
fi
entries="${entries}${f}"$'\n'
done <<< "$candidates"

{ echo "entries<<EOF"; printf '%s' "$entries"; echo "EOF"; } >> "$GITHUB_OUTPUT"
if [ -n "$entries" ]; then echo "has=true" >> "$GITHUB_OUTPUT"; fi
printf 'to announce:\n%s\n' "${entries:-(none)}"

- uses: actions/setup-node@v4
if: steps.collect.outputs.has == 'true'
with:
node-version: 22

- name: Install xurl
if: steps.collect.outputs.has == 'true'
env:
XURL_VERSION: ${{ vars.XURL_VERSION }}
XURL_TOKENS: ${{ secrets.XURL_TOKENS }}
run: |
set -euo pipefail
if [ -z "${XURL_VERSION:-}" ]; then
# Not configured. Entries that need X will fail loudly at post time
# rather than being silently reported as skipped.
echo "XURL_VERSION not set — X posting unavailable this run."
exit 0
fi
# No `|| true` anywhere below: a failed download used to leave the job
# green and simply never post.
curl -fsSL -o /usr/local/bin/xurl \
"https://github.com/xdevplatform/xurl/releases/download/${XURL_VERSION}/xurl-linux-amd64"
chmod +x /usr/local/bin/xurl
printf '%s' "${XURL_TOKENS:-}" > "$HOME/.xurl"

- name: Announce
if: steps.collect.outputs.has == 'true'
env:
ENTRY_FILES: ${{ steps.collect.outputs.entries }}
SITE_URL: ${{ vars.SITE_URL }}
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
RESEND_AUDIENCE: ${{ vars.RESEND_AUDIENCE }}
RESEND_FROM: ${{ vars.RESEND_FROM }}
run: node scripts/announce-changelog.mjs

- name: Mark announced
if: steps.collect.outputs.has == 'true'
env:
# Via env, not `${{ }}` inside the script — expression interpolation
# splices the value into the shell before it runs.
ENTRY_FILES: ${{ steps.collect.outputs.entries }}
run: |
set -euo pipefail
git config user.name "bitrouter-bot"
git config user.email "bot@bitrouter.ai"
while IFS= read -r f; do
[ -z "$f" ] && continue
slug=$(basename "$f" .mdx)
git tag "announced/$slug"
git push origin "announced/$slug"
done <<< "$ENTRY_FILES"
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:
# regenerating, without failing unrelated PRs when upstream prices move.
# (Upstream drift is absorbed at deploy time by `prebuild`.)
- run: pnpm lint:tables
# Enforces the curation gate: a notable/highlight entry that still has
# its draft banner fails here rather than shipping raw release notes.
- run: pnpm lint:changelog
- run: pnpm build
env:
# Authenticates the docs/changelog sync fetches against GitHub
Expand Down
97 changes: 83 additions & 14 deletions .github/workflows/sync-changelog.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
name: Sync Changelog

# Generates draft changelog entries from bitrouter/bitrouter GitHub Releases and
# opens a PR for curation (the "curated, PR-assisted" flow). Three triggers:
# Generates changelog entries from bitrouter/bitrouter GitHub Releases. Three
# triggers:
# • repository_dispatch (bitrouter-release) — fired by the source repo's
# release workflow; near-instant per release.
# • workflow_dispatch — manual run, optionally for one tag or a backfill.
# • schedule — daily safety net in case a dispatch is missed.
#
# The run then forks on what it generated (see scripts/sync-changelog.mjs):
#
# routine only (prereleases, patches) → build-verify, then push to main.
# anything notable/highlight → open a PR for curation.
#
# The fork exists because the old unconditional PR was a bottleneck, not a
# quality gate: nobody wants to hand-write eight alpha entries, so PR #25 sat
# open for five weeks and the public changelog fell seven weeks behind. Routine
# entries now publish themselves; humans only write prose where it earns its
# keep, and scripts/check-changelog.mjs fails CI if they skip it.
on:
repository_dispatch:
types: [bitrouter-release]
Expand All @@ -22,14 +33,19 @@ permissions:
contents: write
pull-requests: write

concurrency: sync-changelog

jobs:
sync:
name: Generate changelog draft + PR
name: Generate changelog entries
runs-on: ubuntu-latest
outputs:
published: ${{ steps.publish.outputs.published }}
routine: ${{ steps.gen.outputs.routine }}
steps:
# Credentials persist here (unlike the other workflows) because the
# routine path pushes the entries it generates.
- uses: actions/checkout@v6
with:
persist-credentials: false

- uses: actions/setup-node@v4
with:
Expand All @@ -44,23 +60,76 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/sync-changelog.mjs

# ── Curation path: something needs a human ──────────────────────────────
- name: Open pull request
if: steps.gen.outputs.count != '0'
if: steps.gen.outputs.curated_count != '0'
uses: peter-evans/create-pull-request@v7
with:
branch: changelog/sync
base: main
title: "docs(changelog): sync new release entries"
commit-message: "docs(changelog): add draft entries from bitrouter releases"
title: "docs(changelog): curate new release entries"
commit-message: "docs(changelog): add entries from bitrouter releases"
labels: changelog
body: |
Auto-generated **draft** changelog entries from `bitrouter/bitrouter` releases.
Changelog entries generated from `bitrouter/bitrouter` releases.

These need curation before merging — write the `title`, `description`
and prose, then delete the `AUTO-GENERATED DRAFT` banner. CI
(`pnpm lint:changelog`) fails while any banner is still present.

Each new file under `content/changelog/` is a draft — curate the
`title`, `description`, and prose, then merge. Re-running the sync
will not overwrite an existing entry.
Needing curation:
```
${{ steps.gen.outputs.curated }}
```

Newly added:
Routine entries included in this PR (publish as-is, no editing needed):
```
${{ steps.gen.outputs.created }}
${{ steps.gen.outputs.routine }}
```

# ── Publish path: routine releases only, no human in the loop ───────────
- uses: pnpm/action-setup@v4
if: steps.gen.outputs.count != '0' && steps.gen.outputs.curated_count == '0'

- name: Install dependencies
if: steps.gen.outputs.count != '0' && steps.gen.outputs.curated_count == '0'
run: pnpm install --frozen-lockfile

# A push to main by GITHUB_TOKEN does not trigger the CI workflow (GitHub's
# recursion safeguard), so this build IS the gate for what we're about to
# publish. It also catches an entry whose MDX doesn't compile.
- name: Verify the site still builds
if: steps.gen.outputs.count != '0' && steps.gen.outputs.curated_count == '0'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm build

- name: Publish routine entries
id: publish
if: steps.gen.outputs.count != '0' && steps.gen.outputs.curated_count == '0'
run: |
set -euo pipefail
git config user.name "bitrouter-bot"
git config user.email "bot@bitrouter.ai"
# Only the changelog and its generated date — `prebuild` also
# regenerates the model/provider tables from upstream, and that drift
# is not ours to commit here.
git add content/changelog lib/changelog-latest.ts
if git diff --cached --quiet; then
echo "Nothing staged; nothing to publish."
exit 0
fi
git commit -m "docs(changelog): publish routine release entries"
git push origin HEAD:main
echo "published=true" >> "$GITHUB_OUTPUT"

# This push used GITHUB_TOKEN, and GitHub does not fire workflows for such a
# push — so announce-changelog.yml's `push:` trigger will never see it. Call it
# directly instead. (It has its own ANNOUNCE_ENABLED + environment brakes.)
announce:
needs: sync
if: needs.sync.outputs.published == 'true'
uses: ./.github/workflows/announce-changelog.yml
with:
files: ${{ needs.sync.outputs.routine }}
secrets: inherit
17 changes: 15 additions & 2 deletions app/changelog/(index)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import "@/components/landing/zed/zed.css";
import { getChangelogItems } from "@/lib/source";
import { changelogSource, getChangelogItems } from "@/lib/source";
import { ChangelogFeed } from "@/components/changelog/changelog-feed";
import { Kicker } from "@/components/landing/zed/primitives";
import { getMDXComponents } from "@/mdx-components";
import type { Metadata } from "next";
import type { ReactNode } from "react";

export default async function ChangelogIndexPage() {
const items = getChangelogItems();

// Render every entry's notes here, on the server, and hand them to the feed as
// elements keyed by URL. The feed is a client component (it clears the nav's
// unseen dot), and MDX bodies can't be compiled there — but a server component
// may pass already-rendered elements down as props. The per-release pages stay
// as permalinks for sharing, RSS, and search.
const bodies: Record<string, ReactNode> = {};
for (const page of changelogSource.getPages()) {
const MDX = page.data.body;
bodies[page.url] = <MDX components={getMDXComponents({})} />;
}

return (
<div className="zed-bg">
<section style={{ position: "relative" }}>
Expand Down Expand Up @@ -66,7 +79,7 @@ export default async function ChangelogIndexPage() {
No entries yet. Check back soon.
</p>
) : (
<ChangelogFeed items={items} />
<ChangelogFeed items={items} bodies={bodies} />
)}
<div style={{ height: 60 }} />
</div>
Expand Down
19 changes: 12 additions & 7 deletions app/changelog/(posts)/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "@/components/landing/zed/zed.css";
import { changelogSource } from "@/lib/source";
import { headlineOf } from "@/lib/changelog";
import { notFound } from "next/navigation";
import Link from "next/link";
import { getMDXComponents } from "@/mdx-components";
Expand Down Expand Up @@ -55,14 +56,12 @@ export default async function ChangelogEntryPage({ params }: Props) {
)}
</div>

{/* Headline = description, matching the index. The sync writes the
version string as the title, so using it here gave entry pages a
headline of "v1.0.0-alpha.18"; the version is already the chip above. */}
<h1 className="zed-display" style={{ fontSize: "clamp(32px, 5vw, 44px)", lineHeight: 1.08, margin: "14px 0 0" }}>
{page.data.title}
{headlineOf(page.data)}
</h1>
{page.data.description && (
<p style={{ fontFamily: "var(--font-mono)", fontSize: 16, lineHeight: 1.6, color: "var(--z-ink-4)", margin: "16px 0 0" }}>
{page.data.description}
</p>
)}

<div className="zed-article" style={{ marginTop: 28, paddingBottom: 76 }}>
<MDX components={getMDXComponents({})} />
Expand All @@ -86,8 +85,14 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const page = changelogSource.getPage([slug]);
if (!page) notFound();
// Keep the version in the <title> — it's what people search for — but don't
// repeat it when the headline already is the version string.
const headline = headlineOf(page.data);
return {
title: page.data.title,
title:
page.data.version && headline !== page.data.version
? `${page.data.version} — ${headline}`
: headline,
description: page.data.description,
alternates: { canonical: `https://bitrouter.ai${page.url}` },
};
Expand Down
Loading
Loading