aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/agent_detection_manifest_check.py75
-rw-r--r--scripts/changelog.py10
-rw-r--r--scripts/docs/preview.integration.test.ts90
-rw-r--r--scripts/docs/preview.mjs75
-rw-r--r--scripts/docs/snapshot.mjs116
-rw-r--r--scripts/docs/versions.integration.test.ts129
-rw-r--r--scripts/docs/versions.mjs283
-rw-r--r--scripts/docs/versions.test.ts33
-rw-r--r--scripts/preview.py7
-rwxr-xr-xscripts/release_perf_smoke.sh2
-rw-r--r--scripts/test_agent_detection_manifest_check.py66
-rw-r--r--scripts/test_package_windows_conpty.py2
-rw-r--r--scripts/test_preview.py55
-rw-r--r--scripts/test_unix_installer.py2
-rw-r--r--scripts/windows_arm64_installer_test.ps12
-rw-r--r--scripts/windows_install_conpty_package_test.ps118
16 files changed, 857 insertions, 108 deletions
diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py
index f180318b..f0dc0005 100644
--- a/scripts/agent_detection_manifest_check.py
+++ b/scripts/agent_detection_manifest_check.py
@@ -13,7 +13,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_BUNDLED_DIR = PROJECT_ROOT / "src" / "detect" / "manifests"
-DEFAULT_WEBSITE_DIR = PROJECT_ROOT / "website" / "agent-detection"
+DEFAULT_PUBLISHED_DIR = PROJECT_ROOT / "distribution" / "agent-detection"
ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs"
MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"}
@@ -54,9 +54,9 @@ MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512
# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release
-# can consume top_non_empty_lines. Remove this entry when the website publishes
-# the bundled Grok manifest.
-STAGED_WEBSITE_MANIFESTS = {
+# can consume top_non_empty_lines. Remove this entry when the distribution
+# publishes the bundled Grok manifest.
+STAGED_PUBLISHED_MANIFESTS = {
"grok": (
"2026.07.16.2",
"2026.07.16.1",
@@ -64,16 +64,31 @@ STAGED_WEBSITE_MANIFESTS = {
),
}
+# Muse is bundled on master but has not shipped in a stable binary yet. Stable
+# clients cannot identify it, so publish it with the release that first ships
+# the corresponding Agent variant and remove this exact-content exception.
+UNPUBLISHED_BUNDLED_MANIFESTS = {
+ "muse": (
+ "2026.08.26.1",
+ "b69c4d87fa9c19e3e6453b706fbe39c98a8b33ffbaa48e8cd5ae6751e9615074",
+ ),
+}
+
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bundled-dir", type=Path, default=DEFAULT_BUNDLED_DIR)
- parser.add_argument("--website-dir", type=Path, default=DEFAULT_WEBSITE_DIR)
+ parser.add_argument("--published-dir", type=Path, default=DEFAULT_PUBLISHED_DIR)
parser.add_argument("--engine-version", type=int)
parser.add_argument(
- "--require-website",
+ "--require-published",
+ action="store_true",
+ help="fail if published agent-detection assets or catalog are missing",
+ )
+ parser.add_argument(
+ "--require-all-published",
action="store_true",
- help="fail if website agent-detection assets or catalog are missing",
+ help="fail if any bundled manifest is intentionally held for a future stable release",
)
return parser.parse_args()
@@ -286,11 +301,13 @@ def load_manifest_dir(path: Path, engine_version: int) -> dict[str, tuple[Path,
def validate_catalog(
- website_dir: Path,
+ published_dir: Path,
bundled: dict[str, tuple[Path, dict]],
engine_version: int,
+ *,
+ allow_unpublished: bool = False,
) -> None:
- catalog_path = website_dir / "index.toml"
+ catalog_path = published_dir / "index.toml"
catalog = load_toml(catalog_path)
if set(catalog) != {"schema_version", "agents"}:
raise CheckError(f"{catalog_path}: expected only schema_version and agents")
@@ -314,7 +331,7 @@ def validate_catalog(
raise CheckError(f"{catalog_path}: unsafe path for {agent_id}: {rel_path}")
if agent_id not in bundled:
raise CheckError(f"{catalog_path}: unknown agent {agent_id}; binary cannot identify it")
- manifest_path = website_dir / rel_path
+ manifest_path = published_dir / rel_path
manifest = validate_manifest(manifest_path, engine_version)
if manifest["id"] != agent_id:
raise CheckError(f"{manifest_path}: id {manifest['id']} does not match catalog {agent_id}")
@@ -322,17 +339,17 @@ def validate_catalog(
bundled_path, bundled_manifest = bundled[agent_id]
cmp = compare_versions(manifest["version"], bundled_manifest["version"], manifest_path)
- staged_manifest = STAGED_WEBSITE_MANIFESTS.get(agent_id)
- website_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
+ staged_manifest = STAGED_PUBLISHED_MANIFESTS.get(agent_id)
+ published_digest = hashlib.sha256(manifest_path.read_bytes()).hexdigest()
stages_new_engine_manifest = (
staged_manifest
- == (bundled_manifest["version"], manifest["version"], website_digest)
+ == (bundled_manifest["version"], manifest["version"], published_digest)
and bundled_manifest["min_engine_version"] == engine_version
and manifest["min_engine_version"] < bundled_manifest["min_engine_version"]
)
if cmp < 0 and not stages_new_engine_manifest:
raise CheckError(
- f"{manifest_path}: website version {manifest['version']} is lower than bundled "
+ f"{manifest_path}: published version {manifest['version']} is lower than bundled "
f"{bundled_manifest['version']} in {bundled_path}"
)
if cmp == 0 and manifest_path.read_text(encoding="utf-8") != bundled_path.read_text(encoding="utf-8"):
@@ -341,13 +358,22 @@ def validate_catalog(
)
missing = sorted(set(bundled) - set(seen))
- if missing:
- raise CheckError(f"{catalog_path}: missing bundled agent(s): {', '.join(missing)}")
+ unexpected_missing = []
+ for agent_id in missing:
+ bundled_path, bundled_manifest = bundled[agent_id]
+ staged = UNPUBLISHED_BUNDLED_MANIFESTS.get(agent_id) if allow_unpublished else None
+ digest = hashlib.sha256(bundled_path.read_bytes()).hexdigest()
+ if staged != (bundled_manifest["version"], digest):
+ unexpected_missing.append(agent_id)
+ if unexpected_missing:
+ raise CheckError(
+ f"{catalog_path}: missing bundled agent(s): {', '.join(unexpected_missing)}"
+ )
catalog_paths = set(seen.values()) | {"index.toml"}
- extra = sorted(path.name for path in website_dir.glob("*.toml") if path.name not in catalog_paths)
+ extra = sorted(path.name for path in published_dir.glob("*.toml") if path.name not in catalog_paths)
if extra:
- raise CheckError(f"{website_dir}: TOML file(s) not listed in catalog: {', '.join(extra)}")
+ raise CheckError(f"{published_dir}: TOML file(s) not listed in catalog: {', '.join(extra)}")
def main() -> int:
@@ -355,10 +381,15 @@ def main() -> int:
try:
engine_version = read_engine_version(args.engine_version)
bundled = load_manifest_dir(args.bundled_dir, engine_version)
- if args.require_website or args.website_dir.exists():
- if not args.website_dir.is_dir():
- raise CheckError(f"{args.website_dir}: website manifest directory is missing")
- validate_catalog(args.website_dir, bundled, engine_version)
+ if args.require_published or args.require_all_published or args.published_dir.exists():
+ if not args.published_dir.is_dir():
+ raise CheckError(f"{args.published_dir}: published manifest directory is missing")
+ validate_catalog(
+ args.published_dir,
+ bundled,
+ engine_version,
+ allow_unpublished=not args.require_all_published,
+ )
except CheckError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
diff --git a/scripts/changelog.py b/scripts/changelog.py
index 9a96ea4e..9fcae9cd 100644
--- a/scripts/changelog.py
+++ b/scripts/changelog.py
@@ -16,7 +16,7 @@ DEFAULT_LIVE_MANIFEST_URL = "https://herdr.dev/latest.json"
SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$", re.MULTILINE)
VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\s+-\s+\d{4}-\d{2}-\d{2}$")
DEFAULT_RELEASE_REPO = "herdrdev/herdr"
-DEFAULT_LATEST_JSON_PATH = Path("website/latest.json")
+DEFAULT_LATEST_JSON_PATH = Path("distribution/latest.json")
DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json")
PROTOCOL_SOURCE_PATH = Path("src/protocol/wire.rs")
CORE_ASSET_TARGETS = (
@@ -619,11 +619,11 @@ def verify_asset_urls_resolve(assets: dict[str, str], label: str) -> None:
def ensure_manifest_is_outdated(current_manifest: dict[str, Any], version: str) -> None:
current_version = current_manifest.get("version")
if not isinstance(current_version, str):
- raise ChangelogError("website/latest.json is missing a string version")
+ raise ChangelogError("distribution/latest.json is missing a string version")
if parse_version(current_version) >= parse_version(version):
raise ChangelogError(
- f"website/latest.json is already at v{normalize_version(current_version)}; expected something older than v{normalize_version(version)}"
+ f"distribution/latest.json is already at v{normalize_version(current_version)}; expected something older than v{normalize_version(version)}"
)
@@ -696,7 +696,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int:
print("next:")
print(f" git diff -- {manifest_path}")
print(f" git add {manifest_path}")
- print(f" git commit -m \"docs: update website manifest for v{version}\"")
+ print(f" git commit -m \"docs: publish release distribution for v{version}\"")
print(" git push")
return 0
@@ -763,7 +763,7 @@ def build_parser() -> argparse.ArgumentParser:
sync_latest_json = subparsers.add_parser(
"sync-latest-json",
- help="Update website/latest.json from a published GitHub release",
+ help="Update distribution/latest.json from a published GitHub release",
)
sync_latest_json.add_argument("--version", required=True)
sync_latest_json.add_argument("--repo", default=DEFAULT_RELEASE_REPO)
diff --git a/scripts/docs/preview.integration.test.ts b/scripts/docs/preview.integration.test.ts
new file mode 100644
index 00000000..74e54957
--- /dev/null
+++ b/scripts/docs/preview.integration.test.ts
@@ -0,0 +1,90 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const script = resolve(import.meta.dir, 'preview.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe('preview documentation snapshots', () => {
+ test('snapshots and validates the selected commit exactly', async () => {
+ const root = await fixture();
+ const commit = git(root, ['rev-parse', 'HEAD']).trim();
+ await write(root, 'docs/preview/website/stale.mdx', 'stale\n');
+ await writeManifest(root, commit);
+
+ runScript(root, ['snapshot', commit]);
+ runScript(root, ['check']);
+
+ expect(await read(root, 'docs/preview/website/src/content/docs/index.mdx')).toBe(
+ 'selected preview\n',
+ );
+ await expect(read(root, 'docs/preview/website/stale.mdx')).rejects.toThrow();
+ });
+
+ test('rejects snapshot drift', async () => {
+ const root = await fixture();
+ const commit = git(root, ['rev-parse', 'HEAD']).trim();
+ await writeManifest(root, commit);
+ runScript(root, ['snapshot', commit]);
+ await write(root, 'docs/preview/website/src/content/docs/index.mdx', 'changed\n');
+
+ expect(() => runScript(root, ['check'])).toThrow();
+ });
+});
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-preview-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'docs/next/website/src/content/docs/index.mdx', 'selected preview\n');
+ await write(root, 'docs/next/website/src/data/config-reference.json', '{"preview":true}\n');
+ git(root, ['init', '-q']);
+ git(root, ['config', 'user.email', 'test@example.com']);
+ git(root, ['config', 'user.name', 'Test']);
+ git(root, ['add', '.']);
+ git(root, ['commit', '-qm', 'preview fixture']);
+ return root;
+}
+
+async function writeManifest(root: string, commit: string) {
+ await write(
+ root,
+ 'distribution/preview.json',
+ `${JSON.stringify({
+ schema_version: 1,
+ channel: 'preview',
+ build_id: 'test-build',
+ commit,
+ builds: { 'test-build': { commit, tag: 'preview-test-build' } },
+ })}\n`,
+ );
+}
+
+async function write(root: string, path: string, content: string) {
+ const destination = resolve(root, path);
+ await mkdir(resolve(destination, '..'), { recursive: true });
+ await writeFile(destination, content, 'utf8');
+}
+
+async function read(root: string, path: string) {
+ return readFile(resolve(root, path), 'utf8');
+}
+
+function git(root: string, args: string[]) {
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function runScript(root: string, args: string[]) {
+ execFileSync('node', [script, ...args], {
+ cwd: root,
+ env: { ...process.env, HERDR_DOCS_REPO_ROOT: root },
+ stdio: 'pipe',
+ });
+}
diff --git a/scripts/docs/preview.mjs b/scripts/docs/preview.mjs
new file mode 100644
index 00000000..9ef85728
--- /dev/null
+++ b/scripts/docs/preview.mjs
@@ -0,0 +1,75 @@
+import { readFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ compareGitTree,
+ createGit,
+ extractGitTree,
+ gitPathExists,
+ resolveCommit,
+} from './snapshot.mjs';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = process.env.HERDR_DOCS_REPO_ROOT
+ ? resolve(process.env.HERDR_DOCS_REPO_ROOT)
+ : resolve(scriptDir, '../..');
+const git = createGit(repoRoot);
+const sourceRoot = 'docs/next/website';
+const snapshotRoot = resolve(repoRoot, 'docs/preview/website');
+const manifestPath = resolve(repoRoot, 'distribution/preview.json');
+const fullShaPattern = /^[0-9a-f]{40}$/;
+
+export async function snapshotPreview(ref) {
+ const commit = resolveCommit(git, ref);
+ if (!gitPathExists(git, commit, `${sourceRoot}/src/content/docs`)) {
+ throw new Error(`${commit} does not contain staged website documentation`);
+ }
+ if (!gitPathExists(git, commit, `${sourceRoot}/src/data/config-reference.json`)) {
+ throw new Error(`${commit} does not contain the staged config reference`);
+ }
+ await extractGitTree(git, commit, sourceRoot, snapshotRoot);
+ process.stdout.write(`snapshotted preview documentation from ${commit}\n`);
+}
+
+export async function checkPreview() {
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ if (manifest.schema_version !== 1 || manifest.channel !== 'preview') {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+ if (typeof manifest.commit !== 'string' || !fullShaPattern.test(manifest.commit)) {
+ throw new Error(`${manifestPath} must contain a full preview commit SHA`);
+ }
+ const build = manifest.builds?.[manifest.build_id];
+ if (!build || build.commit !== manifest.commit) {
+ throw new Error(`${manifestPath} active build does not match its preview commit`);
+ }
+ const expectedTag = `preview-${manifest.build_id}`;
+ if (build.tag !== expectedTag) {
+ throw new Error(`${manifestPath} active build tag does not match ${expectedTag}`);
+ }
+ if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/content/docs`)) {
+ throw new Error(`${manifest.commit} does not contain staged website documentation`);
+ }
+ if (!gitPathExists(git, manifest.commit, `${sourceRoot}/src/data/config-reference.json`)) {
+ throw new Error(`${manifest.commit} does not contain the staged config reference`);
+ }
+ await compareGitTree(git, manifest.commit, sourceRoot, snapshotRoot);
+ process.stdout.write(`validated preview documentation snapshot ${manifest.commit}\n`);
+}
+
+async function main() {
+ const [command, value] = process.argv.slice(2);
+ if (command === 'snapshot' && value) {
+ await snapshotPreview(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkPreview();
+ return;
+ }
+ throw new Error('usage: node scripts/docs/preview.mjs check | snapshot <commit>');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/scripts/docs/snapshot.mjs b/scripts/docs/snapshot.mjs
new file mode 100644
index 00000000..e9eaf92c
--- /dev/null
+++ b/scripts/docs/snapshot.mjs
@@ -0,0 +1,116 @@
+import { execFileSync } from 'node:child_process';
+import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, relative, resolve } from 'node:path';
+
+export function createGit(repoRoot) {
+ return function git(args, options = {}) {
+ return execFileSync('git', args, {
+ cwd: repoRoot,
+ encoding: options.binary ? undefined : 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ };
+}
+
+export function resolveCommit(git, ref) {
+ return git(['rev-parse', `${ref}^{commit}`]).trim();
+}
+
+export function gitPathExists(git, ref, path) {
+ try {
+ git(['cat-file', '-e', `${ref}:${path}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function gitTreesEqual(git, ref, left, right) {
+ try {
+ git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function listGitEntries(git, ref, root) {
+ const output = git(['ls-tree', '-r', '-z', ref, '--', root]);
+ return output
+ .split('\0')
+ .filter(Boolean)
+ .map((line) => {
+ const separator = line.indexOf('\t');
+ const [mode, type, object] = line.slice(0, separator).split(' ');
+ return { mode, type, object, path: line.slice(separator + 1) };
+ });
+}
+
+export async function extractGitTree(git, ref, sourceRoot, destinationRoot) {
+ const entries = listGitEntries(git, ref, sourceRoot);
+ if (entries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
+
+ await rm(destinationRoot, { recursive: true, force: true });
+ for (const entry of entries) {
+ assertOrdinaryFile(entry, ref);
+ const relativePath = relative(sourceRoot, entry.path);
+ assertRelativePath(relativePath, entry.path);
+ const destination = resolve(destinationRoot, relativePath);
+ await mkdir(dirname(destination), { recursive: true });
+ await writeFile(destination, git(['cat-file', 'blob', entry.object], { binary: true }));
+ }
+}
+
+export async function compareGitTree(git, ref, sourceRoot, snapshotRoot) {
+ const expectedEntries = listGitEntries(git, ref, sourceRoot);
+ if (expectedEntries.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
+
+ const expected = new Map();
+ for (const entry of expectedEntries) {
+ assertOrdinaryFile(entry, ref);
+ const relativePath = relative(sourceRoot, entry.path);
+ assertRelativePath(relativePath, entry.path);
+ expected.set(relativePath, entry);
+ }
+ const actualPaths = await listDocumentationPaths(snapshotRoot);
+ const expectedPaths = [...expected.keys()].sort();
+ if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
+ throw new Error(`${snapshotRoot} file list differs from ${ref}:${sourceRoot}`);
+ }
+
+ for (const relativePath of expectedPaths) {
+ const entry = expected.get(relativePath);
+ const snapshotPath = resolve(snapshotRoot, relativePath);
+ const expectedContent = git(['cat-file', 'blob', entry.object], { binary: true });
+ if (!(await readFile(snapshotPath)).equals(expectedContent)) {
+ throw new Error(`${relativePath} differs from ${ref}:${entry.path}`);
+ }
+ }
+}
+
+export async function listDocumentationPaths(root) {
+ const paths = [];
+ async function walk(directory) {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = resolve(directory, entry.name);
+ if (entry.isDirectory()) await walk(path);
+ else if (entry.isFile()) paths.push(relative(root, path));
+ else throw new Error(`${path} is not an ordinary documentation file`);
+ }
+ }
+ await walk(root);
+ return paths.sort();
+}
+
+function assertOrdinaryFile(entry, ref) {
+ if (entry.type !== 'blob' || entry.mode !== '100644') {
+ throw new Error(`${ref}:${entry.path} is not an ordinary documentation file`);
+ }
+}
+
+function assertRelativePath(relativePath, originalPath) {
+ if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
+ throw new Error(`unsafe snapshot path ${originalPath}`);
+ }
+}
diff --git a/scripts/docs/versions.integration.test.ts b/scripts/docs/versions.integration.test.ts
new file mode 100644
index 00000000..a52be1be
--- /dev/null
+++ b/scripts/docs/versions.integration.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const script = resolve(import.meta.dir, 'versions.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe('documentation release publishing', () => {
+ test('publishes immutable tagged snapshots and validates maintained corrections', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'distribution/latest.json', '{"version":"0.9.0"}\n');
+ await write(root, 'README.md', 'stable readme\n');
+ await write(root, 'README.zh-CN.md', 'stable readme zh-cn\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/README.md', 'next readme\n');
+ await write(root, 'docs/next/README.zh-CN.md', 'next readme zh-cn\n');
+
+ git(root, ['init', '-q']);
+ git(root, ['config', 'user.email', 'test@example.com']);
+ git(root, ['config', 'user.name', 'Test']);
+ git(root, ['add', '.']);
+ git(root, ['commit', '-qm', 'release fixture']);
+ git(root, ['tag', 'v0.9.0']);
+ git(root, ['tag', 'v1.0.0']);
+
+ runScript(root, ['publish', 'v1.0.0']);
+
+ 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, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe(
+ nextDocs,
+ );
+
+ const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
+ expect(manifest.current).toBe('1.0.0');
+ expect(manifest.stable_source).toBe('snapshot');
+ expect(manifest.versions[0]).toMatchObject({
+ version: '1.0.0',
+ tag: 'v1.0.0',
+ commit: expect.stringMatching(/^[0-9a-f]{40}$/),
+ source: 'docs/next/website/src/content/docs',
+ });
+
+ await write(root, 'distribution/latest.json', '{"version":"1.0.0"}\n');
+ 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');
+ runScript(root, ['publish', 'v1.0.0']);
+ expect(await read(root, 'README.md')).toBe('post-release correction\n');
+
+ runScript(root, ['publish', 'v0.9.0']);
+ const archivedManifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
+ expect(archivedManifest.current).toBe('1.0.0');
+ expect(archivedManifest.versions.map(({ version }) => version)).toEqual(['1.0.0', '0.9.0']);
+ expect(await read(root, 'README.md')).toBe('post-release correction\n');
+ 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;
+ await write(root, 'docs/versions/manifest.json', `${JSON.stringify(archivedManifest)}\n`);
+ expect(() => runScript(root, ['check'])).toThrow();
+ }, 30_000);
+});
+
+async function write(root: string, path: string, content: string) {
+ const destination = resolve(root, path);
+ await mkdir(resolve(destination, '..'), { recursive: true });
+ await writeFile(destination, content, 'utf8');
+}
+
+async function read(root: string, path: string) {
+ return readFile(resolve(root, path), 'utf8');
+}
+
+function git(root: string, args: string[]) {
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' });
+}
+
+function runScript(root: string, args: string[]) {
+ execFileSync('node', [script, ...args], {
+ cwd: root,
+ env: { ...process.env, HERDR_DOCS_REPO_ROOT: root },
+ stdio: 'pipe',
+ });
+}
diff --git a/scripts/docs/versions.mjs b/scripts/docs/versions.mjs
new file mode 100644
index 00000000..163d3ae6
--- /dev/null
+++ b/scripts/docs/versions.mjs
@@ -0,0 +1,283 @@
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import {
+ createGit,
+ extractGitTree,
+ gitPathExists,
+ gitTreesEqual,
+ listDocumentationPaths,
+ resolveCommit,
+} from './snapshot.mjs';
+
+const scriptDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = process.env.HERDR_DOCS_REPO_ROOT
+ ? resolve(process.env.HERDR_DOCS_REPO_ROOT)
+ : resolve(scriptDir, '../..');
+const versionsDir = resolve(repoRoot, 'docs/versions');
+const manifestPath = resolve(versionsDir, 'manifest.json');
+const latestManifestPath = resolve(repoRoot, 'distribution/latest.json');
+const git = createGit(repoRoot);
+
+const VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)$/;
+
+export function normalizeVersion(value) {
+ const match = VERSION_PATTERN.exec(value);
+ if (!match) throw new Error(`version must look like 0.7.5 or v0.7.5, got ${value}`);
+ return match[1];
+}
+
+export function sortVersionsNewestFirst(versions) {
+ return [...versions].sort((left, right) => compareVersions(right.version, left.version));
+}
+
+function compareVersions(left, right) {
+ const a = left.split('.').map(Number);
+ const b = right.split('.').map(Number);
+ for (let index = 0; index < 3; index += 1) {
+ if (a[index] !== b[index]) return a[index] - b[index];
+ }
+ return 0;
+}
+
+async function readManifest() {
+ try {
+ return JSON.parse(await readFile(manifestPath, 'utf8'));
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return { schema_version: 1, stable_source: 'legacy', current: null, versions: [] };
+ }
+ throw error;
+ }
+}
+
+async function writeManifest(manifest) {
+ manifest.versions = sortVersionsNewestFirst(manifest.versions).map(
+ ({ version, tag, commit, source }) => ({
+ version,
+ tag,
+ ...(commit ? { commit } : {}),
+ source,
+ }),
+ );
+ await mkdir(dirname(manifestPath), { recursive: true });
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
+}
+
+function releaseMetadata(tag) {
+ const version = normalizeVersion(tag);
+ const normalizedTag = `v${version}`;
+ return { version, tag: normalizedTag, commit: resolveCommit(git, normalizedTag) };
+}
+
+async function snapshotTag(tag, sourceRoot) {
+ const metadata = { ...releaseMetadata(tag), source: sourceRoot };
+ const destination = resolve(versionsDir, metadata.version);
+ await rm(destination, { recursive: true, force: true });
+ await extractGitTree(git, tag, sourceRoot, resolve(destination, 'website/src/content/docs'));
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ if (gitPathExists(git, tag, referenceSource)) {
+ const referenceDestination = resolve(destination, 'website/src/data/config-reference.json');
+ await mkdir(dirname(referenceDestination), { recursive: true });
+ await writeFile(referenceDestination, git(['show', `${tag}:${referenceSource}`], { binary: true }));
+ }
+
+ return metadata;
+}
+
+export async function backfillVersions() {
+ const tags = git(['tag', '--list', 'v*', '--sort=v:refname']).split('\n').filter(Boolean);
+ const manifest = await readManifest();
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+
+ for (const tag of tags) {
+ const version = normalizeVersion(tag);
+ if (entries.has(version)) continue;
+ const nextRoot = 'docs/next/website/src/content/docs';
+ const legacyRoot = 'website/src/content/docs';
+ const hasNext = gitPathExists(git, tag, nextRoot);
+ const hasLegacy = gitPathExists(git, tag, legacyRoot);
+ if (!hasNext && !hasLegacy) continue;
+ const sourceRoot =
+ hasNext && (!hasLegacy || !gitTreesEqual(git, tag, legacyRoot, nextRoot))
+ ? nextRoot
+ : legacyRoot;
+ const metadata = await snapshotTag(tag, sourceRoot);
+ entries.set(metadata.version, metadata);
+ process.stdout.write(`snapshotted ${tag}\n`);
+ }
+
+ const current = JSON.parse(await readFile(latestManifestPath, 'utf8')).version;
+ await writeManifest({
+ schema_version: 1,
+ stable_source: manifest.stable_source ?? 'legacy',
+ current: normalizeVersion(current),
+ versions: [...entries.values()],
+ });
+}
+
+export async function checkVersions() {
+ const manifest = await readManifest();
+ if (
+ manifest.schema_version !== 1 ||
+ typeof manifest.current !== 'string' ||
+ !['legacy', 'snapshot'].includes(manifest.stable_source ?? 'legacy')
+ ) {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+
+ const latest = JSON.parse(await readFile(latestManifestPath, 'utf8')).version;
+ if (manifest.current !== normalizeVersion(latest)) {
+ throw new Error(`docs current version ${manifest.current} does not match distribution latest ${latest}`);
+ }
+
+ const expectedOrder = sortVersionsNewestFirst(manifest.versions).map(({ version }) => version);
+ if (JSON.stringify(manifest.versions.map(({ version }) => version)) !== JSON.stringify(expectedOrder)) {
+ throw new Error('documentation versions are not sorted newest first');
+ }
+
+ const seen = new Set();
+ for (const entry of manifest.versions) {
+ if (seen.has(entry.version)) throw new Error(`duplicate docs version ${entry.version}`);
+ seen.add(entry.version);
+ if (normalizeVersion(entry.tag) !== entry.version) {
+ throw new Error(`docs version ${entry.version} has mismatched tag ${entry.tag}`);
+ }
+ const requiresCommit =
+ entry.source === 'docs/next/website/src/content/docs' ||
+ ((manifest.stable_source ?? 'legacy') === 'snapshot' && entry.version === manifest.current);
+ if (requiresCommit && !entry.commit) {
+ throw new Error(`docs version ${entry.version} is missing commit provenance`);
+ }
+ if (entry.commit) {
+ if (!/^[0-9a-f]{40}$/.test(entry.commit)) {
+ throw new Error(`docs version ${entry.version} has invalid commit ${entry.commit}`);
+ }
+ const taggedCommit = resolveCommit(git, entry.tag);
+ if (taggedCommit !== entry.commit) {
+ throw new Error(
+ `docs version ${entry.version} tag ${entry.tag} moved from ${entry.commit} to ${taggedCommit}`,
+ );
+ }
+ }
+ 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}`);
+ }
+
+ const versionRoot = resolve(versionsDir, entry.version, 'website');
+ const documentationPaths = await listDocumentationPaths(
+ resolve(versionRoot, 'src/content/docs'),
+ );
+ if (!documentationPaths.includes('index.md') && !documentationPaths.includes('index.mdx')) {
+ throw new Error(`docs version ${entry.version} has no root index`);
+ }
+
+ try {
+ JSON.parse(await readFile(resolve(versionRoot, 'src/data/config-reference.json'), 'utf8'));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+
+ if (!seen.has(manifest.current)) {
+ throw new Error(`current docs version ${manifest.current} has no snapshot`);
+ }
+ process.stdout.write(`validated ${manifest.versions.length} documentation versions\n`);
+}
+
+export async function publishVersion(tag) {
+ const release = releaseMetadata(tag);
+ if (!gitPathExists(git, tag, 'docs/next/website/src/content/docs')) {
+ throw new Error(`${tag} does not contain staged website documentation`);
+ }
+
+ const manifest = await readManifest();
+ const existing = manifest.versions.find((entry) => entry.version === release.version);
+ if (manifest.current === release.version) {
+ if ((manifest.stable_source ?? 'legacy') === 'legacy') {
+ process.stdout.write(`documentation ${release.tag} is already published with the legacy stable source\n`);
+ return;
+ }
+ if (!existing?.commit || existing.commit !== release.commit) {
+ throw new Error(`published documentation ${release.version} has mismatched commit provenance`);
+ }
+ process.stdout.write(`documentation ${release.tag} is already published\n`);
+ return;
+ }
+ if (manifest.current && compareVersions(release.version, manifest.current) < 0) {
+ if (existing) {
+ if (existing.tag !== release.tag) {
+ throw new Error(`version ${release.version} is already associated with ${existing.tag}`);
+ }
+ if (existing.commit && existing.commit !== release.commit) {
+ throw new Error(`version ${release.version} has mismatched commit provenance`);
+ }
+ process.stdout.write(`documentation ${release.tag} is already archived\n`);
+ return;
+ }
+ const archived = await snapshotTag(tag, 'docs/next/website/src/content/docs');
+ const archivedEntries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ archivedEntries.set(archived.version, archived);
+ await writeManifest({
+ ...manifest,
+ versions: [...archivedEntries.values()],
+ });
+ process.stdout.write(
+ `archived documentation snapshot ${release.tag} without changing current ${manifest.current}\n`,
+ );
+ return;
+ }
+ if (existing) {
+ throw new Error(`version ${release.version} already has published documentation`);
+ }
+
+ const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs');
+
+ for (const readme of ['README.md', 'README.zh-CN.md']) {
+ const nextReadme = `docs/next/${readme}`;
+ if (gitPathExists(git, tag, nextReadme)) {
+ await writeFile(resolve(repoRoot, readme), git(['show', `${tag}:${nextReadme}`], { binary: true }));
+ }
+ }
+
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ entries.set(metadata.version, metadata);
+ await writeManifest({
+ schema_version: 1,
+ stable_source: 'snapshot',
+ current: metadata.version,
+ versions: [...entries.values()],
+ });
+ process.stdout.write(`published documentation snapshot ${metadata.tag}\n`);
+}
+
+async function main() {
+ const [command, value] = process.argv.slice(2);
+ if (command === 'backfill' && !value) {
+ await backfillVersions();
+ return;
+ }
+ if (command === 'publish' && value) {
+ await publishVersion(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkVersions();
+ return;
+ }
+ if (command === 'current' && !value) {
+ const manifest = await readManifest();
+ process.stdout.write(`${manifest.current ?? ''}\n`);
+ return;
+ }
+ throw new Error(
+ 'usage: node scripts/docs/versions.mjs backfill | check | current | publish <tag>',
+ );
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/scripts/docs/versions.test.ts b/scripts/docs/versions.test.ts
new file mode 100644
index 00000000..c40ee854
--- /dev/null
+++ b/scripts/docs/versions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from 'bun:test';
+import { normalizeVersion, sortVersionsNewestFirst } from './versions.mjs';
+
+describe('normalizeVersion', () => {
+ test.each([
+ ['0.7.5', '0.7.5'],
+ ['v0.7.5', '0.7.5'],
+ ])('normalizes %s', (input, expected) => {
+ expect(normalizeVersion(input)).toBe(expected);
+ });
+
+ test('rejects non-release refs', () => {
+ expect(() => normalizeVersion('preview-123')).toThrow();
+ });
+});
+
+describe('sortVersionsNewestFirst', () => {
+ test('sorts semantic versions numerically', () => {
+ const versions = [
+ { version: '0.6.10' },
+ { version: '0.7.2' },
+ { version: '0.7.10' },
+ { version: '0.5.12' },
+ ];
+
+ expect(sortVersionsNewestFirst(versions).map(({ version }) => version)).toEqual([
+ '0.7.10',
+ '0.7.2',
+ '0.6.10',
+ '0.5.12',
+ ]);
+ });
+});
diff --git a/scripts/preview.py b/scripts/preview.py
index fa55d623..16c52b6e 100644
--- a/scripts/preview.py
+++ b/scripts/preview.py
@@ -20,6 +20,7 @@ EXPECTED_ASSET_NAMES = {
"windows-x86_64": "herdr-windows-x86_64.zip",
}
HIDDEN_SUBJECTS = (
+ "docs: publish release distribution",
"docs: update website manifest",
"docs: update preview manifest",
"chore: approve contributor",
@@ -294,7 +295,7 @@ def main() -> int:
sub = parser.add_subparsers(required=True)
notes = sub.add_parser("notes")
- notes.add_argument("--manifest", default="website/preview.json")
+ notes.add_argument("--manifest", default="distribution/preview.json")
notes.add_argument("--previous")
notes.add_argument("--commit", required=True)
notes.add_argument("--build-id", required=True)
@@ -304,7 +305,7 @@ def main() -> int:
notes.set_defaults(func=cmd_notes)
manifest = sub.add_parser("manifest")
- manifest.add_argument("--output", default="website/preview.json")
+ manifest.add_argument("--output", default="distribution/preview.json")
manifest.add_argument("--repo", default="herdrdev/herdr")
manifest.add_argument("--tag", required=True)
manifest.add_argument("--build-id", required=True)
@@ -318,7 +319,7 @@ def main() -> int:
manifest.set_defaults(func=cmd_manifest)
current = sub.add_parser("current-commit")
- current.add_argument("--manifest", default="website/preview.json")
+ current.add_argument("--manifest", default="distribution/preview.json")
current.set_defaults(func=cmd_current_commit)
select = sub.add_parser("select-commit")
diff --git a/scripts/release_perf_smoke.sh b/scripts/release_perf_smoke.sh
index 7ce1f736..2b7972e2 100755
--- a/scripts/release_perf_smoke.sh
+++ b/scripts/release_perf_smoke.sh
@@ -37,7 +37,7 @@ trap 'exit 143' TERM
mkdir -p "$root/results"
if [[ -z "$baseline" ]]; then
- baseline_version=$(jq -er '.version' "$repo_root/website/latest.json")
+ baseline_version=$(jq -er '.version' "$repo_root/distribution/latest.json")
baseline="$root/herdr-baseline"
curl -fL --retry 3 \
"https://github.com/herdrdev/herdr/releases/download/v${baseline_version}/herdr-${platform}-${arch}" \
diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py
index a9ec38d1..6c4cd726 100644
--- a/scripts/test_agent_detection_manifest_check.py
+++ b/scripts/test_agent_detection_manifest_check.py
@@ -29,21 +29,33 @@ path = "{path}"
def staged_grok_dirs(root: Path) -> tuple[Path, Path]:
bundled = root / "bundled"
- website = root / "website"
+ published = root / "published"
bundled.mkdir()
- website.mkdir()
+ published.mkdir()
(bundled / "grok.toml").write_bytes(
(check.DEFAULT_BUNDLED_DIR / "grok.toml").read_bytes()
)
- (website / "grok.toml").write_bytes(
- (check.DEFAULT_WEBSITE_DIR / "grok.toml").read_bytes()
+ (published / "grok.toml").write_bytes(
+ (check.DEFAULT_PUBLISHED_DIR / "grok.toml").read_bytes()
)
- (website / "index.toml").write_text(catalog("grok", "grok.toml"))
- return bundled, website
+ (published / "index.toml").write_text(catalog("grok", "grok.toml"))
+ return bundled, published
+
+
+def unpublished_muse_dirs(root: Path) -> tuple[Path, Path]:
+ bundled = root / "bundled"
+ published = root / "published"
+ bundled.mkdir()
+ published.mkdir()
+ (bundled / "muse.toml").write_bytes(
+ (check.DEFAULT_BUNDLED_DIR / "muse.toml").read_bytes()
+ )
+ (published / "index.toml").write_text("schema_version = 1\nagents = []\n")
+ return bundled, published
class AgentDetectionManifestCheckTests(unittest.TestCase):
- def test_validates_bundled_and_matching_website_catalog(self):
+ def test_validates_bundled_and_matching_published_catalog(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
@@ -58,7 +70,7 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
bundled_manifests = check.load_manifest_dir(bundled, engine_version=1)
check.validate_catalog(website, bundled_manifests, engine_version=1)
- def test_rejects_website_version_lower_than_bundled(self):
+ def test_rejects_published_version_lower_than_bundled(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
@@ -73,14 +85,14 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
- def test_allows_explicitly_staged_website_manifest(self):
+ def test_allows_explicitly_staged_published_manifest(self):
with tempfile.TemporaryDirectory() as tmp:
bundled, website = staged_grok_dirs(Path(tmp))
bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
check.validate_catalog(website, bundled_manifests, engine_version=3)
- def test_rejects_mutated_staged_website_manifest(self):
+ def test_rejects_mutated_staged_published_manifest(self):
with tempfile.TemporaryDirectory() as tmp:
bundled, website = staged_grok_dirs(Path(tmp))
with (website / "grok.toml").open("a") as manifest_file:
@@ -90,7 +102,7 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "lower than bundled"):
check.validate_catalog(website, bundled_manifests, engine_version=3)
- def test_rejects_unlisted_website_manifest_lag_for_new_engine(self):
+ def test_rejects_unlisted_published_manifest_lag_for_new_engine(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
bundled = root / "bundled"
@@ -123,6 +135,38 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "same version"):
check.validate_catalog(website, bundled_manifests, engine_version=1)
+ def test_allows_exact_unpublished_bundled_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled, published = unpublished_muse_dirs(Path(tmp))
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
+ check.validate_catalog(
+ published,
+ bundled_manifests,
+ engine_version=3,
+ allow_unpublished=True,
+ )
+
+ def test_release_gate_rejects_exact_unpublished_bundled_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled, published = unpublished_muse_dirs(Path(tmp))
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
+ with self.assertRaisesRegex(check.CheckError, "missing bundled agent"):
+ check.validate_catalog(published, bundled_manifests, engine_version=3)
+
+ def test_rejects_mutated_unpublished_bundled_manifest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled, published = unpublished_muse_dirs(Path(tmp))
+ with (bundled / "muse.toml").open("a") as manifest_file:
+ manifest_file.write("\n# unexpected mutation\n")
+ bundled_manifests = check.load_manifest_dir(bundled, engine_version=3)
+ with self.assertRaisesRegex(check.CheckError, "missing bundled agent"):
+ check.validate_catalog(
+ published,
+ bundled_manifests,
+ engine_version=3,
+ allow_unpublished=True,
+ )
+
def test_rejects_unknown_catalog_agent(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
diff --git a/scripts/test_package_windows_conpty.py b/scripts/test_package_windows_conpty.py
index b8d73a6c..0736208b 100644
--- a/scripts/test_package_windows_conpty.py
+++ b/scripts/test_package_windows_conpty.py
@@ -29,7 +29,7 @@ class WindowsConptyPackageTests(unittest.TestCase):
loader = (
package.PROJECT_ROOT / "vendor/portable-pty/src/win/psuedocon.rs"
).read_text(encoding="utf-8")
- installer = (package.PROJECT_ROOT / "website/install.ps1").read_text(
+ installer = (package.PROJECT_ROOT / "distribution/install.ps1").read_text(
encoding="utf-8"
)
for item in metadata["bundles"]["x86_64"]["files"]:
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index 137a13f0..0f586437 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -83,6 +83,7 @@ class PreviewNotesTests(unittest.TestCase):
def test_hidden_subjects_include_preview_manifest_commits(self):
self.assertTrue(preview.hidden_subject("docs: update preview manifest"))
self.assertTrue(preview.hidden_subject("docs: update website manifest"))
+ self.assertTrue(preview.hidden_subject("docs: publish release distribution"))
self.assertFalse(preview.hidden_subject("release: v0.7.0"))
self.assertFalse(preview.hidden_subject("fix: repair preview manifest"))
@@ -161,60 +162,6 @@ class PreviewNotesTests(unittest.TestCase):
finally:
os.chdir(original_cwd)
- def test_preview_docs_rewrite_links_to_preview_namespace(self):
- source = """---
-title: Install Herdr
----
-
-import ConfigReference from '../../components/ConfigReference.astro';
-import LocaleWidget from '../../../components/LocaleWidget.astro';
-
-[Install](/docs/install/)
-file: ../../../public/assets/logo.svg
-"""
- output = subprocess.check_output(
- ["node", "website/scripts/prepare-docs.mjs", "--rewrite-preview-doc-fixture"],
- input=source,
- text=True,
- )
- self.assertIn("[Install](/docs/preview/install/)", output)
- self.assertIn("file: ../../../../public/assets/logo.svg", output)
- self.assertIn("from '../../../components/ConfigReference.astro'", output)
- self.assertIn("from '../../../../components/LocaleWidget.astro'", output)
- self.assertIn("Preview build `2026-07-29-44b3adb12552`", output)
- self.assertIn(
- "blob/44b3adb125524ea9a55739eee3776f922f2115ad/docs/next/website/src/content/docs/",
- output,
- )
-
- def test_version_docs_rewrite_links_and_source_paths(self):
- source = """---
-title: Install Herdr
----
-
-import ConfigReference from '../../components/ConfigReference.astro';
-
-[Install](/docs/install/)
-[Skill](https://github.com/herdrdev/herdr/blob/master/SKILL.md)
-file: ../../../public/assets/logo.svg
-"""
- output = subprocess.check_output(
- [
- "node",
- "website/scripts/prepare-docs.mjs",
- "--rewrite-version-doc-fixture",
- "0.7.4",
- ],
- input=source,
- text=True,
- )
- self.assertIn("[Install](/docs/0.7.4/install/)", output)
- self.assertIn("file: ../../../../../public/assets/logo.svg", output)
- self.assertIn("from '../../../../components/ConfigReference.astro'", 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)
-
-
class ConventionalCommitTests(unittest.TestCase):
def test_valid_subjects_allow_scopes_and_bang(self):
self.assertTrue(conventional_commits.valid_subject("fix(update): handle preview"))
diff --git a/scripts/test_unix_installer.py b/scripts/test_unix_installer.py
index 82de55c8..ca7d3b19 100644
--- a/scripts/test_unix_installer.py
+++ b/scripts/test_unix_installer.py
@@ -11,7 +11,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
-INSTALLER = REPO_ROOT / "website" / "install.sh"
+INSTALLER = REPO_ROOT / "distribution" / "install.sh"
REQUIRED_COMMANDS = ("awk", "cat", "chmod", "cp", "mkdir", "mktemp", "mv", "rm")
diff --git a/scripts/windows_arm64_installer_test.ps1 b/scripts/windows_arm64_installer_test.ps1
index 7f5d7174..6d40dec0 100644
--- a/scripts/windows_arm64_installer_test.ps1
+++ b/scripts/windows_arm64_installer_test.ps1
@@ -17,7 +17,7 @@ $env:HERDR_INSTALL_DIR = Join-Path $root "bin"
$env:HERDR_CHANNEL = "preview"
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $root | Out-Null
-$installer = (Resolve-Path (Join-Path $PSScriptRoot "..\website\install.ps1")).Path
+$installer = (Resolve-Path (Join-Path $PSScriptRoot "..\distribution\install.ps1")).Path
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1
index 264fafe2..055de63f 100644
--- a/scripts/windows_install_conpty_package_test.ps1
+++ b/scripts/windows_install_conpty_package_test.ps1
@@ -6,8 +6,8 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
-$installerPath = (Resolve-Path -LiteralPath "$PSScriptRoot\..\website\install.ps1").Path
-$bootstrapPath = (Resolve-Path -LiteralPath "$PSScriptRoot\..\website\install.cmd").Path
+$installerPath = (Resolve-Path -LiteralPath "$PSScriptRoot\..\distribution\install.ps1").Path
+$bootstrapPath = (Resolve-Path -LiteralPath "$PSScriptRoot\..\distribution\install.cmd").Path
$bootstrapContent = Get-Content -LiteralPath $bootstrapPath -Raw
foreach ($forbiddenCommand in @("Invoke-RestMethod", "Invoke-WebRequest", "Invoke-Expression", "iex")) {
if ($bootstrapContent -match "(?i)\b$forbiddenCommand\b") {
@@ -263,7 +263,7 @@ try {
$badManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $previewManifestPath -Encoding utf8
$downloadFailed = $false
try {
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel preview `
-ManifestUrl $previewManifestUrl `
-InstallDir $installDir `
@@ -389,7 +389,7 @@ try {
}
}
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel preview `
-ManifestUrl $previewManifestUrl `
-InstallDir $installDir `
@@ -402,7 +402,7 @@ try {
$junctionTarget = Join-Path $root "junction-target"
Move-Item -LiteralPath $x64HostDir -Destination $junctionTarget
New-Item -ItemType Junction -Path $x64HostDir -Target $junctionTarget | Out-Null
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel preview `
-ManifestUrl $previewManifestUrl `
-InstallDir $installDir `
@@ -414,7 +414,7 @@ try {
$rejected = $false
try {
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel preview `
-ManifestUrl $previewManifestUrl `
-InstallDir $installDir `
@@ -430,7 +430,7 @@ try {
}
$stableManifest | Out-File -LiteralPath $stableManifestPath -Encoding utf8
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel stable `
-ManifestUrl $stableManifestUrl `
-InstallDir $installDir
@@ -465,7 +465,7 @@ exit /b 1
$preserveBin = Join-Path $root "preserve-bin"
$env:HERDR_HOME = $preserveHome
$env:Path = "$fakeBin;$oldProcessPath"
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-ManifestUrl "http://127.0.0.1:$port/candidate.json" `
-InstallDir $preserveBin `
-ExpectedBuildId "installer-test"
@@ -476,7 +476,7 @@ exit /b 1
throw "installer did not preserve the existing preview channel"
}
- & "$PSScriptRoot\..\website\install.ps1" `
+ & "$PSScriptRoot\..\distribution\install.ps1" `
-Channel stable `
-ManifestUrl $stableManifestUrl `
-InstallDir $preserveBin