|
| 1 | +"""Release Automation! |
| 2 | +
|
| 3 | +Baked assumptions: |
| 4 | +
|
| 5 | +- CWD is the root of the toml repository (this one) |
| 6 | +- The toml.io repository is available at "../toml.io" |
| 7 | +- Changelog file: |
| 8 | + - is `CHANGELOG.md` |
| 9 | + - has a "## unreleased" heading line. |
| 10 | +- Markdown file: |
| 11 | + - is `toml.md` |
| 12 | + - goes to `specs/en/v{version}` in the website repo |
| 13 | + - lines "TOML" and "====" are the main heading. |
| 14 | +- ABNF file: |
| 15 | + - is `toml.abnf` |
| 16 | + - TODO: figure out where ABNF file goes on the website |
| 17 | +
|
| 18 | +Checked assumptions: |
| 19 | +
|
| 20 | +- Both this and the toml.io repository have an "upstream" remote |
| 21 | +- "upstream" remotes point to "github.com/toml-lang/{repo}" |
| 22 | +- Current branch is the default branch |
| 23 | +- Current branch is up to date with remote |
| 24 | +- Working directory is clean |
| 25 | +
|
| 26 | +""" |
| 27 | + |
| 28 | +import fileinput |
| 29 | +import os |
| 30 | +import re |
| 31 | +import shutil |
| 32 | +import subprocess |
| 33 | +import sys |
| 34 | +import tempfile |
| 35 | +import textwrap |
| 36 | +from contextlib import contextmanager |
| 37 | +from datetime import datetime |
| 38 | +from pathlib import Path |
| 39 | +from typing import List, Tuple |
| 40 | + |
| 41 | +# Copied from semver.org and broken up for readability + line length. |
| 42 | +SEMVER_REGEX = re.compile( |
| 43 | + r""" |
| 44 | + ^ |
| 45 | + (?P<major>0|[1-9]\d*) |
| 46 | + \. |
| 47 | + (?P<minor>0|[1-9]\d*) |
| 48 | + \. |
| 49 | + (?P<patch>0|[1-9]\d*) |
| 50 | + (?: |
| 51 | + - |
| 52 | + (?P<prerelease> |
| 53 | + (?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*) |
| 54 | + (?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))* |
| 55 | + ) |
| 56 | + )? |
| 57 | + (?: |
| 58 | + \+ |
| 59 | + (?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*) |
| 60 | + )? |
| 61 | + $ |
| 62 | + """, |
| 63 | + re.VERBOSE, |
| 64 | +) |
| 65 | + |
| 66 | + |
| 67 | +# |
| 68 | +# Helpers |
| 69 | +# |
| 70 | +@contextmanager |
| 71 | +def task(message: str): |
| 72 | + """A little thing to allow for nicer code organization.""" |
| 73 | + log(f"{message}...") |
| 74 | + log.indent += 1 |
| 75 | + try: |
| 76 | + yield |
| 77 | + except AssertionError as e: |
| 78 | + log(f"ERROR: {e}", error=True) |
| 79 | + sys.exit(1) |
| 80 | + finally: |
| 81 | + log.indent -= 1 |
| 82 | + |
| 83 | + |
| 84 | +def log(message: str, *, error=False) -> None: |
| 85 | + output = textwrap.indent(message, " " * log.indent) |
| 86 | + |
| 87 | + if error: |
| 88 | + file = sys.stderr |
| 89 | + # A dash of red |
| 90 | + if sys.stdout.isatty(): |
| 91 | + output = f"\033[31m{output}\033[0m" |
| 92 | + else: |
| 93 | + file = sys.stdout |
| 94 | + |
| 95 | + print(output, file=file) |
| 96 | + |
| 97 | + |
| 98 | +log.indent = 0 |
| 99 | + |
| 100 | + |
| 101 | +def run(*args, cwd: Path): |
| 102 | + """Runs a command, while also pretty-printing it.""" |
| 103 | + result = subprocess.run(args, cwd=cwd, capture_output=True) |
| 104 | + if result.returncode == 0: |
| 105 | + return result.stdout.decode().rstrip("\n") |
| 106 | + |
| 107 | + # Print information about the failed command. |
| 108 | + log(" ".join(["$", *args])) |
| 109 | + log(" stdout ".center(80, "-")) |
| 110 | + log(result.stdout.decode() or "<nothing>") |
| 111 | + log(" stderr ".center(80, "-")) |
| 112 | + log(result.stderr.decode() or "<nothing>") |
| 113 | + |
| 114 | + assert False, f"Exited with non-zero exit code: {result.returncode}" |
| 115 | + |
| 116 | + |
| 117 | +def change_line(path: Path, *, line: str, to: List[str]) -> None: |
| 118 | + # Create temp file |
| 119 | + fh, tmp_path = tempfile.mkstemp() |
| 120 | + with os.fdopen(fh, "w") as tmp_file, path.open() as given_file: |
| 121 | + for got_line in given_file: |
| 122 | + # not-to-be-replaced lines |
| 123 | + if got_line != line + "\n": |
| 124 | + tmp_file.write(got_line) |
| 125 | + continue |
| 126 | + # replacement lines |
| 127 | + for replacement in to: |
| 128 | + tmp_file.write(replacement + "\n") |
| 129 | + |
| 130 | + # Replace current file with rewritten file |
| 131 | + shutil.copymode(path, tmp_path) |
| 132 | + path.unlink() |
| 133 | + shutil.move(tmp_path, path) |
| 134 | + |
| 135 | + |
| 136 | +def git_commit(message: str, *, files: List[str], repo: Path): |
| 137 | + run("git", "add", *files, cwd=repo) |
| 138 | + run("git", "commit", "-m", message, cwd=repo) |
| 139 | + |
| 140 | + |
| 141 | +# |
| 142 | +# Actual automation |
| 143 | +# |
| 144 | +def get_version() -> str: |
| 145 | + assert len(sys.argv) == 2, "Got wrong number of arguments, expected 1." |
| 146 | + |
| 147 | + version = sys.argv[1] |
| 148 | + |
| 149 | + match = SEMVER_REGEX.match(version) |
| 150 | + assert match is not None, "Given version is not a valid semver." |
| 151 | + assert not match.group("buildmetadata"), "Shouldn't have build metadata in version." |
| 152 | + |
| 153 | + return version |
| 154 | + |
| 155 | + |
| 156 | +def check_repo_state(repo: Path, *, name: str): |
| 157 | + # Check upstream remote is configured correctly |
| 158 | + upstream = run("git", "config", "--get", "remote.upstream.url", cwd=repo) |
| 159 | + assert ( |
| 160 | + upstream == f"[email protected]:toml-lang/{name}.git" |
| 161 | + ), f"Got incorrect upstream repo: {upstream}" |
| 162 | + |
| 163 | + # Check current branch is correct |
| 164 | + current_branch = run("git", "branch", "--show-current", cwd=repo) |
| 165 | + assert current_branch in ("main", "master"), current_branch |
| 166 | + |
| 167 | + # Check working directory is clean |
| 168 | + working_directory_state = run("git", "status", "--porcelain", cwd=repo) |
| 169 | + assert ( |
| 170 | + working_directory_state == "" |
| 171 | + ), f"Dirty working directory\n{working_directory_state}" |
| 172 | + |
| 173 | + # Check up-to-date with remote |
| 174 | + with task("Checking against remote"): |
| 175 | + run("git", "remote", "update", "upstream", cwd=repo) |
| 176 | + |
| 177 | + deviation = run( |
| 178 | + "git", |
| 179 | + "rev-list", |
| 180 | + f"{current_branch}..upstream/{current_branch}", |
| 181 | + "--left-right", |
| 182 | + cwd=repo, |
| 183 | + ) |
| 184 | + assert not deviation, f"Local branch deviates from upstream\n{deviation}" |
| 185 | + |
| 186 | + |
| 187 | +def get_repositories() -> Tuple[Path, Path]: |
| 188 | + spec_repo = Path(".").resolve() |
| 189 | + website_repo = spec_repo.parent / "toml.io" |
| 190 | + |
| 191 | + with task("Checking repositories"): |
| 192 | + with task("toml"): |
| 193 | + check_repo_state(spec_repo, name="toml") |
| 194 | + with task("toml.io"): |
| 195 | + check_repo_state(website_repo, name="toml.io") |
| 196 | + |
| 197 | + return website_repo, spec_repo |
| 198 | + |
| 199 | + |
| 200 | +def prepare_release(version: str, spec_repo: Path, website_repo: Path) -> None: |
| 201 | + # Make "backup" tags |
| 202 | + backup_tag = "backup/{now}".format(now=str(int(datetime.now().timestamp()))) |
| 203 | + run("git", "tag", "-m", "backup", backup_tag, cwd=spec_repo) |
| 204 | + run("git", "tag", "-m", "backup", backup_tag, cwd=website_repo) |
| 205 | + |
| 206 | + date = datetime.today().strftime("%Y-%m-%d") |
| 207 | + release_heading = f"## {version} / {date}" |
| 208 | + release_message = f"Release v{version}" |
| 209 | + |
| 210 | + with task("Updating changelog for release"): |
| 211 | + unreleased_heading = "## unreleased" |
| 212 | + changelog = spec_repo / "CHANGELOG.md" |
| 213 | + |
| 214 | + change_line(changelog, line=unreleased_heading, to=[release_heading]) |
| 215 | + git_commit(release_message, files=[str(changelog)], repo=spec_repo) |
| 216 | + |
| 217 | + with task("Creating release tag"): |
| 218 | + run("git", "tag", "-m", release_message, version, cwd=spec_repo) |
| 219 | + |
| 220 | + with task("Updating changelog for development"): |
| 221 | + change_line( |
| 222 | + changelog, |
| 223 | + line=release_heading, |
| 224 | + to=[unreleased_heading, "", "Nothing.", "", release_heading], |
| 225 | + ) |
| 226 | + git_commit("Bump for development", files=[str(changelog)], repo=spec_repo) |
| 227 | + |
| 228 | + with task("Copy to website"): |
| 229 | + # TODO: ABNF file, https://github.com/toml-lang/toml.io/issues/19 |
| 230 | + source_md = spec_repo / "toml.md" |
| 231 | + destination_md = website_repo / "specs" / "en" / f"v{version}.md" |
| 232 | + |
| 233 | + shutil.copyfile(source_md, destination_md) |
| 234 | + |
| 235 | + with task("Update title"): |
| 236 | + new_heading = f"TOML v{version}" |
| 237 | + change_line(destination_md, line="TOML", to=[new_heading]) |
| 238 | + change_line(destination_md, line="====", to=["=" * len(new_heading)]) |
| 239 | + |
| 240 | + with task("Commit new version"): |
| 241 | + git_commit(release_message, files=[str(destination_md)], repo=website_repo) |
| 242 | + |
| 243 | + |
| 244 | +def push_release(version: str, spec_repo: Path, website_repo: Path) -> None: |
| 245 | + print("Publishing changes...") |
| 246 | + with task("specs repository"): |
| 247 | + run("git", "push", "origin", "HEAD", version, cwd=spec_repo) |
| 248 | + run("git", "push", "upstream", "HEAD", version, cwd=spec_repo) |
| 249 | + |
| 250 | + with task("website repository"): |
| 251 | + run("git", "push", "origin", "HEAD", cwd=website_repo) |
| 252 | + run("git", "push", "upstream", "HEAD", cwd=website_repo) |
| 253 | + |
| 254 | + |
| 255 | +def main() -> None: |
| 256 | + version = get_version() |
| 257 | + website_repo, spec_repo = get_repositories() |
| 258 | + |
| 259 | + with task("Preparing release"): |
| 260 | + prepare_release(version, spec_repo, website_repo) |
| 261 | + |
| 262 | + input("Press enter when ready.") # a chance to stop/pause before publishing |
| 263 | + |
| 264 | + with task("Publishing release"): |
| 265 | + push_release(version, spec_repo, website_repo) |
| 266 | + |
| 267 | + |
| 268 | +if __name__ == "__main__": |
| 269 | + main() |
0 commit comments