build.v

10.5 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 build910import os11import time1213pub const config_name = 'koh.build'1415// Kepr 2's exact list. Peach Android and every build file already in the wild16// depend on these names, so a name outside it is refused.17pub const platforms = ['linux-x86_64', 'linux-aarch64', 'macos-aarch64', 'macos-x86_64',18	'windows-x86_64', 'android-arm64', 'android-x86']1920// Ten minutes is the spec's ceiling for one build: a hung toolchain must not21// hold a release forever.22const build_timeout = 10 * time.minute2324const lang_names = 'zig, rust, go, c, node, swift, python, shell, make, gradle'2526// Lang picks the cross-compile variables a build gets. `gradle` sets none.27pub enum Lang {28	unknown29	zig30	rust31	go32	c33	node34	swift35	python36	shell37	make38	gradle39}4041pub struct Config {42pub:43	command   string44	output    string45	platforms []string46	lang      Lang47}4849// One build file to look for, and what its presence means.50struct Probe {51	file string52	lang Lang53}5455// read_config parses `koh.build`. The three required keys are refused when they56// are missing. Keys Koh 3 does not use are ignored: `trigger`, which the spec57// drops, and Koh 2's `enabled`, `timeout_seconds` and `memory_limit_mb`, because58// `migrate` renames existing `kepr.build` files as they are.59pub fn read_config(root string) !Config {60	text := os.read_file(os.join_path(root, config_name)) or {61		return error('there is no ${config_name} in ${os.base(root)} — see "koh man build"')62	}63	mut command := ''64	mut output := ''65	mut named := ''66	mut lang := Lang.unknown67	for line in text.split_into_lines() {68		trimmed := line.trim_space()69		if trimmed == '' || trimmed.starts_with('#') {70			continue71		}72		at := trimmed.index('=') or { continue }73		key := trimmed[..at].trim_space()74		value := trimmed[at + 1..].trim_space()75		match key {76			'command' { command = value }77			'output' { output = value }78			'platforms' { named = value }79			'lang' {80				lang = lang_of(value) or {81					return error('${config_name} says lang = ${value}, which is not one of ${lang_names}')82				}83			}84			else {}85		}86	}87	if command == '' {88		return error('${config_name} has no command')89	}90	if output == '' {91		return error('${config_name} has no output')92	}93	platform_list := named.split(',').map(it.trim_space()).filter(it != '')94	if platform_list.len == 0 {95		return error('${config_name} names no platforms')96	}97	for platform in platform_list {98		if platform !in platforms {99			return error('${config_name} names platform "${platform}", which is not one of ${platforms.join(', ')}')100		}101	}102	return Config{103		command:   command104		output:    output105		platforms: platform_list106		lang:      if lang == .unknown { detect(root) } else { lang }107	}108}109110// lang_of is the `lang =` override. Naming a language Koh does not know is111// refused rather than quietly guessed at.112fn lang_of(name string) ?Lang {113	return match name.to_lower() {114		'zig' { Lang.zig }115		'rust' { Lang.rust }116		'go' { Lang.go }117		'c' { Lang.c }118		'node' { Lang.node }119		'swift' { Lang.swift }120		'python' { Lang.python }121		'shell' { Lang.shell }122		'make' { Lang.make }123		'gradle' { Lang.gradle }124		else { none }125	}126}127128// detect is Koh 2's rule: a hand-written build.sh wins over everything, then129// the language's own build files, then C's build systems, then a bare Makefile.130fn detect(root string) Lang {131	for probe in [132		Probe{'build.sh', Lang.shell},133		Probe{'build.zig', Lang.zig},134		Probe{'Cargo.toml', Lang.rust},135		Probe{'go.mod', Lang.go},136		Probe{'Package.swift', Lang.swift},137		Probe{'pyproject.toml', Lang.python},138		Probe{'setup.py', Lang.python},139		Probe{'package.json', Lang.node},140		Probe{'app/build.gradle.kts', Lang.gradle},141		Probe{'app/build.gradle', Lang.gradle},142		Probe{'build.gradle.kts', Lang.gradle},143		Probe{'build.gradle', Lang.gradle},144		Probe{'CMakeLists.txt', Lang.c},145		Probe{'meson.build', Lang.c},146		Probe{'configure', Lang.c},147		Probe{'Makefile', Lang.make},148		Probe{'makefile', Lang.make},149	] {150		if os.exists(os.join_path(root, probe.file)) {151			return probe.lang152		}153	}154	return .unknown155}156157// this_machine is the platform name for the machine Koh runs on: what `koh158// build` builds for when no --platform is given.159pub fn this_machine() !string {160	machine := os.uname().machine161	arch := if machine in ['arm64', 'aarch64'] {162		'aarch64'163	} else if machine in ['x86_64', 'amd64'] {164		'x86_64'165	} else {166		return error('this machine is "${machine}" — koh builds for ${platforms.join(', ')}')167	}168	name := '${os.user_os()}-${arch}'169	if name !in platforms {170		return error('this machine is ${name} — koh builds for ${platforms.join(', ')}')171	}172	return name173}174175// The cross-compile settings of one platform, as Koh 2's buildEnvFor had them.176// Empty values mean "nothing extra": Android builds its own toolchain in its177// build script.178struct Cross {179	goos   string180	goarch string181	rust   string182	cc     string183}184185fn cross_of(platform string) Cross {186	return match platform {187		'linux-x86_64' {188			Cross{'linux', 'amd64', 'x86_64-unknown-linux-musl', 'x86_64-linux-musl-gcc'}189		}190		'linux-aarch64' {191			Cross{'linux', 'arm64', 'aarch64-unknown-linux-musl', 'aarch64-linux-musl-gcc'}192		}193		'macos-aarch64' { Cross{'darwin', 'arm64', 'aarch64-apple-darwin', ''} }194		'macos-x86_64' { Cross{'darwin', 'amd64', 'x86_64-apple-darwin', ''} }195		'windows-x86_64' { Cross{'windows', 'amd64', 'x86_64-pc-windows-gnu', ''} }196		else { Cross{} }197	}198}199200// env_for is Koh 2's `lang.buildEnvFor`. KOH_TARGET is the new name;201// KEPR_TARGET is kept because every build script in the wild reads it.202pub fn env_for(lang Lang, platform string) map[string]string {203	if lang == .zig {204		// A Zig project cross-compiles with the triple, which is what Koh 2205		// passed as KEPR_TARGET.206		triple := zig_triple(platform)207		return {208			'KOH_TARGET':  triple209			'KEPR_TARGET': triple210		}211	}212	mut env := {213		'KOH_TARGET':  platform214		'KEPR_TARGET': platform215	}216	cross := cross_of(platform)217	if cross.goos == '' {218		return env219	}220	if lang == .go {221		env['GOOS'] = cross.goos222		env['GOARCH'] = cross.goarch223	} else if lang == .rust {224		env['CARGO_BUILD_TARGET'] = cross.rust225	} else if lang == .c && cross.cc != '' {226		env['CC'] = cross.cc227	}228	return env229}230231// zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no232// triple for keeps the plain name.233fn zig_triple(platform string) string {234	return match platform {235		'linux-x86_64' { 'x86_64-linux-musl' }236		'linux-aarch64' { 'aarch64-linux-musl' }237		'macos-aarch64' { 'aarch64-macos' }238		'macos-x86_64' { 'x86_64-macos' }239		'windows-x86_64' { 'x86_64-windows' }240		else { platform }241	}242}243244// output_path is `output` with the version and platform filled in, so each245// platform's artifact can land at its own path.246pub fn output_path(config Config, platform string, version string) string {247	return config.output.replace('{version}', version).replace('{platform}', platform)248}249250pub struct Built {251pub:252	platform string253	path     string254	size     i64255}256257// build_one runs one platform's build and returns its artifact. A build that258// failed, timed out, or produced nothing is refused: nothing is published.259pub fn build_one(root string, config Config, platform string, version string) !Built {260	path := output_path(config, platform, version)261	run := run_build(root, config.command, env_for(config.lang, platform))!262	if run.timed_out {263		return error('the ${platform} build was still running after ten minutes and was stopped')264	}265	if run.code != 0 {266		return error('the ${platform} build failed:\n${tail(run.output)}')267	}268	full := os.join_path(root, path)269	if !os.exists(full) {270		return error('the ${platform} build did not create ${path}')271	}272	if os.is_dir(full) {273		return error('${path} is a folder — a release file is one file')274	}275	size := os.file_size(full)276	if size == 0 {277		return error('the ${platform} build produced an empty ${path}')278	}279	return Built{280		platform: platform281		path:     path282		size:     size283	}284}285286// Run is one build's outcome: what it printed, its exit code, and whether the287// ten minutes ran out.288struct Run {289	code      int290	timed_out bool291	output    string292}293294// run_build runs the command with the build environment from the repo root.295// Output goes to files rather than pipes: a chatty build must not fill a pipe296// and hang waiting for a reader that is waiting for it to finish.297fn run_build(root string, command string, env map[string]string) !Run {298	logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}')299	os.mkdir_all(logs, os.MkdirParams{})!300	defer {301		os.rmdir_all(logs) or {}302	}303	stdout_path := os.join_path(logs, 'stdout')304	stderr_path := os.join_path(logs, 'stderr')305	mut environment := os.environ()306	for key, value in env {307		environment[key] = value308	}309	mut child := os.new_process('sh')310	// The braces keep a command that redirects its own output — a build script311	// writing its artifact with `>` — from having that file taken over by the312	// log redirect, which the shell would otherwise apply last.313	child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}'])314	child.set_work_folder(root)315	child.set_environment(environment)316	// Its own process group, so a build that hangs takes its children with it.317	child.use_pgroup = true318	child.run()319	deadline := time.now().add(build_timeout)320	for child.is_alive() && time.now() < deadline {321		time.sleep(200 * time.millisecond)322	}323	timed_out := child.is_alive()324	if timed_out {325		// Kill first: waiting on a hung build waits forever.326		child.signal_pgkill()327		child.wait()328	}329	// Whatever the command left running goes with it: a background child could330	// still be writing the artifact after it was hashed. Its own process group331	// again, so a stray child dies and nothing else does.332	child.signal_pgkill()333	stdout := os.read_file(stdout_path) or { '' }334	stderr := os.read_file(stderr_path) or { '' }335	return Run{336		code:      child.code337		timed_out: timed_out338		output:    '${stdout}${stderr}'339	}340}341342// sh_quote keeps a path with a space in it one argument, and cannot be broken343// out of by a quote inside the path.344fn sh_quote(text string) string {345	return "'" + text.replace("'", "'\\''") + "'"346}347348// tail keeps the end of a build log: what went wrong is at the bottom, and a349// compiler's whole output is too much to print.350fn tail(text string) string {351	lines := text.trim_right('\n').split_into_lines()352	if lines.len <= 20 {353		return lines.join('\n')354	}355	return lines[lines.len - 20..].join('\n')356}