hare

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

release.ha (10830B)


      1 // License: GPL-3.0
      2 // (c) 2021-2022 Drew DeVault <sir@cmpwn.com>
      3 // (c) 2021 Eyal 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, dir, io::mode::WRITE)?;
    111 	defer io::close(clfile)!;
    112 	fmt::fprintfln(clfile, changelog_template, lasttag, name, newtag)?;
    113 	shortlog(clfile, range)?;
    114 
    115 	git_runcmd("tag", "-aeF", changelog, newtag)?;
    116 	signtag(dir, name, newtag, key)?;
    117 	fmt::printfln("Tagged {} version {}. "
    118 		"Use 'git push --follow-tags' to publish the new release.",
    119 		name, newtag)!;
    120 };
    121 
    122 fn do_initial_release(ver: (modversion | increment)) (void | release_error) = {
    123 	const ver = match (ver) {
    124 	case let ver: modversion =>
    125 		yield ver;
    126 	case increment =>
    127 		fmt::errorln("Error: cannot increment version number without a previous version to reference.")!;
    128 		fmt::errorln("For the first release, try 'hare release 1.0.0' instead.")!;
    129 		os::exit(1);
    130 	};
    131 
    132 	const key = choosekey()?;
    133 	defer free(key);
    134 	const newtag = fmt::asprintf("{}.{}.{}", ver.0, ver.1, ver.2);
    135 	defer free(newtag);
    136 
    137 	const name = path::basename(os::getcwd());
    138 	const dir = temp::dir();
    139 	defer os::rmdirall(dir)!;
    140 	const (clfile, changelog) = temp::named(os::cwd, dir, io::mode::WRITE)?;
    141 	defer io::close(clfile)!;
    142 	fmt::fprintfln(clfile, initial_template, name, newtag)?;
    143 
    144 	git_runcmd("tag", "-aeF", changelog, newtag)?;
    145 	signtag(dir, name, newtag, key)?;
    146 	fmt::printfln("Tagged {} version {}. "
    147 		"Use 'git push --follow-tags' to publish the new release.",
    148 		name, newtag)!;
    149 };
    150 
    151 fn nextversion(
    152 	current: modversion,
    153 	next: (increment | modversion),
    154 ) modversion = {
    155 	const next = match (next) {
    156 	case let incr: increment =>
    157 		yield incr;
    158 	case let ver: modversion =>
    159 		return ver;
    160 	};
    161 	switch (next) {
    162 	case increment::MAJOR =>
    163 		return (current.0 + 1, 0, 0);
    164 	case increment::MINOR =>
    165 		return (current.0, current.1 + 1, 0);
    166 	case increment::PATCH =>
    167 		return (current.0, current.1, current.2 + 1);
    168 	};
    169 };
    170 
    171 fn checkbranch() (void | release_error) = {
    172 	const default_branch = get_defaultbranch()?;
    173 	defer free(default_branch);
    174 	const current_branch = get_currentbranch()?;
    175 	defer free(current_branch);
    176 	if (default_branch != current_branch) {
    177 		fmt::errorfln(
    178 			"Warning! You do not have the {} branch checked out.",
    179 			default_branch)!;
    180 	};
    181 };
    182 
    183 fn checkstatus() (void | release_error) = {
    184 	const status = strings::rtrim(git_readcmd("status", "-zuno")?);
    185 	defer free(status);
    186 	if (len(status) != 0) {
    187 		fmt::errorln("Warning! You have uncommitted changes.")!;
    188 	};
    189 };
    190 
    191 fn checkbehind() (void | release_error) = {
    192 	const upstream = match (git_readcmd("rev-parse", "HEAD@{upstream}")) {
    193 	case git_error =>
    194 		// Fails if there is no upstream, in which case we don't need to
    195 		// bother checking.
    196 		return;
    197 	case let err: release_error =>
    198 		return err;
    199 	case let s: str =>
    200 		yield s;
    201 	};
    202 	defer free(upstream);
    203 	const head = git_readcmd("rev-parse", "HEAD")?;
    204 	defer free(head);
    205 	if (upstream == head) {
    206 		return;
    207 	};
    208 	match (git_runcmd("merge-base", "--is-ancestor", "HEAD@{upstream}", "HEAD")) {
    209 	case git_error =>
    210 		fmt::errorln("Warning! Your local branch is behind the upstream branch.")!;
    211 	case let err: release_error =>
    212 		return err;
    213 	case => void;
    214 	};
    215 };
    216 
    217 fn shortlog(out: io::file, what: str) (void | release_error) = {
    218 	const cmd = exec::cmd("git", "shortlog", "--no-merges", what)?;
    219 	exec::addfile(&cmd, os::stdout_file, out);
    220 	const proc = exec::start(&cmd)?;
    221 	const status = exec::wait(&proc)?;
    222 	exec::check(&status)?;
    223 };
    224 
    225 fn choosekey() (str | release_error) = {
    226 	match (os::getenv("HAREKEY")) {
    227 	case void => void;
    228 	case let name: str =>
    229 		return name;
    230 	};
    231 
    232 	const paths = [
    233 		"id_ed25519",
    234 		"id_ecdsa",
    235 		"id_rsa",
    236 		"id_dsa",
    237 	];
    238 	let buf = path::init();
    239 	const home = os::getenv("HOME") as str;
    240 	for (let i = 0z; i < len(paths); i += 1) {
    241 		const cand = path::set(&buf, home, ".ssh", paths[i])!;
    242 		if (os::stat(cand) is fs::error) {
    243 			continue;
    244 		};
    245 		return strings::dup(cand);
    246 	};
    247 	fmt::errorln("No suitable SSH key found to sign releases with.")!;
    248 
    249 	fmt::error("Would you like to generate one now? [Y/n] ")!;
    250 	const line = match (bufio::scanline(os::stdin)?) {
    251 	case io::EOF =>
    252 		fmt::fatal("No suitable key available. Terminating.");
    253 	case let line: []u8 =>
    254 		yield strings::fromutf8(line);
    255 	};
    256 	defer free(line);
    257 	if (line != "" && line != "y" && line != "Y") {
    258 		fmt::fatal("No suitable key available. Terminating.");
    259 	};
    260 
    261 	const parent = path::set(&buf, home, ".ssh")!;
    262 	os::mkdirs(parent, 0o755)?;
    263 
    264 	const path = path::set(&buf, home, ".ssh", "id_ed25519")!;
    265 	const cmd = match (exec::cmd("ssh-keygen", "-t", "ed25519", "-f", path)) {
    266 	case let cmd: exec::command =>
    267 		yield cmd;
    268 	case let err: exec::error =>
    269 		fmt::fatal("ssh-keygen: command not found. Is openssh installed?");
    270 	};
    271 	const proc = exec::start(&cmd)?;
    272 	const status = exec::wait(&proc)?;
    273 	exec::check(&status)?;
    274 	fmt::println("You will be prompted to enter your password again to create the release signature.")!;
    275 	return strings::dup(path);
    276 };
    277 
    278 fn signtag(tmpdir: str, name: str, tag: str, key: str) (void | release_error) = {
    279 	// This could work without the agent if it were not for the fact that
    280 	// ssh-keygen is bloody stupid when it comes to prompting you for your
    281 	// password.
    282 	let buf = path::init();
    283 	const socket = path::set(&buf, tmpdir, "agent")!;
    284 	const agent = exec::cmd("ssh-agent", "-Da", socket)?;
    285 	exec::nullstd(&agent);
    286 	const agent = exec::start(&agent)?;
    287 	defer exec::kill(agent)!;
    288 
    289 	const addkey = exec::cmd("ssh-add", key)?;
    290 	exec::setenv(&addkey, "SSH_AUTH_SOCK", socket)!;
    291 	const addkey = exec::start(&addkey)?;
    292 	const addkey = exec::wait(&addkey)?;
    293 	exec::check(&addkey)?;
    294 
    295 	const prefix = fmt::asprintf("--prefix={}-{}/", name, tag);
    296 	defer free(prefix);
    297 	const archive = exec::cmd("git", "archive",
    298 		"--format=tar.gz", prefix, tag)?;
    299 	const ssh = exec::cmd("ssh-keygen",
    300 		"-Y", "sign", "-f", key, "-n", "file")?;
    301 	const note = exec::cmd("git", "notes", "add", "-F", "-", tag)?;
    302 	exec::setenv(&note, "GIT_NOTES_REF", "refs/notes/signatures/tar.gz")!;
    303 
    304 	exec::setenv(&ssh, "SSH_AUTH_SOCK", socket)!;
    305 	// Squelch "Signing data on standard input" message
    306 	// TODO: It might be better to capture this and print it to stderr
    307 	// ourselves if ssh-keygen exits nonzero, so that the error details are
    308 	// available to the user for diagnosis.
    309 	exec::addfile(&ssh, os::stderr, exec::nullfd);
    310 
    311 	const pipe1 = exec::pipe();
    312 	const pipe2 = exec::pipe();
    313 	exec::addfile(&archive, os::stdout_file, pipe1.1);
    314 	exec::addfile(&ssh, os::stdin_file, pipe1.0);
    315 	exec::addfile(&ssh, os::stdout_file, pipe2.1);
    316 	exec::addfile(&note, os::stdin_file, pipe2.0);
    317 	const archive = exec::start(&archive)?;
    318 	const ssh = exec::start(&ssh)?;
    319 	const note = exec::start(&note)?;
    320 	io::close(pipe1.0)?;
    321 	io::close(pipe1.1)?;
    322 	io::close(pipe2.0)?;
    323 	io::close(pipe2.1)?;
    324 	exec::check(&exec::wait(&archive)?)?;
    325 	exec::check(&exec::wait(&ssh)?)?;
    326 	exec::check(&exec::wait(&note)?)?;
    327 };
    328 
    329 fn git_runcmd(args: str...) (void | release_error) = {
    330 	const cmd = exec::cmd("git", args...)?;
    331 	exec::addfile(&cmd, os::stderr, exec::nullfd);
    332 	const proc = exec::start(&cmd)?;
    333 	const status = exec::wait(&proc)?;
    334 	return exec::check(&status)?;
    335 };
    336 
    337 fn git_readcmd(args: str...) (str | release_error) = {
    338 	const pipe = exec::pipe();
    339 	defer io::close(pipe.0)!;
    340 	const cmd = exec::cmd("git", args...)?;
    341 	exec::addfile(&cmd, os::stdout_file, pipe.1);
    342 	exec::addfile(&cmd, os::stderr, exec::nullfd);
    343 	const proc = exec::start(&cmd)?;
    344 	io::close(pipe.1)?;
    345 	const result = io::drain(pipe.0)?;
    346 	const status = exec::wait(&proc)?;
    347 	exec::check(&status)?;
    348 	return strings::fromutf8(result);
    349 };
    350 
    351 fn get_defaultbranch() (str | release_error) = {
    352 	const branch = git_readcmd("config",
    353 		"--default", "master", "init.defaultBranch")?;
    354 	return strings::rtrim(branch);
    355 };
    356 
    357 fn get_currentbranch() (str | release_error) = {
    358 	return strings::rtrim(git_readcmd("branch", "--show-current")?);
    359 };