Articles & Publications

Discover the best tools and products to aid in your development workflow.

Continuous Release on Github with Grunt

Visit 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 developed a new release process for our latest theme AgriFlex4 using the Github release API. What follows are Grunt modules and commands 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. These tasks can be run for any project, not just WordPress!

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: ['style.css']},
      {src: ['css/*.css']},
      {src: ['js/*.js']},
      {src: ['images/**']},
      {src: ['src/**']},
      {src: ['functions.php']},
      {src: ['search.php']},
      {src: ['members-only.php']},
      {src: ['readme.md']},
      {src: ['vendor/autoload.php', 'vendor/composer/**']}
    ]

Custom tasks

The custom tasks I’ve written will configure properties used to run a series of bash commands which eventually uses a Github API to create a release. These tasks rely on a set of properties being defined earlier in the gruntfile.

release:
  branch: ''
  repofullname: ''
  lasttag: ''
  msg: ''
  post: ''
  url: ''

The custom tasks seem complicated, but the purpose of it all is to send a curl request to the Github release service which creates a release and then another one to upload the .zip file to it. The tasks won’t need to be modified directly for your application since they pull in attributes from your package.json file, an environmental variable for RELEASE_KEY, and your git repository.

@registerTask 'release', ['compress', 'makerelease']
@registerTask 'makerelease', 'Set release branch for use in the release task', ->
  done = @async()

  # Define simple properties for release object
  grunt.config 'release.key', process.env.RELEASE_KEY
  grunt.config 'release.file', grunt.template.process '<%= pkg.name %>.zip'

  grunt.util.spawn {
    cmd: 'git'
    args: [ 'rev-parse', '--abbrev-ref', 'HEAD' ]
  }, (err, result, code) ->
    if result.stdout isnt ''
      matches = result.stdout.match /([^\n]+)$/
      grunt.config 'release.branch', matches[1]
      grunt.task.run 'setrepofullname'

    done(err)
    return
  return
@registerTask 'setrepofullname', 'Set repo full name for use in the release task', ->
  done = @async()

  # Get repository owner and name for use in Github REST requests
  grunt.util.spawn {
    cmd: 'git'
    args: [ 'config', '--get', 'remote.origin.url' ]
  }, (err, result, code) ->
    if result.stdout isnt ''
      grunt.log.writeln 'Remote origin url: ' + result
      matches = result.stdout.match /([^\/:]+)\/([^\/.]+)(\.git)?$/
      grunt.config 'release.repofullname', matches[1] + '/' + matches[2]
      grunt.task.run 'setlasttag'

    done(err)
    return
  return
@registerTask 'setlasttag', 'Set release message as range of commits', ->
  done = @async()

  grunt.util.spawn {
    cmd: 'git'
    args: [ 'tag' ]
  }, (err, result, code) ->
    if result.stdout isnt ''
      matches = result.stdout.match /([^\n]+)$/
      grunt.config 'release.lasttag', matches[1] + '..'

    grunt.task.run 'setmsg'

    done(err)
    return
  return
@registerTask 'setmsg', 'Set gh_release body with commit messages since last release', ->
  done = @async()

  releaserange = grunt.template.process '<%= release.lasttag %>HEAD'

  grunt.util.spawn {
    cmd: 'git'
    args: ['shortlog', releaserange, '--no-merges']
  }, (err, result, code) ->
    msg = grunt.template.process 'Upload <%= release.file %> to your dashboard.'

    if result.stdout isnt ''
      message = result.stdout.replace /(\n)\s\s+/g, '$1- '
      message = message.replace /\s*\[skip ci\]/g, ''
      msg += '\n\n# Changes\n' + message

    grunt.config 'release.msg', msg
    grunt.task.run 'setpostdata'
    done(err)
    return
  return
@registerTask 'setpostdata', 'Set post object for use in the release task', ->
  val =
    tag_name: 'v' + grunt.config.get 'pkg.version'
    name: grunt.template.process '<%= pkg.name %> (v<%= pkg.version %>)'
    body: grunt.config.get 'release.msg'
    draft: false
    prerelease: false
    # target_commitish: grunt.config.get 'release.branch'
  grunt.config 'release.post', JSON.stringify val
  grunt.log.write JSON.stringify val

  grunt.task.run 'createrelease'
  return
@registerTask 'createrelease', 'Create a Github release', ->
  done = @async()

  # Create curl arguments for Github REST API request
  args = ['-X', 'POST', '--url']
  args.push grunt.template.process 'https://api.github.com/repos/<%= release.repofullname %>/releases?access_token=<%= release.key %>'
  args.push '--data'
  args.push grunt.config.get 'release.post'
  grunt.log.write 'curl args: ' + args

  # Create Github release using REST API
  grunt.util.spawn {
    cmd: 'curl'
    args: args
  }, (err, result, code) ->
    grunt.log.write '\nResult: ' + result + '\n'
    grunt.log.write 'Error: ' + err + '\n'
    grunt.log.write 'Code: ' + code + '\n'

    if result.stdout isnt ''
      obj = JSON.parse result.stdout
      # Check for error from Github
      if 'errors' of obj and obj['errors'].length > 0
        grunt.fail.fatal 'Github Error'
      else
        # We need the resulting "release" ID value before we can upload the .zip file to it.
        grunt.config 'release.id', obj.id
        grunt.task.run 'uploadreleasefile'

    done(err)
    return
  return
@registerTask 'uploadreleasefile', 'Upload a zip file to the Github release', ->
  done = @async()

  # Create curl arguments for Github REST API request
  args = ['-X', 'POST', '--header', 'Content-Type: application/zip', '--upload-file']
  args.push grunt.config.get 'release.file'
  args.push '--url'
  args.push grunt.template.process 'https://uploads.github.com/repos/<%= release.repofullname %>/releases/<%= release.id %>/assets?access_token=<%= release.key %>&name=<%= release.file %>'
  grunt.log.write 'curl args: ' + args

  # Upload Github release asset using REST API
  grunt.util.spawn {
    cmd: 'curl'
    args: args
  }, (err, result, code) ->
    grunt.log.write '\nResult: ' + result + '\n'
    grunt.log.write 'Error: ' + err + '\n'
    grunt.log.write 'Code: ' + code + '\n'

    if result.stdout isnt ''
      obj = JSON.parse result.stdout
      # Check for error from Github
      if 'errors' of obj and obj['errors'].length > 0
        grunt.fail.fatal 'Github Error'

    done(err)
    return
  return

Gruntfile.coffee

module.exports = (grunt) ->
  @initConfig
    pkg: @file.readJSON('package.json')
    release:
      branch: ''
      repofullname: ''
      lasttag: ''
      msg: ''
      post: ''
      url: ''
    compress:
      main:
        options:
          archive: '<%= pkg.name %>.zip'
        files: [
          {src: ['style.css']},
          {src: ['css/*.css']},
          {src: ['js/*.js']},
          {src: ['images/**']},
          {src: ['src/**']},
          {src: ['functions.php']},
          {src: ['search.php']},
          {src: ['members-only.php']},
          {src: ['readme.md']},
          {src: ['vendor/autoload.php', 'vendor/composer/**']}
        ]

  @loadNpmTasks 'grunt-contrib-compress'

  @registerTask 'release', ['compress', 'makerelease']
  @registerTask 'makerelease', 'Set release branch for use in the release task', ->
    done = @async()

    # Define simple properties for release object
    grunt.config 'release.key', process.env.RELEASE_KEY
    grunt.config 'release.file', grunt.template.process '<%= pkg.name %>.zip'

    grunt.util.spawn {
      cmd: 'git'
      args: [ 'rev-parse', '--abbrev-ref', 'HEAD' ]
    }, (err, result, code) ->
      if result.stdout isnt ''
        matches = result.stdout.match /([^\n]+)$/
        grunt.config 'release.branch', matches[1]
        grunt.task.run 'setrepofullname'

      done(err)
      return
    return
  @registerTask 'setrepofullname', 'Set repo full name for use in the release task', ->
    done = @async()

    # Get repository owner and name for use in Github REST requests
    grunt.util.spawn {
      cmd: 'git'
      args: [ 'config', '--get', 'remote.origin.url' ]
    }, (err, result, code) ->
      if result.stdout isnt ''
        grunt.log.writeln 'Remote origin url: ' + result
        matches = result.stdout.match /([^\/:]+)\/([^\/.]+)(\.git)?$/
        grunt.config 'release.repofullname', matches[1] + '/' + matches[2]
        grunt.task.run 'setlasttag'

      done(err)
      return
    return
  @registerTask 'setlasttag', 'Set release message as range of commits', ->
    done = @async()

    grunt.util.spawn {
      cmd: 'git'
      args: [ 'tag' ]
    }, (err, result, code) ->
      if result.stdout isnt ''
        matches = result.stdout.match /([^\n]+)$/
        grunt.config 'release.lasttag', matches[1] + '..'

      grunt.task.run 'setmsg'

      done(err)
      return
    return
  @registerTask 'setmsg', 'Set gh_release body with commit messages since last release', ->
    done = @async()

    releaserange = grunt.template.process '<%= release.lasttag %>HEAD'

    grunt.util.spawn {
      cmd: 'git'
      args: ['shortlog', releaserange, '--no-merges']
    }, (err, result, code) ->
      msg = grunt.template.process 'Upload <%= release.file %> to your dashboard.'

      if result.stdout isnt ''
        message = result.stdout.replace /(\n)\s\s+/g, '$1- '
        message = message.replace /\s*\[skip ci\]/g, ''
        msg += '\n\n# Changes\n' + message

      grunt.config 'release.msg', msg
      grunt.task.run 'setpostdata'
      done(err)
      return
    return
  @registerTask 'setpostdata', 'Set post object for use in the release task', ->
    val =
      tag_name: 'v' + grunt.config.get 'pkg.version'
      name: grunt.template.process '<%= pkg.name %> (v<%= pkg.version %>)'
      body: grunt.config.get 'release.msg'
      draft: false
      prerelease: false
      # target_commitish: grunt.config.get 'release.branch'
    grunt.config 'release.post', JSON.stringify val
    grunt.log.write JSON.stringify val

    grunt.task.run 'createrelease'
    return
  @registerTask 'createrelease', 'Create a Github release', ->
    done = @async()

    # Create curl arguments for Github REST API request
    args = ['-X', 'POST', '--url']
    args.push grunt.template.process 'https://api.github.com/repos/<%= release.repofullname %>/releases?access_token=<%= release.key %>'
    args.push '--data'
    args.push grunt.config.get 'release.post'
    grunt.log.write 'curl args: ' + args

    # Create Github release using REST API
    grunt.util.spawn {
      cmd: 'curl'
      args: args
    }, (err, result, code) ->
      grunt.log.write '\nResult: ' + result + '\n'
      grunt.log.write 'Error: ' + err + '\n'
      grunt.log.write 'Code: ' + code + '\n'

      if result.stdout isnt ''
        obj = JSON.parse result.stdout
        # Check for error from Github
        if 'errors' of obj and obj['errors'].length > 0
          grunt.fail.fatal 'Github Error'
        else
          # We need the resulting "release" ID value before we can upload the .zip file to it.
          grunt.config 'release.id', obj.id
          grunt.task.run 'uploadreleasefile'

      done(err)
      return
    return
  @registerTask 'uploadreleasefile', 'Upload a zip file to the Github release', ->
    done = @async()

    # Create curl arguments for Github REST API request
    args = ['-X', 'POST', '--header', 'Content-Type: application/zip', '--upload-file']
    args.push grunt.config.get 'release.file'
    args.push '--url'
    args.push grunt.template.process 'https://uploads.github.com/repos/<%= release.repofullname %>/releases/<%= release.id %>/assets?access_token=<%= release.key %>&name=<%= release.file %>'
    grunt.log.write 'curl args: ' + args

    # Upload Github release asset using REST API
    grunt.util.spawn {
      cmd: 'curl'
      args: args
    }, (err, result, code) ->
      grunt.log.write '\nResult: ' + result + '\n'
      grunt.log.write 'Error: ' + err + '\n'
      grunt.log.write 'Code: ' + code + '\n'

      if result.stdout isnt ''
        obj = JSON.parse result.stdout
        # Check for error from Github
        if 'errors' of obj and obj['errors'].length > 0
          grunt.fail.fatal 'Github Error'

      done(err)
      return
    return

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