From acf458b82e9014f6f14ef9aaf8542c8af7645ed9 Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Wed, 4 Sep 2024 14:14:12 +0200 Subject: [PATCH] add CI release scripts --- .github/scripts/release/ci | 26 ++++++ .github/scripts/release/common.py | 83 +++++++++++++++++++ .github/scripts/release/git_commit.py | 26 ++++++ .github/scripts/release/git_push.py | 21 +++++ .github/scripts/release/git_tag.py | 16 ++++ .github/scripts/release/status_check.py | 38 +++++++++ .github/scripts/release/update_changelog.py | 25 ++++++ .../scripts/release/update_rust_version.py | 37 +++++++++ .github/workflows/release.yml | 32 +++++++ CHANGELOG.md | 1 + CONTRIBUTING.md | 13 +-- 11 files changed, 309 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/release/ci create mode 100755 .github/scripts/release/common.py create mode 100755 .github/scripts/release/git_commit.py create mode 100755 .github/scripts/release/git_push.py create mode 100755 .github/scripts/release/git_tag.py create mode 100755 .github/scripts/release/status_check.py create mode 100755 .github/scripts/release/update_changelog.py create mode 100755 .github/scripts/release/update_rust_version.py create mode 100644 .github/workflows/release.yml diff --git a/.github/scripts/release/ci b/.github/scripts/release/ci new file mode 100755 index 00000000..3aa5a862 --- /dev/null +++ b/.github/scripts/release/ci @@ -0,0 +1,26 @@ +#!/usr/bin/env -S python3 -u +import logging + +from common import branch, get_next_dev_version +from status_check import status_check +from update_changelog import update_changelog +from update_rust_version import update_rust_version +from git_commit import git_commit +from git_tag import git_tag +from git_push import git_push + +logger = logging.getLogger(__name__) + +if __name__ == "__main__": + status_check() + update_changelog() + update_rust_version() + git_commit() + git_tag() + + if branch == "main": + update_rust_version(version=get_next_dev_version()) + git_commit(message="reopen main for development") + + git_push() + logger.info("✅ All done. 🥳") diff --git a/.github/scripts/release/common.py b/.github/scripts/release/common.py new file mode 100755 index 00000000..6a703b63 --- /dev/null +++ b/.github/scripts/release/common.py @@ -0,0 +1,83 @@ +import os +import re +import subprocess +import logging +import http.client +import json +from typing import Callable + +logging.basicConfig(format="[%(asctime)s] %(message)s", level=logging.INFO) + +logger = logging.getLogger(__name__) + +github_repository: str +project: str +branch: str +get_version: Callable[[], str] + +if github_repository := os.environ.get("GITHUB_REPOSITORY", None): + logger.info(f"Got repository from environment: {github_repository}") +else: + _origin_url = subprocess.check_output( + ["git", "remote", "get-url", "--push", "origin"], text=True + ).strip() + github_repository = re.search(r"^git@github\.com:(.+)\.git$", _origin_url)[1] + logger.info(f"Got repository from Git: {github_repository}") + +if project := os.environ.get("PROJECT_NAME", None): + logger.info(f"Got project name from $PROJECT_NAME: {project}") +else: + project = github_repository.partition("/")[2] + logger.info(f"Got project name from repository url: {project}") + +branch = subprocess.check_output(["git", "branch", "--show-current"], text=True).strip() + +_version: str | None +if _version := os.environ.get("PROJECT_VERSION", None): + logger.info(f"Got project version from $PROJECT_VERSION: {_version}") +elif os.environ.get("GITHUB_REF", "").startswith("refs/tags/"): + _version = os.environ["GITHUB_REF_NAME"] + logger.info(f"Got project version from $GITHUB_REF: {_version}") +else: + _version = None + logger.info("No version information found.") + + +def get_version() -> str: + if _version is None: + raise RuntimeError("No version information found.") + assert re.match(r"^\d+\.\d+\.\d+$", _version), f"Invalid version: {_version}" + return _version + + +def get_next_dev_version() -> str: + version = get_version().split(".") + if version[0] == "0": + version[1] = str(int(version[1]) + 1) + else: + version[0] = str(int(version[0]) + 1) + return ".".join(version) + "-dev" + + +def get_tag_name() -> str: + return f"{os.environ.get('GIT_TAG_PREFIX', '')}{get_version()}" + + +def http_get(url: str) -> http.client.HTTPResponse: + assert url.startswith("https://") + host, path = re.split(r"(?=/)", url.removeprefix("https://"), maxsplit=1) + logger.info(f"GET {host} {path}") + conn = http.client.HTTPSConnection(host) + conn.request("GET", path, headers={"User-Agent": "mhils/run-tools"}) + resp = conn.getresponse() + print(f"HTTP {resp.status} {resp.reason}") + return resp + + +def http_get_json(url: str) -> dict: + resp = http_get(url) + body = resp.read() + try: + return json.loads(body) + except Exception as e: + raise RuntimeError(f"{resp.status=} {body=}") from e diff --git a/.github/scripts/release/git_commit.py b/.github/scripts/release/git_commit.py new file mode 100755 index 00000000..e7f08839 --- /dev/null +++ b/.github/scripts/release/git_commit.py @@ -0,0 +1,26 @@ +#!/usr/bin/env -S python3 -u +import logging +import subprocess +import sys + +from common import project, get_version + +logger = logging.getLogger(__name__) + + +def git_commit(message: str = ""): + logger.info("➡️ Git commit...") + subprocess.check_call( + [ + "git", + *("-c", f"user.name={project} run bot"), + *("-c", "user.email=git-run-bot@maximilianhils.com"), + "commit", + "--all", + *("-m", message or f"{project} {get_version()}"), + ] + ) + + +if __name__ == "__main__": + git_commit(sys.argv[1] if len(sys.argv) > 1 else "") diff --git a/.github/scripts/release/git_push.py b/.github/scripts/release/git_push.py new file mode 100755 index 00000000..cb89a46b --- /dev/null +++ b/.github/scripts/release/git_push.py @@ -0,0 +1,21 @@ +#!/usr/bin/env -S python3 -u +import logging +import subprocess + +from common import branch, get_tag_name + +logger = logging.getLogger(__name__) + + +def git_push(*identifiers: str): + logger.info("➡️ Git push...") + if not identifiers: + identifiers = [ + branch, + get_tag_name(), + ] + subprocess.check_call(["git", "push", "--atomic", "origin", *identifiers]) + + +if __name__ == "__main__": + git_push() diff --git a/.github/scripts/release/git_tag.py b/.github/scripts/release/git_tag.py new file mode 100755 index 00000000..31a2fe5d --- /dev/null +++ b/.github/scripts/release/git_tag.py @@ -0,0 +1,16 @@ +#!/usr/bin/env -S python3 -u +import logging +import subprocess + +from common import get_tag_name + +logger = logging.getLogger(__name__) + + +def git_tag(name: str = ""): + logger.info("➡️ Git tag...") + subprocess.check_call(["git", "tag", name or get_tag_name()]) + + +if __name__ == "__main__": + git_tag() diff --git a/.github/scripts/release/status_check.py b/.github/scripts/release/status_check.py new file mode 100755 index 00000000..b34ee584 --- /dev/null +++ b/.github/scripts/release/status_check.py @@ -0,0 +1,38 @@ +#!/usr/bin/env -S python3 -u +import logging +import os +import subprocess + +from common import branch, github_repository, http_get_json + +logger = logging.getLogger(__name__) + + +def status_check(): + if os.environ.get("STATUS_CHECK_SKIP_GIT", None) == "true": + logger.warning("⚠️ Skipping check whether Git repo is clean.") + else: + logger.info("➡️ Working dir clean?") + out = subprocess.check_output(["git", "status", "--porcelain"]) + assert not out, "repository is not clean" + + if os.environ.get("STATUS_CHECK_SKIP_CI", None) == "true": + logger.warning(f"⚠️ Skipping status check for {branch}.") + else: + logger.info(f"➡️ CI is passing for {branch}?") + check_runs = http_get_json( + f"https://api.github.com/repos/{github_repository}/commits/{branch}/check-runs" + )["check_runs"] + for check_run in check_runs: + match check_run["conclusion"]: + case "success" | "skipped": + pass + case None: + logger.warning(f"⚠️ CI job still running: {check_run['name']}") + case _: + raise RuntimeError(f"❌ CI job failed: {check_run['name']}") + + + +if __name__ == "__main__": + status_check() diff --git a/.github/scripts/release/update_changelog.py b/.github/scripts/release/update_changelog.py new file mode 100755 index 00000000..01357b7a --- /dev/null +++ b/.github/scripts/release/update_changelog.py @@ -0,0 +1,25 @@ +#!/usr/bin/env -S python3 -u +import datetime +import logging +import re +from pathlib import Path + +from common import project, get_version + +logger = logging.getLogger(__name__) + + +def update_changelog(): + logger.info("➡️ Updating CHANGELOG.md...") + path = Path("CHANGELOG.md") + date = datetime.date.today().strftime("%d %B %Y") + title = f"## {date}: {project} {get_version()}" + cl = path.read_text("utf8") + assert title not in cl, f"Version {get_version()} is already present in {path}." + cl, ok = re.subn(rf"(?<=## Unreleased: {project} next)", f"\n\n\n{title}", cl) + assert ok == 1 + path.write_text(cl, "utf8") + + +if __name__ == "__main__": + update_changelog() diff --git a/.github/scripts/release/update_rust_version.py b/.github/scripts/release/update_rust_version.py new file mode 100755 index 00000000..b86ea7c8 --- /dev/null +++ b/.github/scripts/release/update_rust_version.py @@ -0,0 +1,37 @@ +#!/usr/bin/env -S python3 -u +import logging +import re +import subprocess +import sys +from pathlib import Path + +from common import get_version + +logger = logging.getLogger(__name__) + + +def update_rust_version(version: str = ""): + logger.info("➡️ Updating Cargo.toml...") + path = Path("Cargo.toml") + cl = path.read_text("utf8") + cl, ok = re.subn( + r""" + ( + ^\[(?:workspace\.)?package]\n # [package] or [workspace.package] toml block + (?:(?!\[).*\n)* # lines not starting a new section + version[ \t]*=[ \t]*" # beginning of the version line + ) + [^"]+ + """, + rf"\g<1>{version or get_version()}", + cl, + flags=re.VERBOSE | re.MULTILINE, + ) + assert ok == 1, f"{ok=}" + path.write_text(cl, "utf8") + + subprocess.check_call(["cargo", "update", "--workspace"]) + + +if __name__ == "__main__": + update_rust_version(sys.argv[1] if len(sys.argv) > 1 else "") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6cd5c37b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version (major.minor.patch)' + required: true + type: string + skip-branch-status-check: + description: 'Skip CI status check.' + default: false + required: false + type: boolean + +permissions: {} + +jobs: + release: + environment: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GH_PUSH_TOKEN }} # this token works to push to the protected main branch. + - uses: actions/setup-python@v5 + with: + python-version-file: .github/python-version.txt + - run: ./.github/scripts/release/ci + env: + PROJECT_VERSION: ${{ inputs.version }} + STATUS_CHECK_SKIP_GIT: ${{ inputs.skip-branch-status-check }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 08103220..4b9043ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +## Unreleased: mitmproxy_rs next - Move functionality into submodules. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3843713a..0b554578 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,12 +54,7 @@ be no busy waiting. If you are the current maintainer of mitmproxy_rs, you can perform the following steps to ship a release: -1. Make sure that... - - you are on the `main` branch with a clean working tree. - - `cargo test` is passing without errors. -2. Bump the version in [`Cargo.toml`](Cargo.toml). -3. Run `cargo update --workspace` to update the lockfile with the new version. -4. Update [`CHANGELOG.md`](./CHANGELOG.md). -5. Commit the changes and tag them. - - Convention: Tag name is simply the version number, e.g. `1.0.1`. -6. Manually confirm the CI deploy step on GitHub. +1. Make sure that CI is passing without errors. +2. Make sure that CHANGELOG.md is up-to-date with all entries in the "Unreleased" section. +3. Invoke the release workflow from the GitHub UI: https://github.com/mitmproxy/mitmproxy_rs/actions/workflows/release.yml +4. The spawned workflow run will require manual deploy confirmation on GitHub: https://github.com/mitmproxy/mitmproxy/actions