//! `koh.build`, the platform list, the build environment, and running a build. //! //! A release is a set of built files tied to a save, and this module is the //! "make the files" half: `koh build` runs it and stops, and //! `koh offer --builds` runs it and publishes what came out, so both refuse the //! same things for the same reasons. module build #include import os import time pub const config_name = 'koh.build' // Kepr 2's exact list. Peach Android and every build file already in the wild // depend on these names, so a name outside it is refused. pub const platforms = ['linux-x86_64', 'linux-aarch64', 'macos-aarch64', 'macos-x86_64', 'windows-x86_64', 'android-arm64', 'android-x86'] // Ten minutes is the spec's ceiling for one build: a hung toolchain must not // hold a release forever. const build_timeout = 10 * time.minute const lang_names = 'v, zig, rust, go, c, cpp, c++, node, javascript, typescript, java, kotlin, scala, csharp, c#, cs, swift, python, ruby, php, dart, elixir, lua, r, shell, make, gradle' // Lang picks the cross-compile variables a build gets. Most profiles set only // Koh's target names; the build command owns toolchain-specific translation. pub enum Lang { unknown v zig rust go c cpp node java kotlin scala csharp swift python ruby php dart elixir lua r shell make gradle } pub struct Config { pub: command string output string platforms []string lang Lang } // One build file to look for, and what its presence means. struct Probe { file string lang Lang } // read_config parses `koh.build`. The three required keys are refused when they // are missing. Keys Koh 3 does not use are ignored: `trigger`, which the spec // drops, and Koh 2's `enabled`, `timeout_seconds` and `memory_limit_mb`, because // `migrate` renames existing `kepr.build` files as they are. pub fn read_config(root string) !Config { text := os.read_file(os.join_path(root, config_name)) or { return error('there is no ${config_name} in ${os.base(root)} — see "koh man build"') } mut command := '' mut output := '' mut named := '' mut lang := Lang.unknown for line in text.split_into_lines() { trimmed := line.trim_space() if trimmed == '' || trimmed.starts_with('#') { continue } at := trimmed.index('=') or { continue } key := trimmed[..at].trim_space() value := trimmed[at + 1..].trim_space() match key { 'command' { command = value } 'output' { output = value } 'platforms' { named = value } 'lang' { lang = lang_of(value) or { return error('${config_name} says lang = ${value}, which is not one of ${lang_names}') } } else {} } } if command == '' { return error('${config_name} has no command') } if output == '' { return error('${config_name} has no output') } platform_list := named.split(',').map(it.trim_space()).filter(it != '') if platform_list.len == 0 { return error('${config_name} names no platforms') } mut seen := map[string]bool{} for platform in platform_list { if platform !in platforms { return error('${config_name} names platform "${platform}", which is not one of ${platforms.join(', ')}') } if seen[platform] { return error('${config_name} names platform "${platform}" twice') } seen[platform] = true } return Config{ command: command output: output platforms: platform_list lang: if lang == .unknown { detect(root) } else { lang } } } // lang_of is the `lang =` override. Naming a language Koh does not know is // refused rather than quietly guessed at. fn lang_of(name string) ?Lang { return match name.to_lower() { 'v' { Lang.v } 'zig' { Lang.zig } 'rust' { Lang.rust } 'go' { Lang.go } 'c' { Lang.c } 'cpp', 'c++' { Lang.cpp } 'node', 'javascript', 'typescript' { Lang.node } 'java' { Lang.java } 'kotlin' { Lang.kotlin } 'scala' { Lang.scala } 'csharp', 'c#', 'cs' { Lang.csharp } 'swift' { Lang.swift } 'python' { Lang.python } 'ruby' { Lang.ruby } 'php' { Lang.php } 'dart' { Lang.dart } 'elixir' { Lang.elixir } 'lua' { Lang.lua } 'r' { Lang.r } 'shell' { Lang.shell } 'make' { Lang.make } 'gradle' { Lang.gradle } else { none } } } // detect is Koh 2's rule: a hand-written build.sh wins over everything, then // the language's own build files, then C's build systems, then a bare Makefile. fn detect(root string) Lang { for probe in [ Probe{'build.sh', Lang.shell}, Probe{'v.mod', Lang.v}, Probe{'build.zig', Lang.zig}, Probe{'Cargo.toml', Lang.rust}, Probe{'go.mod', Lang.go}, Probe{'Package.swift', Lang.swift}, Probe{'pyproject.toml', Lang.python}, Probe{'setup.py', Lang.python}, Probe{'package.json', Lang.node}, Probe{'pom.xml', Lang.java}, Probe{'build.sbt', Lang.scala}, Probe{'Gemfile', Lang.ruby}, Probe{'composer.json', Lang.php}, Probe{'pubspec.yaml', Lang.dart}, Probe{'mix.exs', Lang.elixir}, Probe{'DESCRIPTION', Lang.r}, Probe{'app/build.gradle.kts', Lang.gradle}, Probe{'app/build.gradle', Lang.gradle}, Probe{'build.gradle.kts', Lang.gradle}, Probe{'build.gradle', Lang.gradle}, Probe{'CMakeLists.txt', Lang.c}, Probe{'meson.build', Lang.c}, Probe{'configure', Lang.c}, Probe{'Makefile', Lang.make}, Probe{'makefile', Lang.make}, ] { if os.exists(os.join_path(root, probe.file)) { return probe.lang } } return .unknown } // this_machine is the platform name for the machine Koh runs on: what `koh // build` builds for when no --platform is given. pub fn this_machine() !string { machine := os.uname().machine arch := if machine in ['arm64', 'aarch64'] { 'aarch64' } else if machine in ['x86_64', 'amd64'] { 'x86_64' } else { return error('this machine is "${machine}" — koh builds for ${platforms.join(', ')}') } name := '${os.user_os()}-${arch}' if name !in platforms { return error('this machine is ${name} — koh builds for ${platforms.join(', ')}') } return name } // The cross-compile settings of one platform, as Koh 2's buildEnvFor had them. // Empty values mean "nothing extra": Android builds its own toolchain in its // build script. struct Cross { goos string goarch string rust string cc string } fn cross_of(platform string) Cross { return match platform { 'linux-x86_64' { Cross{'linux', 'amd64', 'x86_64-unknown-linux-musl', 'x86_64-linux-musl-gcc'} } 'linux-aarch64' { Cross{'linux', 'arm64', 'aarch64-unknown-linux-musl', 'aarch64-linux-musl-gcc'} } 'macos-aarch64' { Cross{'darwin', 'arm64', 'aarch64-apple-darwin', ''} } 'macos-x86_64' { Cross{'darwin', 'amd64', 'x86_64-apple-darwin', ''} } 'windows-x86_64' { Cross{'windows', 'amd64', 'x86_64-pc-windows-gnu', ''} } else { Cross{} } } } // env_for is Koh 2's `lang.buildEnvFor`. KOH_TARGET is the new name; // KEPR_TARGET is kept because every build script in the wild reads it. pub fn env_for(lang Lang, platform string) map[string]string { if lang == .zig { // A Zig project cross-compiles with the triple, which is what Koh 2 // passed as KEPR_TARGET. triple := zig_triple(platform) return { 'KOH_TARGET': triple 'KEPR_TARGET': triple } } mut env := { 'KOH_TARGET': platform 'KEPR_TARGET': platform } cross := cross_of(platform) if cross.goos == '' { return env } if lang == .go { env['GOOS'] = cross.goos env['GOARCH'] = cross.goarch } else if lang == .rust { env['CARGO_BUILD_TARGET'] = cross.rust } else if lang == .c && cross.cc != '' { env['CC'] = cross.cc } return env } // zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no // triple for keeps the plain name. fn zig_triple(platform string) string { return match platform { 'linux-x86_64' { 'x86_64-linux-musl' } 'linux-aarch64' { 'aarch64-linux-musl' } 'macos-aarch64' { 'aarch64-macos' } 'macos-x86_64' { 'x86_64-macos' } 'windows-x86_64' { 'x86_64-windows' } else { platform } } } // output_path is `output` with the version and platform filled in, so each // platform's artifact can land at its own path. pub fn output_path(config Config, platform string, version string) string { return config.output.replace('{version}', version).replace('{platform}', platform) } pub struct Built { pub: platform string path string size i64 } // build_one runs one platform's build and returns its artifact. A build that // failed, timed out, or produced nothing is refused: nothing is published. pub fn build_one(root string, config Config, platform string, version string) !Built { path := output_path(config, platform, version) run := run_build(root, config.command, env_for(config.lang, platform))! if run.timed_out { return error('the ${platform} build was still running after ten minutes and was stopped') } if run.code != 0 { return error('the ${platform} build failed:\n${tail(run.output)}') } full := os.join_path(root, path) if !os.exists(full) { return error('the ${platform} build did not create ${path}') } if os.is_dir(full) { return error('${path} is a folder — a release file is one file') } size := os.file_size(full) if size == 0 { return error('the ${platform} build produced an empty ${path}') } return Built{ platform: platform path: path size: size } } // Run is one build's outcome: what it printed, its exit code, and whether the // ten minutes ran out. struct Run { code int timed_out bool output string } // run_build runs the command with the build environment from the repo root. // Output goes to files rather than pipes: a chatty build must not fill a pipe // and hang waiting for a reader that is waiting for it to finish. fn run_build(root string, command string, env map[string]string) !Run { logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}') os.mkdir_all(logs, os.MkdirParams{})! defer { os.rmdir_all(logs) or {} } stdout_path := os.join_path(logs, 'stdout') stderr_path := os.join_path(logs, 'stderr') mut environment := os.environ() for key, value in env { environment[key] = value } mut child := os.new_process('sh') // The braces keep a command that redirects its own output — a build script // writing its artifact with `>` — from having that file taken over by the // log redirect, which the shell would otherwise apply last. child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}']) child.set_work_folder(root) child.set_environment(environment) // Its own process group, so a build that hangs takes its children with it. child.use_pgroup = true child.run() deadline := time.now().add(build_timeout) for child.is_alive() && time.now() < deadline { time.sleep(200 * time.millisecond) } timed_out := child.is_alive() // Whatever the command left running goes with it: a background child could // still be writing the artifact after it was hashed, and a hung build must // not hold a release. The group is signalled by id — the command leads its // own group — because `signal_pgkill` returns early once the process has // exited. Nothing is signalled before the command started, when the id is 0 // and the group would be koh's own. if child.pid > 0 { C.kill(-child.pid, C.SIGKILL) } if timed_out { // Kill first: waiting on a hung build waits forever. child.wait() } stdout := os.read_file(stdout_path) or { '' } stderr := os.read_file(stderr_path) or { '' } return Run{ code: child.code timed_out: timed_out output: '${stdout}${stderr}' } } // sh_quote keeps a path with a space in it one argument, and cannot be broken // out of by a quote inside the path. fn sh_quote(text string) string { return "'" + text.replace("'", "'\\''") + "'" } // tail keeps the end of a build log: what went wrong is at the bottom, and a // compiler's whole output is too much to print. fn tail(text string) string { lines := text.trim_right('\n').split_into_lines() if lines.len <= 20 { return lines.join('\n') } return lines[lines.len - 20..].join('\n') }