#!/usr/bin/env groovy import groovy.json.JsonOutput import java.text.* all_status = [:] commit_id = '' git_fork = 'ripple' git_repo = 'rippled' // // this is not the actual token, but an ID/key into the jenkins // credential store which httpRequest can access. // github_cred = '6bd3f3b9-9a35-493e-8aef-975403c82d3e' // // root API url for our repo (default, overriden below) // github_api = 'https://api.github.com/repos/ripple/rippled' try { stage ('Startup Checks') { // here we check the commit author against collaborators // we need a node to do this because readJSON requires // a filesystem (although we just pass it text) node { checkout scm commit_id = getCommitID() // // NOTE this getUserRemoteConfigs call requires a one-time // In-process Script Approval (configure jenkins). We need this // to detect the remote repo to interact with via the github API. // def remote_url = scm.getUserRemoteConfigs()[0].getUrl() if (remote_url) { echo "GIT URL scm: $remote_url" git_fork = remote_url.tokenize('/')[2] git_repo = remote_url.tokenize('/')[3].split('\\.')[0] echo "GIT FORK: $git_fork" echo "GIT REPO: $git_repo" github_api = "https://api.github.com/repos/${git_fork}/${git_repo}" echo "API URL REPO: $github_api" } if (env.CHANGE_AUTHOR) { def collab_found = false; // // this means we have some sort of PR , so verify the author // echo "CHANGE AUTHOR ---> $CHANGE_AUTHOR" echo "CHANGE TARGET ---> $CHANGE_TARGET" echo "CHANGE ID ---> $CHANGE_ID" // // check the commit author against collaborators // we need a node to do this because readJSON requires // a filesystem (although we just pass it text) // def response = httpRequest( timeout: 10, authentication: github_cred, url: "${github_api}/collaborators") def collab_data = readJSON( text: response.content) for (collaborator in collab_data) { if (collaborator['login'] == "$CHANGE_AUTHOR") { echo "$CHANGE_AUTHOR is a collaborator!" collab_found = true; break; } } if (! collab_found) { echo "$CHANGE_AUTHOR is not a collaborator - waiting for manual approval." sendToSlack("A <${env.BUILD_URL}|jenkins job (PR)> is waiting for approval - please review.") try { httpRequest( timeout: 10, authentication: github_cred, url: getCommentURL(), contentType: 'APPLICATION_JSON', httpMode: 'POST', requestBody: JsonOutput.toJson([ body: """ **Thank you** for your submission. It will be reviewed soon and submitted for processing in CI. """ ]) ) } catch (e) { echo 'had a problem interacting with github...comments are probably not updated' } try { input ( message: "User $CHANGE_AUTHOR has submitted PR #$CHANGE_ID. " + "**Please review** the changes for any CI/security concerns " + "and then decide whether to proceed with building.") } catch(e) { def user = e.getCauses()[0].getUser().toString() all_status['startup'] = [ false, 'Approval Check', "Build aborted by [${user}]", "[console](${env.BUILD_URL}/console)"] error "Aborted by: [${user}]" } } } } // UNCOMMENT the following if we want one message for every job... //node('rippled-dev') { // sendToSlack("<${env.BUILD_URL}|Job ${env.BUILD_TAG}> has started.") //} } stage ('Parallel Build') { String[][] variants = [ ['gcc.Release' ,'-Dassert=ON' ,'MANUAL_TESTS=true' ], ['docs' ,'' ,'TARGET=docs' ], ['msvc.Debug' ], ['msvc.Debug' ,'' ,'NINJA_BUILD=true' ], ['msvc.Debug' ,'-Dunity=OFF' ], ['msvc.Release' ], ['gcc.Debug' ], ['gcc.Debug' ,'-Dunity=OFF' ], ['gcc.Release' ,'-Dassert=ON' ], ['gcc.Debug' ,'-Dstatic=OFF' ], ['gcc.Debug' ,'-Dstatic=OFF -DBUILD_SHARED_LIBS=ON' ], ['gcc.Debug' ,'' ,'NINJA_BUILD=true' ], ] // create a map of all builds // that we want to run. The map // is string keys and node{} object values def builds = [:] for (int index = 0; index < variants.size(); index++) { def bldtype = variants[index][0] def cmake_extra = variants[index].size() > 1 ? variants[index][1] : '' def bldlabel = bldtype + cmake_extra def extra_env = variants[index].size() > 2 ? variants[index][2..-1] : [] for (int j = 0; j < extra_env.size(); j++) { bldlabel += "_" + extra_env[j] } bldlabel = bldlabel.replace('-', '_') bldlabel = bldlabel.replace(' ', '') bldlabel = bldlabel.replace('=', '_') def compiler = getFirstPart(bldtype) def config = getSecondPart(bldtype) def target = 'install' // currently ignored for windows builds if (compiler == 'docs') { compiler = 'gcc' config = 'Release' target = 'docs' } def cc = 'gcc' def cxx = 'g++' def ucc = isNoUnity(cmake_extra) ? 'true' : 'false' def node_type = (compiler == 'msvc') ? 'rippled-win' : 'rippled-dev' // the default disposition for parallel test..disabled // for coverage, enabled otherwise. Can still be overridden // by explicitly setting with extra env settings above. def pt = isCoverage(cmake_extra) ? 'false' : 'true' def max_minutes = 25 def env_vars = [ "TARGET=${target}", "BUILD_TYPE=${config}", "COMPILER=${compiler}", "PARALLEL_TESTS=${pt}", 'BUILD=cmake', "MAX_TIME=${max_minutes}m", "BUILD_DIR=${bldlabel}", "CMAKE_EXTRA_ARGS=-Dwerr=ON ${cmake_extra}", 'VERBOSE_BUILD=true'] builds[bldlabel] = { node(node_type) { checkout scm dir ('build') { deleteDir() } def cdir = upDir(pwd()) echo "BASEDIR: ${cdir}" echo "COMPILER: ${compiler}" echo "BUILD_TYPE: ${config}" echo "USE_CC: ${ucc}" env_vars.addAll([ "NIH_CACHE_ROOT=${cdir}/.nih_c"]) if (compiler == 'msvc') { env_vars.addAll([ 'BOOST_ROOT=c:\\lib\\boost_1_70', 'PROJECT_NAME=rippled', 'MSBUILDDISABLENODEREUSE=1', // this ENV setting is probably redundant since we also pass /nr:false to msbuild 'OPENSSL_ROOT=c:\\OpenSSL-Win64']) } else { env_vars.addAll([ 'NINJA_BUILD=false', "CCACHE_BASEDIR=${cdir}", 'PLANTUML_JAR=/opt/plantuml/plantuml.jar', 'APP_ARGS=--unittest-ipv6', 'CCACHE_NOHASHDIR=true', "CC=${cc}", "CXX=${cxx}", 'LCOV_ROOT=""', 'PATH+CMAKE_BIN=/opt/local/cmake', 'GDB_ROOT=/opt/local/gdb', 'BOOST_ROOT=/opt/local/boost_1_70_0', "USE_CCACHE=${ucc}"]) } if (extra_env.size() > 0) { env_vars.addAll(extra_env) } // try to figure out codecov token to use. Look for // MY_CODECOV_TOKEN id first so users can set that // on job scope but then default to RIPPLED_CODECOV_TOKEN // which should be globally scoped def codecov_token = '' try { withCredentials( [string( credentialsId: 'MY_CODECOV_TOKEN', variable: 'CODECOV_TOKEN')]) { codecov_token = env.CODECOV_TOKEN } } catch (e) { // this might throw when MY_CODECOV_TOKEN doesn't exist } if (codecov_token == '') { withCredentials( [string( credentialsId: 'RIPPLED_CODECOV_TOKEN', variable: 'CODECOV_TOKEN')]) { codecov_token = env.CODECOV_TOKEN } } env_vars.addAll(["CODECOV_TOKEN=${codecov_token}"]) withEnv(env_vars) { myStage(bldlabel) def thrown = ''; try { timeout( time: max_minutes * 2, units: 'MINUTES') { if (compiler == 'msvc') { powershell "Remove-Item -Path \"${bldlabel}.txt\" -Force -ErrorAction Ignore" // we capture stdout to variable because I could // not figure out how to make powershell redirect internally output = powershell ( returnStdout: true, script: windowsBuildCmd()) // if the powershell command fails (has nonzero exit) // then the command above throws, we don't get our output, // and we never create this output file. // SEE https://issues.jenkins-ci.org/browse/JENKINS-44930 // Alternatively, figure out how to reliably redirect // all output above to a file (Start/Stop transcript does not work) writeFile( file: "${bldlabel}.txt", text: output) } else { sh "rm -fv ${bldlabel}.txt" // execute the bld command in a redirecting shell // to capture output sh redhatBuildCmd(bldlabel) } } } catch(e) { thrown = "${e}" throw e } finally { if (bldtype == 'docs') { publishHTML( allowMissing: true, alwaysLinkToLastBuild: true, keepAll: true, reportName: 'Doxygen', reportDir: "build/${bldlabel}/html_doc", reportFiles: 'index.html') } if (isCoverage(cmake_extra)) { publishHTML( allowMissing: true, alwaysLinkToLastBuild: false, keepAll: true, reportName: 'Coverage', reportDir: "build/${bldlabel}/coverage", reportFiles: 'index.html') } def envs = '' for (int j = 0; j < extra_env.size(); j++) { envs += ",
" + extra_env[j] } def cmake_txt = cmake_extra if (cmake_txt != '') { cmake_txt = "
" + cmake_txt } def st = reportStatus(bldlabel, bldtype + cmake_txt + envs, env.BUILD_URL, thrown) lock('rippled_dev_status') { all_status[bldlabel] = st } if (thrown == '') { assert st[0] : "Unit Test Failures" } } //try-catch-finally } //withEnv } //node } //builds item } //for variants // this actually executes all the builds we just defined // above, in parallel as slaves are available parallel builds } } finally { // anything here should run always... stage ('Final Status') { node { def results = makeResultText() try { def res = getCommentID() //get array return b/c jenkins does not allow multiple direct return/assign def comment_id = res[0] def url_comment = res[1] def mode = 'PATCH' if (comment_id == 0) { echo 'no existing status comment found' mode = 'POST' } def body = JsonOutput.toJson([ body: results ]) response = httpRequest( timeout: 10, authentication: github_cred, url: url_comment, contentType: 'APPLICATION_JSON', httpMode: mode, requestBody: body) } catch (e) { echo 'had a problem interacting with github...status is probably not updated' } } } } // --------------- // util functions // --------------- def myStage(name) { echo """ +++++++++++++++++++++++++++++++++++++++++ >> building ${name} +++++++++++++++++++++++++++++++++++++++++ """ } def printGitInfo(id, log) { echo """ +++++++++++++++++++++++++++++++++++++++++ >> Building commit ID ${id} >> ${log} +++++++++++++++++++++++++++++++++++++++++ """ } def makeResultText () { def start_time = new Date() def sdf = new SimpleDateFormat('yyyyMMdd - HH:mm:ss') def datestamp = sdf.format(start_time) def results = """ ## Jenkins Build Summary Built from [this commit](https://github.com/${git_fork}/${git_repo}/commit/${commit_id}) Built at __${datestamp}__ ### Test Results Build Type | Log | Result | Status ---------- | --- | ------ | ------ """ for ( e in all_status) { results += e.value[1] + ' | ' + e.value[3] + ' | ' + e.value[2] + ' | ' + (e.value[0] ? 'PASS :white_check_mark: ' : 'FAIL :red_circle: ') + '\n' } results += '\n' echo 'FINAL BUILD RESULTS' echo results results } def getCommentURL () { def url_c = '' if (env.CHANGE_ID && env.CHANGE_ID ==~ /\d+/) { // // CHANGE_ID indicates we are building a PR // find PR comments // def resp = httpRequest( timeout: 10, authentication: github_cred, url: "${github_api}/pulls/$CHANGE_ID") def result = readJSON(text: resp.content) // // follow issue comments link // url_c = result['_links']['issue']['href'] + '/comments' } else { // // if not a PR, just search comments for our commit ID // url_c = "${github_api}/commits/${commit_id}/comments" } url_c } def getCommentID () { def url_c = getCommentURL() def response = httpRequest( timeout: 10, authentication: github_cred, url: url_c) def data = readJSON(text: response.content) def comment_id = 0 // see if we can find and existing comment here with // a heading that matches ours... for (comment in data) { if (comment['body'] =~ /(?m)^##\s+Jenkins Build/) { comment_id = comment['id'] echo "existing status comment ${comment_id} found" url_c = comment['url'] break; } } [comment_id, url_c] } def getCommitID () { def cid = sh ( script: 'git rev-parse HEAD', returnStdout: true) cid = cid.trim() echo "commit ID is ${cid}" commit_log = sh ( script: "git show --name-status ${cid}", returnStdout: true) printGitInfo (cid, commit_log) cid } @NonCPS def getResults(text, label) { // example: /// 194.5s, 154 suites, 948 cases, 360485 tests total, 0 failures // or build log format: // [msvc.release] 71.3s, 162 suites, 995 cases, 318901 tests total, 1 failure def matcher = text == '' ? manager.getLogMatcher(/\[${label}\].+?(\d+) case[s]?, (\d+) test[s]? total, (\d+) (failure(s?))/) : text =~ /(\d+) case[s]?, (\d+) test[s]? total, (\d+) (failure(s?))/ matcher ? matcher[0][1] + ' cases, ' + matcher[0][3] + ' failed' : 'no test results' } @NonCPS def getFailures(text, label) { // [see above for format] def matcher = text == '' ? manager.getLogMatcher(/\[${label}\].+?(\d+) test[s]? total, (\d+) (failure(s?))/) : text =~ /(\d+) test[s]? total, (\d+) (failure(s?))/ // if we didn't match, then return -1 since something is // probably wrong, e.g. maybe the build failed... matcher ? matcher[0][2] as Integer : -1i } @NonCPS def getTime(text, label) { // look for text following a label 'real' for // wallclock time. Some `time`s report fractional // seconds and we can omit those in what we report def matcher = text == '' ? manager.getLogMatcher(/(?m)^\[${label}\]\s+real\s+(.+)\.(\d+?)[s]?/) : text =~ /(?m)^real\s+(.+)\.(\d+?)[s]?/ if (matcher) { return matcher[0][1] + 's' } // alternatively, look for powershell elapsed time // format, e.g. : // TotalSeconds : 523.2140529 def matcher2 = text == '' ? manager.getLogMatcher(/(?m)^\[${label}\]\s+TotalSeconds\s+:\s+(\d+)\.(\d+?)?/) : text =~ /(?m)^TotalSeconds\s+:\s+(\d+)\.(\d+?)?/ matcher2 ? matcher2[0][1] + 's' : 'n/a' } @NonCPS def getFirstPart(bld) { def matcher = bld =~ /^(.+?)\.(.+)$/ matcher ? matcher[0][1] : bld } @NonCPS def isNoUnity(bld) { def matcher = bld =~ /-Dunity=(off|OFF)/ matcher ? true : false } @NonCPS def isCoverage(bld) { def matcher = bld =~ /-Dcoverage=(on|ON)/ matcher ? true : false } @NonCPS def getSecondPart(bld) { def matcher = bld =~ /^(.+?)\.(.+)$/ matcher ? matcher[0][2] : bld } // because I can't seem to find path manipulation // functions in groovy.... @NonCPS def upDir(path) { def matcher = path =~ /^(.+)[\/\\](.+?)/ matcher ? matcher[0][1] : path } def sendToSlack(message) { try { withCredentials( [string( credentialsId: 'RIPPLED_SLACK_INCOMING_URL', variable: 'SLACK_URL')]) { // I was unable to make httpRequest method work with the // formdata required by slack API, so resorting // to curl commands... sh '''\ CONTENT=$(tr -d '[\n]' <&1 1>>${log_file} 2>&1 ccache -s source /opt/rh/devtoolset-7/enable /usr/bin/time -p ./bin/ci/ubuntu/build-and-test.sh 2>&1 ccache -s ''' } // the powershell command used for building def windowsBuildCmd() { ''' # Enable streams 3-6 $WarningPreference = 'Continue' $VerbosePreference = 'Continue' $DebugPreference = 'Continue' $InformationPreference = 'Continue' Invoke-BatchFile "${env:ProgramFiles(x86)}\\Microsoft Visual Studio\\2017\\Community\\VC\\Auxiliary\\Build\\vcvarsall.bat" x86_amd64 Get-ChildItem env:* | Sort-Object name cl cmake --version New-Item -ItemType Directory -Force -Path "build/$env:BUILD_DIR" -ErrorAction Stop $sw = [Diagnostics.Stopwatch]::StartNew() try { Push-Location "build/$env:BUILD_DIR" if ($env:NINJA_BUILD -eq "true") { Invoke-Expression "& cmake -G`"Ninja`" -DCMAKE_BUILD_TYPE=$env:BUILD_TYPE -DCMAKE_VERBOSE_MAKEFILE=ON $env:CMAKE_EXTRA_ARGS ../.." } else { Invoke-Expression "& cmake -G`"Visual Studio 15 2017 Win64`" -DCMAKE_VERBOSE_MAKEFILE=ON $env:CMAKE_EXTRA_ARGS ../.." } if ($LastExitCode -ne 0) { throw "CMake failed" } ## as of 01/2018, DO NOT USE cmake to run the actual build step. for some ## reason, cmake spawning the build under jenkins causes MSBUILD/ninja to ## get stuck at the end of the build. Perhaps cmake is spawning ## incorrectly or failing to pass certain params if ($env:NINJA_BUILD -eq "true") { ninja -j $env:NUMBER_OF_PROCESSORS -v } else { msbuild /fl /m /nr:false /p:Configuration="$env:BUILD_TYPE" /p:Platform=x64 /p:GenerateFullPaths=True /v:normal /nologo /clp:"ShowCommandLine;DisableConsoleColor" "$env:PROJECT_NAME.vcxproj" } if ($LastExitCode -ne 0) { throw "CMake build failed" } $exe = "./$env:BUILD_TYPE/$env:PROJECT_NAME" if ($env:NINJA_BUILD -eq "true") { $exe = "./$env:PROJECT_NAME" } "Exe is at $exe" $params = '--unittest', '--quiet', '--unittest-log' if ($env:PARALLEL_TESTS -eq "true") { $params = $params += "--unittest-jobs=$env:NUMBER_OF_PROCESSORS" } & $exe $params if ($LastExitCode -ne 0) { throw "Unit tests failed" } } catch { throw } finally { $sw.Stop() $sw.Elapsed Pop-Location } ''' } // post processing step after each build: // * archives the log file // * adds short description/status to build status // * returns an array of result info to add to the all_build summary def reportStatus(label, type, bldurl, errmsg) { def outstr = '' def loglink = "[console](${bldurl}/console)" def logfile = "${label}.txt" if ( fileExists(logfile) ) { archiveArtifacts( artifacts: logfile ) outstr = readFile(logfile) loglink = "[logfile](${bldurl}/artifact/${logfile})" } def st = getResults(outstr, label) def time = getTime(outstr, label) def fail_count = getFailures(outstr, label) outstr = null def txtcolor = (fail_count == 0 && errmsg == '') ? 'DarkGreen' : 'Crimson' def shortbld = label // this is just an attempt to shorten the // summary text label to the point of absurdity.. shortbld = shortbld.replace('Debug', 'dbg') shortbld = shortbld.replace('Release', 'rel') shortbld = shortbld.replace('true', 'Y') shortbld = shortbld.replace('false', 'N') shortbld = shortbld.replace('Dcoverage', 'cov') shortbld = shortbld.replace('Dassert', 'asrt') shortbld = shortbld.replace('Dunity', 'unty') shortbld = shortbld.replace('Dsan=address', 'asan') shortbld = shortbld.replace('Dsan=thread', 'tsan') shortbld = shortbld.replace('Dsan=undefined', 'ubsan') shortbld = shortbld.replace('PARALLEL_TEST', 'PL') shortbld = shortbld.replace('MANUAL_TESTS', 'MAN') shortbld = shortbld.replace('NINJA_BUILD', 'ninja') shortbld = shortbld.replace('DEBUGGER', 'gdb') shortbld = shortbld.replace('ON', 'Y') shortbld = shortbld.replace('OFF', 'N') def stattext = "${st}, t: ${time}" if (fail_count <= 0 && errmsg != '') { stattext += " [BAD EXIT]" } manager.addShortText( "${shortbld}: ${stattext}", txtcolor, 'white', '0px', 'white') [fail_count == 0 && errmsg == '', type, stattext, loglink] }