diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..ccbf0ff248 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +/.github @DisnakeDev/maintainers +/scripts/ci @DisnakeDev/maintainers diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml new file mode 100644 index 0000000000..692affbd8b --- /dev/null +++ b/.github/workflows/build-release.yaml @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: MIT + +name: Build (+ Release) + +# test build for commit/tag, but only upload release for tags +on: + push: + branches: + - "master" + - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. v3.6.x + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: read + +jobs: + # Builds sdist and wheel, runs `twine check`, and optionally uploads artifacts. + build: + name: Build package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up environment + id: setup + uses: ./.github/actions/setup-env + with: + python-version: 3.8 + + - name: Install dependencies + run: pdm install -dG build + + - name: Build package + run: | + pdm run python -m build + ls -la dist/ + + - name: Twine check + run: pdm run twine check --strict dist/* + + - name: Show metadata + run: | + mkdir out/ + tar -xf dist/*.tar.gz -C out/ + + echo -e "
Metadata\n" >> $GITHUB_STEP_SUMMARY + cat out/*/PKG-INFO | sed 's/^/ /' | tee -a $GITHUB_STEP_SUMMARY + echo -e "\n
\n" >> $GITHUB_STEP_SUMMARY + + - name: Upload artifact + # only upload artifacts when necessary + if: startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + if-no-files-found: error + + + ### Anything below this only runs for tags ### + + # Ensures that git tag and built version match. + validate-tag: + name: Validate tag + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + env: + GIT_TAG: ${{ github.ref_name }} + outputs: + bump_dev: ${{ steps.check-dev.outputs.bump_dev }} + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Compare sdist version to git tag + run: | + mkdir out/ + tar -xf dist/*.tar.gz -C out/ + + SDIST_VERSION="$(grep "^Version:" out/*/PKG-INFO | cut -d' ' -f2-)" + echo "git tag: $GIT_TAG" + echo "sdist version: $SDIST_VERSION" + + if [ "$GIT_TAG" != "v$SDIST_VERSION" ]; then + echo "error: git tag does not match sdist version" >&2 + exit 1 + fi + + - name: Determine if dev version PR is needed + id: check-dev + run: | + BUMP_DEV= + # if this is a new major/minor version, create a PR later + if [[ "$GIT_TAG" =~ ^v[0-9]+\.[0-9]+\.0$ ]]; then + BUMP_DEV=1 + fi + echo "bump_dev=$BUMP_DEV" | tee -a $GITHUB_OUTPUT + + + # Creates a draft release on GitHub, and uploads the artifacts there. + release-github: + name: Create GitHub draft release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + - validate-tag + permissions: + contents: write # required for creating releases + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Calculate versions + id: versions + env: + GIT_TAG: ${{ github.ref_name }} + run: | + # v1.2.3 -> v1-2-3 (for changelog) + echo "docs_version=${GIT_TAG//./-}" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 + with: + files: dist/* + draft: true + body: | + TBD. + + **Changelog**: https://docs.disnake.dev/en/stable/whats_new.html#${{ steps.versions.outputs.docs_version }} + **Git history**: https://github.com/${{ github.repository }}/compare/vTODO...${{ github.ref_name }} + + + # Creates a PyPI release (using an environment which requires separate confirmation). + release-pypi: + name: Publish package to pypi.org + environment: + name: release-pypi + url: https://pypi.org/project/disnake/ + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: + - build + - validate-tag + permissions: + id-token: write # this permission is mandatory for trusted publishing + + steps: + - name: Download build artifact + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Upload to pypi + uses: pypa/gh-action-pypi-publish@f5622bde02b04381239da3573277701ceca8f6a0 # v1.8.7 + with: + print-hash: true + + + # Creates a PR to bump to an alpha version for development, if applicable. + create-dev-version-pr: + name: Create dev version bump PR + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && needs.validate-tag.outputs.bump_dev + needs: + - validate-tag + - release-github + - release-pypi + + steps: + # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow + - name: Generate app token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 + with: + app_id: ${{ secrets.BOT_APP_ID }} + private_key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v3 + with: + token: ${{ steps.generate_token.outputs.token }} + persist-credentials: false + ref: master # the PR action wants a proper base branch + + - name: Set git name/email + env: + GIT_USER: ${{ vars.GIT_APP_USER_NAME }} + GIT_EMAIL: ${{ vars.GIT_APP_USER_EMAIL }} + run: | + git config user.name "$GIT_USER" + git config user.email "$GIT_EMAIL" + + - name: Update version to dev + id: update-version + run: | + NEW_VERSION="$(python scripts/ci/versiontool.py --set dev)" + git commit -a -m "chore: update version to v$NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Create pull request + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 + with: + token: ${{ steps.generate_token.outputs.token }} + branch: auto/dev-v${{ steps.update-version.outputs.new_version }} + delete-branch: true + base: master + title: "chore: update version to v${{ steps.update-version.outputs.new_version }}" + body: | + Automated dev version PR. + + https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + skip news + t: meta diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 8fdb8e27c7..f2a989c0c4 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -33,7 +33,7 @@ jobs: python-version: '3.9' - name: Install dependencies - run: pdm install -dG tools + run: pdm install -dG changelog - name: Check for presence of a Change Log fragment (only pull requests) # NOTE: The pull request' base branch needs to be fetched so towncrier diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml new file mode 100644 index 0000000000..56c32a4ac5 --- /dev/null +++ b/.github/workflows/create-release-pr.yaml @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: MIT + +name: Create Release PR + +on: + workflow_dispatch: + inputs: + version: + description: "The new version number, e.g. `1.2.3`." + type: string + required: true + +permissions: {} + +jobs: + create-release-pr: + name: Create Release PR + runs-on: ubuntu-latest + + env: + VERSION_INPUT: ${{ inputs.version }} + + steps: + # https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow + - name: Generate app token + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 # v1.8.0 + with: + app_id: ${{ secrets.BOT_APP_ID }} + private_key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v3 + with: + token: ${{ steps.generate_token.outputs.token }} + persist-credentials: false + + - name: Set git name/email + env: + GIT_USER: ${{ vars.GIT_APP_USER_NAME }} + GIT_EMAIL: ${{ vars.GIT_APP_USER_EMAIL }} + run: | + git config user.name "$GIT_USER" + git config user.email "$GIT_EMAIL" + + - name: Set up environment + uses: ./.github/actions/setup-env + with: + python-version: 3.8 + + - name: Install dependencies + run: pdm install -dG changelog + + - name: Update version + run: | + python scripts/ci/versiontool.py --set "$VERSION_INPUT" + git commit -a -m "chore: update version to $VERSION_INPUT" + + - name: Build changelog + run: | + pdm run towncrier build --yes --version "$VERSION_INPUT" + git commit -a -m "docs: build changelog" + + - name: Create pull request + uses: peter-evans/create-pull-request@153407881ec5c347639a548ade7d8ad1d6740e38 # v5.0.2 + with: + token: ${{ steps.generate_token.outputs.token }} + branch: auto/release-v${{ inputs.version }} + delete-branch: true + title: "release: v${{ inputs.version }}" + body: | + Automated release PR, triggered by @${{ github.actor }} for ${{ github.sha }}. + + ### Tasks + - [ ] Add changelogs from backports, if applicable. + - [ ] Once merged, create + push a tag. + + https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + labels: | + t: release + assignees: | + ${{ github.actor }} diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 696bdc5fb0..64dc2e18a7 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -6,7 +6,7 @@ on: push: branches: - 'master' - - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. 3.6 + - 'v[0-9]+.[0-9]+.x' # matches to backport branches, e.g. v3.6.x - 'run-ci/*' tags: pull_request: @@ -124,10 +124,11 @@ jobs: run: nox -s check-manifest # This only runs if the previous steps were successful, no point in running it otherwise - - name: Build package + - name: Try building package run: | - python -m pip install -U build - python -m build + pdm install -dG build + pdm run python -m build + ls -la dist/ # run the libcst parsers and check for changes - name: libcst codemod diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c599e6c6d..104e2264a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--negate] types: [text] exclude_types: [json, pofile] - exclude: 'changelog/|py.typed|disnake/bin/COPYING|.github/PULL_REQUEST_TEMPLATE.md|LICENSE|MANIFEST.in' + exclude: 'changelog/|py.typed|disnake/bin/COPYING|.github/PULL_REQUEST_TEMPLATE.md|.github/CODEOWNERS|LICENSE|MANIFEST.in' - repo: https://github.com/pycqa/isort rev: 5.12.0 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..3919b696e5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,47 @@ + + +# Release Procedure + +This document provides general information and steps about the project's release procedure. +If you're reading this, this will likely not be useful to you, unless you have administrator permissions in the repository or want to replicate this setup in your own project :p + +The process is largely automated, with manual action only being needed where higher permissions are required. +Note that pre-releases (alpha/beta/rc) don't quite work with the current setup; we don't currently anticipate making pre-releases, but this may still be improved in the future. + + +## Steps + +These steps are mostly equivalent for major/minor (feature) and micro (bugfix) releases. +The branch should be `master` for major/minor releases and e.g. `1.2.x` for micro releases. + +1. Run the `Create Release PR` workflow from the GitHub UI (or CLI), specifying the correct branch and new version. + 1. Wait until a PR containing the changelog and version bump is created. Update the changelog description and merge the PR. + 2. In the CLI, fetch changes and create + push a tag for the newly created commit, which will trigger another workflow. + - [if latest] Also force-push a `stable` tag for the same ref. + 3. Update the visibility of old/new versions on https://readthedocs.org. +2. Approve the environment deployment when prompted, which will push the package to PyPI. + 1. Update and publish the created GitHub draft release, as well as a Discord announcement. 🎉 +3. [if major/minor] Create a `v1.2.x` branch for future backports, and merge the newly created dev version PR. + + +### Manual Steps + +If the automated process above does not work for some reason, here's the abridged version of the manual release process: + +1. Update version in `__init__.py`, run `towncrier build`. Commit, push, create + merge PR. +2. Follow steps 1.ii. + 1.iii. like above. +3. Run `python -m build`, attach artifacts to GitHub release. +4. Run `twine check dist/*` + `twine upload dist/*`. +5. Follow steps 2.i. + 3. like above. + + +## Repository Setup + +This automated process requires some initial one-time setup in the repository to work properly: + +1. Create a GitHub App ([docs](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/making-authenticated-api-requests-with-a-github-app-in-a-github-actions-workflow)), enable write permissions for `content` and `pull_requests`. +2. Install the app in the repository. +3. Set repository variables `GIT_APP_USER_NAME` and `GIT_APP_USER_EMAIL` accordingly. +4. Set repository secrets `BOT_APP_ID` and `BOT_PRIVATE_KEY`. +5. Create a `release-pypi` environment, add protection rules. +6. Set up trusted publishing on PyPI ([docs](https://docs.pypi.org/trusted-publishers/adding-a-publisher/)). diff --git a/disnake/__init__.py b/disnake/__init__.py index 396bab5e43..0cfd1b53d0 100644 --- a/disnake/__init__.py +++ b/disnake/__init__.py @@ -81,6 +81,8 @@ class VersionInfo(NamedTuple): serial: int +# fmt: off version_info: VersionInfo = VersionInfo(major=2, minor=10, micro=0, releaselevel="alpha", serial=0) +# fmt: on logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/pyproject.toml b/pyproject.toml index 984bdf767f..4756d55c4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,10 +70,12 @@ tools = [ "pre-commit~=3.0", "slotscheck~=0.16.4", "python-dotenv~=1.0.0", - "towncrier==23.6.0", "check-manifest==0.49", "ruff==0.0.292", ] +changelog = [ + "towncrier==23.6.0", +] codemod = [ # run codemods on the respository (mostly automated typing) "libcst~=1.1.0", @@ -94,6 +96,11 @@ test = [ "looptime~=0.2", "coverage[toml]~=6.5.0", ] +build = [ + "wheel~=0.40.0", + "build~=0.10.0", + "twine~=4.0.2", +] [tool.pdm.scripts] black = { composite = ["lint black"], help = "Run black" } @@ -230,8 +237,8 @@ ignore = [ "T201", # print found, printing is currently accepted in the test bot "PT", # this is not a module of pytest tests ] -"scripts/*.py" = ["S101"] # use of assert is okay in scripts "tests/*.py" = ["S101"] # use of assert is okay in test files +"scripts/*.py" = ["S101"] # use of assert is okay in scripts # we are not using noqa in the example files themselves "examples/*.py" = [ "B008", # do not perform function calls in argument defaults, this is how most commands work @@ -378,6 +385,7 @@ ignore = [ "noxfile.py", # docs "CONTRIBUTING.md", + "RELEASE.md", "assets/**", "changelog/**", "docs/**", diff --git a/scripts/ci/versiontool.py b/scripts/ci/versiontool.py new file mode 100644 index 0000000000..cec50cff3d --- /dev/null +++ b/scripts/ci/versiontool.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import re +import sys +from enum import Enum +from pathlib import Path +from typing import NamedTuple, NoReturn + +TARGET_FILE = Path("disnake/__init__.py") +ORIG_INIT_CONTENTS = TARGET_FILE.read_text("utf-8") + +version_re = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:(a|b|rc)(\d+)?)?") + + +class ReleaseLevel(Enum): + alpha = "a" + beta = "b" + candidate = "rc" + final = "" + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: ReleaseLevel + serial: int + + @classmethod + def from_str(cls, s: str) -> VersionInfo: + match = version_re.fullmatch(s) + if not match: + raise ValueError(f"invalid version: '{s}'") + + major, minor, micro, releaselevel, serial = match.groups() + return VersionInfo( + int(major), + int(minor), + int(micro), + ReleaseLevel(releaselevel or ""), + int(serial or 0), + ) + + def __str__(self) -> str: + s = f"{self.major}.{self.minor}.{self.micro}" + if self.releaselevel is not ReleaseLevel.final: + s += self.releaselevel.value + if self.serial: + s += str(self.serial) + return s + + def to_versioninfo(self) -> str: + return ( + f"VersionInfo(major={self.major}, minor={self.minor}, micro={self.micro}, " + f'releaselevel="{self.releaselevel.name}", serial={self.serial})' + ) + + +def get_current_version() -> VersionInfo: + match = re.search(r"^__version__\b.*\"(.+?)\"$", ORIG_INIT_CONTENTS, re.MULTILINE) + assert match, "could not find current version in __init__.py" + return VersionInfo.from_str(match[1]) + + +def replace_line(text: str, regex: str, repl: str) -> str: + lines = [] + found = False + + for line in text.split("\n"): + if re.search(regex, line): + found = True + line = repl + lines.append(line) + + assert found, f"failed to find `{regex}` in file" + return "\n".join(lines) + + +def fail(msg: str) -> NoReturn: + print("error:", msg, file=sys.stderr) + sys.exit(1) + + +def main() -> None: + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--set", metavar="VERSION", help="set new version (e.g. '1.2.3' or 'dev')") + group.add_argument("--show", action="store_true", help="print current version") + args = parser.parse_args() + + current_version = get_current_version() + + if args.show: + print(str(current_version)) + return + + # else, update to specified version + new_version_str = args.set + + if new_version_str == "dev": + if current_version.releaselevel is not ReleaseLevel.final: + fail("Current version must be final to bump to dev version") + new_version = VersionInfo( + major=current_version.major, + minor=current_version.minor + 1, + micro=0, + releaselevel=ReleaseLevel.alpha, + serial=0, + ) + else: + new_version = VersionInfo.from_str(new_version_str) + + text = ORIG_INIT_CONTENTS + text = replace_line(text, r"^__version__\b", f'__version__ = "{new_version!s}"') + text = replace_line( + text, r"^version_info\b", f"version_info: VersionInfo = {new_version.to_versioninfo()}" + ) + + if text != ORIG_INIT_CONTENTS: + TARGET_FILE.write_text(text, "utf-8") + + print(str(new_version)) + + +main()