Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .devtools/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]] || {
Expand All @@ -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'
Expand All @@ -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
Expand Down
19 changes: 16 additions & 3 deletions .github/actions/pr-checks/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"'
Expand All @@ -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
Expand Down
21 changes: 0 additions & 21 deletions .github/actions/pr-checks/commitlint.default.yaml

This file was deleted.

15 changes: 10 additions & 5 deletions .github/actions/pr-checks/lint.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
#!/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 <what>] <message>
//
// 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";
Expand All @@ -25,15 +26,19 @@ 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 ?? {};
if (typeof rules !== "object" || Array.isArray(rules)) {
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-"));
Expand Down
1 change: 1 addition & 0 deletions .github/actions/pr-checks/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .github/actions/pr-checks/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/ci-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }}
Expand Down
21 changes: 14 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading