//! Releases: the built files, the record that names them, and their keys in the //! bucket. //! //! A version exists the moment its `release.json` lands (§11.2), so history goes //! up first, then every file, then the record, and `releases/latest` moves last. //! Kepr reads all of it back field by field, so this record and `ReleaseRecord` //! in Kepr's `src/bucket/repo.ts` are one contract: a name here that Kepr //! refuses would fail the whole repo's sync. module release import crypto.sha256 import json2 import os import time import src.build import src.exchange import src.history import src.machine import src.remote import src.storage // The version a key is built from is validated where it is read // (`build.project_version`), so it cannot carry a path anywhere. pub fn release_key(repo string, version string) string { return '${repo}/releases/${version}/release.json' } pub fn file_key(repo string, version string, platform string, name string) string { return '${repo}/releases/${version}/${platform}/${name}' } pub fn latest_key(repo string) string { return '${repo}/releases/latest' } // One built file, as Kepr reads it. pub struct File { pub: platform string name string size i64 blake3 string sha256 string } pub struct Record { pub: format int = 1 repo string version string save string published_at i64 machine string notes string files []File } pub struct Published { pub: version string files []File } // publish builds every platform, then uploads the files, the record, and the // new latest. Every build runs before the first upload: one platform failing // must leave the bucket exactly as it was. pub fn publish(store &storage.Store, mut backend remote.Backend, repo string, config build.Config, version string, notes string) !Published { head := history.head(store) or { return error(exchange.no_saves) } // Every build runs before the first upload, so two platforms landing on one // path would publish the second build's bytes for both. This is checked // before anything runs, so a collision builds nothing at all. mut paths := map[string]bool{} for platform in config.platforms { path := build.output_path(config, platform, version) if path in paths { return error('two platforms build to the same file, ${path} — name the platform in ${build.config_name}\'s output, as "out/demo-{platform}" does') } paths[path] = true } mut built := []build.Built{} for platform in config.platforms { built << build.build_one(store.root, config, platform, version)! } mut files := []File{} for artifact in built { // Read once: both hashes come from the same bytes, and a release file // can be tens of megabytes. bytes := os.read_file(os.join_path(store.root, artifact.path))!.bytes() file := describe(artifact, bytes)! upload(mut backend, repo, version, file, bytes)! files << file } record := Record{ repo: repo version: version save: head.id published_at: time.now().unix() machine: machine.name() notes: notes files: files } // The record is what makes the version real. `put_if_absent`, so a record // already there stays: published versions never change. backend.put_if_absent(release_key(repo, version), json2.encode(record).bytes())! move_latest(mut backend, repo, version)! return Published{ version: version files: files } } // describe is one file's record, hashed from the bytes the upload sends. fn describe(artifact build.Built, bytes []u8) !File { name := os.base(artifact.path) if !good_name(name) { return error('"${name}" cannot name a release file — no slash, backslash, quote or control character, and not "." or ".."') } return File{ platform: artifact.platform name: name size: i64(bytes.len) blake3: storage.hash_hex(bytes) sha256: sha256.sum256(bytes).hex() } } // good_name is Kepr's rule for a release file name: what Kepr refuses to read, // Koh must not write. fn good_name(name string) bool { if name == '' || name.len > 255 || name == '.' || name == '..' { return false } return !name.bytes().any(it == `/` || it == `\\` || it == `"` || it < 32 || it == 127) } // upload puts one file at its key. A key that is already there is a retry of an // interrupted publish when the bytes match, and a refusal when they do not: // published versions never change (§11.2). fn upload(mut backend remote.Backend, repo string, version string, file File, bytes []u8) ! { key := file_key(repo, version, file.platform, file.name) if backend.put_if_absent(key, bytes)! { return } existing := backend.get(key)! if !existing.found || storage.hash_hex(existing.body) != file.blake3 { return error('version ${version} already has a different ${file.name}') } } // move_latest swaps the version pointer, which is the last step: until it moves, // nothing about this version is visible. fn move_latest(mut backend remote.Backend, repo string, version string) ! { current := backend.get(latest_key(repo))! if !backend.put_if_match(latest_key(repo), version.bytes(), current.tag)! { return error('${repo}/releases/latest moved while publishing — run it again') } } // check_notes is Koh 2's rule for `--notes`, applied when one is given: a note // that says nothing is worse than none, so a short one is refused. No notes at // all is ordinary — the release then shows its save's message in Kepr. pub fn check_notes(notes string) ! { trimmed := notes.trim_space() if trimmed == '' { return } words := trimmed.split_any(' \t\r\n').filter(it != '').len if trimmed.len < 40 || words < 12 { return error('--notes must be at least 40 characters and at least 12 words; that one is ${trimmed.len} characters and ${words} words') } }