Continuous Release on Github with Grunt

TL:DR; Just show me the Gruntfile!

This post will describe the release tasks I’ve written for Grunt which work seamlessly with our existing continuous deployment pipeline on Codeship. I also mention a step you can take if you want to do this directly from your command line instead.

I help develop WordPress themes and plugins for Texas A&M AgriLife, which are available on Github. Earlier this year, I was asked to develop and implement a continuous deployment pipeline for most of our repositories. I wrote an article about it here. Long story short, we use Codeship to watch our repository branches (staging and master) and run console commands to prep and push them to the staging and production versions of our servers.

I was recently asked to create a release for our latest theme AgriFlex3, and in hope I looked for articles on how to do so with Grunt. I soon found this article by Paul C. Pederson which showed me this approach was possible, but I needed something a little different. What follows are Grunt modules and commands based on those I use to package and release our open-source projects on Github with a release message that describes all of the commits made since the previous release.


grunt-contrib-compress

This is the simplest task. You provide the archive file name and an array of file globs to copy into it. We try to use values from package.json when we can. View the grunt-contrib-compress repository for more information.

  compress:
  main:
    options:
      archive: '<%= pkg.name %>.zip'
    files: [
      {src: ['css/*.css']},
      {src: ['img/**']},
      {src: ['js/*.js']},
      {src: ['**/*.php']},
      {src: ['*.php']},
      {src: ['README.md']},
      {src: ['screenshot.png']}
    ]


grunt-gh-release

This task creates our release using properties within package.json, a Github access token available in our Codeship environment, and a few custom tasks (shown in the next section) that populate the release message beforehand. If you want to run your release task locally then use the documentation’s method for the options token instead of what is shown here. View the grunt-gh-release repository for more information.

  gh_release:
  options:
    token: process.env.GITHUB_PERSONAL_ACCESS_TOKEN
    owner: 'agrilife'
    repo: '<%= pkg.name %>'
  release:
    tag_name: '<%= pkg.version %>'
    target_commitish: 'master'
    name: 'Release'
    body: 'First release'
    draft: false
    prerelease: false
    asset:
      name: '<%= pkg.name %>.zip'
      file: '<%= pkg.name %>.zip'
      'Content-Type': 'application/zip'


Custom tasks

The two custom tasks I’ve written will run console commands and handle their output. The first task gets the last release tag and uses it to set a config value as the range of commit messages we want to retrieve. The second task takes this range and uses it to populate the release message with a bulleted list of commits below their author’s name. This is necessary because the github token is linked to a person and that name shows near release messages. We want to ensure they are not seen by users as the author of all released commits.

  @registerTask 'setreleasemsg', 'Set release message as range of commits', ->
  done = @async()
  grunt.util.spawn {
    cmd: 'git'
    args: [ 'tag' ]
  }, (err, result, code) ->
    if(result.stdout!='')
      # Get last tag in the results
      matches = result.stdout.match(/([^\n]+)$/)
      # Set commit message timeline
      releaserange = matches[1] + '..HEAD'
      grunt.config.set 'releaserange', releaserange
      # Run the next task
      grunt.task.run('shortlog');
    done(err)
    return
  return

  @registerTask 'shortlog', 'Set gh_release body with commit messages since last release', ->
  done = @async()
  grunt.util.spawn {
    cmd: 'git'
    args: ['shortlog', grunt.config.get('releaserange'), '--no-merges']
  }, (err, result, code) ->
    if(result.stdout != '')
      # Hyphenate commit messages
      message = result.stdout.replace(/(\n)\s\s+/g, '$1- ')
      # Set release message
      grunt.config 'gh_release.release.body', message
    else
      # Just in case merges are the only commit
      grunt.config 'gh_release.release.body', 'release'
    done(err)
    return
  return

If this helps you, or if you have any suggestions for improvements, please leave a comment!


Gruntfile.coffee

  module.exports = (grunt) ->
  @initConfig
    pkg: @file.readJSON('package.json')
    compress:
      main:
        options:
          archive: '<%= pkg.name %>.zip'
        files: [
          {src: ['css/*.css']},
          {src: ['img/**']},
          {src: ['js/*.js']},
          {src: ['**/*.php']},
          {src: ['*.php']},
          {src: ['README.md']},
          {src: ['screenshot.png']}
        ]
    gh_release:
      options:
        token: process.env.GITHUB_PERSONAL_ACCESS_TOKEN
        owner: 'agrilife'
        repo: '<%= pkg.name %>'
      release:
        tag_name: '<%= pkg.version %>'
        target_commitish: 'master'
        name: 'Release'
        body: 'First release'
        draft: false
        prerelease: false
        asset:
          name: '<%= pkg.name %>.zip'
          file: '<%= pkg.name %>.zip'
          'Content-Type': 'application/zip'

  @loadNpmTasks 'grunt-contrib-compress'
  @loadNpmTasks 'grunt-gh-release'

  @registerTask 'release', ['compress', 'setreleasemsg', 'gh_release']
  @registerTask 'setreleasemsg', 'Set release message as range of commits', ->
    done = @async()
    grunt.util.spawn {
      cmd: 'git'
      args: [ 'tag' ]
    }, (err, result, code) ->
      if(result.stdout!='')
        # Get last tag in the results
        matches = result.stdout.match(/([^\n]+)$/)
        # Set commit message timeline
        releaserange = matches[1] + '..HEAD'
        grunt.config.set 'releaserange', releaserange
        # Run the next task
        grunt.task.run('shortlog');
      done(err)
      return
    return
  @registerTask 'shortlog', 'Set gh_release body with commit messages since last release', ->
    done = @async()
    grunt.util.spawn {
      cmd: 'git'
      args: ['shortlog', grunt.config.get('releaserange'), '--no-merges']
    }, (err, result, code) ->
      if(result.stdout != '')
        # Hyphenate commit messages
        message = result.stdout.replace(/(\n)\s\s+/g, '$1- ')
        # Set release message
        grunt.config 'gh_release.release.body', message
      else
        # Just in case merges are the only commit
        grunt.config 'gh_release.release.body', 'release'
      done(err)
      return
    return