From 04a860fe56c6650c8ac5f4198f665e61fe5aae13 Mon Sep 17 00:00:00 2001 From: david morales <118213696+davidmoralesz@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:07:07 -0600 Subject: [PATCH] fix(actions): lint pull requests against config-conventional pr-checks linted against a hand-written rule set. It now uses @commitlint/config-conventional, installed from the same lockfile, with the caller's rules on top. commitlint.default.yaml is gone. config-conventional caps the header and body lines at 100 characters, which Dependabot's titles and commit bodies exceed, so what Dependabot authors is skipped. --- .devtools/test-hooks.sh | 10 +++++---- .github/actions/pr-checks/action.yml | 19 ++++++++++++++--- .../actions/pr-checks/commitlint.default.yaml | 21 ------------------- .github/actions/pr-checks/lint.mjs | 15 ++++++++----- .github/actions/pr-checks/package-lock.json | 1 + .github/actions/pr-checks/package.json | 3 ++- .github/workflows/ci-pr.yml | 7 ++++--- README.md | 21 ++++++++++++------- 8 files changed, 53 insertions(+), 44 deletions(-) delete mode 100644 .github/actions/pr-checks/commitlint.default.yaml diff --git a/.devtools/test-hooks.sh b/.devtools/test-hooks.sh index d6a9290..a278b9d 100755 --- a/.devtools/test-hooks.sh +++ b/.devtools/test-hooks.sh @@ -69,7 +69,7 @@ printf 'handled an empty diff\n' >"${work}/MSG_BAD" expect pass "reads a good message with --file" "${commit}" --file "${work}/MSG_OK" expect fail "reads a bad message with --file" "${commit}" --file "${work}/MSG_BAD" -# The repo fixture has no .commitlintrc.yaml, so these run on the default rules alone. +# The repo fixture has no .commitlintrc.yaml, so these run on config-conventional alone. echo "pr-checks/lint.mjs" lint=(node "${here}/../.github/actions/pr-checks/lint.mjs") [[ -d "${here}/../.github/actions/pr-checks/node_modules" ]] || { @@ -79,9 +79,11 @@ lint=(node "${here}/../.github/actions/pr-checks/lint.mjs") expect pass "accepts a scoped subject" "${lint[@]}" 'feat(api): add cursor pagination' expect pass "accepts a breaking change" "${lint[@]}" 'fix!: drop support for Node 18' expect pass "accepts a generated merge" "${lint[@]}" "Merge branch 'main' into feat/x" -expect pass "accepts a long Dependabot title" "${lint[@]}" \ +expect pass "accepts a body" "${lint[@]}" "$(cat "${work}/MSG_OK")" +expect fail "rejects a header over 100 characters" "${lint[@]}" \ 'chore(deps): bump check-jsonschema from 0.38.0 to 0.38.2 in /.devtools in the devtools group across 1 directory' -expect pass "accepts any body" "${lint[@]}" "$(cat "${work}/MSG_OK")" +expect fail "rejects a capitalised subject" "${lint[@]}" 'feat(api): Add cursor pagination' +expect fail "rejects a subject ending in a full stop" "${lint[@]}" 'feat(api): add cursor pagination.' expect fail "rejects an unknown type" "${lint[@]}" 'feature: add pagination' expect fail "rejects a missing description" "${lint[@]}" 'feat:' expect fail "rejects a subject with no type" "${lint[@]}" 'handled an empty diff' @@ -93,7 +95,7 @@ printf 'rules:\n scope-enum: [2, always, [api, docs]]\n' >.commitlintrc.yaml expect pass "accepts a scope the repository lists" "${lint[@]}" 'feat(api): add a thing' expect pass "accepts no scope" "${lint[@]}" 'feat: add a thing' expect fail "rejects a scope the repository does not list" "${lint[@]}" 'feat(nope): add a thing' -expect fail "keeps the default rules next to the repository's" "${lint[@]}" 'feature(api): add a thing' +expect fail "keeps config-conventional next to the repository's rules" "${lint[@]}" 'feature(api): add a thing' # A config can name JavaScript to load. On a pull request that file is the author's. printf 'extends: [./run.cjs]\nplugins: [./run.cjs]\nparserPreset: ./run.cjs\n' >.commitlintrc.yaml diff --git a/.github/actions/pr-checks/action.yml b/.github/actions/pr-checks/action.yml index db01ed6..86c8c68 100644 --- a/.github/actions/pr-checks/action.yml +++ b/.github/actions/pr-checks/action.yml @@ -8,6 +8,12 @@ inputs: branch: description: The pull request's head branch name. required: true + author: + description: >- + Login of the pull request's author. Dependabot's titles and commit bodies are not + ours to shorten, so its pull requests are not linted. Empty lints everything. + required: false + default: '' base-sha: description: SHA of the pull request's base. For a stacked PR this is the layer below it. required: true @@ -36,9 +42,15 @@ runs: - shell: bash run: npm ci --prefix "${GITHUB_ACTION_PATH}" --ignore-scripts --no-audit --no-fund - # lint.mjs adds the `rules` of the caller's .commitlintrc.yaml, if it has one, to - # commitlint.default.yaml. It reads that file as data and runs nothing from it. - - shell: bash + # lint.mjs lints against @commitlint/config-conventional plus the `rules` of the + # caller's .commitlintrc.yaml, if it has one. It reads that file as data and runs + # nothing from it. + # + # config-conventional caps the header and each body line at 100 characters. + # Dependabot writes longer ones and offers no way to shorten them, so what it + # authors is skipped: its pull request's title here, its commits below. + - if: inputs.author != 'dependabot[bot]' + shell: bash env: TITLE: ${{ inputs.title }} run: 'node "${GITHUB_ACTION_PATH}/lint.mjs" --label ''pull request title'' "${TITLE}"' @@ -65,6 +77,7 @@ runs: # Merges are generated by GitHub, so only the commits a person wrote are checked. while read -r sha; do [ -n "${sha}" ] || continue + [ "$(git log -1 --format=%an "${sha}")" != 'dependabot[bot]' ] || continue if ! node "${GITHUB_ACTION_PATH}/lint.mjs" --label "commit ${sha:0:8}" "$(git log -1 --format=%B "${sha}")"; then status=1 fi diff --git a/.github/actions/pr-checks/commitlint.default.yaml b/.github/actions/pr-checks/commitlint.default.yaml deleted file mode 100644 index b5818a3..0000000 --- a/.github/actions/pr-checks/commitlint.default.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# The rules every pull request is held to. A repository adds its own in a -# .commitlintrc.yaml at its root, usually `scope-enum`; a rule named there replaces -# the one of the same name here. -# -# There is no `extends`, so the commitlint CLI is the only package this needs. - -# commitlint's built-in pattern does not accept the `!` that marks a breaking change. -parserPreset: - parserOpts: - headerPattern: '^(\w*)(?:\(([^)]*)\))?!?: (.*)$' - headerCorrespondence: [type, scope, subject] - -rules: - type-enum: - - 2 - - always - - [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] - type-case: [2, always, lower-case] - type-empty: [2, never] - scope-case: [2, always, lower-case] - subject-empty: [2, never] diff --git a/.github/actions/pr-checks/lint.mjs b/.github/actions/pr-checks/lint.mjs index afbf1e4..ef7b562 100755 --- a/.github/actions/pr-checks/lint.mjs +++ b/.github/actions/pr-checks/lint.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Lints one message with commitlint, against commitlint.default.yaml plus the rules -// of the repository it runs in. +// Lints one message with commitlint, against @commitlint/config-conventional plus the +// rules of the repository it runs in. // // Usage, from the root of the repository being checked: // lint.mjs [--label ] @@ -8,7 +8,8 @@ // Only the `rules` of the repository's .commitlintrc.yaml are read. Its `extends`, // `plugins` and `parserPreset` can each name a JavaScript file, and on a pull request // that file is the author's: commitlint is given a config built here and run from an -// empty directory, so nothing from the pull request is ever executed. +// empty directory, so nothing from the pull request is ever executed. A repository +// that extends config-conventional itself loses nothing, since that is the base here. import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -25,7 +26,11 @@ if (args[0] === "--label") { } const message = args[0] ?? ""; -const config = parse(readFileSync(join(here, "commitlint.default.yaml"), "utf8")); +// By path, because commitlint runs from an empty directory with nothing to resolve from. +const config = { + extends: [fileURLToPath(import.meta.resolve("@commitlint/config-conventional"))], + rules: {}, +}; const own = [".commitlintrc.yaml", ".commitlintrc.yml"].find((file) => existsSync(file)); if (own) { const rules = parse(readFileSync(own, "utf8"))?.rules ?? {}; @@ -33,7 +38,7 @@ if (own) { console.error(`✖ ${own}: \`rules\` has to be a mapping of rule name to setting`); process.exit(2); } - Object.assign(config.rules, rules); + config.rules = rules; } const work = mkdtempSync(join(tmpdir(), "commitlint-")); diff --git a/.github/actions/pr-checks/package-lock.json b/.github/actions/pr-checks/package-lock.json index f2b2280..410835a 100644 --- a/.github/actions/pr-checks/package-lock.json +++ b/.github/actions/pr-checks/package-lock.json @@ -7,6 +7,7 @@ "name": "pr-checks", "dependencies": { "@commitlint/cli": "21.2.3", + "@commitlint/config-conventional": "21.2.3", "yaml": "2.9.1" }, "engines": { diff --git a/.github/actions/pr-checks/package.json b/.github/actions/pr-checks/package.json index b9201e1..d9de5dc 100644 --- a/.github/actions/pr-checks/package.json +++ b/.github/actions/pr-checks/package.json @@ -1,10 +1,11 @@ { "name": "pr-checks", "private": true, - "description": "Pins commitlint for the pr-checks action. The action installs this with `npm ci`.", + "description": "Pins commitlint and its conventional config for the pr-checks action. The action installs this with `npm ci`.", "type": "module", "dependencies": { "@commitlint/cli": "21.2.3", + "@commitlint/config-conventional": "21.2.3", "yaml": "2.9.1" }, "engines": { diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index a526a1c..9896718 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -11,9 +11,9 @@ name: ci-pr # always match the version the caller pinned, with no second checkout and no # self-referential SHA to keep updating. # -# Titles and commits are linted with commitlint. A repository narrows the rules, most -# often to its own scopes, with a .commitlintrc.yaml at its root; only `rules` is read -# from it. +# Titles and commits are linted with commitlint, against @commitlint/config-conventional. +# A repository adds to the rules, most often its own scopes, with a .commitlintrc.yaml +# at its root; only `rules` is read from it. Dependabot's pull requests are not linted. # # Nothing here reads a secret, and the pull request's code is only ever read as # data, by git and by the YAML parser, never executed. @@ -52,6 +52,7 @@ jobs: with: title: ${{ github.event.pull_request.title }} branch: ${{ github.head_ref }} + author: ${{ github.event.pull_request.user.login }} base-sha: ${{ github.event.pull_request.base.sha }} head-sha: ${{ github.event.pull_request.head.sha }} max-files: ${{ inputs.max-files }} diff --git a/README.md b/README.md index ecdbf69..0953b6b 100644 --- a/README.md +++ b/README.md @@ -95,20 +95,27 @@ jobs: ``` `ci-pr` lints the pull request title and every commit with -[commitlint](https://commitlint.js.org/). With nothing else, that means a known type, an optional -lowercase scope and a description. A repository tightens it with a `.commitlintrc.yaml` at its -root, most often to make its own scopes the only ones allowed: +[commitlint](https://commitlint.js.org/), against +[`@commitlint/config-conventional`](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional): +a known type, a lowercase subject with no full stop, and at most 100 characters in the header and +in each body line. A repository adds its own rules with a `.commitlintrc.yaml` at its root, most +often to make its scopes the only ones allowed: ```yaml # .commitlintrc.yaml +extends: + - "@commitlint/config-conventional" rules: scope-enum: [2, always, [api, docs, deps]] ``` -Only `rules` is read from that file, and a rule named there replaces the default of the same -name. `extends`, `plugins` and `parserPreset` are ignored, because each can name a script and on -a pull request that script would be the author's. The defaults are in -[`commitlint.default.yaml`](.github/actions/pr-checks/commitlint.default.yaml). +Only `rules` is read from that file on a pull request. `extends`, `plugins` and `parserPreset` +are ignored there, because each can name a script and that script would be the author's; the +base is always config-conventional. The `extends` line is for the local `commit-msg` hook, which +reads the whole file. + +Dependabot's pull requests are not linted. Its titles and commit bodies run past 100 characters +and it offers no way to shorten them. A **composite action** is different — reference it by full path and full SHA: