//! Folder-backend tests for publishing: what lands in the bucket, what a retry //! does, what a second build of the same version does, and the notes rule. module release import json2 import os import time import src.build import src.history import src.machine import src.remote import src.snapshot import src.storage // Global rules must not make source fixtures depend on the developer’s home. fn testsuite_begin() { os.setenv('HOME', os.join_path(os.temp_dir(), 'koh3-test-home-${os.getpid()}'), true) } fn scratch(name string) !string { dir := os.join_path(os.temp_dir(), 'koh3_release_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! return dir } fn write(root string, rel string, text string) ! { full := os.join_path(root, rel) os.mkdir_all(os.dir(full), os.MkdirParams{})! os.write_file(full, text)! } fn open_repo(name string) !storage.Store { dir := os.join_path(os.temp_dir(), 'koh3_release_proj_${os.getpid()}_${name}') os.rmdir_all(dir) or {} os.mkdir_all(dir, os.MkdirParams{})! mut store := storage.create(dir)! write(dir, '.kohignore', '*.log\n')! return store } fn make_save(store &storage.Store, message string) !history.Save { return history.save_worktree(store, store.root, snapshot.load(store.root), history.SaveInput{ message: message })! } // The build writes the file `output` names, by hand: only `output` takes the // {version} and {platform} tokens. fn demo_config(store &storage.Store, platforms string) !build.Config { write(store.root, 'VERSION', '1.2.3\n')! mut command := 'mkdir -p out' for platform in platforms.split(',') { trimmed := platform.trim_space() command += ' && printf koh > out/demo-1.2.3-${trimmed}' } write(store.root, 'koh.build', 'command = ${command} output = out/demo-{version}-{platform} platforms = ${platforms} ')! return build.read_config(store.root)! } fn test_publishing_writes_the_files_the_record_and_latest() { base := scratch('publish')! defer { os.rmdir_all(base) or {} } mut store := open_repo('publish')! defer { store.close() } config := demo_config(store, 'macos-aarch64')! head := make_save(store, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! published := publish(store, mut backend, 'proj', config, '1.2.3', '')! assert published.version == '1.2.3' assert published.files.len == 1 name := 'demo-1.2.3-macos-aarch64' artifact := backend.get(file_key('proj', '1.2.3', 'macos-aarch64', name))! assert artifact.found assert artifact.body.bytestr() == 'koh' record := json2.decode[Record](backend.get(release_key('proj', '1.2.3'))!.body.bytestr())! assert record.format == 1 assert record.repo == 'proj' assert record.version == '1.2.3' assert record.save == head.id assert record.machine == machine.name() assert record.notes == '' assert record.files.len == 1 assert record.files[0].platform == 'macos-aarch64' assert record.files[0].name == name assert record.files[0].size == 3 assert record.files[0].blake3 == storage.hash_hex('koh'.bytes()) assert record.files[0].sha256.len == 64 // The record is what makes the version exist, and latest moves last. assert backend.get(latest_key('proj'))!.body.bytestr() == '1.2.3' } // An interrupted publish leaves files behind; running it again finishes it, // because the same bytes are the same release. fn test_a_publish_retries_after_a_partial_upload() { base := scratch('retry')! defer { os.rmdir_all(base) or {} } mut store := open_repo('retry')! defer { store.close() } config := demo_config(store, 'macos-aarch64, linux-aarch64')! make_save(store, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! publish(store, mut backend, 'proj', config, '1.2.3', '')! // Stand in for a run that stopped before the record and the pointer: both // are removed, the uploaded files stay. backend.delete(release_key('proj', '1.2.3'))! backend.delete(latest_key('proj'))! again := publish(store, mut backend, 'proj', config, '1.2.3', '')! assert again.files.len == 2 assert backend.get(release_key('proj', '1.2.3'))!.found assert backend.get(latest_key('proj'))!.body.bytestr() == '1.2.3' assert backend.get(file_key('proj', '1.2.3', 'macos-aarch64', 'demo-1.2.3-macos-aarch64'))!.found assert backend.get(file_key('proj', '1.2.3', 'linux-aarch64', 'demo-1.2.3-linux-aarch64'))!.found } // Two platforms that build to one file are refused before anything runs: no // build, no upload, and the bucket is untouched. fn test_two_platforms_building_to_one_file_are_refused() { base := scratch('collision')! defer { os.rmdir_all(base) or {} } mut store := open_repo('collision')! defer { store.close() } write(store.root, 'VERSION', '1.2.3\n')! // No {platform}, so both platforms would write the same file. write(store.root, 'koh.build', 'command = mkdir -p out && printf koh > out/demo output = out/demo platforms = macos-aarch64, linux-aarch64 ')! config := build.read_config(store.root)! make_save(store, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! if _ := publish(store, mut backend, 'proj', config, '1.2.3', '') { assert false, 'two platforms building to one file must be refused' } else { assert err.msg().contains('two platforms build to the same file') } assert !os.exists(os.join_path(store.root, 'out')) assert backend.list('proj/releases')!.len == 0 } // A published version never changes: a second build with different bytes for // the same platform is refused before anything is uploaded. fn test_a_different_file_for_a_published_version_is_refused() { base := scratch('changed')! defer { os.rmdir_all(base) or {} } mut store := open_repo('changed')! defer { store.close() } config := demo_config(store, 'macos-aarch64')! make_save(store, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! name := 'demo-1.2.3-macos-aarch64' backend.put_if_absent(file_key('proj', '1.2.3', 'macos-aarch64', name), 'other'.bytes())! if _ := publish(store, mut backend, 'proj', config, '1.2.3', '') { assert false, 'a different file for a published version must be refused' } else { assert err.msg().contains('already has a different ${name}') } // Nothing else was written: no record, and latest never moved. assert !backend.get(release_key('proj', '1.2.3'))!.found assert !backend.get(latest_key('proj'))!.found } // One platform failing stops the release before the first upload. fn test_a_failed_build_uploads_nothing() { base := scratch('failed')! defer { os.rmdir_all(base) or {} } mut store := open_repo('failed')! defer { store.close() } // The command writes only the macos file, so the linux build produces // nothing and the whole release stops there. write(store.root, 'VERSION', '1.2.3\n')! write(store.root, 'koh.build', 'command = mkdir -p out && printf koh > out/demo-1.2.3-macos-aarch64 output = out/demo-{version}-{platform} platforms = macos-aarch64, linux-aarch64 ')! config := build.read_config(store.root)! make_save(store, 'first')! mut backend := remote.new_folder(os.join_path(base, 'remote'))! if _ := publish(store, mut backend, 'proj', config, '1.2.3', '') { assert false, 'a build that produced nothing must stop the release' } else { assert err.msg().contains('did not create') } assert backend.list('proj/releases')!.len == 0 } // A folder where a file belongs, or a name Kepr would refuse to read, is // refused here: Kepr refuses the whole repo's sync over one bad record. fn test_a_release_file_name_kepr_would_refuse_is_refused() { assert good_name('demo-1.2.3-android-arm64.apk') assert good_name('Haven.app.zip') assert !good_name('') assert !good_name('.') assert !good_name('..') assert !good_name('a/b') assert !good_name('a\\b') assert !good_name('a"b') assert !good_name('a\x07b') assert !good_name('x'.repeat(256)) } fn test_the_notes_rule_is_koh_2s() { check_notes('')! check_notes(' ')! check_notes('fixed the crash on startup when the config file was missing entirely')! if _ := check_notes('too short to say anything at all') { assert false, 'notes under 40 characters must be refused' } if _ := check_notes('this note is long enough in characters but few words') { assert false, 'notes under 12 words must be refused' } }