diff --git a/GOVERNANCE.md b/GOVERNANCE.md index f20ae6c8..e82e3e02 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -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//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 ` / `, 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. diff --git a/spec/audit.py b/spec/audit.py index 318ea205..240c2380 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -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"(? 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")."""