build.v

10.2 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	return Built{276		platform: platform277		path:     path278		size:     os.file_size(full)279	}280}281282// Run is one build's outcome: what it printed, its exit code, and whether the283// ten minutes ran out.284struct Run {285	code      int286	timed_out bool287	output    string288}289290// run_build runs the command with the build environment from the repo root.291// Output goes to files rather than pipes: a chatty build must not fill a pipe292// and hang waiting for a reader that is waiting for it to finish.293fn run_build(root string, command string, env map[string]string) !Run {294	logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}')295	os.mkdir_all(logs, os.MkdirParams{})!296	defer {297		os.rmdir_all(logs) or {}298	}299	stdout_path := os.join_path(logs, 'stdout')300	stderr_path := os.join_path(logs, 'stderr')301	mut environment := os.environ()302	for key, value in env {303		environment[key] = value304	}305	mut child := os.new_process('sh')306	// The braces keep a command that redirects its own output — a build script307	// writing its artifact with `>` — from having that file taken over by the308	// log redirect, which the shell would otherwise apply last.309	child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}'])310	child.set_work_folder(root)311	child.set_environment(environment)312	// Its own process group, so a build that hangs takes its children with it.313	child.use_pgroup = true314	child.run()315	deadline := time.now().add(build_timeout)316	for child.is_alive() && time.now() < deadline {317		time.sleep(200 * time.millisecond)318	}319	timed_out := child.is_alive()320	if timed_out {321		child.signal_pgkill()322		child.wait()323	}324	stdout := os.read_file(stdout_path) or { '' }325	stderr := os.read_file(stderr_path) or { '' }326	return Run{327		code:      child.code328		timed_out: timed_out329		output:    '${stdout}${stderr}'330	}331}332333// sh_quote keeps a path with a space in it one argument, and cannot be broken334// out of by a quote inside the path.335fn sh_quote(text string) string {336	return "'" + text.replace("'", "'\\''") + "'"337}338339// tail keeps the end of a build log: what went wrong is at the bottom, and a340// compiler's whole output is too much to print.341fn tail(text string) string {342	lines := text.trim_right('\n').split_into_lines()343	if lines.len <= 20 {344		return lines.join('\n')345	}346	return lines[lines.len - 20..].join('\n')347}