Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a script for automating releases #647

Merged
merged 6 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,12 @@ and Python log messages in the debug console under "Python Server".

## Release

- Bump the version in `package.json` and `pyproject.toml` (use even numbers for stable releases).
- Bump the `ruff` and `ruff-lsp` versions in `pyproject.toml`.
- Update the `ruff` version in the README.md
- in the Base URLs
- in "The extension ships with `ruff==...`"
- Make sure you have Python 3.7 installed
- Run `uv venv --python 3.7 && source .venv/bin/activate` to create a Python 3.7 venv and activate it.
- Run `rm requirements.txt requirements-dev.txt && just lock` to update `ruff` and `ruff-lsp`.
- Update the Changelog
- Make sure you have Python 3.7 installed and locatable by uv.
(If you're using pyenv, you may need to run `pyenv local 3.7`.)
- Run `uv run --python=3.7 scripts/release.py`.
(Run `uv run --python=3.7 scripts/release.py --help` for information on what this script does,
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
and its various options.)
- Check the changes the script made, copy-edit the changelog, and commit the changes.
- Create a new PR and merge it.
- [Create a new Release](https://github.com/astral-sh/ruff-vscode/releases/new), enter `x.x.x` (where `x.x.x` is the new version) into the _Choose a tag_ selector. Click _Generate release notes_, curate the release notes and publish the release.
- The Release workflow publishes the extension to the VS Code marketplace.
30 changes: 0 additions & 30 deletions scripts/bump_extension_version.sh

This file was deleted.

30 changes: 0 additions & 30 deletions scripts/bump_lsp_version.sh

This file was deleted.

30 changes: 0 additions & 30 deletions scripts/bump_ruff_version.sh

This file was deleted.

244 changes: 244 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
"""
Script for automating changes necessary for `ruff-vscode` releases.

This script does the following things:
- Bumps the version of this project in `pyproject.toml` and `package.json`
- Bumps the `ruff` and `ruff-lsp` dependency pins in `pyproject.toml`
- Updates the changelog and README
- Updates the package's lockfiles
"""

# /// script
# requires-python = "==3.7.*"
# dependencies = ["packaging", "requests", "rich-argparse", "tomli", "tomlkit"]
#
# [tool.uv]
# exclude-newer = "2024-11-27T00:00:00Z"
# ///
from __future__ import annotations

import argparse
import json
import re
import subprocess
import textwrap
from dataclasses import dataclass
from pathlib import Path

import requests
import tomli
import tomlkit
import tomlkit.items
from packaging.requirements import Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from rich_argparse import RawDescriptionRichHelpFormatter

PYPROJECT_TOML_PATH = Path("pyproject.toml")
PACKAGE_JSON_PATH = Path("package.json")
README_PATH = Path("README.md")
CHANGELOG_PATH = Path("CHANGELOG.md")


@dataclass(frozen=True)
class RuffVersions:
existing_vscode_version: Version
new_vscode_version: Version
existing_ruff_pin: Version
latest_ruff: Version
existing_ruff_lsp_pin: Version
latest_ruff_lsp: Version


def existing_dependency_pin(
dependencies: dict[str, SpecifierSet], dependency: str
) -> Version:
"""Return the version that `dependency` is currently pinned to in pyproject.toml."""
specifiers = dependencies[dependency]
assert len(specifiers) == 1
single_specifier = next(iter(specifiers))
assert single_specifier.operator == "=="
return Version(single_specifier.version)


def latest_pypi_version(project_name: str) -> Version:
"""Determine the latest version of `project_name` that has been uploaded to PyPI."""
pypi_json = requests.get(f"https://pypi.org/pypi/{project_name}/json")
pypi_json.raise_for_status()
return Version(pypi_json.json()["info"]["version"])


def get_ruff_versions() -> RuffVersions:
"""
Obtain metadata about the project; figure out what the new metadata should be.
"""
with PYPROJECT_TOML_PATH.open("rb") as pyproject_file:
pyproject_toml = tomli.load(pyproject_file)

existing_vscode_version = pyproject_toml["project"]["version"]

major, minor, micro = Version(existing_vscode_version).release
AlexWaygood marked this conversation as resolved.
Show resolved Hide resolved
new_vscode_version = Version(f"{major}.{minor + 2}.{micro}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, how would we do a patch release or release a preview version (which we've done a few times in the past)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea of this script was to cover the common workflow rather than all possible workflows. I can add a CLI flag for this if you want, but I worry that the complexity it will add to the script won't be worth the time it will save us

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I see is that this PR removes the documentation on how to do a release manually when not using the script. To me, that means it's now the only way of doing a release.

That's why I think we should either revert the documentation changes or extend the script. I'm worried about reverting the documentation because it probably will get outdated with future changes to this script which then is even worse: You end up with incomplete/incorrect instructions

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Okay, I'll add the CLI flags.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added more flags in 37b2935, and tested them using uv run --python=3.7 scripts/release.py --new-version=2024.600.0 --new-ruff=0.6.0. It did everything I expected it to.

Also, it didn't end up being much more code at all, so I think it was worth it -- thanks for persuading me!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding it and thanks for automating the steps!


dependencies = {
requirement.name: requirement.specifier
for requirement in map(Requirement, pyproject_toml["project"]["dependencies"])
}

return RuffVersions(
existing_vscode_version=existing_vscode_version,
new_vscode_version=new_vscode_version,
existing_ruff_pin=existing_dependency_pin(dependencies, "ruff"),
latest_ruff=latest_pypi_version("ruff"),
existing_ruff_lsp_pin=existing_dependency_pin(dependencies, "ruff-lsp"),
latest_ruff_lsp=latest_pypi_version("ruff-lsp"),
)


def update_pyproject_toml(versions: RuffVersions) -> None:
"""Update metadata in `pyproject.toml`.

Specifically, we update:
- The version of this project itself
- The `ruff` version we pin to in our dependencies list
- The `ruff-lsp` version we pin to in our dependencies list
"""
with PYPROJECT_TOML_PATH.open("rb") as pyproject_file:
pyproject_toml = tomlkit.load(pyproject_file)

project_table = pyproject_toml["project"]
assert isinstance(project_table, tomlkit.items.Table)

project_table["version"] = tomlkit.string(str(versions.new_vscode_version))

existing_dependencies = project_table["dependencies"]
assert isinstance(existing_dependencies, tomlkit.items.Array)
assert len(existing_dependencies) == 3
existing_dependencies[1] = tomlkit.string(f"ruff-lsp=={versions.latest_ruff_lsp}")
existing_dependencies[2] = tomlkit.string(f"ruff=={versions.latest_ruff}")

with PYPROJECT_TOML_PATH.open("w") as pyproject_file:
tomlkit.dump(pyproject_toml, pyproject_file)


def bump_package_json_version(new_version: Version) -> None:
"""Update the version of this package in `package.json`."""
with PACKAGE_JSON_PATH.open("rb") as package_json_file:
package_json = json.load(package_json_file)
package_json["version"] = str(new_version)
with PACKAGE_JSON_PATH.open("w") as package_json_file:
json.dump(package_json, package_json_file, indent=2)
package_json_file.write("\n")


def update_readme(latest_ruff: Version) -> None:
"""Ensure the README is up to date with respect to our pinned Ruff version."""
readme_text = README_PATH.read_text()
readme_text = re.sub(
r"The extension ships with `ruff==\d\.\d\.\d`\.",
f"The extension ships with `ruff=={latest_ruff}`.",
readme_text,
)
readme_text = re.sub(
r"ruff/\d\.\d\.\d\.svg", f"ruff/{latest_ruff}.svg", readme_text
)
README_PATH.write_text(readme_text)


def update_changelog(versions: RuffVersions) -> None:
"""Add a changelog entry describing the updated dependency pins."""
with CHANGELOG_PATH.open() as changelog_file:
changelog_lines = list(changelog_file)

assert (
changelog_lines[4] == f"## {versions.existing_vscode_version}\n"
), f"Unexpected content in CHANGELOG.md ({changelog_lines[4]!r}) -- perhaps the release script is out of date?"

if (
versions.latest_ruff != versions.existing_ruff_pin
and versions.latest_ruff_lsp != versions.existing_ruff_lsp_pin
):
changelog_entry_middle = (
f"This release upgrades the bundled Ruff version to `v{versions.latest_ruff}`, and the bundled `ruff-lsp` "
f"version to `{versions.latest_ruff_lsp}`."
)
elif versions.latest_ruff != versions.existing_ruff_pin:
changelog_entry_middle = f"This release upgrades the bundled Ruff version to `v{versions.latest_ruff}`."
elif versions.latest_ruff_lsp != versions.existing_ruff_lsp_pin:
changelog_entry_middle = f"This release upgrades the bundled `ruff-lsp` version to `v{versions.latest_ruff_lsp}`."
else:
changelog_entry_middle = ""

changelog_entry = textwrap.dedent(f"""\
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need the \.

Isn't it nice how Ruff wraps the multiline string? :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a very common practice when using textwrap.dedent() in combination with triple-quoted strings, or it's easy to end up with a dedented string where the first line has odd indentation https://grep.app/search?q=textwrap.dedent%28%22%22%22%5C&filter[lang][0]=Python

Isn't it nice how Ruff wraps the multiline string? :)

I'm... actually not a massive fan of that formatting choice 🙈 but I don't care enough to argue about it 😆

Copy link
Member

@MichaReiser MichaReiser Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I brought it up because it's a ruff specific style :)


## {versions.new_vscode_version}

{changelog_entry_middle}

**Full Changelog**: https://github.com/astral-sh/ruff-vscode/compare/{versions.existing_vscode_version}...{versions.new_vscode_version}
""")

changelog_lines[3:3] = changelog_entry.splitlines(keepends=True)
CHANGELOG_PATH.write_text("".join(changelog_lines))


def lock_requirements() -> None:
"""Update this package's lockfiles."""
for path in "requirements-dev.txt", "requirements.txt":
Path(path).unlink()
subprocess.run(["just", "lock"], check=True)


def commit_changes(versions: RuffVersions) -> None:
"""Create a new `git` branch, check it out, and commit the changes."""
original_branch = subprocess.run(
["git", "branch", "--show-current"], text=True, check=True, capture_output=True
).stdout.strip()

new_branch = f"release-{versions.new_vscode_version}"

commit_command = [
"git",
"commit",
"-a",
"-m",
f"Release {versions.new_vscode_version}",
"-m",
f"Bump ruff to {versions.latest_ruff} and ruff-lsp to {versions.latest_ruff_lsp}",
]

try:
subprocess.run(["git", "switch", "-c", new_branch], check=True)
subprocess.run(commit_command, check=True)
except:
subprocess.run(["git", "switch", original_branch], check=True)
raise


def prepare_release(*, prepare_pr: bool) -> None:
"""Make all necessary changes for a new `ruff-vscode` release."""
versions = get_ruff_versions()
update_pyproject_toml(versions)
bump_package_json_version(versions.new_vscode_version)
update_readme(versions.latest_ruff)
update_changelog(versions)
lock_requirements()
if prepare_pr:
commit_changes(versions)


def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=RawDescriptionRichHelpFormatter
)
parser.add_argument(
"--prepare-pr",
action="store_true",
help="After preparing the release, commit the results to a new branch",
)
args = parser.parse_args()
prepare_release(prepare_pr=args.prepare_pr)


if __name__ == "__main__":
main()