Content sources
Turn GitHub releases into a product changelog
Write release notes once on GitHub and get a changelog timeline, a page per release, and an RSS feed on your docs site, rebuilt every time you publish.
By Hayden Bleasel9 min read

Add Blume's githubReleases() content source. It reads a repository's releases from the GitHub API each time the site builds, and turns every release into a changelog entry. You write release notes once, on GitHub, and your docs site gets a page per release, a timeline at /changelog, and an RSS feed at /changelog/rss.xml.
By the end of this guide you have that changelog beside your docs, with prereleases handled the way you choose, and a GitHub Actions workflow that rebuilds the site when you publish a release. That last part matters: Blume builds the site ahead of time, so a new release shows up only after the next build.
If you'd rather keep release notes as files in your docs repository, you don't need this source. Any page with type: changelog in its frontmatter joins the same timeline and feed (see Changelogs). The source reads from api.github.com only, so releases on GitLab or GitHub Enterprise Server need another route. The examples use a fictional acme/sdk repository and a docs site at docs.acme.example.
Add the releases source
By default, Blume reads one folder of Markdown. To add releases beside it, list both sources in your config:
import { defineConfig } from "blume";
import { filesystem, githubReleases } from "blume/sources";
export default defineConfig({
title: "Acme",
deployment: {
site: "https://docs.acme.example",
},
content: {
sources: [
filesystem({ root: "docs" }),
githubReleases({
owner: "acme",
repo: "sdk",
prefix: "changelog",
}),
],
},
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "Changelog", path: "/changelog" },
],
},
});- filesystem() keeps your existing docs. A
sourceslist replaces the default folder, so the folder needs its own entry. - githubReleases() reads the releases of
acme/sdk.prefixputs each release page under/changelog/, at a path made from its tag:v1.3.0becomes/changelog/v1-3-0. - deployment.site gives the feed absolute links. A build with no site URL emits no feed. On Vercel and Netlify, Blume detects the URL, so you can leave it out there.
- navigation.tabs adds a Changelog tab that opens the timeline. Tabs also scope the sidebar, so release pages list releases and your docs pages don't.
The source takes a few more options:
| Option | Default | What it does |
|---|---|---|
prereleases | false | Includes prereleases |
drafts | false | Includes draft releases |
limit | 100 | How many releases to include, newest first |
pollInterval | none | Seconds between re-fetches while blume dev runs |
Give the build a GitHub token
A public repository works without a token. GitHub allows 60 unauthenticated API requests an hour per IP address, and a build uses one request per 100 releases, so a shared build machine can still run out. Blume warns with BLUME_MISSING_SECRET whenever GITHUB_TOKEN is unset, public repository or not.
A private repository needs a token: without one, GitHub answers 404 and the changelog comes out empty. Create a fine-grained personal access token for that one repository, with Contents set to Read-only. For local work, put it in .env.local, which Blume loads from the project folder up to the repository root. Keep that file out of Git.
GITHUB_TOKEN=github_pat_replace_meWhere the site builds, set GITHUB_TOKEN in the host's environment variables. In GitHub Actions, the workflow's own token can read the repository it runs in and any public one, so pass ${{ secrets.GITHUB_TOKEN }} to the build step. Releases in another private repository need a personal access token saved as a secret instead.
Preview the changelog
npx blume devOpen http://localhost:4321/changelog. The timeline lists every release newest first, grouped by year, one row each with its title, a Release or Prerelease tag, and its date. Each row links to the release's own page, where the notes render in full. Here's where each part comes from:
| On GitHub | On your site |
|---|---|
| Release name, or the tag when it has none | Page title and timeline label |
| Tag | Page path, and the version (v1.3.0 becomes 1.3.0) |
| Publish date | Timeline date and order, and the feed's date |
| Prerelease flag | The Release or Prerelease tag |
| Notes | Page body, plus a meta description summarized from its text |
| Release URL | The page's Edit on GitHub link |
Blume adjusts the notes on the way in. Headings move up so the top level is an h2 under the page title, since tools like Changesets open sections at ### Patch Changes. A link to your own deployment.site becomes a root-relative path, so it keeps working on preview deploys. A link that isn't a web, mail, phone, or relative address, like javascript:, keeps only its text. Raw HTML in the notes renders as written, so point the source only at repositories you trust.
The feed is at /changelog/rss.xml. Each item carries the release's title, page URL, and publish date, newest first, up to 50 items. Tune it under seo.rss: raise limit, or remove "changelog" from types to keep the timeline without a feed. To redesign the timeline itself, add a custom page at pages/changelog.astro, and Blume stops generating its own.
The dev server fetches releases once, then serves that snapshot from .blume/cache/, even after a restart. To pick up a release you published while it runs, fetch again (the running server reloads):
npx blume syncOr set pollInterval on the source to re-fetch on a timer. A build always fetches fresh.
Handle prereleases
Prereleases stay off the site by default. A beta you publish on GitHub never gets a page, a timeline row, or a feed item. To show them, turn them on:
githubReleases({
owner: "acme",
repo: "sdk",
prefix: "changelog",
prereleases: true,
}),Each prerelease then gets the Prerelease tag on the timeline and its own page, named after its tag: v1.4.0-beta.1 becomes /changelog/v1-4-0-beta-1. The stable v1.4.0 gets a separate page when it ships. The feed includes prereleases too, so feed readers see every beta. If you later turn prereleases off, their pages disappear, so add a redirect for any beta page other sites link to.
Rebuild the site when you publish a release
Publishing a release on GitHub doesn't touch your docs site. The release reaches readers on the next build, so give GitHub a way to start one. A workflow on the release event does it. Its published type fires for releases and prereleases, edited for corrected notes, and deleted when you remove one. Put the workflow in the repository that publishes the releases, even when the docs live somewhere else.
When your host builds the site
Vercel and Netlify build when you push to Git, and a release isn't a push. Create a deploy hook for your production branch instead. In Vercel, it's under the project's Settings, then Git, then Deploy Hooks. In Netlify, it's under Project configuration, then Developer settings, then Continuous deployment, then Build hooks. Anyone with the URL can start a build, so save it as a repository secret named DOCS_DEPLOY_HOOK_URL and call it on each release:
name: Rebuild docs on release
on:
release:
types: [published, edited, deleted]
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Call the docs deploy hook
run: curl -fsS -X POST "$DEPLOY_HOOK_URL"
env:
DEPLOY_HOOK_URL: ${{ secrets.DOCS_DEPLOY_HOOK_URL }}The hook builds your production branch as it is now, so the release's notes arrive with your latest docs.
When GitHub Actions builds the site
If a workflow builds and deploys the site, as in the GitHub Pages guide, don't add the release trigger to that workflow. A run started by a release checks out the tagged commit, which would deploy your docs as they were at that tag. Have the release start the docs workflow on main instead:
name: Rebuild docs on release
on:
release:
types: [published, edited, deleted]
permissions:
actions: write
contents: read
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Run the docs workflow on main
run: gh workflow run docs.yml --ref main --repo "$GITHUB_REPOSITORY"
env:
GH_TOKEN: ${{ github.token }}The docs workflow needs workflow_dispatch in its on block, and its build step needs the token. If the docs build in another repository, name it in --repo and use a personal access token with write access to that repository's Actions as GH_TOKEN.
- name: Build docs
run: npx blume build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}When a workflow publishes your releases
If a tool like Changesets creates releases from a workflow with the workflow's own GITHUB_TOKEN, the workflow above never runs. GitHub doesn't start workflows from events that token causes, apart from workflow_dispatch and repository_dispatch. Start the rebuild from the release workflow itself, after the step that publishes. With changesets/action under the step id changesets:
- name: Rebuild the docs
if: steps.changesets.outputs.published == 'true'
run: curl -fsS -X POST "$DEPLOY_HOOK_URL"
env:
DEPLOY_HOOK_URL: ${{ secrets.DOCS_DEPLOY_HOOK_URL }}Blume's own changelog works this way: it's built from Blume's GitHub releases, and the release workflow redeploys the site after each publish.
Publish a test release
Write the notes as you would for readers. This file becomes the page body:
SMS templates and a faster retry policy.
### Features
- Send SMS from a saved template with `templates.render()`. See [Templates](https://docs.acme.example/templates).
### Fixes
- Retries no longer resend a message the API already accepted.Publish it with the GitHub CLI, then check that the rebuild workflow ran. If the tag doesn't exist yet, gh creates it from the latest commit on the default branch.
gh release create v1.3.0 --repo acme/sdk --title "v1.3.0" --notes-file notes.md
gh run list --repo acme/sdk --event release --limit 1Once the deploy finishes, the timeline's newest row reads v1.3.0, tagged Release, and links to /changelog/v1-3-0. From these notes, Blume builds the same entry you'd get by writing this file by hand (shown for a release published September 15, 2026 at 14:02 UTC):
---
changelog:
category: Release
version: 1.3.0
date: '2026-09-15T14:02:11Z'
seo:
description: >-
SMS templates and a faster retry policy. Send SMS from a saved template with
templates.render(). See Templates. Retries no longer resend a message the
API…
title: v1.3.0
type: changelog
---
SMS templates and a faster retry policy.
## Features
- Send SMS from a saved template with `templates.render()`. See [Templates](/templates).
## Fixes
- Retries no longer resend a message the API already accepted.And the feed gains an item:
<item>
<title>v1.3.0</title>
<link>https://docs.acme.example/changelog/v1-3-0</link>
<guid isPermaLink="true">https://docs.acme.example/changelog/v1-3-0</guid>
<pubDate>Tue, 15 Sep 2026 14:02:11 GMT</pubDate>
</item>To try a prerelease, publish one with --prerelease. It appears only if you set prereleases: true:
gh release create v1.4.0-beta.1 --repo acme/sdk --prerelease --title "v1.4.0 beta 1" --notes "Try scheduled sends before they ship."When GitHub can't be reached
Every build fetches releases fresh. If that fetch fails, from a rate limit, a missing token, or an outage, what ships depends on whether the build has a snapshot from an earlier fetch in .blume/cache/:
- With a snapshot, Blume builds from it and warns with
BLUME_SOURCE_OFFLINE. Readers see the changelog as of the last good fetch. - Without one, as on a fresh CI runner or any build that starts without
.blume/, the changelog comes out empty. Blume warns withBLUME_SOURCE_UNAVAILABLEand the build still succeeds:/changelogsays "No changelog entries yet.", the release pages are gone, and there's no feed.
That's by design, so a changelog problem never blocks a docs deploy. If you'd rather stop the deploy than ship an empty changelog, fail the build step on the warning. In a GitHub Actions build:
- name: Build docs
shell: bash
run: npx blume build 2>&1 | tee build.log
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Stop if the changelog is empty
run: |
if grep -q BLUME_SOURCE_UNAVAILABLE build.log; then
echo "::error::GitHub releases could not be fetched."
exit 1
fishell: bash runs the step with pipefail, so a failed build still fails the step through the pipe.
Troubleshooting
The changelog says "No changelog entries yet."
The fetch failed with no snapshot to fall back on. The BLUME_SOURCE_UNAVAILABLE warning names the API URL and GitHub's status code, like -> 404. A 404 means a private repository without a token, a token that can't see it, or a wrong owner or repo. A 403 or 429 usually means a rate limit: set GITHUB_TOKEN.
A new release doesn't appear
Check that a build ran after you published it: gh run list shows whether the rebuild workflow fired. If the release came from a workflow using GITHUB_TOKEN, it won't have: see Rebuild the site. In blume dev, run npx blume sync. If the release is a prerelease or a draft, the source leaves it out unless you turn that option on.
The build warns that GITHUB_TOKEN is not set
BLUME_MISSING_SECRET is a warning, not an error, and it shows for public repositories too. The build goes on unauthenticated. Set the token to silence it and to get GitHub's higher rate limit.
Issue numbers and @mentions don't link
GitHub turns #123 and @name into links only on its own pages. The API returns the notes as you wrote them, so on your site they stay plain text. Write the full link, like [#123](https://github.com/acme/sdk/pull/123), where readers need one.
The build fails with BLUME_DUPLICATE_ROUTE
A release page and another page share a path. That happens when your docs folder also has a changelog/ folder, like the one blume init's changelog template writes, with a file named after a tag, or when another source under the same prefix has a page at that path. Delete the hand-written entry or give one source another prefix.
Older releases are missing
By default the source includes the 100 newest releases, counted after it leaves out prereleases and drafts. Raise limit to include more. Each further 100 releases costs one more API request per build.
Next step
Add your releases
Keep filesystem selected and add github-releases when init asks where your content lives, then set your owner and repo in the config.
npx blume initA step here not working for you? Report a broken step.