diff --git a/.gitattributes b/.gitattributes index c77f36cc..1bb10659 100644 --- a/.gitattributes +++ b/.gitattributes @@ -21,6 +21,7 @@ catalog/snippets/husky/pre-commit text eol=lf # Those are the CI validation entry point, the fleet-audit runner, the agent-safety hook and its installer with the installer's tests, and the action-owned gate implementations plus their local entry points and tests. # Do not re-add a blanket `*.py text eol=lf`. spec/validate.py text eol=lf +scripts/tests/test_spec_validate.py text eol=lf spec/audit.py text eol=lf spec/fidelity_honesty.py text eol=lf spec/workflow_reuse.py text eol=lf diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 10997a25..ac09efd7 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -200,7 +200,7 @@ This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this sectio - **Filename**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. - **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [the canonical `publish-release.yml`](https://github.com/ptr727/ProjectTemplate/blob/main/.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. @@ -217,7 +217,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither one has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither one has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [the canonical reusable-workflow model](https://github.com/ptr727/ProjectTemplate/blob/main/docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. diff --git a/scripts/tests/test_spec_validate.py b/scripts/tests/test_spec_validate.py new file mode 100755 index 00000000..bcee1a83 --- /dev/null +++ b/scripts/tests/test_spec_validate.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Exercise carried-link portability checks against a crafted manifest.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "spec")) +import validate + + +class CarriedRelativeLinkCase(unittest.TestCase): + """A hub-valid relative link must also resolve after its section is carried.""" + + def setUp(self) -> None: + self.root = Path(self.enterContext(tempfile.TemporaryDirectory())) + self.baseline = [ + { + "path": "GOVERNANCE.md", + "appliesTo": "*", + "sections": [{"name": "Rule", "fidelity": "verbatim"}], + }, + {"path": "WORKFLOW.md", "appliesTo": "*"}, + ] + + def write_governance(self, target: str) -> None: + (self.root / "GOVERNANCE.md").write_text( + f"# Governance\n\n## Rule\n\nRead [the contract]({target}).\n", + encoding="utf-8", + ) + + def test_rejects_a_hub_only_relative_target(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "hub-only.md").write_text("# Hub only\n", encoding="utf-8") + self.write_governance("./docs/hub-only.md") + + self.assertEqual( + validate.carried_relative_link_errors(self.root, self.baseline), + [ + ( + "files.json: GOVERNANCE.md section 'Rule' links to relative target " + "'./docs/hub-only.md', which is not universally carried" + ) + ], + ) + + def test_accepts_a_universally_carried_relative_target(self) -> None: + self.write_governance("./WORKFLOW.md#contract") + + self.assertEqual(validate.carried_relative_link_errors(self.root, self.baseline), []) + + def test_accepts_an_absolute_hub_tool_target(self) -> None: + self.write_governance("https://github.com/example/hub/blob/main/docs/reusable-workflows.md") + + self.assertEqual(validate.carried_relative_link_errors(self.root, self.baseline), []) + + def test_ignores_markdown_syntax_inside_inline_code(self) -> None: + (self.root / "GOVERNANCE.md").write_text( + "# Governance\n\n## Rule\n\nStrip the `[text](url)` syntax.\n", + encoding="utf-8", + ) + + self.assertEqual(validate.carried_relative_link_errors(self.root, self.baseline), []) + + def test_ignores_relative_links_inside_fenced_code(self) -> None: + (self.root / "GOVERNANCE.md").write_text( + "# Governance\n\n## Rule\n\n~~~markdown\n[hub only](./docs/hub-only.md)\n~~~\n", + encoding="utf-8", + ) + + self.assertEqual(validate.carried_relative_link_errors(self.root, self.baseline), []) + + def test_accepts_a_universally_carried_reference_target(self) -> None: + (self.root / "GOVERNANCE.md").write_text( + "# Governance\n\n## Rule\n\nRead [the contract][contract].\n\n" + "[contract]: ./WORKFLOW.md#contract\n", + encoding="utf-8", + ) + + self.assertEqual(validate.carried_relative_link_errors(self.root, self.baseline), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/spec/validate.py b/spec/validate.py index 3d151e6c..1462ebf9 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -10,6 +10,7 @@ import fnmatch import json import pathlib +import posixpath import re import sys @@ -32,6 +33,9 @@ "verbatimJobs", } +MARKDOWN_INLINE_LINK = re.compile(r"\]\((?P[^)\s]+)") +MARKDOWN_REFERENCE_LINK = re.compile(r"^\[[^]]+\]:\s*(?P\S+)", re.MULTILINE) + def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) @@ -41,6 +45,69 @@ def is_str_list(v): return isinstance(v, list) and all(isinstance(x, str) for x in v) +def markdown_targets(text): + """Yield link targets outside fenced blocks.""" + visible = [] + in_fence = False + for line in text.splitlines(): + if re.match(r"^\s*(```|~~~)", line): + in_fence = not in_fence + continue + if not in_fence: + visible.append(line) + body = re.sub(r"`+[^`]*`+", "", "\n".join(visible)) + for pattern in (MARKDOWN_INLINE_LINK, MARKDOWN_REFERENCE_LINK): + yield from (match.group("target") for match in pattern.finditer(body)) + + +def markdown_section(text, name): + """Return one level-two Markdown section, excluding its heading.""" + match = re.search(rf"^## {re.escape(name)}\s*$", text, re.MULTILINE) + if not match: + return "" + following = re.search(r"^## ", text[match.end() :], re.MULTILINE) + end = len(text) if following is None else match.end() + following.start() + return text[match.end() : end] + + +def carried_relative_link_errors(root, baseline): + """Reject verbatim carried links whose relative target is not carried everywhere.""" + universal = { + item["path"] + for item in baseline + if isinstance(item, dict) + and isinstance(item.get("path"), str) + and item.get("appliesTo", "*") == "*" + } + errors = [] + for item in baseline: + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + continue + source = item["path"] + path = root / source + if not source.endswith(".md") or not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + sections = item.get("sections", []) + for section in sections if isinstance(sections, list) else []: + if not isinstance(section, dict) or section.get("fidelity") != "verbatim": + continue + name = section.get("name") + if not isinstance(name, str): + continue + for target in markdown_targets(markdown_section(text, name)): + target = target.split("#", 1)[0] + if not target or "://" in target or target.startswith(("mailto:", "#")): + continue + resolved = posixpath.normpath(posixpath.join(posixpath.dirname(source), target)) + if resolved not in universal: + errors.append( + f"files.json: {source} section '{name}' links to relative target " + f"'{target}', which is not universally carried" + ) + return errors + + def main(): errors = [] repos = load("registry/repos.json") @@ -678,6 +745,8 @@ def check_selector(where, applies_to): f"files.json: {path} declares section '{name}' but no '## {name}' heading exists in {path}" ) + errors.extend(carried_relative_link_errors(ROOT, baseline)) + # Validate the divergence ledger in spec/divergences.json when present, so a mistyped repo name or disposition fails CI rather than silently dropping a burn-down row. dispositions = ("re-vendor", "track", "accepted", "upstream-candidate", "investigate", "retire") if (ROOT / "spec/divergences.json").exists():