From 39acd077a1e40524c1b336a8c1b70d2ece73eb95 Mon Sep 17 00:00:00 2001 From: Cassondra Roberts <1840295+castastrophe@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:49:01 +0000 Subject: [PATCH 1/2] docs: document the ruleset, CODEOWNERS, & merge_group standard Replace the Not covered section with Branch protection, & track CODEOWNERS, merge_group, required checks, & the merge queue in the standards matrix. --- docs/repository-standards.md | 38 +++- skills/standards-matrix/data/manual.json | 204 +++++++++++++++++- skills/standards-matrix/references/checks.md | 2 + skills/standards-matrix/scripts/lib/checks.js | 32 +++ skills/standards-matrix/test/checks.test.js | 27 +++ 5 files changed, 291 insertions(+), 12 deletions(-) diff --git a/docs/repository-standards.md b/docs/repository-standards.md index 31e986c..9b15725 100644 --- a/docs/repository-standards.md +++ b/docs/repository-standards.md @@ -17,6 +17,7 @@ note allows. | `.opx/config.json` | `.opx/config.json` | Routes JS, JSON, & Markdown to one ESLint config; `opx enable release` adds `"release": true` | | `.husky/post-commit` | `.husky/post-commit` | Runs `opx` hooks & lint; never blocks a commit. A repository with its own hooks (`pre-commit`, `commit-msg`) keeps them | | `.env.example` | `.env.example` | Lists every variable the repo reads; required only where it reads some | +| `.github/CODEOWNERS` | `.github/CODEOWNERS` | One line, `* @castastrophe`; the ruleset requires code owner review | | `.github/renovate.json` | `renovate.json` | One-month release age, monthly lockfile refresh | | `.github/release.yml` | `release.yml` | Changelog categories keyed on `type:` labels | | `.gitignore` | `.gitignore` | Add `_site` for repos that build an Eleventy site, & `coverage` for every repo with tests | @@ -179,7 +180,7 @@ on the same files where it is not adopted yet. | File | Rule | | ----------------------------- | -------------------------------------------------------------------------------------------- | -| `.github/workflows/ci.yml` | On `pull_request` & pushes to `main`. `permissions: contents: read`. Steps: checkout with `persist-credentials: false`, `corepack enable`, `setup-node` with `node-version: ${{ matrix.node }}` & `cache: yarn`, `yarn install --immutable`, `yarn lint`, `yarn build`, `yarn typecheck`, `yarn test` (`yarn coverage` on the Node 26 job, with the coverage steps below). `permissions` add `pull-requests: write` for the coverage comment. One job named `CI (node${{ matrix.node }})` over `node: [26, 24]`, so the checks are `CI (node26)` & `CI (node24)`; both are required | +| `.github/workflows/ci.yml` | On `pull_request`, `merge_group`, & pushes to `main`. `permissions: contents: read`. Steps: checkout with `persist-credentials: false`, `corepack enable`, `setup-node` with `node-version: ${{ matrix.node }}` & `cache: yarn`, `yarn install --immutable`, `yarn lint`, `yarn build`, `yarn typecheck`, `yarn test` (`yarn coverage` on the Node 26 job, with the coverage steps below). `permissions` add `pull-requests: write` for the coverage comment. One job named `CI (node${{ matrix.node }})` over `node: [26, 24]`, so the checks are `CI (node26)` & `CI (node24)`; both are required | | `.github/workflows/pages.yml` | Calls the org's reusable `pages.yml@main` with `output-dir`; grants `contents: write` & `pull-requests: write` | | `.github/renovate.json` | The baseline above, with `reviewers` naming the maintainer | @@ -247,11 +248,34 @@ job only; see [Coverage](testing.md#coverage) for the tools & the floor. The Commands table in `AGENTS.md` lists only commands that run from the repository root & that have been run. -## Not covered +## Branch protection -GitHub-side settings (branch protection, required checks, merge options, Pages source) -are not files. The one rule that depends on them: `CI (node26)` & `CI (node24)` are required -status checks. Add the rest here once confirmed. +Every repository carries one repository-level ruleset, named `Default`, that targets the +default branch. Org-level rulesets are not used: the merge queue is available only on +repository rulesets. Import it from the repository's Settings → Rules → Rulesets. + +| Rule | Setting | +| ------------------------ | --------------------------------------------------------------------------------------- | +| Deletion, force push | Blocked (`deletion`, `non_fast_forward`) | +| Creation | Restricted to the bypass actors (`creation`) | +| History | Linear (`required_linear_history`) | +| Commit signatures | Required (`required_signatures`); queue & bot commits show "Verified" | +| Pull request | 1 approval, code owner review, stale approvals dismissed on push, last push approved, threads resolved, squash merge only | +| Required status checks | `CI (node26)` & `CI (node24)`, plus any check the repository's own workflows add; checks are not strict, so a branch need not be current | +| Merge queue | Squash, all green, up to 5 entries, 60 minute check timeout | +| Bypass | Organization admins, for pull requests only | + +The merge queue runs each required check on a `gh-readonly-queue/*` branch, so every +workflow that provides a required check lists `merge_group:` under `on:`. Without it, a +queued pull request waits for checks that never start. A workflow that provides no +required check, such as a release or a Pages workflow, leaves it out. + +Changing a job's name changes the check's name: rename the job in a pull request, then +update the ruleset's required checks, never the reverse, or the queue waits on a check +that no longer exists. + +The ruleset & the secrets are GitHub settings, not files, so the matrix tracks them as +manual rows. Pages source & merge options outside the ruleset are not covered yet. ## Adopting the standard @@ -263,4 +287,6 @@ status checks. Add the rest here once confirmed. 6. If the repo publishes, add `@allons-y/opx-release` & run `opx enable release`. 7. Add `ci.yml` from the snippet above, which gives the `CI (node26)` & `CI (node24)` jobs, & `pages.yml`. 8. Add `AGENTS.md` & link [testing.md](testing.md) from its Testing section. -9. Run `yarn lint`, `yarn build`, `yarn typecheck`, & `yarn test`. +9. Add `.github/CODEOWNERS` & `merge_group:` to every workflow that provides a required check. +10. Run `yarn lint`, `yarn build`, `yarn typecheck`, & `yarn test`. +11. Import the `Default` ruleset, then set `CI (node26)` & `CI (node24)` as required checks. diff --git a/skills/standards-matrix/data/manual.json b/skills/standards-matrix/data/manual.json index 7625a79..98d6657 100644 --- a/skills/standards-matrix/data/manual.json +++ b/skills/standards-matrix/data/manual.json @@ -2,17 +2,209 @@ { "area": "Settings", "tool": "Required checks", - "std": "Both Node jobs are required status checks.", - "src": "GitHub branch protection", + "std": "CI (node26) & CI (node24) are required status checks.", + "src": "Repository ruleset", "repos": { ".github": { - "status": "you", - "state": "Not verified: a GitHub setting.", + "status": "ok", + "state": "Requires CI (node26) & CI (node24).", + "verified": "2026-11-06" + }, + "actions-file-diff": { + "status": "gap", + "state": "Requires test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "actions-weaver": { + "status": "gap", + "state": "Requires test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "agent-skills": { + "status": "gap", + "state": "Requires Build docs site; Lint; Test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "allonsy-studio.github.io": { + "status": "gap", + "state": "Requires no checks.", + "verified": "2026-11-06" + }, + "course-kit": { + "status": "gap", + "state": "Requires test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "dashly": { + "status": "gap", + "state": "Requires Lint; Test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "eleventy-plugin-netlify-data-store": { + "status": "gap", + "state": "Requires Lint; Test \u2014 Node 22 on macos-latest; Test \u2014 Node 22 on ubuntu-latest; & more, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "envoy": { + "status": "ok", + "state": "Requires CI (node26) & CI (node24).", + "verified": "2026-11-06" + }, + "glob-concat-cli": { + "status": "gap", + "state": "Requires coverage (24); lint (24); test (22); & more, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "link-checker": { + "status": "gap", + "state": "Requires test (24); test (lts/*), not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "markdown-it-anchor-sections": { + "status": "gap", + "state": "Requires build-validation, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "opx": { + "status": "gap", + "state": "Requires build; lint; smoke; & more, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "postcss-licensing": { + "status": "gap", + "state": "Requires test, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "stylelint-header": { + "status": "gap", + "state": "Requires Coverage reporting; Stylelint v17 | macos-latest | node v24; Stylelint v17 | ubuntu-latest | node v24; & more, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "template-actions": { + "status": "gap", + "state": "Requires test, not the Node jobs; rename the jobs first.", "verified": "2026-11-06" }, "webc-docs": { - "status": "you", - "state": "Not verified: a GitHub setting.", + "status": "ok", + "state": "Requires CI (node26) & CI (node24).", + "verified": "2026-11-06" + }, + "webc-resources": { + "status": "gap", + "state": "Requires Lint, test, pack (Node 22); Lint, test, pack (Node 24), not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + }, + "website": { + "status": "gap", + "state": "Requires Browser tests (Cypress \u2014 a11y + internal links); Unit tests (Vitest); build-validation, not the Node jobs; rename the jobs first.", + "verified": "2026-11-06" + } + }, + "default": { + "status": "you", + "state": "Not verified: a GitHub setting." + } + }, + { + "area": "Settings", + "tool": "Merge queue", + "std": "The Default ruleset has the merge queue on.", + "src": "Repository ruleset", + "repos": { + ".github": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "actions-file-diff": { + "status": "gap", + "state": "No merge queue; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "actions-weaver": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "agent-skills": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "allonsy-studio.github.io": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "course-kit": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "dashly": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "eleventy-plugin-netlify-data-store": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "envoy": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "glob-concat-cli": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "link-checker": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "markdown-it-anchor-sections": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "opx": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "postcss-licensing": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "stylelint-header": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "template-actions": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "webc-docs": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "webc-resources": { + "status": "ok", + "state": "Merge queue on; signed commits not required yet, pending the queue signing test.", + "verified": "2026-11-06" + }, + "website": { + "status": "gap", + "state": "No merge queue; signed commits not required yet, pending the queue signing test.", "verified": "2026-11-06" } }, diff --git a/skills/standards-matrix/references/checks.md b/skills/standards-matrix/references/checks.md index 8a32cb5..50d5be1 100644 --- a/skills/standards-matrix/references/checks.md +++ b/skills/standards-matrix/references/checks.md @@ -25,6 +25,8 @@ Each row of the matrix comes from one check in `scripts/lib/checks.js`. A check | `husky` | Husky | The repository has no `.husky` hooks. The baseline `post-commit` & a repository's own hooks both pass; the state names which | never | | `opx-release` | opx release | A publishing repository lacks the plugin, `"release": true`, or the `changeset`, `version`, & `release` scripts | Nothing is published | | `ci` | ci.yml | There is no workflow that runs tests, the workflow is not named `ci.yml`, no workflow runs lint, test, (build & typecheck for TypeScript), or the CI workflow lacks node 26 & node 24 jobs named `CI (node26)` & `CI (node24)` | never | +| `merge-group` | merge_group trigger | A `ci`, `test`, `tests`, `testing`, or `build` workflow runs on `pull_request` but not on `merge_group` | No such workflow runs on `pull_request` | +| `codeowners` | CODEOWNERS | There is no `.github/CODEOWNERS` (or root, or `docs/`) file, or it has no default `*` owner | never | | `pages` | Pages | A site exists & `pages.yml` does not call the shared workflow | It hosts the workflow, or there is no site | | `renovate` | Renovate | `renovate.json` is missing or differs from the baseline | never | | `agents` | AGENTS.md | Missing (a lone `CLAUDE.md` is noted), or (outside `.github`) missing the org link or a standard section | never | diff --git a/skills/standards-matrix/scripts/lib/checks.js b/skills/standards-matrix/scripts/lib/checks.js index 3861409..bfbfa0d 100644 --- a/skills/standards-matrix/scripts/lib/checks.js +++ b/skills/standards-matrix/scripts/lib/checks.js @@ -28,6 +28,9 @@ const ORG_ROOT_NAME = "@allons-y/core"; /** Variables every environment provides, so `.env.example` need not list them. */ const AMBIENT_ENV = new Set(["CI", "NODE_ENV", "HOME", "PATH", "TMPDIR", "TEMP", "TMP", "USER", "SHELL", "NO_COLOR", "FORCE_COLOR", "NODE_OPTIONS", "NODE_PATH", "PWD", "DEBUG"]); +/** Workflow files that provide the required checks. */ +const CI_WORKFLOW = /^(ci|test|tests|testing|build)\.ya?ml$/; + /** Dependencies that provide coverage: c8, Vitest's v8 provider, & Jest's built-in reporter. */ const COVERAGE_TOOLS = ["c8", "@vitest/coverage-v8", "jest"]; @@ -401,6 +404,35 @@ export const CHECKS = [ return ok(ts ? "Lint, build, typecheck, & test on both Node versions." : "Lint & test on both Node versions; build & typecheck are omitted because nothing builds."); }, }, + { + id: "merge-group", + area: "CI", + tool: "merge_group trigger", + std: "Each workflow that provides a required check also runs on merge_group.", + src: ".github/workflows/", + run(repo) { + const files = workflowNames(repo).filter((n) => CI_WORKFLOW.test(n)); + const pr = files.filter((n) => /^\s*pull_request(_target)?:/m.test(workflow(repo, n))); + if (!pr.length) return na("No pull request workflow provides a required check."); + const missing = pr.filter((n) => !/^\s*merge_group:/m.test(workflow(repo, n))); + if (missing.length) return gap(`${joinList(missing)} ${missing.length > 1 ? "lack" : "lacks"} a merge_group trigger, so a queued pull request never gets its checks.`); + return ok(`${joinList(pr)} run${pr.length > 1 ? "" : "s"} on merge_group.`); + }, + }, + { + id: "codeowners", + area: "Config", + tool: "CODEOWNERS", + std: "A default owner (`* @castastrophe`) routes review.", + src: ".github/CODEOWNERS", + run(repo) { + const rel = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].find((f) => repo.has(f)); + if (!rel) return gap("No CODEOWNERS file."); + const owner = (repo.read(rel) ?? "").split("\n").map((l) => l.trim()).find((l) => /^\*\s+@/.test(l)); + if (!owner) return gap(`${rel} has no default \`*\` owner.`); + return ok(`${rel}: ${owner.replace(/\s+/g, " ")}.`); + }, + }, { id: "pages", area: "CI", diff --git a/skills/standards-matrix/test/checks.test.js b/skills/standards-matrix/test/checks.test.js index 6fa2ba7..5811393 100644 --- a/skills/standards-matrix/test/checks.test.js +++ b/skills/standards-matrix/test/checks.test.js @@ -248,3 +248,30 @@ test("coverage accepts Jest's own reporter", (t) => { test("coverage is not applicable without a test script", (t) => { t.is(runCheck("coverage", { "package.json": { scripts: {} } })[0], "na"); }); + +const QUEUED = "on:\n pull_request:\n merge_group:\njobs:\n test:\n runs-on: ubuntu-latest\n"; + +test("merge-group is a gap when a required-check workflow skips the merge queue", (t) => { + const [status, state] = runCheck("merge-group", { ".github/workflows/testing.yml": QUEUED.replace(" merge_group:\n", "") }); + t.is(status, "gap"); + t.regex(state, /testing\.yml lacks a merge_group trigger/); +}); + +test("merge-group accepts workflows that run on merge_group", (t) => { + const [status, state] = runCheck("merge-group", { ".github/workflows/ci.yml": QUEUED, ".github/workflows/build.yml": QUEUED }); + t.is(status, "ok"); + t.regex(state, /build\.yml, ci\.yml run on merge_group/); +}); + +test("merge-group ignores workflows that do not provide a required check", (t) => { + t.is(runCheck("merge-group", { ".github/workflows/release.yml": "on:\n push:\n" })[0], "na"); + t.is(runCheck("merge-group", { ".github/workflows/linting.yml": "on:\n pull_request:\n" })[0], "na"); +}); + +test("codeowners wants a default owner", (t) => { + t.is(runCheck("codeowners", {})[0], "gap"); + t.regex(runCheck("codeowners", { ".github/CODEOWNERS": "docs/ @someone\n" })[1], /no default `\*` owner/); + const [status, state] = runCheck("codeowners", { ".github/CODEOWNERS": "* @castastrophe\n" }); + t.is(status, "ok"); + t.regex(state, /\.github\/CODEOWNERS: \* @castastrophe/); +}); From 8c14e33ed943e7e4fd9593ec8fe9257cff10efca Mon Sep 17 00:00:00 2001 From: Cassondra Roberts <1840295+castastrophe@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:04:47 +0000 Subject: [PATCH 2/2] fix(matrix): read the node check from the repository's CI workflow The check read only ci.yml, so a repository that names its CI workflow testing.yml or test.yml always failed it. --- skills/standards-matrix/scripts/lib/checks.js | 14 ++++++++------ skills/standards-matrix/test/checks.test.js | 7 +++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/skills/standards-matrix/scripts/lib/checks.js b/skills/standards-matrix/scripts/lib/checks.js index bfbfa0d..45f16a3 100644 --- a/skills/standards-matrix/scripts/lib/checks.js +++ b/skills/standards-matrix/scripts/lib/checks.js @@ -39,6 +39,11 @@ const hasTypeScript = (repo) => tsSourceFiles(repo).length > 0; const tsPackages = (repo) => repo.packages().filter((p) => repo.has(p.dir === "." ? "tsconfig.json" : `${p.dir}/tsconfig.json`)); const workflow = (repo, name) => repo.read(`.github/workflows/${name}`) ?? ""; const workflowNames = (repo) => repo.files().filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).map((f) => f.split("/").pop()); +/** The CI workflow: `ci.yml`, else the first of the usual test workflow names. */ +const ciWorkflowName = (repo) => { + const names = workflowNames(repo); + return names.includes("ci.yml") ? "ci.yml" : names.find((n) => /^(ci|test|tests|testing)\.ya?ml$/.test(n)); +}; const allWorkflows = (repo) => workflowNames(repo).map((name) => workflow(repo, name)).join("\n"); /** Node majors a workflow runs: a matrix list (`node: [26, 24]`) or explicit `node-version` values. */ function nodeMajors(text) { @@ -82,7 +87,7 @@ export const CHECKS = [ run(repo) { const nvmrc = repo.read(".nvmrc")?.trim(); const engines = rootPkg(repo).engines?.node; - const ci = workflow(repo, "ci.yml"); + const ci = workflow(repo, ciWorkflowName(repo) ?? "ci.yml"); const problems = []; if (nvmrc !== "26") problems.push(`.nvmrc is ${nvmrc ?? "missing"}`); if (!/^>=\s*24(\.\d+){0,2}$/.test(engines ?? "")) problems.push(`engines.node is ${engines ?? "missing"}`); @@ -383,11 +388,8 @@ export const CHECKS = [ src: ".github/workflows/ci.yml", run(repo) { const names = workflowNames(repo); - let file = "ci.yml"; - if (!names.includes(file)) { - file = names.find((n) => /^(ci|test|tests|testing)\.ya?ml$/.test(n)); - if (!file) return gap(names.length ? `No CI workflow that runs tests (workflows: ${joinList(names, 5)}).` : "No workflows."); - } + const file = ciWorkflowName(repo); + if (!file) return gap(names.length ? `No CI workflow that runs tests (workflows: ${joinList(names, 5)}).` : "No workflows."); const ci = workflow(repo, file); const everything = allWorkflows(repo); const ts = hasTypeScript(repo); diff --git a/skills/standards-matrix/test/checks.test.js b/skills/standards-matrix/test/checks.test.js index 5811393..8ffb190 100644 --- a/skills/standards-matrix/test/checks.test.js +++ b/skills/standards-matrix/test/checks.test.js @@ -275,3 +275,10 @@ test("codeowners wants a default owner", (t) => { t.is(status, "ok"); t.regex(state, /\.github\/CODEOWNERS: \* @castastrophe/); }); + +test("node reads the CI workflow even when it is not called ci.yml", (t) => { + const matrix = "jobs:\n test:\n strategy:\n matrix:\n node: [26, 24]\n"; + const files = { ".nvmrc": "26\n", "package.json": { engines: { node: ">=24" } }, ".github/workflows/testing.yml": matrix }; + t.is(runCheck("node", files)[0], "ok"); + t.regex(runCheck("node", { ...files, ".github/workflows/testing.yml": "jobs:\n test:\n strategy:\n matrix:\n node: [26]\n" })[1], /CI has no Node 24 job/); +});