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
2 changes: 1 addition & 1 deletion GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLO

An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it.

- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing comment naming the release tag at that SHA, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. The comment spells the tag exactly as the referenced repository publishes it, `# vX.Y.Z` where its tags carry a `v` and the bare tag, such as `# 1.4.2`, where they do not, so the comment never names a tag that does not exist. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike, except a local (`./`) reference or a self-repository (`$/`) one, which names no ref to pin. In a workflow file, `$/` is GitHub's syntax for a path in the repository holding that file, resolved at that file's own commit. Use a major-only comment, `# vX` or the bare `# X` by the same spelling rule, only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment).
- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing comment naming the release tag at that SHA, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. The comment spells the tag exactly as the referenced repository publishes it, `# vX.Y.Z` where its tags carry a `v` and the bare tag, `# X.Y.Z`, where they do not, so the comment never names a tag that does not exist. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike, except a local (`./`) reference or a self-repository (`$/`) one, which names no ref to pin. In a workflow file, `$/` is GitHub's syntax for a path in the repository holding that file, resolved at that file's own commit. Use a major-only comment, `# vX` or the bare `# X` by the same spelling rule, only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment).
- **Filename**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix, ending instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions/<name>/action.yml`), so these suffix rules do not reach them.
- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`), matching the filename rule above and covering a file that is also dispatchable, and every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee: a called reusable workflow's jobs appear nested inside the caller's run as `<caller job> / <callee job>`, and the runs list shows the caller's workflow name rather than the callee's own.
- **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 trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads, its `spec/files.json` `requiredCheckName`, and each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`, which exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind.
Expand Down
14 changes: 2 additions & 12 deletions spec/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,18 +902,8 @@ def extract_section(text, heading):
# The undeclared-H2 scan reads the same set.
UNDECLARED_HEADING_SCANNED = TEMPLATE_REF_SCANNED

# The version-literal scan reads the four instruction documents a repo owns prose in.
# .github/copilot-instructions.md is left out, since its disproved-claims records name the revision a proof was read against by design.
VERSION_LITERAL_SCANNED = ("AGENTS.md", "GOVERNANCE.md", "CODESTYLE.md", "WORKFLOW.md")

# A three-part version, a full commit SHA, or an abbreviated one standing alone as a token.
# The lookarounds keep a dotted quad, such as an address, from matching as a version.
# An abbreviated SHA must mix a digit and a letter, so an all-letter word such as "facade" is not one.
VERSION_LITERAL = re.compile(
r"(?<![\d.])\d+\.\d+\.\d+(?!\.?\d)"
r"|\b[0-9a-fA-F]{40}\b"
r"|(?<![0-9A-Za-z#_])(?=[0-9a-fA-F]{7,12}(?![0-9A-Za-z_-]))(?=[a-fA-F]*[0-9])(?=[0-9]*[a-fA-F])[0-9a-fA-F]{7,12}(?![0-9A-Za-z_-])"
)
VERSION_LITERAL_SCANNED = validate.VERSION_LITERAL_SCANNED
VERSION_LITERAL = validate.VERSION_LITERAL


def strip_sections(text, names, keep_pins=False):
Expand Down
36 changes: 36 additions & 0 deletions spec/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@
GITHUB_URL_RE = re.compile(
r"^https://github\.com/([A-Za-z0-9-]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?(?![\s\S])"
)
# The version-literal scan reads the four instruction documents a repo owns prose in.
# .github/copilot-instructions.md is left out, since its disproved-claims records name the revision a proof was read against by design.
VERSION_LITERAL_SCANNED = ("AGENTS.md", "GOVERNANCE.md", "CODESTYLE.md", "WORKFLOW.md")

# A three-part version, a full commit SHA, or an abbreviated one standing alone as a token.
# The lookarounds keep a dotted quad, such as an address, from matching as a version.
# An abbreviated SHA must mix a digit and a letter, so an all-letter word such as "facade" is not one.
VERSION_LITERAL = re.compile(
r"(?<![\d.])\d+\.\d+\.\d+(?!\.?\d)"
r"|\b[0-9a-fA-F]{40}\b"
r"|(?<![0-9A-Za-z#_])(?=[0-9a-fA-F]{7,12}(?![0-9A-Za-z_-]))(?=[a-fA-F]*[0-9])(?=[0-9]*[a-fA-F])[0-9a-fA-F]{7,12}(?![0-9A-Za-z_-])"
)
# How faithfully a carried unit is checked, per spec/fidelity-model.md, defaulting to presence.
FIDELITIES = ("presence", "intent", "verbatim", "interface")
# The keys an interface unit's `contract` may carry (kept in sync with files.schema.json).
Expand Down Expand Up @@ -531,6 +543,29 @@ def carried_link_errors(root, baseline):
return errors


def version_literal_errors(root):
"""Reject a three-part version or commit SHA anywhere in the hub's own instruction documents.

Verbatim sections are scanned too, since spec/audit.py excises them on every downstream run and
every carrier inherits their bytes, so a literal there has to be stopped before it merges here.
"""
errors = []
for source in VERSION_LITERAL_SCANNED:
path = root / source
if not path.is_file():
continue
literals = sorted(
set(VERSION_LITERAL.findall(path.read_text(encoding="utf-8", errors="replace")))
)
if literals:
errors.append(
f"{source}: names a three-part version or a commit SHA ({', '.join(literals)}); "
"write the example with a placeholder such as 1.0.N "
"(GOVERNANCE.md, Documentation Style Conventions)"
)
return errors


def main():
errors = []
repos = load("registry/repos.json")
Expand Down Expand Up @@ -1360,6 +1395,7 @@ def check_selector(where, applies_to):
)

errors.extend(carried_link_errors(ROOT, baseline))
errors.extend(version_literal_errors(ROOT))

# 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")
Expand Down
45 changes: 45 additions & 0 deletions tests/test_spec_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,51 @@ 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 VersionLiteralCase(unittest.TestCase):
"""The hub's instruction documents name no three-part version or commit SHA, verbatim sections included."""

def setUp(self) -> None:
self.root = Path(self.enterContext(tempfile.TemporaryDirectory()))

def test_rejects_a_bare_tag_example_inside_a_verbatim_section(self) -> None:
heading = "Workflow YAML Conventions"
governance = next(
item
for item in validate.load("spec/files.json")["baseline"]
if item["path"] == "GOVERNANCE.md"
)
self.assertIn(
{"name": heading, "fidelity": "verbatim"},
[
{"name": s.get("name"), "fidelity": s.get("fidelity")}
for s in governance["sections"]
],
)
(self.root / "GOVERNANCE.md").write_text(
f"# Governance\n\n## {heading}\n\nWrite the bare tag, such as `# 2.7.1`, as published.\n",
encoding="utf-8",
)

errors = validate.version_literal_errors(self.root)

self.assertEqual(1, len(errors))
self.assertIn("GOVERNANCE.md", errors[0])
self.assertIn("2.7.1", errors[0])

def test_accepts_a_placeholder_example(self) -> None:
(self.root / "GOVERNANCE.md").write_text(
"# Governance\n\n## Rule\n\nWrite `# vX.Y.Z` or the bare `# X.Y.Z`, or 1.0.N.\n",
encoding="utf-8",
)

self.assertEqual(validate.version_literal_errors(self.root), [])

def test_hub_instruction_documents_name_no_version(self) -> None:
root = Path(__file__).resolve().parents[1]

self.assertEqual(validate.version_literal_errors(root), [])


class DescriptionErrorsCase(unittest.TestCase):
"""registry/repos.json's optional `description` (GOVERNANCE.md "Repository Details")."""

Expand Down
Loading