ba2f1a3bcb27

add the build module and koh build: parse koh.build, read each project's own version in the spec's order with gradle last, run the build environment per platform and report every output path (3.0.79)

time
2026-09-27 10:13 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
koh 3 phases 6-8
parent
662cda477689
10 files .kohignore+3 −1VERSION+1 −1src/build/build.v+347 −0src/build/build_test.v+270 −0src/build/version.v+149 −0src/cli/args.v+10 −0src/cli/cli.v+4 −1src/cli/cmd_build.v+87 −0tests/cli_test.v+88 −0v.mod+1 −1
.kohignore+3 −1
@@ -8,11 +8,13 @@88 # koh already ignores .koh, .koh-wal, .koh-shm, .koh-v2/ and .git/ automatically.99 1010 # Build output11+# Anchored, or `build/` would also hide a source folder named build at any12+# depth — this repo keeps `src/build/` under version control.1113 zig-out/1214 .zig-cache/1315 zig-cache/1416 target/15-build/17+/build/1618 dist/1719 out/1820 

view file

VERSION+1 −1
@@ -1 +1 @@1-3.0.781+3.0.79

view file

src/build/build.v+347 −0
@@ -0,0 +1,347 @@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.7+8+module build9+10+import os11+import time12+13+pub const config_name = 'koh.build'14+15+// 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.17+pub const platforms = ['linux-x86_64', 'linux-aarch64', 'macos-aarch64', 'macos-x86_64',18+	'windows-x86_64', 'android-arm64', 'android-x86']19+20+// Ten minutes is the spec's ceiling for one build: a hung toolchain must not21+// hold a release forever.22+const build_timeout = 10 * time.minute23+24+const lang_names = 'zig, rust, go, c, node, swift, python, shell, make, gradle'25+26+// Lang picks the cross-compile variables a build gets. `gradle` sets none.27+pub enum Lang {28+	unknown29+	zig30+	rust31+	go32+	c33+	node34+	swift35+	python36+	shell37+	make38+	gradle39+}40+41+pub struct Config {42+pub:43+	command   string44+	output    string45+	platforms []string46+	lang      Lang47+}48+49+// One build file to look for, and what its presence means.50+struct Probe {51+	file string52+	lang Lang53+}54+55+// 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.59+pub 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+}109+110+// lang_of is the `lang =` override. Naming a language Koh does not know is111+// refused rather than quietly guessed at.112+fn 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+}127+128+// 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.130+fn 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+}156+157+// this_machine is the platform name for the machine Koh runs on: what `koh158+// build` builds for when no --platform is given.159+pub 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+}174+175+// 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.178+struct Cross {179+	goos   string180+	goarch string181+	rust   string182+	cc     string183+}184+185+fn 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+}199+200+// 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.202+pub 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+}230+231+// zig_triple is Koh 2's Zig row: the triple is the target. A platform Zig has no232+// triple for keeps the plain name.233+fn 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+}243+244+// output_path is `output` with the version and platform filled in, so each245+// platform's artifact can land at its own path.246+pub fn output_path(config Config, platform string, version string) string {247+	return config.output.replace('{version}', version).replace('{platform}', platform)248+}249+250+pub struct Built {251+pub:252+	platform string253+	path     string254+	size     i64255+}256+257+// 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.259+pub 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+}281+282+// Run is one build's outcome: what it printed, its exit code, and whether the283+// ten minutes ran out.284+struct Run {285+	code      int286+	timed_out bool287+	output    string288+}289+290+// 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.293+fn 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+}332+333+// sh_quote keeps a path with a space in it one argument, and cannot be broken334+// out of by a quote inside the path.335+fn sh_quote(text string) string {336+	return "'" + text.replace("'", "'\\''") + "'"337+}338+339+// 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.341+fn 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+}

view file

src/build/build_test.v+270 −0
@@ -0,0 +1,270 @@1+//! Unit tests for the build module: the version a project declares, what2+//! `koh.build` says, the environment a build gets, and one real build.3+4+module build5+6+import os7+import time8+9+fn scratch(name string) !string {10+	dir := os.join_path(os.temp_dir(), 'koh3-build-${name}-${time.now().unix_nano()}')11+	os.mkdir_all(dir, os.MkdirParams{})!12+	return dir13+}14+15+fn write(root string, rel string, text string) ! {16+	full := os.join_path(root, rel)17+	os.mkdir_all(os.dir(full), os.MkdirParams{})!18+	os.write_file(full, text)!19+}20+21+struct SourceCase {22+	file    string23+	content string24+	want    string25+}26+27+fn test_every_version_source_is_read() {28+	zon := '.{\n    .name = "demo",\n    .minimum_zig_version = "0.16.0",\n    .version = "2.0.1",\n}\n'29+	plist := '<?xml version="1.0"?>\n<dict>\n<key>CFBundleShortVersionString</key>\n<string>6.0.5</string>\n</dict>\n'30+	cases := [31+		SourceCase{'VERSION', '1.0.0\n', '1.0.0'},32+		SourceCase{'build.zig.zon', zon, '2.0.1'},33+		SourceCase{'v.mod', "Module {\n\tname: 'demo'\n\tversion: '3.0.2'\n}", '3.0.2'},34+		SourceCase{'package.json', '{\n  "name": "demo",\n  "version": "4.0.3"\n}', '4.0.3'},35+		SourceCase{'Cargo.toml', '[package]\nname = "demo"\nversion = "5.0.4"\n', '5.0.4'},36+		SourceCase{'Info.plist', plist, '6.0.5'},37+		SourceCase{'build.gradle.kts', 'versionName = "7.0.6"\n', '7.0.6'},38+		SourceCase{'build.gradle', 'versionName "8.0.7"\n', '8.0.7'},39+		SourceCase{'build.gradle', "versionName '9.0.8'\n", '9.0.8'},40+		SourceCase{'app/build.gradle.kts', 'versionName = "10.0.9"\n', '10.0.9'},41+		SourceCase{'app/build.gradle', "versionName '10.0.9'\n", '10.0.9'},42+	]43+	for case in cases {44+		root := scratch('source')!45+		defer {46+			os.rmdir_all(root) or {}47+		}48+		write(root, case.file, case.content)!49+		assert project_version(root)! == case.want, case.file50+	}51+}52+53+// A VERSION file is the operator's override, so it wins over everything else.54+fn test_a_version_file_wins_over_every_source() {55+	root := scratch('override')!56+	defer {57+		os.rmdir_all(root) or {}58+	}59+	write(root, 'build.zig.zon', '.{\n    .version = "2.0.1",\n}\n')!60+	write(root, 'v.mod', "Module {\n\tversion: '3.0.2'\n}")!61+	write(root, 'VERSION', '9.9.9\n')!62+	assert project_version(root)! == '9.9.9'63+}64+65+// Nothing to read is refused, never published as 0.0.0.66+fn test_a_project_with_no_version_is_refused() {67+	root := scratch('no_version')!68+	defer {69+		os.rmdir_all(root) or {}70+	}71+	if _ := project_version(root) {72+		assert false, 'a project with no version must be refused'73+	} else {74+		assert err.msg().contains('VERSION')75+	}76+}77+78+// A value that is not a version would become a file name, so it is refused79+// where it was found rather than used.80+fn test_a_value_that_is_not_a_version_is_refused() {81+	root := scratch('bad_version')!82+	defer {83+		os.rmdir_all(root) or {}84+	}85+	write(root, 'VERSION', '1.0.0 build 7\n')!86+	if _ := project_version(root) {87+		assert false, 'a value that is not a version must be refused'88+	} else {89+		assert err.msg().contains('VERSION')90+	}91+	write(root, 'VERSION', 'abcdefghijklmnopqrstuvwxyz0123456789\n')!92+	if _ := project_version(root) {93+		assert false, 'a version longer than 32 characters must be refused'94+	} else {95+		assert err.msg().contains('VERSION')96+	}97+}98+99+// Koh never runs Gradle: a versionName built from a variable is not a version,100+// so the next Gradle file is tried, and the next source after that.101+fn test_a_variable_version_name_falls_through() {102+	root := scratch('gradle_variable')!103+	defer {104+		os.rmdir_all(root) or {}105+	}106+	write(root, 'app/build.gradle.kts', 'versionName = project.ext.version\n')!107+	write(root, 'build.gradle', 'versionName "2.17.43"\n')!108+	assert project_version(root)! == '2.17.43'109+110+	// A commented-out literal is not read, and versionNameSuffix is not a111+	// versionName: with nothing left, the refusal names a VERSION file.112+	write(root, 'build.gradle', '// versionName "9.9.9"\nversionNameSuffix = "-beta"\n')!113+	if _ := project_version(root) {114+		assert false, 'nothing was readable, so the version must be refused'115+	} else {116+		assert err.msg().contains('VERSION')117+	}118+}119+120+fn test_read_config_parses_the_file_and_ignores_what_it_does_not_use() {121+	root := scratch('config')!122+	defer {123+		os.rmdir_all(root) or {}124+	}125+	write(root, 'koh.build', '# how demo is built126+enabled         = true127+trigger         = on-offer128+timeout_seconds = 900129+command         = ./make.sh130+output          = out/demo-{version}-{platform}131+platforms       = macos-aarch64, linux-aarch64132+lang            = Zig133+')134+	config := read_config(root)!135+	assert config.command == './make.sh'136+	assert config.output == 'out/demo-{version}-{platform}'137+	assert config.platforms == ['macos-aarch64', 'linux-aarch64']138+	assert config.lang == .zig139+	assert output_path(config, 'linux-aarch64', '1.2.3') == 'out/demo-1.2.3-linux-aarch64'140+}141+142+// The language is detected when the file does not name one, so an existing143+// Zig project's build file needs no edit to keep cross-compiling.144+fn test_the_language_is_detected_from_the_project() {145+	root := scratch('detect')!146+	defer {147+		os.rmdir_all(root) or {}148+	}149+	write(root, 'koh.build', 'command = true\noutput = a\nplatforms = macos-aarch64\n')!150+	write(root, 'build.zig', '// a zig project\n')!151+	assert read_config(root)!.lang == .zig152+	write(root, 'build.sh', '#!/bin/sh\n')!153+	assert read_config(root)!.lang == .shell154+}155+156+fn test_read_config_refuses_what_it_cannot_build() {157+	root := scratch('config_bad')!158+	defer {159+		os.rmdir_all(root) or {}160+	}161+	if _ := read_config(root) {162+		assert false, 'a missing koh.build must be refused'163+	} else {164+		assert err.msg().contains('koh.build')165+	}166+}167+168+fn test_read_config_refuses_each_missing_key() {169+	root := scratch('config_keys')!170+	defer {171+		os.rmdir_all(root) or {}172+	}173+	cases := [174+		['command = true\nplatforms = macos-aarch64\n', 'has no output'],175+		['output = a\nplatforms = macos-aarch64\n', 'has no command'],176+		['command = true\noutput = a\n', 'names no platforms'],177+		['command = true\noutput = a\nplatforms = macos-aarch64, amiga\n', 'amiga'],178+		['command = true\noutput = a\nplatforms = macos-aarch64\nlang = cobol\n', 'cobol'],179+	]180+	for case in cases {181+		write(root, 'koh.build', case[0])!182+		if _ := read_config(root) {183+			assert false, case[1]184+		} else {185+			assert err.msg().contains(case[1]), err.msg()186+		}187+	}188+}189+190+fn test_the_build_environment_is_koh_2s() {191+	zig := env_for(.zig, 'linux-aarch64')192+	assert zig['KOH_TARGET'] == 'aarch64-linux-musl'193+	assert zig['KEPR_TARGET'] == 'aarch64-linux-musl'194+	// `go` is a V keyword, so the map needs another name.195+	go_env := env_for(.go, 'linux-x86_64')196+	assert go_env['KOH_TARGET'] == 'linux-x86_64'197+	assert go_env['KEPR_TARGET'] == 'linux-x86_64'198+	assert go_env['GOOS'] == 'linux'199+	assert go_env['GOARCH'] == 'amd64'200+	assert env_for(.rust, 'windows-x86_64')['CARGO_BUILD_TARGET'] == 'x86_64-pc-windows-gnu'201+	assert env_for(.rust, 'macos-aarch64')['CARGO_BUILD_TARGET'] == 'aarch64-apple-darwin'202+	assert env_for(.c, 'linux-x86_64')['CC'] == 'x86_64-linux-musl-gcc'203+	// macOS has no musl cc, and Android carries the targets and nothing else.204+	assert 'CC' !in env_for(.c, 'macos-x86_64')205+	assert env_for(.gradle, 'android-arm64').len == 2206+	assert env_for(.node, 'android-x86')['KEPR_TARGET'] == 'android-x86'207+}208+209+fn test_this_machine_is_one_of_the_platforms() {210+	assert this_machine()! in platforms211+}212+213+// A real build, small enough for a unit test: the command writes the file214+// `output` names, and the environment it saw is in that file. Only `output`215+// takes the tokens — a build script learns the version from the project itself,216+// the way Peach Android's reads VERSION.217+fn test_build_one_runs_the_command_and_finds_the_output() {218+	root := scratch('build_one')!219+	defer {220+		os.rmdir_all(root) or {}221+	}222+	write(root, 'koh.build', 'command = mkdir -p out && env > out/demo-1.2.3223+output = out/demo-{version}224+platforms = macos-aarch64225+lang = make226+')!227+	config := read_config(root)!228+	built := build_one(root, config, 'macos-aarch64', '1.2.3')!229+	assert built.platform == 'macos-aarch64'230+	assert built.path == 'out/demo-1.2.3'231+	assert built.size > 0232+	seen := os.read_file(os.join_path(root, 'out/demo-1.2.3'))!233+	assert seen.contains('KEPR_TARGET=macos-aarch64')234+	assert seen.contains('KOH_TARGET=macos-aarch64')235+}236+237+fn test_build_one_refuses_a_failing_build_and_shows_what_it_said() {238+	root := scratch('build_fail')!239+	defer {240+		os.rmdir_all(root) or {}241+	}242+	write(root, 'koh.build', 'command = sh -c \'echo "no compiler here" >&2; exit 1\'243+output = out/demo244+platforms = macos-aarch64245+')246+	config := read_config(root)!247+	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {248+		assert false, 'a failing build must be refused'249+	} else {250+		assert err.msg().contains('no compiler here')251+	}252+}253+254+// A build that leaves nothing behind is a build failure, not a release.255+fn test_build_one_refuses_a_build_that_creates_nothing() {256+	root := scratch('build_empty')!257+	defer {258+		os.rmdir_all(root) or {}259+	}260+	write(root, 'koh.build', 'command = true261+output = out/demo262+platforms = macos-aarch64263+')264+	config := read_config(root)!265+	if _ := build_one(root, config, 'macos-aarch64', '1.2.3') {266+		assert false, 'a build with no output must be refused'267+	} else {268+		assert err.msg().contains('did not create')269+	}270+}

view file

src/build/version.v+149 −0
@@ -0,0 +1,149 @@1+//! The version a project declares, read from its own files.2+//!3+//! The first source that answers wins, so a `VERSION` file overrides everything4+//! and a project that already declares its version in `v.mod`, `package.json`,5+//! `Cargo.toml`, `Info.plist` or Gradle needs no second copy to keep in step.6+//! Nothing is evaluated: a value built from a variable or a function call is7+//! skipped and the next source is tried, so Koh never runs a build system to8+//! ask what version something is.9+10+module build11+12+import os13+14+// The spec's search order for Gradle's `versionName`.15+const gradle_files = ['app/build.gradle.kts', 'app/build.gradle', 'build.gradle.kts', 'build.gradle']16+17+// project_version is the version the project declares. Finding nothing is an18+// error: publishing refuses rather than inventing 0.0.0, and a build cannot fill19+// `{version}` without one.20+pub fn project_version(root string) !string {21+	if value := file_version(os.join_path(root, 'VERSION')) {22+		return checked(value, 'VERSION')23+	}24+	if value := line_version(os.join_path(root, 'build.zig.zon'), '.version') {25+		return checked(value, 'build.zig.zon')26+	}27+	if value := line_version(os.join_path(root, 'v.mod'), 'version') {28+		return checked(value, 'v.mod')29+	}30+	if value := line_version(os.join_path(root, 'package.json'), '"version"') {31+		return checked(value, 'package.json')32+	}33+	if value := line_version(os.join_path(root, 'Cargo.toml'), 'version') {34+		return checked(value, 'Cargo.toml')35+	}36+	if value := plist_version(os.join_path(root, 'Info.plist')) {37+		return checked(value, 'Info.plist')38+	}39+	for file in gradle_files {40+		if value := gradle_version(os.join_path(root, file)) {41+			return checked(value, file)42+		}43+	}44+	return error('no version in ${root} — add a VERSION file holding the version to publish')45+}46+47+// checked refuses a value that is not a version. It becomes a release file48+// name, and 1–32 letters, digits, dots and dashes is what Koh 2 allowed.49+fn checked(value string, source string) !string {50+	if value.len == 0 || value.len > 32 || !value.bytes().all(is_version_byte(it)) {51+		return error('${source} says the version is "${value}", which is not 1–32 letters, digits, dots and dashes')52+	}53+	return value54+}55+56+fn is_version_byte(character u8) bool {57+	return (character >= `a` && character <= `z`) || (character >= `A` && character <= `Z`)58+		|| (character >= `0` && character <= `9`) || character == `.` || character == `-`59+}60+61+// file_version is the VERSION rule: the whole file, trimmed.62+fn file_version(path string) ?string {63+	text := os.read_file(path) or { return none }64+	value := text.trim_space()65+	if value == '' {66+		return none67+	}68+	return value69+}70+71+// line_version takes the value of the first line whose key starts it, in either72+// quote. A line that builds its value from a variable has no literal, so this73+// source yields nothing and the next one is tried.74+fn line_version(path string, key string) ?string {75+	text := os.read_file(path) or { return none }76+	for line in text.split_into_lines() {77+		trimmed := line.trim_left(' \t')78+		if trimmed == '' || trimmed.starts_with('//') || trimmed.starts_with('#') {79+			continue80+		}81+		if !trimmed.starts_with(key) {82+			continue83+		}84+		if value := quoted_value(trimmed, key.len) {85+			return value86+		}87+	}88+	return none89+}90+91+// quoted_value is the first quoted literal at or after `from` on a line. Only a92+// literal counts: `versionName = project.version` yields nothing.93+fn quoted_value(line string, from int) ?string {94+	for at in from .. line.len {95+		if line[at] != `"` && line[at] != `'` {96+			continue97+		}98+		quote := line[at]99+		for end in at + 1 .. line.len {100+			if line[end] == quote {101+				return line[at + 1..end]102+			}103+		}104+		return none105+	}106+	return none107+}108+109+// plist_version reads CFBundleShortVersionString from an Info.plist: the value110+// is in the <string> element after the key, not on the key's line.111+fn plist_version(path string) ?string {112+	text := os.read_file(path) or { return none }113+	at := text.index('<key>CFBundleShortVersionString</key>') or { return none }114+	rest := text[at..]115+	open := rest.index('<string>') or { return none }116+	close := rest.index('</string>') or { return none }117+	if close <= open + '<string>'.len {118+		return none119+	}120+	value := rest[open + '<string>'.len..close].trim_space()121+	if value == '' {122+		return none123+	}124+	return value125+}126+127+// gradle_version is `versionName` assigned a quoted literal, in either the128+// Kotlin (`= "2.17.43"`) or the Groovy (`"2.17.43"`) syntax. Commented-out lines129+// are skipped and `versionNameSuffix` is not a versionName, as the spec says.130+// Koh never runs Gradle: a value from a variable or `gradle.properties` is not131+// read, and the next source is tried instead.132+fn gradle_version(path string) ?string {133+	text := os.read_file(path) or { return none }134+	for line in text.split_into_lines() {135+		trimmed := line.trim_left(' \t')136+		if trimmed == '' || trimmed.starts_with('//') || trimmed.starts_with('/*')137+			|| trimmed.starts_with('*') {138+			continue139+		}140+		if !trimmed.starts_with('versionName') || trimmed.len <= 'versionName'.len141+			|| (trimmed['versionName'.len] != ` ` && trimmed['versionName'.len] != `=`) {142+			continue143+		}144+		if value := quoted_value(trimmed, 'versionName'.len) {145+			return value146+		}147+	}148+	return none149+}

view file

src/cli/args.v+10 −0
@@ -16,6 +16,9 @@1616 	positional []string1717 	values     map[string]string1818 	flags      map[string]bool19+	// Options that took a value more than once keep every value here, in order,20+	// for the few commands that accept a repeat (`koh build --platform`).21+	multi map[string][]string1922 }2023 2124 pub fn parse(command string, raw []string, spec Options) !Args {@@ -44,6 +47,7 @@4447 					value = raw[index]4548 				}4649 				args.values[name] = value50+				args.multi[name] << value4751 			} else if name in spec.flags {4852 				if inline != '' {4953 					return error('--${name} takes no value')@@ -68,6 +72,12 @@6872 6973 pub fn (args &Args) value(name string) string {7074 	return args.values[name] or { '' }75+}76+77+// values_of returns every value given for an option, in the order they were78+// given. An option that never repeated has the one value it took.79+pub fn (args &Args) values_of(name string) []string {80+	return args.multi[name] or { []string{} }7181 }7282 7383 pub fn (args &Args) value_or_none(name string) ?string {

view file

src/cli/cli.v+4 −1
@@ -39,7 +39,7 @@3939 			text += '  ${fmt.gold(row[0])}${' '.repeat(12 - row[0].len)}${fmt.muted(row[1])}\n'4040 		}4141 	}42-	text += '\n  ${fmt.dim('also: remote · flag · pluck · peek · init · doctor · strip · migrate')}\n'42+	text += '\n  ${fmt.dim('also: remote · flag · pluck · peek · build · init · doctor · strip · migrate')}\n'4343 	text += '\n  ${fmt.dim('koh man <command>  for options and examples')}\n'4444 	return text4545 }@@ -116,6 +116,9 @@116116 		}117117 		'steal' {118118 			return cmd_steal(rest)119+		}120+		'build' {121+			return cmd_build(rest)119122 		}120123 		'migrate' {121124 			return cmd_migrate(rest)

view file

src/cli/cmd_build.v+87 −0
@@ -0,0 +1,87 @@1+//! `koh build` — build with `koh.build`, and upload nothing.2+//!3+//! Publishing is `koh offer --builds`; this is for the person who wants the4+//! files, or who wants to know whether the build works at all.5+6+module cli7+8+import json29+import src.build10+import src.fmt11+12+pub fn cmd_build(raw []string) int {13+	args := parse('build', raw, Options{14+		values: ['platform']15+		flags:  ['all', 'json']16+	}) or { return usage_error(err.msg()) }17+	// Two ways of naming platforms at once is a mistake in the command line, so18+	// it is reported as one before anything is read.19+	if args.flag('all') && args.values_of('platform').len > 0 {20+		return usage_error('--all and --platform cannot be combined')21+	}22+	store := open_store() or { return refuse(args.json(), err.msg()) }23+	defer {24+		store.close()25+	}26+	config := build.read_config(store.root) or { return refuse(args.json(), err.msg()) }27+	// Not `version`: that is the koh binary's own version, in this module.28+	published_version := build.project_version(store.root) or {29+		return refuse(args.json(), err.msg())30+	}31+	// The version comes before the platform list, so a project that builds for32+	// another platform still says which version it declares.33+	if !args.json() {34+		note('version ${published_version}')35+	}36+	platforms := wanted_platforms(args, config) or { return refuse(args.json(), err.msg()) }37+	mut built := []build.Built{}38+	for platform in platforms {39+		result := build.build_one(store.root, config, platform, published_version) or {40+			return refuse(args.json(), err.msg())41+		}42+		built << result43+		if !args.json() {44+			ok('${platform}  ${result.path}  (${fmt.human_bytes(result.size)})')45+		}46+	}47+	if args.json() {48+		emit_json(json2.encode(BuildJson{49+			version: published_version50+			files:   built.map(BuiltJson{51+				platform: it.platform52+				path:     it.path53+				size:     it.size54+			})55+		}))56+	}57+	return 058+}59+60+// wanted_platforms is what to build: every platform `koh.build` names with61+// --all, the named ones with --platform, and this machine's own otherwise. A62+// platform the file does not build for is refused rather than skipped.63+fn wanted_platforms(args Args, config build.Config) ![]string {64+	mut wanted := args.values_of('platform')65+	if args.flag('all') {66+		wanted = config.platforms.clone()67+	} else if wanted.len == 0 {68+		wanted = [build.this_machine()!]69+	}70+	for platform in wanted {71+		if platform !in config.platforms {72+			return error('${build.config_name} does not build for "${platform}" — it builds for ${config.platforms.join(', ')}')73+		}74+	}75+	return wanted76+}77+78+struct BuiltJson {79+	platform string80+	path     string81+	size     i6482+}83+84+struct BuildJson {85+	version string86+	files   []BuiltJson87+}

view file

tests/cli_test.v+88 −0
@@ -104,6 +104,94 @@104104 }105105 106106 // The help screen is commands and plain words: every flag lives in `koh man`.107+// this_platform is the platform name for the machine the tests run on, so a108+// build test does not depend on which machine that is.109+fn this_platform() string {110+	machine := os.uname().machine111+	arch := if machine in ['arm64', 'aarch64'] { 'aarch64' } else { 'x86_64' }112+	return '${os.user_os()}-${arch}'113+}114+115+// `koh build` builds with `koh.build`: the version and platform fill the output116+// path, and each artifact is printed with its size.117+fn test_build_builds_the_named_platforms() {118+	dir := temp_repo('build_ok')119+	defer { os.rmdir_all(dir) or {} }120+	init_repo(dir)121+	host := this_platform()122+	write(dir, 'VERSION', '1.2.3\n')123+	write(dir, 'koh.build', 'command = mkdir -p out && printf built > out/demo-1.2.3-${host}124+output = out/demo-{version}-{platform}125+platforms = ${host}126+')127+	one := run(dir, 'build')128+	assert one.code == 0, one.err129+	assert one.out.contains('version 1.2.3')130+	assert one.out.contains('out/demo-1.2.3-${host}')131+	assert os.exists(os.join_path(dir, 'out/demo-1.2.3-${host}'))132+133+	// --platform picks one, --all takes every platform the file names.134+	write(dir, 'koh.build', 'command = mkdir -p out && printf m > out/demo-macos-aarch64 && printf l > out/demo-linux-aarch64135+output = out/demo-{platform}136+platforms = macos-aarch64, linux-aarch64137+')138+	all := run(dir, 'build', '--all', '--json')139+	assert all.code == 0, all.err140+	assert all.out.contains('"platform":"macos-aarch64"')141+	assert all.out.contains('"platform":"linux-aarch64"')142+	assert os.exists(os.join_path(dir, 'out/demo-linux-aarch64'))143+	picked := run(dir, 'build', '--platform', 'linux-aarch64')144+	assert picked.code == 0, picked.err145+	assert picked.out.contains('out/demo-linux-aarch64')146+	assert !picked.out.contains('out/demo-macos-aarch64')147+}148+149+fn test_build_refuses_what_it_cannot_do() {150+	dir := temp_repo('build_bad')151+	defer { os.rmdir_all(dir) or {} }152+	init_repo(dir)153+	missing := run(dir, 'build')154+	assert missing.code == 1155+	assert missing.err.contains('koh.build')156+157+	write(dir, 'koh.build', 'command = true158+output = out/demo159+platforms = macos-aarch64160+')161+	no_version := run(dir, 'build')162+	assert no_version.code == 1163+	assert no_version.err.contains('VERSION')164+165+	write(dir, 'VERSION', '1.2.3\n')166+	not_listed := run(dir, 'build', '--platform', 'linux-aarch64')167+	assert not_listed.code == 1168+	assert not_listed.err.contains('does not build for')169+170+	unknown := run(dir, 'build', '--platform', 'amiga')171+	assert unknown.code == 1172+	assert unknown.err.contains('amiga')173+174+	both := run(dir, 'build', '--all', '--platform', 'macos-aarch64')175+	assert both.code == 2176+	assert both.err.contains('cannot be combined')177+178+	write(dir, 'koh.build', 'command = true179+output = out/demo180+platforms = macos-aarch64, amiga181+')182+	bad_platform := run(dir, 'build')183+	assert bad_platform.code == 1184+	assert bad_platform.err.contains('amiga')185+186+	write(dir, 'koh.build', 'command = sh -c \'echo "no toolchain" >&2; exit 1\'187+output = out/demo188+platforms = ${this_platform()}189+')190+	failed := run(dir, 'build')191+	assert failed.code == 1192+	assert failed.err.contains('no toolchain')193+}194+107195 fn test_the_help_screen_lists_commands_and_no_flags() {108196 	dir := temp_repo('help')109197 	defer { os.rmdir_all(dir) or {} }

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.0.78'3+	version: '3.0.79'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file