From 71795a52eb4a453e9643dd52f33078c2f1e3235d Mon Sep 17 00:00:00 2001 From: Brett Date: Thu, 6 Aug 2026 10:27:56 +1000 Subject: [PATCH 1/2] ci: gate PyPI publish behind full CI + environment approval Adopt the release.yml/python-publish.yml split already used in python-tesla-fleet-api: a reusable workflow_call gate job runs the same lint/type-check/test/build steps CI does on the exact tag SHA, and only then does the pypi environment (protected, OIDC trusted publishing) allow the publish step to run. --- .github/workflows/python-publish.yml | 89 +++-------------------- .github/workflows/release.yml | 105 +++++++++++++++++++++++++++ AGENTS.md | 1 + 3 files changed, 116 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index a26fd01..b7b8aef 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,85 +1,16 @@ name: Publish Python 🐍 distribution 📦 to PyPI -on: push +on: + push: + tags: + - "v*.*.*" jobs: - build: - name: Build distribution 📦 - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.x" - - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: python3 -m build - - name: Store the distribution packages - uses: actions/upload-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes - needs: - - build - runs-on: ubuntu-latest - environment: - name: pypi - url: https://pypi.org/p/teslemetry-stream + release: + name: Release + uses: ./.github/workflows/release.yml + with: + pypi-project-url: https://pypi.org/p/teslemetry-stream permissions: id-token: write - - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - - github-release: - name: >- - Upload to GitHub Release - needs: - - publish-to-pypi - runs-on: ubuntu-latest - - permissions: - contents: write # IMPORTANT: mandatory for making GitHub Releases - - steps: - - name: Download all the dists - uses: actions/download-artifact@v4 - with: - name: python-package-distributions - path: dist/ - - name: Create GitHub Release - env: - GITHUB_TOKEN: ${{ github.token }} - run: >- - gh release create - '${{ github.ref_name }}' - --repo '${{ github.repository }}' - --notes "" - - name: Upload artifacts to GitHub Release - env: - GITHUB_TOKEN: ${{ github.token }} - # Upload to GitHub Release using the `gh` CLI. - # `dist/` contains the built packages, and the - # sigstore-produced signatures and certificates. - run: >- - gh release upload - '${{ github.ref_name }}' dist/** - --repo '${{ github.repository }}' + contents: write diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..583ec71 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release gate & publish + +on: + workflow_call: + inputs: + python-version: + description: Python version to build/test/publish with + required: false + type: string + default: "3.13" + pypi-project-url: + description: PyPI project URL shown on the environment deployment + required: true + type: string + environment-name: + description: Protected GitHub environment gating the publish step + required: false + type: string + default: pypi + +jobs: + gate: + name: Full CI gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python ${{ inputs.python-version }} + run: uv python install ${{ inputs.python-version }} + - name: Install dependencies + run: uv sync --python ${{ inputs.python-version }} + - name: Ruff + run: uv run --with ruff ruff check teslemetry_stream + - name: Mypy + run: uv run --with mypy mypy teslemetry_stream + - name: Run tests + run: | + shopt -s nullglob + files=(tests/test_*.py) + if [ ${#files[@]} -eq 0 ]; then + echo "No test files found under tests/ - failing." >&2 + exit 1 + fi + for f in "${files[@]}"; do + echo "Running $f" + uv run python "$f" + done + - name: Build + run: uv build + - name: Check distribution + run: uv run --with twine twine check dist/* + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish distribution to PyPI + needs: gate + runs-on: ubuntu-latest + environment: + name: ${{ inputs.environment-name }} + url: ${{ inputs.pypi-project-url }} + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Upload distribution to GitHub Release + needs: publish-to-pypi + runs-on: ubuntu-latest + permissions: + contents: write # mandatory for making GitHub Releases + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --generate-notes + - name: Upload artifacts to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/AGENTS.md b/AGENTS.md index f437e8b..6c5d4e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - Energy site events (`teslemetry_stream/energysite.py`) are shaped differently from vehicle signals: `live_status`/`site_info` are flat top-level envelopes (`{createdAt, site_id, isCache?, live_status|site_info}`), not nested under `data`, and the payload is a full opaque document rather than a field delta - there is no per-field config to enable, the server auto-polls subscribed sites. Contract source: Teslemetry/api PR 310 (`src/routes/sse/index.ts`, `liveStatusSchema.ts`, `siteInfoSchema.ts`), flag-gated server-side as of this writing - `tests/test_energysite_events.py` fixtures mirror that PR's schemas. - `energy_totals` (Teslemetry/api PR 316, trimmed by PR 321) is shaped differently again: the site id rides the `id` field, not `site_id` - filter on `id` and `totals`, not `site_id`. It carries a compact cumulative `totals` object (`EnergyHistoryTotals` in `const.py`) instead of a document, fires only when the server's periodic `calendar_history` poll detects a change (silence is not staleness), and has no snapshot-on-connect delivery. As of PR 321 the wire payload is trimmed to `id`/`createdAt`/`totals` plus `isCache` only when true (`product_type`/`topic`/`url` were dropped as redundant with the event's own topic name and site id); `Key.PRODUCT_TYPE`/`Key.TOPIC`/`Key.URL` in `const.py` remain defined for other event kinds but are no longer part of the energy_totals filter. - `site_info` events no longer carry `tariff_content`/`tariff_content_v2` (Teslemetry/api PR 318); the V2 tariff is its own `tariff_content_v2` event/listener (`listen_TariffContentV2`), same envelope shape as `site_info`, with a `None` body meaning an explicit server-side removal rather than "not received yet". Both share the same silence-means-no-change contract - freshness lives in REST, never in event cadence. There is deliberately no library helper recombining `site_info` and `tariff_content_v2` into one document - that would only ever cover the V2 tariff (legacy V1 `tariff_content` has no SSE topic and stays REST-only by design), so it can't actually promise the whole REST-shaped document; a consumer wanting both tariffs together should use the REST site_info endpoint. +- Releases (tag `v*.*.*`) go through `.github/workflows/release.yml`, a reusable `workflow_call` gate: full CI (ruff, mypy, tests, build+twine) must pass on the exact release SHA before the `pypi` environment's protection rules (and its trusted-publishing OIDC) allow `publish-to-pypi` to run; `python-publish.yml` just supplies the tag trigger and calls it. The `pypi` GitHub environment itself (required reviewers, deployment branches) is admin-configured outside this repo's files. - `TeslemetryStream(topics=...)` (Teslemetry/api PR 319) is an optional exact SSE wire-event allowlist sent as the connection's `topics` query param; `SseTopic` in `const.py` is the closed set the server recognizes (must stay in sync with the api's `SSE_TOPICS`), and `SSE_VEHICLE_TOPICS`/`SSE_ENERGY_TOPICS`/`SSE_ALL_TOPICS` are client-side presets - flat per-product-kind lists of exact wire names, deliberately not further split by whether a topic happens to have a connect-time snapshot server-side; that's upstream server behavior, not something this library encodes. Omitting `topics` (`None`) is legacy-all forever - every applicable event delivered unfiltered - and existing callers that never pass it are unaffected. An explicitly empty iterable is rejected with `ValueError` at construction time rather than silently falling back to legacy-all - "no topics" must not mean "all topics", mirroring the server's own 400 on an empty `topics` value. A bare `str`/`SseTopic` is accepted as a single topic rather than iterated character-by-character - `topics` type-checks `str | Iterable[str] | None` precisely because a lone string also satisfies `Iterable[str]`, the classic footgun. `tests/test_sse_topics.py` covers the tariff listener, its null-removal signal, the `topics` param's URL construction, the empty-iterable rejection, and the bare-string/bare-`SseTopic` case. ## Maintaining this file From 890d909c37052ad4c55cc9b2e084bfef6e264e23 Mon Sep 17 00:00:00 2001 From: Brett Date: Thu, 6 Aug 2026 10:38:19 +1000 Subject: [PATCH 2/2] fix(release): run full Python matrix in release gate The gate previously tested only Python 3.13, while ci.yml covers 3.9-3.13, so a compatibility regression on an older version could pass the gate and publish. Split into lint/test/build jobs matching ci.yml's shape, with test running the same version matrix; build (and publish) stay single-version. --- .github/workflows/release.yml | 41 ++++++++++++++++++++++++++++++----- AGENTS.md | 2 +- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 583ec71..57b2f12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,8 @@ on: default: pypi jobs: - gate: - name: Full CI gate + lint: + name: Lint & type-check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -28,14 +28,30 @@ jobs: uses: astral-sh/setup-uv@v5 with: enable-cache: true - - name: Set up Python ${{ inputs.python-version }} - run: uv python install ${{ inputs.python-version }} - name: Install dependencies - run: uv sync --python ${{ inputs.python-version }} + run: uv sync - name: Ruff run: uv run --with ruff ruff check teslemetry_stream - name: Mypy run: uv run --with mypy mypy teslemetry_stream + + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --python ${{ matrix.python-version }} - name: Run tests run: | shopt -s nullglob @@ -48,6 +64,19 @@ jobs: echo "Running $f" uv run python "$f" done + + build: + name: Build distribution + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + - name: Set up Python ${{ inputs.python-version }} + run: uv python install ${{ inputs.python-version }} - name: Build run: uv build - name: Check distribution @@ -60,7 +89,7 @@ jobs: publish-to-pypi: name: Publish distribution to PyPI - needs: gate + needs: build runs-on: ubuntu-latest environment: name: ${{ inputs.environment-name }} diff --git a/AGENTS.md b/AGENTS.md index 6c5d4e6..6e335df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - Energy site events (`teslemetry_stream/energysite.py`) are shaped differently from vehicle signals: `live_status`/`site_info` are flat top-level envelopes (`{createdAt, site_id, isCache?, live_status|site_info}`), not nested under `data`, and the payload is a full opaque document rather than a field delta - there is no per-field config to enable, the server auto-polls subscribed sites. Contract source: Teslemetry/api PR 310 (`src/routes/sse/index.ts`, `liveStatusSchema.ts`, `siteInfoSchema.ts`), flag-gated server-side as of this writing - `tests/test_energysite_events.py` fixtures mirror that PR's schemas. - `energy_totals` (Teslemetry/api PR 316, trimmed by PR 321) is shaped differently again: the site id rides the `id` field, not `site_id` - filter on `id` and `totals`, not `site_id`. It carries a compact cumulative `totals` object (`EnergyHistoryTotals` in `const.py`) instead of a document, fires only when the server's periodic `calendar_history` poll detects a change (silence is not staleness), and has no snapshot-on-connect delivery. As of PR 321 the wire payload is trimmed to `id`/`createdAt`/`totals` plus `isCache` only when true (`product_type`/`topic`/`url` were dropped as redundant with the event's own topic name and site id); `Key.PRODUCT_TYPE`/`Key.TOPIC`/`Key.URL` in `const.py` remain defined for other event kinds but are no longer part of the energy_totals filter. - `site_info` events no longer carry `tariff_content`/`tariff_content_v2` (Teslemetry/api PR 318); the V2 tariff is its own `tariff_content_v2` event/listener (`listen_TariffContentV2`), same envelope shape as `site_info`, with a `None` body meaning an explicit server-side removal rather than "not received yet". Both share the same silence-means-no-change contract - freshness lives in REST, never in event cadence. There is deliberately no library helper recombining `site_info` and `tariff_content_v2` into one document - that would only ever cover the V2 tariff (legacy V1 `tariff_content` has no SSE topic and stays REST-only by design), so it can't actually promise the whole REST-shaped document; a consumer wanting both tariffs together should use the REST site_info endpoint. -- Releases (tag `v*.*.*`) go through `.github/workflows/release.yml`, a reusable `workflow_call` gate: full CI (ruff, mypy, tests, build+twine) must pass on the exact release SHA before the `pypi` environment's protection rules (and its trusted-publishing OIDC) allow `publish-to-pypi` to run; `python-publish.yml` just supplies the tag trigger and calls it. The `pypi` GitHub environment itself (required reviewers, deployment branches) is admin-configured outside this repo's files. +- Releases (tag `v*.*.*`) go through `.github/workflows/release.yml`, a reusable `workflow_call` gate: `lint` + the full `test` python-version matrix (mirrors `ci.yml`) must pass on the exact release SHA before `build` (single Python, build+twine) runs, and only then do the `pypi` environment's protection rules (and its trusted-publishing OIDC) allow `publish-to-pypi`; `python-publish.yml` just supplies the tag trigger and calls it. The `pypi` GitHub environment itself (required reviewers, deployment branches) is admin-configured outside this repo's files. - `TeslemetryStream(topics=...)` (Teslemetry/api PR 319) is an optional exact SSE wire-event allowlist sent as the connection's `topics` query param; `SseTopic` in `const.py` is the closed set the server recognizes (must stay in sync with the api's `SSE_TOPICS`), and `SSE_VEHICLE_TOPICS`/`SSE_ENERGY_TOPICS`/`SSE_ALL_TOPICS` are client-side presets - flat per-product-kind lists of exact wire names, deliberately not further split by whether a topic happens to have a connect-time snapshot server-side; that's upstream server behavior, not something this library encodes. Omitting `topics` (`None`) is legacy-all forever - every applicable event delivered unfiltered - and existing callers that never pass it are unaffected. An explicitly empty iterable is rejected with `ValueError` at construction time rather than silently falling back to legacy-all - "no topics" must not mean "all topics", mirroring the server's own 400 on an empty `topics` value. A bare `str`/`SseTopic` is accepted as a single topic rather than iterated character-by-character - `topics` type-checks `str | Iterable[str] | None` precisely because a lone string also satisfies `Iterable[str]`, the classic footgun. `tests/test_sse_topics.py` covers the tariff listener, its null-removal signal, the `topics` param's URL construction, the empty-iterable rejection, and the bare-string/bare-`SseTopic` case. ## Maintaining this file