fix(docs): allow corrections to published versions

refs #916
This commit is contained in:
Ogulcan Celik 2026-08-04 03:58:48 +03:00
parent b12da23918
commit cffc6c4b50
18 changed files with 130 additions and 69 deletions

View File

@ -61,7 +61,7 @@ Process:
- Preserve the existing changelog style and sections: `Added`, `Changed`, `Fixed`, `Removed`, and `Breaking Changes` when applicable. - Preserve the existing changelog style and sections: `Added`, `Changed`, `Fixed`, `Removed`, and `Breaking Changes` when applicable.
7. Audit next-release public docs. 7. Audit next-release public docs.
- Treat root `README.md` and the current stable docs source as the latest released public docs. While `docs/versions/manifest.json` has `stable_source: legacy`, that source is `website/src/content/docs/`; afterward it is the current immutable snapshot under `docs/versions/<current>/website/src/content/docs/`. - Treat root `README.md` and the version selected by `docs/versions/manifest.json` under `docs/versions/<current>/website/src/content/docs/` as the latest released public docs. Published version docs may contain factual corrections made after the release tag.
- Treat `docs/next/README.md` as the next-release root README and `docs/next/website/src/content/docs/` as the complete unpublished website-doc draft. - Treat `docs/next/README.md` as the next-release root README and `docs/next/website/src/content/docs/` as the complete unpublished website-doc draft.
- Treat `docs/preview/website/` as bot-owned output for the active preview release. Never edit it during release review and never use it as the stable release source. - Treat `docs/preview/website/` as bot-owned output for the active preview release. Never edit it during release review and never use it as the stable release source.
- Compare meaningful user-facing changes in the range against next-release docs first. - Compare meaningful user-facing changes in the range against next-release docs first.

View File

@ -42,7 +42,7 @@ jobs:
with: with:
bun-version: 1.3.14 bun-version: 1.3.14
- name: Validate published snapshots - name: Validate published documentation
run: | run: |
node website/scripts/docs-versions.mjs check node website/scripts/docs-versions.mjs check
node website/scripts/docs-preview.mjs check node website/scripts/docs-preview.mjs check

View File

@ -163,9 +163,9 @@ Unreleased docs live in `docs/next/website/src/content/docs/`. Update those when
The active preview release docs live in `docs/preview/website/`. Preview CI owns this mutable snapshot and commits it atomically with `website/preview.json`; never edit it manually. Validate it with `node website/scripts/docs-preview.mjs check`. The active preview release docs live in `docs/preview/website/`. Preview CI owns this mutable snapshot and commits it atomically with `website/preview.json`; never edit it manually. Validate it with `node website/scripts/docs-preview.mjs check`.
Immutable stable release snapshots live in `docs/versions/`. The website build generates `/docs/preview/` from the active preview snapshot and `/docs/<version>/` from stable snapshots. The current stable `/docs/` temporarily uses the tracked legacy `website/src/content/docs/` tree; the next stable release switches it to the current immutable snapshot and removes the legacy copy. Do not edit generated preview, version, or snapshot-backed stable files under `website/src/content/docs/`. Published stable-release documentation lives in `docs/versions/`. Release CI seeds each version from the tagged `docs/next` tree, and maintainers may correct factual documentation errors in a published version afterward. Apply a correction separately to `docs/next` when it also applies to future releases; never replace a published tree with the current draft. The website build generates `/docs/preview/` from the active preview snapshot, `/docs/<version>/` from the maintained version directories, and `/docs/` from the version selected by `docs/versions/manifest.json`. Do not edit generated files under `website/src/content/docs/`.
During release review, finalize `docs/next` and run `just release-docs-check`. Do not copy draft docs into preview or stable manually. Preview CI snapshots the selected commit. After a stable GitHub Release succeeds, release CI snapshots the exact tag, updates `latest.json`, and deploys them together. Normal feature/fix work should not edit root `README.md`, root `CHANGELOG.md`, legacy stable website docs, or `website/latest.json` unless explicitly requested. During release review, finalize `docs/next` and run `just release-docs-check`. Do not copy draft docs into preview or published versions manually. Preview CI snapshots the selected commit. After a stable GitHub Release succeeds, release CI seeds a new version from the exact tag, updates `latest.json`, and deploys them together. Normal feature/fix work should not edit root `README.md`, root `CHANGELOG.md`, published version docs, or `website/latest.json` unless it is a focused correction to already-published documentation or explicitly requested.
Put local PRDs, planning notes, and exploratory specs under `.local/prd/`; `.local/` is ignored and locally controlled. Put local PRDs, planning notes, and exploratory specs under `.local/prd/`; `.local/` is ignored and locally controlled.

View File

@ -65,9 +65,9 @@ If your proposal changes the visual language, interaction model, workflow, persi
## Documentation for unreleased changes ## Documentation for unreleased changes
The root `README.md`, root `CHANGELOG.md`, and public website docs describe released Herdr builds. Do not update root `README.md`, root `CHANGELOG.md`, `docs/preview/`, `docs/versions/`, or `website/src/content/docs/` for normal PRs. The root `README.md`, root `CHANGELOG.md`, and public website docs describe released Herdr builds. Do not update root `README.md`, root `CHANGELOG.md`, `docs/preview/`, `docs/versions/`, or `website/src/content/docs/` for normal code PRs. A focused correction to already-published documentation may update the affected `docs/versions/<version>/` files and should make the same correction under `docs/next` when it remains relevant to future releases.
If your PR changes user-facing behavior, mention the needed public-doc update in the PR. Update `docs/next/README.md` only when the root README needs to change for the next stable release. Update the draft under `docs/next/website/src/content/docs/` when website docs need to change. Draft changes stay unpublished until preview CI snapshots a selected commit or stable release CI snapshots a tag; contributors and maintainers do not copy them into public docs manually. If your PR changes user-facing behavior, mention the needed public-doc update in the PR. Update `docs/next/README.md` only when the root README needs to change for the next stable release. Update the draft under `docs/next/website/src/content/docs/` when website docs need to change. Draft changes stay unpublished until preview CI snapshots a selected commit or stable release CI seeds a new version from a tag; contributors and maintainers do not copy an entire draft tree into published docs manually.
You do not need to edit the changelog for normal PRs. Maintainers prepare `docs/next/CHANGELOG.md` during release review. You do not need to edit the changelog for normal PRs. Maintainers prepare `docs/next/CHANGELOG.md` during release review.

View File

@ -373,6 +373,8 @@ position = "bottom-right"
Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notification that works well over SSH, `system` for the local OS notification service, or `off` to disable popups. Herdr suppresses popups for the active tab. Search `ui.toast` in the [Config reference](/docs/config-reference/) for positions, delay behavior, and clipboard feedback settings. Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notification that works well over SSH, `system` for the local OS notification service, or `off` to disable popups. Herdr suppresses popups for the active tab. Search `ui.toast` in the [Config reference](/docs/config-reference/) for positions, delay behavior, and clipboard feedback settings.
On macOS, `system` tries `terminal-notifier` first and falls back to `/usr/bin/osascript` when it is unavailable or fails. The fallback appears as Script Editor in Notification Center and cannot activate the hosting terminal. Install `terminal-notifier` with `brew install terminal-notifier`. For a supported, detected terminal, it can activate the terminal app when you click the notification. Alternatively, choose `terminal` to let a supported outer terminal own the notification.
## Sound ## Sound
Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; relative paths are resolved from the config file's directory. Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; relative paths are resolved from the config file's directory.

View File

@ -367,6 +367,8 @@ position = "bottom-right"
アプリ内トーストには `herdr`、SSH 越しでも使いやすい外側のターミナル通知には `terminal`、ローカル OS の通知サービスには `system`、ポップアップを無効にするには `off` を選びます。Herdr はアクティブなタブのポップアップを抑制します。位置、遅延の挙動、クリップボードのフィードバック設定については、[設定リファレンス](/docs/config-reference/)で `ui.toast` を検索してください。 アプリ内トーストには `herdr`、SSH 越しでも使いやすい外側のターミナル通知には `terminal`、ローカル OS の通知サービスには `system`、ポップアップを無効にするには `off` を選びます。Herdr はアクティブなタブのポップアップを抑制します。位置、遅延の挙動、クリップボードのフィードバック設定については、[設定リファレンス](/docs/config-reference/)で `ui.toast` を検索してください。
macOS では、`system` は最初に `terminal-notifier` を試し、利用できない場合や失敗した場合は `/usr/bin/osascript` にフォールバックします。このフォールバックは通知センターで Script Editor として表示され、ホストターミナルをアクティブにできません。`brew install terminal-notifier` で `terminal-notifier` をインストールできます。対応するターミナルを Herdr が検出できた場合、通知のクリック時にそのターミナルアプリをアクティブにできます。または、`terminal` を選んで対応する外側のターミナルに通知を送信させてください。
## サウンド ## サウンド
サウンド通知はローカルの Herdr クライアントで再生されます。カスタムサウンドは mp3 ファイルでなければなりません。相対パスは設定ファイルのディレクトリから解決されます。 サウンド通知はローカルの Herdr クライアントで再生されます。カスタムサウンドは mp3 ファイルでなければなりません。相対パスは設定ファイルのディレクトリから解決されます。

View File

@ -367,6 +367,8 @@ position = "bottom-right"
选择 `herdr` 可使用应用内 toast选择 `terminal` 可使用适合 SSH 场景的外层终端通知,选择 `system` 可使用本地操作系统通知服务,选择 `off` 可禁用弹出通知。Herdr 会抑制活动标签页的弹出通知。在[配置参考](/docs/config-reference/)中搜索 `ui.toast`,可查看位置、延迟行为和剪贴板反馈设置。 选择 `herdr` 可使用应用内 toast选择 `terminal` 可使用适合 SSH 场景的外层终端通知,选择 `system` 可使用本地操作系统通知服务,选择 `off` 可禁用弹出通知。Herdr 会抑制活动标签页的弹出通知。在[配置参考](/docs/config-reference/)中搜索 `ui.toast`,可查看位置、延迟行为和剪贴板反馈设置。
在 macOS 上,`system` 会先尝试 `terminal-notifier`;如果它不可用或执行失败,则回退到 `/usr/bin/osascript`。该回退方式会在通知中心显示为 Script Editor并且无法激活承载 Herdr 的终端。可运行 `brew install terminal-notifier` 安装 `terminal-notifier`。如果 Herdr 检测到受支持的终端,点击通知时它可以激活该终端应用。也可以选择 `terminal`,让受支持的外层终端负责发送通知。
## 声音 ## 声音
声音通知通过本地 Herdr 客户端播放。自定义声音必须是 mp3 文件;相对路径从配置文件所在目录解析。 声音通知通过本地 Herdr 客户端播放。自定义声音必须是 mp3 文件;相对路径从配置文件所在目录解析。

View File

@ -373,6 +373,8 @@ position = "bottom-right"
Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notification that works well over SSH, `system` for the local OS notification service, or `off` to disable popups. Herdr suppresses popups for the active tab. Search `ui.toast` in the [Config reference](/docs/config-reference/) for positions, delay behavior, and clipboard feedback settings. Choose `herdr` for an in-app toast, `terminal` for an outer-terminal notification that works well over SSH, `system` for the local OS notification service, or `off` to disable popups. Herdr suppresses popups for the active tab. Search `ui.toast` in the [Config reference](/docs/config-reference/) for positions, delay behavior, and clipboard feedback settings.
On macOS, `system` tries `terminal-notifier` first and falls back to `/usr/bin/osascript` when it is unavailable or fails. The fallback appears as Script Editor in Notification Center and cannot activate the hosting terminal. Install `terminal-notifier` with `brew install terminal-notifier`. For a supported, detected terminal, it can activate the terminal app when you click the notification. Alternatively, choose `terminal` to let a supported outer terminal own the notification.
## Sound ## Sound
Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; relative paths are resolved from the config file's directory. Sound notifications play through the local Herdr client. Custom sounds must be mp3 files; relative paths are resolved from the config file's directory.

View File

@ -367,6 +367,8 @@ position = "bottom-right"
アプリ内トーストには `herdr`、SSH 越しでも使いやすい外側のターミナル通知には `terminal`、ローカル OS の通知サービスには `system`、ポップアップを無効にするには `off` を選びます。Herdr はアクティブなタブのポップアップを抑制します。位置、遅延の挙動、クリップボードのフィードバック設定については、[設定リファレンス](/docs/config-reference/)で `ui.toast` を検索してください。 アプリ内トーストには `herdr`、SSH 越しでも使いやすい外側のターミナル通知には `terminal`、ローカル OS の通知サービスには `system`、ポップアップを無効にするには `off` を選びます。Herdr はアクティブなタブのポップアップを抑制します。位置、遅延の挙動、クリップボードのフィードバック設定については、[設定リファレンス](/docs/config-reference/)で `ui.toast` を検索してください。
macOS では、`system` は最初に `terminal-notifier` を試し、利用できない場合や失敗した場合は `/usr/bin/osascript` にフォールバックします。このフォールバックは通知センターで Script Editor として表示され、ホストターミナルをアクティブにできません。`brew install terminal-notifier` で `terminal-notifier` をインストールできます。対応するターミナルを Herdr が検出できた場合、通知のクリック時にそのターミナルアプリをアクティブにできます。または、`terminal` を選んで対応する外側のターミナルに通知を送信させてください。
## サウンド ## サウンド
サウンド通知はローカルの Herdr クライアントで再生されます。カスタムサウンドは mp3 ファイルでなければなりません。相対パスは設定ファイルのディレクトリから解決されます。 サウンド通知はローカルの Herdr クライアントで再生されます。カスタムサウンドは mp3 ファイルでなければなりません。相対パスは設定ファイルのディレクトリから解決されます。

View File

@ -367,6 +367,8 @@ position = "bottom-right"
选择 `herdr` 可使用应用内 toast选择 `terminal` 可使用适合 SSH 场景的外层终端通知,选择 `system` 可使用本地操作系统通知服务,选择 `off` 可禁用弹出通知。Herdr 会抑制活动标签页的弹出通知。在[配置参考](/docs/config-reference/)中搜索 `ui.toast`,可查看位置、延迟行为和剪贴板反馈设置。 选择 `herdr` 可使用应用内 toast选择 `terminal` 可使用适合 SSH 场景的外层终端通知,选择 `system` 可使用本地操作系统通知服务,选择 `off` 可禁用弹出通知。Herdr 会抑制活动标签页的弹出通知。在[配置参考](/docs/config-reference/)中搜索 `ui.toast`,可查看位置、延迟行为和剪贴板反馈设置。
在 macOS 上,`system` 会先尝试 `terminal-notifier`;如果它不可用或执行失败,则回退到 `/usr/bin/osascript`。该回退方式会在通知中心显示为 Script Editor并且无法激活承载 Herdr 的终端。可运行 `brew install terminal-notifier` 安装 `terminal-notifier`。如果 Herdr 检测到受支持的终端,点击通知时它可以激活该终端应用。也可以选择 `terminal`,让受支持的外层终端负责发送通知。
## 声音 ## 声音
声音通知通过本地 Herdr 客户端播放。自定义声音必须是 mp3 文件;相对路径从配置文件所在目录解析。 声音通知通过本地 Herdr 客户端播放。自定义声音必须是 mp3 文件;相对路径从配置文件所在目录解析。

View File

@ -1,13 +1,18 @@
# Versioned documentation # Versioned documentation
This directory contains immutable documentation snapshots for stable Herdr releases. This directory contains the published documentation for stable Herdr releases.
Do not edit snapshot files manually. They must match the release tag recorded in `manifest.json`. Validate them with: Release CI creates each version from the tagged `docs/next` tree after the GitHub Release succeeds. Maintainers can correct published documentation in its version directory afterward. When a correction also applies to future releases, make the same focused change under `docs/next`; do not replace a published tree with the current draft.
Validate the manifest and build every published version with:
```bash ```bash
node website/scripts/docs-versions.mjs check node website/scripts/docs-versions.mjs check
cd website && bun run build
``` ```
Release CI creates a new snapshot from the tagged `docs/next` tree after the GitHub Release succeeds. `website/scripts/prepare-docs.mjs` renders these snapshots at `/docs/<version>/` and uses the current snapshot for `/docs/` after the legacy stable-doc migration completes. `/docs/preview/` comes only from the active preview release snapshot in `docs/preview/`, never directly from `docs/next/`. `website/scripts/prepare-docs.mjs` renders each maintained version at `/docs/<version>/` and uses the version selected by `manifest.json` for `/docs/`. Generated files under `website/src/content/docs/` are not editable sources. `/docs/preview/` comes only from the active preview release snapshot in `docs/preview/`, never directly from `docs/next/`.
The `tag`, `commit`, and `source` fields in `manifest.json` record where release CI initially published a version. Git history records later documentation corrections.
The historical backfill starts at v0.5.11, the first release that included the Astro/Starlight documentation site. The historical backfill starts at v0.5.11, the first release that included the Astro/Starlight documentation site.

View File

@ -211,7 +211,7 @@ file: ../../../public/assets/logo.svg
self.assertIn("[Install](/docs/0.7.4/install/)", output) self.assertIn("[Install](/docs/0.7.4/install/)", output)
self.assertIn("file: ../../../../../public/assets/logo.svg", output) self.assertIn("file: ../../../../../public/assets/logo.svg", output)
self.assertIn("from '../../../../components/ConfigReference.astro'", output) self.assertIn("from '../../../../components/ConfigReference.astro'", output)
self.assertIn("blob/v0.7.4/docs/next/website/src/content/docs/index.mdx", output) self.assertIn("blob/master/docs/versions/0.7.4/website/src/content/docs/index.mdx", output)
self.assertIn("blob/v0.7.4/SKILL.md", output) self.assertIn("blob/v0.7.4/SKILL.md", output)

View File

@ -15,9 +15,9 @@ Documentation has three lifecycle states:
- `../docs/next/website/` is the committed, author-edited draft. Production builds never read it. - `../docs/next/website/` is the committed, author-edited draft. Production builds never read it.
- `../docs/preview/website/` is the latest preview release snapshot, rendered at `/docs/preview/`. - `../docs/preview/website/` is the latest preview release snapshot, rendered at `/docs/preview/`.
- `../docs/versions/<version>/website/` contains immutable stable release snapshots, rendered at `/docs/<version>/`. - `../docs/versions/<version>/website/` contains maintained stable-release documentation, rendered at `/docs/<version>/`. Release CI seeds a new version from its tag; later factual corrections are made directly in that version directory and mirrored to `docs/next` when they also apply to future releases.
The current stable site temporarily remains tracked under `src/content/docs/` because it contains post-v0.7.5 documentation corrections. The next stable release switches `docs/versions/manifest.json` to snapshot-backed stable docs and removes that legacy copy. From then on, `src/content/docs/` is entirely generated and ignored. The version selected by `docs/versions/manifest.json` is also rendered at `/docs/`. `src/content/docs/` is entirely generated and ignored.
Preview CI snapshots the selected commit and updates `preview.json` in one commit: Preview CI snapshots the selected commit and updates `preview.json` in one commit:
@ -26,7 +26,7 @@ node website/scripts/docs-preview.mjs snapshot <commit>
node website/scripts/docs-preview.mjs check node website/scripts/docs-preview.mjs check
``` ```
Stable release CI snapshots the exact tag after the GitHub Release succeeds: Stable release CI seeds a new maintained version from the exact tag after the GitHub Release succeeds:
```bash ```bash
node website/scripts/docs-versions.mjs publish <tag> node website/scripts/docs-versions.mjs publish <tag>

View File

@ -48,7 +48,7 @@ assertIncludes(preview, 'name="robots" content="noindex, nofollow"');
assertIncludes(archived, `data-pagefind-filter="version[content]" content="${versions.current}"`); assertIncludes(archived, `data-pagefind-filter="version[content]" content="${versions.current}"`);
assertIncludes(archived, 'name="robots" content="noindex, nofollow"'); assertIncludes(archived, 'name="robots" content="noindex, nofollow"');
if (archived.includes(`This page documents Herdr ${versions.current}`)) { if (archived.includes(`This page documents Herdr ${versions.current}`)) {
throw new Error('the current immutable snapshot must not be labeled as outdated'); throw new Error('the current documentation version must not be labeled as outdated');
} }
const versionSelect = stable.match(/<select[^>]*aria-label="Documentation version"[^>]*>([\s\S]*?)<\/select>/)?.[1]; const versionSelect = stable.match(/<select[^>]*aria-label="Documentation version"[^>]*>([\s\S]*?)<\/select>/)?.[1];
if (!versionSelect) throw new Error('stable docs are missing the version selector'); if (!versionSelect) throw new Error('stable docs are missing the version selector');
@ -68,7 +68,7 @@ if (previous) {
const sitemap = await readFile(resolve(distDir, 'sitemap-0.xml'), 'utf8'); const sitemap = await readFile(resolve(distDir, 'sitemap-0.xml'), 'utf8');
assertIncludes(sitemap, 'https://herdr.dev/docs/'); assertIncludes(sitemap, 'https://herdr.dev/docs/');
if (nonCanonicalDocsUrl.test(sitemap)) { if (nonCanonicalDocsUrl.test(sitemap)) {
throw new Error('preview or immutable documentation URLs must not appear in the sitemap'); throw new Error('preview or versioned documentation URLs must not appear in the sitemap');
} }
const build = await inspectFiles(distDir); const build = await inspectFiles(distDir);

View File

@ -73,7 +73,7 @@ export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) {
assertRelativePath(relativePath, entry.path); assertRelativePath(relativePath, entry.path);
expected.set(relativePath, entry); expected.set(relativePath, entry);
} }
const actualPaths = await listSnapshotPaths(snapshotRoot); const actualPaths = await listDocumentationPaths(snapshotRoot);
const expectedPaths = [...expected.keys()].sort(); const expectedPaths = [...expected.keys()].sort();
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) { if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`); throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`);
@ -89,7 +89,7 @@ export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) {
} }
} }
async function listSnapshotPaths(root) { export async function listDocumentationPaths(root) {
const paths = []; const paths = [];
async function walk(directory) { async function walk(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) { for (const entry of await readdir(directory, { withFileTypes: true })) {

View File

@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test'; import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@ -21,7 +21,8 @@ describe('documentation release publishing', () => {
await write(root, 'website/latest.json', '{"version":"0.9.0"}\n'); await write(root, 'website/latest.json', '{"version":"0.9.0"}\n');
await write(root, 'README.md', 'stable readme\n'); await write(root, 'README.md', 'stable readme\n');
await write(root, 'README.zh-CN.md', 'stable readme zh-cn\n'); await write(root, 'README.zh-CN.md', 'stable readme zh-cn\n');
await write(root, 'docs/next/website/src/content/docs/index.mdx', 'next docs\n'); const nextDocs = '---\ntitle: Documentation\n---\n\nnext docs\n';
await write(root, 'docs/next/website/src/content/docs/index.mdx', nextDocs);
await write(root, 'docs/next/website/src/data/config-reference.json', '{"next":true}\n'); await write(root, 'docs/next/website/src/data/config-reference.json', '{"next":true}\n');
await write(root, 'docs/next/README.md', 'next readme\n'); await write(root, 'docs/next/README.md', 'next readme\n');
await write(root, 'docs/next/README.zh-CN.md', 'next readme zh-cn\n'); await write(root, 'docs/next/README.zh-CN.md', 'next readme zh-cn\n');
@ -40,7 +41,7 @@ describe('documentation release publishing', () => {
await expect(read(root, 'website/src/data/config-reference.json')).rejects.toThrow(); await expect(read(root, 'website/src/data/config-reference.json')).rejects.toThrow();
expect(await read(root, 'README.md')).toBe('next readme\n'); expect(await read(root, 'README.md')).toBe('next readme\n');
expect(await read(root, 'README.zh-CN.md')).toBe('next readme zh-cn\n'); expect(await read(root, 'README.zh-CN.md')).toBe('next readme zh-cn\n');
expect(await read(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe('next docs\n'); expect(await read(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe(nextDocs);
const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json')); const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
expect(manifest.current).toBe('1.0.0'); expect(manifest.current).toBe('1.0.0');
@ -65,7 +66,19 @@ describe('documentation release publishing', () => {
`${JSON.stringify({ build_id: 'preview-test', commit: previewCommit })}\n`, `${JSON.stringify({ build_id: 'preview-test', commit: previewCommit })}\n`,
); );
runPrepare(root); runPrepare(root);
expect(await read(root, 'website/src/content/docs/index.mdx')).toBe('next docs\n'); const stableDocs = await read(root, 'website/src/content/docs/index.mdx');
expect(stableDocs).toContain('next docs');
expect(stableDocs).toContain(
'editUrl: https://github.com/herdrdev/herdr/blob/master/docs/versions/1.0.0/website/src/content/docs/index.mdx',
);
const versionedDocs = await read(
root,
'website/src/content/docs/_versions/1.0.0/index.mdx',
);
expect(versionedDocs).toContain('next docs');
expect(versionedDocs).toContain(
'editUrl: https://github.com/herdrdev/herdr/blob/master/docs/versions/1.0.0/website/src/content/docs/index.mdx',
);
expect(await read(root, 'website/src/content/docs/preview/index.mdx')).toContain( expect(await read(root, 'website/src/content/docs/preview/index.mdx')).toContain(
'Preview build `preview-test`', 'Preview build `preview-test`',
); );
@ -74,10 +87,36 @@ describe('documentation release publishing', () => {
await write(root, 'website/latest.json', '{"version":"1.0.0"}\n'); await write(root, 'website/latest.json', '{"version":"1.0.0"}\n');
runScript(root, ['check']); runScript(root, ['check']);
const correctedDocs = '---\ntitle: Documentation\n---\n\ncorrected docs\n';
await write(
root,
'docs/versions/1.0.0/website/src/content/docs/index.mdx',
correctedDocs,
);
await write(
root,
'docs/versions/1.0.0/website/src/data/config-reference.json',
'{"corrected":true}\n',
);
runScript(root, ['check']);
await write(root, 'README.md', 'post-release correction\n'); await write(root, 'README.md', 'post-release correction\n');
runScript(root, ['publish', 'v1.0.0']); runScript(root, ['publish', 'v1.0.0']);
expect(await read(root, 'README.md')).toBe('post-release correction\n'); expect(await read(root, 'README.md')).toBe('post-release correction\n');
runPrepare(root);
expect(await read(root, 'website/src/content/docs/index.mdx')).toContain('corrected docs');
expect(await read(root, 'website/src/content/docs/_versions/1.0.0/index.mdx')).toContain(
'corrected docs',
);
expect(await read(root, 'website/src/data/config-reference.json')).toBe(
'{"corrected":true}\n',
);
const versionReferences = JSON.parse(
await read(root, 'website/src/data/config-reference-versions.json'),
);
expect(versionReferences['1.0.0']).toEqual({ corrected: true });
runScript(root, ['publish', 'v0.9.0']); runScript(root, ['publish', 'v0.9.0']);
const archivedManifest = JSON.parse(await read(root, 'docs/versions/manifest.json')); const archivedManifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
expect(archivedManifest.current).toBe('1.0.0'); expect(archivedManifest.current).toBe('1.0.0');
@ -85,6 +124,34 @@ describe('documentation release publishing', () => {
expect(await read(root, 'README.md')).toBe('post-release correction\n'); expect(await read(root, 'README.md')).toBe('post-release correction\n');
runScript(root, ['check']); runScript(root, ['check']);
const correctedArchivedDocs = '---\ntitle: Documentation\n---\n\ncorrected archived docs\n';
await write(
root,
'docs/versions/0.9.0/website/src/content/docs/index.mdx',
correctedArchivedDocs,
);
runScript(root, ['publish', 'v0.9.0']);
expect(await read(root, 'docs/versions/0.9.0/website/src/content/docs/index.mdx')).toBe(
correctedArchivedDocs,
);
const archivedDocsRoot = resolve(
root,
'docs/versions/0.9.0/website/src/content/docs',
);
await rm(archivedDocsRoot, { recursive: true, force: true });
expect(() => runScript(root, ['check'])).toThrow();
await write(
root,
'docs/versions/0.9.0/website/src/content/docs/index.mdx',
correctedArchivedDocs,
);
const linkedPage = resolve(archivedDocsRoot, 'linked.mdx');
await symlink('index.mdx', linkedPage);
expect(() => runScript(root, ['check'])).toThrow();
await rm(linkedPage);
runScript(root, ['check']);
delete archivedManifest.versions[0].commit; delete archivedManifest.versions[0].commit;
await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`); await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`);
expect(() => runScript(root, ['check'])).toThrow(); expect(() => runScript(root, ['check'])).toThrow();

View File

@ -2,11 +2,11 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { import {
compareGitTree,
createGit, createGit,
extractGitTree, extractGitTree,
gitPathExists, gitPathExists,
gitTreesEqual, gitTreesEqual,
listDocumentationPaths,
resolveCommit, resolveCommit,
} from './docs-snapshot.mjs'; } from './docs-snapshot.mjs';
@ -167,38 +167,26 @@ export async function checkVersions() {
if (!['website/src/content/docs', 'docs/next/website/src/content/docs'].includes(entry.source)) { if (!['website/src/content/docs', 'docs/next/website/src/content/docs'].includes(entry.source)) {
throw new Error(`docs version ${entry.version} has unsupported source ${entry.source}`); throw new Error(`docs version ${entry.version} has unsupported source ${entry.source}`);
} }
const sourceRoot = entry.source;
const snapshotRoot = resolve(versionsDir, entry.version, 'website/src/content/docs');
await compareGitTree(git, entry.tag, sourceRoot, snapshotRoot);
const referenceSource = sourceRoot.includes('docs/next/') const versionRoot = resolve(versionsDir, entry.version, 'website');
? 'docs/next/website/src/data/config-reference.json' const documentationPaths = await listDocumentationPaths(
: 'website/src/data/config-reference.json'; resolve(versionRoot, 'src/content/docs'),
const referenceSnapshot = resolve(
versionsDir,
entry.version,
'website/src/data/config-reference.json',
); );
if (gitPathExists(git, entry.tag, referenceSource)) { if (!documentationPaths.includes('index.md') && !documentationPaths.includes('index.mdx')) {
const actualReference = await readFile(referenceSnapshot); throw new Error(`docs version ${entry.version} has no root index`);
const taggedReference = git(['show', `${entry.tag}:${referenceSource}`], { binary: true }); }
if (!actualReference.equals(taggedReference)) {
throw new Error(`${entry.version} config reference differs from ${entry.tag}:${referenceSource}`); try {
} JSON.parse(await readFile(resolve(versionRoot, 'src/data/config-reference.json'), 'utf8'));
} else { } catch (error) {
try { if (error.code !== 'ENOENT') throw error;
await readFile(referenceSnapshot);
throw new Error(`${entry.version} has an unexpected config reference snapshot`);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
} }
} }
if (!seen.has(manifest.current)) { if (!seen.has(manifest.current)) {
throw new Error(`current docs version ${manifest.current} has no snapshot`); throw new Error(`current docs version ${manifest.current} has no snapshot`);
} }
process.stdout.write(`validated ${manifest.versions.length} documentation snapshots\n`); process.stdout.write(`validated ${manifest.versions.length} documentation versions\n`);
} }
export async function publishVersion(tag) { export async function publishVersion(tag) {
@ -217,8 +205,7 @@ export async function publishVersion(tag) {
if (!existing?.commit || existing.commit !== release.commit) { if (!existing?.commit || existing.commit !== release.commit) {
throw new Error(`published documentation ${release.version} has mismatched commit provenance`); throw new Error(`published documentation ${release.version} has mismatched commit provenance`);
} }
await compareGitTree(git, release.commit, existing.source, resolve(versionsDir, release.version, 'website/src/content/docs')); process.stdout.write(`documentation ${release.tag} is already published\n`);
process.stdout.write(`documentation snapshot ${release.tag} is already published\n`);
return; return;
} }
if (manifest.current && compareVersions(release.version, manifest.current) < 0) { if (manifest.current && compareVersions(release.version, manifest.current) < 0) {
@ -229,13 +216,7 @@ export async function publishVersion(tag) {
if (existing.commit && existing.commit !== release.commit) { if (existing.commit && existing.commit !== release.commit) {
throw new Error(`version ${release.version} has mismatched commit provenance`); throw new Error(`version ${release.version} has mismatched commit provenance`);
} }
await compareGitTree( process.stdout.write(`documentation ${release.tag} is already archived\n`);
git,
release.commit,
existing.source,
resolve(versionsDir, release.version, 'website/src/content/docs'),
);
process.stdout.write(`documentation snapshot ${release.tag} is already archived\n`);
return; return;
} }
const archived = await snapshotTag(tag, 'docs/next/website/src/content/docs'); const archived = await snapshotTag(tag, 'docs/next/website/src/content/docs');
@ -251,7 +232,7 @@ export async function publishVersion(tag) {
return; return;
} }
if (existing) { if (existing) {
throw new Error(`version ${release.version} already has a documentation snapshot`); throw new Error(`version ${release.version} already has published documentation`);
} }
const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs'); const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs');

View File

@ -44,7 +44,6 @@ if (process.argv[2] === '--rewrite-preview-doc-fixture') {
rewriteVersionDocContent(Buffer.concat(chunks).toString('utf8'), { rewriteVersionDocContent(Buffer.concat(chunks).toString('utf8'), {
version: process.argv[3] ?? '0.7.5', version: process.argv[3] ?? '0.7.5',
tag: `v${process.argv[3] ?? '0.7.5'}`, tag: `v${process.argv[3] ?? '0.7.5'}`,
sourceRoot: 'docs/next/website/src/content/docs',
relativePath: 'index.mdx', relativePath: 'index.mdx',
}), }),
); );
@ -118,8 +117,8 @@ async function prepareDocs({ draft }) {
stableDocsDir, stableDocsDir,
(content, relativePath) => (content, relativePath) =>
rewriteStableDocContent(content, { rewriteStableDocContent(content, {
version: currentEntry.version,
tag: currentEntry.tag, tag: currentEntry.tag,
sourceRoot: currentEntry.source,
relativePath, relativePath,
}), }),
); );
@ -162,7 +161,6 @@ async function prepareDocs({ draft }) {
rewriteVersionDocContent(content, { rewriteVersionDocContent(content, {
version, version,
tag: entry.tag, tag: entry.tag,
sourceRoot: entry.source,
relativePath, relativePath,
}), }),
); );
@ -269,24 +267,22 @@ export function rewritePreviewDocContent(
return insertPreviewNotice(withSourceLink, relativePath, { buildId, commit }); return insertPreviewNotice(withSourceLink, relativePath, { buildId, commit });
} }
export function rewriteStableDocContent(content, { tag, sourceRoot, relativePath }) { export function rewriteStableDocContent(content, { version, tag, relativePath }) {
const taggedContent = rewriteRepositoryLinks(content, tag); const taggedContent = rewriteRepositoryLinks(content, tag);
return setGeneratedEditUrl( return setGeneratedEditUrl(taggedContent, versionedDocSourceUrl(version, relativePath));
taggedContent,
`https://github.com/herdrdev/herdr/blob/${tag}/${sourceRoot}/${relativePath}`,
);
} }
export function rewriteVersionDocContent(content, { version, tag, sourceRoot, relativePath }) { export function rewriteVersionDocContent(content, { version, tag, relativePath }) {
const taggedContent = rewriteRepositoryLinks( const taggedContent = rewriteRepositoryLinks(
content.replaceAll('/docs/', `/docs/${version}/`), content.replaceAll('/docs/', `/docs/${version}/`),
tag, tag,
); );
const rewritten = rewriteRelativeDocPaths(taggedContent, 2); const rewritten = rewriteRelativeDocPaths(taggedContent, 2);
return setGeneratedEditUrl( return setGeneratedEditUrl(rewritten, versionedDocSourceUrl(version, relativePath));
rewritten, }
`https://github.com/herdrdev/herdr/blob/${tag}/${sourceRoot}/${relativePath}`,
); function versionedDocSourceUrl(version, relativePath) {
return `https://github.com/herdrdev/herdr/blob/master/docs/versions/${version}/website/src/content/docs/${relativePath}`;
} }
function rewriteRepositoryLinks(content, ref) { function rewriteRepositoryLinks(content, ref) {