Files
crewAI/lib/devtools/tests/test_docs_versioning.py
Lucas Gomide 93dafe2637 feat: adopt directory-based docs versioning with Edge channel
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>
2026-06-17 11:08:45 -03:00

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)