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
↓ koh-e042d2bf.tar.gzatlas at this save
files changed9 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 −1src/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 }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 }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)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.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)!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+}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+}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 }