Articles & Publications
Discover the best tools and products to aid in your development workflow.
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!