tutanota/ci/merge.groovy

560 lines
14 KiB
Groovy
Raw Normal View History

import groovy.json.JsonSlurper
HashSet<String> changeset = new HashSet<String>()
String linuxWorkspace = '/opt/jenkins/jobs/bootleg-ci-merge/workspace-0'
ArrayList<String> linuxWorkspaceClones = [
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-1',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-2',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-3',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-4',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-5',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-6',
'/opt/jenkins/jobs/bootleg-ci-merge/workspace-7'
]
String macWorkspace = '/Users/jenkins/jenkins/workspace/bootleg-ci-merge/'
pipeline {
environment {
VERSION = sh(returnStdout: true, script: "${env.NODE_PATH}/node -p -e \"require('./package.json').version\" | tr -d \"\n\"")
APK_SIGN_STORE = '/opt/android-keystore/android.jks'
PATH = "${env.NODE_PATH}:${env.PATH}:/home/jenkins/emsdk/upstream/bin/:/home/jenkins/emsdk/:/home/jenkins/emsdk/upstream/emscripten:/usr/lib/bin:/opt/homebrew/bin"
ANDROID_SDK_ROOT = "/opt/android-sdk"
ANDROID_HOME = "/opt/android-sdk"
}
agent {
label 'linux'
}
options {
// as long as tests like node/browser/android are run sequentially the timeout needs to be higher than 10 minutes
timeout(time: 15, unit: 'MINUTES')
// this prevents jenkins from running the "Check out from version control" step in every stage.
// we're running several stages in parallel on the same folder, which means git will run in parallel, which
// it can't because it places a lock file in .git
skipDefaultCheckout true
}
tools {
jdk 'jdk-21.0.2'
}
parameters {
validatingString(
// this branch will be the initial branch checked out on the job.
// this is important because if we update the jenkinsfile in a commit
// we need to run the new version, not the one from master.
name: 'SOURCE_BRANCH',
defaultValue: 'dummy-do-not-use',
description: "Branch name (no 'origin' or similar) that gets merged into TARGET_BRANCH",
regex: /^(?!dummy-do-not-use$).*$/,
failedValidationMessage: "please provide one source branch name!",
)
validatingString(
name: 'TARGET_BRANCH',
defaultValue: 'dummy-do-not-use',
description: "Branch name (no 'origin' or similar) that gets updated",
regex: /^(?!dummy-do-not-use$).*$/,
failedValidationMessage: "please provide one target branch name!",
)
booleanParam(
name: 'CLEAN_WORKSPACE',
defaultValue: false,
description: "run 'git clean -dfx' as the first step of the pipeline"
)
booleanParam(
name: 'DRY_RUN',
defaultValue: false,
description: "run the tests, but don't push to TARGET_BRANCH"
)
booleanParam(
name: 'FORCE_RUN_ALL',
defaultValue: false,
description: "run ALL the tests, even if they would normally be pruned because there are no relevant changes."
)
}
stages {
stage("repo prep") {
/**
* each physical node / container has to have a workspace prepared to do any of the checks we want to run.
*
* Most checks could run in parallel because they're read-only workloads, but jenkins really likes allocating
* new workspaces when it detects two stages running in parallel on the same workspace, even when explicitly
* told where to run the stages.
*
* we get around this by first preparing the workspace and then making symlinks for jenkins to use as
* "separate" workspaces that are already initialized.
*/
parallel {
stage("checkout linux") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspace
}
}
steps {
assertBranchWasSet("TARGET_BRANCH", params.TARGET_BRANCH)
assertBranchWasSet("SOURCE_BRANCH", params.SOURCE_BRANCH)
script {
currentBuild.displayName = "${params.DRY_RUN ? "DRY_RUN " : ""}${params.SOURCE_BRANCH} -> ${params.TARGET_BRANCH}"
}
initWorkspace(changeset, params.SOURCE_BRANCH, params.TARGET_BRANCH, params.CLEAN_WORKSPACE)
// building sqlcipher is not parallelizable on the same directory and doesn't lock; so we
// pre-build it while we're not parallel yet. the browser and node tests will pick up the binary
// instead of each building it. we can parallelize it with build-packages though.
sh '''
node buildSrc/getNodeGypLibrary.js @signalapp/sqlcipher --copy-target node_sqlcipher --environment node --root-dir . &
PID1=$!
npm run build-packages &
PID2=$!
wait $PID1
EXIT_CODE1=$?
wait $PID2
EXIT_CODE2=$?
exit $(node -p "$EXIT_CODE1 + $EXIT_CODE2")
'''
duplicateWorkspace(linuxWorkspace, linuxWorkspaceClones)
}
}
stage("checkout mac m1") {
agent {
node {
label "mac-m1"
customWorkspace macWorkspace
}
}
steps {
initWorkspace(changeset, params.SOURCE_BRANCH, params.TARGET_BRANCH, params.CLEAN_WORKSPACE)
prepareSwift()
}
}
2025-05-14 11:44:04 +02:00
}
}
stage("Lint and Style") {
parallel {
stage("find FIXMEs") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[0]
}
}
steps {
findFixmes()
}
}
stage("lint:check") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[1]
}
}
steps {
sh 'npm run lint:check'
}
}
stage("style:check") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[2]
}
}
steps {
sh 'npm run style:check'
}
}
stage("check rust formatting") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[3]
}
}
// this is so quick, we can run it every time.
steps {
sh "cargo fmt --check"
}
}
stage("lint swift") {
agent {
node {
label "mac-m1"
customWorkspace macWorkspace
}
}
when {
expression { extensionChanged(changeset, ".swift") }
}
steps {
lock("ios-build-m1") {
lintSwift()
}
}
}
}
}
stage("Testing and Building") {
parallel {
stage("tests") {
// the test cases write to the same files in the common workspace which produces race conditions like wasm/malloc function not available. as a workaround we run the critical tests sequentially. ideally we should never write to a re-used workspace.
stages {
stage("node tests") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[1]
}
}
steps {
sh 'cd test && node test'
}
}
stage("browser tests") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[2]
}
}
steps {
sh 'npm run test:app -- --no-run --browser --browser-cmd \'$(which chromium) --no-sandbox --enable-logging=stderr --headless=new --disable-gpu\''
}
}
stage("android tests") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspace
}
}
when {
expression { hasRelevantChangesIn(changeset, "app-android") }
}
steps {
testAndroid()
}
}
}
}
stage("packages test") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[0]
}
}
when {
expression { hasRelevantChangesIn(changeset, "packages") }
}
steps {
sh 'npm run --if-present test -ws'
}
}
stage("build web app") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[3]
}
}
steps {
sh 'node webapp --disable-minify'
}
}
stage("build web app calendar") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[4]
}
}
steps {
sh 'node webapp --disable-minify --app calendar'
}
}
stage("sdk tests") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[5]
}
}
when {
expression { hasRelevantChangesIn(changeset, "tuta-sdk") }
}
steps {
// once we spin local http server, we should also include more test by:
// --features test-with-local-http-server
sh "cargo test --package tuta-sdk"
}
}
stage("clippy lints") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspaceClones[6]
}
}
when {
expression { extensionChanged(changeset, ".rs", ".toml") }
}
steps {
// -Dwarnings changes warnings to errors so that the check fails
sh "cargo clippy --all --no-deps -- -Dwarnings"
}
}
stage("ios tests") {
// iOS tests write to the same temp folder which can cause both tests to fail, so they must run sequentially
stages {
stage("ios app") {
agent {
node {
label "mac-m1"
customWorkspace macWorkspace
}
}
when {
expression { hasRelevantChangesIn(changeset, "app-ios") }
}
steps {
lock("ios-build-m1") {
testFastlane("test_tuta_app")
}
}
}
stage("ios framework") {
agent {
node {
label "mac-m1"
customWorkspace macWorkspace
}
}
when {
expression { hasRelevantChangesIn(changeset, "app-ios") }
}
steps {
lock("ios-build-m1") {
testFastlane("test_tuta_shared_framework")
}
}
}
}
}
}
}
stage("finalize") {
agent {
node {
label 'linux'
customWorkspace linuxWorkspace
}
}
steps {
finalize(params.DRY_RUN)
}
}
}
}
void assertBranchWasSet(String name, String param) {
if (param.contains("dummy-do-not-use")) {
error("Parameter ${name} must be set to a valid branch name, not '${param}'.")
}
}
void initWorkspace(HashSet<String> changeset, String srcBranch, String targetBranch, boolean shouldClean) {
if (shouldClean) {
sh """
git submodule deinit --all --force
git clean -dfx
git fetch
git reset --hard origin/${srcBranch}
cargo clean
"""
}
sh "git status && git remote -v"
fetch(srcBranch, targetBranch)
merge(srcBranch, targetBranch)
submodules()
sh "pwd && git status && git remote -v && git submodule status"
getChangeset(changeset, targetBranch)
if (shouldRunNpmCi()) {
sh "npm ci"
}
}
void duplicateWorkspace(String source, ArrayList<String> targets) {
// don't trust the jenkins primitives to do this correctly...
sh """
pwd
cd ${source}/..
TARGETS="${targets.join(" ")}"
ls -halt
rm -f \$TARGETS
ls -halt
for target in \${TARGETS}; do
ln -s ${source} \$target
done
ls -halt
"""
}
void fetch(String srcBranch, String targetBranch) {
sh """
git switch --detach HEAD~1
git branch -D ${targetBranch} ${srcBranch} || true
git fetch
git switch ${srcBranch}
git switch ${targetBranch}
"""
}
void submodules() {
sh """
git submodule init
git submodule sync --recursive
git submodule update
"""
}
void prepareSwift() {
sh '''
mkdir -p ./build-calendar-app
mkdir -p ./build
cd app-ios
xcodegen --spec calendar-project.yml
xcodegen --spec mail-project.yml
cd ../tuta-sdk/ios
xcodegen
'''
}
void lintSwift() {
sh '''
cd app-ios
./lint.sh lint:check
./lint.sh style:check
'''
}
void merge(String srcBranch, String targetBranch) {
def sucessfulMerge = sh(returnStatus: true, script: "git merge --ff-only ${srcBranch}").toInteger()
if (sucessfulMerge != 0) {
error("Failed to merge branch ${srcBranch} into ${targetBranch}. Please rebase and try again.")
}
}
// must be called while checked out on targetBranch, after ff-merging srcBranch.
void getChangeset(HashSet<String> changeset, String targetBranch) {
def out = sh(returnStdout: true, script: "git diff --name-only ${targetBranch} origin/${targetBranch} --")
def lines = out.split('\n')
for (String line in lines) {
changeset.add(line.trim())
}
println "changeset:\n\n${changeset.join("\n")}"
}
// return whether any file in the given paths changed (recursively)
boolean hasRelevantChangesIn(HashSet<String> changeset, String... paths) {
boolean relevant = false
for (String path in paths) {
relevant = relevant || changeset.any { f -> f.startsWith(path) }
}
return relevant || extensionChanged(changeset, "groovy") || params.FORCE_RUN_ALL
}
// return whether any file with the given extensions changed
boolean extensionChanged(HashSet<String> changeset, String... exts) {
boolean changed = false
for (String ext in exts) {
changed = changed || changeset.any { f -> f.endsWith(ext) }
}
return changed || params.FORCE_RUN_ALL
}
boolean shouldRunNpmCi() {
def current = readFile(file: 'package.json')
def old
try {
old = readFile(file: 'cache/package.json')
} catch (e) {
print "package.json not found in cache, re-caching."
print e
return true
} finally {
writeFile(file: 'cache/package.json', text: current)
}
def json = new JsonSlurper()
def oldJson = json.parseText(old)
def currentJson = json.parseText(current)
def oldVersion = oldJson.version
def currentVersion = currentJson.version
def oldWithUpdatedVersion = old.replaceAll(oldVersion, currentVersion)
def expectedJSON = json.parseText(oldWithUpdatedVersion)
if (expectedJSON.equals(currentJson)) {
print "skipping npm ci as package.json is unchanged"
return false
} else {
return true
}
}
void findFixmes() {
sh '''
if grep "FIXME\\|[fF]ixme" -r src buildSrc test/tests packages/*/lib app-android/app/src app-ios/tutanota/Sources tuta-sdk; then
echo 'FIXMEs in src';
exit 1;
else
echo 'No FIXMEs in src';
fi
'''
}
void testAndroid() {
sh '''
# We have some tests for same day alarms that depends on this TimeZone
export TZ=Europe/Berlin
mkdir -p build
mkdir -p build-calendar-app
cd app-android
./gradlew lint -PtargetABI=arm64 --quiet
./gradlew test -PtargetABI=arm64
'''
}
void testFastlane(String task) {
sh """
export LC_ALL="en_US.UTF-8"
export LANG="en_US.UTF-8"
cd app-ios
fastlane ${task}
"""
}
/**
* push the resulting repo state to origin if it's not a dry run
*/
void finalize(boolean dryRun) {
if (dryRun) {
catchError(buildResult: 'UNSTABLE', stageResult: 'UNSTABLE') {
sh "exit 1"
}
echo """everything is fine, but I'm not pushing (DRY_RUN)!
use the following link to re-run and merge:
https://next.tutao.de/jenkins/job/${env.JOB_NAME}/parambuild?TARGET_BRANCH=${params.TARGET_BRANCH}&SOURCE_BRANCH=${params.SOURCE_BRANCH}&CLEAN_WORKSPACE=false&DRY_RUN=false
"""
} else {
sh "git push origin HEAD:${params.TARGET_BRANCH}"
}
}