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
76 changes: 76 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: ci

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

# action.yml shipped invalid YAML in v1.0.0 and v1: an embedded script was
# dedented out of its block scalar, so GitHub could not load the action at
# all. Parsing it on every change is the check that would have caught it.
- name: action.yml parses
run: python3 -c "import yaml,sys; yaml.safe_load(open('action.yml')); print('action.yml parses')"

- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: stable
cache: false

# Installed from source at a pinned version rather than pulled as a
# third-party action or image, matching this repo's "no third-party
# actions" posture.
- name: actionlint
run: |
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7
actionlint -color

# This action's own README recommends SHA pinning; practice it here.
- name: actions are pinned to SHAs
run: |
# `uses: ./` and `./path` reference this repo at the commit already
# checked out; there is no external ref to pin, so exclude them.
all_uses="$(grep -rhoE '^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*[^[:space:]]+' action.yml .github/workflows/ \
| sed -E 's/.*uses:[[:space:]]*//' | grep -v '^\./' | sort -u)"
unpinned="$(printf '%s\n' "$all_uses" | grep -vE '@(sha256:)?[0-9a-f]{40,}$' || true)"
if [ -n "$unpinned" ]; then
echo "these must be pinned to a full commit SHA (or image digest):"
printf '%s\n' "$unpinned"
exit 1
fi

# Run the action against this very PR. A composite action can pass every
# static check and still fail at runtime, so exercise it for real.
#
# The action installs the linter with `go install`, which needs the
# DivergentCodes/commitlint module to be fetchable. While that repo is
# private the module proxy returns 404 on a hosted runner, so this job is
# skipped rather than left failing for a reason unrelated to this code.
# Once commitlint is public, delete the `if:` and this job runs everywhere.
self-test:
if: ${{ github.repository_owner == 'DivergentCodes' && vars.COMMITLINT_PUBLIC == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: run this action (advisory)
uses: ./
with:
# Never fail this repo's CI on a contributor's commit style; the point
# is to prove the action executes, not to gate on its verdict.
pr-title-mode: warn
commits-mode: warn

- name: run with commits-mode off
uses: ./
with:
pr-title-mode: warn
commits-mode: off
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 DivergentCodes

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
26 changes: 17 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@ on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
contents: read
jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: DivergentCodes/commitlint-action@<full-sha> # v1.0.0
- uses: actions/checkout@<full-sha> # required for commits-mode
- uses: DivergentCodes/commitlint-action@<full-sha> # v1.0.1
with:
pr-title-mode: block # squash-merge title is load-bearing
commits-mode: warn # advisory; intermediate commits vanish at squash
```

`actions/checkout` is required whenever `commits-mode` is not `off`, because
the action reads the PR's commits from git. With `commits-mode: off` the title
comes from the event payload and no checkout is needed.

Tag pinning (`@v1`) also works and is fine for internal repos, but SHA
pinning is recommended for anything security-sensitive.

Expand All @@ -55,20 +60,19 @@ merge with rebase or merge commits instead, set `commits-mode: block`.
| `scopes` | any | comma-separated allowed scopes |
| `require-scope` | `false` | require a `(scope)` |
| `max-subject-length` | `72` | subject length limit |
| `github-token` | `github.token` | reads PR commits via the API |

## Permissions

`permissions: pull-requests: read` is sufficient — the action reads the PR
title from the event payload and PR commits via the API with the default
`github.token`. It does not need `contents` access and never writes anything.
`permissions: contents: read` is sufficient — enough for `actions/checkout`
to fetch the commits. The action makes no API calls, needs no token, and never
writes anything. With `commits-mode: off` it reads only the event payload and
needs no permissions beyond the workflow default.

## Runner requirements

Runs on `ubuntu-latest` (and other GitHub-hosted runners) out of the box: it
uses `actions/setup-go` for the linter and `python3` + `base64` (both
preinstalled on hosted runners) to decode commit messages from the API. On
**self-hosted runners**, ensure Go, `python3`, and `base64` are available.
uses `actions/setup-go` to install the linter and reads commits with `git`. On
**self-hosted runners**, ensure Go and `git` are available.

## Troubleshooting

Expand All @@ -84,3 +88,7 @@ preinstalled on hosted runners) to decode commit messages from the API. On
Published as **GitHub tagged releases** with changelogs in the release notes.
Reference a release by SHA (preferred) or tag; there is no committed
changelog file.

## License

[MIT](LICENSE)
58 changes: 30 additions & 28 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ description: >
source. No third-party actions beyond GitHub's own.
author: DivergentCodes

branding:
icon: check-square
color: green

inputs:
version:
description: commitlint version to install (a tag like v1.0.0, or latest)
description: commitlint version to install (a tag like v1.1.0, or latest)
default: latest
pr-title-mode:
description: "Lint the PR title: block | warn | off"
Expand All @@ -27,58 +31,56 @@ inputs:
max-subject-length:
description: Maximum subject line length
default: "72"
github-token:
description: Token for reading PR commits via the API
default: ${{ github.token }}

runs:
using: composite
steps:
- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: stable
cache: false

- name: Install commitlint
shell: bash
run: go install github.com/DivergentCodes/commitlint@${{ inputs.version }}
env:
VERSION: ${{ inputs.version }}
run: go install "github.com/DivergentCodes/commitlint@${VERSION}"

- name: Lint PR title
if: inputs.pr-title-mode != 'off' && github.event_name == 'pull_request'
if: ${{ inputs.pr-title-mode != 'off' && github.event_name == 'pull_request' }}
shell: bash
env:
MODE: ${{ inputs.pr-title-mode }}
COMMITLINT_TYPES: ${{ inputs.types }}
COMMITLINT_SCOPES: ${{ inputs.scopes }}
COMMITLINT_REQUIRE_SCOPE: ${{ inputs.require-scope }}
COMMITLINT_MAX_SUBJECT_LENGTH: ${{ inputs.max-subject-length }}
run: commitlint lint --github-pr-title --mode "${{ inputs.pr-title-mode }}"
run: commitlint lint --github-pr-title --mode "$MODE"

# Lints the PR's own commits with `--range`, which reads them from git and
# skips merge commits itself. That needs no API call, so there is no token,
# no pagination limit, and no partial-coverage failure mode.
#
# actions/checkout leaves a shallow clone that does not contain the base
# commit, so fetch both endpoints before resolving the range. HEAD on a
# pull_request event is GitHub's synthetic merge commit; a range endpoint is
# always included, so lint up to the PR's real branch tip instead.
- name: Lint PR commits
if: inputs.commits-mode != 'off' && github.event_name == 'pull_request'
if: ${{ inputs.commits-mode != 'off' && github.event_name == 'pull_request' }}
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
MODE: ${{ inputs.commits-mode }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
COMMITLINT_TYPES: ${{ inputs.types }}
COMMITLINT_SCOPES: ${{ inputs.scopes }}
COMMITLINT_REQUIRE_SCOPE: ${{ inputs.require-scope }}
COMMITLINT_MAX_SUBJECT_LENGTH: ${{ inputs.max-subject-length }}
# Merge commits from base-branch syncs aren't squash material.
COMMITLINT_ALLOW_REVERT_PREFIX: "true"
run: |
set -euo pipefail
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}/commits?per_page=250"
status=0
count=0
while IFS= read -r encoded; do
[ -z "$encoded" ] && continue
count=$((count + 1))
printf '%s' "$encoded" | base64 -d > /tmp/commit-msg.txt
commitlint lint --file /tmp/commit-msg.txt --mode "${{ inputs.commits-mode }}" || status=1
done < <(curl -fsSL -H "Authorization: Bearer ${GH_TOKEN}" "$api" |
python3 -c 'import json,sys,base64
for c in json.load(sys.stdin):
msg = c["commit"]["message"]
if msg.startswith("Merge "): continue
print(base64.b64encode(msg.encode()).decode())')
echo "linted $count commit message(s)"
exit $status
if [ ! -d .git ]; then
echo "::error::commits-mode needs the repository checked out; add actions/checkout before this action, or set commits-mode: off"
exit 1
fi
git fetch --no-tags --depth=50 origin "$BASE_SHA" "$HEAD_SHA"
commitlint lint --range "${BASE_SHA}..${HEAD_SHA}" --mode "$MODE"
Loading