diff --git a/.github/actions/git-https-auth/action.yml b/.github/actions/git-https-auth/action.yml
new file mode 100644
index 000000000..5a45bafb5
--- /dev/null
+++ b/.github/actions/git-https-auth/action.yml
@@ -0,0 +1,11 @@
+name: "Configure git for HTTPS GitHub access"
+description: >-
+ Rewrites git@github.com: URLs to https://github.com/ so a later checkout
+ or clone authenticates over HTTPS (e.g. with a token) instead of SSH.
+
+runs:
+ using: composite
+ steps:
+ - name: Configure git to use HTTPS for GitHub
+ shell: bash
+ run: git config --global url."https://github.com/".insteadOf "git@github.com:"
diff --git a/.github/actions/setup-e2e/action.yml b/.github/actions/setup-e2e/action.yml
index 24f7c4561..a1f6a2fcc 100644
--- a/.github/actions/setup-e2e/action.yml
+++ b/.github/actions/setup-e2e/action.yml
@@ -10,8 +10,7 @@ runs:
using: composite
steps:
- name: Configure git to use HTTPS for GitHub
- shell: bash
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv (with cache) and Python
uses: astral-sh/setup-uv@v7
diff --git a/.github/scripts/post-core-coverage-digest.py b/.github/scripts/post-core-coverage-digest.py
new file mode 100644
index 000000000..01d262743
--- /dev/null
+++ b/.github/scripts/post-core-coverage-digest.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+"""Post a compact weekly digest for a Core consumer-coverage artifact."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+
+LOW_COVERAGE_PERCENT = 80.0
+MAX_MISSED_LINE_RANGES = 8
+MAX_LOW_COVERAGE_FILES = 8
+
+
+@dataclass(frozen=True)
+class FileCoverage:
+ path: str
+ found: int
+ hit: int
+ functions_found: int
+ functions_hit: int
+ missed_lines: tuple[int, ...]
+
+ @property
+ def percent(self) -> float:
+ return 100 * self.hit / self.found if self.found else 100.0
+
+ @property
+ def missed(self) -> int:
+ return self.found - self.hit
+
+
+def display_path(path: str) -> str:
+ marker = "/crates/"
+ return path[path.index(marker) + 1 :] if marker in path else Path(path).name
+
+
+def format_line_ranges(numbers: tuple[int, ...]) -> str:
+ ranges: list[str] = []
+ start = previous = None
+ for number in numbers:
+ if start is None:
+ start = previous = number
+ elif number == previous + 1:
+ previous = number
+ else:
+ ranges.append(str(start) if start == previous else f"{start}-{previous}")
+ start = previous = number
+ if start is not None:
+ ranges.append(str(start) if start == previous else f"{start}-{previous}")
+ shown = ", ".join(ranges[:MAX_MISSED_LINE_RANGES])
+ return (
+ shown
+ if len(ranges) <= MAX_MISSED_LINE_RANGES
+ else f"{shown}, … ({len(ranges) - MAX_MISSED_LINE_RANGES} more ranges)"
+ )
+
+
+def coverage_marker(percent: float) -> str:
+ if percent >= LOW_COVERAGE_PERCENT:
+ return "🟢"
+ if percent >= 50:
+ return "🟠"
+ return "🔴"
+
+
+def parse_lcov(path: Path) -> list[FileCoverage]:
+ records: list[FileCoverage] = []
+ source: str | None = None
+ found = hit = functions_found = functions_hit = 0
+ missed_lines: list[int] = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ if line.startswith("SF:"):
+ source = line[3:]
+ elif line.startswith("LF:"):
+ found = int(line[3:])
+ elif line.startswith("LH:"):
+ hit = int(line[3:])
+ elif line.startswith("FNF:"):
+ functions_found = int(line[4:])
+ elif line.startswith("FNH:"):
+ functions_hit = int(line[4:])
+ elif line.startswith("DA:"):
+ line_number, count = line[3:].split(",", maxsplit=1)
+ if count == "0":
+ missed_lines.append(int(line_number))
+ elif line == "end_of_record" and source is not None:
+ records.append(
+ FileCoverage(
+ display_path(source),
+ found,
+ hit,
+ functions_found,
+ functions_hit,
+ tuple(missed_lines),
+ )
+ )
+ source = None
+ found = hit = functions_found = functions_hit = 0
+ missed_lines = []
+ return records
+
+
+def render_digest(
+ *, lcov_path: Path, label: str, recipients: str, run_url: str, result: str
+) -> str:
+ header = "## 📊 Weekly Core coverage"
+ if not lcov_path.is_file():
+ return "\n".join(
+ [
+ header,
+ recipients,
+ "",
+ f"⚠️ **Coverage unavailable** · workflow `{result}`",
+ "",
+ "No LCOV report was produced. Open the run for the failure details.",
+ "",
+ f"[Open run]({run_url})",
+ ]
+ )
+
+ files = parse_lcov(lcov_path)
+ found = sum(item.found for item in files)
+ hit = sum(item.hit for item in files)
+ functions_found = sum(item.functions_found for item in files)
+ functions_hit = sum(item.functions_hit for item in files)
+ gaps = sorted(
+ (item for item in files if item.percent < LOW_COVERAGE_PERCENT),
+ key=lambda item: (item.percent, -item.found, item.path),
+ )
+ line_percent = 100 * hit / found if found else 0.0
+ function_percent = 100 * functions_hit / functions_found if functions_found else 0.0
+ healthy_files = len(files) - len(gaps)
+ lines = [
+ header,
+ "",
+ recipients,
+ "",
+ f"**{label}**",
+ "",
+ "### Coverage snapshot",
+ "",
+ "| Signal | Result |",
+ "| --- | --- |",
+ f"| Lines | {coverage_marker(line_percent)} **{line_percent:.2f}%** · {hit}/{found} covered · {found - hit} missing |",
+ f"| Functions | {coverage_marker(function_percent)} **{function_percent:.2f}%** · {functions_hit}/{functions_found} covered · {functions_found - functions_hit} missing |",
+ f"| Files at target | {coverage_marker(100 * healthy_files / len(files) if files else 0)} **{healthy_files}/{len(files)}** at or above {LOW_COVERAGE_PERCENT:.0f}% |",
+ "",
+ ]
+ if gaps:
+ lines.extend(
+ [
+ "### 🎯 Where to focus",
+ "",
+ "| Source file | Coverage gap |",
+ "| --- | --- |",
+ ]
+ )
+ lines.extend(
+ f"| `{item.path}` | {coverage_marker(item.percent)} **{item.percent:.2f}%** · {item.missed} lines missing
Lines `{format_line_ranges(item.missed_lines)}` |"
+ for item in gaps[:MAX_LOW_COVERAGE_FILES]
+ )
+ if len(gaps) > MAX_LOW_COVERAGE_FILES:
+ lines.extend(
+ [
+ "",
+ f"_Plus {len(gaps) - MAX_LOW_COVERAGE_FILES} more low-coverage files in the artifact._",
+ ]
+ )
+ else:
+ lines.append("✅ Every measured source file meets the coverage target.")
+ lines.extend(
+ ["", f"[View the run and full coverage artifact →]({run_url}#artifacts)"]
+ )
+ return "\n".join(lines)
+
+
+def main() -> None:
+ digest = render_digest(
+ lcov_path=Path(os.environ["LCOV_PATH"]),
+ label=os.environ["REPORT_LABEL"],
+ recipients=os.environ["RECIPIENTS"],
+ run_url=os.environ["RUN_URL"],
+ result=os.environ["WORKFLOW_RESULT"],
+ )
+ subprocess.run(
+ [
+ "gh",
+ "api",
+ f"repos/{os.environ['REPO']}/commits/{os.environ['SHA']}/comments",
+ "--method",
+ "POST",
+ "-f",
+ f"body={digest}",
+ ],
+ check=True,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61a76cf4d..f46402c6a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Configure git to use HTTPS for GitHub
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
@@ -76,7 +76,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Configure git to use HTTPS for GitHub
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
@@ -124,7 +124,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Configure git to use HTTPS for GitHub
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
@@ -187,7 +187,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Configure git to use HTTPS for GitHub
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
@@ -233,7 +233,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Configure git to use HTTPS for GitHub
- run: git config --global url."https://github.com/".insteadOf "git@github.com:"
+ uses: ./.github/actions/git-https-auth
- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
diff --git a/.github/workflows/python-core-coverage.yml b/.github/workflows/python-core-coverage.yml
new file mode 100644
index 000000000..41c0a8305
--- /dev/null
+++ b/.github/workflows/python-core-coverage.yml
@@ -0,0 +1,193 @@
+name: Python Core Coverage
+
+# Coverage floor for band-sdk-core-core computed from THIS repo's real test
+# suite -- band-sdk-core owns the instrumented build + gate (see its
+# `coverage-python-consumer` just recipe); this workflow only resolves the
+# pinned version, checks that tag out as a sibling, and runs it.
+#
+# No explicit Rust-toolchain setup step: band-sdk-core/rust-toolchain.toml
+# is picked up natively by rustup's own directory-walk resolution the first
+# time a `cargo`/`rustup` command runs with that checkout as its cwd, so
+# there is nothing to duplicate here.
+
+on:
+ # Weekly consumer coverage report. This is separate from the PR gate so it
+ # observes main's current coverage without adding PR notifications.
+ schedule:
+ - cron: "33 20 * * 0" # Sunday night: 23:33 Israel summer time, 22:33 winter time
+ pull_request:
+ branches: [main]
+ paths:
+ - '.github/actions/git-https-auth/**'
+ - '.github/scripts/post-core-coverage-digest.py'
+ - '.github/workflows/python-core-coverage.yml'
+ - 'src/**'
+ - 'tests/**'
+ - 'pyproject.toml'
+ - 'uv.lock'
+ merge_group:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: python-core-coverage-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ coverage:
+ # Dependabot cannot read repository Actions secrets, so it cannot check
+ # out the private Core repository with this workflow's token.
+ if: github.actor != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ # Single source of truth: crates/py/scripts/coverage_python_consumer.py's
+ # REPORT_DIR, relative to band-sdk-core's checkout root.
+ REPORT_DIR: target/python-consumer-coverage/report
+ # Single source of truth for band-sdk-core's git tag naming scheme.
+ CORE_TAG_PREFIX: band-sdk-core-core-v
+ ARTIFACT_DIR: python-core-coverage-artifact
+ steps:
+ - name: Checkout band-sdk-python
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ path: band-sdk-python
+
+ - name: Configure git to use HTTPS for GitHub
+ # Path is relative to $GITHUB_WORKSPACE, not this workflow file --
+ # band-sdk-python is checked out under path: band-sdk-python above,
+ # not at the workspace root.
+ uses: ./band-sdk-python/.github/actions/git-https-auth
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ with:
+ enable-cache: true
+ cache-dependency-glob: "band-sdk-python/**/pyproject.toml"
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
+ with:
+ python-version: '3.12'
+
+ - name: Install band-sdk-python dependencies
+ working-directory: band-sdk-python
+ run: uv sync --all-packages --locked --extra dev
+
+ - name: Resolve pinned band-sdk-core version
+ id: pin
+ working-directory: band-sdk-python
+ run: |
+ version=$(uv run python -c "import importlib.metadata; print(importlib.metadata.version('band-sdk-core'))")
+
+ # Pass the version through the environment rather than embedding it
+ # in the Python source string.
+ VERSION="$version" uv run python -c "
+ import os
+ import sys
+ from packaging.version import Version
+
+ version = os.environ['VERSION']
+ prefix = os.environ['CORE_TAG_PREFIX']
+ if Version(version).is_prerelease:
+ sys.exit(
+ f'band-sdk-core=={version} is a pre-release: {prefix}{version} '
+ 'is never tagged (dev pre-releases publish to PyPI but are never committed '
+ 'or tagged in git), so this gate cannot check it out. Pin a stable release '
+ 'to run this gate.'
+ )
+ "
+
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout band-sdk-core at the pinned version
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ repository: band-ai/band-sdk-core
+ ref: ${{ env.CORE_TAG_PREFIX }}${{ steps.pin.outputs.version }}
+ path: band-sdk-core
+ token: ${{ secrets.CORE_SDK_READ_KEY }}
+
+ - name: Install just and cargo-llvm-cov
+ uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2
+ with:
+ tool: just,cargo-llvm-cov
+
+ - name: Run band-sdk-python's tests against instrumented band-sdk-core-core
+ working-directory: band-sdk-core
+ run: just coverage-python-consumer ../band-sdk-python
+
+ - name: Write coverage summary
+ if: always()
+ # No working-directory: band-sdk-core/ may not exist yet (the
+ # pin step's pre-release guard, or any earlier failure, can skip
+ # its checkout) -- a nonexistent step cwd fails the step outright
+ # before this script runs, before the else branch below ever gets
+ # a chance to report that gracefully.
+ run: |
+ summary="band-sdk-core/$REPORT_DIR/summary.txt"
+ if [ -f "$summary" ]; then
+ {
+ echo "## band-sdk-core-core coverage from band-sdk-python's tests"
+ echo '```'
+ cat "$summary"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "No coverage summary produced -- see the coverage step's log above." >> "$GITHUB_STEP_SUMMARY"
+ fi
+
+ - name: Stage coverage report
+ if: always()
+ run: |
+ report="band-sdk-core/$REPORT_DIR"
+ mkdir -p "$ARTIFACT_DIR"
+ [ ! -f "$report/python-consumer.lcov" ] || cp "$report/python-consumer.lcov" "$ARTIFACT_DIR/"
+ [ ! -f "$report/summary.txt" ] || cp "$report/summary.txt" "$ARTIFACT_DIR/"
+ [ ! -d "$report/html" ] || cp -R "$report/html" "$ARTIFACT_DIR/"
+
+ - name: Upload coverage report
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-core-coverage
+ path: ${{ env.ARTIFACT_DIR }}
+ if-no-files-found: warn
+
+ report-weekly:
+ name: report weekly coverage
+ needs: coverage
+ if: "!cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ SHA: ${{ github.sha }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ WORKFLOW_RESULT: ${{ needs.coverage.result }}
+ REPORT_LABEL: band-sdk-core Python bindings
+ LCOV_PATH: artifacts/python-consumer.lcov
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+
+ - name: Download coverage report
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+ with:
+ name: python-core-coverage
+ path: artifacts
+
+ - name: Read integrations mentions list
+ id: mentions
+ if: github.event_name == 'schedule'
+ run: .github/scripts/read-integrations-mentions.sh
+
+ - name: Post the weekly coverage digest
+ env:
+ RECIPIENTS: ${{ github.event_name == 'schedule' && steps.mentions.outputs.mentions || format('@{0}', github.triggering_actor) }}
+ run: python .github/scripts/post-core-coverage-digest.py
diff --git a/pyproject.toml b/pyproject.toml
index 8d360d50e..ffe54d6cc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,7 +23,7 @@ classifiers = [
dependencies = [
"band-client-rest==0.0.27",
- "band-sdk-core==2.2.0",
+ "band-sdk-core==2.5.0",
"phoenix-channels-python-client>=0.2.4",
"python-dotenv>=1.2.2",
"pydantic>=2.0",
@@ -189,6 +189,7 @@ dev = [
"pytest-rerunfailures>=14.0.0",
"tenacity>=8.0.0",
"pytest-timeout>=2.4.0",
+ "packaging>=23.0", # PEP 440 pre-release detection for the CI coverage-gate version guard
"band-testing-python==0.1.4",
"httpx>=0.24.0", # Already in main deps, for mocking
"pytest-httpx>=0.35.0", # httpx_mock fixture: REST header-emission proof
diff --git a/tests/test_python_core_coverage_workflow.py b/tests/test_python_core_coverage_workflow.py
new file mode 100644
index 000000000..f600df5e8
--- /dev/null
+++ b/tests/test_python_core_coverage_workflow.py
@@ -0,0 +1,265 @@
+"""Structural contracts and guard-script behavior for python-core-coverage.yml."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from tests.paths import REPO_ROOT
+
+WORKFLOW_PATH = REPO_ROOT / ".github/workflows/python-core-coverage.yml"
+
+
+def load_workflow() -> dict[str, Any]:
+ return yaml.load(WORKFLOW_PATH.read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
+
+
+def _step(workflow: dict[str, Any], name: str) -> dict[str, Any]:
+ steps = workflow["jobs"]["coverage"]["steps"]
+ return next(step for step in steps if step.get("name") == name)
+
+
+def _pin_guard_script(run_text: str) -> str:
+ """Isolate the pre-release guard invocation from the pin step's run text."""
+ lines = run_text.splitlines()
+ start = next(i for i, line in enumerate(lines) if line.startswith("VERSION="))
+ end = next(i for i, line in enumerate(lines) if 'echo "version=$version"' in line)
+ return "\n".join(lines[start:end])
+
+
+def _run_pin_guard(version: str) -> subprocess.CompletedProcess[str]:
+ """Execute the real guard script from the workflow against a given version string."""
+ workflow = load_workflow()
+ run_text = _step(workflow, "Resolve pinned band-sdk-core version")["run"]
+ core_tag_prefix = workflow["jobs"]["coverage"]["env"]["CORE_TAG_PREFIX"]
+ script = f'version="{version}"\n{_pin_guard_script(run_text)}'
+ bash = "bash"
+ if sys.platform == "win32":
+ bash = str(Path(os.environ["ProgramFiles"]) / "Git" / "bin" / "bash.exe")
+
+ return subprocess.run(
+ [bash, "--noprofile", "--norc", "-eo", "pipefail", "-c", script],
+ capture_output=True,
+ text=True,
+ env={**os.environ, "CORE_TAG_PREFIX": core_tag_prefix},
+ )
+
+
+def test_pin_step_resolves_version_into_github_output() -> None:
+ step = _step(load_workflow(), "Resolve pinned band-sdk-core version")
+ assert step["id"] == "pin"
+ assert "importlib.metadata.version('band-sdk-core')" in step["run"]
+ assert '>> "$GITHUB_OUTPUT"' in step["run"]
+ assert "version=$version" in step["run"]
+
+
+def test_pin_guard_passes_for_a_stable_version() -> None:
+ result = _run_pin_guard("2.4.0")
+ assert result.returncode == 0, result.stderr
+
+
+def test_pin_guard_rejects_dev_prerelease_versions() -> None:
+ result = _run_pin_guard("2.4.0.dev3")
+ assert result.returncode == 1
+ assert "band-sdk-core==2.4.0.dev3 is a pre-release" in result.stderr
+ assert "band-sdk-core-core-v2.4.0.dev3" in result.stderr
+
+
+def test_pin_guard_rejects_cargo_style_dev_versions() -> None:
+ result = _run_pin_guard("2.4.0-dev.3")
+ assert result.returncode == 1
+ assert "band-sdk-core==2.4.0-dev.3 is a pre-release" in result.stderr
+
+
+def test_checkout_ref_matches_pin_step_output() -> None:
+ step = _step(load_workflow(), "Checkout band-sdk-core at the pinned version")
+ assert (
+ step["with"]["ref"]
+ == "${{ env.CORE_TAG_PREFIX }}${{ steps.pin.outputs.version }}"
+ )
+
+
+def test_core_checkout_uses_the_scoped_read_secret() -> None:
+ workflow = load_workflow()
+ steps = workflow["jobs"]["coverage"]["steps"]
+ checkout = _step(workflow, "Checkout band-sdk-core at the pinned version")
+ names = [step.get("name") for step in steps]
+
+ assert checkout["with"]["token"] == "${{ secrets.CORE_SDK_READ_KEY }}"
+ assert "Generate GitHub App Token (scoped to band-sdk-core)" not in names
+
+
+def test_coverage_job_skips_dependabot_without_the_read_secret() -> None:
+ coverage = load_workflow()["jobs"]["coverage"]
+ assert coverage["if"] == "github.actor != 'dependabot[bot]'"
+
+
+def test_prerelease_guard_runs_before_the_cross_repo_checkout() -> None:
+ steps = load_workflow()["jobs"]["coverage"]["steps"]
+ names = [step.get("name") for step in steps]
+ assert names.index("Resolve pinned band-sdk-core version") < names.index(
+ "Checkout band-sdk-core at the pinned version"
+ )
+
+
+def test_core_tag_prefix_is_a_single_source_of_truth() -> None:
+ workflow = load_workflow()
+ prefix = workflow["jobs"]["coverage"]["env"]["CORE_TAG_PREFIX"]
+ assert prefix == "band-sdk-core-core-v"
+ checkout_ref = _step(workflow, "Checkout band-sdk-core at the pinned version")[
+ "with"
+ ]["ref"]
+ pin_run = _step(workflow, "Resolve pinned band-sdk-core version")["run"]
+ assert "${{ env.CORE_TAG_PREFIX }}" in checkout_ref
+ assert "os.environ['CORE_TAG_PREFIX']" in pin_run
+ assert "band-sdk-core-core-v" not in checkout_ref
+ assert "band-sdk-core-core-v" not in pin_run
+
+
+def test_report_dir_is_a_single_source_of_truth() -> None:
+ workflow = load_workflow()
+ report_dir = workflow["jobs"]["coverage"]["env"]["REPORT_DIR"]
+ summary_run = _step(workflow, "Write coverage summary")["run"]
+ stage_run = _step(workflow, "Stage coverage report")["run"]
+ upload_paths = _step(workflow, "Upload coverage report")["with"]["path"]
+ assert "$REPORT_DIR" in summary_run
+ assert "$REPORT_DIR" in stage_run
+ assert "$ARTIFACT_DIR" in stage_run
+ assert upload_paths == "${{ env.ARTIFACT_DIR }}"
+ assert report_dir not in summary_run
+ assert report_dir not in stage_run
+
+
+def test_coverage_artifact_has_a_single_staging_root() -> None:
+ coverage = load_workflow()["jobs"]["coverage"]
+ assert coverage["env"]["ARTIFACT_DIR"] == "python-core-coverage-artifact"
+
+
+def test_write_coverage_summary_has_no_working_directory() -> None:
+ # band-sdk-core/ may not exist (the pin step's guard, or any earlier
+ # failure, can skip its checkout) -- a nonexistent step cwd fails the
+ # step outright before its own else-branch fallback can run.
+ step = _step(load_workflow(), "Write coverage summary")
+ assert "working-directory" not in step
+ assert step["run"].strip().startswith('summary="band-sdk-core/')
+
+
+def test_write_coverage_summary_falls_back_when_missing() -> None:
+ run_text = _step(load_workflow(), "Write coverage summary")["run"]
+ assert "No coverage summary produced" in run_text
+ assert '>> "$GITHUB_STEP_SUMMARY"' in run_text
+
+
+def test_download_artifact_uses_only_supported_inputs() -> None:
+ report_steps = load_workflow()["jobs"]["report-weekly"]["steps"]
+ step = next(
+ step for step in report_steps if step.get("name") == "Download coverage report"
+ )
+ assert "if-no-files-found" not in step["with"]
+
+
+def test_weekly_report_is_scheduled_and_mentions_the_integrations_roster() -> None:
+ workflow = load_workflow()
+ assert workflow["on"]["schedule"] == [{"cron": "33 20 * * 0"}]
+
+ report = workflow["jobs"]["report-weekly"]
+ assert (
+ report["if"]
+ == "!cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
+ )
+ assert report["permissions"] == {"contents": "write"}
+ report_steps = report["steps"]
+ mention_step = next(
+ step
+ for step in report_steps
+ if step["name"] == "Read integrations mentions list"
+ )
+ digest_step = next(
+ step
+ for step in report_steps
+ if step["name"] == "Post the weekly coverage digest"
+ )
+ assert mention_step["id"] == "mentions"
+ assert mention_step["if"] == "github.event_name == 'schedule'"
+ assert (
+ digest_step["env"]["RECIPIENTS"]
+ == "${{ github.event_name == 'schedule' && steps.mentions.outputs.mentions || format('@{0}', github.triggering_actor) }}"
+ )
+
+
+def test_weekly_digest_identifies_low_and_completely_uncovered_files(
+ tmp_path: Path,
+) -> None:
+ script_path = REPO_ROOT / ".github/scripts/post-core-coverage-digest.py"
+ spec = spec_from_file_location("post_core_coverage_digest", script_path)
+ assert spec is not None and spec.loader is not None
+ module = module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+
+ lcov = tmp_path / "coverage.lcov"
+ lcov.write_text(
+ "\n".join(
+ [
+ "SF:/work/crates/core/src/covered.rs",
+ "FNF:2",
+ "FNH:2",
+ "DA:1,1",
+ "DA:2,1",
+ "LF:10",
+ "LH:10",
+ "end_of_record",
+ "SF:/work/crates/core/src/low.rs",
+ "FNF:2",
+ "FNH:1",
+ "DA:10,1",
+ "DA:11,1",
+ "DA:12,0",
+ "DA:13,0",
+ "LF:10",
+ "LH:2",
+ "end_of_record",
+ "SF:/work/crates/core/src/none.rs",
+ "FNF:1",
+ "FNH:0",
+ "DA:20,0",
+ "DA:21,0",
+ "DA:22,0",
+ "DA:23,0",
+ "LF:4",
+ "LH:0",
+ "end_of_record",
+ ]
+ )
+ )
+
+ digest = module.render_digest(
+ lcov_path=lcov,
+ label="Core",
+ recipients="@bandzalkin",
+ run_url="https://example.test/run",
+ result="success",
+ )
+
+ assert "📊 Weekly Core coverage" in digest
+ assert "| Lines | 🟠 **50.00%** · 12/24 covered · 12 missing |" in digest
+ assert "| Functions | 🟠 **60.00%** · 3/5 covered · 2 missing |" in digest
+ assert "| Files at target | 🔴 **1/3** at or above 80% |" in digest
+ assert (
+ "`crates/core/src/none.rs` | 🔴 **0.00%** · 4 lines missing
Lines `20-23`"
+ in digest
+ )
+ assert (
+ "`crates/core/src/low.rs` | 🔴 **20.00%** · 8 lines missing
Lines `12-13`"
+ in digest
+ )
+ assert "covered.rs" not in digest
+ assert module.format_line_ranges((1, 3, 5, 7, 9, 11, 13, 15, 17)) == (
+ "1, 3, 5, 7, 9, 11, 13, 15, … (1 more ranges)"
+ )
diff --git a/uv.lock b/uv.lock
index ab9022a0a..ca8d19ef4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -643,6 +643,7 @@ dev = [
{ name = "opentelemetry-instrumentation-logging" },
{ name = "opentelemetry-resourcedetector-gcp" },
{ name = "opentelemetry-sdk", version = "1.44.0", source = { registry = "https://pypi.org/simple" } },
+ { name = "packaging" },
{ name = "pre-commit" },
{ name = "pydantic-ai-slim" },
{ name = "pydantic-settings", version = "2.14.2", source = { registry = "https://pypi.org/simple" } },
@@ -780,7 +781,7 @@ requires-dist = [
{ name = "anthropic", marker = "extra == 'dev-parlant'", specifier = ">=0.75.0,<1" },
{ name = "async-lru", specifier = ">=2.3.0" },
{ name = "band-client-rest", specifier = "==0.0.27" },
- { name = "band-sdk-core", specifier = "==2.2.0" },
+ { name = "band-sdk-core", specifier = "==2.5.0" },
{ name = "band-testing-python", marker = "extra == 'dev'", specifier = "==0.1.4" },
{ name = "band-testing-python", marker = "extra == 'dev-crewai'", specifier = "==0.1.4" },
{ name = "band-testing-python", marker = "extra == 'dev-parlant'", specifier = "==0.1.4" },
@@ -847,6 +848,7 @@ requires-dist = [
{ name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'dev'", specifier = ">=1.12.0a0,!=1.13.0" },
{ name = "opentelemetry-resourcedetector-gcp", marker = "extra == 'google-adk'", specifier = ">=1.12.0a0,!=1.13.0" },
{ name = "opentelemetry-sdk", marker = "extra == 'dev'", specifier = ">=1.44.0" },
+ { name = "packaging", marker = "extra == 'dev'", specifier = ">=23.0" },
{ name = "parlant", marker = "extra == 'dev-parlant'", specifier = ">=3.3.2" },
{ name = "parlant", marker = "extra == 'parlant'", specifier = ">=3.3.2" },
{ name = "phoenix-channels-python-client", specifier = ">=0.2.4" },
@@ -925,17 +927,17 @@ provides-extras = ["logging", "desktop", "codex", "opencode", "letta", "pydantic
[[package]]
name = "band-sdk-core"
-version = "2.2.0"
+version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f2/b6/6432cd80745c68517ffb2efb9f8dc5f5d2ad423eaccb002d7a14dd1d28f9/band_sdk_core-2.2.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2eaa8f62a5b806d10993ea4feb73f1c48d3c959b9539bfbcdbd1ffa1f426b998", size = 475871, upload-time = "2026-09-01T11:30:26.358Z" },
- { url = "https://files.pythonhosted.org/packages/9a/1d/a20cbfce00fd32ec5e1145528138fd2b0d8af12dd09c337a59c6c4b79f69/band_sdk_core-2.2.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:52c4c6af089e81d877e34b839694e8b01547d0f25891cf1bc9c717cf32278f59", size = 477493, upload-time = "2026-09-01T11:30:27.934Z" },
- { url = "https://files.pythonhosted.org/packages/e5/d8/4ec2c9a0b37027072e92dbe2912fce0d593ee88c3f96e1281882429588ff/band_sdk_core-2.2.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c99fad0f3edb0fc9a9d42079a2be76d573c4dd7a9579ce4b5aea1d5fabaaf9e", size = 526652, upload-time = "2026-09-01T11:30:29.398Z" },
- { url = "https://files.pythonhosted.org/packages/61/1a/548e4f355517ee7cd4422906055765b955ec543efc95ef4993ac8a2a84ed/band_sdk_core-2.2.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:431e7185d2a8659371a6fb22aa0a3383e935607d1c8944f488eebf70b0821cf7", size = 528152, upload-time = "2026-09-01T11:30:30.953Z" },
- { url = "https://files.pythonhosted.org/packages/37/9a/6b19bf76fc55d4f5a1315ac036506aea71c499651b0fbe328004c9e40011/band_sdk_core-2.2.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1996fb61fb22d5750df214b3d180e183cdbf3f7e36cea960ca8ca2442a93b888", size = 705290, upload-time = "2026-09-01T11:30:32.514Z" },
- { url = "https://files.pythonhosted.org/packages/c7/8a/5bceb2a5732fb51b1fa62d6dea172104ea79080c768a0aa4a4d42ddf5d5c/band_sdk_core-2.2.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8688f4c55c7cd778e5bb2846b6ada03e7c392d9eb8565a698fdb3df6e29faa8b", size = 743509, upload-time = "2026-09-01T11:30:34.036Z" },
- { url = "https://files.pythonhosted.org/packages/0e/00/c267b0fc208f121ad25b41f41a18c5d77c9280049eae8a359821d37c2a33/band_sdk_core-2.2.0-cp311-abi3-win_amd64.whl", hash = "sha256:704bff82b7494f1997df1aa20def09d7431075f0ad0a1dd4895a4b916409a9f2", size = 348322, upload-time = "2026-09-01T11:30:35.664Z" },
- { url = "https://files.pythonhosted.org/packages/79/d5/46a9dce9b20509904d69b181685fd2d1d498c657466dcd58ba0fedb78248/band_sdk_core-2.2.0-cp311-abi3-win_arm64.whl", hash = "sha256:7f49e75f6f890f38ba7366fb9264c8ca01c34f231db7ab8f08f4c6cc9c161295", size = 331334, upload-time = "2026-09-01T11:30:36.826Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e9/086ccc58b8355d06910803753a1b82a0cbaa85a4b1e9159aced764712474/band_sdk_core-2.5.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a7075054abfe11142f3142649f10a529bbbbd075d8f0f0c6b7b39f56cd421807", size = 478010, upload-time = "2026-09-15T12:24:19.407Z" },
+ { url = "https://files.pythonhosted.org/packages/65/4f/72a6e4a50803d7f46f04f9031d5391133dd4d7bd503725b793da76e6b112/band_sdk_core-2.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:f860bb80fb16b45c90e60bab8867840e14cb832f965289279b591ade1404e29a", size = 479901, upload-time = "2026-09-15T12:24:21.811Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/aa/f7e29c6845f04f7ebe8ee8753689f5a3031a54754372f6d37f6f5a5df615/band_sdk_core-2.5.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:581aa0be042625ea30be3854da472865c056604a99f901d46c881d65295a8ac4", size = 531534, upload-time = "2026-09-15T12:24:23.301Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/ae/0dc4072394cbc5d5577e28f13c2fa7ba25747e00ccc3715a518f490168b8/band_sdk_core-2.5.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6c323bde9df4410eec81129fb3cb18fd62fc3d77bc1532863e1b1078afcf3038", size = 530187, upload-time = "2026-09-15T12:24:25.012Z" },
+ { url = "https://files.pythonhosted.org/packages/91/84/0b3f3b1d4f3439dec091e984b0b8a479476ad9211f29b9a978da3f5f7723/band_sdk_core-2.5.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54241dc1d3b78e606e2299dbcdf526c5fe54a7600fde919815e1879e813a1380", size = 709933, upload-time = "2026-09-15T12:24:26.762Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/14/52bb27eabbc036a48e84a59a2c943556d894bc38281606a390b5340abbbb/band_sdk_core-2.5.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:60d684e4a12bec4fad30eca33249c3b6222e9a6a65dc582a4ac5e145605975e9", size = 746454, upload-time = "2026-09-15T12:24:28.217Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/14/dbf8075e7a8902523d2c4736dc16f305d3e778e310e87417ec0a3f328fe2/band_sdk_core-2.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ed4e4a15b0eab970dee152a39388852f6ba25567de4d783abbcd082cc4964e45", size = 351353, upload-time = "2026-09-15T12:24:29.617Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/88/1127d1917a8f0cacbef8f3d510f17f6b71ecfc25ed2f1f16982c48f3cde4/band_sdk_core-2.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:63ff44ff7514e81f951b6b164db484a992006d6915fcc2624e46ffdfcbe12cee", size = 333829, upload-time = "2026-09-15T12:24:31.273Z" },
]
[[package]]