Skip to content

Commit eee3b49

Browse files
committed
Add automation to make releases
1 parent 183f3c0 commit eee3b49

File tree

2 files changed

+276
-30
lines changed

2 files changed

+276
-30
lines changed

docs/README.md

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,12 @@
1-
# Maintainance documentation
1+
# Maintainer documentation
22

3-
This section of the repository contains maintainance documentation for TOML.
4-
5-
The plan is that we'd build better automation for managing the specification
6-
and the relevant processes, information about the tooling used and related
7-
items would be documented here.
8-
9-
Currently, this documentation is pretty thin; as we are still working on
10-
improving the automation and setting up workflows.
3+
This section of the repository contains documentation for TOML's maintainers.
114

125
## Release Process
136

14-
> TODO: Automate/update this for the new toml.io website!
7+
- Set up local repos of [toml-lang/toml] and [toml-lang/toml.io], as described in [scripts/release.py](../scripts/release.py).
8+
- In the root of the toml-lang/toml clone, run `python3.8 scripts/release.py <version>`.
9+
- Done! ✨
1510

16-
- Checkout the latest `master` branch.
17-
- Update the existing files:
18-
- `README.md`: Update the notice on top of the file, to reflect the
19-
current state of the project; because... we don't have a better
20-
mechanism for communicating state-of-affairs right now.
21-
- `CHANGELOG.md`: Update the top level heading, to reflect the new
22-
version and date.
23-
- Create the new "release version" of the specification:
24-
- Copy `README.md` to `versions/en/toml-v{version}.md`.
25-
- Update the top-level heading, to clearly include the version
26-
like `TOML v{version}`.
27-
- Remove the note about tracking `master`.
28-
- Commit all these changes.
29-
- Make a PR with these changes, and squash-merge it.
30-
- Go to https://github.com/toml-lang/toml/releases/new and create a new release:
31-
- Tag version: `v{version}` like `v1.0.0-rc.1`
32-
- Target: master
33-
- Title: same as "Tag Version"
34-
- Description: Notes for this release, copied from CHANGELOG.md
35-
- Pre-release: make sure to check/not check this box
11+
[toml-lang/toml]: https://github.com/toml-lang/toml
12+
[toml-lang/toml.io]: https://github.com/toml-lang/toml.io

scripts/release.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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

Comments
 (0)