Skip to content
Merged
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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
#
# tests: the 0.1 acceptance criteria, executable.
# layering: emet_sdk imports nothing internal; emet_hal and emet_engine
# import emet_sdk only.
# import emet_sdk only. The same job runs the release
# invariants and the house-style check.
#
# The layering check exists because the closed-engine plan used to enforce that
# boundary structurally, and an open monorepo does not. See DISTRIBUTION.md §2.
Expand Down Expand Up @@ -46,6 +47,11 @@ jobs:
- name: Check release invariants
run: python tools/release_check.py .

# House style, mechanised because 0.3 removed 178 em-dashes by hand
# after they had passed review one at a time. See CONTRIBUTING.md.
- name: Check house style
run: python tools/check_style.py .

dco:
name: dco sign-off
runs-on: ubuntu-latest
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,19 @@ Before opening a PR, run what CI runs:
```sh
python tools/check_layering.py .
python tools/release_check.py .
python tools/check_style.py .
cd emet-sdk && python -m pytest -q
cd ../emet-hal && python -m pytest -q
cd ../emet-engine && python -m pytest -q
```

## House style

Plain declarative sentences, in code comments and docs alike. No em-dashes:
a comma, a colon, or two sentences. None of the six words that read as a
press release; `tools/check_style.py` lists them, enforces both rules, and
runs in CI, so a stray dash fails the build rather than a review.

CI also builds the three wheels and installs them outside the source tree, so a
packaging mistake that an editable install hides still fails the pipeline. The
exact steps are in `.github/workflows/ci.yml`.
Expand Down
5 changes: 5 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ Run the mechanical half first. It is fast and it fails loudly:
```sh
python tools/check_layering.py .
python tools/release_check.py .
python tools/check_style.py .
```

The tag itself is made by `tools/tag_release.py`, which runs those three
again and refuses a dirty tree, a branch other than master, a version the
packages do not declare, an unsigned key, or a tag that already exists.

Everything below is what a script cannot check.

---
Expand Down
91 changes: 91 additions & 0 deletions tools/check_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Enforce the house style that a review keeps missing.

em-dashes none, anywhere in the tree: a comma, a colon, or two
sentences instead
banned words delve, leverage, robust, seamless, comprehensive,
underscore, in prose

**Why this exists.** 0.3 removed 178 em-dashes by hand from code, comments,
schemas, examples and docs after they had crept in one at a time over three
releases. Each one had passed review, because a reviewer reading for meaning
does not see punctuation. A script does. The banned words are the ones that
read as press release rather than as a person; CONTRIBUTING.md has the rule.

The check is deliberately blunt. There is no allowlist and no way to mark a
line as exempt, because every exemption is the first of many. If a legitimate
case appears, change the rule here, in one place, with a reason.

Dependency-free and short enough to read, like `check_layering.py`.

Usage: python tools/check_style.py [repo_root]
Exit: 0 clean, 1 problems found.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

EM_DASH = "—"

#: Prose that sounds like marketing. Word-bounded, case-insensitive.
BANNED = ("delve", "leverage", "robust", "seamless", "comprehensive", "underscore")

#: What counts as text worth checking.
TEXT_SUFFIXES = {".py", ".md", ".txt", ".toml", ".yaml", ".yml", ".json", ".cfg", ".ini", ".sh", ".ps1"}

#: Never descend into these.
SKIP_DIRS = {".git", ".venv", "__pycache__", "dist", "build", ".pytest_cache", "node_modules"}

#: Third-party text the project did not write and should not rewrite, plus
#: this file, which has to name what it bans.
SKIP_FILES = {"CODE_OF_CONDUCT.md", "LICENSE", "check_style.py"}

BANNED_RE = re.compile(r"\b(" + "|".join(BANNED) + r")\b", re.IGNORECASE)


def text_files(root: Path):
for path in sorted(root.rglob("*")):
if any(part in SKIP_DIRS or part.endswith(".egg-info") for part in path.parts):
continue
if not path.is_file() or path.name in SKIP_FILES:
continue
if path.suffix in TEXT_SUFFIXES or path.name in ("NOTICE",):
yield path


def check(root: Path) -> list[str]:
problems: list[str] = []
for path in text_files(root):
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
rel = path.relative_to(root).as_posix()
for n, line in enumerate(lines, 1):
if EM_DASH in line:
problems.append(f"{rel}:{n}: em-dash. Use a comma, a colon, or two sentences.")
for m in BANNED_RE.finditer(line):
problems.append(f"{rel}:{n}: {m.group(0)!r}. Say the plain thing.")
return problems


def main(argv: list[str]) -> int:
root = Path(argv[1] if len(argv) > 1 else ".").resolve()
if not (root / "emet-sdk").exists():
print(f"style-check: {root} does not look like the Emet repository", file=sys.stderr)
return 1
problems = check(root)
if not problems:
print("style-check: ok")
return 0
for p in problems:
print(f" x {p}")
print(f"\n{len(problems)} problem(s).")
return 1


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
24 changes: 24 additions & 0 deletions tools/release_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
versions all three packages agree
plugins every discovery group has a shipped implementation
docs nothing advertises a version the code no longer is
hal readme every shipped entry point is named in emet-hal/README.md
markers no TODO or FIXME left in shipped source

**Why this exists.** 0.3's scope was "audio in/out, wake word, VAD,
Expand Down Expand Up @@ -133,6 +134,28 @@ def check_groups(root: Path) -> None:
notes.append(f"{total} entry points across {len(provided)} groups")


def check_hal_readme(root: Path) -> None:
"""Every entry point emet-hal registers is named in its README.

0.3 shipped seven new plugins and the README's "what ships" table kept
listing the four from 0.2, through several rounds of "what is left".
A newcomer reads that table before anything else.
"""
pyproject = root / "emet-hal" / "pyproject.toml"
readme = root / "emet-hal" / "README.md"
if not pyproject.exists() or not readme.exists():
return
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
text = readme.read_text(encoding="utf-8")
for group, entries in (data.get("project", {}).get("entry-points") or {}).items():
for name in entries:
if f"`{name}`" not in text:
problem(
f"emet-hal/README.md does not name the {group} entry point `{name}`. "
f"The 'what ships' table is the first thing a contributor reads."
)


def check_docs(root: Path, version: str | None) -> None:
"""Nothing should advertise a version the code no longer is."""
if not version:
Expand Down Expand Up @@ -178,6 +201,7 @@ def main(argv: list[str]) -> int:
check_single_declaration(root)
check_groups(root)
check_docs(root, version)
check_hal_readme(root)
check_markers(root)

for note in notes:
Expand Down
147 changes: 147 additions & 0 deletions tools/tag_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Make the release tag the way the project makes release tags.

Signed, annotated, from the release-notes file, on a clean master that
matches origin, at the version the three packages declare. Every check here
is a mistake that has been made or nearly made:

unsigned tag v0.3 first went up with `git tag -a`, no `-s`, and showed
Unverified on GitHub
wrong branch a tag on a feature branch points at a commit master
never had
dirty tree a tag with uncommitted work nearby is a tag of something
nobody can rebuild
behind origin a tag on a local master that has not pulled the squash
commit points at the wrong parent
version drift the tag says 0.3, a pyproject still says 0.2

Nothing here bypasses the person: it runs `git tag` and, only with `--push`,
`git push`. It never commits and never touches the index.

Usage:
python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt
python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt --push
python tools/tag_release.py 0.3 --notes ../release-notes/v0.3.txt --dry-run
Exit: 0 tagged (or dry run printed), 1 a check failed.
"""

from __future__ import annotations

import argparse
import subprocess
import sys
import tomllib
from pathlib import Path

PACKAGES = ("emet-sdk", "emet-hal", "emet-engine")


def git(root: Path, *args: str) -> str:
return subprocess.run(
["git", *args], cwd=root, check=True, capture_output=True, text=True, encoding="utf-8"
).stdout.strip()


def fail(msg: str) -> int:
print(f"tag-release: {msg}", file=sys.stderr)
return 1


def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="tag_release.py", description=__doc__.split("\n\n")[0])
parser.add_argument("version", help="the release, as 0.N or 0.N.M")
parser.add_argument("--notes", required=True, help="release-notes file; becomes the tag message")
parser.add_argument("--push", action="store_true", help="push the tag to origin after making it")
parser.add_argument("--dry-run", action="store_true", help="run the checks and print the commands only")
parser.add_argument("--root", default=".", help="repository root (default: .)")
args = parser.parse_args(argv[1:])

root = Path(args.root).resolve()
if not (root / "emet-sdk").exists():
return fail(f"{root} does not look like the Emet repository")

series = args.version
full = series if series.count(".") == 2 else f"{series}.0"
tag = f"v{series}"
notes = Path(args.notes).resolve()

# The notes file, before anything else: it is the tag message.
if not notes.exists():
return fail(f"notes file not found: {notes}")
first = notes.read_text(encoding="utf-8").splitlines()[0] if notes.stat().st_size else ""
if not first.startswith(f"{series}:"):
return fail(f"first line of {notes.name} is {first!r}; expected it to start with {series + ':'!r}")

# The three packages agree with the tag.
for pkg in PACKAGES:
data = tomllib.loads((root / pkg / "pyproject.toml").read_text(encoding="utf-8"))
declared = data["project"]["version"]
if declared != full:
return fail(f"{pkg}/pyproject.toml declares {declared}, the tag says {full}")

# Master, clean, and level with origin.
branch = git(root, "rev-parse", "--abbrev-ref", "HEAD")
if branch != "master":
return fail(f"on branch {branch!r}; release tags are made on master")
if git(root, "status", "--porcelain"):
return fail("the working tree is not clean; commit or stash first")
git(root, "fetch", "--quiet", "origin", "master", "--tags")
if git(root, "rev-parse", "HEAD") != git(root, "rev-parse", "origin/master"):
return fail("local master and origin/master differ; pull (or push) first")

# Not twice.
if git(root, "tag", "--list", tag):
return fail(f"{tag} already exists locally. To replace it: git tag -d {tag}, then run again")
if git(root, "ls-remote", "--tags", "origin", tag):
return fail(f"{tag} already exists on origin. Replacing a pushed tag is a deliberate act; see RELEASE_PROCESS.md")

# Signing is configured, so `-s` will succeed rather than prompt.
try:
key = git(root, "config", "--get", "user.signingkey")
except subprocess.CalledProcessError:
key = ""
if not key:
return fail("git has no user.signingkey; the tag would be unsigned. Set gpg.format and user.signingkey first")

# The mechanical release checks, because a tag is a promise they held.
py = sys.executable
for script in ("tools/check_layering.py", "tools/release_check.py", "tools/check_style.py"):
result = subprocess.run([py, str(root / script), str(root)], cwd=root, capture_output=True, text=True)
if result.returncode != 0:
print(result.stdout, end="")
return fail(f"{script} failed; fix that before tagging")

commands = [
["git", "tag", "-s", tag, "-F", str(notes)],
["git", "tag", "-v", tag],
]
if args.push:
commands.append(["git", "push", "origin", tag])

print(f"tag-release: {tag} at {git(root, 'rev-parse', '--short', 'HEAD')} ({first})")
for cmd in commands:
shown = " ".join(cmd)
if args.dry_run:
print(f" would run: {shown}")
continue
print(f" {shown}")
result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, encoding="utf-8")
if result.stdout.strip():
print(" " + result.stdout.strip().replace("\n", "\n "))
if result.returncode != 0:
print(result.stderr, file=sys.stderr)
return fail(f"{shown} failed")
if result.stderr.strip() and cmd[1] == "tag" and "-v" in cmd:
print(" " + result.stderr.strip().replace("\n", "\n "))

if not args.push and not args.dry_run:
print(f"tag-release: made and verified. Push it with: git push origin {tag}")
print(
f"tag-release: then the GitHub release: gh release create {tag} --verify-tag "
f"--prerelease --title \"{first}\" --notes-file \"{notes}\""
)
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))