build.v

10.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 = 'zig, rust, go, c, node, swift, python, shell, make, gradle'2728// Lang picks the cross-compile variables a build gets. `gradle` sets none.29pub enum Lang {30	unknown31	zig32	rust33	go34	c35	node36	swift37	python38	shell39	make40	gradle41}4243pub struct Config {44pub:45	command   string46	output    string47	platforms []string48	lang      Lang49}5051// One build file to look for, and what its presence means.52struct Probe {53	file string54	lang Lang55}5657// read_config parses `koh.build`. The three required keys are refused when they58// are missing. Keys Koh 3 does not use are ignored: `trigger`, which the spec59// drops, and Koh 2's `enabled`, `timeout_seconds` and `memory_limit_mb`, because60// `migrate` renames existing `kepr.build` files as they are.61pub fn read_config(root string) !Config {62	text := os.read_file(os.join_path(root, config_name)) or {63		return error('there is no ${config_name} in ${os.base(root)} — see "koh man build"')64	}65	mut command := ''66	mut output := ''67	mut named := ''68	mut lang := Lang.unknown69	for line in text.split_into_lines() {70		trimmed := line.trim_space()71		if trimmed == '' || trimmed.starts_with('#') {72			continue73		}74		at := trimmed.index('=') or { continue }75		key := trimmed[..at].trim_space()76		value := trimmed[at + 1..].trim_space()77		match key {78			'command' { command = value }79			'output' { output = value }80			'platforms' { named = value }81			'lang' {82				lang = lang_of(value) or {83					return error('${config_name} says lang = ${value}, which is not one of ${lang_names}')84				}85			}86			else {}87		}88	}89	if command == '' {90		return error('${config_name} has no command')91	}92	if output == '' {93		return error('${config_name} has no output')94	}95	platform_list := named.split(',').map(it.trim_space()).filter(it != '')96	if platform_list.len == 0 {97		return error('${config_name} names no platforms')98	}99	for platform in platform_list {100		if platform !in platforms {101			return error('${config_name} names platform "${platform}", which is not one of ${platforms.join(', ')}')102		}103	}104	return Config{105		command:   command106		output:    output107		platforms: platform_list108		lang:      if lang == .unknown { detect(root) } else { lang }109	}110}111112// lang_of is the `lang =` override. Naming a language Koh does not know is113// refused rather than quietly guessed at.114fn lang_of(name string) ?Lang {115	return match name.to_lower() {116		'zig' { Lang.zig }117		'rust' { Lang.rust }118		'go' { Lang.go }119		'c' { Lang.c }120		'node' { Lang.node }121		'swift' { Lang.swift }122		'python' { Lang.python }123		'shell' { Lang.shell }124		'make' { Lang.make }125		'gradle' { Lang.gradle }126		else { none }127	}128}129130// detect is Koh 2's rule: a hand-written build.sh wins over everything, then131// the language's own build files, then C's build systems, then a bare Makefile.132fn detect(root string) Lang {133	for probe in [134		Probe{'build.sh', Lang.shell},135		Probe{'build.zig', Lang.zig},136		Probe{'Cargo.toml', Lang.rust},137		Probe{'go.mod', Lang.go},138		Probe{'Package.swift', Lang.swift},139		Probe{'pyproject.toml', Lang.python},140		Probe{'setup.py', Lang.python},141		Probe{'package.json', Lang.node},142		Probe{'app/build.gradle.kts', Lang.gradle},143		Probe{'app/build.gradle', Lang.gradle},144		Probe{'build.gradle.kts', Lang.gradle},145		Probe{'build.gradle', Lang.gradle},146		Probe{'CMakeLists.txt', Lang.c},147		Probe{'meson.build', Lang.c},148		Probe{'configure', Lang.c},149		Probe{'Makefile', Lang.make},150		Probe{'makefile', Lang.make},151	] {152		if os.exists(os.join_path(root, probe.file)) {153			return probe.lang154		}155	}156	return .unknown157}158159// this_machine is the platform name for the machine Koh runs on: what `koh160// build` builds for when no --platform is given.161pub fn this_machine() !string {162	machine := os.uname().machine163	arch := if machine in ['arm64', 'aarch64'] {164		'aarch64'165	} else if machine in ['x86_64', 'amd64'] {166		'x86_64'167	} else {168		return error('this machine is "${machine}" — koh builds for ${platforms.join(', ')}')169	}170	name := '${os.user_os()}-${arch}'171	if name !in platforms {172		return error('this machine is ${name} — koh builds for ${platforms.join(', ')}')173	}174	return name175}176177// The cross-compile settings of one platform, as Koh 2's buildEnvFor had them.178// Empty values mean "nothing extra": Android builds its own toolchain in its179// build script.180struct Cross {181	goos   string182	goarch string183	rust   string184	cc     string185}186187fn cross_of(platform string) Cross {188	return match platform {189		'linux-x86_64' {190			Cross{'linux', 'amd64', 'x86_64-unknown-linux-musl', 'x86_64-linux-musl-gcc'}191		}192		'linux-aarch64' {193			Cross{'linux', 'arm64', 'aarch64-unknown-linux-musl', 'aarch64-linux-musl-gcc'}194		}195		'macos-aarch64' { Cross{'darwin', 'arm64', 'aarch64-apple-darwin', ''} }196		'macos-x86_64' { Cross{'darwin', 'amd64', 'x86_64-apple-darwin', ''} }197		'windows-x86_64' { Cross{'windows', 'amd64', 'x86_64-pc-windows-gnu', ''} }198		else { Cross{} }199	}200}201202// env_for is Koh 2's `lang.buildEnvFor`. KOH_TARGET is the new name;203// KEPR_TARGET is kept because every build script in the wild reads it.204pub fn env_for(lang Lang, platform string) map[string]string {205	if lang == .zig {206		// A Zig project cross-compiles with the triple, which is what Koh 2207		// passed as KEPR_TARGET.208		triple := zig_triple(platform)209		return {210			'KOH_TARGET':  triple211			'KEPR_TARGET': triple212		}213	}214	mut env := {215		'KOH_TARGET':  platform216		'KEPR_TARGET': platform217	}218	cross := cross_of(platform)219	if cross.goos == '' {220		return env221	}222	if lang == .go {223		env['GOOS'] = cross.goos224		env['GOARCH'] = cross.goarch225	} else if lang == .rust {226		env['CARGO_BUILD_TARGET'] = cross.rust227	} else if lang == .c && cross.cc != '' {228		env['CC'] = cross.cc229	}230	return env231}232233// zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no234// triple for keeps the plain name.235fn zig_triple(platform string) string {236	return match platform {237		'linux-x86_64' { 'x86_64-linux-musl' }238		'linux-aarch64' { 'aarch64-linux-musl' }239		'macos-aarch64' { 'aarch64-macos' }240		'macos-x86_64' { 'x86_64-macos' }241		'windows-x86_64' { 'x86_64-windows' }242		else { platform }243	}244}245246// output_path is `output` with the version and platform filled in, so each247// platform's artifact can land at its own path.248pub fn output_path(config Config, platform string, version string) string {249	return config.output.replace('{version}', version).replace('{platform}', platform)250}251252pub struct Built {253pub:254	platform string255	path     string256	size     i64257}258259// build_one runs one platform's build and returns its artifact. A build that260// failed, timed out, or produced nothing is refused: nothing is published.261pub fn build_one(root string, config Config, platform string, version string) !Built {262	path := output_path(config, platform, version)263	run := run_build(root, config.command, env_for(config.lang, platform))!264	if run.timed_out {265		return error('the ${platform} build was still running after ten minutes and was stopped')266	}267	if run.code != 0 {268		return error('the ${platform} build failed:\n${tail(run.output)}')269	}270	full := os.join_path(root, path)271	if !os.exists(full) {272		return error('the ${platform} build did not create ${path}')273	}274	if os.is_dir(full) {275		return error('${path} is a folder — a release file is one file')276	}277	size := os.file_size(full)278	if size == 0 {279		return error('the ${platform} build produced an empty ${path}')280	}281	return Built{282		platform: platform283		path:     path284		size:     size285	}286}287288// Run is one build's outcome: what it printed, its exit code, and whether the289// ten minutes ran out.290struct Run {291	code      int292	timed_out bool293	output    string294}295296// run_build runs the command with the build environment from the repo root.297// Output goes to files rather than pipes: a chatty build must not fill a pipe298// and hang waiting for a reader that is waiting for it to finish.299fn run_build(root string, command string, env map[string]string) !Run {300	logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}')301	os.mkdir_all(logs, os.MkdirParams{})!302	defer {303		os.rmdir_all(logs) or {}304	}305	stdout_path := os.join_path(logs, 'stdout')306	stderr_path := os.join_path(logs, 'stderr')307	mut environment := os.environ()308	for key, value in env {309		environment[key] = value310	}311	mut child := os.new_process('sh')312	// The braces keep a command that redirects its own output — a build script313	// writing its artifact with `>` — from having that file taken over by the314	// log redirect, which the shell would otherwise apply last.315	child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}'])316	child.set_work_folder(root)317	child.set_environment(environment)318	// Its own process group, so a build that hangs takes its children with it.319	child.use_pgroup = true320	child.run()321	deadline := time.now().add(build_timeout)322	for child.is_alive() && time.now() < deadline {323		time.sleep(200 * time.millisecond)324	}325	timed_out := child.is_alive()326	// Whatever the command left running goes with it: a background child could327	// still be writing the artifact after it was hashed, and a hung build must328	// not hold a release. The group is signalled by id — the command leads its329	// own group — because `signal_pgkill` returns early once the process has330	// exited. Nothing is signalled before the command started, when the id is 0331	// and the group would be koh's own.332	if child.pid > 0 {333		C.kill(-child.pid, C.SIGKILL)334	}335	if timed_out {336		// Kill first: waiting on a hung build waits forever.337		child.wait()338	}339	stdout := os.read_file(stdout_path) or { '' }340	stderr := os.read_file(stderr_path) or { '' }341	return Run{342		code:      child.code343		timed_out: timed_out344		output:    '${stdout}${stderr}'345	}346}347348// sh_quote keeps a path with a space in it one argument, and cannot be broken349// out of by a quote inside the path.350fn sh_quote(text string) string {351	return "'" + text.replace("'", "'\\''") + "'"352}353354// tail keeps the end of a build log: what went wrong is at the bottom, and a355// compiler's whole output is too much to print.356fn tail(text string) string {357	lines := text.trim_right('\n').split_into_lines()358	if lines.len <= 20 {359		return lines.join('\n')360	}361	return lines[lines.len - 20..].join('\n')362}