Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/docs-versions.yml
Original file line number Diff line number Diff line change
@@ -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,
});
}
6 changes: 5 additions & 1 deletion STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 `<!-- versions:ignore -->` 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.
Expand Down
139 changes: 139 additions & 0 deletions scripts/check-versions.py
Original file line number Diff line number Diff line change
@@ -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'(?<![\w/@-]){pkg}@([\^~]?)(\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?)'

# A page can opt a line out when it deliberately shows an old version.
IGNORE = re.compile(r'<!--\s*versions:ignore\s*-->')

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))
Loading