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 mut paths := map[string]bool{}73 for platform in config.platforms {74 artifact := build.build_one(store.root, config, platform, version)!75 76 77 if artifact.path in paths {78 return error('two platforms build to the same file, ${artifact.path} — name the platform in ${build.config_name}\'s output, as "out/demo-{platform}" does')79 }80 paths[artifact.path] = true81 built << artifact82 }83 mut files := []File{}84 for artifact in built {85 86 87 bytes := os.read_file(os.join_path(store.root, artifact.path))!.bytes()88 file := describe(artifact, bytes)!89 upload(mut backend, repo, version, file, bytes)!90 files << file91 }92 record := Record{93 repo: repo94 version: version95 save: head.id96 published_at: time.now().unix()97 machine: machine.name()98 notes: notes99 files: files100 }101 102 103 backend.put_if_absent(release_key(repo, version), json2.encode(record).bytes())!104 move_latest(mut backend, repo, version)!105 return Published{106 version: version107 files: files108 }109}110111112fn describe(artifact build.Built, bytes []u8) !File {113 name := os.base(artifact.path)114 if !good_name(name) {115 return error('"${name}" cannot name a release file — no slash, backslash, quote or control character, and not "." or ".."')116 }117 return File{118 platform: artifact.platform119 name: name120 size: i64(bytes.len)121 blake3: storage.hash_hex(bytes)122 sha256: sha256.sum256(bytes).hex()123 }124}125126127128fn good_name(name string) bool {129 if name == '' || name.len > 255 || name == '.' || name == '..' {130 return false131 }132 return !name.bytes().any(it == `/` || it == `\\` || it == `"` || it < 32 || it == 127)133}134135136137138fn upload(mut backend remote.Backend, repo string, version string, file File, bytes []u8) ! {139 key := file_key(repo, version, file.platform, file.name)140 if backend.put_if_absent(key, bytes)! {141 return142 }143 existing := backend.get(key)!144 if !existing.found || storage.hash_hex(existing.body) != file.blake3 {145 return error('version ${version} already has a different ${file.name}')146 }147}148149150151fn move_latest(mut backend remote.Backend, repo string, version string) ! {152 current := backend.get(latest_key(repo))!153 if !backend.put_if_match(latest_key(repo), version.bytes(), current.tag)! {154 return error('${repo}/releases/latest moved while publishing — run it again')155 }156}157158159160161pub fn check_notes(notes string) ! {162 trimmed := notes.trim_space()163 if trimmed == '' {164 return165 }166 words := trimmed.split_any(' \t\r\n').filter(it != '').len167 if trimmed.len < 40 || words < 12 {168 return error('--notes must be at least 40 characters and at least 12 words; that one is ${trimmed.len} characters and ${words} words')169 }170}