DreamLake

Releases & versioning

A dockit site can keep every released version of its docs online, each at its own URL, with an in-site switcher in the topbar. This guide is the consumer-facing playbook: the pieces you need, the scripts that drive them, and the deploy mechanics — using this workspace as the worked example throughout.

The library ships a copy-in release kit with all of the pieces below — see Ship it with your own site.

Two independent version axes

Do not conflate these; they move on their own schedules:

  1. Your product/library semver — the thing the docs document (here: @dreamlake/dockit, published to npm). Its version lives in the package's own package.json and shows in the topbar's version chips. A product can have its own dropdown and manifest.
  2. The docs-deploy version — a version stamp for the docs site itself, so readers can pin "the docs as of 0.2.0". It lives in two files: the workspace root package.json and the manifest at docs/public/versions.json.

A docs release can ship without a library release (a big guide lands) and vice versa (a patch release with no doc changes worth freezing).

On this site, the Dockit v0.2.15 badge selects /dockit-versions.json. That product manifest currently contains only 0.2.15, linked to the current site. /versions.json preserves the separate historical docs-build sequence; it must not relabel the Dockit package badge. The Python Autodoc tab displays main plus the source revision with prefix: '' and no dropdown, because no package release is implied by this documentation integration.

ts
versionChips: [{
  label: 'dockit', version: __DOCKIT_VERSION__, dropdown: true,
  manifestUrl: '/dockit-versions.json',
}]

// Python Autodoc tab override
versionChips: [{ label: 'autodoc-py', version: __AUTODOC_REVISION__, prefix: '' }]

prefix changes the displayed prefix (default v). manifestUrl selects a path or an absolute manifest URL; it defaults to /versions.json. These settings are documented in the VersionChip reference.

versions.json — the manifest

The default docs-build manifest is served at /versions.json. A chip with dropdown: true consumes this file unless manifestUrl selects a product-specific manifest. Both manifests use the same schema:

docs/public/versions.jsonjson
{
  "current": "0.2.1",
  "stable": "0.1.0",
  "aliases": {
    "latest": "https://dockit.dreamlake.ai",
    "stable": "https://stable.dockit.dreamlake.ai"
  },
  "versions": [
    { "version": "0.2.1", "url": "https://v0-2-1--dockit-docs.netlify.app", "date": "2026-07-20" },
    { "version": "0.2.0", "url": "https://v0-2-0--dockit-docs.netlify.app", "date": "2026-07-20" },
    { "version": "0.1.0", "url": "https://v0-1-0--dockit-docs.netlify.app" }
  ]
}
  • current — the newest release; what the topbar chip labels itself when the hostname gives no better answer.
  • stable — the version the stable alias points at (may trail current).
  • aliases — stable URLs that always point somewhere sensible; latest is your production domain.
  • versions[] — newest first; each entry's url is the documentation destination for that version. Only describe it as frozen when its deployment is actually retained unchanged. date is optional but useful in the popover.

The switcher fetches this manifest at runtime, so a reader on an old frozen deploy still sees the full, current version list — the manifest on your production domain is the source of truth. That fetch is cross-origin from a frozen v/* deploy, so production must serve the selected manifest with a CORS header (/versions.json is already in the netlify.example.toml template):

netlify.tomltoml
[[headers]]
for = "/versions.json"

[headers.values]
Access-Control-Allow-Origin = "*"

Use the same header for /dockit-versions.json when a product chip selects that path. Changing one manifest does not change the other.

set-version.mjs — the bump

One script updates both files atomically. This is the actual script this workspace runs (embedded from scripts/set-version.mjs, not a copy):

#!/usr/bin/env node
/**
 * Usage: node scripts/set-version.mjs <version>
 *
 * Updates the docs version in two places:
 *   - package.json at the workspace root (the canonical version used by
 *     version-branch.sh)
 *   - docs/public/versions.json (the version manifest served at /versions.json,
 *     consumed by the in-site version switcher)
 *
 * versions.json schema:
 *   {
 *     "current": "<latest>",
 *     "versions": [{ "version", "url", "date" }, ...]   // newest first
 *   }
 *
 * Idempotent: if the version already exists, its url/date are refreshed.
 */
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const version = process.argv[2];

if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
  console.error('Usage: node scripts/set-version.mjs <x.y.z>');
  process.exit(1);
}

const pkgPath = resolve(__dirname, '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
pkg.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');

const manifestPath = resolve(__dirname, '..', 'docs', 'public', 'versions.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));

// Canonical per-version URL: the Netlify branch-deploy address for branch
// v<x.y.z>. These always resolve once branch deploys are on — no DNS setup.
// (Pretty subdomains like v0-2-0.dockit.dreamlake.ai would need a manual
// Netlify domain-management step per release; we deliberately don't use them.)
const url = `https://v${version.replace(/\./g, '-')}--dockit-docs.netlify.app`;
const date = new Date().toISOString().slice(0, 10);

const existing = manifest.versions.find((v) => v.version === version);
if (existing) {
  existing.url = url;
  existing.date = date;
} else {
  manifest.versions.unshift({ version, url, date });
}
manifest.current = version;

writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');

console.log(`Updated docs version to ${version}`);
console.log(`  package.json  → version: "${version}"`);
console.log(`  versions.json → current: "${version}", entry: ${url}`);
bash
pnpm set-version 0.2.1

Idempotent — re-running refreshes the existing entry's url/date instead of duplicating it. Note the URL it writes: the Netlify branch-deploy address, not a pretty subdomain — see the URL scheme below for why.

version-branch.sh — the frozen deploy

Each release is frozen by pushing HEAD to a v<x.y.z> branch. Again the actual script, embedded from scripts/version-branch.sh:

#!/usr/bin/env bash
# Push current HEAD to a v<version> branch for Netlify branch deploy.
# Netlify serves branch "vx.y.z" at v<x-y-z>--<site>.netlify.app.
set -euo pipefail

VERSION=$(node -e "console.log(require('./package.json').version)")
BRANCH="v${VERSION}"

echo "Pushing to branch: ${BRANCH}"
git push origin "HEAD:refs/heads/${BRANCH}" --force
echo "Branch deploy will be available at: https://${BRANCH//./-}--dockit-docs.netlify.app"

With branch deploys enabled, Netlify builds that branch at its version URL. Keep the branch and deployed artifact unchanged to preserve that release snapshot.

Netlify branch deploys — the URL scheme

Enable branch deploys on your Netlify site (Site settings → Build & deploy → Branches and deploy contexts → "Deploy all branches", plus empty [context.branch-deploy] / [context.deploy-preview] blocks in netlify.toml). Then every pushed branch serves at:

https://<branch>--<site-name>.netlify.app

with non-alphanumeric characters in the branch name dashed — so branch v0.2.1 on site dockit-docs serves at v0-2-1--dockit-docs.netlify.app. These URLs use Netlify's branch-deploy domain and need no custom DNS once the corresponding deployment exists. They are what set-version.mjs writes into the manifest.

A CLI netlify deploy --alias creates a named draft deploy, not a branch deploy. Netlify advises against reusing a deployed branch name as an alias. The existing prod script still includes that legacy alias step; its predictable URL is not evidence of branch-deploy retention. Create real branch deploys through a connected repository with branch deployment enabled. See the Netlify CLI reference.

Netlify exempts the latest successful deploy of each branch from automatic deploy deletion. Older deploys and draft aliases can be removed under the site's retention policy. Keep each release branch unchanged, verify its successful branch deploy, and retain a separate build archive with its source revision and checksum. A Git branch alone does not save the rendered output. See Netlify deploy retention.

Pretty subdomains are optional — and manual

You can have v0-2-1.your-domain.com instead, but only if your DNS is on Netlify and you manually add each branch subdomain in the Netlify domain-management UI, once per release — the special NETLIFY-type DNS records this creates cannot be set up via the API, so it cannot be scripted. Unless you have a strong cosmetic reason, skip this: the v<x-y-z>--<site>.netlify.app form avoids that per-version DNS setup.

The promotion flow — prod and staging

Merging to main does not change production here. Production and staging are promoted from the CLI, each ending in a force-push to a record branch so the Netlify dashboard reflects what is actually live:

bash
pnpm prod      # version-branch → build → netlify deploy --prod → force-push netlify-production
pnpm staging   # build → netlify deploy --prod (staging site) → force-push netlify-staging

Both deploy the prebuilt docs/dist/client with --no-build to a pinned --site id. The typical release, end to end:

bash
pnpm set-version 0.2.1
git commit -am "release: docs 0.2.1"
pnpm prod

pnpm staging is the same promotion aimed at a second Netlify site, with no version bookkeeping — use it to preview a release first.

Monorepo gotcha: pass --filter <pkg>

In a pnpm workspace, a bare netlify deploy cannot tell which project it belongs to and hangs on an interactive project picker. Pin it with --filter <your-docs-package> (this workspace passes --filter docs) — keep the flag if you invoke the CLI by hand.

The other axis: releasing the library itself

Publishing the documented package to npm is deliberately separate from all of the above. This workspace wires it as the release pane in mprocs.yaml: build the library, then pnpm publish with a fresh OTP fetched at publish time. Docs releases never publish npm packages, and npm releases never touch versions.json.

Ship it with your own site

@dreamlake/dockit ships all four pieces as templates:

bash
ls node_modules/@dreamlake/dockit/templates/
# set-version.mjs  version-branch.sh  versions.example.json  netlify.example.toml

Copy them in, then edit the marked EDIT ME block (your Netlify site name) at the top of each script:

bash
cp node_modules/@dreamlake/dockit/templates/set-version.mjs scripts/
cp node_modules/@dreamlake/dockit/templates/version-branch.sh scripts/
cp node_modules/@dreamlake/dockit/templates/versions.example.json docs/public/versions.json
cp node_modules/@dreamlake/dockit/templates/netlify.example.toml netlify.toml

Wire the scripts into your root package.json:

json
"scripts": {
  "set-version": "node scripts/set-version.mjs",
  "version-branch": "bash scripts/version-branch.sh"
}

The templates assume the same layout as this workspace — root package.json holds the docs-deploy version, the manifest lives at docs/public/versions.json — and resolve paths from the directory you invoke them in.