# 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](/starlight-pydocs/guides/versioned-docs/).

```js title="astro.config.mjs" {7-13}
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' },
        ],
      },
    },
  ],
});
```

## Which objects get a label

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`.

## How the labels are computed

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.

## Listing the releases automatically

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

```js title="astro.config.mjs" {8-15,24}
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.

:::caution[Slow with many releases]
Every ref costs one worktree checkout and one griffe run on the first build. A package with a few
dozen tags takes seconds; a package with hundreds takes minutes, and a CI job that starts with an
empty cache pays that on every build.

Two ways to keep it short:

- **Minor releases only.** `--list 'v*.*.0'` labels `2.1` rather than `2.1.3`, which is what a
  reader needs to pick a version to install.
- **Recent releases only.** Add `.slice(-8)` after the filter. The oldest listed ref is the
  baseline, so anything older than it is simply unlabelled.

See [Caching](#caching): the dumps are keyed by commit, so a warm cache does no git work at all.
:::

## What your build needs

### A git checkout with history

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`:

```yaml title=".github/workflows/docs.yml" {2-3}
- 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`.

### Search paths inside that repository

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.

### Extraction rather than a pinned dump

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/guides/versioned-docs/).

### Refs that contain the package

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.

## Caching

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.

### In GitHub Actions

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:

```js title="astro.config.mjs" {5}
starlightPydocs({
  packages: [
    { name: 'mypkg', search: ['../src'], versions: { refs } },
  ],
  cacheDir: '.cache',
});
```

```yaml title=".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.

## Deprecations are separate

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.

## In the Content Layer loader

The [loader](/starlight-pydocs/guides/vanilla-astro/#the-content-layer-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`:

```astro title="src/pages/whats-new.astro" "addedIn"
---
import { getCollection } from 'astro:content';

const added = (await getCollection('api')).filter((entry) => entry.data.addedIn === '2.0');
---
```