diff --git a/docs/blog_posts.md b/docs/blog_posts.md new file mode 100644 index 0000000..a96ecb7 --- /dev/null +++ b/docs/blog_posts.md @@ -0,0 +1,7 @@ +# Blog Posts + +* 2025-04-21: [Enforcing Conventional Commits with GitHub Actions](blog_posts/2025-04-20-enforcing-conventional-commits-github.md) +* 2025-04-18: [Enforcing Conventional Commits with Git Hooks](blog_posts/2025-04-18-enforcing-conventional-commits-git.md) +* 2025-04-16: [Continuous Delivery for Ruby gems](blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md) +* 2024-03-11: [Testing Ruby code that calls `abort` and `exit`](blog_posts/2024-03-11-testing-ruby-exit-abort.adoc) +* 2022-03-21: [Defining Class Variables and Constants in Anonymous Classes](blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md) diff --git a/docs/blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md b/docs/blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md new file mode 100644 index 0000000..31f84d2 --- /dev/null +++ b/docs/blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md @@ -0,0 +1,134 @@ +# Defining Class Variables and Constants in an Anonymous Class + +## Class Variables + +If you were creating a class using the `class` keyword, you could simply +do the following to create a class variable for the class: + +```Ruby +class MyClass + @@class_var = :foo +end +``` + +However, when creating an anonymous class using `Class.new`, the following +will define the class variable on `Object` instead of on the new anonymous +class (this actually fails on Ruby 3.0.0 or later with a RuntimeError "class +variable access from toplevel": + +```Ruby +MyClass = Class.new do + @@class_var = :foo +end +``` + +This is because class variable references are lexically scoped. In the case +of anonymous classes, `Class.new` evaluates the given block with `class_eval` +which DOES NOT change the lexical scope for the block. This means that the lexical +scope inside the block is the same as the lexical scope outside the block. + +To get the class variable to be defined on `MyClass` instead of `Object`, you +need to use the `class_variable_set` method: + +```Ruby +MyClass = Class.new do + self.class_variable_set(:@@class_var, :foo) +end +``` + +Using `self` in the method invokation `self.class_variable_set` is redundant. +I included it in this example to make the point that `class_eval` DOES set +`self` as expected and that the solution to defining a class variable for +the anonymous classs is to explicitly call `class_variable_set` in the anonymous +class initialization block. + +Here is another example that avoids problems with "class variable access +from toplevel" RuntimeError in Ruby 3.x. Here is how class variables work +using the `class` keyword: + +```Ruby +class OuterClass + class InnerClass + @@class_var = :foo + end +end + +# The results are as expected: +OuterClass::InnerClass.class_variables #=> [:@@class_var] +OuterClass.class_variables #=> [] +``` + +The incorrect solution where InnerClass is an anonymous class: + +```Ruby +class OuterClass + InnerClass = Class.new do + @@class_var = :foo + end +end + +# Oops, the class variable `@@class_var` is defined on OuterClass +# instead of InnerClass: +OuterClass::InnerClass.class_variables #=> [] +OuterClass.class_variables #=> [:@@class_var] +``` + +And the corrected solution where InnerClass is an anonymous class: + +```Ruby +class OuterClass + InnerClass = Class.new do + class_variable_set(:@@class_var, :foo) + end +end + +# The results are (once again) as expected: +OuterClass::InnerClass.class_variables #=> [:@@class_var] +OuterClass.class_variables #=> [] +``` + +## Constants + +The same problem exists for constants and is solved in the same way. + +Here is how class variables work using the `class` keyword: + +```Ruby +class OuterClass + class InnerClass + CONSTANT = :foo + end +end + +# The results are as expected: +OuterClass::InnerClass.constants #=> [:CONSTANT] +OuterClass.constants #=> [:InnerClass] +``` + +The incorrect solution where InnerClass is an anonymous class: + +```Ruby +class OuterClass + InnerClass = Class.new do + CONSTANT = :foo + end +end + +# Oops, the constant :CONSTANT is defined on OuterClass instead of InnerClass: +OuterClass::InnerClass.constants #=> [] +OuterClass.constants #=> [:CONSTANT, :InnerClass] +``` + +And the corrected solution where InnerClass is an anonymous class: + +```Ruby +class OuterClass + InnerClass = Class.new do + const_set(:CONSTANT, :foo) + end +end + +# The results are (once again) as expected: +OuterClass::InnerClass.constants #=> [:CONSTANT] +OuterClass.constants #=> [:InnerClass] +``` diff --git a/docs/blog_posts/2024-03-11-testing-ruby-exit-abort.adoc b/docs/blog_posts/2024-03-11-testing-ruby-exit-abort.adoc new file mode 100644 index 0000000..7aa738f --- /dev/null +++ b/docs/blog_posts/2024-03-11-testing-ruby-exit-abort.adoc @@ -0,0 +1,256 @@ +# Testing Ruby code that calls `abort` and `exit` +James Couball +:toc: + +Testing code that uses the `abort` or `exit` is a challenge for many developers. +These methods are often used in command-line applications to terminate the program +early and return an exit status to the operating system. Developers often encounter +conflicting opinions and misleading advice when seeking clear guidance on this topic. + +This article demystifies the testing of such code, offering a simple and +comprehensive approach for testing with both Minitest and RSpec. + +## How `Kernel#exit` and `Kernel#abort` work + +To effectively test code that calls `exit` and `abort`, it is important to first +understand how these methods work. + +Both `exit` and `abort` raise a `SystemExit` exception. Here is a simplified version +of the `exit` and `abort` methods to illustrate what they do: + +```ruby +# @param status [Boolean, Integer] the exit status +# +# If given an integer value, it is used as the program's exit status. +# +# If given a Boolean value, the program's exit status is system dependent +# and is typically 0 for true and 1 for false. +# +def exit(status = true) + raise SystemExit.new(status, 'exit') +end +``` + +```ruby +def abort(message = nil) + $stderr.puts(message.to_s) if message + raise SystemExit.new(false, message) +end +``` + +Like any other exception, if the `SystemExit` exception is propagated to the +top-level of the program, it causes the program to terminate. + +What makes `SystemExit` different from other exceptions is that it gives some control +over how the program is terminated. If a `SystemExit` exception is not handled, +neither an error message nor a backtrace are output and the program exits with the +exit status given in the exception. + +The `exit` method allows for a status code to be specified, which can indicate to the +operating system or calling process that the program ended successfully or encountered an +error. + +`exit`, `exit(true)` are equivalent and indicate that the program was successful. The +program's exit status is set to a system-dependent value to indicate success. This +value is 0 on Windows and Linux-like systems. + +`exit(false)` indicates that the program encountered an error. +The program's exit status is set to a system-dependent value to indicate failure. +This value is 1 on Windows and Linux-like systems. + +`exit` can be called with an integer value to set the program's exit status directly. +Unless a specific value is needed, it is recommended to use `exit(true)` or `exit(false)`. + +The `abort` method always indicates an error in the program. It allows for a +message to be given which is output to stderr BEFORE the exception is raised. This +means that message is output even if the `SystemExit` exception is handled. + +After outputting the message, the `abort` method functions like `exit(false)`. The +only difference is the raised exception's `message` attrribute is set to the message +given to the `abort` method. + +## Testing code that calls `exit` and `abort` + +With this understanding of how the `exit` and `abort` methods work, you see that +they are not as mysterious as they may seem at first. They are simply methods that +raise a `SystemExit` exception with a status code and an optional message. In the +case of `abort`, the message is printed to stderr before the exception is raised. + +This means that there are four things that can be tested in a program that calls +`exit` or `abort`: + +1. That a `SystemExit` exception is raised +2. That the exception `status` attribute is set to the expected integer value +3. That the exception `message` attribute is set to the expected string value +4. That the program output to stdout or stderr is as expected + +Rescuing the `SystemExit` exception (and not re-raising it) will allow the rest +of the test suite to run without exiting. + +### Testing with Minitest + +The following code shows a few examples of testing the `exit` and `abort` +methods using Minitest. It can be run by following these steps: + +1. Save the code to a file called `test_exit_abort.rb` +2. Install the `minitest` gem if it isn't already installed +3. Run the tests using the command `ruby test_exit_abort.rb` + +```ruby +require 'minitest/autorun' + +class MyTests < Minitest::Test + # Use this approach to confirm that exit or abort was called. Note, however, that + # it does not enable testing for the exception's exit status, message, or any + # program output. + # + def test_exit + assert_raises(SystemExit) { exit } + end + + # To test the exit status and message capture the exception returned from + # assert_raises and then check the status and message. + # + # The `exit` method sets the exception message to 'exit' -- this can not be changed. + # + def test_exit_status_and_message + exception = assert_raises(SystemExit) { exit(1) } + assert_equal(1, exception.status) + assert_equal('exit', exception.message) + end + + # To test any output the program makes, use the assert_output method. This + # method takes two arguments, the first is the expected output to stdout and + # the second is the expected output to stderr. If you don't care about one of + # the outputs, you can pass nil for that argument. + # + def test_exit_and_output + exception = nil + assert_output(nil, "exit output\n") do + exception = assert_raises(SystemExit) { warn 'exit output'; exit(2) } + end + return unless exception + + assert_equal(2, exception.status) + # The `exit` method sets the exception message to 'exit' -- this can not be changed. + assert_equal('exit', exception.message) + end + + # The abort method outputs the given string to stderr and then raises a + # SystemExit exception. The exception status is 1 and the message is the + # string passed to the abort method. + # + def test_abort + exception = nil + assert_output(nil, "aborting the program\n") do + exception = assert_raises(SystemExit) { abort('aborting the program') } + end + return unless exception + + assert_equal(1, exception.status) # abort always sets the status to 1 + assert_equal('aborting the program', exception.message) + end +end +``` + +### Testing with RSpec + +The following code shows similar examples of testing the `exit` and `abort` +methods using RSpec. It can be run by following these steps: + +1. Save the code to a file called `exit_abort_spec.rb` +2. Install the `rspec` gem if it isn't already installed +3. Run the tests using the command `rspec --format=documentation exit_abort_spec.rb` + +Here are the same tests implemented with RSpec: + +```ruby +RSpec.describe 'Kernel#exit and Kernel#abort' do + context 'when the exit method is called with no args' do + # If status and message are not important, you can use the raise_error matcher + # + it 'should raise a SystemExit exception' do + expect { exit }.to raise_error(SystemExit) + end + end + + context 'when the exit method is called with true' do + # The raise_error matcher can take a block that allows you to test the + # exception's status and message. + # + # The `exit` method sets the exception message to 'exit' -- this can not be changed. + # + it 'should raise a SystemExit exception indicating success' do + expect { exit(true) }.to raise_error(SystemExit) do |exception| + expect(exception).to have_attributes(status: 0, success?: true, message: 'exit') + end + end + end + + context 'when the exit method is called with false' do + it 'should raise a SystemExit exception indicating failure' do + expect { exit(false) }.to raise_error(SystemExit) do |exception| + expect(exception).to have_attributes(status: 1, success?: false, message: 'exit') + end + end + end + + context 'when the exit method is called with 99' do + it 'should raise a SystemExit exception whose status is 99' do + expect { exit(99) }.to raise_error(SystemExit) do |exception| + expect(exception).to have_attributes(status: 99, success?: false, message: 'exit') + end + end + end + + context 'when "Exiting" is output to stderr and exit is called with false' do + # The output matcher can be used to test the output to stdout and/or stderr. + # Wrap the code whose output you want to test in a block and pass that block + # to the expect method. + # + it 'should output "Exiting" to stderr and raise a SystemExit exception indicating failure' do + expect do + expect { warn 'Exiting'; exit(false) }.to raise_error(SystemExit) do |exception| + expect(exception).to have_attributes(status: 1, success?: false, message: 'exit') + end + end.to output("Exiting\n").to_stderr + end + end + + context 'when abort is called given the message "Aborting"' do + # This test is structured similarly to the previous test, but instead of wrapping + # the code in a block, a compound expectation is used to test the output and the + # raised exception (joined below with the `and` method). + # + it 'should output "Aborting" to stderr and raise a SystemExit exception indicating failure' do + expect { abort('Aborting') }.to( + raise_error(SystemExit) do |exception| + expect(exception).to have_attributes(status: 1, success?: false, message: 'Aborting') + end.and(output("Aborting\n").to_stderr) + ) + end + end +end +``` + +## Conclusion + +Armed with the knowledge of how `exit` and `abort` work and with these test examples, +you can confidently write tests for code that call these methods. This will help you +to ensure that your code behaves as expected and that you can catch any unexpected +exits before they cause problems in production. + +## Further exploration + +Embarking on this journey of mastering testing strategies for Ruby's `exit` and +`abort` methods is just the beginning. + +To dive deeper and expand your testing prowess, I encourage you to explore resources +such as +link:https://pragprog.com/titles/rspec3/effective-testing-with-rspec-3/[Effective Testing with RSpec 3] +by Myron Marston and Erin Dees, and the Ruby Testing Documentation for +link:https://docs.seattlerb.org/minitest/[Minitest] and +link:https://rspec.info/documentation/[RSpec]. +These resources can further enhance your understanding and skills. + +Knowledge is freedom! diff --git a/docs/blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md b/docs/blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md new file mode 100644 index 0000000..6660ba1 --- /dev/null +++ b/docs/blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md @@ -0,0 +1,335 @@ +# Continuous Delivery for Ruby Gems + +## Introduction + +I have been slowly inching toward implementing Continuous Delivery for my open source +gems for a long time now. What finally got me over the line was a blog post by my new +friend [Jonathan Gnagy](https://therubyist.org/about-the-author/) titled [Moving a +Ruby Gem's CI to GitHub +Actions](https://therubyist.org/2025/02/19/moving-a-ruby-gem-ci-to-github-actions/). + +This article details my Ruby Gem Continuous Delivery implementation starting with +Jonathan's blog post. + +## Contents + +- [The original inspiration](#the-original-inspiration) +- [Additional changes I made](#additional-changes-i-made) +- [Conclusion](#conclusion) +- [Additional considerations](#additional-considerations) +- [Implementation runbook](#implementation-runbook) + +## The original inspiration + +I was excited because Jonathan's post actually describes *How to implement Continuous +Delivery for Ruby gem projects* (I think that Jonathan buried the lede for this +article). The post uses two GitHub actions to achieve this result: the +[googleapis/release-please-action](https://github.com/googleapis/release-please-action) +and the [rubygems/release-gem](https://github.com/rubygems/release-gem) action. + +I followed the instructions from Jonathan's blog (with a couple tweaks) for 13 +different open source gems that I maintain. You can read Jonathan's post to see what +he recommended. See my [Implementation Runbook](#implementation-runbook) (below) for +step-by-step instructions you can follow to implement Continuous Delivery for your +Ruby Gem projects. + +## Additional changes I made + +The implementation was pretty smooth sailing with one exception: following Jonathan's +instructions resulted in two tags being created for each release where only one is +desired. You can see this in [Jonathan's metatron +project](https://github.com/jgnagy/metatron/tags). + +### Change tag format to "v1.0.0" + +The Release Please action creates a release tag and converts it to a GitHub release +complete with a description containing a list of changes. + +By default, this action creates a release tag including the component name (aka gem +name). For the metatron project, the release tag would be "metatron/v0.11.1". + +To align with my existing release process, I DO NOT want this tag to contain the gem +name. From the metatron example, I would want the release tag to be "v0.11.1". + +This is easy enough to accomplish by adding the following to the Release Please +configuration file: + +```json +"include-component-in-tag": false +``` + +### Only create one tag per release + +Unfortunately, the `rubygems/release-gem` action ALSO tries to create a tag in this +format. This is why the metatron project is currently creating **two tags** for each +release. + +Since the `rubygems/release-gem` action uses the `rake release` command under the +hood, it tries to create the same tag ("v0.11.1" for this example). Unfortunately, +this fails because the tag already exists having been created earlier by the Release +Please action. It would be nice if the release-gem action allowed you to specify a +different rake task for this action but it isn't really configurable at all. I am +planning on submitting a PR to add that. + +As a workaround, I have redefined the rake `release` task to not create and push the +tag to GitHub. I replaced the `release` task with a task that just calls +`release:rubygem_push` by including this in my project's `Rakefile`: + +```Ruby +require 'bundler' +require 'bundler/gem_tasks' + +# Make it so that calling `rake release` just calls `rake release:rubygems_push` to +# avoid creating and pushing a new tag. + +Rake::Task['release'].clear +desc 'Customized release task to avoid creating a new tag' +task release: 'release:rubygem_push' +``` + +I'll remove that customization if I can get my change to the `rubygems/release-gem` +accepted. + +### Use sentence case for commits listed in the CHANGELOG + +The last change I made was to add a plugin to sentence case the commit messages when +listing them in the change log. This change is not necessary, but it's my preference. +Add this section after the packages section: + +```json +"plugins": [ + { + "type": "sentence-case" + } +], +``` + +## Conclusion + +Now my release process is fully automated from commit-to-production. Well, almost: +you have to merge the release PR. + +What I like about the Release Please action is that it will update the release PR as +you merge other PRs to master. This means that you don't have to have a release per +PR. Release Please will stack up all the changes into a single release PR even +updating the target release version number if you later push a feature or a breaking +change. + +I strongly recommend that you also implement this workflow for your own gems. + +## Additional considerations + +I strongly recommend that your project enforce that all commit messages conform to +[the Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +specification. + +This is crucial because release-please uses these commit types (like fix:, feat:, +feat!: or BREAKING CHANGE:) to automatically determine the correct semantic version +bump (patch, minor, major) and to generate accurate CHANGELOG entries. + +Furthermore, release-please will ignore any commits that do not conform to convention +commits. It will neither trigger a release for non-conforming commits nor list them +in the CHANGELOG. + +Stay tuned for my next couple of blog post where I discuss how to add this +enforcement to your projects. + +## Implementation runbook + +These are the steps I took to implement continuous delivery in my RubyGem projects. + +### 1. Add `.github/workflows/release.yml` to the project + +Replace `` with the name of your gem: + +```yaml +--- +name: Release Gem +description: | + This workflow creates a new release on GitHub and publishes the gem to + RubyGems.org. + + The workflow uses the `googleapis/release-please-action` to handle the + release creation process and the `rubygems/release-gem` action to publish + the gem to rubygems.org + +on: + push: + branches: ["main"] + + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + + environment: + name: RubyGems + url: https://rubygems.org/gems/ + + permissions: + contents: write + pull-requests: write + id-token: write + + steps: + - name: Checkout project + uses: actions/checkout@v4 + + - name: Create release + uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.AUTO_RELEASE_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Setup ruby + uses: ruby/setup-ruby@v1 + if: ${{ steps.release.outputs.release_created }} + with: + bundler-cache: true + ruby-version: ruby + + - name: Push to RubyGems.org + uses: rubygems/release-gem@v1 + if: ${{ steps.release.outputs.release_created }} +``` + +### 2. Add `.release-please-manifest.json` to the project + +Replace `` with the last released version of your gem (for +example, "0.1.0"). + +```json +{ + ".": "" +} +``` + +If you have never released a version of this gem, just leave the json object empty: + +```json +{} +``` + +### 3. Add `release-please-config.json` to the project + +Make the following replacements: + +- Replace `` with the SHA of the last release (or leave off + `bootstrap-sha` if this gem has never been released). +- Replace `` with the name of your gem +- Replace `` with the path to the gem's version file (for example, + `lib/metatron/version.rb`) + +I listed all the supported change types so they can be unhidden and to ensure they +are consistent with the change types listed in the commitlint configuration. + +```json +{ + "bootstrap-sha": "", + "packages": { + ".": { + "release-type": "ruby", + "package-name": "", + "changelog-path": "CHANGELOG.md", + "version-file": "", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "draft": false, + "prerelease": false, + "include-component-in-tag": false, + "pull-request-title-pattern": "chore: release v${version}", + "changelog-sections": [ + { "type": "feat", "section": "Features", "hidden": false }, + { "type": "fix", "section": "Bug Fixes", "hidden": false }, + { "type": "build", "section": "Other Changes", "hidden": false }, + { "type": "chore", "section": "Other Changes", "hidden": false }, + { "type": "ci", "section": "Other Changes", "hidden": false }, + { "type": "docs", "section": "Other Changes", "hidden": false }, + { "type": "perf", "section": "Other Changes", "hidden": false }, + { "type": "refactor", "section": "Other Changes", "hidden": false }, + { "type": "revert", "section": "Other Changes", "hidden": false }, + { "type": "style", "section": "Other Changes", "hidden": false }, + { "type": "test", "section": "Other Changes", "hidden": false } + ] + } + }, + "plugins": [ + { + "type": "sentence-case" + } + ], + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} +``` + +### 4. Customize the `release` task in the project's `Rakefile` + +```Ruby +require 'bundler' +require 'bundler/gem_tasks' + +# Make it so that calling `rake release` just calls `rake release:rubygems_push` to +# avoid creating and pushing a new tag. + +Rake::Task['release'].clear +desc 'Customized release task to avoid creating a new tag' +task release: 'release:rubygem_push' +``` + +### 5. Add the `AUTO_RELEASE_TOKEN` secret for the project in GitHub + +The release-please-action requires a GitHub Personal Access token in order to do its +work which includes updating the changelog, creating tags and creating pull requests. + +The release workflow accesses this token in the `AUTO_RELEASE_TOKEN` secret. + +- Create a PAT. Either create a classic token with repo access or a fine-grained + token with the following permissions: + - **Contents**: Read and Write + - **Metadata**: Read + - **Pull Requests**: Read and Write +- Add the secret by navigating to the project's GitHub page and then selecting + Settings -> Secrets and variables -> Actions -> Repository secrets -> New + repository secret +- Use `AUTO_RELEASE_TOKEN` for the secret name and the token you created as the + secret value. + +### 6. Create a PR with the changes above and merge it + +Commit the changes above, create a PR, and merge it once the CI build is completed + +- Commit the changes you made above (on a branch) +- Prefix the commit with 'fix:' so that a new release is created +- Create a PR +- Wait for the CI build to succeed +- Merge the PR + +Verification: + +- Verify that the release workflow runs and creates a new release PR + +### 7. Add a trusted publisher for the gem in RubyGems.org + +In order to publish the gem to rubygems.org, rubygems/release-gem action requires +the gem have trusted publishing configured on RubyGems.org. + +- Login to Rubygems.org +- Go to the page on RubyGems for the gem being published +- In the "Links" section, click "Trusted publishers" and enter your password if + prompted +- Click the "Create" button and enter the publisher information +- Trusted publisher type: Github Actions +- **Repository owner**: `` +- **Repository name**: `` +- **Workflow filename**: release.yml +- **Environment**: RubyGems (this must match what is in the `release.yml`) + +### 8. Merge the release PR + +Verification: + +- Verify that the release workflow successfully pushes a new version of the gem to + rubygems.org diff --git a/docs/blog_posts/2025-04-18-enforcing-conventional-commits-git.md b/docs/blog_posts/2025-04-18-enforcing-conventional-commits-git.md new file mode 100644 index 0000000..183bd24 --- /dev/null +++ b/docs/blog_posts/2025-04-18-enforcing-conventional-commits-git.md @@ -0,0 +1,19 @@ +# Enforcing Conventional Commits with Git Hooks + +**THIS ARTICLE IS A WORK IN PROGRESS** + +## Introduction + +## Contents + +- [Introduction](#introduction) +- [Contents](#contents) +- [Conclusion](#conclusion) +- [Additional considerations](#additional-considerations) +- [Implementation runbook](#implementation-runbook) + +## Conclusion + +## Additional considerations + +## Implementation runbook diff --git a/docs/blog_posts/2025-04-20-enforcing-conventional-commits-github.md b/docs/blog_posts/2025-04-20-enforcing-conventional-commits-github.md new file mode 100644 index 0000000..d35b275 --- /dev/null +++ b/docs/blog_posts/2025-04-20-enforcing-conventional-commits-github.md @@ -0,0 +1,13 @@ +# Enforcing Conventional Commits with GitHub Actions + +**THIS ARTICLE IS A WORK IN PROGRESS** + +## Introduction + +## Contents + +## Conclusion + +## Additional considerations + +## Implementation runbook diff --git a/docs/index.md b/docs/index.md index 9ed8268..73c97f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,6 +18,14 @@ systems to track these programs. I also work with teams to help them implement their transformation goals. +### Latest Blog Posts + +* 2025-04-21: [Enforcing Conventional Commits with GitHub Actions](blog_posts/2025-04-20-enforcing-conventional-commits-github.md) +* 2025-04-18: [Enforcing Conventional Commits with Git Hooks](blog_posts/2025-04-18-enforcing-conventional-commits-git.md) +* 2025-04-16: [Continuous Delivery for Ruby gems](blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md) +* 2024-03-11: [Testing Ruby code that calls `abort` and `exit`](blog_posts/2024-03-11-testing-ruby-exit-abort.adoc) +* 2022-03-21: [Defining Class Variables and Constants in Anonymous Classes](blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md) + ### GitHub Stats [![James Couball's GitHub Stats](https://github-readme-stats.vercel.app/api?username=jcouball&theme=dark&show_icons=true)](https://github.com/jcouball/github-readme-stats) diff --git a/mkdocs.yml b/mkdocs.yml index 17a21a8..134baf9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,12 @@ theme: nav: - About Me: index.md - Documentation Standards: documentation_standards.md + - Blog Posts: + - blog_posts.md + - Defining Class Variables and Constants in an Anonymous Class: blog_posts/2022-03-21-defining-class-variables-and-constants-in-an-anonymous-class.md + - Continuous Delivery for Ruby Gems: blog_posts/2025-04-16-continuous-delivery-for-ruby-gems.md + - Enforcing Conventional Commits with Git Hooks: blog_posts/2025-04-18-enforcing-conventional-commits-git.md + - Enforcing Conventional Commits with GitHub Actions: blog_posts/2025-04-20-enforcing-conventional-commits-github.md - AI: - ChatGPT Prompt Engineering for Developers: ai/chatgpt_prompt_engineering_for_developers.md