ci(docs): daily docs-drift job — canonical inventory vs README/docs/registries (Wave 0.1) (#353)

docs/features.yaml is the curated single source of truth (12 features,
11 TTS + 7 ASR engine ids, required install docs). scripts/check-docs-drift.py
diffs it against README.md, docs/, and the engine registries — parsing
registry keys from source so the CI runner never imports torch. The daily
workflow updates ONE rolling 'docs-drift' issue in place and auto-closes it
when clean (pattern adapted from Patter, MIT). Self-test includes a
real-repo-is-clean gate, so any PR that changes engines/features without
updating the inventory fails CI too.

Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-11 21:02:07 +05:30
committed by GitHub
co-authored by Claude Fable 5
parent 73de4f9277
commit 11c498eeb5
4 changed files with 508 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
# Docs drift — daily inventory-vs-docs check with a single rolling issue.
#
# docs/features.yaml is the canonical inventory; scripts/check-docs-drift.py
# diffs it against README.md, docs/, and the engine registries. On drift the
# job updates (or creates) ONE issue labeled `docs-drift` in place — no issue
# spam — and closes it automatically when the check is clean again.
#
# Companion to the PR-gating validate-install-docs.py step in ci.yml.
# Spec: docs/competitive-analysis.md Spec 9a / parity program Wave 0.1.
# Rolling-issue pattern adapted from Patter (MIT).
name: docs-drift
on:
schedule:
# Daily 03:30 UTC — after most merges, before EU morning triage.
- cron: "30 3 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
drift:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install checker deps
run: pip install "pyyaml>=6"
- name: Check inventory vs README/docs/registries
id: drift
continue-on-error: true
run: python scripts/check-docs-drift.py --output drift-report.md
- name: Update rolling docs-drift issue
uses: actions/github-script@v7
env:
DRIFT_OUTCOME: ${{ steps.drift.outcome }}
with:
script: |
const fs = require('fs');
const drifted = process.env.DRIFT_OUTCOME === 'failure';
const { owner, repo } = context.repo;
const label = 'docs-drift';
const open = await github.rest.issues.listForRepo({
owner, repo, state: 'open', labels: label, per_page: 5,
});
if (drifted) {
let body = '';
try {
body = fs.readFileSync('drift-report.md', 'utf8');
} catch {
body = '# Docs drift report\n\nThe checker failed before writing a report — see the workflow run logs.';
}
body += `\n\n---\n_Last checked by [run ${context.runId}](https://github.com/${owner}/${repo}/actions/runs/${context.runId})._\n`;
if (open.data.length > 0) {
await github.rest.issues.update({
owner, repo, issue_number: open.data[0].number, body,
});
core.info(`Updated rolling issue #${open.data[0].number}`);
} else {
const created = await github.rest.issues.create({
owner, repo,
title: 'docs-drift: feature inventory vs docs mismatch',
body,
labels: [label, 'documentation'],
});
core.info(`Created rolling issue #${created.data.number}`);
}
} else {
for (const issue of open.data) {
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: 'Drift resolved — nightly check is clean again. Closing automatically.',
});
await github.rest.issues.update({
owner, repo, issue_number: issue.number, state: 'closed',
});
core.info(`Closed rolling issue #${issue.number}`);
}
}
- name: Surface drift as a failed run
if: steps.drift.outcome == 'failure'
run: |
echo "Docs drift detected — see the rolling docs-drift issue."
exit 1
+73
View File
@@ -0,0 +1,73 @@
# Canonical feature inventory — the single source of truth that the daily
# docs-drift job (.github/workflows/docs-drift.yml) diffs against README.md,
# docs/, and the engine registries via scripts/check-docs-drift.py.
#
# When a PR adds or removes an engine or user-facing feature, update this
# file in the same PR — otherwise the nightly job opens/updates the rolling
# `docs-drift` issue. Spec: docs/competitive-analysis.md Spec 9a /
# docs/specs/2026-06-12-elevenlabs-parity-program.md Wave 0.1.
# Each name must appear verbatim in README.md (the Features grid).
features:
- Voice Cloning
- Voice Design
- Video Dubbing
- Dictation Widget
- Vocal Isolation
- Speaker Diarization
- Batch Queue
- MCP Server
- AI Watermark
- 100% Local
- GPU Auto-Detect
- Extensible
# id: must exactly match the registry keys in backend/services/tts_backend.py
# (_REGISTRY eager entries + _LAZY_REGISTRY).
# readme (optional): a string that must appear in README.md (engine table row).
# doc (optional): a repo-relative doc file that must exist.
tts_engines:
- id: omnivoice
readme: "**OmniVoice** (default)"
- id: cosyvoice
readme: CosyVoice 3
doc: docs/engines/cosyvoice.md
- id: kittentts
readme: KittenTTS
- id: mlx-audio
readme: MLX-Audio
- id: voxcpm2
readme: VoxCPM2
- id: moss-tts-nano
readme: MOSS-TTS-Nano
- id: gpt-sovits
- id: sherpa-onnx
- id: indextts2
doc: docs/engines/indextts.md
- id: omnivoice-gguf
- id: supertonic3
# Same contract against backend/services/asr_backend.py _REGISTRY.
asr_engines:
- id: whisperx
readme: "**WhisperX** (default)"
- id: faster-whisper
readme: Faster-Whisper
- id: mlx-whisper
readme: MLX Whisper
- id: pytorch-whisper
readme: PyTorch Whisper
- id: nemo-parakeet
readme: Parakeet TDT
- id: moonshine
readme: Moonshine
- id: funasr
readme: FunASR
# Doc files that must exist (the install path users are sent to).
docs:
- docs/install/macos.md
- docs/install/windows.md
- docs/install/linux.md
- docs/install/docker.md
- docs/install/troubleshooting.md
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Diff the canonical feature inventory against README, docs, and registries.
The inventory (``docs/features.yaml``) is the curated truth. This checker
verifies, without importing any backend module (the engine registries pull
torch transitively, which the docs-drift CI runner does not have):
1. every ``features[]`` name appears verbatim in README.md;
2. ``tts_engines[].id`` is exactly the set of registry keys parsed from
``backend/services/tts_backend.py`` (eager ``_REGISTRY`` + lazy
``_LAZY_REGISTRY``), both directions;
3. ``asr_engines[].id`` likewise against ``backend/services/asr_backend.py``;
4. every ``readme:`` string appears in README.md;
5. every ``doc:`` / ``docs[]`` file exists.
Exit 0 = no drift. Exit 1 = drift; findings go to stderr and, with
``--output``, to a Markdown report consumed by the rolling-issue automation
in ``.github/workflows/docs-drift.yml``.
Companion to ``scripts/validate-install-docs.py`` (the PR-gating half).
Rolling-issue pattern adapted from Patter (MIT) — see
docs/competitive-analysis.md, Patter deep dive 4.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
# Markers locating the registry dicts whose keys we extract. Each marker is
# matched at a line start; the block ends at the first line that is exactly
# ``}`` or ``})`` (the registries are flat string-keyed dict literals).
_TTS_MARKERS = ("_LAZY_REGISTRY: dict[str, tuple[str, str]] = {",
"_REGISTRY: dict[str, type[TTSBackend]] = _LazyRegistry({")
_ASR_MARKERS = ("_REGISTRY: dict[str, type[ASRBackend]] = {",)
_KEY_RE = re.compile(r'^\s*"([^"]+)"\s*:')
def _registry_ids(source: str, markers: tuple[str, ...], *, path: str) -> set[str]:
"""Parse string keys out of the dict literal(s) following each marker."""
ids: set[str] = set()
lines = source.splitlines()
for marker in markers:
try:
start = next(i for i, ln in enumerate(lines) if ln.strip() == marker.strip())
except StopIteration:
raise SystemExit(
f"check-docs-drift: marker not found in {path}: {marker!r}"
"the registry layout changed; update _TTS_MARKERS/_ASR_MARKERS."
)
for ln in lines[start + 1:]:
stripped = ln.strip()
if stripped in ("}", "})"):
break
if stripped.startswith("#"):
continue
m = _KEY_RE.match(ln)
if m:
ids.add(m.group(1))
return ids
def _check(root: Path) -> list[str]:
drifts: list[str] = []
inv_path = root / "docs" / "features.yaml"
if not inv_path.exists():
return [f"`{inv_path.relative_to(root)}` is missing"]
inv = yaml.safe_load(inv_path.read_text(encoding="utf-8")) or {}
readme = (root / "README.md").read_text(encoding="utf-8")
# 1. Features present in README.
for name in inv.get("features", []):
if name not in readme:
drifts.append(f"feature `{name}` is in the inventory but not in README.md")
# 24. Engine ids vs registries; readme strings; per-engine docs.
for section, src_rel, markers in (
("tts_engines", "backend/services/tts_backend.py", _TTS_MARKERS),
("asr_engines", "backend/services/asr_backend.py", _ASR_MARKERS),
):
entries = inv.get(section, [])
inv_ids = {e["id"] for e in entries}
code_ids = _registry_ids(
(root / src_rel).read_text(encoding="utf-8"), markers, path=src_rel
)
for missing in sorted(code_ids - inv_ids):
drifts.append(
f"engine `{missing}` exists in `{src_rel}` but not in the "
f"`{section}` inventory — document it (or list it deliberately)"
)
for gone in sorted(inv_ids - code_ids):
drifts.append(
f"engine `{gone}` is in the `{section}` inventory but no longer "
f"in `{src_rel}` — remove it from the inventory and docs"
)
for entry in entries:
readme_name = entry.get("readme")
if readme_name and readme_name not in readme:
drifts.append(
f"engine `{entry['id']}`: expected `{readme_name}` in README.md"
)
doc = entry.get("doc")
if doc and not (root / doc).exists():
drifts.append(f"engine `{entry['id']}`: doc `{doc}` does not exist")
# 5. Required docs exist.
for doc in inv.get("docs", []):
if not (root / doc).exists():
drifts.append(f"required doc `{doc}` does not exist")
return drifts
def _report(drifts: list[str], checked: int) -> str:
lines = ["# Docs drift report", ""]
if drifts:
lines.append(f"{len(drifts)} mismatch(es) between `docs/features.yaml`, "
"README.md, docs/, and the engine registries:")
lines.append("")
lines += [f"- {d}" for d in drifts]
lines.append("")
lines.append("Fix by updating the docs **or** the inventory — whichever is "
"stale. This issue updates in place and closes automatically "
"when the nightly check is clean.")
else:
lines.append(f"No drift — {checked} inventory entries verified.")
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None, root: Path | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=None,
help="repo root (default: parent of this script's dir)")
parser.add_argument("--output", type=Path, default=None,
help="write a Markdown report to this path")
args = parser.parse_args([] if argv is None else argv)
repo = args.root or root or Path(__file__).resolve().parents[1]
drifts = _check(repo)
inv = yaml.safe_load((repo / "docs" / "features.yaml").read_text(encoding="utf-8")) \
if (repo / "docs" / "features.yaml").exists() else {}
checked = sum(len(inv.get(k, [])) for k in ("features", "tts_engines", "asr_engines", "docs"))
if args.output:
args.output.write_text(_report(drifts, checked), encoding="utf-8")
if drifts:
for d in drifts:
print(f"docs-drift: {d}", file=sys.stderr)
print(f"check-docs-drift: {len(drifts)} drift(s) across {checked} "
"inventory entries.", file=sys.stderr)
return 1
print(f"OK — {checked} inventory entries verified against README, docs, "
"and engine registries.")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+170
View File
@@ -0,0 +1,170 @@
"""Self-test for scripts/check-docs-drift.py (parity program Wave 0.1).
Mirrors the import pattern of tests/scripts/test_validate_install_docs.py:
the hyphenated script is loaded via importlib against a tmp-path repo fixture,
so no test depends on real repo state.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check-docs-drift.py"
@pytest.fixture(scope="module")
def drift_module():
spec = importlib.util.spec_from_file_location("check_docs_drift", SCRIPT_PATH)
mod = importlib.util.module_from_spec(spec)
sys.modules["check_docs_drift"] = mod
spec.loader.exec_module(mod)
return mod
_TTS_SOURCE = '''
_LAZY_REGISTRY: dict[str, tuple[str, str]] = {
"indextts2": ("engines.indextts", "IndexTTS2Backend"),
}
_REGISTRY: dict[str, type[TTSBackend]] = _LazyRegistry({
"omnivoice": OmniVoiceBackend,
# "indextts2": resolved lazily
"cosyvoice": CosyVoiceBackend,
})
'''
_ASR_SOURCE = '''
_REGISTRY: dict[str, type[ASRBackend]] = {
"whisperx": WhisperXBackend,
}
'''
_INVENTORY = """
features:
- Voice Cloning
tts_engines:
- id: omnivoice
readme: OmniVoice (default)
- id: cosyvoice
doc: docs/engines/cosyvoice.md
- id: indextts2
asr_engines:
- id: whisperx
readme: WhisperX (default)
docs:
- docs/install/macos.md
"""
_README = """# App
## Features
Voice Cloning
## Engines
| OmniVoice (default) | ... |
| WhisperX (default) | ... |
"""
def _make_root(tmp_path: Path, *, inventory: str = _INVENTORY, readme: str = _README,
tts: str = _TTS_SOURCE, asr: str = _ASR_SOURCE) -> Path:
root = tmp_path / "repo"
(root / "docs" / "engines").mkdir(parents=True)
(root / "docs" / "install").mkdir(parents=True)
(root / "backend" / "services").mkdir(parents=True)
(root / "docs" / "features.yaml").write_text(inventory, encoding="utf-8")
(root / "README.md").write_text(readme, encoding="utf-8")
(root / "backend" / "services" / "tts_backend.py").write_text(tts, encoding="utf-8")
(root / "backend" / "services" / "asr_backend.py").write_text(asr, encoding="utf-8")
(root / "docs" / "engines" / "cosyvoice.md").write_text("# CosyVoice\n", encoding="utf-8")
(root / "docs" / "install" / "macos.md").write_text("# macOS\n", encoding="utf-8")
return root
def test_clean_state_passes(drift_module, tmp_path, capsys):
root = _make_root(tmp_path)
assert drift_module.main([], root=root) == 0
assert "OK" in capsys.readouterr().out
def test_feature_missing_from_readme_fails(drift_module, tmp_path, capsys):
root = _make_root(tmp_path, readme=_README.replace("Voice Cloning", "Something Else"))
assert drift_module.main([], root=root) == 1
assert "Voice Cloning" in capsys.readouterr().err
def test_engine_in_code_but_not_inventory_fails(drift_module, tmp_path, capsys):
tts = _TTS_SOURCE.replace(
'"cosyvoice": CosyVoiceBackend,',
'"cosyvoice": CosyVoiceBackend,\n "newengine": NewBackend,',
)
root = _make_root(tmp_path, tts=tts)
assert drift_module.main([], root=root) == 1
assert "newengine" in capsys.readouterr().err
def test_engine_in_inventory_but_not_code_fails(drift_module, tmp_path, capsys):
asr = _ASR_SOURCE.replace('"whisperx": WhisperXBackend,', "")
root = _make_root(tmp_path, asr=asr)
assert drift_module.main([], root=root) == 1
assert "whisperx" in capsys.readouterr().err
def test_commented_registry_lines_ignored(drift_module, tmp_path):
# `# "indextts2": resolved lazily` inside _REGISTRY must not count as a key
# (it is also a real lazy key here, so a parser that read comments would
# not fail — drop the lazy entry to make the assertion meaningful).
tts = _TTS_SOURCE.replace(' "indextts2": ("engines.indextts", "IndexTTS2Backend"),\n', "")
inventory = _INVENTORY.replace(" - id: indextts2\n", "")
root = _make_root(tmp_path, tts=tts, inventory=inventory)
assert drift_module.main([], root=root) == 0
def test_missing_engine_doc_fails(drift_module, tmp_path, capsys):
root = _make_root(tmp_path)
(root / "docs" / "engines" / "cosyvoice.md").unlink()
assert drift_module.main([], root=root) == 1
assert "cosyvoice" in capsys.readouterr().err
def test_missing_required_doc_fails(drift_module, tmp_path, capsys):
root = _make_root(tmp_path)
(root / "docs" / "install" / "macos.md").unlink()
assert drift_module.main([], root=root) == 1
assert "docs/install/macos.md" in capsys.readouterr().err
def test_readme_string_missing_fails(drift_module, tmp_path, capsys):
root = _make_root(tmp_path, readme=_README.replace("WhisperX (default)", "WhisperX"))
assert drift_module.main([], root=root) == 1
assert "WhisperX (default)" in capsys.readouterr().err
def test_report_written_on_drift(drift_module, tmp_path):
root = _make_root(tmp_path)
(root / "docs" / "install" / "macos.md").unlink()
out = tmp_path / "drift-report.md"
assert drift_module.main(["--output", str(out)], root=root) == 1
text = out.read_text(encoding="utf-8")
assert "Docs drift report" in text and "docs/install/macos.md" in text
def test_report_written_on_clean(drift_module, tmp_path):
root = _make_root(tmp_path)
out = tmp_path / "drift-report.md"
assert drift_module.main(["--output", str(out)], root=root) == 0
assert "No drift" in out.read_text(encoding="utf-8")
def test_changed_registry_layout_is_loud(drift_module, tmp_path):
root = _make_root(tmp_path, tts="_SOMETHING_ELSE = {}\n")
with pytest.raises(SystemExit, match="marker not found"):
drift_module.main([], root=root)
def test_real_repo_is_clean(drift_module):
"""The shipped inventory must match the shipped README/registries."""
repo = Path(__file__).resolve().parents[2]
assert drift_module.main([], root=repo) == 0