diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22619482..f40a5241 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,42 +1,131 @@ name: deploy on: - push: - tags: - - "*" + release: + types: [published] -# Set permissions at the job level. permissions: {} jobs: - package: - name: Build & inspect our package. + verify: + name: Verify release PR status + if: github.repository == 'pytest-dev/pluggy' runs-on: ubuntu-latest permissions: - id-token: write - attestations: write + checks: read + pull-requests: read + outputs: + pr-number: ${{ steps.verify.outputs.pr-number }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Find release PR and verify CI status + id: verify + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + VERSION: ${{ github.ref_name }} with: - fetch-depth: 0 - persist-credentials: false - - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 - with: - attest-build-provenance-github: 'true' + script: | + const { VERSION } = process.env; + const branch = `release-${VERSION}`; + + // Find the open release PR. + const { data: prs } = await github.rest.pulls.list({ + ...context.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open', + }); + if (prs.length === 0) { + core.setFailed(`No open PR found for branch ${branch}`); + return; + } + const prNumber = prs[0].number; + core.setOutput('pr-number', prNumber); + core.info(`Found PR #${prNumber} for ${branch}`); + + // Verify all required checks passed on the PR head commit. + const { data: checks } = await github.rest.checks.listForRef({ + ...context.repo, + ref: prs[0].head.sha, + }); + const failed = checks.check_runs.filter( + cr => cr.conclusion !== 'success' && cr.conclusion !== 'skipped' && cr.conclusion !== null + ); + if (failed.length > 0) { + for (const cr of failed) { + core.error(`${cr.name}: ${cr.conclusion}`); + } + core.setFailed(`PR #${prNumber} has ${failed.length} failing check(s) — refusing to deploy.`); + return; + } + core.info('All PR checks passed.'); deploy: - needs: [package] - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pluggy' + name: Publish to PyPI + needs: [verify] runs-on: ubuntu-latest permissions: id-token: write + attestations: write + contents: write steps: - - name: Download built packages from the check-package job. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + mkdir dist + gh release download "${GITHUB_REF_NAME}" --dir dist + ls -la dist/ + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - name: Packages - path: dist - - name: Publish package + path: repo + sparse-checkout: | + .github + persist-credentials: false + + - name: Generate build provenance attestations + uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 + with: + subject-path: "dist/*" + + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: attestations: true + + merge: + name: Merge release PR + needs: [verify, deploy] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Merge PR and delete release branch + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + PR_NUMBER: ${{ needs.verify.outputs.pr-number }} + VERSION: ${{ github.ref_name }} + with: + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + const branch = `release-${process.env.VERSION}`; + + await github.rest.pulls.merge({ + ...context.repo, + pull_number: prNumber, + merge_method: 'merge', + commit_title: `Merge release ${process.env.VERSION} (#${prNumber})`, + }); + core.info(`Merged PR #${prNumber}`); + + try { + await github.rest.git.deleteRef({ + ...context.repo, + ref: `heads/${branch}`, + }); + core.info(`Deleted branch ${branch}`); + } catch (e) { + if (e.status !== 422) throw e; + core.info(`Branch ${branch} already deleted.`); + } diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index 9b095bbc..ee4c5394 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -26,7 +26,9 @@ jobs: timeout-minutes: 120 if: >- github.event_name == 'workflow_dispatch' - || (github.event_name == 'pull_request' && contains(github.head_ref, 'downstream')) + || (github.event_name == 'pull_request' + && (startsWith(github.head_ref, 'release-') + || contains(github.head_ref, 'downstream'))) strategy: fail-fast: false matrix: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..de0898d8 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,165 @@ +name: prepare-release + +on: + push: + branches: + - main + +permissions: {} + +jobs: + prepare: + name: Prepare release PR + runs-on: ubuntu-latest + # Only run in the upstream repo, not forks. + if: github.repository == 'pytest-dev/pluggy' + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install "setuptools-scm[toml]>=10.0" towncrier + + - name: Compute next version + id: version + run: | + VERSION=$(python -m setuptools_scm --strip-dev) || exit 0 + if [ -z "$VERSION" ]; then exit 0; fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT" + echo "Computed version: $VERSION" + + - name: Check if release is already published or branch is up-to-date + if: steps.version.outputs.version + id: check + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + VERSION: ${{ steps.version.outputs.version }} + BRANCH: release-${{ steps.version.outputs.version }} + PUSH_SHA: ${{ github.sha }} + with: + script: | + const { VERSION, BRANCH, PUSH_SHA } = process.env; + + // A published (non-draft) release means the version is locked in. + try { + const release = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag: VERSION, + }); + if (!release.data.draft) { + core.notice(`Release ${VERSION} is already published — nothing to do.`); + core.setOutput('skip', 'true'); + return; + } + } catch (e) { + if (e.status !== 404) throw e; + } + + // If the release branch already exists and was built from this + // exact main commit, there is nothing to update. + try { + const commits = await github.rest.repos.listCommits({ + ...context.repo, + sha: BRANCH, + per_page: 2, + }); + if (commits.data.length >= 2 && commits.data[1].sha === PUSH_SHA) { + core.info(`Release branch ${BRANCH} already up-to-date with main.`); + core.setOutput('skip', 'true'); + return; + } + } catch (e) { + if (e.status !== 404 && e.status !== 409) throw e; + } + + - name: Create release branch + if: steps.version.outputs.version && steps.check.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + BRANCH="${{ steps.version.outputs.branch }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -B "$BRANCH" + + towncrier build --yes --version "$VERSION" + + git add -A + git commit -m "Preparing release $VERSION" + git push --force origin "$BRANCH" + + - name: Create or update pull request + if: steps.version.outputs.version && steps.check.outputs.skip != 'true' + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + VERSION: ${{ steps.version.outputs.version }} + BRANCH: release-${{ steps.version.outputs.version }} + with: + script: | + const { VERSION, BRANCH } = process.env; + const body = [ + 'Automated release PR created by CI.', + '', + '## Checklist', + '', + '- [ ] Review the generated CHANGELOG.rst', + '- [ ] Ensure all CI checks pass', + '- [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge', + ].join('\n'); + + // Look for an existing PR from this release branch. + const { data: prs } = await github.rest.pulls.list({ + ...context.repo, + head: `${context.repo.owner}:${BRANCH}`, + state: 'open', + }); + + if (prs.length > 0) { + await github.rest.pulls.update({ + ...context.repo, + pull_number: prs[0].number, + title: `Release ${VERSION}`, + body, + }); + core.info(`Updated existing PR #${prs[0].number}`); + } else { + // Close any stale release PRs for a different version. + const { data: allPrs } = await github.rest.pulls.list({ + ...context.repo, + state: 'open', + }); + for (const pr of allPrs) { + if (pr.head.ref.startsWith('release-') && pr.head.ref !== BRANCH) { + await github.rest.pulls.update({ + ...context.repo, + pull_number: pr.number, + state: 'closed', + }); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pr.number, + body: `Superseded by release ${VERSION}.`, + }); + core.info(`Closed stale PR #${pr.number} (${pr.head.ref})`); + } + } + + const { data: newPr } = await github.rest.pulls.create({ + ...context.repo, + head: BRANCH, + base: 'main', + title: `Release ${VERSION}`, + body, + }); + core.info(`Created new PR #${newPr.number} for ${BRANCH}`); + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d91ed711..d97b53a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,17 @@ jobs: with: fetch-depth: 0 persist-credentials: false + + - name: Detect release branch version + id: release + if: startsWith(github.head_ref || '', 'release-') + run: | + VERSION="${GITHUB_HEAD_REF#release-}" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY=$VERSION" >> "$GITHUB_ENV" + fi + - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 test: @@ -145,3 +156,109 @@ jobs: fail_ci_if_error: true files: ./coverage.xml verbose: true + + release-artifacts: + name: Update draft GitHub release + needs: [check-package, test] + if: >- + github.repository == 'pytest-dev/pluggy' + && github.event_name == 'pull_request' + && startsWith(github.head_ref, 'release-') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Determine version from branch + id: version + run: | + VERSION="${GITHUB_HEAD_REF#release-}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::notice::Branch $GITHUB_HEAD_REF is not a release version branch — skipping." + exit 0 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.version.outputs.version + with: + persist-credentials: false + + - name: Download built packages + if: steps.version.outputs.version + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: Packages + path: dist + + - name: Create or update draft release + if: steps.version.outputs.version + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + env: + VERSION: ${{ steps.version.outputs.version }} + HEAD_REF: ${{ github.head_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { VERSION, HEAD_REF, HEAD_SHA } = process.env; + const tag = VERSION; + const title = `pluggy ${VERSION}`; + const body = `Release ${VERSION} — built from \`${HEAD_REF}\` at ${HEAD_SHA}.`; + + let release; + try { + const { data } = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag, + }); + release = data; + } catch (e) { + if (e.status !== 404) throw e; + } + + if (release && !release.draft) { + core.notice(`Release ${VERSION} is already published — skipping.`); + return; + } + + if (release) { + // Update existing draft: replace assets with freshly tested ones. + await github.rest.repos.updateRelease({ + ...context.repo, + release_id: release.id, + tag_name: tag, + name: title, + body, + draft: true, + target_commitish: HEAD_SHA, + }); + for (const asset of release.assets) { + await github.rest.repos.deleteReleaseAsset({ + ...context.repo, + asset_id: asset.id, + }); + } + } else { + const { data } = await github.rest.repos.createRelease({ + ...context.repo, + tag_name: tag, + name: title, + body, + draft: true, + target_commitish: HEAD_SHA, + }); + release = data; + } + + // Upload all assets from dist/. + for (const file of fs.readdirSync('dist')) { + const filePath = path.join('dist', file); + await github.rest.repos.uploadReleaseAsset({ + ...context.repo, + release_id: release.id, + name: file, + data: fs.readFileSync(filePath), + }); + core.info(`Uploaded ${file}`); + } diff --git a/RELEASING.rst b/RELEASING.rst index a6f58da9..7978bb42 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -1,28 +1,53 @@ Release Procedure ----------------- -#. Depending on the magnitude of the changes in the release, consider testing - some of the large downstream users of pluggy against the upcoming release. - You can do so with ``uv run downstream/run_downstream.py ``; - use ``--list`` to discover available recipes. +Releases are largely automated via GitHub Actions. The version is derived +automatically from changelog fragment types using the ``towncrier-fragments`` +version scheme (``feature`` → minor, ``bugfix`` → patch, ``removal`` → major). -#. From a clean work tree, execute:: +Automated flow +~~~~~~~~~~~~~~ - tox -e release -- VERSION +#. Contributors add changelog fragments (``changelog.d/..rst``) in + their pull requests as usual. + +#. Every push to ``main`` triggers the ``prepare-release`` workflow, which: + + * Computes the next version from the fragment types. + * Creates (or force-updates) a ``release-X.Y.Z`` branch with the + towncrier-built ``CHANGELOG.rst`` committed. + * Opens (or updates) a PR targeting ``main``. + +#. The normal CI (``test`` workflow) runs on the release PR. Once all checks + pass, the built wheel and sdist are uploaded to a **draft GitHub release**. - This will create the branch ready to be pushed. +#. A maintainer reviews the PR and, when satisfied, **publishes the draft + release** in the GitHub UI. -#. Open a PR targeting ``main``. +#. Publishing the release triggers the ``deploy`` workflow, which: -#. All tests must pass and the PR must be approved by at least another maintainer. + * Verifies all PR checks are green. + * Downloads the release assets (bit-identical to the draft). + * Uploads them to PyPI via trusted publishing. + * Merges the release PR into ``main``. + * Cleans up the release branch. -#. Publish to PyPI by pushing a tag:: +Downstream testing +~~~~~~~~~~~~~~~~~~ - git tag X.Y.Z release-X.Y.Z - git push git@github.com:pytest-dev/pluggy.git X.Y.Z +Before publishing a release, consider running downstream integration tests:: - The tag will trigger a new build, which will deploy to PyPI. + uv run downstream/run_downstream.py -#. Make sure it is `available on PyPI `_. +Use ``--list`` to discover available recipes. + +Manual fallback +~~~~~~~~~~~~~~~ + +For local testing or exceptional situations, the legacy script is still +available:: + + tox -e release -- VERSION -#. Merge the PR into ``main``, either manually or using GitHub's web interface. +This creates a ``release-VERSION`` branch with the changelog committed, +ready to be pushed and opened as a PR manually. diff --git a/changelog/186.doc.rst b/changelog.d/186.doc.rst similarity index 100% rename from changelog/186.doc.rst rename to changelog.d/186.doc.rst diff --git a/changelog/431.bugfix.rst b/changelog.d/431.bugfix.rst similarity index 100% rename from changelog/431.bugfix.rst rename to changelog.d/431.bugfix.rst diff --git a/changelog/522.doc.rst b/changelog.d/522.doc.rst similarity index 100% rename from changelog/522.doc.rst rename to changelog.d/522.doc.rst diff --git a/changelog/590.trivial.rst b/changelog.d/590.trivial.rst similarity index 100% rename from changelog/590.trivial.rst rename to changelog.d/590.trivial.rst diff --git a/changelog/629.bugfix.rst b/changelog.d/629.bugfix.rst similarity index 100% rename from changelog/629.bugfix.rst rename to changelog.d/629.bugfix.rst diff --git a/changelog/632.removal.rst b/changelog.d/632.feature.rst similarity index 100% rename from changelog/632.removal.rst rename to changelog.d/632.feature.rst diff --git a/changelog/README.rst b/changelog.d/README.rst similarity index 100% rename from changelog/README.rst rename to changelog.d/README.rst diff --git a/changelog/_template.rst b/changelog.d/_template.rst similarity index 100% rename from changelog/_template.rst rename to changelog.d/_template.rst diff --git a/pyproject.toml b/pyproject.toml index 3adc4454..f36bf2c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ "setuptools>=65.0", - "setuptools-scm[toml]>=8.0", + "setuptools-scm[toml]>=10.0", ] build-backend = "setuptools.build_meta" @@ -67,6 +67,7 @@ known-local-folder = ["pluggy"] lines-after-imports = 2 [tool.setuptools_scm] +version_scheme = "towncrier-fragments" [tool.uv] default-groups = ["dev", "testing"] @@ -75,9 +76,9 @@ default-groups = ["dev", "testing"] package = "pluggy" package_dir = "src/pluggy" filename = "CHANGELOG.rst" -directory = "changelog/" +directory = "changelog.d/" title_format = "pluggy {version} ({project_date})" -template = "changelog/_template.rst" +template = "changelog.d/_template.rst" [[tool.towncrier.type]] directory = "removal" diff --git a/scripts/release.py b/scripts/release.py index 84dc4675..3f9e5051 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,9 +1,21 @@ -""" -Release script. +"""Release script — local fallback for the automated CI pipeline. + +Typical CI usage (automated): + The ``prepare-release`` GitHub Actions workflow uses + ``python -m setuptools_scm --strip-dev`` and handles branching, + towncrier, and PR creation automatically. + +Manual usage:: + + tox -e release # auto-detect version from fragments + tox -e release -- 1.7.0 # explicit version override """ +from __future__ import annotations + import argparse from subprocess import check_call +from subprocess import check_output import sys from colorama import Fore @@ -12,8 +24,22 @@ from git import Repo +def compute_version_auto() -> str: + """Derive the next release version via ``setuptools_scm --strip-dev``.""" + version = ( + check_output( + [sys.executable, "-m", "setuptools_scm", "--strip-dev"], + ) + .decode() + .strip() + ) + if not version: + raise RuntimeError("setuptools_scm returned an empty version.") + return version + + def create_branch(version: str) -> Repo: - """Create a fresh branch from upstream/main""" + """Create a fresh branch from upstream/main.""" repo = Repo.init(".") if repo.is_dirty(untracked_files=True): raise RuntimeError("Repository is dirty, please commit/stash your changes.") @@ -28,7 +54,7 @@ def create_branch(version: str) -> Repo: def get_upstream(repo: Repo) -> Remote: - """Find upstream repository for pluggy on the remotes""" + """Find upstream repository for pluggy on the remotes.""" for remote in repo.remotes: for url in remote.urls: if url.endswith(("pytest-dev/pluggy.git", "pytest-dev/pluggy")): @@ -37,7 +63,7 @@ def get_upstream(repo: Repo) -> Remote: def pre_release(version: str) -> None: - """Generates new docs, release announcements and creates a local tag.""" + """Create release branch and build changelog.""" create_branch(version) changelog(version, write_out=True) @@ -47,27 +73,30 @@ def pre_release(version: str) -> None: print(f"{Fore.GREEN}Please push your branch to your fork and open a PR.") -def changelog(version: str, write_out: bool = False) -> None: - if write_out: - addopts = [] - else: - addopts = ["--draft"] +def changelog(version: str, *, write_out: bool = False) -> None: + addopts: list[str] = [] if write_out else ["--draft"] print(f"{Fore.CYAN}Generating CHANGELOG") - check_call(["towncrier", "build", "--yes", "--version", version] + addopts) + check_call(["towncrier", "build", "--yes", "--version", version, *addopts]) def main() -> int: init(autoreset=True) parser = argparse.ArgumentParser() - parser.add_argument("version", help="Release version") + parser.add_argument( + "version", + nargs="?", + default=None, + help="Release version (auto-detected from fragments if omitted)", + ) options = parser.parse_args() try: - pre_release(options.version) + version = options.version or compute_version_auto() + print(f"{Fore.CYAN}Release version: {version}") + pre_release(version) except RuntimeError as e: print(f"{Fore.RED}ERROR: {e}") return 1 - else: - return 0 + return 0 if __name__ == "__main__": diff --git a/testing/test_tracer.py b/testing/test_tracer.py index c90c78f1..13b29721 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -1,8 +1,15 @@ import pytest +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager from pluggy._tracing import TagTracer +hookspec = HookspecMarker("example") +hookimpl = HookimplMarker("example") + + @pytest.fixture def rootlogger() -> TagTracer: return TagTracer() @@ -75,3 +82,79 @@ def test_setprocessor(rootlogger: TagTracer) -> None: log2("seen") tags, args = l2[0] assert args == ("seen",) + + +def test_plugin_tracing(pm: PluginManager) -> None: + class Api: + @hookspec + def hello(self, arg: object) -> None: + "api hook 1" + + pm.add_hookspecs(Api) + hook = pm.hook + test_hc = hook.hello + + class Plugin: + @hookimpl + def hello(self, arg): + return arg + 1 + + plugin = Plugin() + + trace_out: list[str] = [] + pm.trace.root.setwriter(trace_out.append) + pm.register(plugin) + pm.enable_tracing() + + out = test_hc(arg=3) + assert out == [4] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [4] [hook]\n", + ] + + +def test_dbl_plugin_tracing(pm: PluginManager) -> None: + class Api: + @hookspec + def hello(self, arg: object) -> None: + "api hook 1" + + pm.add_hookspecs(Api) + hook = pm.hook + test_hc = hook.hello + + class Plugin: + @hookimpl + def hello(self, arg): + return arg + 1 + + @hookimpl(specname="hello") + def hello_again(self, arg): + return arg + 100 + + plugin = Plugin() + + trace_out: list[str] = [] + pm.trace.root.setwriter(trace_out.append) + pm.register(plugin) + pm.enable_tracing() + + out = test_hc(arg=3) + assert out == [103, 4] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [103, 4] [hook]\n", + ] + + trace_out.clear() + pm.unregister(plugin) + out = test_hc(arg=3) + assert out == [] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [] [hook]\n", + ] diff --git a/tox.ini b/tox.ini index a09b09cc..fa44ce9e 100644 --- a/tox.ini +++ b/tox.ini @@ -52,5 +52,6 @@ passenv = * deps = colorama gitpython + setuptools-scm[toml]>=10.0 towncrier commands = python scripts/release.py {posargs}