mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-06-23 09:08:10 +00:00
Switch docs.crewai.com from navigation-only versioning (every version selector entry rendered the same docs/<lang>/* source files) to Mintlify's directory-based versioning so each version selector entry renders its own snapshot. Add an "Edge" channel under docs/edge/<lang>/* that always reflects main HEAD for unreleased work, eliminating pre-release leakage onto frozen release labels. External links to canonical /<lang>/* URLs are preserved via wildcard redirects that always land on the current default version. Layout: - docs/edge/<lang>/* rolling source (you edit here) - docs/edge/enterprise-api.*.yaml - docs/v<X.Y.Z>/<lang>/* frozen, immutable snapshots - docs/v<X.Y.Z>/enterprise-api.*.yaml - docs/images/ shared, append-only - docs/docs.json nav + redirects URLs follow the Mintlify-idiomatic shape: /edge/<lang>/<page> for Edge, /v<X.Y.Z>/<lang>/<page> for every frozen snapshot. The wildcard redirects /<lang>/:slug* -> /<default>/<lang>/:slug* keep stale links working, and every freeze rewrites them (plus all per-section/per-page redirects) so destinations always resolve to the current default without depending on a second redirect hop. Release flow integration (devtools release): - New module crewai_devtools.docs_versioning.freeze() materialises docs/v<X.Y.Z>/ from docs/edge/, rewrites openapi: refs inside the snapshot, inserts the version into every language block in docs.json, and refreshes all redirect destinations. - _update_docs_and_create_pr() in cli.py now calls that freeze during Phase 2 of devtools release. Edge changelogs are updated first (so the snapshot freeze picks them up), then the snapshot is staged alongside docs.json, branched as docs/freeze-v<X.Y.Z>, and the PR is titled [docs-freeze] docs: snapshot and changelog for v<X.Y.Z> — the title prefix the new CI guard reads. - The PR still gates tag, GitHub release, PyPI publish, and the enterprise release as before; no new PRs are added. - Pre-releases (1.X.YaN, 1.X.YbN, ...) skip the snapshot — they ride Edge — and the docs PR title omits the [docs-freeze] prefix. - docs_check (AI-generated docs scaffolding) writes to docs/edge/<lang>/* so newly-generated unreleased docs land in Edge and never accidentally touch a frozen snapshot. Migration scripts (one-shot): - scripts/docs/freeze_historical_versions.py reconstructs all 16 historical snapshots (v1.10.0 .. v1.14.7) from git tags via git archive | tar, rewriting openapi: MDX refs so each snapshot reads its own enterprise-api YAML rather than the live one. - scripts/docs/prefix_version_paths.py one-shot-migrates docs.json: rewrites every page path in 16 versioned blocks to point under docs/v<X.Y.Z>/, inserts a new Edge entry per language, tags v1.14.7 as Latest (default), prunes pages whose target file doesn't exist in the snapshot (e.g. docs/ar/ didn't exist before v1.12.0), and writes the wildcard + per-section redirects. - scripts/docs/freeze_current_edge.py is now a thin CLI wrapper around docs_versioning.freeze for manual one-off freezes (e.g. retroactively snapshotting a forgotten release). CI guards (.github/workflows/docs-snapshots.yml): - Frozen snapshots under docs/v[0-9]*/ are immutable; only PRs whose title contains [docs-freeze] (i.e. release-cut PRs generated by devtools release or the manual wrapper) may modify them. - Images under docs/images/ are append-only since snapshots share a single image directory. Deleting or renaming an image breaks every historical snapshot that still references it. Restored docs/images/crewai-otel-export.png from PR #3673; it was deleted in PR #4908 but v1.10.0 / v1.10.1 snapshots still reference it. Restoring instead of editing the snapshots preserves historical rendering fidelity and validates the new append-only rule retroactively. Tests: - lib/devtools/tests/test_docs_versioning.py covers the freeze: file copy, openapi rewrite, version insertion, default demotion, redirect upserts, per-section redirect rewriting, idempotency, and invalid inputs. Verified locally with mintlify broken-links: 0 broken links across the full site (Edge + 16 frozen versions, 4 locales). AGENTS.md (repo root) is the contributor guide for the new model; RELEASING.md is the release-cut runbook; README's Contribution section links to both. Co-authored-by: Cursor <cursoragent@cursor.com>
227 lines
8.6 KiB
Python
227 lines
8.6 KiB
Python
"""Tests for the Edge -> snapshot freeze used by the docs PR step."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from crewai_devtools.docs_versioning import (
|
|
InvalidVersionError,
|
|
MissingEdgeSourcesError,
|
|
freeze,
|
|
)
|
|
import pytest
|
|
|
|
|
|
def _build_docs_root(tmp_path: Path) -> Path:
|
|
"""Build a minimal docs/ tree with one previous snapshot + Edge + docs.json.
|
|
|
|
The shape mirrors the real repo: an Edge directory with per-locale folders
|
|
and YAMLs, one previously-frozen v1.14.7 snapshot, and a docs.json with
|
|
Edge + the previous default in the version selector plus canonical-URL
|
|
wildcard redirects.
|
|
"""
|
|
docs = tmp_path / "docs"
|
|
|
|
# Edge sources (what the release will freeze).
|
|
edge_en = docs / "edge" / "en"
|
|
edge_en.mkdir(parents=True)
|
|
(edge_en / "introduction.mdx").write_text("# Intro (Edge)\n")
|
|
(edge_en / "changelog.mdx").write_text("---\ntitle: Changelog\n---\n")
|
|
(edge_en / "api.mdx").write_text(
|
|
'---\nopenapi: "/enterprise-api.en.yaml GET /foo"\n---\n'
|
|
)
|
|
(docs / "edge" / "enterprise-api.en.yaml").write_text("openapi: 3.0.0\n")
|
|
|
|
# A pre-existing frozen snapshot to clone the nav structure from.
|
|
snap_en = docs / "v1.14.7" / "en"
|
|
snap_en.mkdir(parents=True)
|
|
(snap_en / "introduction.mdx").write_text("# Intro (1.14.7)\n")
|
|
(snap_en / "changelog.mdx").write_text("---\ntitle: Changelog\n---\n")
|
|
(snap_en / "api.mdx").write_text(
|
|
'---\nopenapi: "/v1.14.7/enterprise-api.en.yaml GET /foo"\n---\n'
|
|
)
|
|
|
|
docs_json = {
|
|
"navigation": {
|
|
"languages": [
|
|
{
|
|
"language": "en",
|
|
"versions": [
|
|
{
|
|
"version": "Edge",
|
|
"tag": "Edge",
|
|
"tabs": [
|
|
{
|
|
"tab": "Guides",
|
|
"pages": [
|
|
"edge/en/introduction",
|
|
"edge/en/changelog",
|
|
"edge/en/api",
|
|
],
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"version": "v1.14.7",
|
|
"default": True,
|
|
"tag": "Latest",
|
|
"tabs": [
|
|
{
|
|
"tab": "Guides",
|
|
"pages": [
|
|
"v1.14.7/en/introduction",
|
|
"v1.14.7/en/changelog",
|
|
"v1.14.7/en/api",
|
|
],
|
|
}
|
|
],
|
|
},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
"redirects": [
|
|
{
|
|
"source": "/en/:slug*",
|
|
"destination": "/v1.14.7/en/:slug*",
|
|
"permanent": False,
|
|
}
|
|
],
|
|
}
|
|
(docs / "docs.json").write_text(json.dumps(docs_json, indent=2) + "\n")
|
|
return docs
|
|
|
|
|
|
class TestFreeze:
|
|
def test_copies_edge_files_into_snapshot(self, tmp_path: Path) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
result = freeze("1.15.0", docs)
|
|
|
|
snapshot = docs / "v1.15.0"
|
|
assert snapshot.is_dir()
|
|
assert (snapshot / "en" / "introduction.mdx").read_text() == "# Intro (Edge)\n"
|
|
assert (snapshot / "enterprise-api.en.yaml").read_text() == "openapi: 3.0.0\n"
|
|
assert result.snapshot_path == snapshot
|
|
assert result.files_copied >= 4
|
|
assert result.snapshot_already_existed is False
|
|
|
|
def test_rewrites_openapi_refs_to_snapshot_yaml(self, tmp_path: Path) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
result = freeze("1.15.0", docs)
|
|
|
|
frozen_api = (docs / "v1.15.0" / "en" / "api.mdx").read_text()
|
|
assert "/v1.15.0/enterprise-api.en.yaml" in frozen_api
|
|
# The Edge source must NOT be rewritten — it stays generic so the next
|
|
# release freezes pick up the same edit.
|
|
edge_api = (docs / "edge" / "en" / "api.mdx").read_text()
|
|
assert "/v1.15.0/" not in edge_api
|
|
assert "/enterprise-api.en.yaml" in edge_api
|
|
assert result.openapi_refs_rewritten >= 1
|
|
|
|
def test_inserts_version_after_edge_and_demotes_previous_default(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
freeze("1.15.0", docs)
|
|
|
|
data = json.loads((docs / "docs.json").read_text())
|
|
versions = data["navigation"]["languages"][0]["versions"]
|
|
labels = [v["version"] for v in versions]
|
|
assert labels == ["Edge", "v1.15.0", "v1.14.7"]
|
|
|
|
new_entry = versions[1]
|
|
assert new_entry["default"] is True
|
|
assert new_entry["tag"] == "Latest"
|
|
# Page paths in the new entry must point at the new snapshot.
|
|
page_strs = [
|
|
p for tab in new_entry["tabs"] for p in tab["pages"] if isinstance(p, str)
|
|
]
|
|
assert all(p.startswith("v1.15.0/en/") for p in page_strs)
|
|
|
|
previous = versions[2]
|
|
assert "default" not in previous
|
|
assert previous.get("tag") != "Latest"
|
|
|
|
def test_updates_canonical_url_redirect_to_new_default(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
result = freeze("1.15.0", docs)
|
|
|
|
data = json.loads((docs / "docs.json").read_text())
|
|
en_redirect = next(r for r in data["redirects"] if r["source"] == "/en/:slug*")
|
|
assert en_redirect["destination"] == "/v1.15.0/en/:slug*"
|
|
assert en_redirect["permanent"] is False
|
|
assert result.redirects_upserted >= 1
|
|
|
|
def test_rewrites_stale_per_section_redirects_to_new_default(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
# docs.json carries pre-existing per-section/per-page redirects whose
|
|
# destinations point at /<locale>/... or /v<prev>/<locale>/...; those
|
|
# need to land on the current default version directly because
|
|
# Mintlify's link-checker doesn't follow redirect chains.
|
|
docs = _build_docs_root(tmp_path)
|
|
data = json.loads((docs / "docs.json").read_text())
|
|
data["redirects"].extend(
|
|
[
|
|
{"source": "/concepts/:path*", "destination": "/en/concepts/:path*"},
|
|
{
|
|
"source": "/api-reference/:path*",
|
|
"destination": "/v1.14.7/en/api-reference/:path*",
|
|
},
|
|
{"source": "/introduction", "destination": "/en/introduction"},
|
|
{"source": "/external", "destination": "https://example.com/"},
|
|
]
|
|
)
|
|
(docs / "docs.json").write_text(json.dumps(data, indent=2) + "\n")
|
|
|
|
freeze("1.15.0", docs)
|
|
|
|
data = json.loads((docs / "docs.json").read_text())
|
|
by_source = {r["source"]: r["destination"] for r in data["redirects"]}
|
|
assert by_source["/concepts/:path*"] == "/v1.15.0/en/concepts/:path*"
|
|
assert by_source["/api-reference/:path*"] == "/v1.15.0/en/api-reference/:path*"
|
|
assert by_source["/introduction"] == "/v1.15.0/en/introduction"
|
|
# Absolute URLs and other non-locale destinations are left alone.
|
|
assert by_source["/external"] == "https://example.com/"
|
|
|
|
def test_is_idempotent_when_snapshot_already_exists(self, tmp_path: Path) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
freeze("1.15.0", docs)
|
|
before = (docs / "docs.json").read_text()
|
|
result = freeze("1.15.0", docs)
|
|
|
|
assert result.snapshot_already_existed is True
|
|
assert result.files_copied == 0
|
|
assert result.openapi_refs_rewritten == 0
|
|
# docs.json shape doesn't change on the second run because the version
|
|
# is already registered.
|
|
assert result.docsjson_entries_inserted == 0
|
|
assert (docs / "docs.json").read_text() == before
|
|
|
|
def test_rejects_invalid_version_string(self, tmp_path: Path) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
|
|
with pytest.raises(InvalidVersionError):
|
|
freeze("v1.15.0", docs)
|
|
with pytest.raises(InvalidVersionError):
|
|
freeze("1.15", docs)
|
|
with pytest.raises(InvalidVersionError):
|
|
freeze("1.15.0a1", docs)
|
|
|
|
def test_rejects_missing_edge_directory(self, tmp_path: Path) -> None:
|
|
docs = _build_docs_root(tmp_path)
|
|
import shutil
|
|
|
|
shutil.rmtree(docs / "edge")
|
|
|
|
with pytest.raises(MissingEdgeSourcesError):
|
|
freeze("1.15.0", docs)
|