diff --git a/README.md b/README.md index faa106c3..b96af2aa 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,32 @@ Now let's also add steps for [posterous-sbt](https://github.com/n8han/posterous- The `check` part of the release step is run at the start, to make sure we have everything set up to post the release notes later on. After publishing the actual build artifacts, we also publish the release notes. +#### GitFlow +[Gitflow](http://nvie.com/posts/a-successful-git-branching-model/) is a very popular Git branching method for creating +releases. This involves creating a new release branch from the develop branch. + + import ReleaseTransformations._ + import sbtrelease._ + + // ... + + releaseProcess := Seq[ReleaseStep]( + checkSnapshotDependencies, + inquireVersions, + inquireBranches, + setReleaseVersion, + commitReleaseVersion, + setReleaseBranch, + pushChanges, + setNextBranch, + setNextVersion, + commitNextVersion, + pushChanges + ) + +`inquireBranches` will ask for a release branch name where the release version will get pushed. It will then +switch back to the current branch before committing and pushing next version. + ## Credits Thank you, [Jason](https://github.com/retronym) and [Mark](https://github.com/harrah), for your feedback and ideas. diff --git a/src/main/scala/ReleaseExtra.scala b/src/main/scala/ReleaseExtra.scala index 349df099..8a2f4296 100644 --- a/src/main/scala/ReleaseExtra.scala +++ b/src/main/scala/ReleaseExtra.scala @@ -52,6 +52,16 @@ object ReleaseStateTransformations { } + lazy val inquireBranches: ReleaseStep = { st: State => + val releaseBranch = st.get(commandLineReleaseBranch).flatten.getOrElse(SimpleReader.readLine("Release branch : ") match { + case Some(input) => input.trim + case None => sys.error("No branch provided!") + }) + val nextBranch = st.get(commandLineNextBranch).flatten.getOrElse(vcs(st).currentBranch) + + st.put(branches, (releaseBranch, nextBranch)) + } + lazy val runClean : ReleaseStep = ReleaseStep( action = { st: State => @@ -92,6 +102,23 @@ object ReleaseStateTransformations { ), st) } + lazy val setReleaseBranch: ReleaseStep = setBranch(_._1) + lazy val setNextBranch: ReleaseStep = setBranch(_._2) + private[sbtrelease] def setBranch(selectBranch: Branches => String): ReleaseStep = { st: State => + val vs = st.get(branches).getOrElse(sys.error("No branches are set! Was this release part executed before inquireBranches?")) + val selected = selectBranch(vs) + + st.log.info(s"Checking out $selected") + val vc = vcs(st) + val processLogger: ProcessLogger = if (vc.isInstanceOf[Git]) { + // Git outputs to standard error, so use a logger that redirects stderr to info + vc.stdErrorToStdOut(st.log) + } else st.log + vc.setBranch(selected) !! processLogger + + st + } + private def vcs(st: State): Vcs = { st.extract.get(releaseVcs).getOrElse(sys.error("Aborting release. Working directory is not a repository of a recognized VCS.")) } @@ -213,9 +240,6 @@ object ReleaseStateTransformations { lazy val pushChanges: ReleaseStep = ReleaseStep(pushChangesAction, checkUpstream) private[sbtrelease] lazy val checkUpstream = { st: State => - if (!vcs(st).hasUpstream) { - sys.error("No tracking branch is set up. Either configure a remote tracking branch, or remove the pushChanges release part.") - } val defaultChoice = extractDefault(st, "n") val log = toProcessLogger(st) @@ -250,18 +274,18 @@ object ReleaseStateTransformations { val vc = vcs(st) if (vc.hasUpstream) { - defaultChoice orElse SimpleReader.readLine("Push changes to the remote repository (y/n)? [y] ") match { - case Yes() | Some("") => - val processLogger: ProcessLogger = if (vc.isInstanceOf[Git]) { - // Git outputs to standard error, so use a logger that redirects stderr to info - vc.stdErrorToStdOut(log) - } else log - vc.pushChanges !! processLogger - case _ => st.log.warn("Remember to push the changes yourself!") - } + defaultChoice orElse SimpleReader.readLine("Push changes to the remote repository (y/n)? [y] ") match { + case Yes() | Some("") => + val processLogger: ProcessLogger = if (vc.isInstanceOf[Git]) { + // Git outputs to standard error, so use a logger that redirects stderr to info + vc.stdErrorToStdOut(st.log) + } else st.log + vc.pushChanges(!vc.hasUpstream) !! processLogger + case _ => st.log.warn("Remember to push the changes yourself!") } else { st.log.info("Changes were NOT pushed, because no upstream branch is configured for the local branch [%s]" format vcs(st).currentBranch) } + st } @@ -335,12 +359,21 @@ object ExtraReleaseCommands { private lazy val inquireVersionsCommandKey = "release-inquire-versions" lazy val inquireVersionsCommand = Command.command(inquireVersionsCommandKey)(inquireVersions) + private lazy val inquireBranchesCommandKey = "release-branches-versions" + lazy val inquireBranchesCommand = Command.command(inquireBranchesCommandKey)(inquireBranches) + private lazy val setReleaseVersionCommandKey = "release-set-release-version" lazy val setReleaseVersionCommand = Command.command(setReleaseVersionCommandKey)(setReleaseVersion) private lazy val setNextVersionCommandKey = "release-set-next-version" lazy val setNextVersionCommand = Command.command(setNextVersionCommandKey)(setNextVersion) + private lazy val setReleaseBranchCommandKey = "release-set-release-branch" + lazy val setReleaseBranchCommand = Command.command(setReleaseBranchCommandKey)(setReleaseBranch) + + private lazy val setNextBranchCommandKey = "release-set-next-branch" + lazy val setNextBranchCommand = Command.command(setNextBranchCommandKey)(setNextBranch) + private lazy val commitReleaseVersionCommandKey = "release-commit-release-version" lazy val commitReleaseVersionCommand = Command.command(commitReleaseVersionCommandKey)(commitReleaseVersion) diff --git a/src/main/scala/ReleasePlugin.scala b/src/main/scala/ReleasePlugin.scala index 4c77fe30..98899b97 100644 --- a/src/main/scala/ReleasePlugin.scala +++ b/src/main/scala/ReleasePlugin.scala @@ -119,8 +119,11 @@ object ReleasePlugin extends AutoPlugin { object ReleaseKeys { val versions = AttributeKey[Versions]("releaseVersions") + val branches = AttributeKey[Branches]("releaseBranches") val commandLineReleaseVersion = AttributeKey[Option[String]]("release-input-release-version") val commandLineNextVersion = AttributeKey[Option[String]]("release-input-next-version") + val commandLineReleaseBranch = AttributeKey[Option[String]]("release-input-release-branch") + val commandLineNextBranch = AttributeKey[Option[String]]("release-input-next-branch") val useDefaults = AttributeKey[Boolean]("releaseUseDefaults") val skipTests = AttributeKey[Boolean]("releaseSkipTests") val cross = AttributeKey[Boolean]("releaseCross") @@ -139,6 +142,10 @@ object ReleasePlugin extends AutoPlugin { (Space ~> token("release-version") ~> Space ~> token(StringBasic, "")) map ParseResult.ReleaseVersion private[this] val NextVersion: Parser[ParseResult] = (Space ~> token("next-version") ~> Space ~> token(StringBasic, "")) map ParseResult.NextVersion + private[this] val ReleaseBranch: Parser[ParseResult] = + (Space ~> token("release-branch") ~> Space ~> token(StringBasic, "")) map ParseResult.ReleaseBranch + private[this] val NextBranch: Parser[ParseResult] = + (Space ~> token("next-branch") ~> Space ~> token(StringBasic, "")) map ParseResult.NextBranch private[this] val TagDefault: Parser[ParseResult] = (Space ~> token("default-tag-exists-answer") ~> Space ~> token(StringBasic, "o|k|a|")) map ParseResult.TagDefault @@ -147,13 +154,15 @@ object ReleasePlugin extends AutoPlugin { private[this] object ParseResult { final case class ReleaseVersion(value: String) extends ParseResult final case class NextVersion(value: String) extends ParseResult + final case class ReleaseBranch(value: String) extends ParseResult + final case class NextBranch(value: String) extends ParseResult final case class TagDefault(value: String) extends ParseResult case object WithDefaults extends ParseResult case object SkipTests extends ParseResult case object CrossBuild extends ParseResult } - private[this] val releaseParser: Parser[Seq[ParseResult]] = (ReleaseVersion | NextVersion | WithDefaults | SkipTests | CrossBuild | TagDefault).* + private[this] val releaseParser: Parser[Seq[ParseResult]] = (ReleaseVersion | NextVersion | ReleaseBranch | NextBranch | WithDefaults | SkipTests | CrossBuild | TagDefault).* val releaseCommand: Command = Command(releaseCommandKey)(_ => releaseParser) { (st, args) => val extracted = Project.extract(st) @@ -168,6 +177,8 @@ object ReleasePlugin extends AutoPlugin { .put(tagDefault, args.collectFirst{case ParseResult.TagDefault(value) => value}) .put(commandLineReleaseVersion, args.collectFirst{case ParseResult.ReleaseVersion(value) => value}) .put(commandLineNextVersion, args.collectFirst{case ParseResult.NextVersion(value) => value}) + .put(commandLineReleaseBranch, args.collectFirst{case ParseResult.ReleaseBranch(value) => value}) + .put(commandLineNextBranch, args.collectFirst{case ParseResult.NextBranch(value) => value}) val initialChecks = releaseParts.map(_.check) diff --git a/src/main/scala/Vcs.scala b/src/main/scala/Vcs.scala index e27c4d80..caac48f9 100644 --- a/src/main/scala/Vcs.scala +++ b/src/main/scala/Vcs.scala @@ -22,8 +22,9 @@ trait Vcs { def hasUpstream: Boolean def trackingRemote: String def isBehindRemote: Boolean - def pushChanges: ProcessBuilder + def pushChanges(withUpstream: Boolean): ProcessBuilder def currentBranch: String + def setBranch(branch: String): ProcessBuilder def hasUntrackedFiles: Boolean = untrackedFiles.nonEmpty def untrackedFiles: Seq[String] def hasModifiedFiles: Boolean = modifiedFiles.nonEmpty @@ -107,10 +108,12 @@ class Mercurial(val baseDir: File) extends Vcs with GitLike { def isBehindRemote = cmd("incoming", "-b", ".", "-q") ! devnull == 0 - def pushChanges = cmd("push", "-b", ".") + def pushChanges(withUpstream: Boolean) = cmd("push", "-b", ".") def currentBranch = cmd("branch").!!.trim + def setBranch(branch: String) = throw sys.error("Branch switching not currently supported in hg") + // FIXME: This is utterly bogus, but I cannot find a good way... def checkRemote(remote: String) = cmd("id", "-n") @@ -131,13 +134,21 @@ class Git(val baseDir: File) extends Vcs with GitLike { private lazy val trackingBranchCmd = cmd("config", "branch.%s.merge" format currentBranch) private def trackingBranch: String = (trackingBranchCmd !!).trim.stripPrefix("refs/heads/") - private lazy val trackingRemoteCmd: ProcessBuilder = cmd("config", "branch.%s.remote" format currentBranch) - def trackingRemote: String = trackingRemoteCmd.!!.trim + private def trackingRemoteCmd(branch: String): ProcessBuilder = cmd("config", "branch.%s.remote" format branch) + def trackingRemote: String = (trackingRemoteCmd(currentBranch) !!) trim - def hasUpstream = trackingRemoteCmd ! devnull == 0 && trackingBranchCmd ! devnull == 0 + def hasUpstream = trackingRemoteCmd(currentBranch) ! devnull == 0 && trackingBranchCmd ! devnull == 0 def currentBranch = cmd("symbolic-ref", "HEAD").!!.trim.stripPrefix("refs/heads/") + def setBranch(branch: String) = { + if (trackingRemoteCmd(branch) ! devnull != 0) { + val currentRemote = trackingRemote + cmd("checkout", "-b", branch).!! + cmd("config", "--add", "branch.%s.remote".format(branch), currentRemote) + } else cmd("checkout", branch) + } + def currentHash = revParse("HEAD") private def revParse(name: String) = cmd("rev-parse", name).!!.trim @@ -176,11 +187,13 @@ class Git(val baseDir: File) extends Vcs with GitLike { def status = cmd("status", "--porcelain") - def pushChanges = pushCurrentBranch #&& pushTags + def pushChanges(setUpstream: Boolean) = pushCurrentBranch(setUpstream) #&& pushTags - private def pushCurrentBranch = { + private def pushCurrentBranch(setUpstream: Boolean) = { val localBranch = currentBranch - cmd("push", trackingRemote, "%s:%s" format (localBranch, trackingBranch)) + if (setUpstream) { + cmd ("push", "-u", trackingRemote, localBranch) + } else cmd("push", trackingRemote, "%s:%s" format (localBranch, trackingBranch)) } private def pushTags = cmd("push", "--tags", trackingRemote) @@ -217,7 +230,9 @@ class Subversion(val baseDir: File) extends Vcs { override def currentBranch: String = workingDirSvnUrl.substring(workingDirSvnUrl.lastIndexOf("/") + 1) - override def pushChanges: ProcessBuilder = commit("push changes", false, false) + def setBranch(branch: String) = throw sys.error("Branch switching not currently supported in svn") + + override def pushChanges(withUpstream: Boolean): ProcessBuilder = commit("push changes", false, false) override def isBehindRemote: Boolean = false diff --git a/src/main/scala/package.scala b/src/main/scala/package.scala index 267be3cb..67b9c7a2 100644 --- a/src/main/scala/package.scala +++ b/src/main/scala/package.scala @@ -1,5 +1,6 @@ package object sbtrelease { type Versions = (String, String) + type Branches = (String, String) def versionFormatError = sys.error("Version format is not compatible with " + Version.VersionR.pattern.toString) } diff --git a/src/sbt-test/sbt-release/command-line-version-numbers/build.sbt b/src/sbt-test/sbt-release/command-line-version-numbers/build.sbt index 23b2b0aa..cbf0f8a7 100644 --- a/src/sbt-test/sbt-release/command-line-version-numbers/build.sbt +++ b/src/sbt-test/sbt-release/command-line-version-numbers/build.sbt @@ -19,8 +19,8 @@ val parser = Space ~> StringBasic checkContentsOfVersionSbt := { val expected = parser.parsed - val versionFile = ((baseDirectory).value) / "version.sbt" - assert(IO.read(versionFile).contains(expected), s"does not contains ${expected} in ${versionFile}") + val versionFile = ((baseDirectory).value) / "version.sbt" + assert(IO.read(versionFile).contains(expected), s"does not contains ${expected} in ${versionFile}") } diff --git a/src/sbt-test/sbt-release/gitflow/.gitignore b/src/sbt-test/sbt-release/gitflow/.gitignore new file mode 100644 index 00000000..3a3aee2e --- /dev/null +++ b/src/sbt-test/sbt-release/gitflow/.gitignore @@ -0,0 +1,2 @@ +target +global/ diff --git a/src/sbt-test/sbt-release/gitflow/build.sbt b/src/sbt-test/sbt-release/gitflow/build.sbt new file mode 100644 index 00000000..3d7069ca --- /dev/null +++ b/src/sbt-test/sbt-release/gitflow/build.sbt @@ -0,0 +1,23 @@ +import ReleaseTransformations._ +import sbt.complete.DefaultParsers._ + +releaseProcess := Seq( + checkSnapshotDependencies, + inquireVersions, + inquireBranches, + setReleaseVersion, + commitReleaseVersion, + setReleaseBranch, + setNextBranch, + setNextVersion, + commitNextVersion +) + +val checkContentsOfVersionSbt = inputKey[Unit]("Check that the contents of version.sbt is as expected") +val parser = Space ~> StringBasic + +checkContentsOfVersionSbt := { + val expected = parser.parsed + val versionFile = ((baseDirectory).value) / "version.sbt" + assert(IO.read(versionFile).contains(expected), s"does not contains ${expected} in ${versionFile}") +} \ No newline at end of file diff --git a/src/sbt-test/sbt-release/gitflow/project/build.sbt b/src/sbt-test/sbt-release/gitflow/project/build.sbt new file mode 100644 index 00000000..ebda5784 --- /dev/null +++ b/src/sbt-test/sbt-release/gitflow/project/build.sbt @@ -0,0 +1,7 @@ +{ + val pluginVersion = System.getProperty("plugin.version") + if(pluginVersion == null) + throw new RuntimeException("""|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) + else addSbtPlugin("com.github.gseitz" % "sbt-release" % pluginVersion) +} diff --git a/src/sbt-test/sbt-release/gitflow/test b/src/sbt-test/sbt-release/gitflow/test new file mode 100644 index 00000000..b7078f12 --- /dev/null +++ b/src/sbt-test/sbt-release/gitflow/test @@ -0,0 +1,11 @@ +$ exec git init . +$ exec git config --add branch.master.remote origin +$ exec git add . +$ exec git commit -m init + +> 'release release-version 0.7.0 next-version 1.0.0-SNAPSHOT release-branch release-test next-branch master' +> checkContentsOfVersionSbt 1.0.0-SNAPSHOT +$ exec git checkout release-test +> checkContentsOfVersionSbt 0.7.0 + +-> release with-defaults diff --git a/src/sbt-test/sbt-release/gitflow/version.sbt b/src/sbt-test/sbt-release/gitflow/version.sbt new file mode 100644 index 00000000..57b0bcbc --- /dev/null +++ b/src/sbt-test/sbt-release/gitflow/version.sbt @@ -0,0 +1 @@ +version in ThisBuild := "0.1.0-SNAPSHOT"