index.md (6770B)
1 --- 2 title: "Simple Commit Linting for Issue Number in GitHub Actions" 3 date: 2023-04-28T00:00:00+00:00 4 draft: false 5 canonical_url: https://www.viget.com/articles/simple-commit-linting-for-issue-number-in-github-actions/ 6 --- 7 8 I don't believe there is **a** right way to do software; I think teams 9 can be effective (or ineffective!) in a lot of different ways using all 10 sorts of methodologies and technologies. But one hill upon which I will 11 die is this: referencing tickets in commit messages pays enormous 12 dividends over the long haul and you should always do it. As someone who 13 regularly commits code to apps created in the Obama era, nothing warms 14 my heart like running 15 [`:Git blame`](https://github.com/tpope/vim-fugitive#fugitivevim) on 16 some confusing code and seeing a reference to a GitHub Issue where I can 17 get the necessary context. And, conversely, nothing sparks nerd rage 18 like `fix bug` or `PR feedback` or, heaven forbid, `oops`. 19 20 In a recent [project 21 retrospective](https://www.viget.com/articles/get-the-most-out-of-your-internal-retrospectives/), 22 the team identified that we weren't being as consistent with this as 23 we'd like, and decided to take action. I figured some sort of commit 24 linting would be a good candidate for [continuous 25 integration](https://www.viget.com/articles/maintenance-matters-continuous-integration/) 26 --- when a team member pushes a branch up to GitHub, check the commits 27 and make sure they include a reference to a ticket. 28 29 I looked into [commitlint](https://commitlint.js.org/), but I found it a 30 lot more opinionated than I am --- I really just want to make sure 31 commits begin with either `[#XXX]` (an issue number) or `[n/a]` --- and 32 rather difficult to reconfigure. After struggling with it for a few 33 hours, I decided to just DIY it with a simple inline script. If you just 34 want something you can drop into a GitHub Actions YAML file to lint your 35 commits, here it is (but stick around and I'll break it down and then 36 show how to do it in a few other languages): 37 38 ```yaml 39 steps: 40 - name: Checkout code 41 uses: actions/checkout@v3 42 with: 43 fetch-depth: 0 44 45 - name: Set up ruby 3.2.1 46 uses: ruby/setup-ruby@v1 47 with: 48 ruby-version: 3.2.1 49 50 - name: Lint commits 51 run: | 52 git log --format=format:%s HEAD ^origin/main | ruby -e ' 53 $stdin.each_line do |msg| 54 next if /^\[(#\d+|n\/a)\]/.match?(msg) 55 warn %(Commits must begin with [#XXX] or [n/a] (#{msg.strip})) 56 exit 1 57 end 58 ' 59 ``` 60 61 A few notes: 62 63 - That `fetch-depth: 0` is essential in order to be able to compare 64 the branch being built with `main` (or whatever you call your 65 primary development branch) --- by default, your Action only knows 66 about the current branch. 67 - `git log --format=format:%s HEAD ^origin/main` is going to give you 68 the first line of every commit that's in the source branch but not 69 in `main`; those are the commits we want to lint. 70 - With that list of commits, we loop through each message and compare 71 it with the regular expression `/^\[(#\d+|n\/a)\]/`, i.e. does this 72 message begin with either `[#XXX]` (where `X` are digits) or 73 `[n/a]`? 74 - If any message does **not** match, print an error out to standard 75 error (that's `warn`) and exit with a non-zero status (so that the 76 GitHub Action fails). 77 78 If you want to try this out locally (or perhaps modify the script to 79 validate messages in a different way), here's a `docker run` command 80 you can use: 81 82 ```bash 83 echo '[#123] Message 1 84 [n/a] Message 2 85 [#122] Message 3' | docker run --rm -i ruby:3.2.1 ruby -e ' 86 $stdin.each_line do |msg| 87 next if /^\[(#\d+|n\/a)\]/.match?(msg) 88 warn %(Commits must begin with [#XXX] or [n/a] (#{msg.strip})) 89 exit 1 90 end 91 ' 92 ``` 93 94 Note that running this command should output nothing since these are all 95 valid commit messages; modify one of the messages if you want to see the 96 failure state. 97 98 ## Other Languages 99 100 Since there's a very real possibility you might not otherwise install 101 Ruby in your GitHub Actions, and because I weirdly enjoy writing the 102 same code in a bunch of different languages, here are scripts for 103 several of Viget's other favorites: 104 105 ### JavaScript 106 107 ```bash 108 git log --format=format:%s HEAD ^origin/main | node -e " 109 let msgs = require('fs').readFileSync(0).toString().trim().split('\n'); 110 for (let msg of msgs) { 111 if (msg.match(/^\[(#\d+|n\/a)\]/)) { continue; } 112 process.stderr.write('Commits must begin with [#XXX] or [n/a] (' + msg + ')'); 113 process.exit(1); 114 } 115 " 116 ``` 117 118 To test: 119 120 ```bash 121 echo '[#123] Message 1 122 [n/a] Message 2 123 [#122] Message 3' | docker run --rm -i node:18.15.0 node -e " 124 let msgs = require('fs').readFileSync(0).toString().trim().split('\n'); 125 for (let msg of msgs) { 126 if (msg.match(/^\[(#\d+|n\/a)\]/)) { continue; } 127 process.stderr.write('Commits must begin with [#XXX] or [n/a] (' + msg + ')'); 128 process.exit(1); 129 } 130 " 131 ``` 132 133 ### PHP 134 135 ```bash 136 git log --format=format:%s HEAD ^origin/main | php -r ' 137 while ($msg = fgets(STDIN)) { 138 if (preg_match("/^\[(#\d+|n\/a)\]/", $msg)) { continue; } 139 fwrite(STDERR, "Commits must begin with #[XXX] or [n/a] (" . trim($msg) . ")\n"); 140 exit(1); 141 } 142 ' 143 ``` 144 145 To test: 146 147 ```bash 148 echo '[#123] Message 1 149 [n/a] Message 2 150 [#122] Message 3' | docker run --rm -i php:8.2.4 php -r ' 151 while ($msg = fgets(STDIN)) { 152 if (preg_match("/^\[(#\d+|n\/a)\]/", $msg)) { continue; } 153 fwrite(STDERR, "Commits must begin with #[XXX] or [n/a] (" . trim($msg) . ")\n"); 154 exit(1); 155 } 156 ' 157 ``` 158 159 ### Python 160 161 ```bash 162 git log --format=format:%s HEAD ^origin/main | python -c ' 163 import sys 164 import re 165 for msg in sys.stdin: 166 if re.match(r"^\[(#\d+|n\/a)\]", msg): 167 continue 168 print("Commits must begin with #[xxx] or [n/a] (%s)" % msg.strip(), file=sys.stderr) 169 sys.exit(1) 170 ' 171 ``` 172 173 To test: 174 175 ```bash 176 echo '[#123] Message 1 177 [n/a] Message 2 178 [#122] Message 3' | docker run --rm -i python:3.11.3 python -c ' 179 import sys 180 import re 181 for msg in sys.stdin: 182 if re.match(r"^\[(#\d+|n\/a)\]", msg): 183 continue 184 print("Commits must begin with #[xxx] or [n/a] (%s)" % msg.strip(), file=sys.stderr) 185 sys.exit(1) 186 ' 187 ``` 188 189 ------------------------------------------------------------------------ 190 191 So there you have it: simple GitHub Actions commit linting in most of 192 Viget's favorite languages (try as I might, I could not figure out how 193 to do this in [Elixir](https://elixir-lang.org/), at least not in a 194 concise way). As I said up front, writing good tickets and then 195 referencing them in commit messages so that they can easily be surfaced 196 with `git blame` pays **huge** dividends over the life of a codebase. If 197 you're not already in the habit of doing this, well, the best time to 198 start was `Initial commit`, but the second best time is today.