e042d2bfc1ff

publish releases: build every platform before uploading, write the files, the record and releases/latest, and rename kepr.build in migrate (3.0.82)

time
2026-09-27 10:26 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
koh 3 phases 6-8
parent
819e88eff38d
9 files VERSION+1 −1src/cli/cmd_migrate.v+5 −0src/cli/cmd_offer.v+67 −3src/exchange/steal.v+1 −1src/migrate/migrate.v+29 −0src/migrate/migrate_test.v+27 −0src/release/release.v+162 −0src/release/release_test.v+208 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.811+3.0.82

view file

src/cli/cmd_migrate.v+5 −0
@@ -33,6 +33,7 @@3333 			objects:      report.objects3434 			head:         report.head3535 			ignore_moved: report.ignore_carried36+			build_file:   report.build_renamed3637 		}))3738 		return 03839 	}@@ -42,6 +43,9 @@4243 	if report.ignore_carried {4344 		note('carried the ignore rules to ${snapshot.ignore_file}')4445 	}46+	if report.build_renamed {47+		note('renamed kepr.build to koh.build, and saved that')48+	}4549 	if report.log_md_was_tracked {4650 		warn('LOG.md was tracked by Koh 2; koh writes it itself now, so it is no longer snapshotted')4751 	}@@ -61,4 +65,5 @@6165 	objects      int6266 	head         string6367 	ignore_moved bool68+	build_file   bool6469 }

view file

src/cli/cmd_offer.v+67 −3
@@ -1,23 +1,35 @@1-//! `koh offer` — send the local saves to this machine's remote.1+//! `koh offer` — send the local saves to this machine's remote, and, with2+//! `--builds`, publish a release built from them.23 34 module cli45 56 import json267 import os8+import src.build9+import src.exchange10+import src.fmt11+import src.history712 import src.machine13+import src.release814 import src.remote915 import src.storage10-import src.exchange1116 1217 pub fn cmd_offer(raw []string) int {1318 	args := parse('offer', raw, Options{14-		flags: ['json']19+		values: ['notes']20+		flags:  ['json', 'builds']1521 	}) or { return usage_error(err.msg()) }22+	if args.value('notes') != '' && !args.flag('builds') {23+		return usage_error('--notes belongs to --builds')24+	}1625 	store := open_store() or { return refuse(args.json(), err.msg()) }1726 	defer {1827 		store.close()1928 	}2029 	mut backend := this_machine_backend() or { return refuse(args.json(), err.msg()) }30+	if args.flag('builds') {31+		return offer_builds(args, store, mut backend)32+	}2133 	report := exchange.offer(store, mut backend) or { return refuse(args.json(), err.msg()) }2234 	if args.json() {2335 		emit_json(json2.encode(OfferedJson{@@ -38,6 +50,45 @@3850 	return 03951 }4052 53+// offer_builds is `koh offer --builds`: the history goes up first, then a54+// release built from the working tree. Unsaved changes are refused, so the55+// published files always belong to a save that is on the remote.56+fn offer_builds(args Args, store &storage.Store, mut backend remote.Backend) int {57+	head := history.head(store) or { return refuse(args.json(), exchange.no_saves) }58+	if exchange.dirty(store, head) or { true } {59+		return refuse(args.json(), 'save or checkpoint first — a release is built from a save')60+	}61+	report := exchange.offer(store, mut backend) or { return refuse(args.json(), err.msg()) }62+	config := build.read_config(store.root) or { return refuse(args.json(), err.msg()) }63+	// Not `version`: that is the koh binary's own version, in this module.64+	release_version := build.project_version(store.root) or {65+		return refuse(args.json(), err.msg())66+	}67+	notes := args.value('notes')68+	release.check_notes(notes) or { return refuse(args.json(), err.msg()) }69+	published := release.publish(store, mut backend, report.repo, config, release_version, notes) or {70+		return refuse(args.json(), err.msg())71+	}72+	if args.json() {73+		emit_json(json2.encode(PublishedJson{74+			repo:    report.repo75+			version: published.version76+			files:   published.files.map(PublishedFileJson{77+				platform: it.platform78+				name:     it.name79+				size:     it.size80+				blake3:   it.blake381+			})82+		}))83+		return 084+	}85+	for file in published.files {86+		ok('${file.platform}  ${file.name}  (${fmt.human_bytes(file.size)})')87+	}88+	ok('published ${published.version} of "${report.repo}" from [${report.head}]')89+	return 090+}91+4192 // this_machine_backend builds the backend from the machine's saved remote.4293 pub fn this_machine_backend() !remote.Backend {4394 	settings := machine.load_remote()!@@ -85,4 +136,17 @@85136 	objects int86137 	events  int87138 	head    string139+}140+141+struct PublishedFileJson {142+	platform string143+	name     string144+	size     i64145+	blake3   string146+}147+148+struct PublishedJson {149+	repo    string150+	version string151+	files   []PublishedFileJson88152 }

view file

src/exchange/steal.v+1 −1
@@ -421,7 +421,7 @@421421 // dirty reports whether the working tree holds anything a steal would422422 // overwrite. With no saves yet, that is every file but the untouched ignore423423 // list `koh init` wrote.424-fn dirty(store &storage.Store, head ?history.Save) !bool {424+pub fn dirty(store &storage.Store, head ?history.Save) !bool {425425 	files := snapshot.walk(store, store.root, snapshot.load(store.root))!426426 	if saved := head {427427 		return !snapshot.same_tree(history.files_of(store, saved.id)!, files)

view file

src/migrate/migrate.v+29 −0
@@ -12,6 +12,7 @@1212 import encoding.base641313 import json21414 import os15+import src.build1516 import src.events1617 import src.history1718 import src.snapshot@@ -19,6 +20,9 @@1920 2021 pub const koh2_dir = '.koh'2122 pub const archive_dir = '.koh-v2'23+// Koh 2's build file, renamed to `koh.build` (build.config_name) by the24+// migration, which is the name Koh 3 reads.25+const kepr_build_file = 'kepr.build'2226 pub const staging_file = '.koh-migrate'2327 pub const default_lane = 'dev'2428 @@ -61,6 +65,7 @@6165 	head               string6266 	ignore_carried     bool6367 	log_md_was_tracked bool68+	build_renamed      bool6469 }6570 6671 // run converts the repo at `root`. `lane` picks which line to import: `dev`@@ -111,6 +116,7 @@111116 		return error(failure)112117 	}113118 	swap(root, mut report)!119+	rename_build_file(root, mut report)!114120 	report.saves = line.len115121 	for manifest in line {116122 		if manifest.is_checkpoint {@@ -409,6 +415,29 @@409415 		os.write_file(ignore_target, body)!410416 		report.ignore_carried = true411417 	}418+}419+420+// rename_build_file finishes the move to `koh.build`, and records it as a save:421+// the migration must not leave the working tree dirty with a rename the next422+// save would otherwise pick up as unreviewed work.423+fn rename_build_file(root string, mut report Report) ! {424+	old := os.join_path(root, kepr_build_file)425+	if !os.is_file(old) {426+		return427+	}428+	fresh := os.join_path(root, build.config_name)429+	if os.exists(fresh) {430+		return error('both ${kepr_build_file} and ${build.config_name} exist — keep the one you want and migrate again')431+	}432+	os.mv(old, fresh, os.MvParams{})!433+	mut store := storage.open(root)!434+	defer {435+		store.close()436+	}437+	history.save_worktree(store, root, snapshot.load(root), history.SaveInput{438+		message: 'migrate: rename ${kepr_build_file} to ${build.config_name}'439+	})!440+	report.build_renamed = true412441 }413442 414443 // bare_hash drops the "blake3:" prefix Koh 2 wrote in its manifests.

view file

src/migrate/migrate_test.v+27 −0
@@ -169,6 +169,33 @@169169 	assert changed == ['.kohignore']170170 }171171 172+// §13: the migration renames `kepr.build` to `koh.build`, and records the173+// rename as a save, so the tree it leaves behind holds no unreviewed work.174+fn test_migration_renames_the_koh_2_build_file() {175+	koh2 := koh2_binary() or { return }176+	fixture := make_fixture(koh2)!177+	defer {178+		os.rmdir_all(fixture.dir) or {}179+	}180+	build_file := 'command = true\noutput = out/demo\nplatforms = macos-aarch64\n'181+	os.write_file(os.join_path(fixture.dir, 'kepr.build'), build_file)!182+	sh(fixture.dir, '${koh2} save "add the build file"')!183+184+	report := run(fixture.dir, default_lane)!185+	assert report.build_renamed186+	assert !os.exists(os.join_path(fixture.dir, 'kepr.build'))187+	assert os.read_file(os.join_path(fixture.dir, 'koh.build'))! == build_file188+189+	store := storage.open(fixture.dir)!190+	defer {191+		store.close()192+	}193+	head := history.head(store) or { panic('no head after migrating') }194+	assert head.message == 'migrate: rename kepr.build to koh.build'195+	files := snapshot.walk(store, fixture.dir, snapshot.load(fixture.dir))!196+	assert snapshot.same_tree(history.files_of(store, head.id)!, files)197+}198+172199 fn test_migrate_refuses_a_tree_that_does_not_match_the_last_save() {173200 	koh2 := koh2_binary() or { return }174201 	fixture := make_fixture(koh2)!

view file

src/release/release.v+162 −0
@@ -0,0 +1,162 @@1+//! Releases: the built files, the record that names them, and their keys in the2+//! bucket.3+//!4+//! A version exists the moment its `release.json` lands (§11.2), so history goes5+//! up first, then every file, then the record, and `releases/latest` moves last.6+//! Kepr reads all of it back field by field, so this record and `ReleaseRecord`7+//! in Kepr's `src/bucket/repo.ts` are one contract: a name here that Kepr8+//! refuses would fail the whole repo's sync.9+10+module release11+12+import crypto.sha25613+import json214+import os15+import time16+import src.build17+import src.exchange18+import src.history19+import src.machine20+import src.remote21+import src.storage22+23+// The version a key is built from is validated where it is read24+// (`build.project_version`), so it cannot carry a path anywhere.25+pub fn release_key(repo string, version string) string {26+	return '${repo}/releases/${version}/release.json'27+}28+29+pub fn file_key(repo string, version string, platform string, name string) string {30+	return '${repo}/releases/${version}/${platform}/${name}'31+}32+33+pub fn latest_key(repo string) string {34+	return '${repo}/releases/latest'35+}36+37+// One built file, as Kepr reads it.38+pub struct File {39+pub:40+	platform string41+	name     string42+	size     i6443+	blake3   string44+	sha256   string45+}46+47+pub struct Record {48+pub:49+	format       int = 150+	repo         string51+	version      string52+	save         string53+	published_at i6454+	machine      string55+	notes        string56+	files        []File57+}58+59+pub struct Published {60+pub:61+	version string62+	files   []File63+}64+65+// publish builds every platform, then uploads the files, the record, and the66+// new latest. Every build runs before the first upload: one platform failing67+// must leave the bucket exactly as it was.68+pub fn publish(store &storage.Store, mut backend remote.Backend, repo string,69+	config build.Config, version string, notes string) !Published {70+	head := history.head(store) or { return error(exchange.no_saves) }71+	mut built := []build.Built{}72+	for platform in config.platforms {73+		built << build.build_one(store.root, config, platform, version)!74+	}75+	mut files := []File{}76+	for artifact in built {77+		// Read once: both hashes come from the same bytes, and a release file78+		// can be tens of megabytes.79+		bytes := os.read_file(os.join_path(store.root, artifact.path))!.bytes()80+		file := describe(artifact, bytes)!81+		upload(mut backend, repo, version, file, bytes)!82+		files << file83+	}84+	record := Record{85+		repo:         repo86+		version:      version87+		save:         head.id88+		published_at: time.now().unix()89+		machine:      machine.name()90+		notes:        notes91+		files:        files92+	}93+	// The record is what makes the version real. `put_if_absent`, so a record94+	// already there stays: published versions never change.95+	backend.put_if_absent(release_key(repo, version), json2.encode(record).bytes())!96+	move_latest(mut backend, repo, version)!97+	return Published{98+		version: version99+		files:   files100+	}101+}102+103+// describe is one file's record, hashed from the bytes the upload sends.104+fn describe(artifact build.Built, bytes []u8) !File {105+	name := os.base(artifact.path)106+	if !good_name(name) {107+		return error('"${name}" cannot name a release file — no slash, backslash, quote or control character, and not "." or ".."')108+	}109+	return File{110+		platform: artifact.platform111+		name:     name112+		size:     i64(bytes.len)113+		blake3:   storage.hash_hex(bytes)114+		sha256:   sha256.sum256(bytes).hex()115+	}116+}117+118+// good_name is Kepr's rule for a release file name: what Kepr refuses to read,119+// Koh must not write.120+fn good_name(name string) bool {121+	if name == '' || name.len > 255 || name == '.' || name == '..' {122+		return false123+	}124+	return !name.bytes().any(it == `/` || it == `\\` || it == `"` || it < 32 || it == 127)125+}126+127+// upload puts one file at its key. A key that is already there is a retry of an128+// interrupted publish when the bytes match, and a refusal when they do not:129+// published versions never change (§11.2).130+fn upload(mut backend remote.Backend, repo string, version string, file File, bytes []u8) ! {131+	key := file_key(repo, version, file.platform, file.name)132+	if backend.put_if_absent(key, bytes)! {133+		return134+	}135+	existing := backend.get(key)!136+	if !existing.found || storage.hash_hex(existing.body) != file.blake3 {137+		return error('version ${version} already has a different ${file.name}')138+	}139+}140+141+// move_latest swaps the version pointer, which is the last step: until it moves,142+// nothing about this version is visible.143+fn move_latest(mut backend remote.Backend, repo string, version string) ! {144+	current := backend.get(latest_key(repo))!145+	if !backend.put_if_match(latest_key(repo), version.bytes(), current.tag)! {146+		return error('${repo}/releases/latest moved while publishing — run it again')147+	}148+}149+150+// check_notes is Koh 2's rule for `--notes`, applied when one is given: a note151+// that says nothing is worse than none, so a short one is refused. No notes at152+// all is ordinary — the release then shows its save's message in Kepr.153+pub fn check_notes(notes string) ! {154+	trimmed := notes.trim_space()155+	if trimmed == '' {156+		return157+	}158+	words := trimmed.split_any(' \t\r\n').filter(it != '').len159+	if trimmed.len < 40 || words < 12 {160+		return error('--notes must be at least 40 characters and at least 12 words; that one is ${trimmed.len} characters and ${words} words')161+	}162+}

view file

src/release/release_test.v+208 −0
@@ -0,0 +1,208 @@1+//! Folder-backend tests for publishing: what lands in the bucket, what a retry2+//! does, what a second build of the same version does, and the notes rule.3+4+module release5+6+import json27+import os8+import time9+import src.build10+import src.history11+import src.machine12+import src.remote13+import src.snapshot14+import src.storage15+16+fn scratch(name string) !string {17+	dir := os.join_path(os.temp_dir(), 'koh3_release_${os.getpid()}_${name}')18+	os.rmdir_all(dir) or {}19+	os.mkdir_all(dir, os.MkdirParams{})!20+	return dir21+}22+23+fn write(root string, rel string, text string) ! {24+	full := os.join_path(root, rel)25+	os.mkdir_all(os.dir(full), os.MkdirParams{})!26+	os.write_file(full, text)!27+}28+29+fn open_repo(name string) !storage.Store {30+	dir := os.join_path(os.temp_dir(), 'koh3_release_proj_${os.getpid()}_${name}')31+	os.rmdir_all(dir) or {}32+	os.mkdir_all(dir, os.MkdirParams{})!33+	mut store := storage.create(dir)!34+	write(dir, '.kohignore', '*.log\n')!35+	return store36+}37+38+fn make_save(store &storage.Store, message string) !history.Save {39+	return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{40+		message: message41+	})!42+}43+44+// The build writes the file `output` names, by hand: only `output` takes the45+// {version} and {platform} tokens.46+fn demo_config(store &storage.Store, platforms string) !build.Config {47+	write(store.root, 'VERSION', '1.2.3\n')!48+	mut command := 'mkdir -p out'49+	for platform in platforms.split(',') {50+		trimmed := platform.trim_space()51+		command += ' && printf koh > out/demo-1.2.3-${trimmed}'52+	}53+	write(store.root, 'koh.build', 'command = ${command}54+output = out/demo-{version}-{platform}55+platforms = ${platforms}56+')!57+	return build.read_config(store.root)!58+}59+60+fn test_publishing_writes_the_files_the_record_and_latest() {61+	base := scratch('publish')!62+	defer {63+		os.rmdir_all(base) or {}64+	}65+	mut store := open_repo('publish')!66+	defer {67+		store.close()68+	}69+	config := demo_config(store, 'macos-aarch64')!70+	head := make_save(store, 'first')!71+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!72+73+	published := publish(store, mut backend, 'proj', config, '1.2.3', '')!74+	assert published.version == '1.2.3'75+	assert published.files.len == 176+77+	name := 'demo-1.2.3-macos-aarch64'78+	artifact := backend.get(file_key('proj', '1.2.3', 'macos-aarch64', name))!79+	assert artifact.found80+	assert artifact.body.bytestr() == 'koh'81+82+	record := json2.decode[Record](backend.get(release_key('proj', '1.2.3'))!.body.bytestr())!83+	assert record.format == 184+	assert record.repo == 'proj'85+	assert record.version == '1.2.3'86+	assert record.save == head.id87+	assert record.machine == machine.name()88+	assert record.notes == ''89+	assert record.files.len == 190+	assert record.files[0].platform == 'macos-aarch64'91+	assert record.files[0].name == name92+	assert record.files[0].size == 393+	assert record.files[0].blake3 == storage.hash_hex('koh'.bytes())94+	assert record.files[0].sha256.len == 6495+	// The record is what makes the version exist, and latest moves last.96+	assert backend.get(latest_key('proj'))!.body.bytestr() == '1.2.3'97+}98+99+// An interrupted publish leaves files behind; running it again finishes it,100+// because the same bytes are the same release.101+fn test_a_publish_retries_after_a_partial_upload() {102+	base := scratch('retry')!103+	defer {104+		os.rmdir_all(base) or {}105+	}106+	mut store := open_repo('retry')!107+	defer {108+		store.close()109+	}110+	config := demo_config(store, 'macos-aarch64, linux-aarch64')!111+	make_save(store, 'first')!112+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!113+114+	publish(store, mut backend, 'proj', config, '1.2.3', '')!115+	// Stand in for a run that stopped before the record and the pointer: both116+	// are removed, the uploaded files stay.117+	backend.delete(release_key('proj', '1.2.3'))!118+	backend.delete(latest_key('proj'))!119+	again := publish(store, mut backend, 'proj', config, '1.2.3', '')!120+	assert again.files.len == 2121+	assert backend.get(release_key('proj', '1.2.3'))!.found122+	assert backend.get(latest_key('proj'))!.body.bytestr() == '1.2.3'123+	assert backend.get(file_key('proj', '1.2.3', 'macos-aarch64', 'demo-1.2.3-macos-aarch64'))!.found124+	assert backend.get(file_key('proj', '1.2.3', 'linux-aarch64', 'demo-1.2.3-linux-aarch64'))!.found125+}126+127+// A published version never changes: a second build with different bytes for128+// the same platform is refused before anything is uploaded.129+fn test_a_different_file_for_a_published_version_is_refused() {130+	base := scratch('changed')!131+	defer {132+		os.rmdir_all(base) or {}133+	}134+	mut store := open_repo('changed')!135+	defer {136+		store.close()137+	}138+	config := demo_config(store, 'macos-aarch64')!139+	make_save(store, 'first')!140+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!141+	name := 'demo-1.2.3-macos-aarch64'142+	backend.put_if_absent(file_key('proj', '1.2.3', 'macos-aarch64', name), 'other'.bytes())!143+144+	if _ := publish(store, mut backend, 'proj', config, '1.2.3', '') {145+		assert false, 'a different file for a published version must be refused'146+	} else {147+		assert err.msg().contains('already has a different ${name}')148+	}149+	// Nothing else was written: no record, and latest never moved.150+	assert !backend.get(release_key('proj', '1.2.3'))!.found151+	assert !backend.get(latest_key('proj'))!.found152+}153+154+// One platform failing stops the release before the first upload.155+fn test_a_failed_build_uploads_nothing() {156+	base := scratch('failed')!157+	defer {158+		os.rmdir_all(base) or {}159+	}160+	mut store := open_repo('failed')!161+	defer {162+		store.close()163+	}164+	// The command writes only the macos file, so the linux build produces165+	// nothing and the whole release stops there.166+	write(store.root, 'VERSION', '1.2.3\n')!167+	write(store.root, 'koh.build', 'command = mkdir -p out && printf koh > out/demo-1.2.3-macos-aarch64168+output = out/demo-{version}-{platform}169+platforms = macos-aarch64, linux-aarch64170+')!171+	config := build.read_config(store.root)!172+	make_save(store, 'first')!173+	mut backend := remote.new_folder(os.join_path(base, 'remote'))!174+175+	if _ := publish(store, mut backend, 'proj', config, '1.2.3', '') {176+		assert false, 'a build that produced nothing must stop the release'177+	} else {178+		assert err.msg().contains('did not create')179+	}180+	assert backend.list('proj/releases')!.len == 0181+}182+183+// A folder where a file belongs, or a name Kepr would refuse to read, is184+// refused here: Kepr refuses the whole repo's sync over one bad record.185+fn test_a_release_file_name_kepr_would_refuse_is_refused() {186+	assert good_name('demo-1.2.3-android-arm64.apk')187+	assert good_name('Haven.app.zip')188+	assert !good_name('')189+	assert !good_name('.')190+	assert !good_name('..')191+	assert !good_name('a/b')192+	assert !good_name('a\\b')193+	assert !good_name('a"b')194+	assert !good_name('a\x07b')195+	assert !good_name('x'.repeat(256))196+}197+198+fn test_the_notes_rule_is_koh_2s() {199+	check_notes('')!200+	check_notes('   ')!201+	check_notes('fixed the crash on startup when the config file was missing entirely')!202+	if _ := check_notes('too short to say anything at all') {203+		assert false, 'notes under 40 characters must be refused'204+	}205+	if _ := check_notes('this note is long enough in characters but few words') {206+		assert false, 'notes under 12 words must be refused'207+	}208+}

view file

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

view file