davideisinger.com

My personal website
Log | Files | Refs | README

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.