diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47151fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +pkg/ +.bundle/ +Gemfile.lock diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e088013 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org/' + +gemspec diff --git a/README.md b/README.md new file mode 100644 index 0000000..7531a55 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +This very simple gem provides a `git release` command, which will +automatically fill out any and all "release tags" into fully-blown "Github +Releases", complete with release notes, a heading, and all the other good +things in life. + +Using this gem, you can turn the following tag annotation: + + First Release + + It is with much fanfare and blowing of horns that I bequeath the + awesomeness of `git release` upon the world. + + Features in this release include: + + * Ability to create a release from a tag annotation or commit message; + * Automatically generates an OAuth token if needed; + * Feeds your cat while you're hacking(*) + + You should install it now! `gem install github-release` + +Into [this](https://github.com/mpalmer/github-release/releases/tag/v0.1.0) +simply by running + + git release + + +# Installation + +Simply install the gem: + + gem install github-release + + +# Usage + +Using `git release` is very simple. Just make sure that your `origin` +remote points to your Github repo, and then run `git release`. All tags +that look like a "version tag" (see "Configuration", below) will be created +as Github releases (if they don't already exist) and the message from the +tag will be used as the release notes. + +The format of the release notes is quite straightforward -- the first line +of the message associated with the commit will be used as the "name" of the +release, with the rest of the message used as the "body" of the release. +The body will be interpreted as Github-flavoured markdown, so if you'd like +to get fancy, go for your life. + +The message associated with the "release tag" is either the tag's annotation +message (if it is an annotated tag) or else the commit log of the commit on +which the tag is placed. I *strongly* recommend annotated tags (but then +again, [I'm biased...](http://theshed.hezmatt.org/git-version-bump)) + +The first time you use `git release`, it will ask you for your Github +username and password. This is used to request an OAuth token to talk to +the Github API, which is then stored in your global git config. Hence you +*shouldn't* be asked for your credentials every time you use `git release`. +If you need to use multiple github accounts for different repos, you can +override the `release.api-token` config parameter in your repo configuration +(but you'll have to get your own OAuth token). + + +# Configuration + +There are a few things you can configure to make `git release` work slightly +differently. None of them should be required for normal, sane use. + + * `release.remote` (default `origin`) -- The name of the remote which is + used to determine what github repository to send release notes to. + + * `release.api-token` (default is runtime generated) -- The OAuth token + to use to authenticate access to the Github API. When you first run `git + release`, you'll be prompted for a username and password to use to + generate an initial token; if you need to override it on a per-repo + basis, this is the key you'll use. + + * `release.tag-regex` (default `v\d+\.\d+(\.\d+)?$`) -- The regular + expression to filter which tags denote releases, as opposed to other tags + you might have decided to make. Only tags which match this regular + expression will be pushed up by `git release`, and only those tags will + be marked as releases. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c3adc7b --- /dev/null +++ b/Rakefile @@ -0,0 +1,20 @@ +require 'rubygems' +require 'bundler' + +begin + Bundler.setup(:default, :development) +rescue Bundler::BundlerError => e + $stderr.puts e.message + $stderr.puts "Run `bundle install` to install missing gems" + exit e.status_code +end + +Bundler::GemHelper.install_tasks + +require 'rdoc/task' + +Rake::RDocTask.new do |rd| + rd.main = "README.md" + rd.title = 'github-release' + rd.rdoc_files.include("README.md", "lib/**/*.rb") +end diff --git a/bin/git-release b/bin/git-release new file mode 100755 index 0000000..a0512e8 --- /dev/null +++ b/bin/git-release @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require 'github-release' + +GithubRelease.new.run diff --git a/github-release.gemspec b/github-release.gemspec new file mode 100644 index 0000000..f0dbf9b --- /dev/null +++ b/github-release.gemspec @@ -0,0 +1,25 @@ +require 'git-version-bump' + +Gem::Specification.new do |s| + s.name = "github-release" + + s.version = GVB.version + s.date = GVB.date + + s.platform = Gem::Platform::RUBY + + s.homepage = "http://theshed.hezmatt.org/github-release" + s.summary = "Upload tag annotations to github" + s.authors = ["Matt Palmer"] + + s.extra_rdoc_files = ["README.md"] + s.files = `git ls-files`.split("\n") + s.executables = ["git-release"] + + s.add_dependency 'octokit' + s.add_dependency 'git-version-bump' + + s.add_development_dependency 'rake' + s.add_development_dependency 'bundler' + s.add_development_dependency 'rdoc' +end diff --git a/lib/github-release.rb b/lib/github-release.rb new file mode 100644 index 0000000..697f7b2 --- /dev/null +++ b/lib/github-release.rb @@ -0,0 +1,135 @@ +require 'octokit' +require 'io/console' + +class GithubRelease + def run + new_releases = tagged_releases.select { |r| !github_releases.include?(r) } + + if new_releases.empty? + puts "No new release tags to push." + end + + new_releases.each { |t| create_release(t) } + + puts "All done!" + end + + private + def api + @api ||= Octokit::Client.new(:access_token => token) + end + + def token + @token ||= begin + # We cannot use the 'defaults' functionality of git_config here, + # because get_new_token would be evaluated before git_config ran + git_config("release.api-token") || get_new_token + end + + log_val(@token) + end + + def get_new_token + puts "Requesting a new OAuth token from Github..." + print "Github username: " + user = $stdin.gets.chomp + print "Github password: " + pass = $stdin.noecho(&:gets).chomp + puts + + api = Octokit::Client.new(:login => user, :password => pass) + begin + res = api.create_authorization(:scopes => [:repo], :note => "git release") + rescue Octokit::Unauthorized + puts "Username or password incorrect. Please try again." + return get_new_token + end + + token = res[:token] + + system("git config --global release.api-token '#{token}'") + + log_val(token) + end + + def tag_regex + @tag_regex ||= `git config --get release.tag-regex`.strip + @tag_regex = /^v\d+\.\d+(\.\d+)?$/ if @tag_regex.empty? + log_val(@tag_regex) + end + + def tagged_releases + @tagged_releases ||= `git tag`.split("\n").map(&:strip).grep tag_regex + log_val(@tagged_releases) + end + + def repo_name + @repo_name ||= begin + case repo_url + when %r{^https://github.com/([^/]+/[^/]+)} + return $1.gsub(/\.git$/, '') + when %r{^(?:git@)?github\.com:([^/]+/[^/]+)} + return $1.gsub(/\.git$/, '') + else + raise RuntimeError, + "I cannot recognise the format of the push URL for remote #{remote_name} (#{repo_url})" + end + end + log_val(@repo_name) + end + + def repo_url + @repo_url ||= begin + git_config("remote.#{remote_name}.pushurl") || git_config("remote.#{remote_name}.url") + end + log_val(@repo_url) + end + + def remote_name + @remote_name ||= git_config("release.remote", "origin") + log_val(@remote_name) + end + + def github_releases + @github_releases ||= api.repo(repo_name).rels[:releases].get.data.map(&:tag_name) + log_val(@github_releases) + end + + def git_config(item, default = nil) + @config_cache ||= {} + + @config_cache[item] ||= begin + v = `git config #{item}`.strip + v.empty? ? default : v + end + + log_val(@config_cache[item], item) + end + + def create_release(tag) + print "Creating a release for #{tag}..." + system("git push #{remote_name} tag #{tag} >/dev/null") + + msg = `git tag -l -n1000 '#{tag}'` + + # Ye ghods is is a horrific format to parse + name, body = msg.split("\n", 2) + name = name.gsub(/^#{tag}/, '').strip + body = body.split("\n").map { |l| l.sub(/^ /, '') }.join("\n") + + api.create_release(repo_name, tag, :name => name, :body => body) + + puts " done!" + end + + def log_val(v, note = nil) + return v unless $DEBUG + + calling_func = caller[0].split("`")[-1].sub(/'$/, '') + + print "#{note}: " if note + puts "#{calling_func} => #{v.inspect}" + + v + end +end