diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 75edfc1e..e170a8c4 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -269,7 +269,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 **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. 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, and 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 and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the README is carried onward rather than corrected there. +- **Description** is one canonical sentence that carries to the README, the About panel, and (for a Docker repo) the Docker Hub short description alike, at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the source, itself link-free plain text on one line for the same reason: `repo-config/configure.sh apply` writes it to the About panel directly, and the README's **tagline** (its first non-empty line after the `#` H1 heading) follows it rather than the other way around. A repo that has not adopted the field yet keeps the pre-existing convention, where the README tagline is the source of truth and the About panel is set from it by hand (`gh api -X PATCH repos// -f description=...`). `spec/audit.py`'s `description_findings()` reports drift either way, falling back to the tagline when no field is declared. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. When the current description is *more specific* than the declared source (a chip revision or variant it omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the declared source so the other mirrors follow it. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the canonical value is carried onward rather than corrected there. - **Topics** are optional, and 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, since 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/TODO.md b/TODO.md index 0ea1cb91..4e4d3159 100644 --- a/TODO.md +++ b/TODO.md @@ -35,23 +35,6 @@ One pull request pointing a hub `uses:` at a hub-owned action, so that the resol - **Settled** - The resolvability pass reports what it covered on every run, so the hub's zero is visible rather than silent, which is why this is a separate decision rather than a defect in that pass. - **Settled** - The fleet's `ptr727` pins are live in the downstream repos that consume the action, and `repo_gate.py --root ` from a hub checkout reads them there, so the pass is not idle fleet-wide. -### The Declared Repository Description - -One pull request moving the canonical short description into declared data, so every check and every push reads a field rather than parsing a document, and the About panel gets something that writes it. - -**State** `decision`. **Touches** [`registry/repos.json`][repos] and its schema, [`spec/audit.py`][audit], and `repo-config/configure.sh`. **Cost** one hub edit, and repos adopt the field one at a time. The tagline rule this cluster once carried shipped on 2026-08-08. - -- **Declare the description in [`registry/repos.json`][repos] instead of deriving it by parsing the README.** Every check and every push then reads a field. - - **Blocked by** - Nothing. - - **Issue** - None filed, and the disposition is recorded on [#509][issue-509]. - - **Checked** - `develop` at `3d1a0b1` on 2026-08-06, where neither `registry/repos.json` nor `registry/repos.schema.json` carries a `description` key. - - **Open** - Nothing beyond sequencing, which is that this leads and the README shape follows. - - **Settled** - PhotoCleaner#32 measures the cost of parsing, since a workflow step reading the intro at publish time needs nine guards against headings, block quotes, all four list markers, ordered lists, HTML, tables, code, links and the length cap, and every one of them fails the release rather than the tagline. - - **Settled** - The field makes the README intro a third mirror rather than the source, so the audit compares all three against one declared value and `repo-config/configure.sh` sets the About panel from the same field it already sets every other setting from. - - **Settled** - The 100-character cap stays, since Docker Hub's short description is the tightest surface. - - **Settled** - The field is optional at first so the audit falls back to the README intro while repos adopt it, and it needs a schema entry because `registry/repos.schema.json` sets `additionalProperties: false`. - - **Settled** - The ask on the Docker repos meanwhile is only that the parsing step is not propagated further. - ### Reducing the Carried Surface Further One pull request measuring the remaining carried surface against the carry-versus-reach test and moving whatever qualifies, now that the model is settled rather than open. @@ -515,7 +498,6 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w [issue-456]: https://github.com/ptr727/ProjectTemplate/issues/456 [issue-483]: https://github.com/ptr727/ProjectTemplate/issues/483 [issue-489]: https://github.com/ptr727/ProjectTemplate/issues/489 -[issue-509]: https://github.com/ptr727/ProjectTemplate/issues/509 [issue-521]: https://github.com/ptr727/ProjectTemplate/issues/521 [issue-523]: https://github.com/ptr727/ProjectTemplate/issues/523 [issue-558]: https://github.com/ptr727/ProjectTemplate/issues/558 diff --git a/registry/repos.json b/registry/repos.json index 9347dfbb..d3ea07aa 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -10,6 +10,7 @@ { "name": "ProjectTemplate", "url": "https://github.com/ptr727/ProjectTemplate", + "description": "Agent enablement for a fleet of repositories: autonomy and repeatable quality inside guardrails.", "status": "cataloged", "types": ["source-only", "docs"], "groundTruthBranch": "main", diff --git a/registry/repos.schema.json b/registry/repos.schema.json index be6df3a5..4efe29be 100644 --- a/registry/repos.schema.json +++ b/registry/repos.schema.json @@ -47,6 +47,13 @@ "properties": { "name": { "type": "string", "minLength": 1 }, "url": { "type": "string", "format": "uri", "minLength": 1 }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^\\S(?:[^\\n\\r]*\\S)?$", + "$comment": "Single line, no leading/trailing whitespace - kept in sync with spec/validate.py's description_errors(). That function also rejects Markdown links, which this pattern does not attempt to express." + }, "status": { "enum": ["cataloged", "backlog", "archived", "excluded"] }, "exclusionReason": { "type": "string", "minLength": 1, "pattern": "\\S" }, "types": { "type": "array", "items": { "type": "string" } }, diff --git a/repo-config/configure.sh b/repo-config/configure.sh index 2972e361..e5de18eb 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -64,6 +64,29 @@ esac main_ruleset="$script_dir/main.json" settings_file="$script_dir/settings.json" +# ----- Resolve the declared description (optional, shared by apply and check) ----- +# Per GOVERNANCE.md "Repository Details", once a repo declares registry/repos.json's `description` field, that field becomes the About panel's source rather than the README. +# The audit's description_findings() (spec/audit.py) measures the README, About, and Docker Hub mirror set against that same field. +# A repo with no declared field is left untouched here, so the README stays its source of truth. +description="" +if [ -f "$registry" ]; then + # Trimmed defensively even though spec/validate.py already rejects an untrimmed value. + # A registry edited ahead of its next validate.py run still resolves to the same canonical value spec/audit.py compares against. + if ! description="$(jq -r --arg n "$name" \ + '(.repos[] | select(.name==$n) | .description) // "" | gsub("^\\s+|\\s+$"; "")' "$registry")"; then + echo "Failed to read description from $registry (invalid JSON?)." >&2 + exit 1 + fi + # The trim above only strips leading/trailing whitespace, so an embedded newline or carriage return survives it. + # Caught here rather than left to reach `gh api` as a multi-line value. + case "$description" in + *$'\n'* | *$'\r'*) + echo "The declared description for $name in $registry carries an embedded newline. Fix it there (spec/validate.py rejects this once run)." >&2 + exit 1 + ;; + esac +fi + # ----- Ruleset id lookup (shared by apply and check) ----- # Map a ruleset name to the id of the first match, leaving it empty when nothing matches. # It warns on duplicates, and aborts on an API error or at the per_page cap, where a single-fetch lookup is unreliable. @@ -158,7 +181,12 @@ cmd_apply() { payload="$(jq --argjson d "$disc" '. + {has_discussions: $d}' "$settings_file")" echo "Warning: $repo has no 'main' branch. Leaving default_branch unchanged." >&2 fi - echo "Applying general settings (has_discussions=$disc)" + # The About description, only once a repo declares registry/repos.json's `description` (see the resolution above). + # Left untouched otherwise, so a repo that has not adopted the field yet keeps its hand-set (or README-derived) description. + if [ -n "$description" ]; then + payload="$(jq --arg desc "$description" '. + {description: $desc}' <<<"$payload")" + fi + echo "Applying general settings (has_discussions=$disc$([ -n "$description" ] && echo ", description from registry/repos.json"))" printf '%s' "$payload" | gh api --method PATCH "repos/$repo" --input - >/dev/null # ----- Dependabot alerts + automated security updates ----- gh api --method PUT "repos/$repo/vulnerability-alerts" >/dev/null @@ -275,6 +303,16 @@ check_settings() { if gh api "repos/$repo/branches/main" --jq '.name' >/dev/null 2>&1; then assert "default_branch = main" test "$(jq -r '.default_branch' <<<"$live")" = main fi + # The About description, only where the registry declares one (see the resolution above). + # A repo that has not adopted the field is a manual-verify note, exactly as secrets are: nothing declared here to check against. + # The two reasons `$description` can be empty are told apart, since "no registry" and "no field for this repo" call for different follow-up. + if [ -n "$description" ]; then + assert "description = '$description'" test "$(jq -r '.description' <<<"$live")" = "$description" + elif [ ! -f "$registry" ]; then + note "description: no $registry to read (pass a plain repo argument or run from a hub checkout) - verify manually" + else + note "description: no registry/repos.json description declared for $name - verify manually (falls back to the README tagline, see GOVERNANCE.md 'Repository Details')" + fi } check_security() { diff --git a/scripts/tests/test_spec_validate.py b/scripts/tests/test_spec_validate.py index 1fe70977..c7a29340 100755 --- a/scripts/tests/test_spec_validate.py +++ b/scripts/tests/test_spec_validate.py @@ -142,5 +142,57 @@ def test_checks_an_explicit_whole_intent_file_that_also_lists_sections(self) -> self.assertEqual(1, len(validate.carried_link_errors(self.root, baseline))) +class DescriptionErrorsCase(unittest.TestCase): + """registry/repos.json's optional `description` (GOVERNANCE.md "Repository Details").""" + + def test_a_plain_short_sentence_is_clean(self) -> None: + self.assertEqual(validate.description_errors("Fixture", "A short tagline."), []) + + def test_whitespace_only_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", " "), + ["Fixture: description must be a non-empty string"], + ) + + def test_an_inline_markdown_link_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "See [docs](https://example.test) for more."), + ["Fixture: description carries Markdown links - keep it link-free plain text"], + ) + + def test_a_reference_style_markdown_link_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "See [docs][ref] for more."), + ["Fixture: description carries Markdown links - keep it link-free plain text"], + ) + + def test_leading_or_trailing_whitespace_is_rejected(self) -> None: + # Not silently trimmed here, even though spec/audit.py and configure.sh both strip it defensively. + # Rejecting it at the source keeps the registry's own text the exact canonical form every mirror carries. + self.assertEqual( + validate.description_errors("Fixture", " A short tagline. "), + [ + "Fixture: description must be plain single-line text with no leading or trailing whitespace" + ], + ) + + def test_an_embedded_newline_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "A tagline.\nA second line."), + [ + "Fixture: description must be plain single-line text with no leading or trailing whitespace" + ], + ) + + def test_exactly_the_cap_is_clean(self) -> None: + self.assertEqual(validate.description_errors("Fixture", "a" * 100), []) + + def test_over_the_cap_is_rejected(self) -> None: + self.assertEqual( + validate.description_errors("Fixture", "a" * 101), + ["Fixture: description is 101 characters, over the 100-char limit"], + ) + + if __name__ == "__main__": unittest.main() diff --git a/spec/audit.py b/spec/audit.py index 7006ba2e..e5f2bc47 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -1285,6 +1285,118 @@ def readme_shield_findings(text, model, entry): return findings +def description_findings(doc_texts, entry, live, slug): + """The README title/tagline, the GitHub About description, and the Docker Hub short description, checked as one mirror set. + + Per spec/readme-structure.md item 1 and GOVERNANCE.md "Repository Details", the H1 is the repo name. The tagline + after it, the first line of the intro region, is a link-free plain sentence of at most 100 characters that + carries verbatim to the GitHub About description, and on a docker repo to the Docker Hub short description. Any + further paragraph is free prose no mirror reads, which is why only the first line is measured. + + registry/repos.json's optional `description` is the canonical value once a repo declares it (GOVERNANCE.md + "Repository Details"). repo-config/configure.sh then writes the About panel from that same field. A repo that + has not adopted it yet still has the README as its source of truth, so the checks below fall back to the + tagline exactly as before. + + A declared field's mirrors (About, Docker Hub) are measured against it even where the README itself could + not be read (missing, oversized, or fetched with no inline content) or carries no usable tagline, since the + declared value is canonical on its own and does not need the README to establish it. + """ + findings = [] + declared = (entry.get("description") or "").strip() or None + readme_want = None + if "README.md" in doc_texts: + title, intro = title_and_intro(doc_texts["README.md"]) + intro_line = tagline(intro) + # 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 the tagline (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 tagline 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 tagline carries Markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)", + ) + ) + readme_want = strip_md_links(intro_line).strip() + if len(readme_want) > 100: + findings.append( + ( + "LETTER", + f"readme: the tagline is {len(readme_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)", + ) + ) + # The declared field wins once a repo has one; every mirror (README, About, Docker Hub) is then measured against it. + # A repo with no declared field keeps the README as the source, exactly as before, so with no readable tagline + # there is nothing yet to measure the other mirrors against. + want = declared or readme_want + if want is None: + return findings + source = "registry/repos.json" if declared else "the README" + if declared and readme_want is not None and readme_want != declared: + findings.append( + ( + "LETTER", + f"readme: the tagline ('{readme_want}') does not match the declared description ('{declared}') - registry/repos.json's description is canonical once declared, so the README follows it (GOVERNANCE.md Repository Details)", + ) + ) + desc = (live.get("description") or "").strip() + if desc != want: + findings.append( + ( + "LETTER", + f"description: the About description does not match the {'declared description' if declared else 'README tagline'} (description '{desc}' vs '{want}') - set it from {source}, or sharpen {source} first if the description carries real detail (GOVERNANCE.md Repository Details)", + ) + ) + # Docker Hub short description mirrors the same canonical value, 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: # noqa: BLE001 + dh = None + findings.append( + ( + "DRIFT", + f"description: could not read the Docker Hub short description to verify it mirrors {source} ({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 '{want}' - set it from {source} (spec/readme-structure.md)", + ) + ) + return findings + + def workspace_cspell_words(text): """True if workspace/settings JSON carries its own cSpell word list - the block cspell.json canonicalizes. @@ -2197,85 +2309,8 @@ def audit_repo(entry, spec, branch=None): ) # --- README title and intro are the one canonical short description --- - # Per spec/readme-structure.md item 1 and GOVERNANCE.md "Repository Details", the H1 is the repo name. - # The tagline after it, the first line of the intro region, is a link-free plain sentence of at most 100 characters that carries verbatim to the GitHub About description, and on a docker repo to the Docker Hub short description. - # Any further paragraph is free prose no mirror reads, which is why only the first line is measured. - # The README is the source of truth. - if "README.md" in doc_texts: - title, intro = title_and_intro(doc_texts["README.md"]) - intro_line = tagline(intro) - # 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 the tagline (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 tagline 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 tagline carries Markdown links - keep it link-free plain text, it doubles as the repo About description (spec/readme-structure.md)", - ) - ) - want = strip_md_links(intro_line).strip() - if len(want) > 100: - findings.append( - ( - "LETTER", - f"readme: the tagline 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 tagline (description '{desc}' vs readme '{want}') - set it from the README, or sharpen the README first if the description carries real detail (GOVERNANCE.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: # noqa: BLE001 - 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 tagline ('{want}') - set it from the README (spec/readme-structure.md)", - ) - ) + # See description_findings() for the mirror set (README, GitHub About, Docker Hub) and the declared-field precedence. + findings += description_findings(doc_texts, entry, live, slug) # --- README section order, shield classes, and license-shield placement --- # The readme-structure dimension, driven by the declared model in spec/readme-sections.json rather than by prose. @@ -4120,6 +4155,110 @@ def _selftest(): else: print(" ok tagline: the first line of the intro region, further paragraphs excluded") + # description_findings(): the README/About/Docker Hub mirror set, with and without a declared registry field. + desc_readme = {"README.md": "# Fixture\n\nA short tagline.\n"} + desc_cases = [ + ( + "no declared field, About matches the README tagline", + desc_readme, + {}, + {"description": "A short tagline."}, + 0, + ), + ( + "no declared field, About mismatches the README tagline", + desc_readme, + {}, + {"description": "Something else."}, + 1, + ), + ( + "declared field matches both README and About", + desc_readme, + {"description": "A short tagline."}, + {"description": "A short tagline."}, + 0, + ), + ( + "declared field present, README tagline diverges from it", + desc_readme, + {"description": "The declared description."}, + {"description": "The declared description."}, + 1, + ), + ( + "declared field present, About diverges from it", + desc_readme, + {"description": "A short tagline."}, + {"description": "Something else."}, + 1, + ), + ( + "declared field present, no README at all, About diverges from it", + {}, + {"description": "The declared description."}, + {"description": "Something else."}, + 1, + ), + ( + "declared field present, no README at all, About already matches it", + {}, + {"description": "The declared description."}, + {"description": "The declared description."}, + 0, + ), + ( + "no declared field and no README, nothing to measure against", + {}, + {}, + {"description": "Anything."}, + 0, + ), + ] + for label, doc_texts_fx, entry_fx, live_fx, wantn in desc_cases: + got = description_findings(doc_texts_fx, entry_fx, live_fx, "owner/Fixture") + if len(got) != wantn: + ok = False + print( + f" {'ok ' if len(got) == wantn else 'FAIL'} want={wantn} got={len(got)} description: {label}" + ) + if len(got) != wantn: + for _, t in got: + print(f" {t}") + # The declared field, once present, is what the wording names as the source - not "the README". + declared_mismatch = description_findings( + desc_readme, + {"description": "The declared description."}, + {"description": "Something else."}, + "owner/Fixture", + ) + if not any( + "declared description" in t and "registry/repos.json" in t for _, t in declared_mismatch + ): + ok = False + print(f" FAIL description: declared-field wording -> {declared_mismatch}") + else: + print( + " ok description: a declared field names registry/repos.json as the source, not the README" + ) + # A docker-publishing repo's Docker Hub short description is checked against the same canonical value. + real_dhd = globals()["docker_hub_description"] + globals()["docker_hub_description"] = lambda slug: "A stale Docker Hub blurb." + try: + docker_mismatch = description_findings( + desc_readme, + {"description": "A short tagline.", "publish": [{"target": "docker"}]}, + {"description": "A short tagline."}, + "owner/Fixture", + ) + finally: + globals()["docker_hub_description"] = real_dhd + if not any("Docker Hub short description" in t for _, t in docker_mismatch): + ok = False + print(f" FAIL description: Docker Hub mismatch not reported -> {docker_mismatch}") + else: + print(" ok description: a docker repo's stale Docker Hub short description is reported") + # A ground-truth branch that does not resolve is one error, not a baseline's worth of letters. # Every `?ref=` read would 404 and report each carried file absent, describing the ref, not the repo. # The branch facts are already read at that point, so they are reported rather than dropped. diff --git a/spec/readme-structure.md b/spec/readme-structure.md index babfc7c8..fcaaa32e 100644 --- a/spec/readme-structure.md +++ b/spec/readme-structure.md @@ -6,7 +6,7 @@ The order, the requirement, and the shield sets are declared data in [`readme-se ## Sections and Order -1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then the **tagline** as the first line of the next paragraph. The tagline is a **single sentence, link-free plain text, at most 100 characters**, and it alone is the canonical short description: it carries to the GitHub About description (GOVERNANCE.md "Repository Details"), to the `HISTORY.md` opening, and, for a repo that publishes a Docker image, to the Docker Hub short description. Those surfaces render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. **Further paragraphs below the tagline are free prose**, carrying whatever a reader needs to understand the project before the fold, under no length or link rule and read by no mirror. The rule is scoped to the one line the mirrors take precisely so that a README is not forbidden from saying anything more about itself above the fold. The audit checks the H1 name, the tagline's length and link-free form, and the mirrors. +1. **Title (`# `)** - the H1 **is the repository name** (a hyphenated name may render its hyphens as spaces: `Financial-Modeling` -> `Financial Modeling`), then the **tagline** as the first line of the next paragraph. The tagline is a **single sentence, link-free plain text, at most 100 characters**, and it is one canonical short description shared with the GitHub About description (GOVERNANCE.md "Repository Details"), the `HISTORY.md` opening, and, for a repo that publishes a Docker image, the Docker Hub short description. Those surfaces render no Markdown, and Docker Hub caps the short description near 100 characters, the tightest surface, which sets the limit. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the canonical value and the tagline follows it. A repo that has not adopted the field keeps the tagline itself as the source. **Further paragraphs below the tagline are free prose**, carrying whatever a reader needs to understand the project before the fold, under no length or link rule and read by no mirror. The rule is scoped to the one line the mirrors take precisely so that a README is not forbidden from saying anything more about itself above the fold. The audit checks the H1 name, the tagline's length and 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, and name a channel for what it ships, so a repo publishing executables writes Binary Releases where one publishing source archives writes Versioned Releases. It carries three sub-sections: - **Build Status (`###`)** - the CI and build status shields. - **Releases (`###`)** - the version shields, one group per channel the project publishes, per "Shields" below. @@ -97,7 +97,7 @@ The rule lives in [`CODESTYLE.md`][codestyle] "Markdown and Spelling", which eve Docker Hub has two text fields: a **short description** (the tagline, capped near 100 characters) that mirrors the README tagline (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. -The short description reaches Docker Hub from the **GitHub About panel**, which the docker-readme task reads as `github.event.repository.description`, rather than from the README. That keeps a Markdown parser out of a publish job, and it means the README-to-About hop is the one a human or the audit closes, not CI. Where About has drifted from the README, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches. +The short description reaches Docker Hub from the **GitHub About panel**, which the docker-readme task reads as `github.event.repository.description`, rather than from the README. That keeps a Markdown parser out of a publish job. `repo-config/configure.sh apply` writes the About panel from `registry/repos.json`'s declared `description` once a repo has one, so that hop is a tool call rather than a hand edit for an adopting repo. A repo that has not declared the field still closes it by hand. Where About has drifted from the canonical value, the publish faithfully carries the drift onward, which is why the audit checks all three surfaces rather than only the two CI touches. diff --git a/spec/validate.py b/spec/validate.py index 446f144e..70648088 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -42,6 +42,10 @@ MARKDOWN_INLINE_LINK = re.compile(r"\]\((?P[^)\s]+)") MARKDOWN_REFERENCE_LINK = re.compile(r"^\[[^]]+\]:\s*(?P\S+)", re.MULTILINE) TEMPLATE_REPOSITORY_URL = "https://github.com/ptr727/ProjectTemplate" +# A description-shaped link use, `[text](url)` or `[text][ref]`, kept in sync with spec/audit.py's strip_md_links(). +# The carried-link regexes above find a definition's target inside a whole document, not a use inside one short string. +DESCRIPTION_LINK_INLINE = re.compile(r"\[([^\]]*)\]\((?:[^()]|\([^()]*\))*\)") +DESCRIPTION_LINK_REF = re.compile(r"\[([^\]]*)\]\[[^\]]*\]") def load(rel): @@ -52,6 +56,33 @@ def is_str_list(v): return isinstance(v, list) and all(isinstance(x, str) for x in v) +def description_errors(name, desc): + """Shape errors for a registry entry's optional `description` (GOVERNANCE.md "Repository Details"). + + Absence is not checked here, since the field is optional. `desc` is only passed in once a repo declares it. + + Link-free is enforced because spec/audit.py's description_findings() strips Markdown links from the README's + own tagline before comparing, but never re-strips the declared field it compares that tagline against. A + declared value carrying a link would therefore report as a permanent readme mismatch, and repo-config/ + configure.sh would push the literal Markdown source to GitHub's About panel, which does not render it. + + Already trimmed and single-line is enforced so the registry's own text already reads as exactly what every + mirror carries, rather than relying on the whitespace-stripping spec/audit.py and repo-config/configure.sh + each do defensively to keep agreeing with each other. + """ + if not isinstance(desc, str) or not desc.strip(): + return [f"{name}: description must be a non-empty string"] + if desc != desc.strip() or "\n" in desc or "\r" in desc: + return [ + f"{name}: description must be plain single-line text with no leading or trailing whitespace" + ] + if DESCRIPTION_LINK_INLINE.search(desc) or DESCRIPTION_LINK_REF.search(desc): + return [f"{name}: description carries Markdown links - keep it link-free plain text"] + if len(desc) > 100: + return [f"{name}: description is {len(desc)} characters, over the 100-char limit"] + return [] + + def markdown_targets(text): """Yield link targets outside fenced blocks.""" visible = [] @@ -471,6 +502,10 @@ def check_secret_set(label, entry, need_kind): effective_model = model or default_model or "release" if effective_model == "operational" and eol is None: errors.append(f"{name}: operational repo must declare lineEndings (lf or crlf)") + # Optional per GOVERNANCE.md "Repository Details": a repo that has not adopted the field yet is unaffected, since spec/audit.py's description_findings() falls back to the README tagline for it. + desc = repo.get("description") + if desc is not None: + errors.extend(description_errors(name, desc)) status = repo.get("status") if status is None: