From 3d7d6e019df423f76ee44aa99818e99ae5f05ef2 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 25 Aug 2026 16:40:11 +0000 Subject: [PATCH] fix(repo): let dependency updates clear the DCO and title gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot's two open pull requests were red on the same two checks, and neither failure was about the dependency. The DCO check rejected a sign-off that was present. Dependabot authors as `dependabot[bot] <…@users.noreply.github.com>` and signs off as `dependabot[bot] `, and dco.yml demanded the two match exactly. Since `Signed off` is a required check, that left every dependency update mergeable only by spending the org-admin bypass — the habit ADR 0015 was written to end. A GitHub App's sign-off is now matched on name, with both halves of the author identity required to say bot. The title check rejected a prefix the config could never have got right: `prefix: 'devcontainer'` is a scope where Dependabot emits a type. Prefixes become build(deps), build(deps-dev) and ci(deps) — the vocabulary PR #5 already used — and check:commits now reads dependabot.yml and CONTRIBUTING.md alongside the three copies it reconciled, so the next one fails at the hook rather than a week later in a bot's pull request. CONTRIBUTING.md had already drifted; it was missing deps-dev. Dependabot's capitalised subject is not configurable, so a normaliser lowercases it rather than exempting the bot from the rule. Also excludes wrangler from the tooling group: GOVERNANCE.md rests its guarantee on that pin moving through a diff someone opened deliberately, and inside the group it would move in someone else's lockfile churn. See docs/adr/0016-dependency-update-policy.md. Signed-off-by: Justin Merrell --- .config/lefthook.yml | 10 ++ .github/CONTRIBUTING.md | 11 +- .github/dependabot.yml | 29 +++- .github/workflows/dco.yml | 31 +++- .github/workflows/dependabot-title.yml | 76 ++++++++++ GOVERNANCE.md | 7 +- docs/adr/0016-dependency-update-policy.md | 167 ++++++++++++++++++++++ taskfiles/check.Taskfile.yml | 2 +- tools/src/commits.ts | 99 ++++++++++++- 9 files changed, 418 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/dependabot-title.yml create mode 100644 docs/adr/0016-dependency-update-policy.md diff --git a/.config/lefthook.yml b/.config/lefthook.yml index 9423a72..c5a3513 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -28,6 +28,16 @@ pre-commit: glob: '.github/{rulesets/**,CODEOWNERS,workflows/*.yml}' run: task check:rulesets + # Every copy of the Conventional Commits vocabulary, this file included -- + # the commit-msg hook below inlines the types a fifth time. CI checks them + # too; catching a mismatch here saves a round trip, and the prefixes in + # dependabot.yml are only ever exercised by a bot nobody watches. + - name: commits + glob: + - '.github/{conventional-commits.yaml,dependabot.yml,CONTRIBUTING.md,workflows/lint-pr.yml}' + - '.config/lefthook.yml' + run: task check:commits + - name: markdown glob: '**/*.md' run: task check:md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b1eddc7..4a5a541 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -138,7 +138,11 @@ Types: `feat`, `fix`, `perf`, `docs`, `chore`, `refactor`, `test`, `ci`, `build`, `style`, `revert`. Scopes: `component`, `blueprint`, `listing`, `conformance`, `tools`, `ci`, -`devcontainer`, `docs`, `repo`, `deps`. +`devcontainer`, `docs`, `repo`, `deps`, `deps-dev`. + +`deps` and `deps-dev` are Dependabot's: a dependency update arrives as +`build(deps):`, `build(deps-dev):`, or `ci(deps):`. See +[ADR 0016](../docs/adr/0016-dependency-update-policy.md). Releases are cut by [release-please](https://github.com/googleapis/release-please) from these messages. A `feat(component):` commit produces a `component/v1.x.0` @@ -155,6 +159,11 @@ Every commit must carry a `Signed-off-by` trailer: git commit -s -m "feat(component): add restartPolicy" ``` +The trailer's name and email must match the commit's author. One exception, for +a GitHub App: an app signs under its operator's address rather than the noreply +address its commits are authored from, so a bot's sign-off is matched on name +alone. See [ADR 0016](../docs/adr/0016-dependency-update-policy.md). + ## Proposing a structural change Changes to the repository architecture, the release model, or the family diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f9775e4..bd17deb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,18 @@ # - devcontainers : bumps Features in devcontainer.json AND refreshes # .devcontainer/devcontainer-lock.json (digest pins). # - github-actions : bumps the actions pinned in .github/workflows/. -# - bun : bumps tools/ dependencies (ajv, yaml, biome). +# - bun : bumps tools/ dependencies. +# +# Every `prefix` below is a Conventional Commits TYPE, optionally with a scope +# appended by `include: 'scope'` -- Dependabot emits the prefix as the type, so +# a value that is only a valid scope produces a message no gate will accept. +# `task check:commits` holds these against .github/conventional-commits.yaml; +# it was added because `prefix: 'devcontainer'` sat here wrong for months. +# +# `build`, `ci` and `chore` are all hidden in .github/release-please/config.json, +# so no dependency bump can cut a specification release. +# +# See docs/adr/0016-dependency-update-policy.md. version: 2 updates: - package-ecosystem: 'devcontainers' @@ -12,7 +23,8 @@ updates: schedule: interval: 'weekly' commit-message: - prefix: 'devcontainer' + prefix: 'build' + include: 'scope' # -> build(deps): bump ... - package-ecosystem: 'github-actions' directory: '/' @@ -20,15 +32,24 @@ updates: interval: 'weekly' commit-message: prefix: 'ci' + include: 'scope' # -> ci(deps): bump ... - package-ecosystem: 'bun' directory: '/tools' schedule: interval: 'weekly' commit-message: - prefix: 'chore' - prefix-development: 'chore' + prefix: 'build' + prefix-development: 'build' + include: 'scope' # -> build(deps-dev): bump ... groups: tooling: patterns: - '*' + # wrangler is the only dependency in this repository handed a + # credential, and GOVERNANCE.md -> Tooling dependencies rests its + # guarantee on that pin moving only through a diff someone opened + # deliberately. Inside the group it would arrive folded into somebody + # else's lockfile churn, so it is kept out and reviewed on its own. + exclude-patterns: + - 'wrangler' diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index dafe498..8f4813d 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -4,6 +4,21 @@ name: DCO # Developer Certificate of Origin sign-off on every commit. Until this workflow # existed, nothing checked it — the requirement lived entirely in prose, which # is the same as not having it. +# +# One narrow exception, for a GitHub App. The DCO certifies provenance by a +# legal person, and for an app that person is its operator, not the app: a +# Dependabot commit is authored as +# `dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>` and +# signed off by GitHub, Inc. as `dependabot[bot] `. The +# names agree; the addresses cannot, because neither field is the other's to +# set. Demanding they match rejected a sign-off that was present and valid, and +# left every dependency update mergeable only through the org-admin bypass — +# the habit docs/adr/0015-selective-code-owner-review.md was written to end. +# See docs/adr/0016-dependency-update-policy.md. +# +# This is a paper trail, not a security control, and the exception widens +# nothing: anyone who would forge a bot's sign-off can already author under a +# bot's address, or simply type the trailer themselves. on: pull_request: @@ -41,13 +56,25 @@ jobs: for sha in $commits; do author="$(git show -s --format='%an <%ae>' "$sha")" + name="$(git show -s --format='%an' "$sha")" + email="$(git show -s --format='%ae' "$sha")" subject="$(git show -s --format='%s' "$sha")" # Trailers only — a "Signed-off-by" written into the body is not a # sign-off, and %(trailers) is what git itself considers one. - if git show -s --format='%(trailers:key=Signed-off-by,valueonly)' "$sha" \ - | grep -qxF "$author"; then + trailers="$(git show -s --format='%(trailers:key=Signed-off-by,valueonly)' "$sha")" + + # A GitHub App: both halves of the identity must say so, and the + # trailer still has to name the same author. Only the address is + # allowed to differ, because only the address is out of its hands. + case "$name" in *'[bot]') bot=1 ;; *) bot=0 ;; esac + case "$email" in *'[bot]@users.noreply.github.com') ;; *) bot=0 ;; esac + + if printf '%s\n' "$trailers" | grep -qxF "$author"; then echo " ✓ ${sha:0:8} ${subject}" + elif [ "$bot" -eq 1 ] \ + && printf '%s\n' "$trailers" | sed -E 's/ *<[^>]*>$//' | grep -qxF "$name"; then + echo " ✓ ${sha:0:8} ${subject} — app sign-off by ${name}" else echo "::error::${sha:0:8} (${subject}) has no Signed-off-by matching its author ${author}" failed=1 diff --git a/.github/workflows/dependabot-title.yml b/.github/workflows/dependabot-title.yml new file mode 100644 index 0000000..4ed20b7 --- /dev/null +++ b/.github/workflows/dependabot-title.yml @@ -0,0 +1,76 @@ +name: Dependabot Title + +# Dependabot capitalises the subject of the messages it writes -- `build(deps): +# Bump ...` -- which is exactly what the `subjectPattern` in lint-pr.yml +# rejects. That capital is not configurable: dependabot-core decides it from a +# heuristic over recent commit messages, and on this repository it lands on +# "capitalise" against a history that is entirely lowercase. +# +# So the choice was to exempt the bot from the title rule or to fix the title. +# Exempting it would leave `Bump` in the log on `main` forever and carve a hole +# in a rule that otherwise has none. This lowercases the first letter instead, +# and the rule stays single. See docs/adr/0016-dependency-update-policy.md. +# +# Three things about this file are load-bearing: +# +# 1. `pull_request_target`, because a `pull_request` run on a fork-context +# Dependabot pull request gets a read-only token and cannot retitle it. +# That means this workflow runs FROM THE BASE BRANCH WITH A WRITE TOKEN, +# so it must never check out, build, or execute anything from the head -- +# and it does not: there is no checkout step at all. +# 2. The title reaches the shell through `env:`, never through a `${{ }}` +# expansion inside `run:`. Interpolating pull-request-controlled text into +# a privileged shell is the textbook injection, whoever opened it. +# 3. It is idempotent, which is what makes listening to `edited` safe: our own +# edit re-fires the event, the second run finds nothing to change and +# stops. There is no loop. +# +# It is a NORMALISER, not a gate -- `Conventional PR title` in lint-pr.yml is +# still the thing that judges the result. Expect one transient red on `opened`: +# that check and this job start together, this one edits the title, and the +# edit re-runs the check green. + +on: + pull_request_target: + types: [opened, reopened, edited] + +permissions: + pull-requests: write + +# Queue rather than cancel: a cancelled retitle would leave the pull request +# with the title the check has already rejected. +concurrency: + group: dependabot-title-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + subject: + name: Lowercase subject + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Lowercase the first letter of the subject + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + # Case only, and only after a conventional-SHAPED prefix. Whether + # the type and scope in that prefix are real is the title check's + # business, not this job's: rewriting them here would paper over a + # broken .github/dependabot.yml, which is the other half of this. + if [[ ! "$PR_TITLE" =~ ^([a-z]+(\([a-z][a-z-]*\))?!?:[[:space:]])([A-Z].*)$ ]]; then + echo "Nothing to lowercase: ${PR_TITLE}" + exit 0 + fi + + prefix="${BASH_REMATCH[1]}" + subject="${BASH_REMATCH[3]}" + fixed="${prefix}${subject,}" + + echo " ${PR_TITLE}" + echo "→ ${fixed}" + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --title "$fixed" diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 5d5b4b3..753a8c6 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -210,7 +210,12 @@ lockfile rather than fetched at deploy time, so the code that receives the token changes only through a diff someone opened deliberately — never through a resolution that moved on its own. `tools/` is not a CODEOWNERS path, so that diff is not gated on a review; the exact pin plus the lockfile is what carries -the guarantee. Nothing else in `tools/` holds a secret, and nothing published +the guarantee. For the same reason it is excluded from the grouped Dependabot +update in [`.github/dependabot.yml`](.github/dependabot.yml): folded into four +other packages' lockfile churn it would arrive as a diff nobody opened *for +it*, which is not the deliberate one this paragraph promises. It always comes +as its own pull request. See +[ADR 0016](docs/adr/0016-dependency-update-policy.md). Nothing else in `tools/` holds a secret, and nothing published derives from wrangler either. ## Security diff --git a/docs/adr/0016-dependency-update-policy.md b/docs/adr/0016-dependency-update-policy.md new file mode 100644 index 0000000..2970898 --- /dev/null +++ b/docs/adr/0016-dependency-update-policy.md @@ -0,0 +1,167 @@ +# ADR 0016: Dependency updates arrive as signed, conventional commits + +- **Status:** Accepted +- **Date:** 2026-08-25 +- **Refines:** [ADR 0015](0015-selective-code-owner-review.md) + +## Context + +Dependabot has opened five pull requests against this repository. Three were +closed unmerged. The two that were still open when this was written — #60, +bumping a dev container Feature, and #61, bumping four tools — had sat for three +days, red on the same two checks, and would have sat indefinitely. + +Neither failure was about the dependency. `Lint`, `Schema`, `Site Build` and the +whole Dev Container workflow passed on both. What rejected them was this +repository's own CI contract, in two independent ways. + +**The DCO check rejected a sign-off that was there.** Dependabot signs its +commits. It authors them as one identity and signs them as another: + +``` +author: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +trailer: Signed-off-by: dependabot[bot] +``` + +`.github/workflows/dco.yml` matched the trailer against `%an <%ae>` exactly, so +the mismatched address failed it. `Signed off` is a required status check in +`.github/rulesets/main-branch.json`, which left every dependency update in this +repository mergeable only by spending the `OrganizationAdmin` bypass — the exact +habit [ADR 0015](0015-selective-code-owner-review.md) was written to end, +recreated for a class of change nobody would think to look at. + +**The title check rejected a prefix the configuration could never have got +right.** `.github/dependabot.yml` set `prefix: 'devcontainer'`. Dependabot emits +the prefix as the Conventional Commits *type*, and `devcontainer` is a *scope* +in `.github/conventional-commits.yaml`. Every dev container update since that +line was written opened with a title no gate would accept. Nothing said so, +because `task check:commits` reconciled three copies of the vocabulary and +`dependabot.yml` was a fourth it did not read. CONTRIBUTING.md was a fifth, and +had already drifted — it was missing `deps-dev`. + +Separately, Dependabot capitalises the subject it writes (`Bump …`), which +`subjectPattern` in `lint-pr.yml` rejects. That capital is not configurable: +dependabot-core decides it from a heuristic over recent commit messages, and on +this repository it lands on "capitalise" against a history that is entirely +lowercase — 4 bot pull requests out of 4. + +## Decision + +### 1. A GitHub App's sign-off is matched on name, not address + +The DCO certifies provenance by a legal person. For a GitHub App that person is +its operator, not the app, and the operator signs under its own address — an +address the commit author field is not theirs to set, any more than the noreply +author address is theirs to sign under. The two can never match, so requiring +that they match does not test provenance; it tests a coincidence. + +`dco.yml` keeps the exact `Name ` match for everyone and adds one branch: +when *both* halves of the author identity say GitHub App — `%an` ends in +`[bot]` **and** `%ae` ends in `[bot]@users.noreply.github.com` — a +`Signed-off-by` trailer naming that same author is accepted whatever its +address. A real trailer is still required, the name must still match, and a +commit that is a bot in only one half of its identity is still rejected. + +This widens nothing that mattered. The check is a paper trail, not a security +control: anyone willing to forge a bot's sign-off could already author under a +bot's address, or simply type the trailer. + +### 2. A dependency update is `build(deps)`, `build(deps-dev)`, or `ci(deps)` + +`prefix` values in `.github/dependabot.yml` are types, and `include: 'scope'` +appends `deps` or `deps-dev` according to the dependency's own kind. That gives +the dev container and `tools/` ecosystems `build(…)` and the actions ecosystem +`ci(…)`. + +It is the vocabulary this repository already reached for: the one dependency +bump in its history is `build(deps): bump the tooling group, dropping +ajv-formats`. It also puts the `deps` and `deps-dev` scopes to work, which have +been in `conventional-commits.yaml` since the beginning with nothing producing +them. `build`, `ci` and `chore` are all `hidden: true` in +`.github/release-please/config.json`, so no dependency bump can cut a +specification release whichever of them is used. + +### 3. The capitalised subject is corrected, not excused + +`.github/workflows/dependabot-title.yml` lowercases the first letter of the +subject on a Dependabot pull request, and the title check then judges the result +like any other. The rule stays single: there is no author exempt from it, and +nothing lands on `main` reading `Bump`. + +The workflow needs `pull_request_target` — a `pull_request` run on a Dependabot +pull request holds a read-only token and cannot retitle anything — which means +it runs from the base branch holding a write token. Three properties keep that +safe, and are stated in the file so they survive editing: it never checks out +the head (there is no checkout step), the title reaches the shell through `env:` +rather than a `${{ }}` expansion inside `run:`, and it rewrites case only, after +a conventional-*shaped* prefix. Whether the type and scope in that prefix are +real remains the title check's business; correcting them here would paper over +exactly the `dependabot.yml` bug described above. + +### 4. Every copy of the vocabulary is checked, including the bot's + +`tools/src/commits.ts` now reads `.github/dependabot.yml` and +`.github/CONTRIBUTING.md` alongside the three files it already reconciled. A +prefix must parse as `` or `()` and name a type and scope the +source of truth lists; `include: 'scope'` requires `deps` and `deps-dev` to +exist. When the type it finds is a valid *scope*, the failure says so — that +being the mistake actually made. + +A `commits` job in `.config/lefthook.yml` runs the check before the push as well +as in CI. The prefixes in `dependabot.yml` are exercised once a week by a bot +nobody watches, which is the longest possible feedback loop for a typo. + +### 5. `wrangler` is excluded from the grouped update + +GOVERNANCE.md → Tooling dependencies names `wrangler` as the only dependency +here handed a credential, and rests its guarantee on that exact pin moving "only +through a diff someone opened deliberately". Grouped with everything else under +`patterns: ['*']`, it would move inside four other packages' lockfile churn — a +diff someone opened, but not one opened *for it*. `exclude-patterns` keeps it +out, so it always arrives as its own reviewable pull request. + +## Alternatives considered + +**Exempt bot pull requests from the title check** — `ignoreLabels: dependencies` +is the semantic-pull-request action's own escape hatch, and it is one line +against a workflow. Rejected: it stops validating the type and scope as well as +the case, so the `devcontainer:` bug would still have been invisible, and it +leaves `Bump` in `git log` on `main` permanently. A rule with one author exempt +is a weaker thing to maintain than a rule with none. + +**Relax `subjectPattern` for everyone** — retires a repository-wide convention +to accommodate a bot, and takes the guard away from humans too. + +**Exempt bot commits from the DCO check entirely** — simpler than matching on +name, and wrong: Dependabot's sign-off is real and there is no reason to stop +reading it. Skipping the check would also skip it for any future app whose +sign-off is genuinely absent. + +**Add `/.github/dependabot.yml` to CODEOWNERS** — would gate the configuration +on a review. Rejected as inconsistent with GOVERNANCE.md's deliberate argument +that `tools/` is not gated on a review, and that the pin plus the lockfile +carries the guarantee; +the `check:commits` guard is the control that fits that argument. + +**Split major bumps out of the `tooling` group** — #61 carries `cspell` 9 → 10 +in a grouped diff and CI passes on it, so the group is doing its job. Worth +revisiting the first time a major lands broken, not before. + +## Consequences + +Dependency updates merge on green CI without a bypass. They are correctly typed +and scoped, so they read as maintenance in `git log` and cannot cut a release. +The vocabulary now has five copies and a check that holds all five, rather than +three copies and two that drifted unobserved. `wrangler` moves only in a pull +request opened for it. + +`dependabot-title.yml` is a normaliser, not a gate. Like +`codeowners-notice.yml` it must never be added to the required checks in +`.github/rulesets/main-branch.json`. Expect one transient red on `opened`: the +title check and the normaliser start together, the normaliser edits the title, +and the edit re-runs the check green. + +## Follow-ups + +- #60 and #61 are recreated rather than force-fixed, so they come back carrying + the corrected prefixes. diff --git a/taskfiles/check.Taskfile.yml b/taskfiles/check.Taskfile.yml index f7f962d..3b2d490 100644 --- a/taskfiles/check.Taskfile.yml +++ b/taskfiles/check.Taskfile.yml @@ -106,7 +106,7 @@ tasks: # --- Prose, workflows, and shell ------------------------------------------- commits: - desc: Hold the Conventional Commits vocabulary in step across its three copies + desc: Hold the Conventional Commits vocabulary in step across all five of its copies dir: '{{.ROOT_DIR}}/{{.TOOLS_DIR}}' cmds: - bun run src/commits.ts diff --git a/tools/src/commits.ts b/tools/src/commits.ts index 1256509..e352d9e 100644 --- a/tools/src/commits.ts +++ b/tools/src/commits.ts @@ -1,12 +1,19 @@ /** - * Hold the three copies of the Conventional Commits vocabulary in step. + * Hold every copy of the Conventional Commits vocabulary in step. * * `.github/conventional-commits.yaml` calls itself the single source of truth * and says it is "consumed by .github/workflows/lint-pr.yml". It is not * consumed by anything: the workflow inlines the same lists in its `with:` * block, and the .config/lefthook.yml commit-msg hook inlines the types again - * inside a POSIX regex. Three copies, and a comment asking people to keep them - * in step. + * inside a POSIX regex. Copies, and a comment asking people to keep them in + * step. + * + * Two more copies were found the expensive way. `.github/dependabot.yml` + * writes commit prefixes, and it carried `prefix: 'devcontainer'` -- a scope + * where a type belongs -- for months, so every devcontainer update opened with + * a title no gate would accept and nothing said why. CONTRIBUTING.md prints + * both lists for a human and had already drifted, missing `deps-dev`. Neither + * was checked, because the check knew about three files. * * That is the same defect as a ruleset file documenting a rule it does not * carry — a claim a reader will believe and not think to check. The lists @@ -29,6 +36,11 @@ import { Failures, REPO_ROOT } from './spec.ts' const SOURCE = join(REPO_ROOT, '.github', 'conventional-commits.yaml') const WORKFLOW = join(REPO_ROOT, '.github', 'workflows', 'lint-pr.yml') const HOOKS = join(REPO_ROOT, '.config', 'lefthook.yml') +const DEPENDABOT = join(REPO_ROOT, '.github', 'dependabot.yml') +const CONTRIBUTING = join(REPO_ROOT, '.github', 'CONTRIBUTING.md') + +/** The scopes `include: 'scope'` appends, by the dependency's own type. */ +const DEPENDABOT_SCOPES = ['deps', 'deps-dev'] as const /** A `key:` followed by an indented `- item` list, in a small YAML file. */ function yamlList(source: string, key: string): string[] { @@ -69,6 +81,36 @@ function hookTypes(source: string): string[] { return match?.[1] === undefined ? [] : match[1].split('|') } +/** Every commit-message prefix Dependabot is configured to write. */ +function dependabotPrefixes(source: string): string[] { + const values: string[] = [] + for (const line of source.split('\n')) { + const text = line.trim() + if (text.startsWith('#')) continue + const match = /^(?:prefix|prefix-development):\s*(.+?)\s*(?:#.*)?$/.exec(text) + if (match?.[1] !== undefined) values.push(match[1].replace(/^['"]|['"]$/g, '')) + } + return values +} + +/** Whether any entry asks Dependabot to append `(deps)` / `(deps-dev)`. */ +function dependabotIncludesScope(source: string): boolean { + return source.split('\n').some((line) => /^include:\s*['"]?scope['"]?/.test(line.trim())) +} + +/** A prose `Label: `a`, `b`, `c`.` run, as CONTRIBUTING.md prints one. */ +function proseList(source: string, label: string): string[] { + const lines = source.split('\n') + const start = lines.findIndex((line) => line.startsWith(`${label}:`)) + if (start < 0) return [] + const run: string[] = [] + for (const line of lines.slice(start)) { + if (line.trim() === '') break + run.push(line) + } + return [...run.join(' ').matchAll(/`([^`]+)`/g)].map((match) => match[1] as string) +} + function compare( label: string, expected: readonly string[], @@ -114,9 +156,56 @@ function main(): void { failures, ) + const contributing = readFileSync(CONTRIBUTING, 'utf8') + compare('CONTRIBUTING.md types', types, proseList(contributing, 'Types'), failures) + compare('CONTRIBUTING.md scopes', scopes, proseList(contributing, 'Scopes'), failures) + + // Dependabot emits `commit-message.prefix` as the TYPE, so a value that is + // only a valid scope produces a message every gate rejects. Checked as a + // prefix rather than compared as a list: the file names a few of the + // vocabulary, not all of it. + const dependabot = readFileSync(DEPENDABOT, 'utf8') + const prefixes = dependabotPrefixes(dependabot) + if (prefixes.length === 0) { + failures.add( + ".github/dependabot.yml declares no commit-message prefix — the file's shape changed", + ) + } + for (const prefix of prefixes) { + const parsed = /^([a-z]+)(?:\(([a-z][a-z-]*)\))?$/.exec(prefix) + if (parsed === null) { + failures.add(`.github/dependabot.yml: prefix '${prefix}' is not a or ()`) + continue + } + const [, type, scope] = parsed + if (type !== undefined && !types.includes(type)) { + const hint = scopes.includes(type) ? ` — it is a scope, not a type` : '' + failures.add( + `.github/dependabot.yml: prefix '${prefix}' has type '${type}', ` + + `which conventional-commits.yaml does not list${hint}`, + ) + } + if (scope !== undefined && !scopes.includes(scope)) { + failures.add( + `.github/dependabot.yml: prefix '${prefix}' has scope '${scope}', ` + + `which conventional-commits.yaml does not list`, + ) + } + } + if (dependabotIncludesScope(dependabot)) { + const absent = DEPENDABOT_SCOPES.filter((scope) => !scopes.includes(scope)) + if (absent.length > 0) { + failures.add( + `.github/dependabot.yml asks for include: 'scope', which writes ` + + `${DEPENDABOT_SCOPES.join(' and ')} — conventional-commits.yaml does not ` + + `list ${absent.join(' or ')}`, + ) + } + } + failures.report( - `Conventional Commits vocabulary agrees across 3 file(s): ${types.length} type(s), ` + - `${scopes.length} scope(s).`, + `Conventional Commits vocabulary agrees across 5 file(s): ${types.length} type(s), ` + + `${scopes.length} scope(s), ${prefixes.length} Dependabot prefix(es).`, ) }