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
↓ koh-ba2f1a3b.tar.gzatlas at this save
files changed10 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 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+}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+}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+}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 {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)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+}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 {} }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 }