-
Notifications
You must be signed in to change notification settings - Fork 1
240 lines (215 loc) · 12.4 KB
/
commitlint.yml
File metadata and controls
240 lines (215 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# SPDX-FileCopyright (c) 2025-2026 SKY, LLC.
# SPDX-License-Identifier: MPL-2.0
# ─────────────────────────────────────────────────────────────────────────────
# Commitlint — Conventional Commits REQUIRED check on PR titles
#
# Phase R1b of `docs/architecture/release-automation-plan.md`
# (promoted from the R1a advisory gate on 2026-06-10 after ≥1 month of
# observation; the trailing 80 first-parent merges on `main` were 100%
# conformant at promotion time).
#
# Why this exists:
#
# UFFS is migrating to release-plz + git-cliff + Conventional Commits to
# automate version bumping and changelog generation (see plan §4 and §5).
# Release-plz infers the next version and the changelog category from the
# PR title's `type:` prefix. Non-conforming titles silently produce no
# changelog entry — release-plz treats them as if they didn't merge.
# That's a footgun: a fix shipped under "Improve search performance" gets
# no version bump and no changelog mention.
#
# This workflow surfaces non-conforming titles at PR-open time so the
# author can fix them before merge.
#
# Why required (now enforcing):
#
# Phase R1a (2026-04-25 → 2026-06-10) was the OBSERVATION period. At
# R1a start adherence was ~100% over the last 3 days (24/24 PRs) but
# only 83.3% over the last 30 days (75/90 PRs); the non-conforming PRs
# used project-internal prefixes like `security:`, `bench:`, `shmem:`.
# Over the observation window the project converged on the 11 standard
# Conventional Commits types (the `security:` carve-out was migrated to
# `fix(security):` / `chore(security):` — see the R1b CC-type
# convergence deviation row). At promotion the trailing 80 first-parent
# merges on `main` were 100% conformant, so the gate is now mandatory
# with negligible false-positive risk.
#
# In required mode this workflow exits non-zero on a non-conforming
# title AND posts the sticky PR comment, so the author both sees the
# guidance and is blocked from merging until the title conforms.
#
# Sticky-comment design:
#
# The job re-runs on every `synchronize` event (force-pushes, additional
# commits to the PR branch). Naïvely posting a comment on every run
# spams the PR. Instead, we tag managed comments with a unique HTML
# marker `<!-- commitlint-advisory: managed by ... -->` and use the
# GitHub REST API to either edit the existing managed comment in-place
# or delete it once the title becomes conformant. Result: at most ONE
# advisory comment per PR, auto-pruned when fixed.
#
# What this does NOT do:
#
# • Validate individual commit messages on the feature branch. UFFS
# uses squash-merge exclusively, so the PR title (which becomes the
# squash subject) is the only commit message that lands on `main`.
# • Validate the PR description / body. Only the title is parsed for
# release-plz's version inference.
#
# References:
#
# • CONTRIBUTING.md → "Commit message conventions"
# (the user-facing list of allowed types and examples)
# • https://www.conventionalcommits.org/en/v1.0.0/
# (the spec that defines the type list and breaking-change syntax)
# • plan §2.8 (current adherence baseline) and §3 (R1a → R1b transition)
name: "📝 Commitlint (required)"
on:
pull_request:
# `edited` catches title edits after open. `synchronize` catches
# additional commits / force-pushes (so a stale advisory comment
# gets pruned promptly when the author updates the title).
# `reopened` covers the reopen-after-close case so a closed-then-
# reopened PR re-evaluates fresh.
types: [opened, edited, synchronize, reopened]
# Comment posting requires write access to the PR (which is the
# `pull-requests` scope, not `contents`). Read on contents is implicit
# and unused — we never check out code in this workflow.
permissions:
contents: read
pull-requests: write
# Cancel any in-flight commitlint run on the same PR when a new event
# arrives. Title edits land in bursts (typo → realize → fix); we only
# care about the latest state.
concurrency:
group: commitlint-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr-title:
name: "PR title — Conventional Commits"
runs-on: ubuntu-latest
timeout-minutes: 2
# Skip on PRs from forks: forked PRs don't have write tokens, so
# `gh pr comment` would fail. Forks are advised separately via the
# CONTRIBUTING.md docs; they get a hint from the PR template instead.
if: github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Validate PR title against Conventional Commits
env:
# `pull_request.title` is the canonical squash-merge subject.
# We deliberately do NOT inspect commits on the branch — those
# get squashed away on merge and are noise for release-plz.
TITLE: ${{ github.event.pull_request.title }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# `github.token` is the workflow's ephemeral GITHUB_TOKEN.
# The `gh` CLI auto-detects this env var.
GH_TOKEN: ${{ github.token }}
# Marker embedded in the comment body so the same comment can
# be located across re-runs. Changing this string is a one-
# time migration: bump the marker AND delete any pre-bump
# advisory comments by hand on open PRs.
COMMENT_MARKER: "<!-- commitlint-advisory: managed by .github/workflows/commitlint.yml -->"
run: |
set -euo pipefail
# ── Allowed Conventional Commits types ─────────────────────
# Mirrors CONTRIBUTING.md → "Commit message conventions".
# Order matters only for readability; the regex is OR-of-all.
# Adding a new type means updating both this regex AND
# CONTRIBUTING.md in the same PR (and ideally cliff.toml's
# commit_parsers in Phase R2).
PATTERN='^(feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)(\([a-z0-9-]+\))?!?: .{1,}$'
# Group redirects to avoid the SC2129 shellcheck warning
# and minimise filesystem churn on the GitHub Actions side.
{
echo "## 📝 Commitlint — PR title check"
echo ""
echo "| Field | Value |"
echo "| --- | --- |"
echo "| Title | \`${TITLE}\` |"
echo "| Pattern | \`${PATTERN}\` |"
echo "| Mode | **required** (Phase R1b) — blocks merge until the title conforms |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
# ── Locate any existing managed advisory comment ───────────
# `gh api` paginates automatically. We use the raw issues
# comments endpoint (PR comments live there) and jq-filter
# by marker presence. `head -1` defends against the
# (impossible-but-cheap-to-guard) double-managed-comment case.
EXISTING_ID=$(
gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--jq ".[] | select(.body | contains(\"${COMMENT_MARKER}\")) | .id" \
| head -1 || true
)
if echo "$TITLE" | grep -qE "$PATTERN"; then
# ── Conforming title ───────────────────────────────────
echo "::notice::PR title matches Conventional Commits: ${TITLE}"
echo "✅ **Conforms** to Conventional Commits." >> "$GITHUB_STEP_SUMMARY"
if [ -n "${EXISTING_ID}" ]; then
# Title was previously non-conforming, now fixed —
# remove the stale advisory comment so the PR isn't
# cluttered with a resolved warning.
echo "::notice::Removing stale advisory comment ${EXISTING_ID}"
gh api -X DELETE "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_ID}"
echo "🧹 Removed stale advisory comment from earlier non-conforming title." >> "$GITHUB_STEP_SUMMARY"
fi
exit 0
fi
# ── Non-conforming title ───────────────────────────────────
echo "::warning::PR title does not match Conventional Commits: ${TITLE}"
echo "⚠️ **Does not conform** — advisory comment posted/updated below." >> "$GITHUB_STEP_SUMMARY"
# Compose the advisory body. The marker MUST be the first
# line for trivially-greppable detection. Keep the body
# informative but not punishing: contributors who hit this
# check probably haven't read CONTRIBUTING.md yet.
#
# NOTE on indentation: YAML's literal block scalar (`run: |`)
# strips the least common leading indentation from every
# content line. All heredoc body lines below sit at the
# same column as `set -euo pipefail` at the top of this
# block, so YAML strips them flush before bash sees them.
# No post-hoc `sed` indent-stripping required.
BODY=$(cat <<EOF
${COMMENT_MARKER}
## ⚠️ PR title is not in Conventional Commits format
**Your title:** \`${TITLE}\`
**Expected pattern:** \`type(optional-scope): subject\` where \`type\` is one of:
\`feat\`, \`fix\`, \`perf\`, \`refactor\`, \`docs\`, \`test\`, \`build\`, \`ci\`, \`chore\`, \`style\`, \`revert\`
**Examples** (from recent UFFS merges):
- \`fix(release): re-codesign macOS binaries after strip — v0.5.73\`
- \`refactor(uffs-mft): eliminate most shadow_reuse / shadow_unrelated via renames & let-else\`
- \`docs(architecture): add Windows-clippy + Linux-native-cross plan with W0 baseline\`
- \`feat(cli)!: drop deprecated --q shorthand\` (the trailing \`!\` marks a breaking change)
See [CONTRIBUTING.md → Commit message conventions](https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md#commit-message-conventions) for the full rationale and the table of which types trigger releases.
---
**🔴 This check is REQUIRED (release-automation Phase R1b)** — a non-conforming title **blocks merge**. Edit the PR title to match the pattern above; this check re-runs on every title edit and turns green (and this comment auto-deletes) once the title conforms.
*(Workflow: \`.github/workflows/commitlint.yml\` · Plan: [\`docs/architecture/release-automation-plan.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/main/docs/architecture/release-automation-plan.md) Phase R1b)*
EOF
)
if [ -n "${EXISTING_ID}" ]; then
# Edit in place — preserves comment threading and avoids
# spamming the PR with duplicate advisories on every push.
echo "::notice::Updating existing advisory comment ${EXISTING_ID}"
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_ID}" \
-f body="${BODY}" >/dev/null
else
# Pass --repo explicitly so `gh` does not try to infer the
# repo slug from the working directory's git remote — this
# job runs without an `actions/checkout` step, so a remote-
# less invocation crashes with `fatal: not a git repository`
# and the script's `set -euo pipefail` propagates the
# failure, defeating the advisory-mode `exit 0` below.
# (Discovered while diagnosing the stalled Dependabot tokio
# PR #125 — the workflow was supposed to be advisory but
# this single bug turned every non-conforming title into a
# red required-check.)
echo "::notice::Posting new advisory comment"
gh pr comment "${PR_NUMBER}" \
--repo "${GITHUB_REPOSITORY}" \
--body "${BODY}"
fi
# REQUIRED MODE (Phase R1b): exit non-zero on non-conformance
# so the check fails and (once added to branch protection's
# required-status-checks for `main`) blocks merge until the
# author fixes the title. The sticky comment above tells them
# how. Promoted from the R1a advisory `exit 0` on 2026-06-10.
exit 1