12345678910module release1112import crypto.sha25613import json214import os15import time16import src.build17import src.exchange18import src.history19import src.machine20import src.remote21import src.storage22232425pub fn release_key(repo string, version string) string {26 return '${repo}/releases/${version}/release.json'27}2829pub fn file_key(repo string, version string, platform string, name string) string {30 return '${repo}/releases/${version}/${platform}/${name}'31}3233pub fn latest_key(repo string) string {34 return '${repo}/releases/latest'35}363738pub struct File {39pub:40 platform string41 name string42 size i6443 blake3 string44 sha256 string45}4647pub struct Record {48pub:49 format int = 150 repo string51 version string52 save string53 published_at i6454 machine string55 notes string56 files []File57}5859pub struct Published {60pub:61 version string62 files []File63}6465666768pub 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 78 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 94 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}102103104fn 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}117118119120fn 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}126127128129130fn 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}140141142143fn 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}149150151152153pub 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}