Skip to content

Version annotations

Added in 2.0 appears beside an object’s source link, in the row under its heading. It answers the question every reader arrives with: is this in the release I have installed? You only need to supply a list of past git refs from your own repository. Where the sourceLink preset says which forge you are on, the version links to that release’s page.

This page describes how to label the pages of one release. If you want each release to have its own set of pages at its own URL instead, see Versioned docs.

astro.config.mjs
starlightPydocs({
packages: [
{
name: 'mypkg',
search: ['../src'],
versions: {
// Oldest first. The working tree is the
// newest version, so it is not listed.
refs: [
{ ref: 'v1.0.0', label: '1.0' },
{ ref: 'v1.1.0', label: '1.1' },
{ ref: 'v2.0.0', label: '2.0' },
],
},
},
],
});

With this configuration, a class introduced in 2.0 reads Added in 2.0. A method that has existed since 1.1 reads Added in 1.1. The text is your label, exactly as you wrote it. Two kinds of objects get no label:

  • Objects in the oldest listed ref. “Added in 1.0” on two-thirds of a package would be noise. It would also be wrong for any object older than 1.0. Listing 1.0 as the oldest ref makes it the baseline, not a label.
  • Objects in none of the listed refs. These objects exist only in the current source, which has no version number to show. Add a ref for the latest release, and everything added since then stands out by having none. Add a ref for a release you are about to cut, and its new objects get a label.

Removed objects get no label either. They are not in the current model, so nothing can look them up.

Inherited and re-exported members are labelled by where they are defined. For example, mypkg.Report, re-exported from mypkg.report, carries the history of mypkg.report.Report.

Griffe’s own griffe check command prints prose, not data. So starlight-pydocs compares dumps instead:

  1. Each ref is resolved to a commit with git rev-parse --verify <ref>^{commit}.
  2. That commit is checked out into the cache directory with git worktree add --detach <cacheDir>/starlight-pydocs/worktrees/<sha>. This shares the object database with your repository instead of cloning it.
  3. The package’s search paths are rebased onto the worktree. The same griffe dump -f -d <style> command runs there, with the same extensions and docstring options as the current source. This keeps the two dumps comparable.
  4. Every object path in each dump is collected. Then starlight-pydocs walks the refs from oldest to newest: the first ref that contains a path is the release that introduced it.

The result, a map from object path to version label, is written beside the package’s dump. It is read back when pages render. Only the label text reaches the browser.

refs is a plain array, so build it from your tags instead of editing the config on every release:

astro.config.mjs
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
// The repository root, one level above this Astro project.
const repo = fileURLToPath(new URL('..', import.meta.url));
const opts = { cwd: repo, encoding: 'utf8' };
// Every release tag, oldest first. `v:refname` sorts 1.10 after
// 1.9, which an alphabetical sort does not.
const args = ['tag', '--list', 'v*', '--sort=v:refname'];
const refs = execFileSync('git', args, opts)
.split('\n')
// Releases only: drops `v2.0.0.dev0` and other prereleases.
.filter((tag) => /^v\d+\.\d+(\.\d+)?$/.test(tag))
.map((tag) => ({ ref: tag, label: tag.slice(1) }));
starlightPydocs({
packages: [
{
name: 'mypkg',
search: ['../src'],
// A checkout with no tags gives no labels, rather than a
// build that stops on a ref it cannot resolve.
...(refs.length > 0 && { versions: { refs } }),
},
],
});

The oldest tag stays the baseline and gets no label, so the list grows one at the new end each time you release.

starlight-pydocs extracts the sources from your own repository. A shallow clone with no tags cannot resolve v1.0.0. In GitHub Actions, set fetch-depth: 0:

.github/workflows/docs.yml
- uses: actions/checkout@v7
with:
fetch-depth: 0

Without this, the build stops and names the missing ref: 'mypkg' lists the version ref 'v1.0.0', which /repo does not have.

starlight-pydocs materialises the refs as a worktree of the repository that the first search path belongs to, and rebases every search path onto it. A path outside that repository has no equivalent in the worktree, and the build reports this.

You cannot combine versions with source: { file } or source: { url }. A pinned dump is one release with no history behind it. Validation rejects this combination; it does not fail silently. To document an old release as pages of its own, add a second packages entry instead. See Versioned docs.

starlight-pydocs extracts each listed ref with the same package name and search layout. A release from before the package existed at that path fails extraction. Start your list at the first release you can extract.

A commit cannot change, so starlight-pydocs caches a ref’s dump permanently at <cacheDir>/starlight-pydocs/versions/<name>-<sha>-<options>/dump.json. The first build checks out one worktree per ref and runs griffe once per ref. Every later build reads the cached dumps and does no git work. Worktrees are keyed by sha and reused, so each one costs only one checkout.

The options half of the key covers the docstring style, the docstring options, the extensions, forceInspection, and the repository-relative search paths. If you change any of these, starlight-pydocs re-extracts the refs. Otherwise the comparison would compare dumps made in two different ways.

cacheDir defaults to node_modules/.astro. Point it at a directory your CI caches, so the git work happens once across builds instead of once per build.

Cache the ref dumps the same way you cache pnpm’s store or uv’s: with actions/cache, keyed on the tag list. A build then extracts only the releases the cache does not already have.

Put the cache directory outside node_modules, so installing dependencies cannot remove it:

astro.config.mjs
starlightPydocs({
packages: [
{ name: 'mypkg', search: ['../src'], versions: { refs } },
],
cacheDir: '.cache',
});
.github/workflows/docs.yml
steps:
- uses: actions/checkout@v7
with:
# Version refs are resolved in your own repository.
fetch-depth: 0
- id: refs
run: |
key=$(git tag --list 'v*' | sha256sum | cut -c1-16)
echo "key=$key" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
# Only the dumps. Worktrees are rebuilt on demand and are large.
path: .cache/starlight-pydocs/versions
key: pydocs-versions-${{ steps.refs.outputs.key }}
restore-keys: pydocs-versions-

A cache entry cannot be overwritten, so the key has to change when the tag list does. restore-keys is what makes that cheap: the new key misses, the prefix match restores the previous build’s dumps, griffe runs for the new tag only, and the job saves the whole set under the new key.

Use the cache rather than an artifact. Artifacts are for files a build publishes; they are not restored into the next run on their own.

The dumps are megabytes each, so a package with many releases makes a large cache. GitHub’s limits apply: 10 GB per repository, and an entry that goes unread for 7 days is evicted, which costs you one slow build.

The Deprecated badge and its notice come from the docstring: a Deprecated: block, a deprecated section, or griffe’s is_deprecated. They need no git history. Deprecated since 0.3 is what your docstring says. Added in 1.1 is what the refs say. The two are independent, and both can appear together. Only Deprecated is a badge; the version is plain text in the row beneath the heading.

The loader takes the same versions option. It puts the label on each entry as addedIn. To build a “what is new in this release” page, filter with getCollection:

src/pages/whats-new.astro
---
import { getCollection } from 'astro:content';
const added = (await getCollection('api')).filter((entry) => entry.data.addedIn === '2.0');
---