From 4ba9e21c019fb246c05fa25d28ac9e2edda01309 Mon Sep 17 00:00:00 2001 From: Simon Klein Date: Fri, 29 May 2020 15:14:20 +0200 Subject: [PATCH 1/6] #30 Add release process functions --- README.md | 108 +++++++- .../cloudogu/ces/cesbuildlib/Changelog.groovy | 83 +++++++ src/com/cloudogu/ces/cesbuildlib/Git.groovy | 232 +++++++++++++----- .../cloudogu/ces/cesbuildlib/GitFlow.groovy | 71 ++++++ .../cloudogu/ces/cesbuildlib/GitHub.groovy | 77 ++++++ .../ces/cesbuildlib/MavenLocal.groovy | 2 +- .../ces/cesbuildlib/ChangelogTest.groovy | 82 +++++++ .../ces/cesbuildlib/GitFlowTest.groovy | 84 +++++++ .../ces/cesbuildlib/GitHubTest.groovy | 99 ++++++++ .../cloudogu/ces/cesbuildlib/GitTest.groovy | 130 ++++++---- .../ces/cesbuildlib/ScriptMock.groovy | 42 ++-- 11 files changed, 876 insertions(+), 134 deletions(-) create mode 100644 src/com/cloudogu/ces/cesbuildlib/Changelog.groovy create mode 100644 src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy create mode 100644 src/com/cloudogu/ces/cesbuildlib/GitHub.groovy create mode 100644 test/com/cloudogu/ces/cesbuildlib/ChangelogTest.groovy create mode 100644 test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy create mode 100644 test/com/cloudogu/ces/cesbuildlib/GitHubTest.groovy diff --git a/README.md b/README.md index 11d78d95..bd970904 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Jenkins Pipeline Shared library, that contains additional features for Git, Mave - [Branches](#branches) - [SonarCloud](#sonarcloud) - [Pull Requests in SonarQube](#pull-requests-in-sonarqube) +- [Changelog](#changelog) + - [changelogFileName](#changelogFileName) +- [GitHub](#github) +- [GitFlow](#gitflow) - [Steps](#steps) - [mailIfStatusChanged](#mailifstatuschanged) - [isPullRequest](#ispullrequest) @@ -484,23 +488,17 @@ gitWithCreds 'https://your.repo' // Implicitly passed credentials * `git.setTag('tag', 'message', 'Author', 'Author@mail.server)` * `git.setTag('tag', 'message')` - uses the name and email of the last committer as author and committer. * `git.fetch()` -* `git.merge('develop')` +* `git.pull()` - pulls, and in case of merge, uses the name and email of the last committer as author and committer. +* `git.pull('refspec')` - pulls specific refspec (e.g. `origin master`), and in case of merge, uses the name and email + of the last committer as author and committer. +* `git.pull('refspec', 'Author', 'Author@mail.server)` +* `git.merge('develop', 'Author', 'Author@mail.server)` +* `git.merge('develop')` - uses the name and email of the last committer as author and committer. * `git.mergeFastForwardOnly('master')` ### Changes to remote repository * `git.push('master')` - pushes origin -* `pushGitHubPagesBranch('folderToPush', 'commit Message')` - Commits and pushes a folder to the `gh-pages` branch of - the current repo. Can be used to conveniently deliver websites. See https://pages.github.com. Note: - * Uses the name and email of the last committer as author and committer. - * the `gh-pages` branch is temporarily checked out to the `.gh-pages` folder. - * Don't forget to create a git object with credentials. - * Optional: You can deploy to a sub folder of your GitHub Pages branch using a third parameter - * Examples: - * [cloudogu/continuous-delivery-slides](https://github.com/cloudogu/continuous-delivery-slides/) - * [cloudogu/k8s-security-3-things](https://github.com/cloudogu/k8s-security-3-things) - * See also [Cloudogu Blog: Continuous Delivery with reveal.js](https://cloudogu.com/en/blog/continuous-delivery-with-revealjs) - # Docker @@ -785,6 +783,92 @@ So a PR build is treated just like any other. That is, The Jenkins GitHub Plugin sets `BRANCH_NAME` to the PR Name, e.g. `PR-42`. +# Changelog +Provides the functionality to read changes of a specific version in a changelog that is +based on the changelog format on https://keepachangelog.com/. + +Note: The changelog will automatically be formatted. Characters like `"`, `'`, `\` will be removed. + A `\n` will be replaced with `\\n`. This is done to make it possible to pass this string to a json + struct as a value. + +Example: + +```groovy +Changelog changelog = new Changelog(this) + +stage('Changelog') { + String changes = changelog.getChangesForVersion('v1.0.0') + // ... +} +``` +## changelogFileName +You can optionally pass the path to the changelog file if it is located somewhere else than in the root path or +if the file name is not `CHANGELOG.md`. + +Example: + +```groovy +Changelog changelog = new Changelog(this, 'myNewChangelog.md') + +stage('Changelog') { + String changes = changelog.getChangesForVersion('v1.0.0') + // ... +} +``` + +# GitHub +Provides the functionality to do changes on a github repository such as creating a new release. + +Example: + +```groovy +Git git = new Git(this) +GitHub github = new GitHub(this, git) + +stage('Github') { + github.createRelease('v1.1.1', 'Changes for version v1.1.1') +} +``` + +* `github.createRelease(releaseVersion, changes)` - Creates a release on github. + * Use the `releaseVersion` (String) as name and tag. + * Use the `changes` (String) as body of the release. +* `github.createReleaseWithChangelog(releaseVersion, changelog)` - Creates a release on github. + * Use the `releaseVersion` (String) as name and tag. + * Use the `changelog` (Changelog) to extract the changes out of a changelog and add them to the body of the release. +* `pushPagesBranch('folderToPush', 'commit Message')` - Commits and pushes a folder to the `gh-pages` branch of + the current repo. Can be used to conveniently deliver websites. See https://pages.github.com. Note: + * Uses the name and email of the last committer as author and committer. + * the `gh-pages` branch is temporarily checked out to the `.gh-pages` folder. + * Don't forget to create a git object with credentials. + * Optional: You can deploy to a sub folder of your GitHub Pages branch using a third parameter + * Examples: + * [cloudogu/continuous-delivery-slides](https://github.com/cloudogu/continuous-delivery-slides/) + * [cloudogu/k8s-security-3-things](https://github.com/cloudogu/k8s-security-3-things) + * See also [Cloudogu Blog: Continuous Delivery with reveal.js](https://cloudogu.com/en/blog/continuous-delivery-with-revealjs) + + +# GitFlow + +A wrapper class around the Git class to simplify the use of the git flow branching model. + +Example: + +```groovy +Git git = new Git(this) +GitFlow gitflow = new GitFlow(this, git) + +stage('Gitflow') { + if (gitflow.isReleaseBranch()){ + gitflow.finishRelease('v1.0.0') + } +} +``` + +* `gitflow.isReleaseBranch()` - Checks if the currently checked out branch is a gitflow release branch. +* `gitflow.finishRelease(releaseVersion)` - Finishes a git release by merging into develop and master. + * Use the `releaseVersion` (String) as the name of the new git release. + # Steps ## mailIfStatusChanged diff --git a/src/com/cloudogu/ces/cesbuildlib/Changelog.groovy b/src/com/cloudogu/ces/cesbuildlib/Changelog.groovy new file mode 100644 index 00000000..88cbc967 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/Changelog.groovy @@ -0,0 +1,83 @@ +package com.cloudogu.ces.cesbuildlib + +/** + * Provides the functionality to read changes of a specific version in a changelog that is + * based on the changelog format on https://keepachangelog.com/. + */ +class Changelog implements Serializable { + private script + private String changelogFileName + + Changelog(script) { + this(script, 'CHANGELOG.md') + } + + Changelog(script, changelogFileName) { + this.script = script + this.changelogFileName = changelogFileName + } + + /** + * @return Returns the content of the given changelog. + */ + private String readChangelog(){ + script.readFile changelogFileName + } + + /** + * Extracts the changes for a given version out of the changelog. + * + * @param releaseVersion The version to get the changes for. + * @return Returns the changes as String. + */ + String changesForVersion(String releaseVersion) { + def changelog = readChangelog() + def start = changesStartIndex(changelog, releaseVersion) + def end = changesEndIndex(changelog, start) + return escapeForJson(changelog.substring(start, end).trim()) + } + + /** + * Removes characters from a string that could break the json struct when passing the string as json value. + * + * @param string The string to format. + * @return Returns the formatted string. + */ + private static String escapeForJson(String string) { + return string + .replace("\"", "") + .replace("'", "") + .replace("\\", "") + .replace("\n", "\\n") + } + + /** + * Returns the start index of changes of a specific release version in the changelog. + * + * @param releaseVersion The version to get the changes for. + * @return Returns the index in the changelog string where the changes start. + */ + private static int changesStartIndex(String changelog, String releaseVersion) { + def index = changelog.indexOf("## [${releaseVersion}]") + if (index == -1){ + throw new IllegalArgumentException("The desired version '${releaseVersion}' could not be found in the changelog.") + } + def offset = changelog.substring(index).indexOf("\n") + return index + offset + } + + /** + * Returns the end index of changes of a specific release version in the changelog. + * + * @param start The start index of the changes for this version. + * @return Returns the index in the changelog string where the changes end. + */ + private static int changesEndIndex(String changelog, int start) { + def changelogAfterStartIndex = changelog.substring(start) + def index = changelogAfterStartIndex.indexOf("\n## [") + if (index == -1) { + return changelog.length() + } + return index + start + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/Git.groovy b/src/com/cloudogu/ces/cesbuildlib/Git.groovy index 0dc196a9..4df1928c 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Git.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Git.groovy @@ -85,6 +85,14 @@ class Git implements Serializable { return branchName.substring(branchName.lastIndexOf('/') + 1) } + /** + * @return true if this branch differs from the develop branch + */ + boolean originBranchesHaveDiverged(String targetBranch, String sourceBranch) { + String diff = executeGitWithCredentials("log origin/${targetBranch}..origin/${sourceBranch} --oneline") + return diff.length() > 0 + } + /** * @return the Git Author of HEAD, in the following form User Name <user.name@doma.in> */ @@ -93,31 +101,31 @@ class Git implements Serializable { } String getCommitAuthorName() { - return getCommitAuthorComplete().replaceAll(" <.*", "") + return getCommitAuthorComplete().replaceAll(' <.*', '') } String getCommitAuthorEmail() { - def matcher = getCommitAuthorComplete() =~ "<(.*?)>" - matcher ? matcher[0][1] : "" + def matcher = getCommitAuthorComplete() =~ '<(.*?)>' + matcher ? matcher[0][1] : '' } String getCommitMessage() { - sh.returnStdOut "git log -1 --pretty=%B" + sh.returnStdOut 'git log -1 --pretty=%B' } String getCommitHash() { - sh.returnStdOut "git rev-parse HEAD" + sh.returnStdOut 'git rev-parse HEAD' } String getCommitHashShort() { - sh.returnStdOut "git rev-parse --short HEAD" + sh.returnStdOut 'git rev-parse --short HEAD' } /** * @return the URL of the Git repository, e.g. {@code https://github.com/orga/repo.git} */ String getRepositoryUrl() { - sh.returnStdOut "git remote get-url origin" + sh.returnStdOut 'git remote get-url origin' } /** @@ -147,13 +155,21 @@ class Git implements Serializable { String getTag() { // Note that "git name-rev --name-only --tags HEAD" always seems to append a caret (e.g. "1.0.0^") - return sh.returnStdOut("git tag --points-at HEAD") + return sh.returnStdOut('git tag --points-at HEAD') } boolean isTag() { return !getTag().isEmpty() } + /** + * @return true if the specified tag exists on origin. + */ + boolean originTagExists(String tag) { + def tagFound = this.executeGitWithCredentials("ls-remote origin refs/tags/${tag}") + return tagFound != null && tagFound.length() > 0 + } + def add(String pathspec) { script.sh "git add $pathspec" } @@ -173,8 +189,7 @@ class Git implements Serializable { * @param message */ void commit(String message, String authorName, String authorEmail) { - script.withEnv(["GIT_AUTHOR_NAME=$authorName", "GIT_AUTHOR_EMAIL=$authorEmail", - "GIT_COMMITTER_NAME=$authorName", "GIT_COMMITTER_EMAIL=$authorEmail"]) { + withAuthorAndEmail(authorName, authorEmail) { script.sh "git commit -m \"$message\"" } } @@ -185,8 +200,8 @@ class Git implements Serializable { * @param tag * @param message */ - void setTag(String tag, String message) { - setTag(tag, message, commitAuthorName, commitAuthorEmail) + void setTag(String tag, String message, boolean force = false) { + setTag(tag, message, commitAuthorName, commitAuthorEmail, force) } /** @@ -197,10 +212,21 @@ class Git implements Serializable { * @param authorName * @param authorEmail */ - void setTag(String tag, String message, String authorName, String authorEmail) { + void setTag(String tag, String message, String authorName, String authorEmail, boolean force = false) { + def args = "" + if (force) { + args += " -f" + } + + withAuthorAndEmail(authorName, authorEmail) { + script.sh "git tag${args} -m \"${message}\" ${tag}" + } + } + + private void withAuthorAndEmail(String authorName, String authorEmail, Closure closure) { script.withEnv(["GIT_AUTHOR_NAME=$authorName", "GIT_AUTHOR_EMAIL=$authorEmail", "GIT_COMMITTER_NAME=$authorName", "GIT_COMMITTER_EMAIL=$authorEmail"]) { - script.sh "git tag -m \"${message}\" ${tag}" + closure.call() } } @@ -211,7 +237,7 @@ class Git implements Serializable { // we need to configure remote, // because jenkins configures the remote only for the current branch script.sh "git config 'remote.origin.fetch' '+refs/heads/*:refs/remotes/origin/*'" - executeGitWithCredentials "fetch --all" + executeGitWithCredentials 'fetch --all' } /** @@ -225,6 +251,17 @@ class Git implements Serializable { script.sh "git checkout ${branchName}" } + /** + * Checkout a branch and get the latest origin commit. + * It is recommended to do a fetch before. + * + * @param branchName The name of the branch to checkout and pull from. + */ + void checkoutLatest(branchName) { + checkout(branchName) + script.sh "git reset --hard origin/${branchName}" + } + /** * Switch branch to remote branch. Creates new local branch if it does not exist; * Note: In a multibranch pipeline Jenkins will only fetch the changed branch, @@ -234,7 +271,7 @@ class Git implements Serializable { */ void checkoutOrCreate(String branchName) { def returnCode = script.sh(returnStatus: true, script: "git checkout ${branchName}") as int - if(returnCode != 0) { + if (returnCode != 0) { script.sh "git checkout -b ${branchName}" } } @@ -248,7 +285,24 @@ class Git implements Serializable { * @param branchName name of branch to merge with */ void merge(String branchName) { - script.sh "git merge ${branchName}" + merge(branchName, commitAuthorName, commitAuthorEmail) + } + + /** + * Merge branch into the current checked out branch using the specific name and emails as author and committer. + * + * Note: In a multibranch pipeline Jenkins will only fetch the changed branch, + * so you have to call {@link #fetch()} before merge. + * + * @param args name of branch to merge with + * @param authorName + * @param authorEmail + */ + void merge(String args, String authorName, String authorEmail) { + script.withEnv(["GIT_AUTHOR_NAME=$authorName", "GIT_AUTHOR_EMAIL=$authorEmail", + "GIT_COMMITTER_NAME=$authorName", "GIT_COMMITTER_EMAIL=$authorEmail"]) { + script.sh "git merge ${args}" + } } /** @@ -261,7 +315,19 @@ class Git implements Serializable { * @param branchName name of branch to merge with */ void mergeFastForwardOnly(String branchName) { - script.sh "git merge --ff-only ${branchName}" + merge("--ff-only ${branchName}", commitAuthorName, commitAuthorEmail) + } + + /** + * Resolve the merge as a non-fast-forward. + * + * Note: In a multibranch pipeline Jenkins will only fetch the changed branch, + * so you have to call {@link #fetch()} before merge. + * + * @param branchName name of branch to merge with + */ + void mergeNoFastForward(String branchName) { + merge("--no-ff ${branchName}", commitAuthorName, commitAuthorEmail) } /** @@ -270,7 +336,36 @@ class Git implements Serializable { * @param refSpec branch or tag name */ void push(String refSpec) { - executeGitWithCredentials "push origin ${refSpec}" + executeGitWithCredentialsAndRetry "push origin ${refSpec}" + } + + /** + * Pulls to local from remote repo. + * + * @param refSpec branch or tag name + */ + void pull(String refSpec = '', String authorName = commitAuthorName, String authorEmail = commitAuthorEmail) { + withAuthorAndEmail(authorName, authorEmail) { + executeGitWithCredentials "pull ${refSpec}" + } + } + + /** + * Removes a branch at origin. + * + * @param refSpec branch name + */ + void deleteOriginBranch(String refSpec) { + executeGitWithCredentials "push --delete origin ${refSpec}" + } + + /** + * Removes a local branch. + * + * @param refSpec branch name + */ + void deleteLocalBranch(String refSpec) { + script.sh "git branch -d ${refSpec}" } /** @@ -284,53 +379,80 @@ class Git implements Serializable { * @param workspaceFolder * @param commitMessage */ + @Deprecated void pushGitHubPagesBranch(String workspaceFolder, String commitMessage, String subFolder = '.') { - def ghPagesTempDir = '.gh-pages' - try { - script.dir(ghPagesTempDir) { - git url: repositoryUrl, branch: 'gh-pages', changelog: false, poll: false - - script.sh "mkdir -p ${subFolder}" - script.sh "cp -rf ../${workspaceFolder}/* ${subFolder}" - add '.' - commit commitMessage - push 'gh-pages' + new GitHub(script, this).pushPagesBranch(workspaceFolder, commitMessage, subFolder) + } + + protected String executeWithCredentials(Closure closure) { + if (credentials) { + script.withCredentials([script.usernamePassword(credentialsId: credentials, + passwordVariable: 'GIT_AUTH_PSW', usernameVariable: 'GIT_AUTH_USR')]) { + closure.call(true) } - } finally { - script.sh "rm -rf ${ghPagesTempDir}" + } else { + closure.call(false) } } /** * This method executes the git command with a bash function as credential helper, - * which return username and password from jenkins credentials. - * - * If the script failes with exit code 128, this will retry the call up to the - * configured max retries before failing. - * + * which return username and password from jenkins credentials (if git.credentials are set) + * * @param args git arguments + * @return Returns an array with a string array of two elements. + * The first element contains the command out put. The second element contans the command status code */ - protected void executeGitWithCredentials(String args) { - if (credentials) { - script.withCredentials([script.usernamePassword(credentialsId: credentials, - passwordVariable: 'GIT_AUTH_PSW', usernameVariable: 'GIT_AUTH_USR')]) { - def pushResultCode = 128 - def retryCount = 0 - while (pushResultCode == 128 && retryCount < maxRetries) { - if (retryCount > 0) { - script.echo "Got error code ${pushResultCode} - retrying in ${retryTimeout} ms ..." - sleep(retryTimeout) - } - ++retryCount - pushResultCode = script.sh returnStatus: true, script: "git -c credential.helper=\"!f() { echo username='\$GIT_AUTH_USR'; echo password='\$GIT_AUTH_PSW'; }; f\" ${args}" - pushResultCode = pushResultCode as int - } - if (pushResultCode != 0) { - script.error "Unable to execute git call. Retried ${retryCount} times. Last error code: ${pushResultCode}" + protected String executeGitWithCredentials(String args) { + return executeWithCredentials { boolean hasCredentials -> + return executeGit(args, hasCredentials) + } + } + + private String createGitCommand(String args, boolean hasCredentials) { + String gitCommand + if (hasCredentials) { + gitCommand = "git -c credential.helper=\"!f() { echo username='\$GIT_AUTH_USR'; echo password='\$GIT_AUTH_PSW'; }; f\" ${args}" + } else { + gitCommand = "git ${args}" + } + gitCommand + } + + /** + * Similar to executeGitWithCredentials() except that it reties git.retryCount times when git returns code 128. + * @param args + */ + protected void executeGitWithCredentialsAndRetry(String args) { + executeWithCredentials { boolean hasCredentials -> + def returnCode = 128 + def retryCount = 0 + while (returnCode == 128 && retryCount < maxRetries) { + if (retryCount > 0) { + script.echo "Got error code ${returnCode} - retrying in ${retryTimeout} ms ..." + sleep(retryTimeout) } + ++retryCount + returnCode = script.sh(returnStatus: true, script: createGitCommand(args, hasCredentials)) as int + } + if (returnCode != 0) { + script.error "Unable to execute git call. Retried ${retryCount} times. Last error code: ${returnCode}" } - } else { - script.sh "git ${args}" } } + + /** + * Executes a git command. + * + * @param args git arguments + * @return Returns the console output. + */ + protected String executeGit(String args, boolean hasCredentials = false){ + def commandOutput = script.sh( + script: createGitCommand(args, hasCredentials), + returnStdout: true + ) + script.echo commandOutput + return commandOutput + } } diff --git a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy new file mode 100644 index 00000000..421ca878 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy @@ -0,0 +1,71 @@ +package com.cloudogu.ces.cesbuildlib + +class GitFlow implements Serializable { + private script + private git + Sh sh + + GitFlow(script, Git git) { + this.script = script + this.git = git + this.sh = new Sh(script) + } + + /** + * @return if this branch is a release branch according to git flow + */ + boolean isReleaseBranch() { + return git.getBranchName().startsWith('release/') + } + + /** + * Finishes a git flow release and pushes all merged branches to remote + * + * Only execute this function if you are already on a release branch + * + * @param releaseVersion the version that is going to be released + */ + void finishRelease(String releaseVersion) { + String branchName = git.getBranchName() + + // Stop the build here if there is already a tag for this version on remote. + // Do not stop the build when the tag only exists locally + // because this could mean the build has failed and was restarted. + if (git.originTagExists("${releaseVersion}")) { + script.error('You cannot build this version, because it already exists.') + } + + // Make sure all branches are fetched + git.fetch() + + // Stop the build if there are new changes on develop that are not merged into this feature branch. + if (git.originBranchesHaveDiverged(branchName, 'develop')) { + script.error('There are changes on develop branch that are not merged into release. Please merge and restart process.') + } + + // Make sure any branch we need exists locally + git.checkoutLatest(branchName) + git.checkoutLatest('develop') + git.checkoutLatest('master') + + // Merge release branch into master + git.mergeNoFastForward(branchName) + + // Create tag. Use -f because the created tag will persist when build has failed. + git.setTag(releaseVersion, "release version ${releaseVersion}", true); + + // Merge release branch into develop + git.checkout('develop') + git.mergeNoFastForward(branchName) + + // Delete release branch + git.deleteLocalBranch(branchName) + + // Checkout tag + git.checkout(releaseVersion) + + // Push changes and tags + git.push("master develop ${releaseVersion}") + git.deleteOriginBranch(branchName) + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/GitHub.groovy b/src/com/cloudogu/ces/cesbuildlib/GitHub.groovy new file mode 100644 index 00000000..be39fdf7 --- /dev/null +++ b/src/com/cloudogu/ces/cesbuildlib/GitHub.groovy @@ -0,0 +1,77 @@ +package com.cloudogu.ces.cesbuildlib + +class GitHub implements Serializable { + private script + private git + + GitHub(script, git) { + this.script = script + this.git = git + } + + /** + * Creates a new release on Github and adds changelog info to it + * + * @param releaseVersion the version for the github release + * @param changelog the changelog object to extract the release information from + */ + void createReleaseWithChangelog(String releaseVersion, Changelog changelog) { + try { + def changelogText = changelog.changesForVersion(releaseVersion) + script.echo "The description of github release will be: >>>${changelogText}<<<" + createRelease(releaseVersion, changelogText) + } catch (IllegalArgumentException e) { + script.unstable("Release failed due to error: ${e}") + script.echo 'Please manually update github release.' + } + } + + /** + * Creates a release on Github and fills it with the changes provided + */ + void createRelease(String releaseVersion, String changes) { + def repositoryName = git.getRepositoryName() + if (!git.credentials) { + throw new IllegalArgumentException('Unable to create Github release without credentials.') + } + script.withCredentials([script.usernamePassword( + credentialsId: git.credentials, usernameVariable: 'GIT_AUTH_USR', passwordVariable: 'GIT_AUTH_PSW')]) { + + def body = + """{"tag_name": "${releaseVersion}", "target_commitish": "master", "name": "${releaseVersion}", "body":"${changes}"}""" + def apiUrl = "https://api.github.com/repos/${repositoryName}/releases" + def flags = """--request POST --data '${body.trim()}' --header "Content-Type: application/json" """ + def username = '\$GIT_AUTH_USR' + def password = '\$GIT_AUTH_PSW' + script.sh "curl -u ${username}:${password} ${flags} ${apiUrl}" + } + } + + /** + * Commits and pushes a folder to the gh-pages branch of the current repo. + * Can be used to conveniently deliver websites. See https://pages.github.com/ + * + * Uses the name and email of the last committer as author and committer. + * + * Note that the branch is temporarily checked out to the .gh-pages folder. + * + * @param workspaceFolder + * @param commitMessage + */ + void pushPagesBranch(String workspaceFolder, String commitMessage, String subFolder = '.') { + def ghPagesTempDir = '.gh-pages' + try { + script.dir(ghPagesTempDir) { + this.git.git url: this.git.repositoryUrl, branch: 'gh-pages', changelog: false, poll: false + + script.sh "mkdir -p ${subFolder}" + script.sh "cp -rf ../${workspaceFolder}/* ${subFolder}" + this.git.add '.' + this.git.commit commitMessage + this.git.push 'gh-pages' + } + } finally { + script.sh "rm -rf ${ghPagesTempDir}" + } + } +} diff --git a/src/com/cloudogu/ces/cesbuildlib/MavenLocal.groovy b/src/com/cloudogu/ces/cesbuildlib/MavenLocal.groovy index f9640d95..50c6ae90 100644 --- a/src/com/cloudogu/ces/cesbuildlib/MavenLocal.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/MavenLocal.groovy @@ -33,4 +33,4 @@ class MavenLocal extends Maven { script.echo 'WARNING: javaHome is empty. Did you check "Install automatically"?' } } -} \ No newline at end of file +} diff --git a/test/com/cloudogu/ces/cesbuildlib/ChangelogTest.groovy b/test/com/cloudogu/ces/cesbuildlib/ChangelogTest.groovy new file mode 100644 index 00000000..c587349e --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/ChangelogTest.groovy @@ -0,0 +1,82 @@ +package com.cloudogu.ces.cesbuildlib + +import org.junit.Test + +class ChangelogTest extends GroovyTestCase { + def validChangelog = + ''' +## [Unreleased] +### Changed +- Some other things + +## [v2.0.0] - 2020-01-01 +### Changed +- Everything! + +## [v1.0.0] - 2020-01-01 +### Changed +- Something + +## [v0.9.9] - 2020-01-01 +### Added +- Anything + +''' + def newChangelog = + ''' +## [Unreleased] + +## [v0.0.1] - 2020-01-01 +### Added +- Nothing yet + +''' + + ScriptMock scriptMock = new ScriptMock() + + @Test + void testGetCorrectVersion() { + scriptMock.files.put('CHANGELOG.md', validChangelog) + Changelog changelog = new Changelog(scriptMock) + + def changes1 = changelog.changesForVersion("v1.0.0") + assertEquals("### Changed\\n- Something", changes1) + + def changes2 = changelog.changesForVersion("v0.9.9") + assertEquals("### Added\\n- Anything", changes2) + + def changes3 = changelog.changesForVersion("v2.0.0") + assertEquals("### Changed\\n- Everything!", changes3) + } + + @Test + void testWillWorkWithNewChangelog() { + scriptMock.files.put('CHANGELOG.md', newChangelog) + Changelog changelog = new Changelog(scriptMock) + def changes = changelog.changesForVersion("v0.0.1") + assertEquals("### Added\\n- Nothing yet", changes) + } + + @Test + void testReplaceInvalidCharactersCorrect() { + scriptMock.files.put('CHANGELOG.md', validChangelog) + Changelog changelog = new Changelog(scriptMock) + + assertEquals("", changelog.escapeForJson("\"")) + assertEquals("", changelog.escapeForJson("'")) + assertEquals("", changelog.escapeForJson("''")) + assertEquals("", changelog.escapeForJson("\\")) + assertEquals("\\n", changelog.escapeForJson("\n")) + assertEquals("\\n", changelog.escapeForJson("\n\"\"''\\\\")) + } + + @Test + void testThrowsErrorOnVersionNotFound() { + scriptMock.files.put('CHANGELOG.md', validChangelog) + Changelog changelog = new Changelog(scriptMock) + def exception = shouldFail { + changelog.changesForVersion("not existing version") + } + assertEquals("The desired version 'not existing version' could not be found in the changelog.", exception) + } +} diff --git a/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy new file mode 100644 index 00000000..a2f0a02d --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy @@ -0,0 +1,84 @@ +package com.cloudogu.ces.cesbuildlib + +import org.junit.Test + +class GitFlowTest extends GroovyTestCase { + def scriptMock = new ScriptMock() + + @Test + void testIsReleaseBranch() { + String branchPrefixRelease = "release" + String branchPrefixFeature = "feature" + + def scriptMock1 = new ScriptMock() + scriptMock1.env = new Object() { + String BRANCH_NAME = "$branchPrefixRelease/something" + } + Git git1 = new Git(scriptMock1) + GitFlow gitflow1 = new GitFlow(scriptMock1, git1) + + def scriptMock2 = new ScriptMock() + scriptMock2.env = new Object() { + String BRANCH_NAME = "$branchPrefixFeature/something" + } + Git git2 = new Git(scriptMock2) + GitFlow gitflow2 = new GitFlow(scriptMock2, git2) + + assertTrue(gitflow1.isReleaseBranch()) + assertFalse(gitflow2.isReleaseBranch()) + } + + @Test + void testFinishRelease() { + scriptMock.expectedShRetValueForScript.put('git push origin master develop myVersion', 0) + scriptMock.expectedDefaultShRetValue = "" + scriptMock.env.BRANCH_NAME = "myReleaseBranch" + Git git = new Git(scriptMock) + GitFlow gitflow = new GitFlow(scriptMock, git) + gitflow.finishRelease("myVersion") + scriptMock.allActualArgs.removeAll("echo ") + scriptMock.allActualArgs.removeAll("git --no-pager show -s --format='%an <%ae>' HEAD") + int i = 0 + assertEquals("git ls-remote origin refs/tags/myVersion", scriptMock.allActualArgs[i++]) + assertEquals("git config 'remote.origin.fetch' '+refs/heads/*:refs/remotes/origin/*'", scriptMock.allActualArgs[i++]) + assertEquals("git fetch --all", scriptMock.allActualArgs[i++]) + assertEquals("git log origin/myReleaseBranch..origin/develop --oneline", scriptMock.allActualArgs[i++]) + assertEquals("git checkout myReleaseBranch", scriptMock.allActualArgs[i++]) + assertEquals("git reset --hard origin/myReleaseBranch", scriptMock.allActualArgs[i++]) + assertEquals("git checkout develop", scriptMock.allActualArgs[i++]) + assertEquals("git reset --hard origin/develop", scriptMock.allActualArgs[i++]) + assertEquals("git checkout master", scriptMock.allActualArgs[i++]) + assertEquals("git reset --hard origin/master", scriptMock.allActualArgs[i++]) + assertEquals("git merge --no-ff myReleaseBranch", scriptMock.allActualArgs[i++]) + assertEquals("git tag -f -m \"release version myVersion\" myVersion", scriptMock.allActualArgs[i++]) + assertEquals("git checkout develop", scriptMock.allActualArgs[i++]) + assertEquals("git merge --no-ff myReleaseBranch", scriptMock.allActualArgs[i++]) + assertEquals("git branch -d myReleaseBranch", scriptMock.allActualArgs[i++]) + assertEquals("git checkout myVersion", scriptMock.allActualArgs[i++]) + assertEquals("git push origin master develop myVersion", scriptMock.allActualArgs[i++]) + assertEquals("git push --delete origin myReleaseBranch", scriptMock.allActualArgs[i++]) + } + + @Test + void testThrowsErrorWhenTagAlreadyExists() { + scriptMock.expectedShRetValueForScript.put('git ls-remote origin refs/tags/myVersion', 'thisIsATag') + Git git = new Git(scriptMock) + GitFlow gitflow = new GitFlow(scriptMock, git) + String err = shouldFail(Exception.class) { + gitflow.finishRelease("myVersion") + } + assertEquals("You cannot build this version, because it already exists.", err) + } + + @Test + void testThrowsErrorWhenDevelopHasChanged() { + scriptMock.env.BRANCH_NAME = "branch" + scriptMock.expectedShRetValueForScript.put("git log origin/branch..origin/develop --oneline", "some changes") + Git git = new Git(scriptMock) + GitFlow gitflow = new GitFlow(scriptMock, git) + String err = shouldFail(Exception.class) { + gitflow.finishRelease("myVersion") + } + assertEquals("There are changes on develop branch that are not merged into release. Please merge and restart process.", err) + } +} diff --git a/test/com/cloudogu/ces/cesbuildlib/GitHubTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitHubTest.groovy new file mode 100644 index 00000000..72f8ed42 --- /dev/null +++ b/test/com/cloudogu/ces/cesbuildlib/GitHubTest.groovy @@ -0,0 +1,99 @@ +package com.cloudogu.ces.cesbuildlib + +import org.junit.Test + +class GitHubTest extends GroovyTestCase { + def testChangelog = + ''' +## [Unreleased] + +## [v1.0.0] +### Added +- everything + +''' + + ScriptMock scriptMock = new ScriptMock() + + @Test + void testPushPagesBranch() { + Git git = new Git(scriptMock) + GitHub github = new GitHub(scriptMock, git) + scriptMock.expectedShRetValueForScript.put('git push origin gh-pages', 0) + scriptMock.expectedShRetValueForScript.put("git --no-pager show -s --format='%an <%ae>' HEAD", "User Name ") + scriptMock.expectedShRetValueForScript.put('git remote get-url origin', "https://repo.url") + + github.pushPagesBranch('website', 'Deploys new version of website') + + assertPagesBranchToSubFolder('.', scriptMock) + } + + @Test + void testPushPagesBranchToSubFolder() { + Git git = new Git(scriptMock) + GitHub github = new GitHub(scriptMock, git) + scriptMock.expectedShRetValueForScript.put('git push origin gh-pages', 0) + scriptMock.expectedShRetValueForScript.put("git --no-pager show -s --format='%an <%ae>' HEAD", "User Name ") + scriptMock.expectedShRetValueForScript.put('git remote get-url origin', "https://repo.url") + + github.pushPagesBranch('website', 'Deploys new version of website', 'some-folder') + + assertPagesBranchToSubFolder('some-folder', scriptMock) + } + + private void assertPagesBranchToSubFolder(String subFolder, ScriptMock scriptMock) { + assert scriptMock.actualGitArgs.url == "https://repo.url" + assert scriptMock.actualGitArgs.branch == "gh-pages" + + assert scriptMock.actualDir == '.gh-pages' + assert scriptMock.allActualArgs.contains("cp -rf ../website/* ${subFolder}".toString()) + assert scriptMock.allActualArgs.contains("mkdir -p ${subFolder}".toString()) + assert scriptMock.allActualArgs.contains('git add .') + assert scriptMock.allActualArgs.contains('git commit -m "Deploys new version of website"') + assert scriptMock.actualWithEnv.contains("${'GIT_AUTHOR_NAME=User Name'}") + assert scriptMock.actualWithEnv.contains("${'GIT_COMMITTER_NAME=User Name'}") + assert scriptMock.actualWithEnv.contains("${'GIT_AUTHOR_EMAIL=user.name@doma.in'}") + assert scriptMock.actualWithEnv.contains("${'GIT_COMMITTER_EMAIL=user.name@doma.in'}") + assert scriptMock.allActualArgs.contains('git push origin gh-pages') + assert scriptMock.allActualArgs.last == 'rm -rf .gh-pages' + } + + @Test + void testCreateReleaseByChangelog() { + scriptMock.files.put('CHANGELOG.md', testChangelog) + scriptMock.expectedShRetValueForScript.put("git remote get-url origin", "myRepoName") + Git git = new Git(scriptMock, "credentials") + GitHub github = new GitHub(scriptMock, git) + Changelog changelog = new Changelog(scriptMock) + + github.createReleaseWithChangelog("v1.0.0", changelog) + + assertEquals(2, scriptMock.allActualArgs.size()) + int i = 0; + assertEquals("git remote get-url origin", scriptMock.allActualArgs[i++]) + + String expectedData = """--data '{"tag_name": "v1.0.0", "target_commitish": "master", """ + + """"name": "v1.0.0", "body":"### Added\\n- everything"}'""" + String expectedHeader = """--header "Content-Type: application/json" https://api.github.com/repos/myRepoName/releases""" + + assertEquals("curl -u \$GIT_AUTH_USR:\$GIT_AUTH_PSW --request POST ${expectedData} ${expectedHeader}", scriptMock.allActualArgs[i++]) + } + + @Test + void testReleaseFailsWithoutCredentials() { + scriptMock.files.put('CHANGELOG.md', testChangelog) + scriptMock.expectedShRetValueForScript.put("git remote get-url origin", "myRepoName") + Git git = new Git(scriptMock) + GitHub github = new GitHub(scriptMock, git) + Changelog changelog = new Changelog(scriptMock) + + def exception = shouldFail { + github.createRelease("v1.0.0", "changes") + } + assert exception.contains("Unable to create Github release without credentials") + + assertFalse(scriptMock.unstable) + github.createReleaseWithChangelog("v1.0.0", changelog) + assertTrue(scriptMock.unstable) + } +} diff --git a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy index 012d517f..8e8d973a 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy @@ -78,7 +78,7 @@ class GitTest { void testGetSimpleBranchName() { String expectedSimpleBranchName = "simpleName" def scriptMock = new ScriptMock() - scriptMock.env= new Object() { + scriptMock.env = new Object() { String BRANCH_NAME = "feature/somethingelse/$expectedSimpleBranchName" } Git git = new Git(scriptMock) @@ -190,9 +190,7 @@ class GitTest { @Test void commit() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedDefaultShRetValue = "User Name " - Git git = new Git(scriptMock) git.commit 'msg' def actualWithEnv = scriptMock.actualWithEnvAsMap() assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' @@ -203,10 +201,9 @@ class GitTest { @Test void setTag() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedDefaultShRetValue = "User Name " - Git git = new Git(scriptMock) git.setTag("someTag", "someMessage") + git.setTag("myTag", "myMessage", true) def actualWithEnv = scriptMock.actualWithEnvAsMap() assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' @@ -214,14 +211,57 @@ class GitTest { assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' assert scriptMock.actualShStringArgs[0] == "git tag -m \"someMessage\" someTag" + assert scriptMock.actualShStringArgs[1] == "git tag -f -m \"myMessage\" myTag" } @Test void fetch() { + ScriptMock scriptMock = new ScriptMock() + scriptMock.expectedDefaultShRetValue = "" + Git git = new Git(scriptMock) git.fetch() - assert scriptMock.actualShStringArgs[0] == "git config 'remote.origin.fetch' '+refs/heads/*:refs/remotes/origin/*'" - assert scriptMock.actualShStringArgs[1] == "git fetch --all" + println scriptMock.allActualArgs + assert scriptMock.allActualArgs[0] == "git config 'remote.origin.fetch' '+refs/heads/*:refs/remotes/origin/*'" + assert scriptMock.allActualArgs[1] == "git fetch --all" + } + + @Test + void pull() { + def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull' + scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) + scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') + git = new Git(scriptMock, 'creds') + + git.pull() + + def actualWithEnv = scriptMock.actualWithEnvAsMap() + assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' + assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + + assert scriptMock.actualShMapArgs.size() == 3 + assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials + } + + @Test + void 'pull with refspec'() { + def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull origin master' + scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) + scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') + git = new Git(scriptMock, 'creds') + + git.pull 'origin master' + + def actualWithEnv = scriptMock.actualWithEnvAsMap() + assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' + assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + + assert scriptMock.actualShMapArgs.size() == 3 + assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials } @Test @@ -250,24 +290,51 @@ class GitTest { @Test void merge() { ScriptMock scriptMock = new ScriptMock() + scriptMock.expectedDefaultShRetValue = "User Name " Git git = new Git(scriptMock) git.merge("master") + def actualWithEnv = scriptMock.actualWithEnvAsMap() + assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' + assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + println scriptMock.actualShStringArgs assert scriptMock.actualShStringArgs[0] == "git merge master" } @Test void mergeFastForwardOnly() { ScriptMock scriptMock = new ScriptMock() + scriptMock.expectedDefaultShRetValue = "User Name " Git git = new Git(scriptMock) git.mergeFastForwardOnly("master") + def actualWithEnv = scriptMock.actualWithEnvAsMap() + assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' + assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' assert scriptMock.actualShStringArgs[0] == "git merge --ff-only master" } @Test - void push() { + void mergeNoFastForward() { ScriptMock scriptMock = new ScriptMock() + scriptMock.expectedDefaultShRetValue = "User Name " + Git git = new Git(scriptMock) + git.mergeNoFastForward("master") + def actualWithEnv = scriptMock.actualWithEnvAsMap() + + assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' + assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assert scriptMock.actualShStringArgs[0] == "git merge --no-ff master" + } + + @Test + void push() { scriptMock.expectedShRetValueForScript.put('git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master', 0) git = new Git(scriptMock, 'creds') git.push('master') @@ -275,24 +342,21 @@ class GitTest { @Test void pushNonHttps() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedShRetValueForScript.put('git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master', 0) + git = new Git(scriptMock, 'creds') git.push('master') - assert scriptMock.actualShMapArgs.size() == 1 assert scriptMock.actualShMapArgs.get(0) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' } @Test void pushWithRetry() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedShRetValueForScript.put('git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master', [128, 128, 0]) git = new Git(scriptMock, 'creds') git.retryTimeout = 1 git.push('master') - assert scriptMock.actualShMapArgs.size() == 3 assert scriptMock.actualShMapArgs.get(0) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' assert scriptMock.actualShMapArgs.get(1) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' assert scriptMock.actualShMapArgs.get(2) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' @@ -300,46 +364,10 @@ class GitTest { @Test void pushNoCredentials() { + scriptMock.expectedDefaultShRetValue = 0 + git.retryTimeout = 1 git.push('master') - assert scriptMock.actualShStringArgs.size() == 1 - assert scriptMock.actualShStringArgs.get(0) == 'git push origin master' - } - - @Test - void pushGitHubPagesBranch() { - scriptMock.expectedShRetValueForScript.put("git --no-pager show -s --format='%an <%ae>' HEAD", "User Name ") - scriptMock.expectedShRetValueForScript.put('git remote get-url origin', "https://repo.url") - - git.pushGitHubPagesBranch('website', 'Deploys new version of website') - - assertGitHubPagesBranchToSubFolder('.') - } - - @Test - void pushGitHubPagesBranchToSubFolder() { - scriptMock.expectedShRetValueForScript.put("git --no-pager show -s --format='%an <%ae>' HEAD", "User Name ") - scriptMock.expectedShRetValueForScript.put('git remote get-url origin', "https://repo.url") - - git.pushGitHubPagesBranch('website', 'Deploys new version of website', 'some-folder') - - assertGitHubPagesBranchToSubFolder('some-folder') - } - - private void assertGitHubPagesBranchToSubFolder(String subFolder) { - assert scriptMock.actualGitArgs.url == "https://repo.url" - assert scriptMock.actualGitArgs.branch == "gh-pages" - - assert scriptMock.actualDir == '.gh-pages' - assert scriptMock.actualShStringArgs.contains("cp -rf ../website/* ${subFolder}".toString()) - assert scriptMock.actualShStringArgs.contains("mkdir -p ${subFolder}".toString()) - assert scriptMock.actualShStringArgs.contains('git add .') - assert scriptMock.actualShStringArgs.contains('git commit -m "Deploys new version of website"') - assert scriptMock.actualWithEnv.contains("${'GIT_AUTHOR_NAME=User Name'}") - assert scriptMock.actualWithEnv.contains("${'GIT_COMMITTER_NAME=User Name'}") - assert scriptMock.actualWithEnv.contains("${'GIT_AUTHOR_EMAIL=user.name@doma.in'}") - assert scriptMock.actualWithEnv.contains("${'GIT_COMMITTER_EMAIL=user.name@doma.in'}") - assert scriptMock.actualShStringArgs.contains('git push origin gh-pages') - assert scriptMock.actualShStringArgs.last == 'rm -rf .gh-pages' + assert scriptMock.actualShMapArgs.get(0) == 'git push origin master' } } diff --git a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy index 3e540ccd..17a7a4eb 100644 --- a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy @@ -1,9 +1,11 @@ package com.cloudogu.ces.cesbuildlib class ScriptMock { - def env = [ WORKSPACE: "", HOME: "" ] + def env = [WORKSPACE: "", HOME: ""] boolean expectedIsPullRequest = false + boolean unstable = false + String unstableMsg = "" def expectedQGate def expectedPwd @@ -17,6 +19,7 @@ class ScriptMock { List actualUsernamePasswordArgs = [] List actualShStringArgs = new LinkedList<>() + List allActualArgs = new LinkedList<>() List actualEcho = new LinkedList<>() List actualShMapArgs = new LinkedList<>() @@ -28,29 +31,35 @@ class ScriptMock { List actualWithEnv String actualDir def actualGitArgs + private ignoreOutputFile String sh(String args) { actualShStringArgs.add(args.toString()) + allActualArgs.add(args.toString()) return getReturnValueFor(args) } String sh(Map args) { + def script = args.get('script') + actualShMapArgs.add(args.script.toString()) + allActualArgs.add(args.script.toString()) + return getReturnValueFor(args.get('script')) } private Object getReturnValueFor(Object arg) { - if (expectedDefaultShRetValue == null) { - // toString() to make Map also match GStrings - def value = expectedShRetValueForScript.get(arg.toString()) - if (value instanceof List) { - return ((List) value).removeAt(0) - } else { - return value - } - } else { + + // toString() to make Map also match GStrings + def value = expectedShRetValueForScript.get(arg.toString().trim()) + if (value == null) { return expectedDefaultShRetValue } + if (value instanceof List) { + return ((List) value).removeAt(0) + } else { + return value + } } boolean isPullRequest() { @@ -58,7 +67,7 @@ class ScriptMock { } def timeout(Map params, closure) { - this.actualTimeoutParams = params + actualTimeoutParams = params return closure.call() } @@ -66,13 +75,17 @@ class ScriptMock { return expectedQGate } + def unstable(String msg) { + unstable = true + } + void withSonarQubeEnv(String sonarQubeEnv, Closure closure) { - this.actualSonarQubeEnv = sonarQubeEnv + actualSonarQubeEnv = sonarQubeEnv closure.call() } void withEnv(List env, Closure closure) { - this.actualWithEnv = env + actualWithEnv = env closure.call() } @@ -120,8 +133,7 @@ class ScriptMock { closure.call() } - Map actualWithEnvAsMap() { - actualWithEnv.collectEntries {[it.split('=')[0], it.split('=')[1]]} + actualWithEnv.collectEntries { [it.split('=')[0], it.split('=')[1]] } } } From edb6e113a5e3632b042c80969f6238a0dce4cfc7 Mon Sep 17 00:00:00 2001 From: Johannes Schnatterer Date: Fri, 29 May 2020 18:14:06 +0200 Subject: [PATCH 2/6] #30 Set author of release Branch as author of merge commit --- src/com/cloudogu/ces/cesbuildlib/Git.groovy | 11 +++--- .../cloudogu/ces/cesbuildlib/GitFlow.groovy | 12 ++++-- .../ces/cesbuildlib/GitFlowTest.groovy | 37 +++++++++++++++++++ .../ces/cesbuildlib/ScriptMock.groovy | 19 ++++++++-- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/com/cloudogu/ces/cesbuildlib/Git.groovy b/src/com/cloudogu/ces/cesbuildlib/Git.groovy index 4df1928c..672392ff 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Git.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Git.groovy @@ -299,8 +299,7 @@ class Git implements Serializable { * @param authorEmail */ void merge(String args, String authorName, String authorEmail) { - script.withEnv(["GIT_AUTHOR_NAME=$authorName", "GIT_AUTHOR_EMAIL=$authorEmail", - "GIT_COMMITTER_NAME=$authorName", "GIT_COMMITTER_EMAIL=$authorEmail"]) { + withAuthorAndEmail(authorName, authorEmail) { script.sh "git merge ${args}" } } @@ -314,8 +313,8 @@ class Git implements Serializable { * * @param branchName name of branch to merge with */ - void mergeFastForwardOnly(String branchName) { - merge("--ff-only ${branchName}", commitAuthorName, commitAuthorEmail) + void mergeFastForwardOnly(String branchName, String authorName = commitAuthorName, String authorEmail = commitAuthorEmail) { + merge("--ff-only ${branchName}", authorName, authorEmail) } /** @@ -326,8 +325,8 @@ class Git implements Serializable { * * @param branchName name of branch to merge with */ - void mergeNoFastForward(String branchName) { - merge("--no-ff ${branchName}", commitAuthorName, commitAuthorEmail) + void mergeNoFastForward(String branchName, String authorName = commitAuthorName, String authorEmail = commitAuthorEmail) { + merge("--no-ff ${branchName}", authorName, authorEmail) } /** diff --git a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy index 421ca878..8b44bc0d 100644 --- a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy @@ -1,8 +1,8 @@ package com.cloudogu.ces.cesbuildlib class GitFlow implements Serializable { - private script - private git + private def script + private Git git Sh sh GitFlow(script, Git git) { @@ -52,11 +52,15 @@ class GitFlow implements Serializable { git.mergeNoFastForward(branchName) // Create tag. Use -f because the created tag will persist when build has failed. - git.setTag(releaseVersion, "release version ${releaseVersion}", true); + git.setTag(releaseVersion, "release version ${releaseVersion}", true) + String releaseBranchAuthor = git.commitAuthorName + String releaseBranchEmail = git.commitAuthorEmail // Merge release branch into develop git.checkout('develop') - git.mergeNoFastForward(branchName) + // Set author of release Branch as author of merge commit + // Otherwise the author of the last commit on develop would author the commit, which is unexpected + git.mergeNoFastForward(branchName, releaseBranchAuthor, releaseBranchEmail) // Delete release branch git.deleteLocalBranch(branchName) diff --git a/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy index a2f0a02d..79411f6c 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GitFlowTest.groovy @@ -30,12 +30,26 @@ class GitFlowTest extends GroovyTestCase { @Test void testFinishRelease() { + String releaseBranchAuthorName = 'release' + String releaseBranchEmail = 'rele@s.e' + String releaseBranchAuthor = createGitAuthorString(releaseBranchAuthorName, releaseBranchEmail) + String developBranchAuthorName = 'develop' + String developBranchEmail = 'develop@a.a' + String developBranchAuthor = createGitAuthorString(developBranchAuthorName, developBranchEmail) + scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', + [releaseBranchAuthor, releaseBranchAuthor, releaseBranchAuthor, releaseBranchAuthor, + // these two are the ones where the release branch author is stored: + releaseBranchAuthor, releaseBranchAuthor, + developBranchAuthor, developBranchAuthor + ]) scriptMock.expectedShRetValueForScript.put('git push origin master develop myVersion', 0) + scriptMock.expectedDefaultShRetValue = "" scriptMock.env.BRANCH_NAME = "myReleaseBranch" Git git = new Git(scriptMock) GitFlow gitflow = new GitFlow(scriptMock, git) gitflow.finishRelease("myVersion") + scriptMock.allActualArgs.removeAll("echo ") scriptMock.allActualArgs.removeAll("git --no-pager show -s --format='%an <%ae>' HEAD") int i = 0 @@ -49,10 +63,20 @@ class GitFlowTest extends GroovyTestCase { assertEquals("git reset --hard origin/develop", scriptMock.allActualArgs[i++]) assertEquals("git checkout master", scriptMock.allActualArgs[i++]) assertEquals("git reset --hard origin/master", scriptMock.allActualArgs[i++]) + + // Author & Email 1 (calls 'git --no-pager...' twice) assertEquals("git merge --no-ff myReleaseBranch", scriptMock.allActualArgs[i++]) + assertAuthor(0, releaseBranchAuthorName, releaseBranchEmail) + + // Author & Email 2 (calls 'git --no-pager...' twice) assertEquals("git tag -f -m \"release version myVersion\" myVersion", scriptMock.allActualArgs[i++]) + assertAuthor(1, releaseBranchAuthorName, releaseBranchEmail) + assertEquals("git checkout develop", scriptMock.allActualArgs[i++]) + // Author & Email 3 (calls 'git --no-pager...' twice) assertEquals("git merge --no-ff myReleaseBranch", scriptMock.allActualArgs[i++]) + assertAuthor(2, releaseBranchAuthorName, releaseBranchEmail) + assertEquals("git branch -d myReleaseBranch", scriptMock.allActualArgs[i++]) assertEquals("git checkout myVersion", scriptMock.allActualArgs[i++]) assertEquals("git push origin master develop myVersion", scriptMock.allActualArgs[i++]) @@ -81,4 +105,17 @@ class GitFlowTest extends GroovyTestCase { } assertEquals("There are changes on develop branch that are not merged into release. Please merge and restart process.", err) } + + void assertAuthor(int withEnvInvocationIndex, String author, String email) { + def withEnvMap = scriptMock.actualWithEnvAsMap(withEnvInvocationIndex) + assert withEnvMap['GIT_AUTHOR_NAME'] == author + assert withEnvMap['GIT_COMMITTER_NAME'] == author + assert withEnvMap['GIT_AUTHOR_EMAIL'] == email + assert withEnvMap['GIT_COMMITTER_EMAIL'] == email + } + + String createGitAuthorString(String author, String email) { + "${author} <${email}>" + } + } diff --git a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy index 17a7a4eb..ea00dbce 100644 --- a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy @@ -28,7 +28,8 @@ class ScriptMock { Map actualFileArgs Map actualStringArgs Map files = new HashMap() - List actualWithEnv + List> actualWithEnv = [] + String actualDir def actualGitArgs private ignoreOutputFile @@ -56,6 +57,9 @@ class ScriptMock { return expectedDefaultShRetValue } if (value instanceof List) { + // If an exception is thrown here that means that less list items have been passed to + // expectedShRetValueForScript.put('shell command', List) than actual calls to 'shell command'. + // That is, you have to add more items! return ((List) value).removeAt(0) } else { return value @@ -85,7 +89,7 @@ class ScriptMock { } void withEnv(List env, Closure closure) { - actualWithEnv = env + actualWithEnv.add(env) closure.call() } @@ -133,7 +137,14 @@ class ScriptMock { closure.call() } - Map actualWithEnvAsMap() { - actualWithEnv.collectEntries { [it.split('=')[0], it.split('=')[1]] } + def getActualWithEnv() { + actualWithEnv.isEmpty() ? null : actualWithEnv[actualWithEnv.size() - 1] + } + + Map actualWithEnvAsMap(int index = actualWithEnv.size() - 1) { + if (index < 0) { + null + } + actualWithEnv[index].collectEntries { [it.split('=')[0], it.split('=')[1]] } } } From 0503bb7a5129621348b085c79a40e0b4f8770b52 Mon Sep 17 00:00:00 2001 From: Johannes Schnatterer Date: Tue, 2 Jun 2020 11:54:05 +0200 Subject: [PATCH 3/6] #44 Create issue for Git.push implicitly pushing to remote origin --- README.md | 9 +- src/com/cloudogu/ces/cesbuildlib/Git.groovy | 21 +---- .../cloudogu/ces/cesbuildlib/GitFlow.groovy | 2 +- .../cloudogu/ces/cesbuildlib/GitTest.groovy | 94 +------------------ 4 files changed, 18 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 3d3f2b05..c6d9548e 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,14 @@ gitWithCreds 'https://your.repo' // Implicitly passed credentials ### Changes to remote repository -* `git.push('master')` - pushes origin +* `git.push('origin master')` - pushes origin + **Note**: This always prepends `origin` if not present for historical reasonse (see #44). + That is, right now it is impossible to push other remotes. + This will change in the next major version of ces-build-lib. + This limitation does not apply to other remote-related operations such as `pull()`, `fetch()` and `pushAndPullOnFailure()` + So it's recommended to explicitly mention the origin and not just the refsepc: + * Do: `git.push('origin master')` + * Don't: `git.push('master')` because this will no longer work in the next major version. * `git.pushAndPullOnFailure('refspec')` - pushes and pulls if push failed e.g. because local and remote have diverged, then tries pushing again diff --git a/src/com/cloudogu/ces/cesbuildlib/Git.groovy b/src/com/cloudogu/ces/cesbuildlib/Git.groovy index aa05b1f3..a7dfaa06 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Git.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Git.groovy @@ -335,7 +335,10 @@ class Git implements Serializable { * @param refSpec branch or tag name */ void push(String refSpec = '') { - refSpec = addOriginWhenMissing(refSpec) + // It turned out that it was not a good idea to always add origin at this place as it does not allow for using + // other remotes. + // However, removing "origin" here now breaks backwards compatibility. See #44 + refSpec = refSpec.trim().startsWith('origin') ? refSpec : "origin ${refSpec}" executeGitWithCredentialsAndRetry "push ${refSpec}" } @@ -347,7 +350,6 @@ class Git implements Serializable { * @param authorEmail */ void pull(String refSpec = '', String authorName = commitAuthorName, String authorEmail = commitAuthorEmail) { - refSpec = addOriginWhenMissing(refSpec) withAuthorAndEmail(authorName, authorEmail) { executeGitWithCredentials "pull ${refSpec}" } @@ -361,27 +363,12 @@ class Git implements Serializable { * @param authorEmail */ void pushAndPullOnFailure(String refSpec = '', String authorName = commitAuthorName, String authorEmail = commitAuthorEmail) { - refSpec = addOriginWhenMissing(refSpec) executeGitWithCredentialsAndRetry("push ${refSpec}") { script.echo "Got error, trying to pull first" pull(refSpec, authorName, authorEmail) } } - /** - * Method exists purely because of downward compatibility. Adding remote to pushes and pulls is preferred, - * but historically git push always added `origin` implicitly. - * - */ - private static String addOriginWhenMissing(String refSpec) { - // if refspec contains more than 1 argument e.g. `upstream master` - if(!refSpec || refSpec.trim().split(' ').length > 1 || refSpec.trim() == 'origin') { - return refSpec - } - - return 'origin ' + refSpec - } - /** * Removes a branch at origin. * diff --git a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy index 8b44bc0d..aba8d7c9 100644 --- a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy @@ -69,7 +69,7 @@ class GitFlow implements Serializable { git.checkout(releaseVersion) // Push changes and tags - git.push("master develop ${releaseVersion}") + git.push("origin master develop ${releaseVersion}") git.deleteOriginBranch(branchName) } } diff --git a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy index 40dd03ca..377b451f 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy @@ -227,7 +227,7 @@ class GitTest { } @Test - void "pull with empty refspec"() { + void pull() { def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull' scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') @@ -244,83 +244,7 @@ class GitTest { assert scriptMock.actualShMapArgs.size() == 3 assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials } - - @Test - void "pull origin"() { - def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull origin' - scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) - scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') - git = new Git(scriptMock, 'creds') - - git.pull('origin') - - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - - assert scriptMock.actualShMapArgs.size() == 3 - assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials - } - - @Test - void 'pull master'() { - def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull origin master' - scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) - scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') - git = new Git(scriptMock, 'creds') - - git.pull 'master' - - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - - assert scriptMock.actualShMapArgs.size() == 3 - assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials - } - - @Test - void 'pull origin master'() { - def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull origin master' - scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) - scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') - git = new Git(scriptMock, 'creds') - - git.pull 'origin master' - - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - - assert scriptMock.actualShMapArgs.size() == 3 - assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials - } - - @Test - void 'pull upstream master'() { - def expectedGitCommandWithCredentials = 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull upstream master' - scriptMock.expectedShRetValueForScript.put(expectedGitCommandWithCredentials, 0) - scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') - git = new Git(scriptMock, 'creds') - - git.pull 'upstream master' - - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - - assert scriptMock.actualShMapArgs.size() == 3 - assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials - } - + @Test void checkout() { git.checkout("master") @@ -403,7 +327,8 @@ class GitTest { git.push() assert scriptMock.actualShMapArgs.size() == 1 - assert scriptMock.actualShMapArgs.get(0).trim() == 'git push' + // This is somewhat unexpected and to be resolved with #44 + assert scriptMock.actualShMapArgs.get(0).trim() == 'git push origin' } @Test @@ -424,15 +349,6 @@ class GitTest { assert scriptMock.actualShMapArgs.get(0) == 'git push origin master' } - @Test - void "push upstream master"() { - scriptMock.expectedDefaultShRetValue = 0 - git.push('upstream master') - - assert scriptMock.actualShMapArgs.size() == 1 - assert scriptMock.actualShMapArgs.get(0) == 'git push upstream master' - } - @Test void pushNonHttps() { scriptMock.expectedShRetValueForScript.put('git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master', 0) @@ -487,7 +403,7 @@ class GitTest { git = new Git(scriptMock, 'creds') git.retryTimeout = 1 - git.pushAndPullOnFailure('master') + git.pushAndPullOnFailure('origin master') def actualWithEnv = scriptMock.actualWithEnvAsMap() assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' From 34fcff2fcef00f3e27e7872e9b9acc0ede89cab6 Mon Sep 17 00:00:00 2001 From: Johannes Schnatterer Date: Tue, 2 Jun 2020 15:22:20 +0200 Subject: [PATCH 4/6] Refactor tests before adding functionality. Different author and committer. --- .../cloudogu/ces/cesbuildlib/GitTest.groovy | 83 +++++-------------- .../ces/cesbuildlib/ScriptMock.groovy | 2 +- 2 files changed, 24 insertions(+), 61 deletions(-) diff --git a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy index 377b451f..c61b7ab2 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy @@ -192,11 +192,7 @@ class GitTest { void commit() { scriptMock.expectedDefaultShRetValue = "User Name " git.commit 'msg' - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') } @Test @@ -204,12 +200,9 @@ class GitTest { scriptMock.expectedDefaultShRetValue = "User Name " git.setTag("someTag", "someMessage") git.setTag("myTag", "myMessage", true) - def actualWithEnv = scriptMock.actualWithEnvAsMap() - - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + + assertAuthor('User Name', 'user.name@doma.in') + assert scriptMock.actualShStringArgs[0] == "git tag -m \"someMessage\" someTag" assert scriptMock.actualShStringArgs[1] == "git tag -f -m \"myMessage\" myTag" } @@ -235,16 +228,12 @@ class GitTest { git.pull() - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShMapArgs.size() == 3 assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials } - + @Test void checkout() { git.checkout("master") @@ -270,47 +259,28 @@ class GitTest { @Test void merge() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedDefaultShRetValue = "User Name " - Git git = new Git(scriptMock) git.merge("master") - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - println scriptMock.actualShStringArgs + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShStringArgs[0] == "git merge master" } @Test void mergeFastForwardOnly() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedDefaultShRetValue = "User Name " - Git git = new Git(scriptMock) git.mergeFastForwardOnly("master") - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShStringArgs[0] == "git merge --ff-only master" } @Test void mergeNoFastForward() { - ScriptMock scriptMock = new ScriptMock() scriptMock.expectedDefaultShRetValue = "User Name " - Git git = new Git(scriptMock) git.mergeNoFastForward("master") - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShStringArgs[0] == "git merge --no-ff master" } @@ -382,11 +352,7 @@ class GitTest { git.retryTimeout = 1 git.pushAndPullOnFailure() - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShMapArgs.size() == 5 assert scriptMock.actualShMapArgs.get(2).trim() == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push' @@ -405,12 +371,8 @@ class GitTest { git.retryTimeout = 1 git.pushAndPullOnFailure('origin master') - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' - + assertAuthor('User Name', 'user.name@doma.in') + assert scriptMock.actualShMapArgs.size() == 5 assert scriptMock.actualShMapArgs.get(2) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' assert scriptMock.actualShMapArgs.get(3) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" pull origin master' @@ -428,11 +390,7 @@ class GitTest { git.retryTimeout = 1 git.pushAndPullOnFailure('origin master') - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShMapArgs.size() == 5 assert scriptMock.actualShMapArgs.get(2) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push origin master' @@ -451,11 +409,7 @@ class GitTest { git.retryTimeout = 1 git.pushAndPullOnFailure('upstream master') - def actualWithEnv = scriptMock.actualWithEnvAsMap() - assert actualWithEnv['GIT_AUTHOR_NAME'] == 'User Name' - assert actualWithEnv['GIT_COMMITTER_NAME'] == 'User Name' - assert actualWithEnv['GIT_AUTHOR_EMAIL'] == 'user.name@doma.in' - assert actualWithEnv['GIT_COMMITTER_EMAIL'] == 'user.name@doma.in' + assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShMapArgs.size() == 5 assert scriptMock.actualShMapArgs.get(2) == 'git -c credential.helper="!f() { echo username=\'$GIT_AUTH_USR\'; echo password=\'$GIT_AUTH_PSW\'; }; f" push upstream master' @@ -471,4 +425,13 @@ class GitTest { assert scriptMock.actualShMapArgs.get(0) == 'git push origin master' } + + private void assertAuthor(String authorName, String authorEmail, + String committerName = authorName, String committerEmail = authorEmail) { + def actualWithEnv = scriptMock.actualWithEnvAsMap() + assert actualWithEnv['GIT_AUTHOR_NAME'] == authorName + assert actualWithEnv['GIT_COMMITTER_NAME'] == committerName + assert actualWithEnv['GIT_AUTHOR_EMAIL'] == authorEmail + assert actualWithEnv['GIT_COMMITTER_EMAIL'] == committerEmail + } } diff --git a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy index ea00dbce..2a64c573 100644 --- a/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/ScriptMock.groovy @@ -143,7 +143,7 @@ class ScriptMock { Map actualWithEnvAsMap(int index = actualWithEnv.size() - 1) { if (index < 0) { - null + return null } actualWithEnv[index].collectEntries { [it.split('=')[0], it.split('=')[1]] } } From 57cff402043f28779f318548c8f62d45aee713c8 Mon Sep 17 00:00:00 2001 From: Johannes Schnatterer Date: Tue, 2 Jun 2020 15:48:36 +0200 Subject: [PATCH 5/6] #30 Implement option to set a different committer --- README.md | 18 +++++-- src/com/cloudogu/ces/cesbuildlib/Git.groovy | 9 +++- .../cloudogu/ces/cesbuildlib/GitTest.groovy | 52 ++++++++++++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c6d9548e..907e1eb2 100644 --- a/README.md +++ b/README.md @@ -480,20 +480,30 @@ gitWithCreds 'https://your.repo' // Implicitly passed credentials ### Changes to local repository +Note that most changing operations offer parameters to specify an author. +Theses parameters are optional. If not set the author of the last commit will be used as author and committer. +You can specify a different committer by setting the following fields: + +* `git.committerName` +* `git.committerEmail` + +It is recommended to set a different committer, so it's obvious those commits were done by Jenkins in the name of +the author. This behaviour is implemented by GitHub for example when committing via the Web UI. + * `git.checkout('branchname')` * `git.checkoutOrCreate('branchname')` - Creates new Branch if it does not exist * `git.add('.')` * `git.commit('message', 'Author', 'Author@mail.server)` -* `git.commit('message')` - uses the name and email of the last committer as author and committer. +* `git.commit('message')` - uses default author/committer (see above). * `git.setTag('tag', 'message', 'Author', 'Author@mail.server)` -* `git.setTag('tag', 'message')` - uses the name and email of the last committer as author and committer. +* `git.setTag('tag', 'message')` - uses default author/committer (see above). * `git.fetch()` -* `git.pull()` - pulls, and in case of merge, uses the name and email of the last committer as author and committer. +* `git.pull()` - pulls, and in case of merge, uses default author/committer (see above). * `git.pull('refspec')` - pulls specific refspec (e.g. `origin master`), and in case of merge, uses the name and email of the last committer as author and committer. * `git.pull('refspec', 'Author', 'Author@mail.server)` * `git.merge('develop', 'Author', 'Author@mail.server)` -* `git.merge('develop')` - uses the name and email of the last committer as author and committer. +* `git.merge('develop')` - uses default author/committer (see above). * `git.mergeFastForwardOnly('master')` ### Changes to remote repository diff --git a/src/com/cloudogu/ces/cesbuildlib/Git.groovy b/src/com/cloudogu/ces/cesbuildlib/Git.groovy index a7dfaa06..10359667 100644 --- a/src/com/cloudogu/ces/cesbuildlib/Git.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/Git.groovy @@ -6,6 +6,9 @@ class Git implements Serializable { def credentials = null def retryTimeout = 500 def maxRetries = 5 + String committerName + String committerEmail + Git(script, credentials) { this(script) @@ -224,8 +227,10 @@ class Git implements Serializable { } private void withAuthorAndEmail(String authorName, String authorEmail, Closure closure) { - script.withEnv(["GIT_AUTHOR_NAME=$authorName", "GIT_AUTHOR_EMAIL=$authorEmail", - "GIT_COMMITTER_NAME=$authorName", "GIT_COMMITTER_EMAIL=$authorEmail"]) { + script.withEnv(["GIT_AUTHOR_NAME=${authorName}", + "GIT_AUTHOR_EMAIL=${authorEmail}", + "GIT_COMMITTER_NAME=${committerName ? committerName : authorName}", + "GIT_COMMITTER_EMAIL=${committerEmail ? committerEmail : authorEmail}"]) { closure.call() } } diff --git a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy index c61b7ab2..9af124c0 100644 --- a/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy +++ b/test/com/cloudogu/ces/cesbuildlib/GitTest.groovy @@ -12,8 +12,8 @@ class GitTest { ScriptMock scriptMock = new ScriptMock() Git git = new Git(scriptMock) - def expectedRemoteWithoutCredentials = 'git remote set-url origin https://repo.url' - def expectedRemoteWithCredentials = 'git remote set-url origin https://username:thePassword@repo.url' + static final EXPECTED_COMMITTER_NAME = 'U 2' + static final EXPECTED_COMMITTER_EMAIL = 'user-numb@t.wo' @After void tearDown() throws Exception { @@ -194,6 +194,16 @@ class GitTest { git.commit 'msg' assertAuthor('User Name', 'user.name@doma.in') } + + @Test + void 'commit with different committer'() { + scriptMock.expectedDefaultShRetValue = "User Name " + git.committerName = EXPECTED_COMMITTER_NAME + git.committerEmail = EXPECTED_COMMITTER_EMAIL + git.commit 'msg' + assertAuthor('User Name', 'user.name@doma.in', + EXPECTED_COMMITTER_NAME, EXPECTED_COMMITTER_EMAIL) + } @Test void setTag() { @@ -206,6 +216,18 @@ class GitTest { assert scriptMock.actualShStringArgs[0] == "git tag -m \"someMessage\" someTag" assert scriptMock.actualShStringArgs[1] == "git tag -f -m \"myMessage\" myTag" } + + @Test + void 'setTag with different committer'() { + scriptMock.expectedDefaultShRetValue = "User Name " + git.committerName = EXPECTED_COMMITTER_NAME + git.committerEmail = EXPECTED_COMMITTER_EMAIL + + git.setTag("someTag", "someMessage") + + assertAuthor('User Name', 'user.name@doma.in', + EXPECTED_COMMITTER_NAME, EXPECTED_COMMITTER_EMAIL) + } @Test void fetch() { @@ -233,6 +255,19 @@ class GitTest { assert scriptMock.actualShMapArgs.size() == 3 assert scriptMock.actualShMapArgs.get(2).trim() == expectedGitCommandWithCredentials } + + @Test + void 'pull with different committer'() { + git.committerName = EXPECTED_COMMITTER_NAME + git.committerEmail = EXPECTED_COMMITTER_EMAIL + + scriptMock.expectedShRetValueForScript.put('git --no-pager show -s --format=\'%an <%ae>\' HEAD', 'User Name ') + + git.pull() + + assertAuthor('User Name', 'user.name@doma.in', + EXPECTED_COMMITTER_NAME, EXPECTED_COMMITTER_EMAIL) + } @Test void checkout() { @@ -265,6 +300,19 @@ class GitTest { assertAuthor('User Name', 'user.name@doma.in') assert scriptMock.actualShStringArgs[0] == "git merge master" } + + @Test + void 'merge with different committer'() { + git.committerName = EXPECTED_COMMITTER_NAME + git.committerEmail = EXPECTED_COMMITTER_EMAIL + + scriptMock.expectedDefaultShRetValue = "User Name " + git.merge("master") + + assertAuthor('User Name', 'user.name@doma.in', + EXPECTED_COMMITTER_NAME, EXPECTED_COMMITTER_EMAIL) + assert scriptMock.actualShStringArgs[0] == "git merge master" + } @Test void mergeFastForwardOnly() { From fe94b3098526cf8a9b6721384c6572f45f286ede Mon Sep 17 00:00:00 2001 From: Johannes Schnatterer Date: Tue, 2 Jun 2020 17:45:12 +0200 Subject: [PATCH 6/6] #30 Fixes commit author. Currently, last author from master branch is used. We want last author from release, branch, though. And we want this author to be used for both merges: Develop and master! --- README.md | 8 +++++--- src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 907e1eb2..b4308e07 100644 --- a/README.md +++ b/README.md @@ -484,8 +484,8 @@ Note that most changing operations offer parameters to specify an author. Theses parameters are optional. If not set the author of the last commit will be used as author and committer. You can specify a different committer by setting the following fields: -* `git.committerName` -* `git.committerEmail` +* `git.committerName = 'the name'` +* `git.committerEmail = 'an.em@i.l` It is recommended to set a different committer, so it's obvious those commits were done by Jenkins in the name of the author. This behaviour is implemented by GitHub for example when committing via the Web UI. @@ -875,11 +875,13 @@ Example: ```groovy Git git = new Git(this) +git.committerName = 'jenkins' +git.committerEmail = 'jenkins@your.org' GitFlow gitflow = new GitFlow(this, git) stage('Gitflow') { if (gitflow.isReleaseBranch()){ - gitflow.finishRelease('v1.0.0') + gitflow.finishRelease(git.getSimpleBranchName()) } } ``` diff --git a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy index aba8d7c9..fc220fdc 100644 --- a/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy +++ b/src/com/cloudogu/ces/cesbuildlib/GitFlow.groovy @@ -45,17 +45,18 @@ class GitFlow implements Serializable { // Make sure any branch we need exists locally git.checkoutLatest(branchName) + // Remember latest committer on develop to use as author of release commits + String releaseBranchAuthor = git.commitAuthorName + String releaseBranchEmail = git.commitAuthorEmail + git.checkoutLatest('develop') git.checkoutLatest('master') // Merge release branch into master - git.mergeNoFastForward(branchName) + git.mergeNoFastForward(branchName, releaseBranchAuthor, releaseBranchEmail) // Create tag. Use -f because the created tag will persist when build has failed. git.setTag(releaseVersion, "release version ${releaseVersion}", true) - String releaseBranchAuthor = git.commitAuthorName - String releaseBranchEmail = git.commitAuthorEmail - // Merge release branch into develop git.checkout('develop') // Set author of release Branch as author of merge commit