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
33 changes: 33 additions & 0 deletions .devtools/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ 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.
echo "pr-checks/lint.mjs"
lint=(node "${here}/../.github/actions/pr-checks/lint.mjs")
[[ -d "${here}/../.github/actions/pr-checks/node_modules" ]] || {
echo "✖ run: npm ci --prefix .github/actions/pr-checks --ignore-scripts" >&2
exit 2
}
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[@]}" \
'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 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'
expect pass "accepts any scope without a config" "${lint[@]}" 'feat(anything): add a thing'

mkdir "${work}/scoped"
cd "${work}/scoped"
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'

# 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
printf 'require("fs").writeFileSync("%s/RAN", ""); module.exports = {};\n' "${work}" >run.cjs
expect pass "lints with a config that names a script" "${lint[@]}" 'feat: add a thing'
expect fail "and never runs the script" test -e "${work}/RAN"
cd "${work}/repo"

echo "check-pr-size"
size="${here}/check-pr-size.sh"
git switch --quiet -c feat/small
Expand Down
13 changes: 10 additions & 3 deletions .github/actions/pr-checks/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,18 @@ runs:
# lives in, and the runner unpacks the whole repo -- so .devtools/ is already on
# disk three levels up, with no second checkout, and the scripts always match the
# commit the caller resolved.

# commitlint, at the versions package-lock.json records. No install scripts run,
# and the runner's own Node is enough (22.12 or newer).
- 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
env:
TITLE: ${{ inputs.title }}
run: '${GITHUB_ACTION_PATH}/../../../.devtools/check-conventional-commit.sh --label ''pull request title'' "${TITLE}"'
run: 'node "${GITHUB_ACTION_PATH}/lint.mjs" --label ''pull request title'' "${TITLE}"'

- shell: bash
env:
Expand All @@ -53,12 +61,11 @@ runs:
BASE: ${{ inputs.base-sha }}
HEAD: ${{ inputs.head-sha }}
run: |
check="${GITHUB_ACTION_PATH}/../../../.devtools/check-conventional-commit.sh"
status=0
# Merges are generated by GitHub, so only the commits a person wrote are checked.
while read -r sha; do
[ -n "${sha}" ] || continue
if ! "${check}" --label "commit ${sha:0:8}" "$(git log -1 --format=%s "${sha}")"; then
if ! node "${GITHUB_ACTION_PATH}/lint.mjs" --label "commit ${sha:0:8}" "$(git log -1 --format=%B "${sha}")"; then
status=1
fi
done <<< "$(git rev-list --no-merges "${BASE}".."${HEAD}")"
Expand Down
21 changes: 21 additions & 0 deletions .github/actions/pr-checks/commitlint.default.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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]
61 changes: 61 additions & 0 deletions .github/actions/pr-checks/lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Lints one message with commitlint, against commitlint.default.yaml 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.
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "yaml";

const here = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
let label = "commit message";
if (args[0] === "--label") {
label = args[1];
args.splice(0, 2);
}
const message = args[0] ?? "";

const config = parse(readFileSync(join(here, "commitlint.default.yaml"), "utf8"));
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);
}

const work = mkdtempSync(join(tmpdir(), "commitlint-"));
let result;
try {
const file = join(work, "commitlint.json");
writeFileSync(file, JSON.stringify(config));
result = spawnSync(
process.execPath,
[join(here, "node_modules/@commitlint/cli/cli.js"), "--config", file],
{ cwd: work, input: `${message}\n`, encoding: "utf8" },
);
} finally {
rmSync(work, { recursive: true, force: true });
}

if (result.error) {
console.error(`✖ cannot run commitlint: ${result.error.message}. Was \`npm ci\` run in ${here}?`);
process.exit(2);
}
if (result.status !== 0) {
console.error(`✖ Not a valid ${label}${own ? ` (rules: ${own})` : ""}:\n`);
console.error(`${result.stdout}${result.stderr}`.trimEnd());
process.exit(result.status === 1 ? 1 : 2);
}
Loading
Loading