release.v

5.2 KB · raw

1//! Releases: the built files, the record that names them, and their keys in the2//! bucket.3//!4//! A version exists the moment its `release.json` lands (§11.2), so history goes5//! up first, then every file, then the record, and `releases/latest` moves last.6//! Kepr reads all of it back field by field, so this record and `ReleaseRecord`7//! in Kepr's `src/bucket/repo.ts` are one contract: a name here that Kepr8//! refuses would fail the whole repo's sync.910module release1112import crypto.sha25613import json214import os15import time16import src.build17import src.exchange18import src.history19import src.machine20import src.remote21import src.storage2223// The version a key is built from is validated where it is read24// (`build.project_version`), so it cannot carry a path anywhere.25pub 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}3637// One built file, as Kepr reads it.38pub 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}6465// publish builds every platform, then uploads the files, the record, and the66// new latest. Every build runs before the first upload: one platform failing67// must leave the bucket exactly as it was.68pub 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		// Read once: both hashes come from the same bytes, and a release file78		// can be tens of megabytes.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	// The record is what makes the version real. `put_if_absent`, so a record94	// already there stays: published versions never change.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}102103// describe is one file's record, hashed from the bytes the upload sends.104fn 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}117118// good_name is Kepr's rule for a release file name: what Kepr refuses to read,119// Koh must not write.120fn 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}126127// upload puts one file at its key. A key that is already there is a retry of an128// interrupted publish when the bytes match, and a refusal when they do not:129// published versions never change (§11.2).130fn 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}140141// move_latest swaps the version pointer, which is the last step: until it moves,142// nothing about this version is visible.143fn 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}149150// check_notes is Koh 2's rule for `--notes`, applied when one is given: a note151// that says nothing is worse than none, so a short one is refused. No notes at152// all is ordinary — the release then shows its save's message in Kepr.153pub 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}