From c62e1514242624c85ad16f9e2241f5c4a1803101 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 2 Oct 2026 16:18:33 -0700 Subject: [PATCH 1/3] Add CI check for breaking changes to util-genai public API --- .../instructions/util-genai.instructions.md | 4 + .github/workflows/misc.yml | 23 ++++ AGENTS.md | 8 ++ dev-requirements.txt | 1 + scripts/check_util_api.py | 109 ++++++++++++++++++ tox.ini | 8 ++ .../api_breaks_allowlist.txt | 6 + 7 files changed, 159 insertions(+) create mode 100755 scripts/check_util_api.py create mode 100644 util/opentelemetry-util-genai/api_breaks_allowlist.txt diff --git a/.github/instructions/util-genai.instructions.md b/.github/instructions/util-genai.instructions.md index 5863310ff..704c6cf79 100644 --- a/.github/instructions/util-genai.instructions.md +++ b/.github/instructions/util-genai.instructions.md @@ -37,6 +37,10 @@ land the semconv change first. - Do not remove or rename public objects. Deprecate first via a docstring note pointing to the replacement (not `@deprecated` — unreliable). +- The `check-util-api` CI job fails when a public object is removed or its signature changes + compared to the latest util release. Flag new entries in `api_breaks_allowlist.txt` unless the + PR shows that no released instrumentation uses the object, in this repo or in + opentelemetry-python-contrib. - Private modules and module-private objects start with `_`. - Default to internal (`_`-prefixed) unless instrumentations need it public. - Flag every `Any` in the public API (anything not under a `_`-prefixed module) — parameters, diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index cfa11f162..3d18e5b52 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -194,6 +194,29 @@ jobs: - name: Run dependency check run: uv run --with packaging scripts/check_deps.py + check-util-api: + name: check-util-api + runs-on: ubuntu-slim + timeout-minutes: 10 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # needs the latest opentelemetry-util-genai release tag as the baseline + fetch-depth: 0 + + - name: Set up Python 3.14 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + + - name: Set up uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + + - name: Check util public API + run: uvx --with tox-uv tox -e check-util-api + license-header-check: name: license-header-check runs-on: ubuntu-slim diff --git a/AGENTS.md b/AGENTS.md index 0f57d49b7..d47d5a745 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,12 +127,20 @@ uv run tox -e py314-test-instrumentation-genai-openai-conformance # Type check (pyright) uv run tox -e typecheck + +# Check util-genai for public API breaks against its latest release +uv run tox -e check-util-api ``` Before opening a PR, run `uv run tox -e precommit`, `uv run tox -e typecheck`, and the changed package's test envs (`-oldest` and `-latest`, plus `-conformance` if it ships scenarios) — these mirror the CI gates. +If you change `util/opentelemetry-util-genai`, also run `uv run tox -e check-util-api`. Released +instrumentations accept any util version below 2, so removing or changing a public object can +break them on import. If the break is intended, check that no released instrumentation uses the +object, then add its path to `util/opentelemetry-util-genai/api_breaks_allowlist.txt`. + tox reuses cached envs and won't re-resolve dependencies on its own, so pass `--recreate` (`-r`) after editing a `tests/requirements.*.txt` or a `pyproject.toml` dependency bound — otherwise the run silently uses the previously installed versions. diff --git a/dev-requirements.txt b/dev-requirements.txt index 875483afd..d70a779c6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -8,5 +8,6 @@ readme-renderer==46.0 bleach==6.4.0 # transient dependency for readme-renderer markupsafe>=3.0.3 codespell==2.4.3 +griffe==2.3.0 pre-commit==4.6.2 ruff==0.16.10 diff --git a/scripts/check_util_api.py b/scripts/check_util_api.py new file mode 100755 index 000000000..f8634fefd --- /dev/null +++ b/scripts/check_util_api.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Fail on breaking changes to the opentelemetry-util-genai public API. + +Instrumentations released from this repo accept any util version below 2, so +every 1.x util release has to keep working with them. This script uses griffe +to compare the working tree with the latest util release tag, or with +``--against ``. + +Intended breaks go into ``api_breaks_allowlist.txt`` in the util package +directory, one object path per line. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +import griffe +from packaging.version import Version + +ROOT = Path(__file__).resolve().parent.parent +UTIL_DIR = ROOT / "util" / "opentelemetry-util-genai" +SEARCH_PATH = "util/opentelemetry-util-genai/src" +PACKAGE = "opentelemetry.util.genai" +TAG_PREFIX = "opentelemetry-util-genai==" +ALLOWLIST = UTIL_DIR / "api_breaks_allowlist.txt" + + +def _latest_release_ref() -> str: + tags = subprocess.run( + ["git", "tag", "--list", f"{TAG_PREFIX}*"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.split() + if not tags: + sys.exit(f"No {TAG_PREFIX}* tags found; fetch tags or pass --against.") + latest = max(tags, key=lambda t: Version(t.removeprefix(TAG_PREFIX))) + # griffe treats refs containing "==" as PyPI specs, so pass the commit. + return subprocess.run( + ["git", "rev-list", "-n1", latest], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _load_allowlist() -> set[str]: + if not ALLOWLIST.exists(): + return set() + lines = (line.split("#", 1)[0].strip() for line in ALLOWLIST.open()) + return {line for line in lines if line} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--against", + help="git ref to compare against (default: latest util release tag)", + ) + args = parser.parse_args() + ref: str = args.against or _latest_release_ref() + + old = griffe.load_git( + PACKAGE, ref=ref, repo=ROOT, search_paths=[SEARCH_PATH] + ) + new = griffe.load(PACKAGE, search_paths=[str(ROOT / SEARCH_PATH)]) + + allowed = _load_allowlist() + used: set[str] = set() + failures: list[str] = [] + for breakage in griffe.find_breaking_changes(old, new): + path = breakage.obj.path + # Fires on any change to an attribute initializer, not an API break. + if breakage.kind is griffe.BreakageKind.ATTRIBUTE_CHANGED_VALUE: + continue + if path in allowed: + used.add(path) + continue + failures.append( + breakage.explain(style=griffe.ExplanationStyle.ONE_LINE) + ) + + for path in sorted(allowed - used): + print(f"note: stale allowlist entry, can be removed: {path}") + + if failures: + print(f"Breaking changes to {PACKAGE} public API vs {ref}:") + for failure in failures: + print(f" {failure}") + print( + "\nReleased instrumentations accept any util <2 and may rely on " + "these APIs. Restore them, or if no released package uses them, " + f"add the object paths to {ALLOWLIST.relative_to(ROOT)}." + ) + return 1 + print(f"No breaking changes to {PACKAGE} public API vs {ref}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tox.ini b/tox.ini index 94bc70745..08585fe12 100644 --- a/tox.ini +++ b/tox.ini @@ -384,6 +384,14 @@ deps = commands = python {toxinidir}/scripts/check_deps.py +[testenv:check-util-api] +description = Fail on breaking changes to the opentelemetry-util-genai public API vs its latest release. +deps = + -c {toxinidir}/dev-requirements.txt + griffe + packaging +commands = + python {toxinidir}/scripts/check_util_api.py {posargs} [testenv:changelog-preview] description = Render a draft changelog for every package that uses towncrier. diff --git a/util/opentelemetry-util-genai/api_breaks_allowlist.txt b/util/opentelemetry-util-genai/api_breaks_allowlist.txt new file mode 100644 index 000000000..d8370a427 --- /dev/null +++ b/util/opentelemetry-util-genai/api_breaks_allowlist.txt @@ -0,0 +1,6 @@ +# Public API breaks allowed against the latest opentelemetry-util-genai release. +# scripts/check_util_api.py reads this file. One object path per line. +# +# Add an entry only after checking that no released instrumentation uses it, +# in this repo or in opentelemetry-python-contrib. The script reports entries +# as stale once the next util version ships, so remove them then. From cbfba35d47bac7430fd916c2616e079380ecf612 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 2 Oct 2026 16:34:42 -0700 Subject: [PATCH 2/3] Use griffe CLI and approval label, matching otel-python core --- .../instructions/util-genai.instructions.md | 5 +- .github/workflows/misc.yml | 5 +- AGENTS.md | 6 +- scripts/check_util_api.py | 109 ------------------ tox.ini | 5 +- .../api_breaks_allowlist.txt | 6 - 6 files changed, 11 insertions(+), 125 deletions(-) delete mode 100755 scripts/check_util_api.py delete mode 100644 util/opentelemetry-util-genai/api_breaks_allowlist.txt diff --git a/.github/instructions/util-genai.instructions.md b/.github/instructions/util-genai.instructions.md index 704c6cf79..5653d282f 100644 --- a/.github/instructions/util-genai.instructions.md +++ b/.github/instructions/util-genai.instructions.md @@ -38,9 +38,8 @@ land the semconv change first. - Do not remove or rename public objects. Deprecate first via a docstring note pointing to the replacement (not `@deprecated` — unreliable). - The `check-util-api` CI job fails when a public object is removed or its signature changes - compared to the latest util release. Flag new entries in `api_breaks_allowlist.txt` unless the - PR shows that no released instrumentation uses the object, in this repo or in - opentelemetry-python-contrib. + compared to main. When a PR does this on purpose, ask it to list which released + instrumentations it checked, in this repo and in opentelemetry-python-contrib. - Private modules and module-private objects start with `_`. - Default to internal (`_`-prefixed) unless instrumentations need it public. - Flag every `Any` in the public API (anything not under a `_`-prefixed module) — parameters, diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index 3d18e5b52..a649b2330 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -198,12 +198,15 @@ jobs: name: check-util-api runs-on: ubuntu-slim timeout-minutes: 10 + if: | + !contains(github.event.pull_request.labels.*.name, 'Approve Public API check') + && github.event.pull_request.user.login != 'otelbot[bot]' && github.event_name == 'pull_request' steps: - name: Checkout repo @ SHA - ${{ github.sha }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # needs the latest opentelemetry-util-genai release tag as the baseline + # griffe compares against origin/main fetch-depth: 0 - name: Set up Python 3.14 diff --git a/AGENTS.md b/AGENTS.md index d47d5a745..44f6cfae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,7 @@ uv run tox -e py314-test-instrumentation-genai-openai-conformance # Type check (pyright) uv run tox -e typecheck -# Check util-genai for public API breaks against its latest release +# Check util-genai for public API breaks against main uv run tox -e check-util-api ``` @@ -138,8 +138,8 @@ the CI gates. If you change `util/opentelemetry-util-genai`, also run `uv run tox -e check-util-api`. Released instrumentations accept any util version below 2, so removing or changing a public object can -break them on import. If the break is intended, check that no released instrumentation uses the -object, then add its path to `util/opentelemetry-util-genai/api_breaks_allowlist.txt`. +break them on import. If the break is intended, say in the PR which released instrumentations you +checked. A maintainer then adds the `Approve Public API check` label to skip the CI job. tox reuses cached envs and won't re-resolve dependencies on its own, so pass `--recreate` (`-r`) after editing a `tests/requirements.*.txt` or a `pyproject.toml` dependency bound — otherwise the diff --git a/scripts/check_util_api.py b/scripts/check_util_api.py deleted file mode 100755 index f8634fefd..000000000 --- a/scripts/check_util_api.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python3 -# Copyright The OpenTelemetry Authors -# SPDX-License-Identifier: Apache-2.0 - -"""Fail on breaking changes to the opentelemetry-util-genai public API. - -Instrumentations released from this repo accept any util version below 2, so -every 1.x util release has to keep working with them. This script uses griffe -to compare the working tree with the latest util release tag, or with -``--against ``. - -Intended breaks go into ``api_breaks_allowlist.txt`` in the util package -directory, one object path per line. -""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path - -import griffe -from packaging.version import Version - -ROOT = Path(__file__).resolve().parent.parent -UTIL_DIR = ROOT / "util" / "opentelemetry-util-genai" -SEARCH_PATH = "util/opentelemetry-util-genai/src" -PACKAGE = "opentelemetry.util.genai" -TAG_PREFIX = "opentelemetry-util-genai==" -ALLOWLIST = UTIL_DIR / "api_breaks_allowlist.txt" - - -def _latest_release_ref() -> str: - tags = subprocess.run( - ["git", "tag", "--list", f"{TAG_PREFIX}*"], - cwd=ROOT, - check=True, - capture_output=True, - text=True, - ).stdout.split() - if not tags: - sys.exit(f"No {TAG_PREFIX}* tags found; fetch tags or pass --against.") - latest = max(tags, key=lambda t: Version(t.removeprefix(TAG_PREFIX))) - # griffe treats refs containing "==" as PyPI specs, so pass the commit. - return subprocess.run( - ["git", "rev-list", "-n1", latest], - cwd=ROOT, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - -def _load_allowlist() -> set[str]: - if not ALLOWLIST.exists(): - return set() - lines = (line.split("#", 1)[0].strip() for line in ALLOWLIST.open()) - return {line for line in lines if line} - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--against", - help="git ref to compare against (default: latest util release tag)", - ) - args = parser.parse_args() - ref: str = args.against or _latest_release_ref() - - old = griffe.load_git( - PACKAGE, ref=ref, repo=ROOT, search_paths=[SEARCH_PATH] - ) - new = griffe.load(PACKAGE, search_paths=[str(ROOT / SEARCH_PATH)]) - - allowed = _load_allowlist() - used: set[str] = set() - failures: list[str] = [] - for breakage in griffe.find_breaking_changes(old, new): - path = breakage.obj.path - # Fires on any change to an attribute initializer, not an API break. - if breakage.kind is griffe.BreakageKind.ATTRIBUTE_CHANGED_VALUE: - continue - if path in allowed: - used.add(path) - continue - failures.append( - breakage.explain(style=griffe.ExplanationStyle.ONE_LINE) - ) - - for path in sorted(allowed - used): - print(f"note: stale allowlist entry, can be removed: {path}") - - if failures: - print(f"Breaking changes to {PACKAGE} public API vs {ref}:") - for failure in failures: - print(f" {failure}") - print( - "\nReleased instrumentations accept any util <2 and may rely on " - "these APIs. Restore them, or if no released package uses them, " - f"add the object paths to {ALLOWLIST.relative_to(ROOT)}." - ) - return 1 - print(f"No breaking changes to {PACKAGE} public API vs {ref}.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tox.ini b/tox.ini index 08585fe12..41b637715 100644 --- a/tox.ini +++ b/tox.ini @@ -385,13 +385,12 @@ commands = python {toxinidir}/scripts/check_deps.py [testenv:check-util-api] -description = Fail on breaking changes to the opentelemetry-util-genai public API vs its latest release. +description = Fail on breaking changes to the opentelemetry-util-genai public API vs main. deps = -c {toxinidir}/dev-requirements.txt griffe - packaging commands = - python {toxinidir}/scripts/check_util_api.py {posargs} + griffe check opentelemetry.util.genai -s util/opentelemetry-util-genai/src -a origin/main [testenv:changelog-preview] description = Render a draft changelog for every package that uses towncrier. diff --git a/util/opentelemetry-util-genai/api_breaks_allowlist.txt b/util/opentelemetry-util-genai/api_breaks_allowlist.txt deleted file mode 100644 index d8370a427..000000000 --- a/util/opentelemetry-util-genai/api_breaks_allowlist.txt +++ /dev/null @@ -1,6 +0,0 @@ -# Public API breaks allowed against the latest opentelemetry-util-genai release. -# scripts/check_util_api.py reads this file. One object path per line. -# -# Add an entry only after checking that no released instrumentation uses it, -# in this repo or in opentelemetry-python-contrib. The script reports entries -# as stale once the next util version ships, so remove them then. From 763f83fe5539ee50f547991f45484f328bedf9c7 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sun, 4 Oct 2026 10:09:28 -0700 Subject: [PATCH 3/3] Compare util API against the PR base branch Assisted-by: ChatGPT --- .github/workflows/misc.yml | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index a649b2330..51f645f87 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -206,7 +206,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # griffe compares against origin/main + # griffe needs the PR base branch available for comparison. fetch-depth: 0 - name: Set up Python 3.14 diff --git a/tox.ini b/tox.ini index 41b637715..5b299837b 100644 --- a/tox.ini +++ b/tox.ini @@ -385,12 +385,12 @@ commands = python {toxinidir}/scripts/check_deps.py [testenv:check-util-api] -description = Fail on breaking changes to the opentelemetry-util-genai public API vs main. +description = Fail on breaking changes to the opentelemetry-util-genai public API vs the PR base branch (main locally). deps = -c {toxinidir}/dev-requirements.txt griffe commands = - griffe check opentelemetry.util.genai -s util/opentelemetry-util-genai/src -a origin/main + griffe check opentelemetry.util.genai -s util/opentelemetry-util-genai/src -a origin/{env:GITHUB_BASE_REF:main} [testenv:changelog-preview] description = Render a draft changelog for every package that uses towncrier.