diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3bd84a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# One line ending everywhere, so generated files compare equal on any platform. +* text=auto eol=lf +*.png binary diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 1857704..f8693b8 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -28,10 +28,17 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ env.RELEASE_REF }} + fetch-depth: 0 + - name: Describe the version being packaged + # The tag for this commit is cut after CD starts, so this is the last + # release plus the distance from it, which is what the commit is. + id: version + run: echo "version=$(git describe --tags --always | sed 's/^v//')" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v4 - uses: docker/build-push-action@v7 with: context: . + build-args: VERSION=${{ steps.version.outputs.version }} outputs: type=oci,dest=${{ runner.temp }}/local-llm-router.tar tags: local-llm-router:${{ env.RELEASE_REF }} - uses: actions/upload-artifact@v7 @@ -65,6 +72,8 @@ jobs: - name: Verify the Helm chart matches the manifests run: python -m llm_router.chart --check && helm lint deploy/helm/llm-routing - name: Package the Helm chart + # The released chart, versioned by its tag, is attached to the GitHub + # release; this one is the unversioned build for inspection. run: helm package deploy/helm/llm-routing --destination chart - name: Verify every catalog adapter has a matching recipe run: python -m llm_router.adapters check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41fed88..0bfd8b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,3 +106,14 @@ jobs: --title "$VERSION" \ --target "$RELEASE_SHA" \ --generate-notes + + - name: Attach the Helm chart for this version + if: steps.version.outputs.bump != 'none' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # The chart in the repository carries no version; the tag is the version. + helm package deploy/helm/llm-routing \ + --version "${VERSION#v}" --app-version "${VERSION#v}" --destination dist + gh release upload "$VERSION" dist/*.tgz --repo "${{ github.repository }}" --clobber diff --git a/Dockerfile b/Dockerfile index 21681d5..ced956e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ FROM python:3.13-slim AS builder WORKDIR /build +# The build context carries no git history, so the release version is passed +# in; without it the image reports 0.0.0 rather than guessing. +ARG VERSION=0.0.0 +ENV SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION} COPY pyproject.toml README.md ./ COPY src ./src RUN python -m pip wheel --no-cache-dir --wheel-dir /wheels . diff --git a/README.md b/README.md index 64a01a1..b4c7002 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,9 @@ helm upgrade --install llm-routing deploy/helm/llm-routing \ | `gateway.replicas` | `2` | Starting gateway size. | | `gateway.autoscaling.minReplicas` / `maxReplicas` | `2` / `20` | KEDA bounds. | +Each GitHub release carries the chart packaged at that version. The chart in the repository +has no version of its own: like the Python package, it takes the release tag. + The chart is generated from the manifests and never edited by hand; tests check that it renders exactly what kustomize renders, in both modes. diff --git a/deploy/helm/llm-routing/Chart.yaml b/deploy/helm/llm-routing/Chart.yaml index 8b4be27..0d78b18 100644 --- a/deploy/helm/llm-routing/Chart.yaml +++ b/deploy/helm/llm-routing/Chart.yaml @@ -2,6 +2,8 @@ apiVersion: v2 name: llm-routing description: OpenAI-compatible gateway, policy router and local LLM serving plane. type: application -version: 0.1.0 -appVersion: "0.1.0" +# Set from the release tag when the chart is packaged: +# helm package --version --app-version +version: 0.0.0 +appVersion: "0.0.0" kubeVersion: ">=1.27.0-0" diff --git a/pyproject.toml b/pyproject.toml index 32f589a..d218583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "local-llm-router" -version = "0.1.0" +dynamic = ["version"] description = "OpenAI-compatible gateway and policy router for local LLM inference." readme = "README.md" requires-python = ">=3.11" @@ -57,6 +57,16 @@ dev = [ "types-PyYAML>=6.0.12.20260906,<7", ] +# The version is the release tag. Releases are tagged by the release workflow +# after a merge, so no file in the repository has to be edited to cut one. +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" +# Used when there is no git history to read, as in a source archive. +fallback_version = "0.0.0" + [tool.hatch.build.targets.wheel] packages = ["src/llm_router"] diff --git a/src/llm_router/__init__.py b/src/llm_router/__init__.py index 6838d70..80f5bf8 100644 --- a/src/llm_router/__init__.py +++ b/src/llm_router/__init__.py @@ -1,3 +1,8 @@ """Local LLM inference gateway and routing policy package.""" -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("local-llm-router") +except PackageNotFoundError: # pragma: no cover - running from an uninstalled tree + __version__ = "0.0.0" diff --git a/src/llm_router/app.py b/src/llm_router/app.py index 8a4073e..4032620 100644 --- a/src/llm_router/app.py +++ b/src/llm_router/app.py @@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from opentelemetry.trace import TracerProvider +from llm_router import __version__ from llm_router.admission import ( AdmissionController, AdmissionRejectedError, @@ -259,7 +260,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="Local LLM Inference Router", - version="0.1.0", + version=__version__, lifespan=lifespan, ) diff --git a/src/llm_router/chart.py b/src/llm_router/chart.py index d89395a..c1dc834 100644 --- a/src/llm_router/chart.py +++ b/src/llm_router/chart.py @@ -8,7 +8,6 @@ """ import re -import tomllib from collections.abc import Sequence from pathlib import Path from typing import Any @@ -178,25 +177,23 @@ def _values(source_root: Path) -> str: return "\n".join(lines) + "\n" -def _chart_metadata(source_root: Path) -> str: - project = tomllib.loads((source_root / "pyproject.toml").read_text(encoding="utf-8")) - version = project["project"]["version"] - return ( - "apiVersion: v2\n" - f"name: {CHART_NAME}\n" - "description: OpenAI-compatible gateway, policy router and local LLM serving plane.\n" - "type: application\n" - f"version: {version}\n" - f'appVersion: "{version}"\n' - 'kubeVersion: ">=1.27.0-0"\n' - ) +CHART_METADATA = f"""apiVersion: v2 +name: {CHART_NAME} +description: OpenAI-compatible gateway, policy router and local LLM serving plane. +type: application +# Set from the release tag when the chart is packaged: +# helm package --version --app-version +version: 0.0.0 +appVersion: "0.0.0" +kubeVersion: ">=1.27.0-0" +""" def build_chart(source_root: Path = Path(".")) -> dict[str, str]: """Every file of the chart, keyed by its path inside the chart directory.""" files = { - "Chart.yaml": _chart_metadata(source_root), + "Chart.yaml": CHART_METADATA, "values.yaml": _values(source_root), "templates/_helpers.tpl": HELPERS, "templates/dashboards.yaml": DASHBOARDS, diff --git a/tests/unit/test_chart.py b/tests/unit/test_chart.py index 59511fc..332a807 100644 --- a/tests/unit/test_chart.py +++ b/tests/unit/test_chart.py @@ -42,6 +42,11 @@ def resources(rendered: str) -> dict[Key, dict[str, Any]]: # Kustomize moves a patched variable to the front; order means nothing. for container in document["spec"]["template"]["spec"]["containers"]: container.get("env", []).sort(key=lambda item: item["name"]) + if document["kind"] == "ConfigMap": + # A Windows checkout may hold the same file with different line endings. + document["data"] = { + name: content.replace("\r\n", "\n") for name, content in document["data"].items() + } found[document["kind"], document["metadata"]["name"]] = document return found