diff --git a/.github/workflows/docs-versions.yml b/.github/workflows/docs-versions.yml new file mode 100644 index 0000000..e518d7f --- /dev/null +++ b/.github/workflows/docs-versions.yml @@ -0,0 +1,99 @@ +name: Docs versions + +on: + pull_request: + paths: + - '**/*.mdx' + - 'scripts/check-versions.py' + # Weekly, so a release upstream surfaces as an issue rather than waiting for + # someone to notice a stale install command. + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: docs-versions-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The compatibility page is the source of truth. This asks only whether the rest + # of the docs agree with it, so it needs no network and cannot fail because a + # package was published while the pull request was open. + consistency: + name: docs match the compatibility page + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 scripts/check-versions.py --docs + + # This one does hit the network, and a new release upstream is not a reason to + # fail an unrelated pull request, so it never runs on one. + upstream: + name: compatibility page matches npm + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check npm + id: check + run: | + if python3 scripts/check-versions.py --npm > drift.txt 2>&1; then + echo "drifted=false" >> "$GITHUB_OUTPUT" + else + echo "drifted=true" >> "$GITHUB_OUTPUT" + fi + cat drift.txt + + # One open issue at a time. A second week of drift comments on the first + # rather than filing a duplicate. + - name: Open or update an issue + if: steps.check.outputs.drifted == 'true' && github.event_name == 'schedule' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = [ + 'The compatibility page is behind what is published on npm.', + '', + '```', + fs.readFileSync('drift.txt', 'utf8').trim(), + '```', + '', + 'Update `get-started/introduction/compatibility.mdx`, then run', + '`python3 scripts/check-versions.py --docs` to find every pin that', + 'needs to move with it.', + ].join('\n'); + + const title = 'Docs versions are behind npm'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'version-drift', + }); + + if (open.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: open[0].number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + labels: ['version-drift'], + body, + }); + } diff --git a/STYLE.md b/STYLE.md index 9b08763..06159ce 100644 --- a/STYLE.md +++ b/STYLE.md @@ -137,10 +137,11 @@ A page full of callouts has none. ## Enforcement -Most of this guide is mechanical, so two linters enforce it instead of a reviewer. +Most of this guide is mechanical, so linters enforce it instead of a reviewer. - Prose: [Vale](https://vale.sh), with our rules in `styles/Fhenix/` and configuration in `.vale.ini`. It catches em dashes, decorative Unicode, filler phrases, marketing adjectives, "simple" and "easy", idioms, vague link text, non-canonical terminology, Title Case headings, and overlong sentences. - Structure: `scripts/lint-docs.py`, which reads what Vale cannot see. It catches missing frontmatter, manual H1 headings, skipped heading levels, code blocks with no language tag, relative or absolute internal links, and images with no alt text. +- Versions: `scripts/check-versions.py`, which treats the [compatibility page](get-started/introduction/compatibility.mdx) as the single source of truth and fails when any pin elsewhere disagrees with it. Update that page first, then run the script to find every install command and table that has to move with it. Run both on what you changed, before you open a pull request: @@ -149,8 +150,11 @@ brew install vale FILES=$(git diff --name-only --diff-filter=d origin/main...HEAD -- '*.mdx') python3 scripts/lint-docs.py $FILES vale $FILES +python3 scripts/check-versions.py --docs ``` +A version that is deliberately old, because the sentence is describing history, opts out with a `` comment on the same line. A version written as `package@v1.2.3` is read as a historical reference and never checked. + The `Docs style` GitHub Action runs both on the `.mdx` files a pull request touches. Errors block the merge, warnings do not. Pages written before the linters existed still contain violations, so the action ignores files your branch did not touch. Fix a legacy page when you are already editing it, not in a sweep. To see the whole backlog, run the action manually with `scope: all`. Neither linter can judge whether a claim is true, whether a sample runs, or whether a sentence earns its place. That is what review is for. diff --git a/scripts/check-versions.py b/scripts/check-versions.py new file mode 100755 index 0000000..304861e --- /dev/null +++ b/scripts/check-versions.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Version consistency checks for the CoFHE docs. + +The compatibility page is the single source of truth for package versions. +This script asserts two things: + + --docs every version pinned anywhere in the docs matches that page + --npm the compatibility page itself matches what is published on npm + +The two run separately on purpose. The docs check is offline and runs on every +pull request. The npm check needs the network and only tells you the world moved, +which is not a reason to fail an unrelated pull request, so it runs on a schedule. + +Usage: + python3 scripts/check-versions.py --docs [files...] # defaults to every .mdx + python3 scripts/check-versions.py --npm + +Exits 1 if any mismatch is found. See STYLE.md. +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +TRUTH = Path("get-started/introduction/compatibility.mdx") + +# Historical references name a version to say when something changed, which is a +# fact about the past rather than a pin that can go stale. They are written with a +# `v` prefix (`cofhe-contracts@v0.1.2`), so the pin patterns below never match them. +PIN = r'(?') + +SKIP_DIRS = ("cofhejs/",) + + +def truth_table(path: Path = TRUTH): + """Package to version, read from the compatibility page's tables.""" + versions = {} + for line in path.read_text(encoding="utf-8").splitlines(): + # | `@cofhe/sdk` | [`0.7.1`](...) | ... | or | `@cofhe/sdk` | `0.7.1` | ... | + m = re.match( + r'\|\s*\*{0,2}`(@?[a-z0-9@/-]+)`\*{0,2}\s*\|\s*\[?`([\^~]?\d+\.\d+\.\d+)`', + line.strip(), + ) + if m: + versions[m.group(1)] = m.group(2).lstrip("^~") + return versions + + +def scan(paths, versions): + problems = [] + for path in paths: + if any(str(path).startswith(d) for d in SKIP_DIRS): + continue + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if IGNORE.search(line): + continue + + # explicit pins: pkg@1.2.3, pkg@^1.2.3 + for pkg, want in versions.items(): + for m in re.finditer(PIN.format(pkg=re.escape(pkg)), line): + if m.group(2) != want: + problems.append( + (path, n, f"{pkg}@{m.group(1)}{m.group(2)} should be {want}") + ) + + # table rows: | `pkg` | `1.2.3` | ... + cells = [c.strip() for c in line.split("|")] + for i, cell in enumerate(cells[:-1]): + name = re.fullmatch(r'\*{0,2}`(@?[a-z0-9@/-]+)`\*{0,2}', cell) + ver = re.fullmatch(r'\[?`([\^~]?\d+\.\d+\.\d+)`.*', cells[i + 1]) + if name and ver and name.group(1) in versions: + want = versions[name.group(1)] + if ver.group(1).lstrip("^~") != want: + problems.append( + (path, n, f"{name.group(1)} table entry `{ver.group(1)}` should be {want}") + ) + return problems + + +def npm_latest(pkg): + out = subprocess.run( + ["npm", "view", pkg, "version"], capture_output=True, text=True, timeout=120 + ) + return out.stdout.strip() or None + + +def main(argv): + args = [a for a in argv[1:] if not a.startswith("--")] + mode_docs = "--docs" in argv + mode_npm = "--npm" in argv + if not (mode_docs or mode_npm): + print(__doc__) + return 2 + + versions = truth_table() + if not versions: + print(f"{TRUTH}: error could not read any version from the compatibility tables") + return 1 + + failed = False + + if mode_docs: + paths = [Path(a) for a in args] or sorted(Path(".").rglob("*.mdx")) + paths = [p for p in paths if p.suffix == ".mdx" and p.exists()] + problems = scan(paths, versions) + for path, n, message in problems: + print(f"{path}:{n}: error [version-drift] {message}") + print( + f"\n{len(problems)} version mismatch(es) in {len(paths)} file(s), " + f"against {TRUTH}.", + file=sys.stderr, + ) + failed |= bool(problems) + + if mode_npm: + stale = [] + for pkg, documented in sorted(versions.items()): + latest = npm_latest(pkg) + if latest is None: + print(f"error [not-published] {pkg} is not published under that name") + stale.append((pkg, documented, "missing")) + elif latest != documented: + print(f"error [behind-npm] {pkg}: docs say {documented}, npm has {latest}") + stale.append((pkg, documented, latest)) + print( + f"\n{len(stale)} package(s) out of date in {TRUTH}.", file=sys.stderr + ) + failed |= bool(stale) + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))