diff --git a/README.md b/README.md index b25dcc1..4fd71dd 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,75 @@ gradle-semantic-release-version =============================== -Gradle plugin that manages the project version based on: +Gradle plugin that manages the project version based on information from the Git repository the project resides in, primarily the Git tags. -- A version file containing the last release version -- Information from the git repository the project resides in +Optionally instead of determining the version from the Git tags, a version file containing the last release version can be used. The plugin works based on the following assumptions: -1. When a release is created - - The release version is written to the version file - - The task `setReleaseVersion` can be used for that - - A commit is created the includes the change to the version file and other release related changes - - A tag is created that marks the commit as the release -2. If the git repository is clean and HEAD points to a tag, the project version is the release version -3. If the git repository is not clean or HEAD does not point to a tag, the project version is a SNAPSHOT version that increases the minor version compared to the last release version -4. If no release version is configured or the version file is missing, the project version is `1.0.0-SNAPSHOT` +1. When a release is created a respective Git tag is created that marks a commit as the release +2. Versions use semantic versioning (`..`) and tags follow this pattern or have a `v` as prefix +3. If the git repository is clean and HEAD points to such a tag, the project version is the release version +4. If the git repository is not clean or HEAD does not point to such a tag, the project version is a SNAPSHOT version that increases the minor version compared to the last release version +5. If no release version can be determined, the project version is `1.0.0-SNAPSHOT` -For a tag to be recognized it needs to match the configured release version, optionally with the prefix `v`, for example `1.0.0` or `v1.0.0`. +Plugin variants +--------------- + +There are two variants of the plugin: + +1. `semantic-release-version` is the variant that uses default settings and is not configurable +2. `semantic-release-version-custom` is the variant that is configurable where the behavior can be adapted + +The `semantic-release-version` plugin does not use a version file and assumes tags that use the semantic version for a release, optionally including a prefix `v`. + +Easiest way to use it is using the version published in the [Gradle Plugin Portal](https://plugins.gradle.org/): + +```groovy +plugins { + id 'to.wetransform.semantic-release-version' version '' +} +``` + +The `semantic-release-version-custom` plugin allows adapting the plugin configuration and changing settings related to which tags are recognized as version tags and also allows to determine the last release version from a file that is part of the repository. + +```groovy +plugins { + id 'to.wetransform.semantic-release-version-custom' version '' +} +``` + +When using this plugin variant you need to be aware that the version is only set after evaluation of the Gradle configuration. +That means any logic using the project version also must be executed after evaluation, for example: + +```groovy +afterEvaluate { + // access `version` +} +``` + +Use release version for dirty repository +---------------------------------------- If the environment variable `RELEASE` is set to `true`, the release version is used, even if the repository is not clean. -This is intended for use cases where the release version was set, but the repository is expected to be dirty, e.g. due to other CI tasks. +This is intended for use cases where a release tag was created, but the repository is expected to be dirty, e.g. due to other CI tasks. + A cleaner method is avoid the repository being dirty, e.g. by adding additional files that are created during the release process to `.gitignore` if possible. +Configuration +------------- + +Please note that configuring the plugin is only possible when using the plugin variant `semantic-release-version-custom`. + +### Using a version file + +If you do not want to rely on determining the last release version from Git, you can instead use a version file. + +In that case when a release is created it is expected that: + +- The release version is written to the version file + - The task `setReleaseVersion` can be used for that +- A commit is created the includes the change to the version file and other release related changes +- A tag is created that marks the commit as the release + By default the version file is assumed to be the file `version.txt` in the root project. diff --git a/build.gradle b/build.gradle index c14aea8..3c6bd00 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,15 @@ gradlePlugin { id = 'to.wetransform.semantic-release-version' implementationClass = 'to.wetransform.gradle.version.VersionPlugin' displayName = 'semantic-release-version' - description = 'Gradle plugin that determines the current version from a file with the last release version and information from Git. Intended to be used with semantic-release.' + description = 'Gradle plugin that determines the current release or SNAPSHOT version from Git based on existing tags. Intended to be used with semantic-release.' + tags.set(['semver', 'release', 'version', 'semantic-release', 'git']) + } + + configurableVersionPlugin { + id = 'to.wetransform.semantic-release-version-custom' + implementationClass = 'to.wetransform.gradle.version.ConfigurableVersionPlugin' + displayName = 'semantic-release-version-custom' + description = 'Gradle plugin that determines the current version based on information from Git and optionally a version file. Compared to the semantic-release-version plugin the behavior can be customized.' tags.set(['semver', 'release', 'version', 'semantic-release']) } } diff --git a/src/functionalTest/java/to/wetransform/gradle/version/VersionPluginFunctionalTest.java b/src/functionalTest/java/to/wetransform/gradle/version/VersionPluginFunctionalTest.java index dc116f5..2f81e39 100644 --- a/src/functionalTest/java/to/wetransform/gradle/version/VersionPluginFunctionalTest.java +++ b/src/functionalTest/java/to/wetransform/gradle/version/VersionPluginFunctionalTest.java @@ -28,7 +28,7 @@ public void canRunTask() throws IOException { BuildResult result = GradleRunner.create() .forwardOutput() .withPluginClasspath() - .withArguments("greet") + .withArguments("showVersion") .withProjectDir(projectDir) .build(); diff --git a/src/main/groovy/to/wetransform/gradle/version/AbstractVersionPlugin.groovy b/src/main/groovy/to/wetransform/gradle/version/AbstractVersionPlugin.groovy new file mode 100644 index 0000000..ba77e21 --- /dev/null +++ b/src/main/groovy/to/wetransform/gradle/version/AbstractVersionPlugin.groovy @@ -0,0 +1,194 @@ +package to.wetransform.gradle.version; + +import java.util.function.BiPredicate +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +abstract class AbstractVersionPlugin implements Plugin { + + private static def SEM_VER_REGEX = /^(\d+)\.(\d+)\.(\d+)$/ + + private static def SEM_VER_EXTRACT_REGEX = /((\d+)\.(\d+)\.(\d+))$/ + + public static def DEFAULT_SNAPSHOT = '1.0.0-SNAPSHOT' + + private final boolean allowConfiguration + + protected AbstractVersionPlugin(boolean allowConfiguration) { + this.allowConfiguration = allowConfiguration + } + + void apply(Project project) { + //XXX not sure how to easiest use the service - instead the repo is opened manually + // project.apply(plugin: 'org.ajoberstar.grgit.service') + + // register extension + VersionExtension extension + if (allowConfiguration) { + extension = project.extensions.create('versionConfig', VersionExtension, project) + } + else { + extension = new VersionExtension(project) + } + + // define tasks + project.task('showVersion') { + group 'Version' + description 'Print current project version' + + doLast { + println "Version: ${project.version}" + } + } + + if (allowConfiguration || extension.useVersionFile) { + project.task('setReleaseVersion') { + group 'Version' + description 'Set a new release version (write to version file), provide version to set as Gradle property `newVersion`' + + doLast { + def versionFile = project.versionConfig.versionFile + versionFile.text = project.properties['newVersion'] + } + } + } + + project.task('verifyReleaseVersion') { + group 'Version' + description 'Check if a release version is configured, otherwise (if the version is a -SNAPSHOT version) fail' + + doLast { + def version = project.version + + assert version + assert !version.endsWith('-SNAPSHOT') + assert version =~ SEM_VER_REGEX + } + } + + // set version + if (allowConfiguration) { + project.afterEvaluate { + // apply after evaluate to allow configuring settings + applyVersion(it, extension) + } + } + else { + // directly apply with defaults + applyVersion(project, extension) + } + } + + void applyVersion(Project project, VersionExtension extension) { + if (project.version != Project.DEFAULT_VERSION) { + throw new IllegalStateException("Version may not be configured if version plugin is applied") + } else { + project.version = determineVersion( + project, extension.useVersionFile, extension.tagGlobPatterns, extension.versionFile, extension.gitDir, extension.verifyTag + ) + } + } + + String determineVersion(Project project, boolean useVersionFile, Iterable tagMatchPatterns, File versionFile, File gitDir, BiPredicate verifyTag) { + def releaseVersion = null + def grgit = null + + if (useVersionFile) { + // read version from file + if (versionFile.exists()) { + releaseVersion = versionFile.text.trim() + + // verify version + if (releaseVersion) { + def match = releaseVersion ==~ SEM_VER_REGEX + if (!match) { + throw new IllegalStateException("Provided version for last release is not a valid semantic version: $releaseVersion") + } + } + } + + if (!releaseVersion) { + // assume initial snapshot + project.logger.info("Version file does not exist or contains no version, assuming ${DEFAULT_SNAPSHOT}") + return DEFAULT_SNAPSHOT + } + } else { + // use information from git to determine last version + try { + grgit = org.ajoberstar.grgit.Grgit.open(dir: gitDir) + } catch (Exception e) { + project.logger.warn("Could not open Git repository in $gitDir", e) + } + + if (!grgit) { + project.logger.info("No Git repository found, assuming ${DEFAULT_SNAPSHOT} as version") + return DEFAULT_SNAPSHOT + } + + def describe = grgit.describe(abbrev: 0, tags: true, match: tagMatchPatterns as List) + if (!describe) { + // nothing found + project.logger.info("No tag found for determining version, assuming ${DEFAULT_SNAPSHOT}") + return DEFAULT_SNAPSHOT + } + else { + def matcher = describe =~ SEM_VER_EXTRACT_REGEX + if (matcher) { + releaseVersion = matcher[0][1] + } + else { + throw new IllegalStateException("Cannot extract release version from tag: $describe") + } + } + } + + def dirty = false + def tagOnCurrentCommit = false + if (grgit == null) { + try { + grgit = org.ajoberstar.grgit.Grgit.open(dir: gitDir) + } catch (Exception e) { + project.logger.warn("Could not open Git repository in $gitDir", e) + grgit = null + } + } + if (grgit) { + dirty = !grgit.status().isClean() + def currentCommit = grgit.head().id + tagOnCurrentCommit = grgit.tag.list().findAll { tag -> + tag.commit.id == currentCommit && verifyTag.test(tag.name, releaseVersion) + } + } + + if ('true'.equalsIgnoreCase(System.getenv('RELEASE'))) { + // force release version if repo is dirty (e.g. during release in CI) + // but still verify tag + if (tagOnCurrentCommit) { + return releaseVersion + } + else { + throw new IllegalStateException("There is no matching tag for the configured release version $releaseVersion") + } + } + + if (tagOnCurrentCommit && !dirty) { + project.logger.info("Current commit is tagged and repository clean, using release version specified in file: $releaseVersion") + releaseVersion + } + else { + // build snapshot version with next minor version + def matcher = releaseVersion =~ SEM_VER_REGEX + if (matcher) { + project.logger.info("Current commit is not tagged or repository is dirty, using snapshot version based on last release") + + def major = matcher[0][1] as int + def minor = matcher[0][2] as int + + "${major}.${minor+1}.0-SNAPSHOT" + } + else { + throw new IllegalStateException("Provided version not a semantic version") + } + } + } +} diff --git a/src/main/groovy/to/wetransform/gradle/version/ConfigurableVersionPlugin.groovy b/src/main/groovy/to/wetransform/gradle/version/ConfigurableVersionPlugin.groovy new file mode 100644 index 0000000..a8a58ee --- /dev/null +++ b/src/main/groovy/to/wetransform/gradle/version/ConfigurableVersionPlugin.groovy @@ -0,0 +1,10 @@ +package to.wetransform.gradle.version + +/** + * Version plugin that allows customization through VersionExtension. + */ +class ConfigurableVersionPlugin extends AbstractVersionPlugin { + ConfigurableVersionPlugin() { + super(true) + } +} diff --git a/src/main/groovy/to/wetransform/gradle/version/VersionExtension.groovy b/src/main/groovy/to/wetransform/gradle/version/VersionExtension.groovy index 8290a26..14dfc24 100644 --- a/src/main/groovy/to/wetransform/gradle/version/VersionExtension.groovy +++ b/src/main/groovy/to/wetransform/gradle/version/VersionExtension.groovy @@ -10,6 +10,18 @@ class VersionExtension { this.gitDir = project.rootProject.projectDir } + /** + * States if a version file is used to determine the (release) version. + * If disabled will solely rely on informaton on Git tags. + */ + boolean useVersionFile = false + + /** + * Valid glob patterns for selecting tags when determininig version from Git. + * See also https://git-scm.com/docs/git-describe#Documentation/git-describe.txt---matchltpatterngt + */ + Iterable tagGlobPatterns = ["*.*.*"] + /** * Location of the file that holds the last release version, if it exists. */ diff --git a/src/main/groovy/to/wetransform/gradle/version/VersionPlugin.groovy b/src/main/groovy/to/wetransform/gradle/version/VersionPlugin.groovy index c3a6dda..4ffcf22 100644 --- a/src/main/groovy/to/wetransform/gradle/version/VersionPlugin.groovy +++ b/src/main/groovy/to/wetransform/gradle/version/VersionPlugin.groovy @@ -1,133 +1,10 @@ -package to.wetransform.gradle.version; - -import java.util.function.BiPredicate -import org.gradle.api.Plugin; -import org.gradle.api.Project; - -class VersionPlugin implements Plugin { - - private static def SEM_VER_REGEX = /(\d+)\.(\d+)\.(\d+)/ - - void apply(Project project) { - //XXX not sure how to easiest use the service - instead the repo is opened manually - // project.apply(plugin: 'org.ajoberstar.grgit.service') - - // register extension - project.extensions.create('versionConfig', VersionExtension, project) - - // define tasks - project.task('showVersion') { - group 'Version' - description 'Print current project version' - - doLast { - println "Version: ${project.version}" - } - } - - project.task('setReleaseVersion') { - group 'Version' - description 'Set a new release version (write to version file), provide version to set as Gradle property `newVersion`' - - doLast { - def versionFile = project.versionConfig.versionFile - versionFile.text = project.properties['newVersion'] - } - } - - project.task('verifyReleaseVersion') { - group 'Version' - description 'Check if a release version is configured, otherwise (if the version is a -SNAPSHOT version) fail' - - doLast { - def version = project.version - - assert version - assert !version.endsWith('-SNAPSHOT') - assert version =~ SEM_VER_REGEX - } - } - - // set version - - project.afterEvaluate { - if(it.version != Project.DEFAULT_VERSION) { - throw new IllegalStateException("Version may not be configured if version plugin is applied") - } else { - it.version = determineVersion( - it, it.versionConfig.versionFile, it.versionConfig.gitDir, it.versionConfig.verifyTag - ) - } - } - } - - String determineVersion(Project project, File versionFile, File gitDir, BiPredicate verifyTag) { - // read version from file - def releaseVersion = null - if (versionFile.exists()) { - releaseVersion = versionFile.text.trim() - - // verify version - if (releaseVersion) { - def matcher = releaseVersion =~ SEM_VER_REGEX - if (!matcher) { - throw new IllegalStateException("Provided version for last release is not a valid semantic version: $releaseVersion") - } - } - } - - if (!releaseVersion) { - // assume initial snapshot - project.logger.info("Version file does not exist or contains no version, assuming 1.0.0-SNAPSHOT") - return '1.0.0-SNAPSHOT' - } - - def dirty = false - def tagOnCurrentCommit = false - def grgit - try { - grgit = org.ajoberstar.grgit.Grgit.open(dir: gitDir) - } catch (IllegalStateException e) { - project.logger.warn("Could not open Git repository in $gitDir", e) - grgit = null - } - if (grgit) { - dirty = !grgit.status().isClean() - def currentCommit = grgit.head().id - tagOnCurrentCommit = grgit.tag.list().findAll { tag -> - tag.commit.id == currentCommit && verifyTag.test(tag.name, releaseVersion) - } - } - - if ('true'.equalsIgnoreCase(System.getenv('RELEASE'))) { - // force release version if repo is dirty (e.g. during release in CI) - // but still verify tag - if (tagOnCurrentCommit) { - return releaseVersion - } - else { - throw new IllegalStateException("There is no matching tag for the configured release version $releaseVersion") - } - } - - if (tagOnCurrentCommit && !dirty) { - project.logger.info("Current commit is tagged and repository clean, using release version specified in file: $releaseVersion") - releaseVersion - } - else { - // build snapshot version with next minor version - def matcher = releaseVersion =~ SEM_VER_REGEX - if (matcher) { - project.logger.info("Current commit is not tagged or repository is dirty, using snapshot version based on last release") - - def major = matcher[0][1] as int - def minor = matcher[0][2] as int - - "${major}.${minor+1}.0-SNAPSHOT" - } - else { - throw new IllegalStateException("Provided version not a semantic version") - } - } +package to.wetransform.gradle.version + +/** + * Version plugin that does not allow configuration and uses defaults. + */ +class VersionPlugin extends AbstractVersionPlugin { + VersionPlugin() { + super(false) } } diff --git a/src/test/java/to/wetransform/gradle/version/ConfigurableVersionPluginTest.java b/src/test/java/to/wetransform/gradle/version/ConfigurableVersionPluginTest.java new file mode 100644 index 0000000..ab193b4 --- /dev/null +++ b/src/test/java/to/wetransform/gradle/version/ConfigurableVersionPluginTest.java @@ -0,0 +1,26 @@ +package to.wetransform.gradle.version; + +import org.gradle.api.Project; +import org.gradle.testfixtures.ProjectBuilder; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ConfigurableVersionPluginTest { + @Test + public void pluginRegistersATask() { + // Create a test project and apply the plugin + Project project = ProjectBuilder.builder().build(); + project.getPlugins().apply("to.wetransform.semantic-release-version-custom"); + + // Verify that tasks exist + assertThat(project.getTasks().findByName("showVersion")).isNotNull(); + assertThat(project.getTasks().findByName("setReleaseVersion")).isNotNull(); + assertThat(project.getTasks().findByName("verifyReleaseVersion")).isNotNull(); + + // Verify that version is not set + assertThat(project.getVersion()).isEqualTo(Project.DEFAULT_VERSION); + + //TODO test default version after evaluation + } +} diff --git a/src/test/java/to/wetransform/gradle/version/VersionPluginTest.java b/src/test/java/to/wetransform/gradle/version/VersionPluginTest.java index 7825c36..3605d32 100644 --- a/src/test/java/to/wetransform/gradle/version/VersionPluginTest.java +++ b/src/test/java/to/wetransform/gradle/version/VersionPluginTest.java @@ -15,11 +15,10 @@ public void pluginRegistersATask() { // Verify that tasks exist assertThat(project.getTasks().findByName("showVersion")).isNotNull(); - assertThat(project.getTasks().findByName("setReleaseVersion")).isNotNull(); + assertThat(project.getTasks().findByName("setReleaseVersion")).isNull(); + assertThat(project.getTasks().findByName("verifyReleaseVersion")).isNotNull(); // Verify that version is not set - assertThat(project.getVersion()).isEqualTo(Project.DEFAULT_VERSION); - - //TODO test default version after evaluation + assertThat(project.getVersion()).isEqualTo(AbstractVersionPlugin.DEFAULT_SNAPSHOT); } }