From 2fb8aec40b9410b81146cd35bacec9fd77b165ee Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:04:41 -0500 Subject: [PATCH 1/6] feat(github-actions-conventions): lint job, step and action display names naming-lint gains three rules and tightens one: - job-name: every job has a Title Case display name, separate from its id; expressions keep their place ("Drift (${{ matrix.skill }})"). The ci-status job is named exactly "CI Status", and the report-ci-status alias job keeps the required context "ci-status" until the github-iac ci-gate ruleset requires "CI Status" (jobNameExceptions). - step-name: step names are sentence case, as GitHub's own steps are; a later capitalized word must be in the new properNouns list, an acronym or a mixed-case name. - action-name: an action's name is its directory's Title Case form. - workflow-name: accepts the Title Case form only (or a recorded exception); the bare stem no longer passes. job-name and step-name report at the vocabulary's displayNameLevel, warning for now: ci-workflows runs the analyzer from standards main, so error would block it before its jobs are named. The README replaces "never rename ci-status" with the three-step rename the alias supports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- .github/workflows/pr-require-checks.yml | 2 +- .../github-actions-conventions/README.md | 61 ++++-- .../naming-lint.mjs | 191 ++++++++++++++++-- .../naming-lint.test.mjs | 162 +++++++++++++-- .../vocabulary.json | 32 +++ .../vocabulary.schema.json | 27 +++ 6 files changed, 422 insertions(+), 53 deletions(-) diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index de73d0f2..485517d1 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -271,7 +271,7 @@ jobs: # The pull-request half of the Code Review Rules check: this repository's # own AGENTS.md. Other repositories are checked by the scheduled # maintenance-audit-code-review-rules workflow. - - name: Code Review Rules contract test + - name: Test the `Code Review Rules` contract if: ${{ !cancelled() }} run: bash harness/shell/run-tests.sh components/code-review-rules/code-review-rules.test.sh - name: This repository's AGENTS.md carries the section diff --git a/components/github-actions-conventions/README.md b/components/github-actions-conventions/README.md index a54217c2..cd6811c6 100644 --- a/components/github-actions-conventions/README.md +++ b/components/github-actions-conventions/README.md @@ -1,14 +1,14 @@ # GitHub Actions naming and structure conventions The one source for how Melodic Software names GitHub Actions workflow files, -workflow `name:` values, job ids, composite action directories, activities and -GitHub Apps. `naming-lint.mjs` checks a repository against it. Rules that +workflow `name:` values, job ids, job and step display names, composite action +directories and names, activities and GitHub Apps. `naming-lint.mjs` checks a repository against it. Rules that another component already owns are linked under [Rules owned elsewhere](#rules-owned-elsewhere), not restated here. | File | Role | | --- | --- | -| [`vocabulary.json`](vocabulary.json) | Stages, function words per stage, verbs, tool and engine names, the activity grammar, exemptions and Apps | +| [`vocabulary.json`](vocabulary.json) | Stages, function words per stage, verbs, tool and engine names, the activity grammar, display words and proper nouns, exemptions and Apps | | [`vocabulary.schema.json`](vocabulary.schema.json) | Draft 2020-12 schema for the vocabulary | | [`rename-map.json`](rename-map.json) | Every workflow and composite action rename across the active repositories, with job id renames and couplings | | [`rename-map.schema.json`](rename-map.schema.json) | Draft 2020-12 schema for the rename map | @@ -72,9 +72,27 @@ workflow `name:` and file name never appear in it. So: - An activity may carry one mode after `#`, from the pairs in `activityGrammar.modes` (`simplify#diff`, `fix-docs#report`). -`ci-status` is the only required check in every repository and keeps that name -everywhere. The gate workflow, `pr-require-checks.yml`, aggregates every lane -into it. Lanes are added or renamed behind it without touching a ruleset. +Every job, caller jobs included, has a display `name:` in Title Case, separate +from its id: every word starts with a capital letter or a digit, or is a +`displayWords` form (`markdownlint`). An expression keeps its place, so a matrix +job reads `Drift (${{ matrix.skill }})`. The check then shows that name, not the +id: `PR Review / Claude` where the jobs are `pr-review` and `claude`. + +Step names are sentence case, as GitHub's own steps are (`Set up job`): the +first word is capitalized, and a later word is capitalized only when it is a +proper noun in `properNouns`, an acronym (`JSON`) or a mixed-case name +(`GitHub`). The first word may be a lowercase tool name (`shellcheck`) or an +identifier (`linux-x64`). Text in backticks and `${{ }}` expressions is not +checked. + +The CI Status job (id `ci-status`, named `CI Status`) is the only required +check in every repository. The gate workflow, `pr-require-checks.yml`, +aggregates every lane into it, so lanes are added or renamed behind it without +touching a ruleset. While the org ci-gate ruleset still requires the context +`ci-status`, the gate also carries the `report-ci-status` job, named `ci-status` +(`jobNameExceptions` in `vocabulary.json`): it needs the CI Status job, runs +`if: always()`, and ends with that job's result. Once the ruleset requires +`CI Status`, each repository removes it. ## Composite actions @@ -87,7 +105,7 @@ into it. Lanes are added or renamed behind it without touching a ruleset. `psscriptanalyzer`). - An action's `name:` is the name of the directory holding its `action.yml`, in Title Case by the workflow rule above (`check-managed-files` is named - `Check Managed Files`). naming-lint checks the directory, not this `name:`. + `Check Managed Files`). ## Settings a called workflow does not inherit @@ -113,8 +131,11 @@ renaming, find each of these and change it in the same rollout: 1. **Required checks.** Renaming a required job (its caller id or the called job's `name:`) blocks every merge while all checks show green: GitHub reports no pending check, and the only API signal is - `mergeStateStatus: BLOCKED`. Keeping `ci-status` the single required check - makes file and lane renames safe; never rename `ci-status`. + `mergeStateStatus: BLOCKED`. Keeping CI Status the single required check + makes file and lane renames safe. Renaming the required job itself takes + three steps: every governed repository reports both the old and the new + name (the alias job above), then the ci-gate ruleset in github-iac switches + to the new name, then the alias goes. 2. **Runner policy.** The runner-policy contract is keyed by the reusable workflow's bare path and fails closed on an unknown path. A renamed reusable needs a contract under its new path before any consumer calls it. @@ -199,16 +220,21 @@ directory under `.github/actions/` that holds an `action.yml` or | Rule | Level | Fails when | | --- | --- | --- | | `workflow-filename` | error | the file stem is not `-[-]` with a known stage and function word | -| `workflow-name` | error | `name:` is missing, or is neither the stem's Title Case form nor the stem (see [Rollout](#rollout)), or differs from a recorded exception | +| `workflow-name` | error | `name:` is missing or is not the stem's Title Case form (or the recorded exception) | | `workflow-unparsable` | error | the file is not a YAML mapping | | `gate-ci-status` | error | `pr-require-checks` has no `ci-status` job | | `job-id-kebab` | error | a job id is not kebab-case | | `job-id-stage-word` | warning | a job id starts with a stage word but is not a known slot name | | `job-id-verb-first` | warning | a job id is not verb-first and is not a listed tool, engine or reserved id; whether a word is a verb cannot always be decided, so this never blocks | +| `job-name` | `displayNameLevel` | a job has no `name:`, its name is not Title Case, the `ci-status` job is not named `CI Status`, or a `jobNameExceptions` job differs from its recorded name | +| `step-name` | `displayNameLevel` | a step name in a workflow or composite action is not sentence case | | `action-directory` | error | an action directory is not kebab-case, not verb-first, an engine or a listed tool, or not a `/` pair under a slot name | +| `action-name` | error | an action's `name:` is not its directory's Title Case form | +| `action-unparsable` | error | an `action.yml` is not a YAML mapping | -`checkActivityName` and `parseSlotName` are exported for tools that validate -activity and slot names outside workflow files. +`checkActivityName`, `checkJobName`, `checkStepName`, `parseSlotName` and +`titleCaseName` are exported for tools that validate these names outside +workflow files. ### Rollout @@ -232,9 +258,14 @@ closed the advisory period. renamed to `release-publish-plugins.yml` before the switch. - **Rollback:** set the composite's `mode: advisory`, which reports every finding as a warning and passes, or remove the lane. -- **Title Case names:** `workflow-name` accepts the bare stem as well as its - Title Case form until every org repository is converted; a later change then - accepts the Title Case form only. +- **Display names:** `job-name` and `step-name` report at + `displayNameLevel`, `warning` while the org's repositories name their jobs + and adopt the lint; ci-workflows runs the analyzer from standards' `main`, so + an `error` level would block it before its jobs are named. Each repository + names its jobs in the same change that updates every script and filter + reading the old check names. Once standards, ci-workflows and + claude-code-plugins pass with no warning of either rule, a change sets + `displayNameLevel` to `error`. ### Ownership and operation diff --git a/components/github-actions-conventions/naming-lint.mjs b/components/github-actions-conventions/naming-lint.mjs index a1869b7a..d0882850 100755 --- a/components/github-actions-conventions/naming-lint.mjs +++ b/components/github-actions-conventions/naming-lint.mjs @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import Ajv2020 from "ajv/dist/2020.js"; -import { isMap, isScalar, LineCounter, parseDocument } from "yaml"; +import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml"; export class ConfigurationError extends Error { constructor(message) { @@ -171,6 +171,82 @@ export function titleCaseName(stem, displayWords) { .join(" "); } +// The words of a display name, with `${{ }}` expressions and backtick spans +// removed and surrounding punctuation and a possessive `'s` trimmed. +function displayTokens(name) { + return name + .replace(/\$\{\{.*?\}\}/gu, " ") + .replace(/`[^`]*`/gu, " ") + .split(/\s+/u) + .map((token) => token.replace(/^[^\p{L}\p{N}.]+|[^\p{L}\p{N}]+$/gu, "").replace(/'s$/u, "")) + .filter((token) => token !== ""); +} + +function knownNames(vocabulary) { + return new Set([ + ...Object.values(vocabulary.displayWords), + ...vocabulary.properNouns.flatMap((noun) => noun.split(" ")), + ...vocabulary.toolNames, + ...vocabulary.engines, + ]); +} + +// A job's display name is Title Case: every word starts with a capital letter +// or a digit, or is a `displayWords` form (`markdownlint`, `yq`). +export function checkJobName(id, name, vocabulary) { + const exception = vocabulary.jobNameExceptions.find((entry) => entry.jobId === id); + if (exception !== undefined) { + return name === exception.name + ? undefined + : `job \`${id}\` is a recorded exception and must be named exactly \`${exception.name}\``; + } + const suggestion = titleCaseName(id, vocabulary.displayWords); + if (name === undefined) { + return `job \`${id}\` has no \`name:\`; give it a Title Case display name such as \`${suggestion}\``; + } + // The required check's context is this job's name, so it is fixed. + if (vocabulary.reservedJobIds.includes(id) && name !== suggestion) { + return `job \`${id}\` is the required check and must be named exactly \`${suggestion}\``; + } + const displayForms = new Set(Object.values(vocabulary.displayWords)); + const lower = displayTokens(name).filter( + (token) => /^\p{Ll}/u.test(token) && !displayForms.has(token), + ); + if (lower.length > 0) { + return `job \`${id}\` name \`${name}\` is not Title Case (${lower.map((token) => `\`${token}\``).join(", ")}); for example \`${suggestion}\``; + } + return undefined; +} + +// A step name is sentence case, as GitHub's own steps are (`Set up job`): the +// first word is capitalized unless it is a known lowercase name or an +// identifier, and a later capitalized word is a proper noun from the +// vocabulary, an acronym, or a mixed-case identifier. +export function checkStepName(name, vocabulary) { + let text = name; + for (const noun of vocabulary.properNouns.filter((entry) => entry.includes(" "))) { + text = text.replaceAll(noun, " "); + } + const known = knownNames(vocabulary); + const [first, ...rest] = displayTokens(text); + if ( + first !== undefined && + displayTokens(name)[0] === first && + /^\p{Ll}+$/u.test(first) && + !known.has(first) + ) { + return `step name \`${name}\` must start with a capital letter (sentence case)`; + } + const capitalized = rest.filter((token) => /^\p{Lu}\p{Ll}+$/u.test(token) && !known.has(token)); + if (capitalized.length > 0) { + return ( + `step name \`${name}\` is not sentence case: ${capitalized.map((token) => `\`${token}\``).join(", ")} ` + + "is capitalized; lower-case it, or add the proper noun to `properNouns`" + ); + } + return undefined; +} + function isSlotName(name, vocabulary) { return KEBAB.test(name) && parseSlotName(name, vocabulary).function !== undefined; } @@ -286,6 +362,32 @@ function checkJobId(file, id, line, vocabulary) { ); } +function stepFindings(file, steps, lineCounter, vocabulary) { + if (!isSeq(steps)) { + return []; + } + const findings = []; + for (const step of steps.items) { + const nameNode = isMap(step) ? step.get("name", true) : undefined; + if (!isScalar(nameNode)) { + continue; + } + const problem = checkStepName(String(nameNode.value), vocabulary); + if (problem !== undefined) { + findings.push( + finding( + vocabulary.displayNameLevel, + "step-name", + file, + lineOf(lineCounter, nameNode), + problem, + ), + ); + } + } + return findings; +} + async function workflowFindings(root, repository, vocabulary) { const directory = path.join(root, ".github", "workflows"); let entries; @@ -337,13 +439,12 @@ async function workflowFindings(root, repository, vocabulary) { const name = isScalar(nameNode) ? String(nameNode.value) : undefined; const nameExemption = waived("workflow-name"); const titleCase = titleCaseName(stem, vocabulary.displayWords); - // The Title Case form is the convention. The bare stem also passes until - // every org repository is converted; a later stage then drops it from this - // list. A recorded exception is exact: it takes neither form. - const accepted = nameExemption ? [nameExemption.expectedName] : [titleCase, stem]; - if (!accepted.includes(name)) { + // A recorded exception is exact: it takes neither the stem nor its Title + // Case form. + const expected = nameExemption ? nameExemption.expectedName : titleCase; + if (name !== expected) { const target = nameExemption - ? `the recorded exception \`${accepted[0]}\`` + ? `the recorded exception \`${expected}\`` : `the Title Case form of the file stem, \`${titleCase}\``; findings.push( finding( @@ -368,6 +469,25 @@ async function workflowFindings(root, repository, vocabulary) { if (item !== undefined) { findings.push(item); } + const job = isMap(pair.value) ? pair.value : undefined; + const jobNameNode = job?.get("name", true); + const jobNameProblem = checkJobName( + id, + isScalar(jobNameNode) ? String(jobNameNode.value) : undefined, + vocabulary, + ); + if (jobNameProblem !== undefined) { + findings.push( + finding( + vocabulary.displayNameLevel, + "job-name", + file, + lineOf(lineCounter, jobNameNode ?? pair.key), + jobNameProblem, + ), + ); + } + findings.push(...stepFindings(file, job?.get("steps", true), lineCounter, vocabulary)); } } if (stem === GATE_STEM && !jobIds.some((id) => vocabulary.reservedJobIds.includes(id))) { @@ -443,6 +563,51 @@ function checkActionDirectory(segments, vocabulary) { return `action directory \`${display}\` is nested too deep; use \`.github/actions///\` or a flat directory`; } +// An action's `name:` is its directory's Title Case form, and its composite +// steps follow the step-name rule. +async function actionMetadataFindings(root, file, directory, vocabulary) { + const lineCounter = new LineCounter(); + const document = parseDocument(await readFile(path.join(root, file), "utf8"), { lineCounter }); + if (document.errors.length > 0 || !isMap(document.contents)) { + return [ + finding( + "error", + "action-unparsable", + file, + 1, + `action metadata could not be parsed: ${document.errors[0]?.message ?? "top level is not a mapping"}`, + ), + ]; + } + const findings = []; + const nameNode = document.contents.get("name", true); + const name = isScalar(nameNode) ? String(nameNode.value) : undefined; + const expected = titleCaseName(directory, vocabulary.displayWords); + if (name !== expected) { + findings.push( + finding( + "error", + "action-name", + file, + lineOf(lineCounter, nameNode), + name === undefined + ? `action has no \`name:\`; set it to the Title Case form of its directory, \`${expected}\`` + : `action \`name: ${name}\` must equal the Title Case form of its directory, \`${expected}\``, + ), + ); + } + const runs = document.contents.get("runs", true); + findings.push( + ...stepFindings( + file, + isMap(runs) ? runs.get("steps", true) : undefined, + lineCounter, + vocabulary, + ), + ); + return findings; +} + export async function lintRepository({ root = process.cwd(), repository = "", @@ -456,18 +621,12 @@ export async function lintRepository({ const resolvedRoot = path.resolve(root); const findings = await workflowFindings(resolvedRoot, repository, words); for (const { segments, metadata } of await actionDirectories(resolvedRoot)) { + const file = `.github/actions/${segments.join("/")}/${metadata}`; const problem = checkActionDirectory(segments, words); if (problem !== undefined) { - findings.push( - finding( - "error", - "action-directory", - `.github/actions/${segments.join("/")}/${metadata}`, - 1, - problem, - ), - ); + findings.push(finding("error", "action-directory", file, 1, problem)); } + findings.push(...(await actionMetadataFindings(resolvedRoot, file, segments.at(-1), words))); } if (mode === "advisory") { for (const item of findings) { diff --git a/components/github-actions-conventions/naming-lint.test.mjs b/components/github-actions-conventions/naming-lint.test.mjs index a7b70757..31d6d89f 100644 --- a/components/github-actions-conventions/naming-lint.test.mjs +++ b/components/github-actions-conventions/naming-lint.test.mjs @@ -10,6 +10,8 @@ import Ajv2020 from "ajv/dist/2020.js"; import { ConfigurationError, checkActivityName, + checkJobName, + checkStepName, formatAnnotation, lintRepository, loadVocabulary, @@ -29,24 +31,36 @@ test.after(async () => { await Promise.all(temporaryRoots.map((root) => rm(root, { recursive: true, force: true }))); }); +// The workflow name is given as a stem and written in its Title Case form; +// `{ raw: "..." }` writes it as given. Each job is named for its id. function workflow(name, jobIds) { - const header = name === undefined ? "" : `name: ${name}\n`; + const written = + typeof name === "string" ? titleCaseName(name, vocabulary.displayWords) : name?.raw; + const header = written === undefined ? "" : `name: ${written}\n`; const jobs = jobIds - .map((id) => ` ${id}:\n runs-on: ubuntu-24.04\n steps:\n - run: "true"\n`) + .map( + (id) => + ` ${id}:\n name: ${titleCaseName(id, vocabulary.displayWords)}\n` + + ` runs-on: ubuntu-24.04\n steps:\n - name: Run it\n run: "true"\n`, + ) .join(""); return `${header}on: pull_request\npermissions: {}\njobs:\n${jobs}`; } -const ACTION = `name: fixture +const action = ( + directory, +) => `name: ${titleCaseName(directory.split("/").at(-1), vocabulary.displayWords)} description: Fixture action. runs: using: composite steps: - - run: "true" + - name: Run it + run: "true" shell: bash `; -// workflows: { "file.yml": source }, actions: ["dir", "lane/unit"] +// workflows: { "file.yml": source }, actions: ["dir", "lane/unit"] or +// { "dir": source } async function repository({ workflows = {}, actions = [] }) { const root = await mkdtemp(path.join(tmpdir(), "naming-lint-")); temporaryRoots.push(root); @@ -54,9 +68,12 @@ async function repository({ workflows = {}, actions = [] }) { for (const [file, source] of Object.entries(workflows)) { await writeFile(path.join(root, ".github", "workflows", file), source); } - for (const directory of actions) { + const sources = Array.isArray(actions) + ? Object.fromEntries(actions.map((directory) => [directory, action(directory)])) + : actions; + for (const [directory, source] of Object.entries(sources)) { await mkdir(path.join(root, ".github", "actions", directory), { recursive: true }); - await writeFile(path.join(root, ".github", "actions", directory, "action.yml"), ACTION); + await writeFile(path.join(root, ".github", "actions", directory, "action.yml"), source); } return root; } @@ -94,7 +111,7 @@ test("each convention breach is reported under its own rule", async () => { workflows: { "ci.yml": workflow("ci", ["ci-status"]), "pr-gatekeep.yml": workflow("pr-gatekeep", ["check"]), - "pr-review.yml": workflow("Claude Review", [ + "pr-review.yml": workflow({ raw: "Claude Review" }, [ "Build_Test", "pr-section-drift", "security-review", @@ -129,7 +146,7 @@ test("each convention breach is reported under its own rule", async () => { "warning job-id-verb-first .github/workflows/pr-review.yml", ]); const kebab = findings.find((item) => item.rule === "job-id-kebab"); - // workflow("Claude Review", ...) puts the first job key on line 5. + // workflow({ raw: "Claude Review" }, ...) puts the first job key on line 5. assert.equal(kebab.line, 5); }); @@ -170,7 +187,7 @@ test("annotations escape the characters the workflow command syntax reserves", ( test("the OIDC negative-test name exception applies only in github-iac", async () => { const root = await repository({ workflows: { - "release-deploy-oidc-negative-test.yml": workflow("release-deployx", ["test"]), + "release-deploy-oidc-negative-test.yml": workflow({ raw: "release-deployx" }, ["test"]), }, }); assert.deepEqual( @@ -200,7 +217,9 @@ test("the recorded exception is exact: neither the stem nor a Title Case form pa "Release Deployx", ]) { const root = await repository({ - workflows: { "release-deploy-oidc-negative-test.yml": workflow(name, ["test"]) }, + workflows: { + "release-deploy-oidc-negative-test.yml": workflow({ raw: name }, ["test"]), + }, }); const findings = await lintRepository({ root, @@ -230,16 +249,21 @@ test("a stem's Title Case form capitalizes each word and upper-cases the acronym } }); -test("a workflow name is its stem or the stem's Title Case form, and nothing else", async () => { - for (const name of ["pr-review-security", "PR Review Security"]) { - const root = await repository({ - workflows: { "pr-review-security.yml": workflow(name, ["pr-review-security"]) }, - }); - assert.deepEqual(await lintRepository({ root, vocabulary, mode: "enforcing" }), [], name); - } - for (const name of ["Pr Review Security", "pr review security", "PR-Review-Security"]) { +test("a workflow name is the stem's Title Case form, and nothing else", async () => { + const passing = await repository({ + workflows: { + "pr-review-security.yml": workflow({ raw: "PR Review Security" }, ["pr-review-security"]), + }, + }); + assert.deepEqual(await lintRepository({ root: passing, vocabulary, mode: "enforcing" }), []); + for (const name of [ + "pr-review-security", + "Pr Review Security", + "pr review security", + "PR-Review-Security", + ]) { const root = await repository({ - workflows: { "pr-review-security.yml": workflow(name, ["pr-review-security"]) }, + workflows: { "pr-review-security.yml": workflow({ raw: name }, ["pr-review-security"]) }, }); const findings = await lintRepository({ root, vocabulary, mode: "enforcing" }); assert.deepEqual( @@ -251,6 +275,94 @@ test("a workflow name is its stem or the stem's Title Case form, and nothing els } }); +test("a job name is Title Case, and an expression keeps its place", () => { + for (const name of [ + "Check Links", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub expression, not a JS template + "Drift (${{ matrix.skill }})", + "PR Review / Claude", + "Lint Markdown With markdownlint", + "Test 2", + ]) { + assert.equal(checkJobName("check-links", name, vocabulary), undefined, name); + } + for (const [name, pattern] of [ + [undefined, /has no `name:`; .* `Check Links`/u], + ["check-links", /not Title Case \(`check-links`\)/u], + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub expression, not a JS template + ["drift (${{ matrix.skill }})", /not Title Case \(`drift`\)/u], + ["Build and Test", /not Title Case \(`and`\)/u], + ]) { + assert.match(checkJobName("check-links", name, vocabulary) ?? "", pattern, String(name)); + } +}); + +test("the required job is named CI Status, and the alias keeps the old context", () => { + assert.equal(checkJobName("ci-status", "CI Status", vocabulary), undefined); + assert.match(checkJobName("ci-status", "Ci Status", vocabulary), /exactly `CI Status`/u); + assert.match(checkJobName("ci-status", "Required Checks", vocabulary), /exactly `CI Status`/u); + assert.equal(checkJobName("report-ci-status", "ci-status", vocabulary), undefined); + assert.match( + checkJobName("report-ci-status", "Report CI Status", vocabulary), + /recorded exception .* exactly `ci-status`/u, + ); +}); + +test("a step name is sentence case", () => { + for (const name of [ + "Set up job", + "Set up Node", + "Install and verify the Claude Code CLI", + "Authenticate to Pulumi Cloud", + "Check the lanes App's token", + "Validate against the JSON schema", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub expression, not a JS template + "Publish the GitHub release with ${{ inputs.Tag }}", + "Check the `Code Review Rules` section", + "shellcheck (zero findings)", + "linux-x64 publish serves the dashboard", + "Set up Node.js", + ]) { + assert.equal(checkStepName(name, vocabulary), undefined, name); + } + for (const [name, pattern] of [ + ["Install Pinned Tools", /`Pinned`, `Tools` is capitalized/u], + ["Create tag + GitHub Release", /`Release` is capitalized/u], + ["install the tools", /must start with a capital letter/u], + ]) { + assert.match(checkStepName(name, vocabulary) ?? "", pattern, name); + } +}); + +test("display-name findings report at displayNameLevel; action names are errors", async () => { + const root = await repository({ + workflows: { + "pr-review.yml": + "name: PR Review\non: pull_request\npermissions: {}\njobs:\n" + + ' pr-review:\n runs-on: ubuntu-24.04\n steps:\n - name: Run The Review\n run: "true"\n', + }, + actions: { + "check-links": + 'name: check-links\nruns:\n using: composite\n steps:\n - name: Check Every Link\n run: "true"\n shell: bash\n', + "report-lane-outcome": "jobs: [unclosed\n", + }, + }); + for (const level of ["warning", "error"]) { + const findings = await lintRepository({ + root, + vocabulary: { ...vocabulary, displayNameLevel: level }, + mode: "enforcing", + }); + assert.deepEqual(summary(findings), [ + "error action-name .github/actions/check-links/action.yml", + `${level} step-name .github/actions/check-links/action.yml`, + "error action-unparsable .github/actions/report-lane-outcome/action.yml", + `${level} job-name .github/workflows/pr-review.yml`, + `${level} step-name .github/workflows/pr-review.yml`, + ]); + } +}); + test("every target name in the approved rename map passes the vocabulary", async () => { const map = await readJson("rename-map.json"); const byRepo = Map.groupBy(map, (entry) => entry.repo); @@ -269,8 +381,16 @@ test("every target name in the approved rename map passes the vocabulary", async if (entry.to.endsWith("/pr-require-checks.yml")) { jobs.push("ci-status"); } + // A recorded name exception is written as given; any other target name + // is a stem, written in its Title Case form. + const exception = vocabulary.exemptions.some( + (item) => + item.repository === `melodic-software/${repo}` && + item.path === entry.to && + item.expectedName === entry.to_name, + ); workflows[path.basename(entry.to)] = workflow( - entry.to_name, + exception ? { raw: entry.to_name } : entry.to_name, jobs.length > 0 ? jobs : ["test"], ); } diff --git a/components/github-actions-conventions/vocabulary.json b/components/github-actions-conventions/vocabulary.json index 8f5d9685..45221129 100644 --- a/components/github-actions-conventions/vocabulary.json +++ b/components/github-actions-conventions/vocabulary.json @@ -258,6 +258,38 @@ "wip": "WIP", "yq": "yq" }, + "properNouns": [ + "App", + "Bash", + "Biome", + "Bun", + "Buildx", + "Chromium", + "Claude", + "Claude Code", + "Dependabot", + "Docker", + "Gitleaks", + "Go", + "Lefthook", + "Markdown", + "Node", + "Pester", + "Playwright", + "Pulumi", + "Pulumi Cloud", + "Python", + "Ruff", + "Windows" + ], + "displayNameLevel": "warning", + "jobNameExceptions": [ + { + "jobId": "report-ci-status", + "name": "ci-status", + "reason": "Keeps the required context `ci-status` reporting, with the `CI Status` job's conclusion, until the github-iac ci-gate ruleset requires `CI Status`; removed once it does." + } + ], "exemptions": [ { "path": ".github/workflows/release-deploy.yml", diff --git a/components/github-actions-conventions/vocabulary.schema.json b/components/github-actions-conventions/vocabulary.schema.json index 31b0b9bb..d62599bb 100644 --- a/components/github-actions-conventions/vocabulary.schema.json +++ b/components/github-actions-conventions/vocabulary.schema.json @@ -15,6 +15,9 @@ "reservedJobIds", "activityGrammar", "displayWords", + "properNouns", + "displayNameLevel", + "jobNameExceptions", "exemptions", "apps" ], @@ -108,6 +111,30 @@ "propertyNames": { "pattern": "^[a-z][a-z0-9]*$" }, "additionalProperties": { "type": "string", "minLength": 1 } }, + "properNouns": { + "description": "Capitalized words and phrases a sentence-case step name may carry after its first word: product, tool and platform names. A mixed-case word (`GitHub`) or an acronym needs no entry.", + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^\\S+( \\S+)*$" } + }, + "displayNameLevel": { + "description": "Level of the job-name and step-name findings: `warning` while the org's repositories adopt the display-name rules, then `error`.", + "enum": ["warning", "error"] + }, + "jobNameExceptions": { + "description": "Jobs whose display name is fixed by something outside the convention, each with its exact name and the reason.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["jobId", "name", "reason"], + "properties": { + "jobId": { "$ref": "#/$defs/kebab" }, + "name": { "type": "string", "minLength": 1 }, + "reason": { "$ref": "#/$defs/text" } + } + } + }, "exemptions": { "description": "The complete exemption list. Nothing else is exempt.", "type": "array", From d1797fab886d8e469809c0253ca0a7e36e5e59dc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:09:46 -0500 Subject: [PATCH 2/6] feat(github-actions-conventions): admit claude-code-plugins' workflow names claude-code-plugins cannot run naming-lint enforcing today: 20 blocking findings, every one a file or action-directory name. Fourteen are verb-first names whose words the vocabulary lacked, so this adds the function words (intake reenter; maintenance audit-lane-runs, check-upstream-drift, delete-stale-branches, file-dead-links, file-recurring-issues, monitor-signals, reconcile-lost-runs; post-merge select-mutation-range, test-mutation; pr rate-merge-risk) and the verbs delete, log, monitor, open, rate, reenter and seal. The other six need renames, tracked in melodic-software/claude-code-plugins#15394; each gets a repository-scoped workflow-filename exemption naming that issue, so every other rule still applies to them. The README's exemptions section now describes both kinds of entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- .../github-actions-conventions/README.md | 17 ++++-- .../naming-lint.test.mjs | 3 +- .../vocabulary.json | 54 +++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/components/github-actions-conventions/README.md b/components/github-actions-conventions/README.md index cd6811c6..695f69e8 100644 --- a/components/github-actions-conventions/README.md +++ b/components/github-actions-conventions/README.md @@ -170,11 +170,18 @@ what it does. The Apps in use are listed under `apps` in `vocabulary.json`. ## Exemptions -The complete list is `exemptions` in `vocabulary.json`. It holds one entry: -github-iac's OIDC negative test, `release-deploy-oidc-negative-test.yml`, -whose `name:` is `release-deployx`: the deploy workflow's name plus one -character, so the identity policy that trusts `release-deploy` must reject it. -Only that exact value passes: neither the stem nor a Title Case form does. +The complete list is `exemptions` in `vocabulary.json`, each entry with its +reason. Two kinds stand: + +- **OIDC trust inputs.** github-iac's `release-deploy.yml` keeps the `name:` + `release-deploy`, which the Pulumi issuer policy trusts, and its negative + test `release-deploy-oidc-negative-test.yml` keeps `release-deployx`, one + character more, which that policy must reject. Only those exact values pass: + neither the stem's Title Case form nor anything else does. +- **Pending renames.** A `workflow-filename` exemption lets a repository run + the lint enforcing before a file it cannot rename yet is renamed; the + entry's reason links the issue whose rename removes it, and every other rule + still applies to the file. GitHub's dynamic workflows (Dependabot, CodeQL default setup, Pages) need no entry: they have no file in `.github/workflows/`. diff --git a/components/github-actions-conventions/naming-lint.test.mjs b/components/github-actions-conventions/naming-lint.test.mjs index 31d6d89f..8c7d1fe8 100644 --- a/components/github-actions-conventions/naming-lint.test.mjs +++ b/components/github-actions-conventions/naming-lint.test.mjs @@ -486,7 +486,8 @@ test("the vocabulary loader rejects cross-field drift the schema cannot express" assert.throws(() => validateVocabulary(nounFunction), /accepted verb/u); const nameWithoutValue = clone(); - delete nameWithoutValue.exemptions[0].expectedName; + delete nameWithoutValue.exemptions.find((item) => item.rules.includes("workflow-name")) + .expectedName; assert.throws(() => validateVocabulary(nameWithoutValue), /expectedName/u); const badShape = clone(); diff --git a/components/github-actions-conventions/vocabulary.json b/components/github-actions-conventions/vocabulary.json index 45221129..d5b25842 100644 --- a/components/github-actions-conventions/vocabulary.json +++ b/components/github-actions-conventions/vocabulary.json @@ -18,6 +18,7 @@ "intake": [ { "name": "label", "status": "in-use" }, { "name": "label-needs-triage", "status": "in-use" }, + { "name": "reenter", "status": "in-use" }, { "name": "triage", "status": "in-use" } ], "plan": [], @@ -47,6 +48,7 @@ }, { "name": "lint-dockerfile", "status": "in-use" }, { "name": "merge", "status": "reserved", "purpose": "Merge the pull request." }, + { "name": "rate-merge-risk", "status": "in-use" }, { "name": "refine", "status": "reserved", @@ -75,11 +77,13 @@ ], "post-merge": [ { "name": "detect-silent-revert", "status": "in-use" }, + { "name": "select-mutation-range", "status": "in-use" }, { "name": "sweep-comments", "status": "reserved", "purpose": "File late review comments as issues." }, + { "name": "test-mutation", "status": "in-use" }, { "name": "verify", "status": "reserved", @@ -99,6 +103,7 @@ ], "maintenance": [ { "name": "audit-code-review-rules", "status": "in-use" }, + { "name": "audit-lane-runs", "status": "in-use" }, { "name": "audit-link-check-excludes", "status": "in-use" }, { "name": "audit-org-architecture-pointer", "status": "in-use" }, { "name": "audit-pr-convention-pins", "status": "in-use" }, @@ -108,12 +113,18 @@ "status": "in-use", "purpose": "Check every Markdown link with the network on." }, + { "name": "check-upstream-drift", "status": "in-use" }, + { "name": "delete-stale-branches", "status": "in-use" }, + { "name": "file-dead-links", "status": "in-use" }, + { "name": "file-recurring-issues", "status": "in-use" }, { "name": "file-upstream-drift-issues", "status": "in-use" }, { "name": "measure-skill-edit", "status": "reserved", "purpose": "Measure one proposed skill edit against the merged skill on a person's dispatch, and open a draft pull request when it wins." }, + { "name": "monitor-signals", "status": "in-use" }, + { "name": "reconcile-lost-runs", "status": "in-use" }, { "name": "reconcile-pr-creation-policy", "status": "in-use" }, { "name": "repin-account-rotation", "status": "in-use" }, { "name": "repin-ci-workflows", "status": "in-use" }, @@ -148,6 +159,7 @@ "clean", "collect", "compare", + "delete", "deploy", "detect", "download", @@ -161,12 +173,17 @@ "install", "label", "lint", + "log", "measure", "merge", + "monitor", "notify", + "open", "prune", "publish", + "rate", "reconcile", + "reenter", "refine", "regenerate", "repin", @@ -178,6 +195,7 @@ "review", "run", "scan", + "seal", "select", "setup", "sign", @@ -291,6 +309,42 @@ } ], "exemptions": [ + { + "path": ".github/workflows/dependabot-fragments.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name has no stage word; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, + { + "path": ".github/workflows/eval-browser-dispatch.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name has no stage word; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, + { + "path": ".github/workflows/eval-hill-climb-pass.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name has no stage word; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, + { + "path": ".github/workflows/security-outside-actor.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name has no stage word; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, + { + "path": ".github/workflows/pr-feedback-dispatch.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name is not verb-first after its stage; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, + { + "path": ".github/workflows/post-merge-demotion-alert.yml", + "repository": "melodic-software/claude-code-plugins", + "rules": ["workflow-filename"], + "reason": "The file name is not verb-first after its stage; its rename is tracked in melodic-software/claude-code-plugins#15394, which removes this entry." + }, { "path": ".github/workflows/release-deploy.yml", "repository": "melodic-software/github-iac", From 35444248659cce5cd368b55e5d2ce399cd8a47ab Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:12:30 -0500 Subject: [PATCH 3/6] fix(github-actions-conventions): keep word positions in step-name Review fixes for #983: - step-name removed a multi-word proper noun before tokenizing, so the word after a leading "Claude Code" or "Pulumi Cloud" became the first word and escaped the capitalization check ("Claude Code Review" passed). Tokens now keep their positions; a multi-word noun exempts its words only where they appear together, so "Code" alone is no longer exempt either. - The README's display-name rollout links its completion issue, standards#985, due 2026-12-11, as the enforcement rollout requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- .../github-actions-conventions/README.md | 4 ++- .../naming-lint.mjs | 31 ++++++++++++------- .../naming-lint.test.mjs | 3 ++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/components/github-actions-conventions/README.md b/components/github-actions-conventions/README.md index cd6811c6..d8ff1c93 100644 --- a/components/github-actions-conventions/README.md +++ b/components/github-actions-conventions/README.md @@ -265,7 +265,9 @@ closed the advisory period. names its jobs in the same change that updates every script and filter reading the old check names. Once standards, ci-workflows and claude-code-plugins pass with no warning of either rule, a change sets - `displayNameLevel` to `error`. + `displayNameLevel` to `error`: + [#985](https://github.com/melodic-software/standards/issues/985), due + 2026-12-11. ### Ownership and operation diff --git a/components/github-actions-conventions/naming-lint.mjs b/components/github-actions-conventions/naming-lint.mjs index d0882850..3a89a2ed 100755 --- a/components/github-actions-conventions/naming-lint.mjs +++ b/components/github-actions-conventions/naming-lint.mjs @@ -185,7 +185,7 @@ function displayTokens(name) { function knownNames(vocabulary) { return new Set([ ...Object.values(vocabulary.displayWords), - ...vocabulary.properNouns.flatMap((noun) => noun.split(" ")), + ...vocabulary.properNouns.filter((noun) => !noun.includes(" ")), ...vocabulary.toolNames, ...vocabulary.engines, ]); @@ -223,21 +223,28 @@ export function checkJobName(id, name, vocabulary) { // identifier, and a later capitalized word is a proper noun from the // vocabulary, an acronym, or a mixed-case identifier. export function checkStepName(name, vocabulary) { - let text = name; + const known = knownNames(vocabulary); + const tokens = displayTokens(name); + // A multi-word proper noun exempts its words where they appear together, + // and only there: `Claude Code` passes, `Code` alone does not. + const exempt = tokens.map((token) => known.has(token)); for (const noun of vocabulary.properNouns.filter((entry) => entry.includes(" "))) { - text = text.replaceAll(noun, " "); + const words = noun.split(" "); + for (let start = 0; start + words.length <= tokens.length; start += 1) { + if (words.every((word, offset) => tokens[start + offset] === word)) { + words.forEach((_, offset) => { + exempt[start + offset] = true; + }); + } + } } - const known = knownNames(vocabulary); - const [first, ...rest] = displayTokens(text); - if ( - first !== undefined && - displayTokens(name)[0] === first && - /^\p{Ll}+$/u.test(first) && - !known.has(first) - ) { + const [first] = tokens; + if (first !== undefined && !exempt[0] && /^\p{Ll}+$/u.test(first)) { return `step name \`${name}\` must start with a capital letter (sentence case)`; } - const capitalized = rest.filter((token) => /^\p{Lu}\p{Ll}+$/u.test(token) && !known.has(token)); + const capitalized = tokens.filter( + (token, index) => index > 0 && !exempt[index] && /^\p{Lu}\p{Ll}+$/u.test(token), + ); if (capitalized.length > 0) { return ( `step name \`${name}\` is not sentence case: ${capitalized.map((token) => `\`${token}\``).join(", ")} ` + diff --git a/components/github-actions-conventions/naming-lint.test.mjs b/components/github-actions-conventions/naming-lint.test.mjs index 31d6d89f..e101f628 100644 --- a/components/github-actions-conventions/naming-lint.test.mjs +++ b/components/github-actions-conventions/naming-lint.test.mjs @@ -329,6 +329,9 @@ test("a step name is sentence case", () => { ["Install Pinned Tools", /`Pinned`, `Tools` is capitalized/u], ["Create tag + GitHub Release", /`Release` is capitalized/u], ["install the tools", /must start with a capital letter/u], + ["Claude Code Review", /`Review` is capitalized/u], + ["Pulumi Cloud Deploy", /`Deploy` is capitalized/u], + ["Review the Code", /`Code` is capitalized/u], ]) { assert.match(checkStepName(name, vocabulary) ?? "", pattern, name); } From a16690699595085dcfb610c094ff4392231ac412 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:45:58 -0500 Subject: [PATCH 4/6] feat(managed-files-guard): name synced caller jobs in Title Case The org job-name rule (#983) wants a Title Case display name on every job, and these synced callers are the last two jobs in claude-code-plugins without one: - managed-files-guard and its fleet variant: job pr-check-managed-files is named "PR Check Managed Files", which becomes the check context on every target. No ruleset requires that context; the only reader found (claude-code-plugins reconcile.mjs) keys on the workflow path. - lychee-online's claude-code-plugins caller: job check-links is named "Check Links". The test and the docs that stated the context now name it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- components/lychee-online/targets/claude-code-plugins.yml | 1 + components/managed-files-guard/README.md | 2 +- .../managed-files-guard/managed-files-guard-fleet.yml | 8 ++++---- .../managed-files-guard/managed-files-guard.test.sh | 4 ++-- components/managed-files-guard/managed-files-guard.yml | 6 +++--- distribution/README.md | 6 +++--- distribution/sync-manifest.yml | 2 +- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/components/lychee-online/targets/claude-code-plugins.yml b/components/lychee-online/targets/claude-code-plugins.yml index 43e28ec6..160699c3 100644 --- a/components/lychee-online/targets/claude-code-plugins.yml +++ b/components/lychee-online/targets/claude-code-plugins.yml @@ -27,6 +27,7 @@ permissions: {} jobs: check-links: + name: Check Links permissions: contents: read uses: melodic-software/ci-workflows/.github/workflows/maintenance-check-links-online.yml@main diff --git a/components/managed-files-guard/README.md b/components/managed-files-guard/README.md index 59b9555e..734f3414 100644 --- a/components/managed-files-guard/README.md +++ b/components/managed-files-guard/README.md @@ -20,7 +20,7 @@ the workflow `name:` (each is its destination stem in Title Case) and their head | `managed-files-guard-fleet-caller` | `managed-files-guard-fleet.yml` | `.github/workflows/pr-check-managed-files.yml` | `melodic-ubuntu-24.04-x64` | Both carry the same action pin and the same job name, so the check context is -`pr-check-managed-files` on every target. The destinations differ because the +`PR Check Managed Files` on every target. The destinations differ because the manifest requires each destination to have one owning component. The guard caller is the second recorded exception to the rule that workflow callers stay consumer-owned (`distribution/README.md`), for the same reason as the Claude diff --git a/components/managed-files-guard/managed-files-guard-fleet.yml b/components/managed-files-guard/managed-files-guard-fleet.yml index 981188b1..1d90f185 100644 --- a/components/managed-files-guard/managed-files-guard-fleet.yml +++ b/components/managed-files-guard/managed-files-guard-fleet.yml @@ -29,7 +29,7 @@ name: PR Check Managed Files # The two files differ only in this paragraph, the source lines above, the # workflow `name:` (each is its destination stem in Title Case), the `paths` entries # (each lists its own targets' destinations) and `runs-on`; the job -# name is the same, so the check context is `pr-check-managed-files` on every +# name is the same, so the check context is `PR Check Managed Files` on every # target. # The guard fails only on an exact managed destination path, so the workflow @@ -115,9 +115,9 @@ concurrency: jobs: pr-check-managed-files: - # Equal to the job id so the check context is `pr-check-managed-files`; - # zizmor's pedantic `anonymous-definition` audit flags an unnamed job. - name: pr-check-managed-files + # The check context is this Title Case name (org naming lint, job-name); + # zizmor's pedantic `anonymous-definition` audit also flags an unnamed job. + name: PR Check Managed Files runs-on: melodic-ubuntu-24.04-x64 timeout-minutes: 10 # No checkout step: the action checks out this repository itself, with diff --git a/components/managed-files-guard/managed-files-guard.test.sh b/components/managed-files-guard/managed-files-guard.test.sh index 9857c7f3..620afbbe 100755 --- a/components/managed-files-guard/managed-files-guard.test.sh +++ b/components/managed-files-guard/managed-files-guard.test.sh @@ -120,8 +120,8 @@ check_shape() { assert_eq "$p exactly one job" '1' "$(q '.jobs | length')" assert_eq "$p the job is named pr-check-managed-files" 'pr-check-managed-files' "$(q '.jobs | keys | .[0]')" - assert_eq "$p the job display name equals its id (check context is pr-check-managed-files)" \ - 'pr-check-managed-files' "$(q '.jobs["pr-check-managed-files"].name')" + assert_eq "$p the job display name is the Title Case of its id (check context is PR Check Managed Files)" \ + 'PR Check Managed Files' "$(q '.jobs["pr-check-managed-files"].name')" assert_eq "$p the job runs on $runs_on directly" "$runs_on" \ "$(q '.jobs["pr-check-managed-files"]["runs-on"]')" assert_eq "$p the job has a 10-minute timeout" '10' "$(q '.jobs["pr-check-managed-files"]["timeout-minutes"]')" diff --git a/components/managed-files-guard/managed-files-guard.yml b/components/managed-files-guard/managed-files-guard.yml index 38cd1e0f..3bb2eb0c 100644 --- a/components/managed-files-guard/managed-files-guard.yml +++ b/components/managed-files-guard/managed-files-guard.yml @@ -98,9 +98,9 @@ concurrency: jobs: pr-check-managed-files: - # Equal to the job id so the check context is `pr-check-managed-files`; - # zizmor's pedantic `anonymous-definition` audit flags an unnamed job. - name: pr-check-managed-files + # The check context is this Title Case name (org naming lint, job-name); + # zizmor's pedantic `anonymous-definition` audit also flags an unnamed job. + name: PR Check Managed Files runs-on: ubuntu-24.04 timeout-minutes: 10 # No checkout step: the action checks out this repository itself, with diff --git a/distribution/README.md b/distribution/README.md index 3b16c8e4..b3b279f8 100644 --- a/distribution/README.md +++ b/distribution/README.md @@ -422,9 +422,9 @@ Both call `.github/actions/check-managed-files` at the same pin. The two files differ only in `runs-on`, comments, their `on.pull_request.paths` entries (each lists the managed destinations of the targets that receive it) and the workflow `name:`, which is each destination stem in Title Case (`PR Check Managed Files Hosted` and -`PR Check Managed Files`). They share the job id and job name -`pr-check-managed-files`, so the check context is `pr-check-managed-files` -on every target, hosted or fleet-routed. The hosted caller +`PR Check Managed Files`). They share the job id +`pr-check-managed-files` and job name `PR Check Managed Files`, which is the +check context on every target, hosted or fleet-routed. The hosted caller runs on `ubuntu-24.04` and is `managed` for the public targets. The fleet-routed caller runs on `melodic-ubuntu-24.04-x64` and is `managed` for the fleet-enrolled private targets (`agent-automations`, diff --git a/distribution/sync-manifest.yml b/distribution/sync-manifest.yml index 8babd4fe..cc82c6ff 100644 --- a/distribution/sync-manifest.yml +++ b/distribution/sync-manifest.yml @@ -299,7 +299,7 @@ components: # Two components, one per runner-policy inventory. The files are identical # except for `runs-on`, the workflow `name:`, their headers and their # `paths` entries (each lists its own targets' destinations): same action - # pin, same job name, so the check context is `pr-check-managed-files` on + # pin, same job name, so the check context is `PR Check Managed Files` on # every target. Destinations must be unique across components, so each has # its own path (`pr-check-managed-files-hosted.yml` for the hosted caller). # A target takes one or the other, never both. From 953436e584dda996b6a9403bd8479e5f1e8a85e1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:34:13 -0500 Subject: [PATCH 5/6] test(github-actions-conventions): pick the expectedName exemption by rule The loader test assumed exemptions[0] waives workflow-name; the workflow-filename exemptions this branch adds now sort first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- components/github-actions-conventions/naming-lint.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/github-actions-conventions/naming-lint.test.mjs b/components/github-actions-conventions/naming-lint.test.mjs index cf4a9918..cb84c0f0 100644 --- a/components/github-actions-conventions/naming-lint.test.mjs +++ b/components/github-actions-conventions/naming-lint.test.mjs @@ -492,7 +492,7 @@ test("the vocabulary loader rejects cross-field drift the schema cannot express" assert.throws(() => validateVocabulary(nounFunction), /accepted verb/u); const nameWithoutValue = clone(); - delete nameWithoutValue.exemptions[0].expectedName; + delete nameWithoutValue.exemptions.find((e) => e.rules.includes("workflow-name")).expectedName; assert.throws(() => validateVocabulary(nameWithoutValue), /expectedName/u); const badShape = clone(); From 4516351cc1f2cce6977a8d77753e74dc3c12ca72 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:01:50 -0500 Subject: [PATCH 6/6] docs(github-actions-conventions): end the Pending renames bullet Without a blank line the dynamic-workflows sentence rendered inside the bullet as a lazy continuation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- components/github-actions-conventions/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/components/github-actions-conventions/README.md b/components/github-actions-conventions/README.md index 45cbcf80..7e080433 100644 --- a/components/github-actions-conventions/README.md +++ b/components/github-actions-conventions/README.md @@ -182,6 +182,7 @@ reason. Two kinds stand: the lint enforcing before a file it cannot rename yet is renamed; the entry's reason links the issue whose rename removes it, and every other rule still applies to the file. + GitHub's dynamic workflows (Dependabot, CodeQL default setup, Pages) need no entry: they have no file in `.github/workflows/`.