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(¬e, "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(¬e, os::stdin_file, pipe2.0); 319 const archive = exec::start(&archive)?; 320 const ssh = exec::start(&ssh)?; 321 const note = exec::start(¬e)?; 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(¬e)?)?; 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 };