From 7a4d81b46db681681d90212d80510d1549e3d5cb Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sat, 10 Oct 2026 18:14:19 +0900 Subject: [PATCH 1/3] feat(github.pr.lint-title): add composite action to lint pull request titles --- .../actions/github.pr.lint-title/action.yaml | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/actions/github.pr.lint-title/action.yaml diff --git a/.github/actions/github.pr.lint-title/action.yaml b/.github/actions/github.pr.lint-title/action.yaml new file mode 100644 index 0000000..a563616 --- /dev/null +++ b/.github/actions/github.pr.lint-title/action.yaml @@ -0,0 +1,105 @@ +name: GitHub Pull Request - Lint Title +description: "Check that the pull request title follows Conventional Commits (`type(scope)!: subject`). With squash merges the title becomes the commit message, which release tools such as `release-please` read to compute the next version. Run it on the `opened`, `edited`, `reopened` and `synchronize` events so that title changes are checked." + + +inputs: + title: + required: false + default: ${{ github.event.pull_request.title }} + description: "(Optional) The pull request title to check. Defaults to the title of the pull request that triggered the workflow." + types: + required: false + default: | + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + description: "(Optional) The allowed types, one per line. Defaults to the default types of `conventional-pre-commit`: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style` and `test`." + scope_required: + required: false + default: "false" + description: "(Optional) Whether a scope such as `feat(iam-role): ...` is required. Defaults to `false`." + +outputs: + type: + value: ${{ steps.lint.outputs.type }} + description: "The type of the title (e.g. `feat`)." + scope: + value: ${{ steps.lint.outputs.scope }} + description: "The scope of the title without parentheses (e.g. `iam-role`). Empty if the title has no scope." + breaking: + value: ${{ steps.lint.outputs.breaking }} + description: "Whether the title marks a breaking change with `!`. `true` or `false`." + subject: + value: ${{ steps.lint.outputs.subject }} + description: "The subject of the title after `: `." + + +runs: + using: composite + + steps: + - name: Lint Pull Request Title + id: lint + shell: bash + env: + TITLE: ${{ inputs.title }} + TYPES: ${{ inputs.types }} + SCOPE_REQUIRED: ${{ inputs.scope_required }} + run: | + # Escape a value for a workflow command such as `::error::`. + escape() { + local value="${1//'%'/'%25'}" + value="${value//$'\r'/'%0D'}" + printf '%s' "${value//$'\n'/'%0A'}" + } + + # Join the allowed types into a regex alternation. Only plain words are accepted so that a type cannot inject regex syntax. + types="" + while IFS= read -r type; do + type="${type//[[:space:]]/}" + if [ -z "$type" ]; then + continue + fi + if [[ ! "$type" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "::error title=Invalid input::$(escape "The type '$type' in \`types\` must consist of letters, digits, \`_\` or \`-\`.")" + exit 1 + fi + types="${types:+$types|}$type" + done <<< "$TYPES" + if [ -z "$types" ]; then + echo "::error title=Invalid input::\`types\` must list at least one type, one per line." + exit 1 + fi + + # Groups: 1 = type, 3 = scope, 4 = `!`, 5 = subject. + pattern="^(${types})(\(([^()[:space:]]+)\))?(!)?: ([^[:space:]].*)$" + expected="\`type(scope)!: subject\` with one of the types: ${types//|/, }" + if [ "$SCOPE_REQUIRED" = "true" ]; then + expected="${expected}. A scope is required" + fi + + if [[ ! "$TITLE" =~ $pattern ]] || { [ "$SCOPE_REQUIRED" = "true" ] && [ -z "${BASH_REMATCH[3]}" ]; }; then + echo "::error title=Invalid pull request title::$(escape "The title '$TITLE' does not follow Conventional Commits. Expected ${expected}.")" + exit 1 + fi + + breaking=false + if [ -n "${BASH_REMATCH[4]}" ]; then + breaking=true + fi + delimiter="$(head -c 15 /dev/urandom | base64)" + { + printf 'type=%s\n' "${BASH_REMATCH[1]}" + printf 'scope=%s\n' "${BASH_REMATCH[3]}" + printf 'breaking=%s\n' "$breaking" + printf 'subject<<%s\n%s\n%s\n' "$delimiter" "${BASH_REMATCH[5]}" "$delimiter" + } >> "$GITHUB_OUTPUT" + echo "The title follows Conventional Commits (type: ${BASH_REMATCH[1]}, scope: ${BASH_REMATCH[3]:-none}, breaking: ${breaking})." From 9597703fe1266fad69b662c1e5a78610d6dc7da5 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sat, 10 Oct 2026 23:46:57 +0900 Subject: [PATCH 2/3] feat(github.pr.lint-title): lint pull request titles with commitlint --- .../actions/github.pr.lint-title/action.yaml | 130 ++++++++++-------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/.github/actions/github.pr.lint-title/action.yaml b/.github/actions/github.pr.lint-title/action.yaml index a563616..a5d5bfe 100644 --- a/.github/actions/github.pr.lint-title/action.yaml +++ b/.github/actions/github.pr.lint-title/action.yaml @@ -1,42 +1,35 @@ name: GitHub Pull Request - Lint Title -description: "Check that the pull request title follows Conventional Commits (`type(scope)!: subject`). With squash merges the title becomes the commit message, which release tools such as `release-please` read to compute the next version. Run it on the `opened`, `edited`, `reopened` and `synchronize` events so that title changes are checked." +description: "Lint the pull request title with `commitlint`. With squash merges the title becomes the commit message, which release tools such as `release-please` read to compute the next version. Uses the repository's commitlint config when it exists, so titles follow the same rules as local commits; otherwise `@commitlint/config-conventional`. Run it on the `opened`, `edited`, `reopened` and `synchronize` events of `pull_request` so that title changes are checked." inputs: title: required: false default: ${{ github.event.pull_request.title }} - description: "(Optional) The pull request title to check. Defaults to the title of the pull request that triggered the workflow." - types: + description: "(Optional) The pull request title to lint. Defaults to the title of the pull request that triggered the workflow." + config_file: required: false - default: | - build - chore - ci - docs - feat - fix - perf - refactor - revert - style - test - description: "(Optional) The allowed types, one per line. Defaults to the default types of `conventional-pre-commit`: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style` and `test`." - scope_required: + default: ".commitlintrc.yaml" + description: "(Optional) The commitlint config file, relative to the workspace. It is used only when the file exists, so check out the repository first to use it; otherwise `@commitlint/config-conventional` is used. Never check out the head of a pull request in a `pull_request_target` workflow to read it, because a config can load code from the repository. Defaults to `.commitlintrc.yaml`." + commitlint_version: required: false - default: "false" - description: "(Optional) Whether a scope such as `feat(iam-role): ...` is required. Defaults to `false`." + default: "21.2.3" + description: "(Optional) The version of the `@commitlint` packages to install. Keep it equal to the version of the local `commit-msg` hook so that both apply the same rules. Defaults to `21.2.3`." + node_version: + required: false + default: "24" + description: "(Optional) The version of Node.js to set up. Defaults to `24`." outputs: type: value: ${{ steps.lint.outputs.type }} - description: "The type of the title (e.g. `feat`)." + description: "The type of the title (e.g. `feat`), as parsed by commitlint." scope: value: ${{ steps.lint.outputs.scope }} description: "The scope of the title without parentheses (e.g. `iam-role`). Empty if the title has no scope." breaking: value: ${{ steps.lint.outputs.breaking }} - description: "Whether the title marks a breaking change with `!`. `true` or `false`." + description: "Whether the title marks a breaking change (e.g. `feat!: ...`), as parsed by commitlint. `true` or `false`." subject: value: ${{ steps.lint.outputs.subject }} description: "The subject of the title after `: `." @@ -46,13 +39,37 @@ runs: using: composite steps: + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node_version }} + + - name: Install commitlint + id: install + shell: bash + env: + COMMITLINT_VERSION: ${{ inputs.commitlint_version }} + run: | + if [[ ! "$COMMITLINT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error title=Invalid input::\`commitlint_version\` must be an exact version such as \`21.2.3\`." + exit 1 + fi + + prefix="$RUNNER_TEMP/github-pr-lint-title" + npm install --prefix "$prefix" --no-save --no-audit --no-fund --ignore-scripts \ + "@commitlint/cli@${COMMITLINT_VERSION}" \ + "@commitlint/config-conventional@${COMMITLINT_VERSION}" \ + "@commitlint/load@${COMMITLINT_VERSION}" \ + "@commitlint/parse@${COMMITLINT_VERSION}" + echo "prefix=$prefix" >> "$GITHUB_OUTPUT" + - name: Lint Pull Request Title id: lint shell: bash env: TITLE: ${{ inputs.title }} - TYPES: ${{ inputs.types }} - SCOPE_REQUIRED: ${{ inputs.scope_required }} + CONFIG_FILE: ${{ inputs.config_file }} + PREFIX: ${{ steps.install.outputs.prefix }} run: | # Escape a value for a workflow command such as `::error::`. escape() { @@ -61,45 +78,40 @@ runs: printf '%s' "${value//$'\n'/'%0A'}" } - # Join the allowed types into a regex alternation. Only plain words are accepted so that a type cannot inject regex syntax. - types="" - while IFS= read -r type; do - type="${type//[[:space:]]/}" - if [ -z "$type" ]; then - continue - fi - if [[ ! "$type" =~ ^[A-Za-z0-9_-]+$ ]]; then - echo "::error title=Invalid input::$(escape "The type '$type' in \`types\` must consist of letters, digits, \`_\` or \`-\`.")" - exit 1 - fi - types="${types:+$types|}$type" - done <<< "$TYPES" - if [ -z "$types" ]; then - echo "::error title=Invalid input::\`types\` must list at least one type, one per line." - exit 1 + config="$CONFIG_FILE" + if [ -z "$config" ] || [ ! -f "$config" ]; then + config="$PREFIX/commitlintrc.yaml" + printf 'extends:\n- "@commitlint/config-conventional"\n' > "$config" + echo "Using \`@commitlint/config-conventional\` because \`${CONFIG_FILE}\` does not exist." fi + # Resolve `extends` and parser presets from the installed packages, wherever the config file is. + export NODE_PATH="$PREFIX/node_modules" - # Groups: 1 = type, 3 = scope, 4 = `!`, 5 = subject. - pattern="^(${types})(\(([^()[:space:]]+)\))?(!)?: ([^[:space:]].*)$" - expected="\`type(scope)!: subject\` with one of the types: ${types//|/, }" - if [ "$SCOPE_REQUIRED" = "true" ]; then - expected="${expected}. A scope is required" + exitcode=0 + result="$(printf '%s\n' "$TITLE" | "$PREFIX/node_modules/.bin/commitlint" --config "$config" 2>&1)" || exitcode=$? + if [ -n "$result" ]; then + echo "$result" fi - - if [[ ! "$TITLE" =~ $pattern ]] || { [ "$SCOPE_REQUIRED" = "true" ] && [ -z "${BASH_REMATCH[3]}" ]; }; then - echo "::error title=Invalid pull request title::$(escape "The title '$TITLE' does not follow Conventional Commits. Expected ${expected}.")" + if [ "$exitcode" -ne 0 ]; then + echo "::error title=Invalid pull request title::$(escape "The title '$TITLE' does not pass commitlint:"$'\n'"$result")" exit 1 fi - breaking=false - if [ -n "${BASH_REMATCH[4]}" ]; then - breaking=true - fi - delimiter="$(head -c 15 /dev/urandom | base64)" - { - printf 'type=%s\n' "${BASH_REMATCH[1]}" - printf 'scope=%s\n' "${BASH_REMATCH[3]}" - printf 'breaking=%s\n' "$breaking" - printf 'subject<<%s\n%s\n%s\n' "$delimiter" "${BASH_REMATCH[5]}" "$delimiter" - } >> "$GITHUB_OUTPUT" - echo "The title follows Conventional Commits (type: ${BASH_REMATCH[1]}, scope: ${BASH_REMATCH[3]:-none}, breaking: ${breaking})." + # Parse the title with the same parser preset as the lint, and expose the parts as outputs. + cat > "$PREFIX/parse.mjs" <<'EOF' + import { appendFileSync } from "node:fs"; + import { randomUUID } from "node:crypto"; + import load from "@commitlint/load"; + import parse from "@commitlint/parse"; + + const config = await load({}, { file: process.env.CONFIG }); + const parsed = await parse(process.env.TITLE, undefined, config.parserPreset?.parserOpts); + const breaking = parsed.notes.some((note) => /^BREAKING[ -]CHANGE$/.test(note.title)); + const outputs = { type: parsed.type, scope: parsed.scope, breaking: String(breaking), subject: parsed.subject }; + for (const [key, value] of Object.entries(outputs)) { + const delimiter = `ghadelimiter_${randomUUID()}`; + appendFileSync(process.env.GITHUB_OUTPUT, `${key}<<${delimiter}\n${value ?? ""}\n${delimiter}\n`); + } + console.log(`The title passes commitlint (type: ${outputs.type}, scope: ${outputs.scope || "none"}, breaking: ${outputs.breaking}).`); + EOF + CONFIG="$config" node "$PREFIX/parse.mjs" From 4f5d589241602033fc37aa77c524eb9ea11eabf7 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sat, 10 Oct 2026 23:57:25 +0900 Subject: [PATCH 3/3] feat(github.pr.lint-title): install commitlint with mise at the repository's version --- .../actions/github.pr.lint-title/action.yaml | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/.github/actions/github.pr.lint-title/action.yaml b/.github/actions/github.pr.lint-title/action.yaml index a5d5bfe..3cc71bd 100644 --- a/.github/actions/github.pr.lint-title/action.yaml +++ b/.github/actions/github.pr.lint-title/action.yaml @@ -1,5 +1,5 @@ name: GitHub Pull Request - Lint Title -description: "Lint the pull request title with `commitlint`. With squash merges the title becomes the commit message, which release tools such as `release-please` read to compute the next version. Uses the repository's commitlint config when it exists, so titles follow the same rules as local commits; otherwise `@commitlint/config-conventional`. Run it on the `opened`, `edited`, `reopened` and `synchronize` events of `pull_request` so that title changes are checked." +description: "Lint the pull request title with `commitlint`. With squash merges the title becomes the commit message, which release tools such as `release-please` read to compute the next version. Installs commitlint with mise at the version of `npm:@commitlint/cli` in the repository's mise config, and uses the repository's commitlint config, so titles follow the same rules as local commits. Run it on the `opened`, `edited`, `reopened` and `synchronize` events of `pull_request` so that title changes are checked." inputs: @@ -13,14 +13,12 @@ inputs: description: "(Optional) The commitlint config file, relative to the workspace. It is used only when the file exists, so check out the repository first to use it; otherwise `@commitlint/config-conventional` is used. Never check out the head of a pull request in a `pull_request_target` workflow to read it, because a config can load code from the repository. Defaults to `.commitlintrc.yaml`." commitlint_version: required: false - default: "21.2.3" - description: "(Optional) The version of the `@commitlint` packages to install. Keep it equal to the version of the local `commit-msg` hook so that both apply the same rules. Defaults to `21.2.3`." - node_version: - required: false - default: "24" - description: "(Optional) The version of Node.js to set up. Defaults to `24`." + description: "(Optional) The version of the `@commitlint` packages to install. Defaults to the version of `npm:@commitlint/cli` in the repository's mise config (check out the repository first), or `21.2.3` if it is not set there." outputs: + version: + value: ${{ steps.install.outputs.version }} + description: "The version of the `@commitlint` packages used." type: value: ${{ steps.lint.outputs.type }} description: "The type of the title (e.g. `feat`), as parsed by commitlint." @@ -39,10 +37,12 @@ runs: using: composite steps: - - name: Set up Node.js - uses: actions/setup-node@v6 + # Install only mise here. The commitlint packages are installed in the next step, not every tool of the repository. + - name: Set up mise + uses: tedilabs/github-actions/.github/actions/mise.setup-tools@main with: - node-version: ${{ inputs.node_version }} + install: "false" + cache_key_prefix: mise-v1-github-pr-lint-title - name: Install commitlint id: install @@ -50,18 +50,33 @@ runs: env: COMMITLINT_VERSION: ${{ inputs.commitlint_version }} run: | - if [[ ! "$COMMITLINT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error title=Invalid input::\`commitlint_version\` must be an exact version such as \`21.2.3\`." + version="$COMMITLINT_VERSION" + if [ -z "$version" ]; then + version="$(mise current npm:@commitlint/cli 2>/dev/null || true)" + fi + version="${version:-21.2.3}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error title=Invalid input::The commitlint version '${version}' must be an exact version such as \`21.2.3\`." exit 1 fi + mise install \ + "npm:@commitlint/cli@${version}" \ + "npm:@commitlint/config-conventional@${version}" \ + "npm:@commitlint/load@${version}" \ + "npm:@commitlint/parse@${version}" + + # Expose the packages that are imported directly in one `node_modules`, so that `NODE_PATH` can resolve them: + # `@commitlint/config-conventional` for `extends` in a config file anywhere, and `load`/`parse` for the parse script. prefix="$RUNNER_TEMP/github-pr-lint-title" - npm install --prefix "$prefix" --no-save --no-audit --no-fund --ignore-scripts \ - "@commitlint/cli@${COMMITLINT_VERSION}" \ - "@commitlint/config-conventional@${COMMITLINT_VERSION}" \ - "@commitlint/load@${COMMITLINT_VERSION}" \ - "@commitlint/parse@${COMMITLINT_VERSION}" - echo "prefix=$prefix" >> "$GITHUB_OUTPUT" + mkdir -p "$prefix/node_modules/@commitlint" + for package in config-conventional load parse; do + ln -sfn "$(mise where "npm:@commitlint/${package}@${version}")/node_modules/@commitlint/${package}" "$prefix/node_modules/@commitlint/${package}" + done + { + echo "version=$version" + echo "prefix=$prefix" + } >> "$GITHUB_OUTPUT" - name: Lint Pull Request Title id: lint @@ -69,6 +84,7 @@ runs: env: TITLE: ${{ inputs.title }} CONFIG_FILE: ${{ inputs.config_file }} + VERSION: ${{ steps.install.outputs.version }} PREFIX: ${{ steps.install.outputs.prefix }} run: | # Escape a value for a workflow command such as `::error::`. @@ -84,16 +100,15 @@ runs: printf 'extends:\n- "@commitlint/config-conventional"\n' > "$config" echo "Using \`@commitlint/config-conventional\` because \`${CONFIG_FILE}\` does not exist." fi - # Resolve `extends` and parser presets from the installed packages, wherever the config file is. export NODE_PATH="$PREFIX/node_modules" exitcode=0 - result="$(printf '%s\n' "$TITLE" | "$PREFIX/node_modules/.bin/commitlint" --config "$config" 2>&1)" || exitcode=$? + result="$(printf '%s\n' "$TITLE" | mise exec "npm:@commitlint/cli@${VERSION}" -- commitlint --config "$config" 2>&1)" || exitcode=$? if [ -n "$result" ]; then echo "$result" fi if [ "$exitcode" -ne 0 ]; then - echo "::error title=Invalid pull request title::$(escape "The title '$TITLE' does not pass commitlint:"$'\n'"$result")" + echo "::error title=Invalid pull request title::$(escape "The title '$TITLE' does not pass commitlint ${VERSION}:"$'\n'"$result")" exit 1 fi @@ -114,4 +129,4 @@ runs: } console.log(`The title passes commitlint (type: ${outputs.type}, scope: ${outputs.scope || "none"}, breaking: ${outputs.breaking}).`); EOF - CONFIG="$config" node "$PREFIX/parse.mjs" + CONFIG="$config" mise exec "npm:@commitlint/cli@${VERSION}" -- node "$PREFIX/parse.mjs"