hare

[hare] The Hare programming language
git clone https://git.torresjrjr.com/hare.git
Log | Files | Refs | README | LICENSE

release.ha (10868B)


      1 // License: GPL-3.0
      2 // (c) 2021-2022 Drew DeVault <sir@cmpwn.com>
      3 // (c) 2021 Ember Sawady <ecs@d2evs.net>
      4 // (c) 2022 Jon Eskin <eskinjp@gmail.com>
      5 use bufio;
      6 use errors;
      7 use fmt;
      8 use fs;
      9 use io;
     10 use os::exec;
     11 use os;
     12 use path;
     13 use strconv;
     14 use strings;
     15 use temp;
     16 
     17 type increment = enum {
     18 	MAJOR,
     19 	MINOR,
     20 	PATCH,
     21 };
     22 
     23 type modversion = (uint, uint, uint);
     24 type git_error = !exec::exit_status;
     25 type badversion = !void;
     26 type release_error = !(exec::error | io::error | fs::error | errors::error |
     27 	badversion | git_error);
     28 
     29 const changelog_template: str = "# This is the changelog for your release. It has been automatically pre-filled
     30 # with the changes from version {} via git-shortlog(1). Please review the
     31 # changelog below and, if necessary, add a brief note regarding any steps
     32 # required for the user to upgrade this software. It is recommended to keep this
     33 # brief and clinical, so readers can quickly understand what's changed, and to
     34 # save marketing comments for a separate release announcement.
     35 #
     36 # Any lines which begin with \"#\", like this one, are for your information
     37 # only, and will be removed from the final changelog. Edit this file to your
     38 # satisfaction, then save and close your editor.
     39 #
     40 {} version {}
     41 ";
     42 
     43 const initial_template: str = "# These are the release notes for the initial release of {0}.
     44 #
     45 # Any lines which begin with \"#\", like this one, are for your information
     46 # only, and will be removed from the final release notes. Edit this file to your
     47 # satisfaction, then save and close your editor.
     48 #
     49 {0} version {1}
     50 ";
     51 
     52 fn parseversion(in: str) (modversion | badversion) = {
     53 	const items = strings::split(in, ".");
     54 	defer free(items);
     55 	if (len(items) != 3) {
     56 		return badversion;
     57 	};
     58 	let major = 0u, minor = 0u, patch = 0u;
     59 	const ptrs = [&major, &minor, &patch];
     60 	for (let i = 0z; i < len(items); i += 1) {
     61 		*ptrs[i] = match (strconv::stou(items[i])) {
     62 		case let u: uint =>
     63 			yield u;
     64 		case =>
     65 			return badversion;
     66 		};
     67 	};
     68 	return (major, minor, patch);
     69 };
     70 
     71 fn do_release(
     72 	next: (increment | modversion),
     73 	dryrun: bool,
     74 ) (void | release_error) = {
     75 	// XXX: If we were feeling REALLY fancy we could run the diff and
     76 	// automatically detect new functions/types/etc (minor bump), breaking
     77 	// changes (major bump), or neither (patch bump). I don't feel that
     78 	// fancy, however.
     79 
     80 	// TODO: Run hare test
     81 	checkbranch()?;
     82 	checkstatus()?;
     83 	git_runcmd("fetch")?;
     84 	checkbehind()?;
     85 
     86 	// TODO: Detect if distance from the last tag is zero commits
     87 	const lasttag = match (git_readcmd("describe", "--abbrev=0")) {
     88 	case git_error =>
     89 		return do_initial_release(next);
     90 	case let err: release_error =>
     91 		return err;
     92 	case let s: str =>
     93 		yield strings::rtrim(s);
     94 	};
     95 	defer free(lasttag);
     96 
     97 	const key = choosekey()?;
     98 	defer free(key);
     99 
    100 	const current = parseversion(lasttag)?;
    101 	const new = nextversion(current, next);
    102 	const newtag = fmt::asprintf("{}.{}.{}", new.0, new.1, new.2);
    103 	defer free(newtag);
    104 	const range = fmt::asprintf("{}..HEAD", lasttag);
    105 	defer free(range);
    106 
    107 	const name = path::basename(os::getcwd());
    108 	const dir = temp::dir();
    109 	defer os::rmdirall(dir)!;
    110 	const (clfile, changelog) = temp::named(os::cwd,
    111 		dir, io::mode::WRITE, 0o644)?;
    112 	defer io::close(clfile)!;
    113 	fmt::fprintfln(clfile, changelog_template, lasttag, name, newtag)?;
    114 	shortlog(clfile, range)?;
    115 
    116 	git_runcmd("tag", "-aeF", changelog, newtag)?;
    117 	signtag(dir, name, newtag, key)?;
    118 	fmt::printfln("Tagged {} version {}. "
    119 		"Use 'git push --follow-tags' to publish the new release.",
    120 		name, newtag)!;
    121 };
    122 
    123 fn do_initial_release(ver: (modversion | increment)) (void | release_error) = {
    124 	const ver = match (ver) {
    125 	case let ver: modversion =>
    126 		yield ver;
    127 	case increment =>
    128 		fmt::errorln("Error: cannot increment version number without a previous version to reference.")!;
    129 		fmt::errorln("For the first release, try 'hare release 1.0.0' instead.")!;
    130 		os::exit(1);
    131 	};
    132 
    133 	const key = choosekey()?;
    134 	defer free(key);
    135 	const newtag = fmt::asprintf("{}.{}.{}", ver.0, ver.1, ver.2);
    136 	defer free(newtag);
    137 
    138 	const name = path::basename(os::getcwd());
    139 	const dir = temp::dir();
    140 	defer os::rmdirall(dir)!;
    141 	const (clfile, changelog) = temp::named(os::cwd,
    142 		dir, io::mode::WRITE, 0o644)?;
    143 	defer io::close(clfile)!;
    144 	fmt::fprintfln(clfile, initial_template, name, newtag)?;
    145 
    146 	git_runcmd("tag", "-aeF", changelog, newtag)?;
    147 	signtag(dir, name, newtag, key)?;
    148 	fmt::printfln("Tagged {} version {}. "
    149 		"Use 'git push --follow-tags' to publish the new release.",
    150 		name, newtag)!;
    151 };
    152 
    153 fn nextversion(
    154 	current: modversion,
    155 	next: (increment | modversion),
    156 ) modversion = {
    157 	const next = match (next) {
    158 	case let incr: increment =>
    159 		yield incr;
    160 	case let ver: modversion =>
    161 		return ver;
    162 	};
    163 	switch (next) {
    164 	case increment::MAJOR =>
    165 		return (current.0 + 1, 0, 0);
    166 	case increment::MINOR =>
    167 		return (current.0, current.1 + 1, 0);
    168 	case increment::PATCH =>
    169 		return (current.0, current.1, current.2 + 1);
    170 	};
    171 };
    172 
    173 fn checkbranch() (void | release_error) = {
    174 	const default_branch = get_defaultbranch()?;
    175 	defer free(default_branch);
    176 	const current_branch = get_currentbranch()?;
    177 	defer free(current_branch);
    178 	if (default_branch != current_branch) {
    179 		fmt::errorfln(
    180 			"Warning! You do not have the {} branch checked out.",
    181 			default_branch)!;
    182 	};
    183 };
    184 
    185 fn checkstatus() (void | release_error) = {
    186 	const status = strings::rtrim(git_readcmd("status", "-zuno")?);
    187 	defer free(status);
    188 	if (len(status) != 0) {
    189 		fmt::errorln("Warning! You have uncommitted changes.")!;
    190 	};
    191 };
    192 
    193 fn checkbehind() (void | release_error) = {
    194 	const upstream = match (git_readcmd("rev-parse", "HEAD@{upstream}")) {
    195 	case git_error =>
    196 		// Fails if there is no upstream, in which case we don't need to
    197 		// bother checking.
    198 		return;
    199 	case let err: release_error =>
    200 		return err;
    201 	case let s: str =>
    202 		yield s;
    203 	};
    204 	defer free(upstream);
    205 	const head = git_readcmd("rev-parse", "HEAD")?;
    206 	defer free(head);
    207 	if (upstream == head) {
    208 		return;
    209 	};
    210 	match (git_runcmd("merge-base", "--is-ancestor", "HEAD@{upstream}", "HEAD")) {
    211 	case git_error =>
    212 		fmt::errorln("Warning! Your local branch is behind the upstream branch.")!;
    213 	case let err: release_error =>
    214 		return err;
    215 	case => void;
    216 	};
    217 };
    218 
    219 fn shortlog(out: io::file, what: str) (void | release_error) = {
    220 	const cmd = exec::cmd("git", "shortlog", "--no-merges", what)?;
    221 	exec::addfile(&cmd, os::stdout_file, out);
    222 	const proc = exec::start(&cmd)?;
    223 	const status = exec::wait(&proc)?;
    224 	exec::check(&status)?;
    225 };
    226 
    227 fn choosekey() (str | release_error) = {
    228 	match (os::getenv("HAREKEY")) {
    229 	case void => void;
    230 	case let name: str =>
    231 		return name;
    232 	};
    233 
    234 	const paths = [
    235 		"id_ed25519",
    236 		"id_ecdsa",
    237 		"id_rsa",
    238 		"id_dsa",
    239 	];
    240 	let buf = path::init()!;
    241 	const home = os::getenv("HOME") as str;
    242 	for (let i = 0z; i < len(paths); i += 1) {
    243 		const cand = path::set(&buf, home, ".ssh", paths[i])!;
    244 		if (os::stat(cand) is fs::error) {
    245 			continue;
    246 		};
    247 		return strings::dup(cand);
    248 	};
    249 	fmt::errorln("No suitable SSH key found to sign releases with.")!;
    250 
    251 	fmt::error("Would you like to generate one now? [Y/n] ")!;
    252 	const line = match (bufio::scanline(os::stdin)?) {
    253 	case io::EOF =>
    254 		fmt::fatal("No suitable key available. Terminating.");
    255 	case let line: []u8 =>
    256 		yield strings::fromutf8(line)!;
    257 	};
    258 	defer free(line);
    259 	if (line != "" && line != "y" && line != "Y") {
    260 		fmt::fatal("No suitable key available. Terminating.");
    261 	};
    262 
    263 	const parent = path::set(&buf, home, ".ssh")!;
    264 	os::mkdirs(parent, 0o755)?;
    265 
    266 	const path = path::set(&buf, home, ".ssh", "id_ed25519")!;
    267 	const cmd = match (exec::cmd("ssh-keygen", "-t", "ed25519", "-f", path)) {
    268 	case let cmd: exec::command =>
    269 		yield cmd;
    270 	case let err: exec::error =>
    271 		fmt::fatal("ssh-keygen: command not found. Is openssh installed?");
    272 	};
    273 	const proc = exec::start(&cmd)?;
    274 	const status = exec::wait(&proc)?;
    275 	exec::check(&status)?;
    276 	fmt::println("You will be prompted to enter your password again to create the release signature.")!;
    277 	return strings::dup(path);
    278 };
    279 
    280 fn signtag(tmpdir: str, name: str, tag: str, key: str) (void | release_error) = {
    281 	// This could work without the agent if it were not for the fact that
    282 	// ssh-keygen is bloody stupid when it comes to prompting you for your
    283 	// password.
    284 	let buf = path::init()!;
    285 	const socket = path::set(&buf, tmpdir, "agent")!;
    286 	const agent = exec::cmd("ssh-agent", "-Da", socket)?;
    287 	exec::nullstd(&agent);
    288 	const agent = exec::start(&agent)?;
    289 	defer exec::kill(agent)!;
    290 
    291 	const addkey = exec::cmd("ssh-add", key)?;
    292 	exec::setenv(&addkey, "SSH_AUTH_SOCK", socket)!;
    293 	const addkey = exec::start(&addkey)?;
    294 	const addkey = exec::wait(&addkey)?;
    295 	exec::check(&addkey)?;
    296 
    297 	const prefix = fmt::asprintf("--prefix={}-{}/", name, tag);
    298 	defer free(prefix);
    299 	const archive = exec::cmd("git", "archive",
    300 		"--format=tar.gz", prefix, tag)?;
    301 	const ssh = exec::cmd("ssh-keygen",
    302 		"-Y", "sign", "-f", key, "-n", "file")?;
    303 	const note = exec::cmd("git", "notes", "add", "-F", "-", tag)?;
    304 	exec::setenv(&note, "GIT_NOTES_REF", "refs/notes/signatures/tar.gz")!;
    305 
    306 	exec::setenv(&ssh, "SSH_AUTH_SOCK", socket)!;
    307 	// Squelch "Signing data on standard input" message
    308 	// TODO: It might be better to capture this and print it to stderr
    309 	// ourselves if ssh-keygen exits nonzero, so that the error details are
    310 	// available to the user for diagnosis.
    311 	exec::addfile(&ssh, os::stderr_file, exec::nullfd);
    312 
    313 	const pipe1 = exec::pipe();
    314 	const pipe2 = exec::pipe();
    315 	exec::addfile(&archive, os::stdout_file, pipe1.1);
    316 	exec::addfile(&ssh, os::stdin_file, pipe1.0);
    317 	exec::addfile(&ssh, os::stdout_file, pipe2.1);
    318 	exec::addfile(&note, os::stdin_file, pipe2.0);
    319 	const archive = exec::start(&archive)?;
    320 	const ssh = exec::start(&ssh)?;
    321 	const note = exec::start(&note)?;
    322 	io::close(pipe1.0)?;
    323 	io::close(pipe1.1)?;
    324 	io::close(pipe2.0)?;
    325 	io::close(pipe2.1)?;
    326 	exec::check(&exec::wait(&archive)?)?;
    327 	exec::check(&exec::wait(&ssh)?)?;
    328 	exec::check(&exec::wait(&note)?)?;
    329 };
    330 
    331 fn git_runcmd(args: str...) (void | release_error) = {
    332 	const cmd = exec::cmd("git", args...)?;
    333 	exec::addfile(&cmd, os::stderr_file, exec::nullfd);
    334 	const proc = exec::start(&cmd)?;
    335 	const status = exec::wait(&proc)?;
    336 	return exec::check(&status)?;
    337 };
    338 
    339 fn git_readcmd(args: str...) (str | release_error) = {
    340 	const pipe = exec::pipe();
    341 	defer io::close(pipe.0)!;
    342 	const cmd = exec::cmd("git", args...)?;
    343 	exec::addfile(&cmd, os::stdout_file, pipe.1);
    344 	exec::addfile(&cmd, os::stderr_file, exec::nullfd);
    345 	const proc = exec::start(&cmd)?;
    346 	io::close(pipe.1)?;
    347 	const result = io::drain(pipe.0)?;
    348 	const status = exec::wait(&proc)?;
    349 	exec::check(&status)?;
    350 	return strings::fromutf8(result)!;
    351 };
    352 
    353 fn get_defaultbranch() (str | release_error) = {
    354 	const branch = git_readcmd("config",
    355 		"--default", "master", "init.defaultBranch")?;
    356 	return strings::rtrim(branch);
    357 };
    358 
    359 fn get_currentbranch() (str | release_error) = {
    360 	return strings::rtrim(git_readcmd("branch", "--show-current")?);
    361 };