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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}`.
Expand All @@ -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.

Expand Down
87 changes: 87 additions & 0 deletions scripts/tests/test_spec_validate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Exercise carried-link portability checks against a crafted manifest."""

Comment thread
Copilot marked this conversation as resolved.
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()
69 changes: 69 additions & 0 deletions spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import fnmatch
import json
import pathlib
import posixpath
import re
import sys

Expand All @@ -32,6 +33,9 @@
"verbatimJobs",
}

MARKDOWN_INLINE_LINK = re.compile(r"\]\((?P<target>[^)\s]+)")
MARKDOWN_REFERENCE_LINK = re.compile(r"^\[[^]]+\]:\s*(?P<target>\S+)", re.MULTILINE)


def load(rel):
return json.loads((ROOT / rel).read_text(encoding="utf-8"))
Expand All @@ -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")
Expand Down Expand Up @@ -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():
Expand Down