release.v

5.7 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	// Every build runs before the first upload, so two platforms landing on one72	// path would publish the second build's bytes for both. This is checked73	// before anything runs, so a collision builds nothing at all.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		// Read once: both hashes come from the same bytes, and a release file89		// can be tens of megabytes.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	// The record is what makes the version real. `put_if_absent`, so a record105	// already there stays: published versions never change.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}113114// describe is one file's record, hashed from the bytes the upload sends.115fn 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}128129// good_name is Kepr's rule for a release file name: what Kepr refuses to read,130// Koh must not write.131fn 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}137138// upload puts one file at its key. A key that is already there is a retry of an139// interrupted publish when the bytes match, and a refusal when they do not:140// published versions never change (§11.2).141fn 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}151152// move_latest swaps the version pointer, which is the last step: until it moves,153// nothing about this version is visible.154fn 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}160161// check_notes is Koh 2's rule for `--notes`, applied when one is given: a note162// that says nothing is worse than none, so a short one is refused. No notes at163// all is ordinary — the release then shows its save's message in Kepr.164pub 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}