build.v

11.8 KB · raw

1//! `koh.build`, the platform list, the build environment, and running a build.2//!3//! A release is a set of built files tied to a save, and this module is the4//! "make the files" half: `koh build` runs it and stops, and5//! `koh offer --builds` runs it and publishes what came out, so both refuse the6//! same things for the same reasons.78module build910#include <signal.h>1112import os13import time1415pub const config_name = 'koh.build'1617// Kepr 2's exact list. Peach Android and every build file already in the wild18// depend on these names, so a name outside it is refused.19pub const platforms = ['linux-x86_64', 'linux-aarch64', 'macos-aarch64', 'macos-x86_64',20	'windows-x86_64', 'android-arm64', 'android-x86']2122// Ten minutes is the spec's ceiling for one build: a hung toolchain must not23// hold a release forever.24const build_timeout = 10 * time.minute2526const 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'2728// Lang picks the cross-compile variables a build gets. Most profiles set only29// Koh's target names; the build command owns toolchain-specific translation.30pub enum Lang {31	unknown32	v33	zig34	rust35	go36	c37	cpp38	node39	java40	kotlin41	scala42	csharp43	swift44	python45	ruby46	php47	dart48	elixir49	lua50	r51	shell52	make53	gradle54}5556pub struct Config {57pub:58	command   string59	output    string60	platforms []string61	lang      Lang62}6364// One build file to look for, and what its presence means.65struct Probe {66	file string67	lang Lang68}6970// read_config parses `koh.build`. The three required keys are refused when they71// are missing. Keys Koh 3 does not use are ignored: `trigger`, which the spec72// drops, and Koh 2's `enabled`, `timeout_seconds` and `memory_limit_mb`, because73// `migrate` renames existing `kepr.build` files as they are.74pub fn read_config(root string) !Config {75	text := os.read_file(os.join_path(root, config_name)) or {76		return error('there is no ${config_name} in ${os.base(root)} — see "koh man build"')77	}78	mut command := ''79	mut output := ''80	mut named := ''81	mut lang := Lang.unknown82	for line in text.split_into_lines() {83		trimmed := line.trim_space()84		if trimmed == '' || trimmed.starts_with('#') {85			continue86		}87		at := trimmed.index('=') or { continue }88		key := trimmed[..at].trim_space()89		value := trimmed[at + 1..].trim_space()90		match key {91			'command' { command = value }92			'output' { output = value }93			'platforms' { named = value }94			'lang' {95				lang = lang_of(value) or {96					return error('${config_name} says lang = ${value}, which is not one of ${lang_names}')97				}98			}99			else {}100		}101	}102	if command == '' {103		return error('${config_name} has no command')104	}105	if output == '' {106		return error('${config_name} has no output')107	}108	platform_list := named.split(',').map(it.trim_space()).filter(it != '')109	if platform_list.len == 0 {110		return error('${config_name} names no platforms')111	}112	mut seen := map[string]bool{}113	for platform in platform_list {114		if platform !in platforms {115			return error('${config_name} names platform "${platform}", which is not one of ${platforms.join(', ')}')116		}117		if seen[platform] {118			return error('${config_name} names platform "${platform}" twice')119		}120		seen[platform] = true121	}122	return Config{123		command:   command124		output:    output125		platforms: platform_list126		lang:      if lang == .unknown { detect(root) } else { lang }127	}128}129130// lang_of is the `lang =` override. Naming a language Koh does not know is131// refused rather than quietly guessed at.132fn lang_of(name string) ?Lang {133	return match name.to_lower() {134		'v' { Lang.v }135		'zig' { Lang.zig }136		'rust' { Lang.rust }137		'go' { Lang.go }138		'c' { Lang.c }139		'cpp', 'c++' { Lang.cpp }140		'node', 'javascript', 'typescript' { Lang.node }141		'java' { Lang.java }142		'kotlin' { Lang.kotlin }143		'scala' { Lang.scala }144		'csharp', 'c#', 'cs' { Lang.csharp }145		'swift' { Lang.swift }146		'python' { Lang.python }147		'ruby' { Lang.ruby }148		'php' { Lang.php }149		'dart' { Lang.dart }150		'elixir' { Lang.elixir }151		'lua' { Lang.lua }152		'r' { Lang.r }153		'shell' { Lang.shell }154		'make' { Lang.make }155		'gradle' { Lang.gradle }156		else { none }157	}158}159160// detect is Koh 2's rule: a hand-written build.sh wins over everything, then161// the language's own build files, then C's build systems, then a bare Makefile.162fn detect(root string) Lang {163	for probe in [164		Probe{'build.sh', Lang.shell},165		Probe{'v.mod', Lang.v},166		Probe{'build.zig', Lang.zig},167		Probe{'Cargo.toml', Lang.rust},168		Probe{'go.mod', Lang.go},169		Probe{'Package.swift', Lang.swift},170		Probe{'pyproject.toml', Lang.python},171		Probe{'setup.py', Lang.python},172		Probe{'package.json', Lang.node},173		Probe{'pom.xml', Lang.java},174		Probe{'build.sbt', Lang.scala},175		Probe{'Gemfile', Lang.ruby},176		Probe{'composer.json', Lang.php},177		Probe{'pubspec.yaml', Lang.dart},178		Probe{'mix.exs', Lang.elixir},179		Probe{'DESCRIPTION', Lang.r},180		Probe{'app/build.gradle.kts', Lang.gradle},181		Probe{'app/build.gradle', Lang.gradle},182		Probe{'build.gradle.kts', Lang.gradle},183		Probe{'build.gradle', Lang.gradle},184		Probe{'CMakeLists.txt', Lang.c},185		Probe{'meson.build', Lang.c},186		Probe{'configure', Lang.c},187		Probe{'Makefile', Lang.make},188		Probe{'makefile', Lang.make},189	] {190		if os.exists(os.join_path(root, probe.file)) {191			return probe.lang192		}193	}194	return .unknown195}196197// this_machine is the platform name for the machine Koh runs on: what `koh198// build` builds for when no --platform is given.199pub fn this_machine() !string {200	machine := os.uname().machine201	arch := if machine in ['arm64', 'aarch64'] {202		'aarch64'203	} else if machine in ['x86_64', 'amd64'] {204		'x86_64'205	} else {206		return error('this machine is "${machine}" — koh builds for ${platforms.join(', ')}')207	}208	name := '${os.user_os()}-${arch}'209	if name !in platforms {210		return error('this machine is ${name} — koh builds for ${platforms.join(', ')}')211	}212	return name213}214215// The cross-compile settings of one platform, as Koh 2's buildEnvFor had them.216// Empty values mean "nothing extra": Android builds its own toolchain in its217// build script.218struct Cross {219	goos   string220	goarch string221	rust   string222	cc     string223}224225fn cross_of(platform string) Cross {226	return match platform {227		'linux-x86_64' {228			Cross{'linux', 'amd64', 'x86_64-unknown-linux-musl', 'x86_64-linux-musl-gcc'}229		}230		'linux-aarch64' {231			Cross{'linux', 'arm64', 'aarch64-unknown-linux-musl', 'aarch64-linux-musl-gcc'}232		}233		'macos-aarch64' { Cross{'darwin', 'arm64', 'aarch64-apple-darwin', ''} }234		'macos-x86_64' { Cross{'darwin', 'amd64', 'x86_64-apple-darwin', ''} }235		'windows-x86_64' { Cross{'windows', 'amd64', 'x86_64-pc-windows-gnu', ''} }236		else { Cross{} }237	}238}239240// env_for is Koh 2's `lang.buildEnvFor`. KOH_TARGET is the new name;241// KEPR_TARGET is kept because every build script in the wild reads it.242pub fn env_for(lang Lang, platform string) map[string]string {243	if lang == .zig {244		// A Zig project cross-compiles with the triple, which is what Koh 2245		// passed as KEPR_TARGET.246		triple := zig_triple(platform)247		return {248			'KOH_TARGET':  triple249			'KEPR_TARGET': triple250		}251	}252	mut env := {253		'KOH_TARGET':  platform254		'KEPR_TARGET': platform255	}256	cross := cross_of(platform)257	if cross.goos == '' {258		return env259	}260	if lang == .go {261		env['GOOS'] = cross.goos262		env['GOARCH'] = cross.goarch263	} else if lang == .rust {264		env['CARGO_BUILD_TARGET'] = cross.rust265	} else if lang == .c && cross.cc != '' {266		env['CC'] = cross.cc267	}268	return env269}270271// zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no272// triple for keeps the plain name.273fn zig_triple(platform string) string {274	return match platform {275		'linux-x86_64' { 'x86_64-linux-musl' }276		'linux-aarch64' { 'aarch64-linux-musl' }277		'macos-aarch64' { 'aarch64-macos' }278		'macos-x86_64' { 'x86_64-macos' }279		'windows-x86_64' { 'x86_64-windows' }280		else { platform }281	}282}283284// output_path is `output` with the version and platform filled in, so each285// platform's artifact can land at its own path.286pub fn output_path(config Config, platform string, version string) string {287	return config.output.replace('{version}', version).replace('{platform}', platform)288}289290pub struct Built {291pub:292	platform string293	path     string294	size     i64295}296297// build_one runs one platform's build and returns its artifact. A build that298// failed, timed out, or produced nothing is refused: nothing is published.299pub fn build_one(root string, config Config, platform string, version string) !Built {300	path := output_path(config, platform, version)301	run := run_build(root, config.command, env_for(config.lang, platform))!302	if run.timed_out {303		return error('the ${platform} build was still running after ten minutes and was stopped')304	}305	if run.code != 0 {306		return error('the ${platform} build failed:\n${tail(run.output)}')307	}308	full := os.join_path(root, path)309	if !os.exists(full) {310		return error('the ${platform} build did not create ${path}')311	}312	if os.is_dir(full) {313		return error('${path} is a folder — a release file is one file')314	}315	size := os.file_size(full)316	if size == 0 {317		return error('the ${platform} build produced an empty ${path}')318	}319	return Built{320		platform: platform321		path:     path322		size:     size323	}324}325326// Run is one build's outcome: what it printed, its exit code, and whether the327// ten minutes ran out.328struct Run {329	code      int330	timed_out bool331	output    string332}333334// run_build runs the command with the build environment from the repo root.335// Output goes to files rather than pipes: a chatty build must not fill a pipe336// and hang waiting for a reader that is waiting for it to finish.337fn run_build(root string, command string, env map[string]string) !Run {338	logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}')339	os.mkdir_all(logs, os.MkdirParams{})!340	defer {341		os.rmdir_all(logs) or {}342	}343	stdout_path := os.join_path(logs, 'stdout')344	stderr_path := os.join_path(logs, 'stderr')345	mut environment := os.environ()346	for key, value in env {347		environment[key] = value348	}349	mut child := os.new_process('sh')350	// The braces keep a command that redirects its own output — a build script351	// writing its artifact with `>` — from having that file taken over by the352	// log redirect, which the shell would otherwise apply last.353	child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}'])354	child.set_work_folder(root)355	child.set_environment(environment)356	// Its own process group, so a build that hangs takes its children with it.357	child.use_pgroup = true358	child.run()359	deadline := time.now().add(build_timeout)360	for child.is_alive() && time.now() < deadline {361		time.sleep(200 * time.millisecond)362	}363	timed_out := child.is_alive()364	// Whatever the command left running goes with it: a background child could365	// still be writing the artifact after it was hashed, and a hung build must366	// not hold a release. The group is signalled by id — the command leads its367	// own group — because `signal_pgkill` returns early once the process has368	// exited. Nothing is signalled before the command started, when the id is 0369	// and the group would be koh's own.370	if child.pid > 0 {371		C.kill(-child.pid, C.SIGKILL)372	}373	if timed_out {374		// Kill first: waiting on a hung build waits forever.375		child.wait()376	}377	stdout := os.read_file(stdout_path) or { '' }378	stderr := os.read_file(stderr_path) or { '' }379	return Run{380		code:      child.code381		timed_out: timed_out382		output:    '${stdout}${stderr}'383	}384}385386// sh_quote keeps a path with a space in it one argument, and cannot be broken387// out of by a quote inside the path.388fn sh_quote(text string) string {389	return "'" + text.replace("'", "'\\''") + "'"390}391392// tail keeps the end of a build log: what went wrong is at the bottom, and a393// compiler's whole output is too much to print.394fn tail(text string) string {395	lines := text.trim_right('\n').split_into_lines()396	if lines.len <= 20 {397		return lines.join('\n')398	}399	return lines[lines.len - 20..].join('\n')400}