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 72 73 74 mut paths := map[string]bool{}75 for platform in config.platforms {76 path := build.output_path(config, platform, version)77 if path in paths {78 return error('two platforms build to the same file, ${path} — name the platform in ${build.config_name}\'s output, as "out/demo-{platform}" does')79 }80 paths[path] = true81 }82 mut built := []build.Built{}83 for platform in config.platforms {84 built << build.build_one(store.root, config, platform, version)!85 }86 mut files := []File{}87 for artifact in built {88 89 90 bytes := os.read_file(os.join_path(store.root, artifact.path))!.bytes()91 file := describe(artifact, bytes)!92 upload(mut backend, repo, version, file, bytes)!93 files << file94 }95 record := Record{96 repo: repo97 version: version98 save: head.id99 published_at: time.now().unix()100 machine: machine.name()101 notes: notes102 files: files103 }104 105 106 backend.put_if_absent(release_key(repo, version), json2.encode(record).bytes())!107 move_latest(mut backend, repo, version)!108 return Published{109 version: version110 files: files111 }112}113114115fn describe(artifact build.Built, bytes []u8) !File {116 name := os.base(artifact.path)117 if !good_name(name) {118 return error('"${name}" cannot name a release file — no slash, backslash, quote or control character, and not "." or ".."')119 }120 return File{121 platform: artifact.platform122 name: name123 size: i64(bytes.len)124 blake3: storage.hash_hex(bytes)125 sha256: sha256.sum256(bytes).hex()126 }127}128129130131fn good_name(name string) bool {132 if name == '' || name.len > 255 || name == '.' || name == '..' {133 return false134 }135 return !name.bytes().any(it == `/` || it == `\\` || it == `"` || it < 32 || it == 127)136}137138139140141fn upload(mut backend remote.Backend, repo string, version string, file File, bytes []u8) ! {142 key := file_key(repo, version, file.platform, file.name)143 if backend.put_if_absent(key, bytes)! {144 return145 }146 existing := backend.get(key)!147 if !existing.found || storage.hash_hex(existing.body) != file.blake3 {148 return error('version ${version} already has a different ${file.name}')149 }150}151152153154fn move_latest(mut backend remote.Backend, repo string, version string) ! {155 current := backend.get(latest_key(repo))!156 if !backend.put_if_match(latest_key(repo), version.bytes(), current.tag)! {157 return error('${repo}/releases/latest moved while publishing — run it again')158 }159}160161162163164pub fn check_notes(notes string) ! {165 trimmed := notes.trim_space()166 if trimmed == '' {167 return168 }169 words := trimmed.split_any(' \t\r\n').filter(it != '').len170 if trimmed.len < 40 || words < 12 {171 return error('--notes must be at least 40 characters and at least 12 words; that one is ${trimmed.len} characters and ${words} words')172 }173}