From a471ffebd87cb91904bbe3665685498c62c745dd Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 13:01:35 -0400 Subject: [PATCH 1/6] feat(semconv): add conventional pull request action Signed-off-by: Yordis Prieto --- actions/semconv/README.md | 13 +++ actions/semconv/lib/conventional.sh | 86 ++++++++++++++++++ actions/semconv/pull-request/README.md | 62 +++++++++++++ actions/semconv/pull-request/action.yml | 70 +++++++++++++++ actions/semconv/pull-request/validate.sh | 109 +++++++++++++++++++++++ tests/semconv/conventional_test.sh | 68 ++++++++++++++ 6 files changed, 408 insertions(+) create mode 100644 actions/semconv/README.md create mode 100644 actions/semconv/lib/conventional.sh create mode 100644 actions/semconv/pull-request/README.md create mode 100644 actions/semconv/pull-request/action.yml create mode 100755 actions/semconv/pull-request/validate.sh create mode 100755 tests/semconv/conventional_test.sh diff --git a/actions/semconv/README.md b/actions/semconv/README.md new file mode 100644 index 0000000..4ffbde6 --- /dev/null +++ b/actions/semconv/README.md @@ -0,0 +1,13 @@ +# semconv + +Actions that enforce [Conventional Commits][spec] across a repository. + +Every action in this family shares one grammar implementation, +[`lib/conventional.sh`](lib/conventional.sh), so a type accepted in one place +cannot be rejected in another. + +| Action | Enforces | +| --- | --- | +| [`pull-request`](pull-request) | The pull request title and its commit subjects | + +[spec]: https://www.conventionalcommits.org/en/v1.0.0/ diff --git a/actions/semconv/lib/conventional.sh b/actions/semconv/lib/conventional.sh new file mode 100644 index 0000000..c6fc2d2 --- /dev/null +++ b/actions/semconv/lib/conventional.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Conventional Commits grammar, shared by every action in the semconv family. +# Meant to be sourced, not executed. + +# Turns a comma or newline separated type list into an ERE alternation. +conventional_types_to_alternation() { + local raw=$1 token out="" + + while IFS= read -r token; do + token=${token//[[:space:]]/} + [ -n "$token" ] || continue + + if [[ ! $token =~ ^[a-z][a-z0-9]*$ ]]; then + printf 'configured type %q is not lowercase alphanumeric\n' "$token" >&2 + return 2 + fi + + out+="${out:+|}$token" + # printf adds the trailing newline that read needs to see the final entry. + done < <(printf '%s\n' "$raw" | tr ',' '\n') + + if [ -z "$out" ]; then + printf 'no conventional types configured\n' >&2 + return 2 + fi + + printf '%s' "$out" +} + +conventional_header_pattern() { + printf '^(%s)(\([^()]+\))?!?: .+$' "$1" +} + +# Explains which part of the grammar a subject missed, so the failure is +# actionable for someone who has never read this repository. +conventional_explain() { + local subject=$1 alternation=$2 found + local types="${alternation//|/, }" + + if [ -z "$subject" ]; then + printf 'subject is empty' + return + fi + + if [[ $subject != *:* ]]; then + printf 'missing the ":" separator; expected "[(scope)][!]: "' + return + fi + + found=${subject%%[(:!]*} + + if [[ ! $found =~ ^($alternation)$ ]]; then + printf 'unknown type "%s"; expected one of: %s' "$found" "$types" + return + fi + + if [[ $subject =~ ^($alternation)(\([^()]+\))?!?:[[:space:]]*$ ]]; then + printf 'empty description after "%s:"' "$found" + return + fi + + printf 'malformed header; expected "[(scope)][!]: "' +} + +# Returns 0 when the subject is conventional. Otherwise prints the reason on +# stdout and returns 1. +conventional_check_subject() { + local subject=$1 alternation=$2 subject_pattern=${3-} + local header_pattern description + + header_pattern=$(conventional_header_pattern "$alternation") + + if [[ ! $subject =~ $header_pattern ]]; then + conventional_explain "$subject" "$alternation" + return 1 + fi + + description=${subject#*: } + + if [ -n "$subject_pattern" ] && [[ ! $description =~ $subject_pattern ]]; then + printf 'description "%s" does not match %s' "$description" "$subject_pattern" + return 1 + fi + + return 0 +} diff --git a/actions/semconv/pull-request/README.md b/actions/semconv/pull-request/README.md new file mode 100644 index 0000000..a68730d --- /dev/null +++ b/actions/semconv/pull-request/README.md @@ -0,0 +1,62 @@ +# semconv/pull-request + +Fails a pull request whose title, or any of whose non-merge commit subjects, is +not a Conventional Commit. + +## Usage + +```yaml +name: SemConv + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: {} + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + # This job must not check out, install, or build pull request content. + # See "Why pull_request_target" below. + - uses: TrogonStack/github-actions/actions/semconv/pull-request@ # vX.Y.Z +``` + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `types` | `feat`, `fix`, `chore` | Allowed types, comma or newline separated. Each must be lowercase alphanumeric. | +| `subject-pattern` | `^[^A-Z]` | POSIX ERE the description must match. Empty disables the check. | +| `validate-title` | `true` | Validate the pull request title. | +| `validate-commits` | `true` | Validate every non-merge commit subject. Set to `false` when the repository squash-merges. | +| `pr-number` | from the event | Pull request number. | +| `pr-title` | from the event | Pull request title. | +| `repository` | from the event | Repository as `owner/name`. | +| `token` | `github.token` | Used to list commits. Read access is sufficient. | + +## Why `pull_request_target` + +Under `pull_request`, the workflow file that runs comes from the pull request +head. A contributor could edit this check to pass unconditionally and satisfy a +required status. `pull_request_target` runs the file from the base branch, so +the check cannot be rewritten by the change it is checking. + +That trigger is normally dangerous because it grants a writable token to a job +that might build untrusted code. This action never checks out, installs, or +executes pull request content: it reads the title from the event payload and the +commit subjects from the API. Keep it that way. If the job ever needs to build +something, split that into a separate `pull_request` workflow. + +## Grammar + +``` +[(scope)][!]: +``` + +Merge commits are skipped; they are authored by GitHub rather than the +contributor. A pull request with no non-merge commits fails. diff --git a/actions/semconv/pull-request/action.yml b/actions/semconv/pull-request/action.yml new file mode 100644 index 0000000..052d186 --- /dev/null +++ b/actions/semconv/pull-request/action.yml @@ -0,0 +1,70 @@ +name: Conventional pull request +description: >- + Validates that a pull request title and every non-merge commit subject follow + Conventional Commits. +author: TrogonStack + +inputs: + types: + description: Allowed conventional types, comma or newline separated. + required: false + default: | + feat + fix + chore + subject-pattern: + description: >- + POSIX ERE the description must match. The default rejects a leading + uppercase letter. Set to an empty string to disable the check. + required: false + default: '^[^A-Z]' + validate-title: + description: Validate the pull request title. + required: false + default: 'true' + validate-commits: + description: >- + Validate every non-merge commit subject. Set to false when the repository + squash-merges and only the title reaches the default branch. + required: false + default: 'true' + pr-number: + description: >- + Pull request number. Defaults to the one in the triggering event payload. + required: false + default: '' + pr-title: + description: >- + Pull request title. Defaults to the one in the triggering event payload. + required: false + default: '' + repository: + description: >- + Repository the pull request belongs to, as owner/name. Defaults to the + repository running the workflow. + required: false + default: '' + token: + description: Token used to list the pull request commits. Needs read access only. + required: false + default: ${{ github.token }} + +runs: + using: composite + steps: + # This step reads the pull request through the API and never checks out or + # executes its content. That is what makes the action safe under + # pull_request_target. Do not add checkout, install, or build steps to the + # job that calls it. + - name: Validate conventional commits + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + SEMCONV_TYPES: ${{ inputs.types }} + SEMCONV_SUBJECT_PATTERN: ${{ inputs.subject-pattern }} + SEMCONV_VALIDATE_TITLE: ${{ inputs.validate-title }} + SEMCONV_VALIDATE_COMMITS: ${{ inputs.validate-commits }} + SEMCONV_PR_NUMBER: ${{ inputs.pr-number }} + SEMCONV_PR_TITLE: ${{ inputs.pr-title }} + SEMCONV_REPOSITORY: ${{ inputs.repository }} + run: "$GITHUB_ACTION_PATH/validate.sh" diff --git a/actions/semconv/pull-request/validate.sh b/actions/semconv/pull-request/validate.sh new file mode 100755 index 0000000..b9ed7e2 --- /dev/null +++ b/actions/semconv/pull-request/validate.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Validates a pull request title and its commit subjects against Conventional +# Commits. +# +# SECURITY: this script reads the pull request only through the API. It never +# checks out, builds, or executes pull request content, which is what makes it +# safe to run from pull_request_target. Do not add a checkout or install step +# to the calling job. + +set -euo pipefail + +# shellcheck source=../lib/conventional.sh source-path=SCRIPTDIR +. "${GITHUB_ACTION_PATH}/../lib/conventional.sh" + +types=${SEMCONV_TYPES:?types input is required} +subject_pattern=${SEMCONV_SUBJECT_PATTERN-} +validate_title=${SEMCONV_VALIDATE_TITLE:-true} +validate_commits=${SEMCONV_VALIDATE_COMMITS:-true} + +# Every input falls back to the triggering event, so the common case needs no +# `with:` block. This reads the same under pull_request and pull_request_target. +from_event() { + [ -r "${GITHUB_EVENT_PATH:-}" ] || return 0 + jq -r "$1 // empty" "$GITHUB_EVENT_PATH" +} + +repository=${SEMCONV_REPOSITORY:-${GITHUB_REPOSITORY:-}} +pr_title=${SEMCONV_PR_TITLE:-$(from_event '.pull_request.title')} +pr_number=${SEMCONV_PR_NUMBER:-$(from_event '.pull_request.number')} + +if [ -z "$repository" ]; then + printf '::error::cannot determine the repository\n' + exit 1 +fi + +alternation=$(conventional_types_to_alternation "$types") + +checked=0 +failures=() + +check() { + local label=$1 subject=$2 reason + + checked=$((checked + 1)) + + if reason=$(conventional_check_subject "$subject" "$alternation" "$subject_pattern"); then + printf 'ok %s: %s\n' "$label" "$subject" + else + printf '::error::%s "%s": %s\n' "$label" "$subject" "$reason" + failures+=("- **${label}** \`${subject}\`: ${reason}") + fi +} + +if [ "$validate_title" = "true" ]; then + if [ -z "$pr_title" ]; then + printf '::error::pr-title is empty; pass github.event.pull_request.title\n' + exit 1 + fi + + check 'title' "$pr_title" +fi + +if [ "$validate_commits" = "true" ]; then + if [ -z "$pr_number" ]; then + printf '::error::pr-number is required when validate-commits is true\n' + exit 1 + fi + + # Merge commits are excluded: they are authored by GitHub, not the contributor. + if ! records=$(gh api --paginate "repos/${repository}/pulls/${pr_number}/commits" \ + --jq '.[] | select((.parents | length) < 2) | [.sha[0:7], (.commit.message | split("\n")[0])] | @tsv'); then + printf '::error::unable to list pull request commits\n' + exit 1 + fi + + if [ -z "$records" ]; then + printf '::error::pull request has no non-merge commits to validate\n' + exit 1 + fi + + while IFS=$'\t' read -r sha subject; do + check "$sha" "$subject" + done <<<"$records" +fi + +# Backticks below are markdown for the job summary, not command substitution. +# shellcheck disable=SC2016 +{ + printf '## Conventional commits\n\n' + printf 'Checked %d subject(s). Allowed types: `%s`.\n\n' "$checked" "${alternation//|/, }" + + if [ ${#failures[@]} -eq 0 ]; then + printf 'Everything is conventional.\n' + else + printf '### Not conventional\n\n' + printf '%s\n' "${failures[@]}" + printf '\nEvery commit subject and the pull request title must read\n' + printf '`[(scope)][!]: `. To reword:\n\n' + printf '```sh\n' + printf '# the title: edit it in the GitHub UI\n\n' + printf '# the most recent commit\n' + printf 'git commit --amend && git push --force-with-lease\n\n' + printf '# several commits\n' + printf 'git rebase -i %s && git push --force-with-lease\n' "origin/${GITHUB_BASE_REF:-main}" + printf '```\n' + fi +} >>"${GITHUB_STEP_SUMMARY:-/dev/null}" + +[ ${#failures[@]} -eq 0 ] diff --git a/tests/semconv/conventional_test.sh b/tests/semconv/conventional_test.sh new file mode 100755 index 0000000..a0b1323 --- /dev/null +++ b/tests/semconv/conventional_test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Exercises the grammar directly, so the rules are covered without opening a +# pull request against a fixture repository. + +set -uo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +# shellcheck source=../../actions/semconv/lib/conventional.sh source-path=SCRIPTDIR +. "$root/actions/semconv/lib/conventional.sh" + +pass=0 +fail=0 +types=$(conventional_types_to_alternation "feat,fix,chore") +pattern='^[^A-Z]' + +accepts() { + local reason + if reason=$(conventional_check_subject "$1" "$types" "$pattern"); then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + printf 'FAIL expected accepted: %-40s (%s)\n' "$1" "$reason" + fi +} + +rejects() { + if conventional_check_subject "$1" "$types" "$pattern" >/dev/null; then + fail=$((fail + 1)) + printf 'FAIL expected rejected: %s\n' "$1" + else + pass=$((pass + 1)) + fi +} + +accepts 'feat: add the thing' +accepts 'fix(parser): stop dropping trailing newlines' +accepts 'chore!: drop node 18' +accepts 'feat(a/b.c-d)!: nested scope' +accepts 'fix: a' + +rejects '' +rejects 'add the thing' +rejects 'Feat: add the thing' +rejects 'feat add the thing' +rejects 'feat:' +rejects 'feat: ' +rejects 'docs: not an allowed type' +rejects 'feat: Add the thing' +rejects 'feat(): empty scope' +rejects 'feature: close but no' + +# Type lists are validated rather than interpolated blindly. +if conventional_types_to_alternation 'feat,FIX' 2>/dev/null; then + fail=$((fail + 1)) + printf 'FAIL expected uppercase type to be rejected\n' +else + pass=$((pass + 1)) +fi + +if conventional_types_to_alternation ' ' 2>/dev/null; then + fail=$((fail + 1)) + printf 'FAIL expected empty type list to be rejected\n' +else + pass=$((pass + 1)) +fi + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] From da280266cbfe0b9a791eb5e5f10a3f43ef18e754 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 13:01:36 -0400 Subject: [PATCH 2/6] chore: lint and test the repository in ci Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++++++++++ .github/workflows/semconv.yml | 23 ++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/semconv.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f415ed1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: {} + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + shell: + name: Shell + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - name: shellcheck + run: git ls-files -z '*.sh' | xargs -0 shellcheck -x + - name: Run tests + run: | + while IFS= read -r suite; do + echo "==> $suite" + bash "$suite" + done < <(git ls-files 'tests/**/*_test.sh') + + actionlint: + name: Actionlint + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 # v1.7.7 + with: + args: -color diff --git a/.github/workflows/semconv.yml b/.github/workflows/semconv.yml new file mode 100644 index 0000000..705a098 --- /dev/null +++ b/.github/workflows/semconv.yml @@ -0,0 +1,23 @@ +name: SemConv + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: {} + +jobs: + validate: + name: Conventional pull request + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + # Under pull_request_target this checks out the base ref, which is what + # the local action reference below needs. Never point it at the pull + # request head, and never add an install or build step to this job. + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + - uses: ./actions/semconv/pull-request From fc80cb65b8520d218cbcbd326248fe138ef319dc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 13:03:07 -0400 Subject: [PATCH 3/6] fix(ci): run linters through mise Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 32 +++++++++----------------------- .github/workflows/semconv.yml | 2 +- mise.toml | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 mise.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f415ed1..e13c679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,33 +12,19 @@ concurrency: cancel-in-progress: true jobs: - shell: - name: Shell + check: + name: Lint and test runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: shellcheck - run: git ls-files -z '*.sh' | xargs -0 shellcheck -x - - name: Run tests - run: | - while IFS= read -r suite; do - echo "==> $suite" - bash "$suite" - done < <(git ls-files 'tests/**/*_test.sh') - actionlint: - name: Actionlint - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - persist-credentials: false - - uses: docker://rhysd/actionlint@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9 # v1.7.7 - with: - args: -color + # Tool versions live in mise.toml so a contributor running `mise run lint` + # locally gets exactly what CI runs. + - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + + - run: mise run lint + - run: mise run test diff --git a/.github/workflows/semconv.yml b/.github/workflows/semconv.yml index 705a098..055caef 100644 --- a/.github/workflows/semconv.yml +++ b/.github/workflows/semconv.yml @@ -17,7 +17,7 @@ jobs: # Under pull_request_target this checks out the base ref, which is what # the local action reference below needs. Never point it at the pull # request head, and never add an install or build step to this job. - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: ./actions/semconv/pull-request diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..dd8b504 --- /dev/null +++ b/mise.toml @@ -0,0 +1,14 @@ +[tools] +actionlint = "1.7.12" +shellcheck = "0.11.0" + +[tasks.lint] +description = "Lint every shell script and workflow" +run = [ + "git ls-files -z '*.sh' | xargs -0 shellcheck -x", + "actionlint", +] + +[tasks.test] +description = "Run every test suite" +run = "git ls-files 'tests/**/*_test.sh' | while IFS= read -r suite; do echo \"==> $suite\"; bash \"$suite\" || exit 1; done" From d9b8246f328e3d33af06f8fa858e624549d8dd23 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 17:35:12 -0400 Subject: [PATCH 4/6] chore(semconv): validate the title with the upstream action Only the pull request title reaches the default branch on a squash merge, and a maintained upstream already validates it better than a local grammar can. What this repository adds is one pinned version and one type list for every consumer. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 3 +- .github/workflows/semconv.yml | 9 +- actions/semconv/README.md | 6 +- actions/semconv/lib/conventional.sh | 86 ------------------ actions/semconv/pull-request/README.md | 74 ++++++++------- actions/semconv/pull-request/action.yml | 84 ++++++----------- actions/semconv/pull-request/validate.sh | 109 ----------------------- mise.toml | 9 +- tests/semconv/conventional_test.sh | 68 -------------- 9 files changed, 77 insertions(+), 371 deletions(-) delete mode 100644 actions/semconv/lib/conventional.sh delete mode 100755 actions/semconv/pull-request/validate.sh delete mode 100755 tests/semconv/conventional_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e13c679..65c1e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ concurrency: jobs: check: - name: Lint and test + name: Lint runs-on: ubuntu-latest permissions: contents: read @@ -27,4 +27,3 @@ jobs: - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - run: mise run lint - - run: mise run test diff --git a/.github/workflows/semconv.yml b/.github/workflows/semconv.yml index 055caef..98e333d 100644 --- a/.github/workflows/semconv.yml +++ b/.github/workflows/semconv.yml @@ -1,22 +1,19 @@ name: SemConv on: - pull_request_target: + pull_request: types: [opened, edited, synchronize, reopened] permissions: {} jobs: - validate: - name: Conventional pull request + lint-pr-title: + name: Validate PR Title runs-on: ubuntu-latest timeout-minutes: 5 permissions: pull-requests: read steps: - # Under pull_request_target this checks out the base ref, which is what - # the local action reference below needs. Never point it at the pull - # request head, and never add an install or build step to this job. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/actions/semconv/README.md b/actions/semconv/README.md index 4ffbde6..eeb50e2 100644 --- a/actions/semconv/README.md +++ b/actions/semconv/README.md @@ -2,12 +2,8 @@ Actions that enforce [Conventional Commits][spec] across a repository. -Every action in this family shares one grammar implementation, -[`lib/conventional.sh`](lib/conventional.sh), so a type accepted in one place -cannot be rejected in another. - | Action | Enforces | | --- | --- | -| [`pull-request`](pull-request) | The pull request title and its commit subjects | +| [`pull-request`](pull-request) | The pull request title | [spec]: https://www.conventionalcommits.org/en/v1.0.0/ diff --git a/actions/semconv/lib/conventional.sh b/actions/semconv/lib/conventional.sh deleted file mode 100644 index c6fc2d2..0000000 --- a/actions/semconv/lib/conventional.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -# Conventional Commits grammar, shared by every action in the semconv family. -# Meant to be sourced, not executed. - -# Turns a comma or newline separated type list into an ERE alternation. -conventional_types_to_alternation() { - local raw=$1 token out="" - - while IFS= read -r token; do - token=${token//[[:space:]]/} - [ -n "$token" ] || continue - - if [[ ! $token =~ ^[a-z][a-z0-9]*$ ]]; then - printf 'configured type %q is not lowercase alphanumeric\n' "$token" >&2 - return 2 - fi - - out+="${out:+|}$token" - # printf adds the trailing newline that read needs to see the final entry. - done < <(printf '%s\n' "$raw" | tr ',' '\n') - - if [ -z "$out" ]; then - printf 'no conventional types configured\n' >&2 - return 2 - fi - - printf '%s' "$out" -} - -conventional_header_pattern() { - printf '^(%s)(\([^()]+\))?!?: .+$' "$1" -} - -# Explains which part of the grammar a subject missed, so the failure is -# actionable for someone who has never read this repository. -conventional_explain() { - local subject=$1 alternation=$2 found - local types="${alternation//|/, }" - - if [ -z "$subject" ]; then - printf 'subject is empty' - return - fi - - if [[ $subject != *:* ]]; then - printf 'missing the ":" separator; expected "[(scope)][!]: "' - return - fi - - found=${subject%%[(:!]*} - - if [[ ! $found =~ ^($alternation)$ ]]; then - printf 'unknown type "%s"; expected one of: %s' "$found" "$types" - return - fi - - if [[ $subject =~ ^($alternation)(\([^()]+\))?!?:[[:space:]]*$ ]]; then - printf 'empty description after "%s:"' "$found" - return - fi - - printf 'malformed header; expected "[(scope)][!]: "' -} - -# Returns 0 when the subject is conventional. Otherwise prints the reason on -# stdout and returns 1. -conventional_check_subject() { - local subject=$1 alternation=$2 subject_pattern=${3-} - local header_pattern description - - header_pattern=$(conventional_header_pattern "$alternation") - - if [[ ! $subject =~ $header_pattern ]]; then - conventional_explain "$subject" "$alternation" - return 1 - fi - - description=${subject#*: } - - if [ -n "$subject_pattern" ] && [[ ! $description =~ $subject_pattern ]]; then - printf 'description "%s" does not match %s' "$description" "$subject_pattern" - return 1 - fi - - return 0 -} diff --git a/actions/semconv/pull-request/README.md b/actions/semconv/pull-request/README.md index a68730d..52c7643 100644 --- a/actions/semconv/pull-request/README.md +++ b/actions/semconv/pull-request/README.md @@ -1,56 +1,38 @@ # semconv/pull-request -Fails a pull request whose title, or any of whose non-merge commit subjects, is -not a Conventional Commit. +Fails a pull request whose title is not a Conventional Commit. A thin wrapper +around [`amannn/action-semantic-pull-request`][upstream] that fixes the grammar +so every repository agrees on it. ## Usage +The job name matters. Repository rulesets require the check by the literal +string `Validate PR Title`, so renaming the job silently stops the required +check from ever reporting. + ```yaml name: SemConv on: - pull_request_target: + pull_request: types: [opened, edited, synchronize, reopened] permissions: {} jobs: - validate: + lint-pr-title: + name: Validate PR Title runs-on: ubuntu-latest timeout-minutes: 5 permissions: pull-requests: read steps: - # This job must not check out, install, or build pull request content. - # See "Why pull_request_target" below. - - uses: TrogonStack/github-actions/actions/semconv/pull-request@ # vX.Y.Z + - uses: TrogonStack/github-actions/actions/semconv/pull-request@ ``` -## Inputs - -| Input | Default | Description | -| --- | --- | --- | -| `types` | `feat`, `fix`, `chore` | Allowed types, comma or newline separated. Each must be lowercase alphanumeric. | -| `subject-pattern` | `^[^A-Z]` | POSIX ERE the description must match. Empty disables the check. | -| `validate-title` | `true` | Validate the pull request title. | -| `validate-commits` | `true` | Validate every non-merge commit subject. Set to `false` when the repository squash-merges. | -| `pr-number` | from the event | Pull request number. | -| `pr-title` | from the event | Pull request title. | -| `repository` | from the event | Repository as `owner/name`. | -| `token` | `github.token` | Used to list commits. Read access is sufficient. | - -## Why `pull_request_target` - -Under `pull_request`, the workflow file that runs comes from the pull request -head. A contributor could edit this check to pass unconditionally and satisfy a -required status. `pull_request_target` runs the file from the base branch, so -the check cannot be rewritten by the change it is checking. - -That trigger is normally dangerous because it grants a writable token to a job -that might build untrusted code. This action never checks out, installs, or -executes pull request content: it reads the title from the event payload and the -commit subjects from the API. Keep it that way. If the job ever needs to build -something, split that into a separate `pull_request` workflow. +The reference must be a full commit SHA. Both organizations set +`sha_pinning_required`, so a branch or tag reference makes the job refuse to +start rather than fail. ## Grammar @@ -58,5 +40,29 @@ something, split that into a separate `pull_request` workflow. [(scope)][!]: ``` -Merge commits are skipped; they are authored by GitHub rather than the -contributor. A pull request with no non-merge commits fails. +`feat`, `fix`, and `chore` are the only types. Anything that is not a feature or +a fix is a chore. The description must not start with an uppercase letter. + +The type list is not an input. One grammar for every repository is the reason +this action exists; a knob invites back the divergence it was built to remove. + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `extra-ignore-labels` | none | Further labels that skip the check, one per line. | +| `token` | `github.token` | Read access is sufficient. | + +`bot`, `dependencies`, and `autorelease: pending` are always ignored. The last +is release-please's own pull request, which does not follow the grammar it +exists to produce. + +## Title only + +Only the title is checked, because only the title reaches the default branch on +a squash merge. Repositories that allow merge or rebase commits, or that squash +with `COMMIT_OR_PR_TITLE`, can still land an unconventional subject. That is a +repository settings problem and is fixed in the Terraform that owns those +settings, not here. + +[upstream]: https://github.com/amannn/action-semantic-pull-request diff --git a/actions/semconv/pull-request/action.yml b/actions/semconv/pull-request/action.yml index 052d186..64832a0 100644 --- a/actions/semconv/pull-request/action.yml +++ b/actions/semconv/pull-request/action.yml @@ -1,70 +1,44 @@ -name: Conventional pull request +name: Validate PR Title description: >- - Validates that a pull request title and every non-merge commit subject follow - Conventional Commits. + Fails a pull request whose title is not a Conventional Commit. author: TrogonStack inputs: - types: - description: Allowed conventional types, comma or newline separated. - required: false - default: | - feat - fix - chore - subject-pattern: - description: >- - POSIX ERE the description must match. The default rejects a leading - uppercase letter. Set to an empty string to disable the check. - required: false - default: '^[^A-Z]' - validate-title: - description: Validate the pull request title. - required: false - default: 'true' - validate-commits: - description: >- - Validate every non-merge commit subject. Set to false when the repository - squash-merges and only the title reaches the default branch. - required: false - default: 'true' - pr-number: - description: >- - Pull request number. Defaults to the one in the triggering event payload. - required: false - default: '' - pr-title: - description: >- - Pull request title. Defaults to the one in the triggering event payload. - required: false - default: '' - repository: + extra-ignore-labels: description: >- - Repository the pull request belongs to, as owner/name. Defaults to the - repository running the workflow. + Further labels that skip the check, one per line. Bot and release labels + are already ignored. required: false default: '' token: - description: Token used to list the pull request commits. Needs read access only. + description: Token used to read the pull request. Read access is sufficient. required: false default: ${{ github.token }} runs: using: composite steps: - # This step reads the pull request through the API and never checks out or - # executes its content. That is what makes the action safe under - # pull_request_target. Do not add checkout, install, or build steps to the - # job that calls it. - - name: Validate conventional commits - shell: bash + - name: Validate PR title + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: - GH_TOKEN: ${{ inputs.token }} - SEMCONV_TYPES: ${{ inputs.types }} - SEMCONV_SUBJECT_PATTERN: ${{ inputs.subject-pattern }} - SEMCONV_VALIDATE_TITLE: ${{ inputs.validate-title }} - SEMCONV_VALIDATE_COMMITS: ${{ inputs.validate-commits }} - SEMCONV_PR_NUMBER: ${{ inputs.pr-number }} - SEMCONV_PR_TITLE: ${{ inputs.pr-title }} - SEMCONV_REPOSITORY: ${{ inputs.repository }} - run: "$GITHUB_ACTION_PATH/validate.sh" + GITHUB_TOKEN: ${{ inputs.token }} + with: + # Anything that is not a feature or a fix is a chore. This list is + # deliberately not an input: one grammar for every repository is the + # reason this action exists. + types: | + feat + fix + chore + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" in the pull request title "{title}" should start + with a lowercase letter. + # "autorelease: pending" is release-please's own pull request, which does + # not follow the grammar it exists to produce. + ignoreLabels: | + bot + dependencies + autorelease: pending + ${{ inputs.extra-ignore-labels }} diff --git a/actions/semconv/pull-request/validate.sh b/actions/semconv/pull-request/validate.sh deleted file mode 100755 index b9ed7e2..0000000 --- a/actions/semconv/pull-request/validate.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Validates a pull request title and its commit subjects against Conventional -# Commits. -# -# SECURITY: this script reads the pull request only through the API. It never -# checks out, builds, or executes pull request content, which is what makes it -# safe to run from pull_request_target. Do not add a checkout or install step -# to the calling job. - -set -euo pipefail - -# shellcheck source=../lib/conventional.sh source-path=SCRIPTDIR -. "${GITHUB_ACTION_PATH}/../lib/conventional.sh" - -types=${SEMCONV_TYPES:?types input is required} -subject_pattern=${SEMCONV_SUBJECT_PATTERN-} -validate_title=${SEMCONV_VALIDATE_TITLE:-true} -validate_commits=${SEMCONV_VALIDATE_COMMITS:-true} - -# Every input falls back to the triggering event, so the common case needs no -# `with:` block. This reads the same under pull_request and pull_request_target. -from_event() { - [ -r "${GITHUB_EVENT_PATH:-}" ] || return 0 - jq -r "$1 // empty" "$GITHUB_EVENT_PATH" -} - -repository=${SEMCONV_REPOSITORY:-${GITHUB_REPOSITORY:-}} -pr_title=${SEMCONV_PR_TITLE:-$(from_event '.pull_request.title')} -pr_number=${SEMCONV_PR_NUMBER:-$(from_event '.pull_request.number')} - -if [ -z "$repository" ]; then - printf '::error::cannot determine the repository\n' - exit 1 -fi - -alternation=$(conventional_types_to_alternation "$types") - -checked=0 -failures=() - -check() { - local label=$1 subject=$2 reason - - checked=$((checked + 1)) - - if reason=$(conventional_check_subject "$subject" "$alternation" "$subject_pattern"); then - printf 'ok %s: %s\n' "$label" "$subject" - else - printf '::error::%s "%s": %s\n' "$label" "$subject" "$reason" - failures+=("- **${label}** \`${subject}\`: ${reason}") - fi -} - -if [ "$validate_title" = "true" ]; then - if [ -z "$pr_title" ]; then - printf '::error::pr-title is empty; pass github.event.pull_request.title\n' - exit 1 - fi - - check 'title' "$pr_title" -fi - -if [ "$validate_commits" = "true" ]; then - if [ -z "$pr_number" ]; then - printf '::error::pr-number is required when validate-commits is true\n' - exit 1 - fi - - # Merge commits are excluded: they are authored by GitHub, not the contributor. - if ! records=$(gh api --paginate "repos/${repository}/pulls/${pr_number}/commits" \ - --jq '.[] | select((.parents | length) < 2) | [.sha[0:7], (.commit.message | split("\n")[0])] | @tsv'); then - printf '::error::unable to list pull request commits\n' - exit 1 - fi - - if [ -z "$records" ]; then - printf '::error::pull request has no non-merge commits to validate\n' - exit 1 - fi - - while IFS=$'\t' read -r sha subject; do - check "$sha" "$subject" - done <<<"$records" -fi - -# Backticks below are markdown for the job summary, not command substitution. -# shellcheck disable=SC2016 -{ - printf '## Conventional commits\n\n' - printf 'Checked %d subject(s). Allowed types: `%s`.\n\n' "$checked" "${alternation//|/, }" - - if [ ${#failures[@]} -eq 0 ]; then - printf 'Everything is conventional.\n' - else - printf '### Not conventional\n\n' - printf '%s\n' "${failures[@]}" - printf '\nEvery commit subject and the pull request title must read\n' - printf '`[(scope)][!]: `. To reword:\n\n' - printf '```sh\n' - printf '# the title: edit it in the GitHub UI\n\n' - printf '# the most recent commit\n' - printf 'git commit --amend && git push --force-with-lease\n\n' - printf '# several commits\n' - printf 'git rebase -i %s && git push --force-with-lease\n' "origin/${GITHUB_BASE_REF:-main}" - printf '```\n' - fi -} >>"${GITHUB_STEP_SUMMARY:-/dev/null}" - -[ ${#failures[@]} -eq 0 ] diff --git a/mise.toml b/mise.toml index dd8b504..a7e5987 100644 --- a/mise.toml +++ b/mise.toml @@ -3,12 +3,9 @@ actionlint = "1.7.12" shellcheck = "0.11.0" [tasks.lint] -description = "Lint every shell script and workflow" +description = "Lint every workflow and shell script" run = [ - "git ls-files -z '*.sh' | xargs -0 shellcheck -x", "actionlint", + # -r so the task is a no-op rather than an error when no script matches. + "git ls-files -z '*.sh' | xargs -0 -r shellcheck -x", ] - -[tasks.test] -description = "Run every test suite" -run = "git ls-files 'tests/**/*_test.sh' | while IFS= read -r suite; do echo \"==> $suite\"; bash \"$suite\" || exit 1; done" diff --git a/tests/semconv/conventional_test.sh b/tests/semconv/conventional_test.sh deleted file mode 100755 index a0b1323..0000000 --- a/tests/semconv/conventional_test.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Exercises the grammar directly, so the rules are covered without opening a -# pull request against a fixture repository. - -set -uo pipefail - -root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) -# shellcheck source=../../actions/semconv/lib/conventional.sh source-path=SCRIPTDIR -. "$root/actions/semconv/lib/conventional.sh" - -pass=0 -fail=0 -types=$(conventional_types_to_alternation "feat,fix,chore") -pattern='^[^A-Z]' - -accepts() { - local reason - if reason=$(conventional_check_subject "$1" "$types" "$pattern"); then - pass=$((pass + 1)) - else - fail=$((fail + 1)) - printf 'FAIL expected accepted: %-40s (%s)\n' "$1" "$reason" - fi -} - -rejects() { - if conventional_check_subject "$1" "$types" "$pattern" >/dev/null; then - fail=$((fail + 1)) - printf 'FAIL expected rejected: %s\n' "$1" - else - pass=$((pass + 1)) - fi -} - -accepts 'feat: add the thing' -accepts 'fix(parser): stop dropping trailing newlines' -accepts 'chore!: drop node 18' -accepts 'feat(a/b.c-d)!: nested scope' -accepts 'fix: a' - -rejects '' -rejects 'add the thing' -rejects 'Feat: add the thing' -rejects 'feat add the thing' -rejects 'feat:' -rejects 'feat: ' -rejects 'docs: not an allowed type' -rejects 'feat: Add the thing' -rejects 'feat(): empty scope' -rejects 'feature: close but no' - -# Type lists are validated rather than interpolated blindly. -if conventional_types_to_alternation 'feat,FIX' 2>/dev/null; then - fail=$((fail + 1)) - printf 'FAIL expected uppercase type to be rejected\n' -else - pass=$((pass + 1)) -fi - -if conventional_types_to_alternation ' ' 2>/dev/null; then - fail=$((fail + 1)) - printf 'FAIL expected empty type list to be rejected\n' -else - pass=$((pass + 1)) -fi - -printf '\n%d passed, %d failed\n' "$pass" "$fail" -[ "$fail" -eq 0 ] From e6b0c3e2901c1add08e69fd0ffa1694fdb27eb05 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 17:36:24 -0400 Subject: [PATCH 5/6] fix(semconv): grant contents read so checkout can reach a private repository Signed-off-by: Yordis Prieto --- .github/workflows/semconv.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/semconv.yml b/.github/workflows/semconv.yml index 98e333d..8ea85b3 100644 --- a/.github/workflows/semconv.yml +++ b/.github/workflows/semconv.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: + contents: read pull-requests: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 09a648c7ecd880ddf2f4cf85842dbf998becf78f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 21 Sep 2026 17:45:02 -0400 Subject: [PATCH 6/6] chore(ci): move the lint task under a workflow shaped path A task file whose path mirrors the workflow that calls it makes the pairing obvious from either side. Signed-off-by: Yordis Prieto --- .config/mise/tasks/github/actions/ci/lint | 8 ++++++++ .github/workflows/ci.yml | 6 +++--- mise.toml | 8 -------- 3 files changed, 11 insertions(+), 11 deletions(-) create mode 100755 .config/mise/tasks/github/actions/ci/lint diff --git a/.config/mise/tasks/github/actions/ci/lint b/.config/mise/tasks/github/actions/ci/lint new file mode 100755 index 0000000..c6c6034 --- /dev/null +++ b/.config/mise/tasks/github/actions/ci/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +#MISE description="Lint every workflow and shell script" +set -euo pipefail + +actionlint + +# -r keeps this a no-op rather than an error when nothing matches. +git ls-files -z '*.sh' '.config/mise/tasks/**' | xargs -0 -r shellcheck -x diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c1e24..7a308c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ jobs: with: persist-credentials: false - # Tool versions live in mise.toml so a contributor running `mise run lint` - # locally gets exactly what CI runs. + # Tool versions live in mise.toml and the task lives under + # .config/mise/tasks, so a contributor runs exactly what CI runs. - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - - run: mise run lint + - run: mise run github:actions:ci:lint diff --git a/mise.toml b/mise.toml index a7e5987..6707bf1 100644 --- a/mise.toml +++ b/mise.toml @@ -1,11 +1,3 @@ [tools] actionlint = "1.7.12" shellcheck = "0.11.0" - -[tasks.lint] -description = "Lint every workflow and shell script" -run = [ - "actionlint", - # -r so the task is a no-op rather than an error when no script matches. - "git ls-files -z '*.sh' | xargs -0 -r shellcheck -x", -]