# -*- python -*- # kukuri BuildBot master - runs on 192.168.3.110 (budget: 512MB RAM / 3GB disk) # Required environment (via /etc/kukuri-buildbot.env or exported): # WORKER_PASS = (must match worker side) # Git authentication: uses .git-credentials (credential.helper store) to avoid # leaking password into sourcestamps/build URLs. import os from buildbot.plugins import * def _load_env_file(path='/etc/kukuri-buildbot.env'): data = {} try: for line in open(path): line = line.strip() if not line or line.startswith('#') or '=' not in line: continue k, v = line.split('=', 1) data.setdefault(k, v) except FileNotFoundError: pass return data _ENVFILE = _load_env_file() WPASS = os.environ.get('WORKER_PASS') or _ENVFILE.get('WORKER_PASS') if not WPASS: raise SystemExit('Set WORKER_PASS (env or /etc/kukuri-buildbot.env)') REPO = 'http://192.168.3.110/git/kukuri.git' ENV = {'GRADLE_USER_HOME': '/var/jenkins_home/.gradle'} c = BuildmasterConfig = {} c['buildbotNetUsageData'] = None c['title'] = 'kukuri' c['titleURL'] = 'http://192.168.3.110/cgit/cgit.cgi/kukuri.git/' c['buildbotURL'] = 'http://192.168.3.110:8010/' c['workers'] = [worker.Worker('lan-builder', WPASS)] c['protocols'] = {'pb': {'port': 9989}} c['change_source'] = [ changes.GitPoller( REPO, branch='main', pollInterval=30, pollAtLaunch=True, ) ] factory = util.BuildFactory() factory.addStep(steps.ShellCommand( name='setup-git-auth', command=['bash', '-c', ''' mkdir -p "$HOME" echo "http://ketsuban:$(cat /run/secrets/git_pass)@192.168.3.110" > "$HOME/.git-credentials" chmod 600 "$HOME/.git-credentials" git config --global credential.helper store '''], env=ENV, haltOnFailure=True)) factory.addStep(steps.Git(repourl=REPO, mode='full', method='clean')) factory.addStep(steps.ShellCommand( name='detekt-gate', command=['./gradlew', ':modules:orgflow-domain:detekt'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( # --max-workers=2: at the default worker count this step died with # "Gradle build daemon disappeared unexpectedly" on builds 76 and 77, and # ./gradlew --status showed every daemon as "STOPPED (by user or operating # system)". Host RAM is 11.9 GB. With the worker count capped, a clean # assemble measured 5m33s, so it was parallelism exhausting memory rather # than the task graph. name='assemble', command=['./gradlew', '--max-workers=1', 'assemble'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='jvm-tests', command=['./gradlew', 'jvmTest'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='android-tests', command=['./gradlew', 'testDebugUnitTest', 'testReleaseUnitTest'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='wasm-tests', command=['./gradlew', 'wasmJsNodeTest'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='native-tests', command=['./gradlew', 'linuxX64Test'], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='clean-wasm-output', # The Wasm artifact was silently stale: served bundle still had the # 232dp nav rail and the 132dp disc (both pre-change values, measured from # pixels) while every build reported SUCCESS. Cause: the Kotlin/Wasm # compile stayed UP-TO-DATE against build/ outputs left in the tree by a # previous local build, so CI repackaged the old wasm every time. # # Removing the build cache is NOT enough on its own -- Gradle's up-to-date # check also keys on the compile output being present. Delete the actual # output dirs so the compile is forced. This is a build directory, never # source. command=['bash', '-c', ''' for d in \ modules/orgflow-ui/build/classes/kotlin/wasmJs \ modules/orgflow-ui/build/dist/wasmJs \ modules/orgflow-ui/build/kotlin-webpack \ modules/orgflow-ui/build/compileSync \ modules/orgflow-domain/build/distributions do rm -rf "$d" && echo "cleaned $d" done rm -rf "${GRADLE_USER_HOME:-$HOME/.gradle}/caches/build-cache-1" && echo "cleaned build cache" # The configuration cache also has to go. A reused entry made the detekt gate # fail with "ClassNotFoundException: io.gitlab.arturbosch.detekt.cli.Main" # followed by "Reusing configuration cache", because the cached task graph held # a classpath that no longer resolved. The same cache also pins the Wasm compile # as UP-TO-DATE, which is what let a stale bundle ship in the first place. rm -rf .gradle/configuration-cache && echo "cleaned configuration cache" '''], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='bundles', # Task names must be exact. 'bundleWasm' does not exist: Gradle's camelCase # abbreviation matching silently resolves it to 'bundleWasmNode', so the # build still succeeds while building the node bundle a second time and # the intent (the web bundle) is left to 'bundleWeb' alone. # Verified with `./gradlew bundleWasm --dry-run`, which reported # ":modules:orgflow-domain:bundleWasmNode SKIPPED / BUILD SUCCESSFUL". # # bundleWeb is NOT in this list. wasm-opt with -Oz needs 8m45s for the # Wasm module, and running six bundle tasks in one invocation made this step # exceed the worker's step timeout, so build 69 died here with no wasm-web # zip produced. It gets its own step below with a longer timeout. command=['./gradlew', '--max-workers=1', 'bundleDesktop', 'bundleWasmNode', 'bundleNative', 'bundleApk'], env=ENV, haltOnFailure=True)) # The web bundle on its own, because it is by far the slowest single task here: # wasm-opt -O3/-Oz plus webpack measured at 8m45s end to end. Kept separate from # the step above so its timeout can be generous without inflating every step. factory.addStep(steps.ShellCommand( name='bundle-web', # NOT --no-daemon. With --no-daemon the whole build ran in the client JVM and # that one died with "Gradle build daemon disappeared unexpectedly" (builds 76 # and 77), because the tree compiles Kotlin/Wasm for several targets and a # single-use daemon has no reuse. The daemon pool is now capped in # gradle.properties (registry.maximum-size=1, in-process Kotlin) so a normal # daemon is the correct choice here. command=['./gradlew', '--max-workers=1', 'bundleWeb'], env=ENV, haltOnFailure=True, timeout=3600)) # Wait for the GitHub Actions "ios" workflow to finish for this exact commit, # then pull its "kukuri-ios" artifact so deploy ships it alongside the rest. factory.addStep(steps.ShellCommand( name='wait-github-ios', command=['bash', '-c', ''' set -e REPO="ketsuban152/kukuri" TOKEN="Authorization: Bearer $(cat /run/secrets/github_token)" SHA=$(git rev-parse HEAD) DEADLINE=$(( $(date +%s) + 2400 )) while [ "$(date +%s)" -lt "$DEADLINE" ]; do RES=$(curl -sf -H "$TOKEN" "https://api.github.com/repos/$REPO/actions/runs?head_sha=$SHA" | python3 -c ' import json,sys rs=[r for r in json.load(sys.stdin).get("workflow_runs",[]) if r.get("name")=="ios"] if rs: print(rs[0]["id"], rs[0]["status"], rs[0].get("conclusion") or "") ' || true) if [ -n "$RES" ]; then set -- $RES if [ "$2" = "completed" ]; then if [ "$3" = "success" ]; then echo "ios run $1: success"; exit 0; fi echo "ios run $1 finished with conclusion: $3"; exit 1 fi echo "ios run $1: $2..." else echo "no ios run for $SHA yet..." fi sleep 20 done echo "timeout waiting for the ios run"; exit 1 '''], env=ENV, haltOnFailure=True, timeout=2700)) factory.addStep(steps.ShellCommand( name='fetch-github-ios', command=['bash', '-c', ''' set -e REPO="ketsuban152/kukuri" TOKEN="Authorization: Bearer $(cat /run/secrets/github_token)" SHA=$(git rev-parse HEAD) RUN=$(curl -sf -H "$TOKEN" "https://api.github.com/repos/$REPO/actions/runs?head_sha=$SHA" | python3 -c ' import json,sys rs=[r for r in json.load(sys.stdin).get("workflow_runs",[]) if r.get("name")=="ios" and r.get("conclusion")=="success"] print(rs[0]["id"]) ') URL=$(curl -sf -H "$TOKEN" "https://api.github.com/repos/$REPO/actions/runs/$RUN/artifacts" | python3 -c ' import json,sys a=[x for x in json.load(sys.stdin)["artifacts"] if x["name"]=="kukuri-ios"] print(a[0]["archive_download_url"]) ') curl -sfL -H "$TOKEN" -o /tmp/kukuri-ios.zip "$URL" DIST=modules/orgflow-domain/build/distributions # Derive the version with parameter expansion, NOT sed. # A sed backreference written as \\1 inside this Python triple-quoted string is # consumed by Python as an octal escape and reaches the shell as chr(1), which # produced the filename "kukuri-ios-\\x01.zip" -- which then failed FTP upload # with no useful message. Z=$(ls "$DIST"/kukuri-wasm-web-*.zip | head -1) [ -n "$Z" ] || { echo "no kukuri-wasm-web zip to read the version from"; exit 1; } V=${Z##*-}; V=${V%.zip} case "$V" in [0-9]* ) ;; * ) echo "could not parse a version out of $Z (got '$V')"; exit 1 ;; esac cp /tmp/kukuri-ios.zip "$DIST/kukuri-ios-$V.zip" echo "staged: $DIST/kukuri-ios-$V.zip" '''], env=ENV, haltOnFailure=True)) factory.addStep(steps.ShellCommand( name='deploy-to-lighttpd', command=['bash', '-c', ''' set -e PW=$(cat /run/secrets/git_pass) echo "machine 192.168.3.110 login ketsuban password $PW" > /tmp/curl-netrc chmod 600 /tmp/curl-netrc BASE="ftp://192.168.3.110/var/www/html/artifact/kukuri/latest" mkdir -p webout unzip -o -q modules/orgflow-domain/build/distributions/kukuri-wasm-web-*.zip -d webout # Assert every expected artifact exists BEFORE uploading anything. # A Zip task whose `from(...)` directory is missing archives nothing and does # NOT fail, so a botched bundles step yields a short upload list and the FTP # loop dies partway with a bare curl exit code 3. Checking first turns that # into one clear message naming what is missing. DIST=modules/orgflow-domain/build/distributions missing=0 for want in kukuri-desktop-0.1.0.zip kukuri-wasm-node-0.1.0.zip \ kukuri-native-linuxx64-0.1.0.zip kukuri-wasm-web-0.1.0.zip \ kukuri-android-0.1.0.apk; do if [ -s "$DIST/$want" ]; then echo " present: $want ($(wc -c < "$DIST/$want") bytes)" else echo " MISSING: $want" missing=1 fi done if [ "$missing" -ne 0 ]; then echo "ABORT: expected artifacts are absent -- the bundles step did not produce them" ls -la "$DIST" || true rm -f /tmp/curl-netrc exit 1 fi for f in $DIST/*.zip; do curl -sf --netrc-file /tmp/curl-netrc --ftp-create-dirs -T "$f" "$BASE/$(basename "$f")" || { echo "FTP FAILED: $(basename "$f")"; exit 1; } done for f in $DIST/*.apk; do curl -sf --netrc-file /tmp/curl-netrc --ftp-create-dirs -T "$f" "$BASE/$(basename "$f")" || { echo "FTP FAILED: $(basename "$f")"; exit 1; } done for f in webout/*; do curl -sf --netrc-file /tmp/curl-netrc --ftp-create-dirs -T "$f" "$BASE/web/$(basename "$f")" || { echo "FTP FAILED: web/$(basename "$f")"; exit 1; } done rm -f /tmp/curl-netrc echo "deployed to http://192.168.3.110/artifact/kukuri/latest/" '''], env=ENV, haltOnFailure=True)) c['schedulers'] = [ schedulers.SingleBranchScheduler( name='kukuri-main', change_filter=util.ChangeFilter(branch='main'), treeStableTimer=5, builderNames=['kukuri-build'], ), schedulers.ForceScheduler( name='force', builderNames=['kukuri-build'], ), ] c['builders'] = [ util.BuilderConfig( name='kukuri-build', workernames=['lan-builder'], factory=factory, ) ] c['www'] = dict(port=8010, plugins=dict(waterfall_view=True, console_view=True, grid_view=True)) c['db'] = {'db_url': 'sqlite:///state.sqlite3'}