diff --git a/AGENTS.md b/AGENTS.md index ad9325d9..56681cea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,7 +355,7 @@ Contributors commit to this repo with signed commits; the SSH-signing setup live Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. -- **Description** matches the README's first non-empty line after the `#` H1 heading, as plain text - strip markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail - the fix is to sharpen the README so the description follows it. +- **Description** matches the README's first non-empty line after the `#` H1 heading, as plain text - strip markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. The README is the source of truth: set the description from it (`gh api -X PATCH repos// -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail - the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters** - Docker Hub's short-description cap, the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same README intro line, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. - **Topics** are optional; any that are present match the repo's actual content. Do not invent topics to fill the field. - **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only - the REST and GraphQL APIs neither read nor write them - so they are set by hand and cannot be audited through `gh`. diff --git a/spec/audit.py b/spec/audit.py index 9411112b..1bb24376 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -25,6 +25,8 @@ import re import subprocess import sys +import urllib.error +import urllib.request from datetime import datetime, timezone from typing import Any @@ -74,6 +76,25 @@ def gh(path, ok404=False) -> Any: return json.loads(r.stdout) if r.stdout.strip() else None +def docker_hub_description(slug): + """The Docker Hub short description for a repo, or None if the image is genuinely absent (HTTP 404). + + The image name is taken as owner/repo lowercased, the fleet convention (`ptr727/PhotoCleaner` -> + `ptr727/photocleaner`), so a repo whose image is named otherwise, or not yet pushed, 404s and is skipped + rather than falsely flagged. A transient failure (timeout, network, non-404 status) is **raised**, not + swallowed, so the caller surfaces "could not verify" instead of silently passing. Read-only, unauthenticated. + """ + owner, repo = slug.split("/", 1) + url = f"https://hub.docker.com/v2/repositories/{owner.lower()}/{repo.lower()}/" + try: + with urllib.request.urlopen(url, timeout=5) as r: + return json.loads(r.read().decode("utf-8")).get("description") + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + def normalize_ruleset(payload): sub = {k: payload.get(k) for k in RULESET_SUBSET} if isinstance(sub.get("rules"), list): @@ -692,21 +713,42 @@ def audit_repo(entry, spec): elif r_intro != h_intro: findings.append(("LETTER", "history: HISTORY.md intro does not mirror the README intro - copy the README's opening paragraph (spec/readme-structure.md)")) - # --- Repository description mirrors the README intro line --- - # AGENTS.md "Repository Details": the About description is the README's first line after the H1 as plain - # text (links stripped), and the README is the source of truth. spec/readme-structure.md additionally wants - # that line link-free, so it carries to the unrendered description without formatting loss. + # --- README title/intro is the one canonical short description --- + # spec/readme-structure.md item 1 + AGENTS.md "Repository Details": the H1 is the repo name, and the intro + # line after it is a link-free, <=100-char plain sentence that carries verbatim to the GitHub About + # description and (for a docker repo) the Docker Hub short description. The README is the source of truth. if "README.md" in doc_texts: - intro_line = title_and_intro(doc_texts["README.md"])[1].split("\n")[0] + title, intro = title_and_intro(doc_texts["README.md"]) + intro_line = intro.split("\n")[0] + # The H1 is the repository name, and a hyphenated name may render its hyphens as spaces. + # Use the GitHub API's canonical name, since the registry-URL slug can carry a different case. + repo_name = live.get("name") or slug.split("/")[-1] + if not title: + findings.append(("LETTER", "readme: no `# ` H1 title - the README opens with `# ` then a one-line description (spec/readme-structure.md)")) + elif title.replace("-", " ") != repo_name.replace("-", " "): + findings.append(("LETTER", f"readme: the H1 title '{title}' is not the repo name '{repo_name}' (a hyphenated name may render its hyphens as spaces) - the H1 is the repository name (spec/readme-structure.md)")) if not intro_line: findings.append(("LETTER", "readme: no intro line after the H1 - the README opens with the title then a one-line description, which doubles as the About description (spec/readme-structure.md)")) else: if strip_md_links(intro_line) != intro_line: findings.append(("LETTER", "readme: the intro line carries markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)")) - desc = (live.get("description") or "").strip() want = strip_md_links(intro_line).strip() + if len(want) > 100: + findings.append(("LETTER", f"readme: the intro line is {len(want)} characters, over the 100-char limit (Docker Hub's short-description cap, the tightest surface it feeds) - tighten it to one short sentence (spec/readme-structure.md)")) + desc = (live.get("description") or "").strip() if desc != want: findings.append(("LETTER", f"description: the About description does not match the README intro line (description '{desc}' vs readme '{want}') - set it from the README, or sharpen the README first if the description carries real detail (AGENTS.md Repository Details)")) + # Docker Hub short description mirrors the same intro, for a repo that publishes a docker image. + # A transient lookup failure surfaces as a DRIFT ("could not verify"), never aborting or silently passing. + # A 404 (image not at the derived name) returns None and is skipped. + if any((pt.get("target") if isinstance(pt, dict) else pt) == "docker" for pt in entry.get("publish", [])): + try: + dh = docker_hub_description(slug) + except Exception as e: + dh = None + findings.append(("DRIFT", f"description: could not read the Docker Hub short description to verify it mirrors the README ({e}) - verify by hand")) + if dh is not None and dh.strip() != want: + findings.append(("LETTER", f"description: the Docker Hub short description ('{dh.strip()}') does not match the README intro ('{want}') - set it from the README (spec/readme-structure.md)")) # --- cspell single source of truth --- # CODESTYLE.md "Markdown and Spelling": cspell.json is the one word list, and a cSpell words block left in diff --git a/spec/readme-structure.md b/spec/readme-structure.md index c106811a..46168e09 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -4,7 +4,7 @@ The preferred `README.md` shape for a fleet project. The audit's `readme-structu ## Sections and Order -1. **Title (`# `)** - the repo name, then a one-line description as the next paragraph. That line is **link-free plain text**: it doubles as the GitHub About description (AGENTS.md "Repository Details"), which renders no markdown, so a link would carry as raw brackets. The audit checks both properties. +1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then a one-line description as the next paragraph. That description is a **single sentence, link-free plain text, at most 100 characters** - it is the one canonical short description. It doubles as the GitHub About description (AGENTS.md "Repository Details") and, for a repo that publishes a Docker image, the Docker Hub short description. Both render no markdown, and Docker Hub caps the short description near 100 characters - the tightest surface, which sets the limit. The audit checks the H1 name, the length, the link-free form, and the mirrors. 2. **Build and Distribution (`##`)** - a bullet per distribution channel the project actually ships, each linking where it lives: **Source Code** (the GitHub repo), **Versioned Releases** (GitHub Releases), **Docker Images** (Docker Hub), **NuGet Packages** (NuGet.org), **PyPI Packages** (PyPI.org). List only the channels the project uses. It carries three sub-sections: - **Build Status (`###`)** - the CI/build status shields (release build, Docker build, last commit, last build). - **Releases (`###`)** - the version shields (GitHub release, GitHub pre-release, Docker latest/develop, NuGet, PyPI), one per channel the project publishes. @@ -40,4 +40,4 @@ Shields are not a top-level section - they live under **Build and Distribution** ## Docker Hub README -A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the Docker Hub repository overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README. +Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README intro line (item 1), and the longer **overview**. A repo that publishes a Docker image keeps a **separate** `Docker/README.md` for the overview: Docker Hub's description has a much smaller size limit than a project README, so it carries a trimmed overview, not the full README. It is published by the docker-readme workflow task, not copied from the root README.