From 1ca0e0c8e120361f545e44efb8d816bb2ec33ce2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 24 Jul 2026 10:32:05 -0700 Subject: [PATCH 1/5] One canonical <=100-char description across README, About, and Docker Hub A new Docker Hub repo caps its short description near 100 characters, so the README's intro line could not carry as-is. Make the README opening the one canonical short description and audit it across every surface it feeds: - The README H1 is the repository name (a hyphenated name may render its hyphens as spaces, Financial-Modeling -> Financial Modeling); the audit checks it. - The intro line is a single sentence, link-free, at most 100 characters - Docker Hub's cap, the tightest surface. The audit flags an over-length intro. - That one line mirrors to the GitHub About description (already checked) and, for a docker repo, the Docker Hub short description (new best-effort check via the Docker Hub API; the image name is owner/repo lowercased, skipped on 404). Repository Details (verbatim) and spec/readme-structure.md state the rule. Already surfacing drift: PhotoCleaner's 150-char intro and its unsynced Docker Hub tagline. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 2 +- spec/audit.py | 41 ++++++++++++++++++++++++++++++++++------ spec/readme-structure.md | 4 ++-- 3 files changed, 38 insertions(+), 9 deletions(-) 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..67133c1d 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -25,6 +25,7 @@ import re import subprocess import sys +import urllib.request from datetime import datetime, timezone from typing import Any @@ -74,6 +75,22 @@ 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 unavailable. + + The image name is taken as owner/repo lowercased, the fleet convention (`ptr727/PhotoCleaner` -> + `ptr727/photocleaner`). Best-effort: a repo whose Docker Hub image is named otherwise, or not yet pushed, + 404s and is skipped rather than falsely flagged. Read-only, unauthenticated, so it needs no token. + """ + owner, repo = slug.split("/", 1) + url = f"https://hub.docker.com/v2/repositories/{owner.lower()}/{repo.lower()}/" + try: + with urllib.request.urlopen(url, timeout=15) as r: + return json.loads(r.read().decode("utf-8")).get("description") + except Exception: + return None + + def normalize_ruleset(payload): sub = {k: payload.get(k) for k in RULESET_SUBSET} if isinstance(sub.get("rules"), list): @@ -692,21 +709,33 @@ 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; a hyphenated name may render its hyphens as spaces. + repo_name = slug.split("/")[-1] + if 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. + if any((pt.get("target") if isinstance(pt, dict) else pt) == "docker" for pt in entry.get("publish", [])): + dh = docker_hub_description(slug) + 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. From 8cfcc266db4991587bffbac44af1c2b637a2d44e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 24 Jul 2026 10:37:09 -0700 Subject: [PATCH 2/5] Address Copilot: canonical repo name for the H1 check, shorter DH timeout - H1-title check uses live['name'] (the GitHub API canonical name), not the registry-URL slug which can carry a different case; falls back to the slug. - Docker Hub best-effort fetch timeout 15s -> 5s so it can't feel-hang the audit. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 67133c1d..67dc3af2 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -85,7 +85,7 @@ def docker_hub_description(slug): owner, repo = slug.split("/", 1) url = f"https://hub.docker.com/v2/repositories/{owner.lower()}/{repo.lower()}/" try: - with urllib.request.urlopen(url, timeout=15) as r: + with urllib.request.urlopen(url, timeout=5) as r: return json.loads(r.read().decode("utf-8")).get("description") except Exception: return None @@ -716,8 +716,9 @@ def audit_repo(entry, spec): if "README.md" in doc_texts: title, intro = title_and_intro(doc_texts["README.md"]) intro_line = intro.split("\n")[0] - # The H1 is the repository name; a hyphenated name may render its hyphens as spaces. - repo_name = slug.split("/")[-1] + # The H1 is the repository name; a hyphenated name may render its hyphens as spaces. Use the + # GitHub API's canonical name (the registry-URL slug can carry a different case). + repo_name = live.get("name") or slug.split("/")[-1] if 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: From b950c470025f439c054699589a5c114baad9c80d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 24 Jul 2026 10:44:43 -0700 Subject: [PATCH 3/5] Address Copilot: distinguish Docker Hub 404 from a transient failure docker_hub_description() returned None on any error, so a transient failure silently skipped the check. Now a 404 (image not at the derived name) returns None and is skipped, while a transient error is raised and the caller catches it into a DRIFT 'could not verify' advisory - surfaced, never silently passed, and never aborting audit_repo for that repo's other findings. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 67dc3af2..d22d6b6f 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -25,6 +25,7 @@ import re import subprocess import sys +import urllib.error import urllib.request from datetime import datetime, timezone from typing import Any @@ -76,19 +77,22 @@ def gh(path, ok404=False) -> Any: def docker_hub_description(slug): - """The Docker Hub short description for a repo, or None if unavailable. + """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`). Best-effort: a repo whose Docker Hub image is named otherwise, or not yet pushed, - 404s and is skipped rather than falsely flagged. Read-only, unauthenticated, so it needs no token. + `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 Exception: - return None + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise def normalize_ruleset(payload): @@ -732,9 +736,15 @@ def audit_repo(entry, spec): 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. + # 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") rather than aborting the audit 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", [])): - dh = docker_hub_description(slug) + 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)")) From 355ece22ddaf9b49677d113cc5d2f73385873cc3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 24 Jul 2026 10:50:35 -0700 Subject: [PATCH 4/5] Address Copilot: guard a missing H1 (title None) in the readme check title_and_intro returns title=None when README.md has no '# ' H1; the H1-name check called title.replace unconditionally and would raise. Emit a 'no H1' LETTER instead. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index d22d6b6f..8b8ae6e8 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -723,7 +723,9 @@ def audit_repo(entry, spec): # The H1 is the repository name; a hyphenated name may render its hyphens as spaces. Use the # GitHub API's canonical name (the registry-URL slug can carry a different case). repo_name = live.get("name") or slug.split("/")[-1] - if title.replace("-", " ") != repo_name.replace("-", " "): + 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)")) From eb53d6e48fddb8d5a8b38f0732e13c60dc9684b8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 24 Jul 2026 10:55:59 -0700 Subject: [PATCH 5/5] Address Copilot: recast comment semicolon splices, one sentence per line The new readme/description comments used clause-joining semicolons and wrapped a sentence across lines, the exact pattern the Documentation Style Conventions ban. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 8b8ae6e8..1bb24376 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -720,8 +720,8 @@ def audit_repo(entry, spec): if "README.md" in doc_texts: title, intro = title_and_intro(doc_texts["README.md"]) intro_line = intro.split("\n")[0] - # The H1 is the repository name; a hyphenated name may render its hyphens as spaces. Use the - # GitHub API's canonical name (the registry-URL slug can carry a different case). + # 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)")) @@ -738,9 +738,9 @@ def audit_repo(entry, spec): 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") rather than aborting the audit or - # silently passing; a 404 (image not at the derived name) returns None and is skipped. + # 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)