diff --git a/.github/scripts/check_datadogpy_vendor.py b/.github/scripts/check_datadogpy_vendor.py new file mode 100644 index 00000000000..ab68cabf10c --- /dev/null +++ b/.github/scripts/check_datadogpy_vendor.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Compare the vendored datadogpy version with the latest release on PyPI. + +Values written to ``$GITHUB_OUTPUT`` (GitHub Actions inter-step data): + outdated - ``"true"`` when the PyPI release is strictly newer than the vendored pin, ``"false"`` otherwise + latest_version - latest version string fetched from PyPI (e.g. ``"0.53.0"``) + vendored_version - version string parsed from ``ddtrace/vendor/__init__.py`` (e.g. ``"0.52.1"``) + +Exit codes: + 0 - completed successfully (regardless of whether the vendored copy is outdated) + 1 - unexpected error (e.g. PyPI unreachable, version string not parseable) +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import sys +import urllib.error +import urllib.request + + +# Use the copy of `packaging` that is already vendored in this repository so +# the workflow step works without a separate `pip install` and the dependency +# version is pinned alongside the rest of the codebase. +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "ddtrace" / "vendor")) + +from packaging.version import InvalidVersion # noqa: E402 +from packaging.version import Version # noqa: E402 + + +PYPI_URL: str = "https://pypi.org/pypi/datadog/json" +VENDOR_INIT: Path = Path(__file__).parent.parent.parent / "ddtrace" / "vendor" / "__init__.py" + + +def _set_output(name: str, value: str) -> None: + """Append ``name=value`` to the GitHub Actions step-output file. + + When run locally (``$GITHUB_OUTPUT`` is unset) the value is printed to + stdout instead so the output is still visible. + """ + github_output: str | None = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as fh: + fh.write(f"{name}={value}\n") + else: + print(f" {name}={value}") + + +def _latest_pypi_version() -> str: + try: + req: urllib.request.Request = urllib.request.Request(PYPI_URL) + with urllib.request.urlopen(req, timeout=10) as resp: # nosec B310 + data: dict[str, dict[str, str]] = json.load(resp) + version: str = data["info"]["version"] + return version + except (urllib.error.URLError, KeyError, json.JSONDecodeError) as exc: + print(f"ERROR: could not fetch latest datadogpy version from PyPI: {exc}", file=sys.stderr) + sys.exit(1) + + +def _vendored_version() -> str: + try: + content: str = VENDOR_INIT.read_text() + except FileNotFoundError: + print(f"ERROR: {VENDOR_INIT} not found", file=sys.stderr) + sys.exit(1) + + # Extract just the dogstatsd section (from its header up to the next package header + # or end of file) so we don't accidentally pick up a Version: line from another + # vendored package. Each section starts with "\n---+\n"; the lookahead + # matches that two-line opener to stop before the next section begins. + section_match: re.Match[str] | None = re.search( + r"^dogstatsd\n-+\n(.*?)(?=\n\w[^\n]*\n-+\n|\Z)", + content, + re.DOTALL | re.MULTILINE, + ) + if not section_match: + print(f"ERROR: dogstatsd section not found in {VENDOR_INIT}", file=sys.stderr) + sys.exit(1) + + section: str = section_match.group(1) + + # Accept both formats, preserving any PEP 440 suffix (rc, post, dev, …): + # "Version: 0.52.1" (clean release after the vendor bump) + # "Version: 0.52.1rc1" (hypothetical pre-release pin) + # "Version: 8e11af2 (0.39.1)" (git-hash + version in parens, legacy) + # The character class [^\s)] stops at whitespace or a closing paren so the + # parenthesised form is handled without a separate branch. + version_match: re.Match[str] | None = re.search( + r"Version:.*?(\d+\.\d+\.\d+[^\s)]*)", + section, + ) + if not version_match: + print(f"ERROR: could not parse vendored datadogpy version from {VENDOR_INIT}", file=sys.stderr) + sys.exit(1) + return version_match.group(1) + + +def _is_outdated(vendored: str, latest: str) -> bool: + """Return True when the PyPI release is strictly newer than the vendored pin. + + Uses ``packaging.version.Version`` for correct PEP 440 ordering so that + post-releases, pre-releases, and dev releases all compare properly:: + + "0.53.0.post1" > "0.53.0" → True (post-release is newer) + "0.53.0rc1" < "0.53.0" → False (pre-release is older) + "0.44.1.dev0" < "0.44.1" → False (dev release is older) + "0.53.0" == "0.53.0" → False (same) + """ + try: + return Version(latest) > Version(vendored) + except InvalidVersion as exc: + print(f"ERROR: cannot compare versions {vendored!r} and {latest!r}: {exc}", file=sys.stderr) + sys.exit(1) + + +def main() -> None: + latest: str = _latest_pypi_version() + vendored: str = _vendored_version() + + print(f"Vendored datadogpy version : {vendored}") + print(f"Latest PyPI version : {latest}") + + outdated: bool = _is_outdated(vendored, latest) + if outdated: + print("⚠ Vendored version is behind PyPI — consider a vendor bump.") + else: + print("✓ Vendored version is up to date.") + + _set_output("outdated", str(outdated).lower()) + _set_output("latest_version", latest) + _set_output("vendored_version", vendored) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/check-vendor-datadogpy.yml b/.github/workflows/check-vendor-datadogpy.yml new file mode 100644 index 00000000000..fdce002c29e --- /dev/null +++ b/.github/workflows/check-vendor-datadogpy.yml @@ -0,0 +1,87 @@ +name: Check vendored datadogpy version + +# Runs weekly and on demand. Opens (or updates) a GitHub issue when the +# latest `datadog` release on PyPI is ahead of the version pinned in +# ddtrace/vendor/__init__.py, so the team knows to evaluate a vendor bump. + +on: + schedule: + # Every Monday at 09:00 UTC + - cron: "0 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + # Skip scheduled runs on forks to avoid unintended automation. + if: github.event_name != 'schedule' || github.event.repository.fork == false + name: Compare vendored vs PyPI version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Check datadogpy vendor version + id: check + run: python .github/scripts/check_datadogpy_vendor.py + + - name: Open or update tracking issue + if: steps.check.outputs.outdated == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const title = `chore(vendor): bump vendored datadogpy to ${process.env.LATEST_VERSION}`; + const body = [ + `The \`datadog\` package on PyPI has a new release: **${process.env.LATEST_VERSION}**`, + `The version currently vendored in \`ddtrace/vendor/\` is **${process.env.VENDORED_VERSION}**.`, + ``, + `cc @DataDog/python-guild`, + ``, + `## Next steps`, + `1. Review the [datadogpy changelog](https://github.com/DataDog/datadogpy/blob/master/CHANGELOG.md) for changes across the \`datadog/dogstatsd/\` module (any file, not just \`container.py\`).`, + `2. Update the vendored copy of all changed files following the instructions in \`ddtrace/vendor/__init__.py\`.`, + `3. Re-apply any local patches documented in \`ddtrace/vendor/__init__.py\` under the \`dogstatsd\` section.`, + `4. Close this issue once the vendor bump lands.`, + ``, + `_This issue was opened automatically by the [check-vendor-datadogpy](${{ github.server_url }}/${{ github.repository }}/actions/workflows/check-vendor-datadogpy.yml) workflow._`, + ].join('\n'); + + // Check if a tracking issue already exists (avoid duplicates) + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'vendor-bump', + }); + + const alreadyOpen = existing.data.find(i => i.title.includes('datadogpy')); + if (alreadyOpen) { + console.log(`Issue #${alreadyOpen.number} already open — updating body`); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: alreadyOpen.number, + title, + body, + }); + } else { + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['vendor-bump'], + }); + console.log(`Opened issue #${created.data.number}`); + } + env: + LATEST_VERSION: ${{ steps.check.outputs.latest_version }} + VENDORED_VERSION: ${{ steps.check.outputs.vendored_version }}