build.v

10.9 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	mut seen := map[string]bool{}100	for platform in platform_list {101		if platform !in platforms {102			return error('${config_name} names platform "${platform}", which is not one of ${platforms.join(', ')}')103		}104		if seen[platform] {105			return error('${config_name} names platform "${platform}" twice')106		}107		seen[platform] = true108	}109	return Config{110		command:   command111		output:    output112		platforms: platform_list113		lang:      if lang == .unknown { detect(root) } else { lang }114	}115}116117// lang_of is the `lang =` override. Naming a language Koh does not know is118// refused rather than quietly guessed at.119fn lang_of(name string) ?Lang {120	return match name.to_lower() {121		'zig' { Lang.zig }122		'rust' { Lang.rust }123		'go' { Lang.go }124		'c' { Lang.c }125		'node' { Lang.node }126		'swift' { Lang.swift }127		'python' { Lang.python }128		'shell' { Lang.shell }129		'make' { Lang.make }130		'gradle' { Lang.gradle }131		else { none }132	}133}134135// detect is Koh 2's rule: a hand-written build.sh wins over everything, then136// the language's own build files, then C's build systems, then a bare Makefile.137fn detect(root string) Lang {138	for probe in [139		Probe{'build.sh', Lang.shell},140		Probe{'build.zig', Lang.zig},141		Probe{'Cargo.toml', Lang.rust},142		Probe{'go.mod', Lang.go},143		Probe{'Package.swift', Lang.swift},144		Probe{'pyproject.toml', Lang.python},145		Probe{'setup.py', Lang.python},146		Probe{'package.json', Lang.node},147		Probe{'app/build.gradle.kts', Lang.gradle},148		Probe{'app/build.gradle', Lang.gradle},149		Probe{'build.gradle.kts', Lang.gradle},150		Probe{'build.gradle', Lang.gradle},151		Probe{'CMakeLists.txt', Lang.c},152		Probe{'meson.build', Lang.c},153		Probe{'configure', Lang.c},154		Probe{'Makefile', Lang.make},155		Probe{'makefile', Lang.make},156	] {157		if os.exists(os.join_path(root, probe.file)) {158			return probe.lang159		}160	}161	return .unknown162}163164// this_machine is the platform name for the machine Koh runs on: what `koh165// build` builds for when no --platform is given.166pub fn this_machine() !string {167	machine := os.uname().machine168	arch := if machine in ['arm64', 'aarch64'] {169		'aarch64'170	} else if machine in ['x86_64', 'amd64'] {171		'x86_64'172	} else {173		return error('this machine is "${machine}" — koh builds for ${platforms.join(', ')}')174	}175	name := '${os.user_os()}-${arch}'176	if name !in platforms {177		return error('this machine is ${name} — koh builds for ${platforms.join(', ')}')178	}179	return name180}181182// The cross-compile settings of one platform, as Koh 2's buildEnvFor had them.183// Empty values mean "nothing extra": Android builds its own toolchain in its184// build script.185struct Cross {186	goos   string187	goarch string188	rust   string189	cc     string190}191192fn cross_of(platform string) Cross {193	return match platform {194		'linux-x86_64' {195			Cross{'linux', 'amd64', 'x86_64-unknown-linux-musl', 'x86_64-linux-musl-gcc'}196		}197		'linux-aarch64' {198			Cross{'linux', 'arm64', 'aarch64-unknown-linux-musl', 'aarch64-linux-musl-gcc'}199		}200		'macos-aarch64' { Cross{'darwin', 'arm64', 'aarch64-apple-darwin', ''} }201		'macos-x86_64' { Cross{'darwin', 'amd64', 'x86_64-apple-darwin', ''} }202		'windows-x86_64' { Cross{'windows', 'amd64', 'x86_64-pc-windows-gnu', ''} }203		else { Cross{} }204	}205}206207// env_for is Koh 2's `lang.buildEnvFor`. KOH_TARGET is the new name;208// KEPR_TARGET is kept because every build script in the wild reads it.209pub fn env_for(lang Lang, platform string) map[string]string {210	if lang == .zig {211		// A Zig project cross-compiles with the triple, which is what Koh 2212		// passed as KEPR_TARGET.213		triple := zig_triple(platform)214		return {215			'KOH_TARGET':  triple216			'KEPR_TARGET': triple217		}218	}219	mut env := {220		'KOH_TARGET':  platform221		'KEPR_TARGET': platform222	}223	cross := cross_of(platform)224	if cross.goos == '' {225		return env226	}227	if lang == .go {228		env['GOOS'] = cross.goos229		env['GOARCH'] = cross.goarch230	} else if lang == .rust {231		env['CARGO_BUILD_TARGET'] = cross.rust232	} else if lang == .c && cross.cc != '' {233		env['CC'] = cross.cc234	}235	return env236}237238// zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no239// triple for keeps the plain name.240fn zig_triple(platform string) string {241	return match platform {242		'linux-x86_64' { 'x86_64-linux-musl' }243		'linux-aarch64' { 'aarch64-linux-musl' }244		'macos-aarch64' { 'aarch64-macos' }245		'macos-x86_64' { 'x86_64-macos' }246		'windows-x86_64' { 'x86_64-windows' }247		else { platform }248	}249}250251// output_path is `output` with the version and platform filled in, so each252// platform's artifact can land at its own path.253pub fn output_path(config Config, platform string, version string) string {254	return config.output.replace('{version}', version).replace('{platform}', platform)255}256257pub struct Built {258pub:259	platform string260	path     string261	size     i64262}263264// build_one runs one platform's build and returns its artifact. A build that265// failed, timed out, or produced nothing is refused: nothing is published.266pub fn build_one(root string, config Config, platform string, version string) !Built {267	path := output_path(config, platform, version)268	run := run_build(root, config.command, env_for(config.lang, platform))!269	if run.timed_out {270		return error('the ${platform} build was still running after ten minutes and was stopped')271	}272	if run.code != 0 {273		return error('the ${platform} build failed:\n${tail(run.output)}')274	}275	full := os.join_path(root, path)276	if !os.exists(full) {277		return error('the ${platform} build did not create ${path}')278	}279	if os.is_dir(full) {280		return error('${path} is a folder — a release file is one file')281	}282	size := os.file_size(full)283	if size == 0 {284		return error('the ${platform} build produced an empty ${path}')285	}286	return Built{287		platform: platform288		path:     path289		size:     size290	}291}292293// Run is one build's outcome: what it printed, its exit code, and whether the294// ten minutes ran out.295struct Run {296	code      int297	timed_out bool298	output    string299}300301// run_build runs the command with the build environment from the repo root.302// Output goes to files rather than pipes: a chatty build must not fill a pipe303// and hang waiting for a reader that is waiting for it to finish.304fn run_build(root string, command string, env map[string]string) !Run {305	logs := os.join_path(os.temp_dir(), 'koh-build-${os.getpid()}-${time.now().unix_nano()}')306	os.mkdir_all(logs, os.MkdirParams{})!307	defer {308		os.rmdir_all(logs) or {}309	}310	stdout_path := os.join_path(logs, 'stdout')311	stderr_path := os.join_path(logs, 'stderr')312	mut environment := os.environ()313	for key, value in env {314		environment[key] = value315	}316	mut child := os.new_process('sh')317	// The braces keep a command that redirects its own output — a build script318	// writing its artifact with `>` — from having that file taken over by the319	// log redirect, which the shell would otherwise apply last.320	child.set_args(['-c', '{ ${command} ; } > ${sh_quote(stdout_path)} 2> ${sh_quote(stderr_path)}'])321	child.set_work_folder(root)322	child.set_environment(environment)323	// Its own process group, so a build that hangs takes its children with it.324	child.use_pgroup = true325	child.run()326	deadline := time.now().add(build_timeout)327	for child.is_alive() && time.now() < deadline {328		time.sleep(200 * time.millisecond)329	}330	timed_out := child.is_alive()331	// Whatever the command left running goes with it: a background child could332	// still be writing the artifact after it was hashed, and a hung build must333	// not hold a release. The group is signalled by id — the command leads its334	// own group — because `signal_pgkill` returns early once the process has335	// exited. Nothing is signalled before the command started, when the id is 0336	// and the group would be koh's own.337	if child.pid > 0 {338		C.kill(-child.pid, C.SIGKILL)339	}340	if timed_out {341		// Kill first: waiting on a hung build waits forever.342		child.wait()343	}344	stdout := os.read_file(stdout_path) or { '' }345	stderr := os.read_file(stderr_path) or { '' }346	return Run{347		code:      child.code348		timed_out: timed_out349		output:    '${stdout}${stderr}'350	}351}352353// sh_quote keeps a path with a space in it one argument, and cannot be broken354// out of by a quote inside the path.355fn sh_quote(text string) string {356	return "'" + text.replace("'", "'\\''") + "'"357}358359// tail keeps the end of a build log: what went wrong is at the bottom, and a360// compiler's whole output is too much to print.361fn tail(text string) string {362	lines := text.trim_right('\n').split_into_lines()363	if lines.len <= 20 {364		return lines.join('\n')365	}366	return lines[lines.len - 20..].join('\n')367}