From ba8b92039bee993fe447592209c005fa79119c1a Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 12:56:13 +0200 Subject: [PATCH 01/10] refactor(adapters): rename to continuo--adapter (dist+module) Rename both engine adapter distributions and their import modules together so pip name and import name stay in sync. Entry-point group and image build are unchanged; a new guard test locks dist<->module<->entry-point agreement. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- adapters/postgres/README.md | 2 +- .../__init__.py | 0 .../adapter.py | 2 +- adapters/postgres/pyproject.toml | 6 +-- .../tests/test_adapter_postgres_validation.py | 2 +- .../tests/test_adapter_runtime_postgres.py | 4 +- .../test_integration_postgres_validation.py | 2 +- .../test_integration_runtime_postgres.py | 2 +- adapters/trino/README.md | 4 +- .../__init__.py | 0 .../adapter.py | 2 +- adapters/trino/pyproject.toml | 6 +-- .../trino/tests/test_adapter_runtime_trino.py | 4 +- .../tests/test_adapter_trino_validation.py | 2 +- .../tests/test_integration_runtime_trino.py | 4 +- .../test_integration_trino_validation.py | 2 +- tests/test_adapter_naming.py | 41 +++++++++++++++++++ uv.lock | 38 ++++++++--------- 18 files changed, 82 insertions(+), 41 deletions(-) rename adapters/postgres/{continuo_python_runtime_postgres => continuo_postgres_adapter}/__init__.py (100%) rename adapters/postgres/{continuo_python_runtime_postgres => continuo_postgres_adapter}/adapter.py (99%) rename adapters/trino/{continuo_python_runtime_trino => continuo_trino_adapter}/__init__.py (100%) rename adapters/trino/{continuo_python_runtime_trino => continuo_trino_adapter}/adapter.py (99%) create mode 100644 tests/test_adapter_naming.py diff --git a/adapters/postgres/README.md b/adapters/postgres/README.md index 673019b..dbfc11d 100644 --- a/adapters/postgres/README.md +++ b/adapters/postgres/README.md @@ -1,4 +1,4 @@ -# continuo-python-runtime-postgres +# continuo-postgres-adapter Postgres engine-adapter library for Continuo python nodes. `PostgresAdapter` implements `continuo_engine_contract.port.WarehouseAdapter` (from the diff --git a/adapters/postgres/continuo_python_runtime_postgres/__init__.py b/adapters/postgres/continuo_postgres_adapter/__init__.py similarity index 100% rename from adapters/postgres/continuo_python_runtime_postgres/__init__.py rename to adapters/postgres/continuo_postgres_adapter/__init__.py diff --git a/adapters/postgres/continuo_python_runtime_postgres/adapter.py b/adapters/postgres/continuo_postgres_adapter/adapter.py similarity index 99% rename from adapters/postgres/continuo_python_runtime_postgres/adapter.py rename to adapters/postgres/continuo_postgres_adapter/adapter.py index a595539..6e27e55 100644 --- a/adapters/postgres/continuo_python_runtime_postgres/adapter.py +++ b/adapters/postgres/continuo_postgres_adapter/adapter.py @@ -39,7 +39,7 @@ from psycopg2 import sql as pg_sql # type: ignore[import-untyped] from psycopg2.extras import execute_values # type: ignore[import-untyped] -logger = logging.getLogger("continuo_python_runtime_postgres") +logger = logging.getLogger("continuo_postgres_adapter") # The postgres physical-layout vocabulary, mirroring dbt-postgres's own `indexes` # config so the graph reads a python node's layout the way it reads a dbt model's. diff --git a/adapters/postgres/pyproject.toml b/adapters/postgres/pyproject.toml index b0c8a5d..480ce57 100644 --- a/adapters/postgres/pyproject.toml +++ b/adapters/postgres/pyproject.toml @@ -1,6 +1,6 @@ [project] version = "0.2.0" -name = "continuo-python-runtime-postgres" +name = "continuo-postgres-adapter" description = "Postgres warehouse adapter for Continuo: validation and python-node runtime." authors = [{ name = "Simone Carolini" }] maintainers = [{ name = "Simone Carolini" }] @@ -23,7 +23,7 @@ classifiers = [ ] [project.entry-points."continuo_engine.adapters"] -postgres = "continuo_python_runtime_postgres.adapter:PostgresAdapter" +postgres = "continuo_postgres_adapter.adapter:PostgresAdapter" [dependency-groups] dev = [ @@ -40,7 +40,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["continuo_python_runtime_postgres"] +packages = ["continuo_postgres_adapter"] [tool.ruff] target-version = "py312" diff --git a/adapters/postgres/tests/test_adapter_postgres_validation.py b/adapters/postgres/tests/test_adapter_postgres_validation.py index dd2a804..828f415 100644 --- a/adapters/postgres/tests/test_adapter_postgres_validation.py +++ b/adapters/postgres/tests/test_adapter_postgres_validation.py @@ -7,7 +7,7 @@ """ import pytest -from continuo_python_runtime_postgres.adapter import PostgresAdapter, _index_name, _validated_indexes +from continuo_postgres_adapter.adapter import PostgresAdapter, _index_name, _validated_indexes def test_required_env_names_connection_vars(): diff --git a/adapters/postgres/tests/test_adapter_runtime_postgres.py b/adapters/postgres/tests/test_adapter_runtime_postgres.py index fa5e883..7cf72a9 100644 --- a/adapters/postgres/tests/test_adapter_runtime_postgres.py +++ b/adapters/postgres/tests/test_adapter_runtime_postgres.py @@ -25,9 +25,9 @@ import pytest from continuo_engine_contract.types import validate_column_type # type: ignore[import-untyped] -import continuo_python_runtime_postgres.adapter as adapter_module +import continuo_postgres_adapter.adapter as adapter_module -from continuo_python_runtime_postgres.adapter import ( +from continuo_postgres_adapter.adapter import ( PostgresAdapter, _arrow_table_from_rows, _index_name, diff --git a/adapters/postgres/tests/test_integration_postgres_validation.py b/adapters/postgres/tests/test_integration_postgres_validation.py index a0affb9..054ace2 100644 --- a/adapters/postgres/tests/test_integration_postgres_validation.py +++ b/adapters/postgres/tests/test_integration_postgres_validation.py @@ -6,7 +6,7 @@ import psycopg2 import pytest -from continuo_python_runtime_postgres.adapter import PostgresAdapter +from continuo_postgres_adapter.adapter import PostgresAdapter PG = dict( host="localhost", diff --git a/adapters/postgres/tests/test_integration_runtime_postgres.py b/adapters/postgres/tests/test_integration_runtime_postgres.py index 26fd03e..1557456 100644 --- a/adapters/postgres/tests/test_integration_runtime_postgres.py +++ b/adapters/postgres/tests/test_integration_runtime_postgres.py @@ -14,7 +14,7 @@ import yaml from continuo_python_runtime.harness import run_node -from continuo_python_runtime_postgres.adapter import PostgresAdapter +from continuo_postgres_adapter.adapter import PostgresAdapter PG = dict( host="localhost", diff --git a/adapters/trino/README.md b/adapters/trino/README.md index eccdb76..2d5d8a3 100644 --- a/adapters/trino/README.md +++ b/adapters/trino/README.md @@ -1,4 +1,4 @@ -# continuo-python-runtime-trino +# continuo-trino-adapter Trino (Iceberg connector) engine-adapter library for Continuo python nodes. `TrinoAdapter` implements `continuo_engine_contract.port.WarehouseAdapter` @@ -24,7 +24,7 @@ the adapter module docstring. Unlike the postgres runtime adapter, Trino/Iceberg has no multi-statement transactions, so `load()` cannot be a single atomic TRUNCATE+INSERT. Two atomic-replace primitives were verified live (Trino 483 + Iceberg REST catalog) -before choosing one — see the full writeup in `continuo_python_runtime_trino/adapter.py`'s +before choosing one — see the full writeup in `continuo_trino_adapter/adapter.py`'s module docstring: - `CREATE OR REPLACE TABLE t AS SELECT * FROM stage` is a single Iceberg diff --git a/adapters/trino/continuo_python_runtime_trino/__init__.py b/adapters/trino/continuo_trino_adapter/__init__.py similarity index 100% rename from adapters/trino/continuo_python_runtime_trino/__init__.py rename to adapters/trino/continuo_trino_adapter/__init__.py diff --git a/adapters/trino/continuo_python_runtime_trino/adapter.py b/adapters/trino/continuo_trino_adapter/adapter.py similarity index 99% rename from adapters/trino/continuo_python_runtime_trino/adapter.py rename to adapters/trino/continuo_trino_adapter/adapter.py index 029f749..ca983d6 100644 --- a/adapters/trino/continuo_python_runtime_trino/adapter.py +++ b/adapters/trino/continuo_trino_adapter/adapter.py @@ -79,7 +79,7 @@ from trino.auth import BasicAuthentication -logger = logging.getLogger("continuo_python_runtime_trino") +logger = logging.getLogger("continuo_trino_adapter") # Grammar spellings that are not valid Trino type names, mapped to the Trino # spelling with equivalent semantics. Matching is case-insensitive; lookup keys diff --git a/adapters/trino/pyproject.toml b/adapters/trino/pyproject.toml index c40c8b7..9d2ba07 100644 --- a/adapters/trino/pyproject.toml +++ b/adapters/trino/pyproject.toml @@ -1,6 +1,6 @@ [project] version = "0.2.0" -name = "continuo-python-runtime-trino" +name = "continuo-trino-adapter" description = "Trino warehouse adapter for Continuo: validation and python-node runtime." authors = [{ name = "Simone Carolini" }] maintainers = [{ name = "Simone Carolini" }] @@ -23,7 +23,7 @@ classifiers = [ ] [project.entry-points."continuo_engine.adapters"] -trino = "continuo_python_runtime_trino.adapter:TrinoAdapter" +trino = "continuo_trino_adapter.adapter:TrinoAdapter" [dependency-groups] dev = [ @@ -40,7 +40,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["continuo_python_runtime_trino"] +packages = ["continuo_trino_adapter"] [tool.ruff] target-version = "py312" diff --git a/adapters/trino/tests/test_adapter_runtime_trino.py b/adapters/trino/tests/test_adapter_runtime_trino.py index 3c6256b..b5d2bf7 100644 --- a/adapters/trino/tests/test_adapter_runtime_trino.py +++ b/adapters/trino/tests/test_adapter_runtime_trino.py @@ -21,9 +21,9 @@ import pytest from continuo_engine_contract.types import validate_column_type # type: ignore[import-untyped] -import continuo_python_runtime_trino.adapter as adapter_module +import continuo_trino_adapter.adapter as adapter_module -from continuo_python_runtime_trino.adapter import ( +from continuo_trino_adapter.adapter import ( TrinoAdapter, _arrow_table_from_rows, _quote, diff --git a/adapters/trino/tests/test_adapter_trino_validation.py b/adapters/trino/tests/test_adapter_trino_validation.py index 65db8df..0302835 100644 --- a/adapters/trino/tests/test_adapter_trino_validation.py +++ b/adapters/trino/tests/test_adapter_trino_validation.py @@ -6,7 +6,7 @@ """ import pytest -from continuo_python_runtime_trino.adapter import ( +from continuo_trino_adapter.adapter import ( TrinoAdapter, _quote, _sql_string, diff --git a/adapters/trino/tests/test_integration_runtime_trino.py b/adapters/trino/tests/test_integration_runtime_trino.py index 2fa7dc0..1473994 100644 --- a/adapters/trino/tests/test_integration_runtime_trino.py +++ b/adapters/trino/tests/test_integration_runtime_trino.py @@ -21,7 +21,7 @@ import trino -from continuo_python_runtime_trino.adapter import TrinoAdapter +from continuo_trino_adapter.adapter import TrinoAdapter TRINO_ENV = { "TRINO_HOST": "localhost", @@ -359,7 +359,7 @@ def test_entry_point_resolves_to_this_adapter(): """The `trino` engine entry point is registered and loads TrinoAdapter. Does NOT call discover_adapter(): see the module docstring — with - continuo-python-runtime-postgres also installed in this dev venv, two entry points + continuo-postgres-adapter also installed in this dev venv, two entry points are registered under continuo_engine.adapters, and discover_adapter() deliberately raises when more than one is installed. Only a runner image (which installs exactly one engine package) can rely on discovery choosing diff --git a/adapters/trino/tests/test_integration_trino_validation.py b/adapters/trino/tests/test_integration_trino_validation.py index 9f39727..a594cb7 100644 --- a/adapters/trino/tests/test_integration_trino_validation.py +++ b/adapters/trino/tests/test_integration_trino_validation.py @@ -10,7 +10,7 @@ import pytest -from continuo_python_runtime_trino.adapter import TrinoAdapter +from continuo_trino_adapter.adapter import TrinoAdapter TRINO_ENV = { "TRINO_HOST": "localhost", diff --git a/tests/test_adapter_naming.py b/tests/test_adapter_naming.py new file mode 100644 index 0000000..5a11b2c --- /dev/null +++ b/tests/test_adapter_naming.py @@ -0,0 +1,41 @@ +"""Guard: for every installed engine adapter, the distribution name, the import +module, and the entry-point target agree — so `pip install continuo--adapter` +always yields `import continuo__adapter`. Regressing this reintroduces the +pip-name != import-name trap and breaks the deterministic BYO template. +""" +import importlib +import importlib.metadata as md + +import pytest + +from continuo_engine_contract.port import ENTRY_POINT_GROUP + + +def _entry_points(): + return list(md.entry_points(group=ENTRY_POINT_GROUP)) + + +def test_at_least_the_two_known_engines_are_installed(): + names = {ep.name for ep in _entry_points()} + assert {"postgres", "trino"} <= names, f"missing engines; found {sorted(names)}" + + +@pytest.mark.parametrize("ep", _entry_points(), ids=lambda ep: ep.name) +def test_adapter_names_follow_the_convention(ep): + engine = ep.name + expected_dist = f"continuo-{engine}-adapter" + expected_module = f"continuo_{engine}_adapter" + # entry-point target lives in the expected top-level import package (the + # target itself is a submodule, e.g. "continuo_postgres_adapter.adapter") + top_level = ep.module.split(".")[0] + assert top_level == expected_module, ( + f"entry point {engine!r} targets {ep.value!r}, expected top-level module " + f"{expected_module!r}" + ) + # the target module imports + importlib.import_module(expected_module) + # the distribution providing it carries the expected pip name + assert ep.dist is not None and ep.dist.name == expected_dist, ( + f"entry point {engine!r} is provided by {getattr(ep.dist, 'name', None)!r}, " + f"expected {expected_dist!r}" + ) diff --git a/uv.lock b/uv.lock index f636cbd..6703041 100644 --- a/uv.lock +++ b/uv.lock @@ -9,9 +9,9 @@ resolution-markers = [ [manifest] members = [ "continuo-engine-contract", + "continuo-postgres-adapter", "continuo-python-runtime", - "continuo-python-runtime-postgres", - "continuo-python-runtime-trino", + "continuo-trino-adapter", ] [[package]] @@ -171,22 +171,19 @@ dependencies = [ requires-dist = [{ name = "sqlglot", specifier = "==30.17.0" }] [[package]] -name = "continuo-python-runtime" -version = "0.4.0" -source = { editable = "." } +name = "continuo-postgres-adapter" +version = "0.2.0" +source = { editable = "adapters/postgres" } dependencies = [ - { name = "boto3" }, { name = "continuo-engine-contract" }, + { name = "psycopg2-binary" }, { name = "pyarrow" }, - { name = "pyyaml" }, - { name = "sqlglot" }, ] [package.dev-dependencies] dev = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-pyyaml" }, ] test = [ { name = "pytest" }, @@ -195,18 +192,15 @@ test = [ [package.metadata] requires-dist = [ - { name = "boto3", specifier = "==1.43.74" }, { name = "continuo-engine-contract", editable = "contract" }, + { name = "psycopg2-binary", specifier = "==2.9.12" }, { name = "pyarrow", specifier = "==25.0.1" }, - { name = "pyyaml", specifier = "==6.0.3" }, - { name = "sqlglot", specifier = "==30.17.0" }, ] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = "==2.3.1" }, { name = "ruff", specifier = "==0.16.3" }, - { name = "types-pyyaml", specifier = "==6.0.12.20250915" }, ] test = [ { name = "pytest", specifier = "==9.1.1" }, @@ -214,19 +208,22 @@ test = [ ] [[package]] -name = "continuo-python-runtime-postgres" -version = "0.2.0" -source = { editable = "adapters/postgres" } +name = "continuo-python-runtime" +version = "0.4.0" +source = { editable = "." } dependencies = [ + { name = "boto3" }, { name = "continuo-engine-contract" }, - { name = "psycopg2-binary" }, { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "sqlglot" }, ] [package.dev-dependencies] dev = [ { name = "mypy" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] test = [ { name = "pytest" }, @@ -235,15 +232,18 @@ test = [ [package.metadata] requires-dist = [ + { name = "boto3", specifier = "==1.43.74" }, { name = "continuo-engine-contract", editable = "contract" }, - { name = "psycopg2-binary", specifier = "==2.9.12" }, { name = "pyarrow", specifier = "==25.0.1" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "sqlglot", specifier = "==30.17.0" }, ] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = "==2.3.1" }, { name = "ruff", specifier = "==0.16.3" }, + { name = "types-pyyaml", specifier = "==6.0.12.20250915" }, ] test = [ { name = "pytest", specifier = "==9.1.1" }, @@ -251,7 +251,7 @@ test = [ ] [[package]] -name = "continuo-python-runtime-trino" +name = "continuo-trino-adapter" version = "0.2.0" source = { editable = "adapters/trino" } dependencies = [ From 5d1a38c748799b9df78c03b4d486c4adc8f66e85 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:06:15 +0200 Subject: [PATCH 02/10] fix(adapters): update CI/CONTRIBUTING/docs mypy commands to renamed packages Task-1 review (fix round 1): the adapter rename left .github/workflows/ci.yml, CONTRIBUTING.md, and two non-image name references in publish-pypi.yml/README.md still pointing at the pre-rename dist name and module path, breaking the per-adapter mypy CI steps and the documented pre-PR checklist. Swept the repo for remaining continuo-python-runtime- / continuo_python_runtime_ references and updated every non-image one; container image names (continuo-python-runtime- as a ghcr.io tag) are unaffected and frozen. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish-pypi.yml | 4 ++-- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da887d4..a1ad70b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: - name: Types (contract) run: uv run mypy contract/continuo_engine_contract - name: Types (postgres adapter) - run: uv run --package continuo-python-runtime-postgres mypy adapters/postgres/continuo_python_runtime_postgres + run: uv run --package continuo-postgres-adapter mypy adapters/postgres/continuo_postgres_adapter - name: Types (trino adapter) - run: uv run --package continuo-python-runtime-trino mypy adapters/trino/continuo_python_runtime_trino + run: uv run --package continuo-trino-adapter mypy adapters/trino/continuo_trino_adapter # `-m "not image"` deselects tests/test_image_smoke_validation.py, which # needs a built engine image and the env naming it. Those tests run in # images.yml's smoke jobs, where an image actually exists. `and not diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 9ea43a1..877c12a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -4,8 +4,8 @@ name: publish-pypi # (the harness) and continuo-engine-contract (the port, result-block format, # and shared guards) — via PyPI Trusted Publishing (OIDC, no stored token). # -# The two engine adapters (continuo-python-runtime-postgres / -# continuo-python-runtime-trino) are deliberately NOT published here. Nothing +# The two engine adapters (continuo-postgres-adapter / +# continuo-trino-adapter) are deliberately NOT published here. Nothing # installs them from an index: the engine images build them from the build # context (see Dockerfile.postgres / Dockerfile.trino and images.yml), and the # harness package does not depend on them — adapters are found at run time diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fa000a..12171f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,8 +57,8 @@ uv run ruff check . uv run ruff check contract uv run mypy continuo_python_runtime uv run mypy contract/continuo_engine_contract -uv run --package continuo-python-runtime-postgres mypy adapters/postgres/continuo_python_runtime_postgres -uv run --package continuo-python-runtime-trino mypy adapters/trino/continuo_python_runtime_trino +uv run --package continuo-postgres-adapter mypy adapters/postgres/continuo_postgres_adapter +uv run --package continuo-trino-adapter mypy adapters/trino/continuo_trino_adapter uv run pytest --cov=continuo_python_runtime -m "not image and not integration" -v uv run pytest tests/test_csv_readers_integration.py tests/test_validation_runner.py -m integration -v uv run pytest contract/tests -v diff --git a/README.md b/README.md index 68e3698..7fcc2f2 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ validation-side port, adapter class, entry-point group, or image. One | --- | --- | --- | --- | | `continuo-python-runtime` | `continuo_python_runtime` | this repo (root) | Harness (CLI, `conform()`, `RunContext`, error taxonomy) **and** the validation runner (`continuo-runtime validation-op`). Published to PyPI. | | `continuo-engine-contract` | `continuo_engine_contract` | this repo, `contract/` | The `WarehouseAdapter` port, contract schema, the SQL/type/config guards adapters must run, and the result-block format. Published to PyPI. | -| `continuo-python-runtime-postgres` | `continuo_python_runtime_postgres` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. **Not published to PyPI** — built from source into the image. | -| `continuo-python-runtime-trino` | `continuo_python_runtime_trino` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. **Not published to PyPI** — built from source into the image. | +| `continuo-postgres-adapter` | `continuo_postgres_adapter` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. **Not published to PyPI** — built from source into the image. | +| `continuo-trino-adapter` | `continuo_trino_adapter` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. **Not published to PyPI** — built from source into the image. | All four are uv workspace members (`[tool.uv.workspace]` in the root `pyproject.toml`), so `uv sync --all-packages --all-groups` at the repo root From ea5717d2ad5d0efadd937fd8b4b1c0812d000a6e Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:13:30 +0200 Subject: [PATCH 03/10] ci: publish engine adapters on v* with skip-existing + bump guard Build and publish continuo--adapter alongside runtime + contract on the single v* tag; skip-existing lets a tag carry any mix of changed/unchanged versions. A release-time guard (check_version_bumps.py) refuses a tag that changed a package's source since the previous v* tag while its version stayed equal, so skip-existing cannot silently ship stale code under an unbumped version. The guard is name-aware: a package whose dist name also changed since the previous tag (this branch's adapter rename) is a first publish under that name, not a stale re-publish, so it is exempt from the bump requirement. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/publish-pypi.yml | 41 +++++++++++++------ scripts/check_version_bumps.py | 65 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 scripts/check_version_bumps.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 877c12a..e4ce3c5 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,18 +1,16 @@ name: publish-pypi -# Publishes both PyPI distributions this repo owns — continuo-python-runtime -# (the harness) and continuo-engine-contract (the port, result-block format, -# and shared guards) — via PyPI Trusted Publishing (OIDC, no stored token). -# -# The two engine adapters (continuo-postgres-adapter / -# continuo-trino-adapter) are deliberately NOT published here. Nothing -# installs them from an index: the engine images build them from the build -# context (see Dockerfile.postgres / Dockerfile.trino and images.yml), and the -# harness package does not depend on them — adapters are found at run time -# through the `continuo_engine.adapters` entry-point group, inside the image -# that installed exactly one. Publishing them would only add releases with no -# consumer. They remain uv workspace members and are still built, typed, and -# tested by ci.yml. +# Publishes all four PyPI distributions this repo owns — continuo-python-runtime +# (the harness), continuo-engine-contract (the port, result-block format, and +# shared guards), and the two engine adapters (continuo-postgres-adapter, +# continuo-trino-adapter) — via PyPI Trusted Publishing (OIDC, no stored +# token). They publish together on a single v* tag, each at its own +# pyproject version, so a tag that only bumps the runtime still carries an +# unchanged adapter version along for the ride; skip-existing (below) makes +# that a no-op rather than a duplicate-upload failure. A release-time guard +# (scripts/check_version_bumps.py) refuses a tag that changed a package's +# source without bumping its version, so skip-existing can never silently +# ship stale adapter code under an unchanged version number. # # Tag glob note: `v*` is the repository's single release pattern — the same tag # that images.yml builds and pushes both engine images from, so one tag ships @@ -44,7 +42,11 @@ jobs: contents: read # actions/checkout needs read access to the repo steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: astral-sh/setup-uv@v7 + - name: Refuse a tag that changed a package without bumping its version + run: python scripts/check_version_bumps.py - name: Test before publishing # `-m "not image"` deselects the image smoke tests; images.yml runs # those against a built engine image. @@ -55,6 +57,17 @@ jobs: run: uv build --package continuo-engine-contract -o dist - name: Build the runtime sdist + wheel run: uv build -o dist + - name: Build the postgres adapter sdist + wheel + run: uv build --package continuo-postgres-adapter -o dist + - name: Build the trino adapter sdist + wheel + run: uv build --package continuo-trino-adapter -o dist + # Adapters change rarely, so most v* tags carry an unchanged adapter + # version alongside a bumped runtime/contract version. Without + # skip-existing, re-uploading that unchanged version 400s and fails the + # whole publish. Trade-off: a changed package published without a + # version bump is then silently skipped too — the + # check_version_bumps.py guard above is what catches that case, before + # this job's build/publish steps ever run. - name: Publish to TestPyPI if: contains(github.ref_name, '-test') # Pinned to a commit SHA. release/v1 is a moving branch, and this job @@ -64,6 +77,7 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: dist + skip-existing: true - name: Publish to PyPI if: ${{ !contains(github.ref_name, '-test') }} # Pinned to a commit SHA. release/v1 is a moving branch, and this job @@ -72,3 +86,4 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist + skip-existing: true diff --git a/scripts/check_version_bumps.py b/scripts/check_version_bumps.py new file mode 100644 index 0000000..ee73b18 --- /dev/null +++ b/scripts/check_version_bumps.py @@ -0,0 +1,65 @@ +"""Fail if a package's source changed since the previous release tag but its +version was not bumped. skip-existing then safely carries the unchanged +packages. First release (no previous v* tag) is a no-op. + +A package is only an offender when its dist NAME is also unchanged since the +previous tag: if the name changed (e.g. a rename to a new PyPI project), the +same version number is a first publish under that name, not a stale +re-publish, so no bump is required. +""" +import subprocess +import sys +import tomllib + +# dist name -> (pyproject path, paths whose change requires a version bump) +PACKAGES = { + "continuo-python-runtime": ("pyproject.toml", ["pyproject.toml", "continuo_python_runtime"]), + "continuo-engine-contract": ("contract/pyproject.toml", ["contract"]), + "continuo-postgres-adapter": ("adapters/postgres/pyproject.toml", ["adapters/postgres"]), + "continuo-trino-adapter": ("adapters/trino/pyproject.toml", ["adapters/trino"]), +} + + +def _run(*args): + return subprocess.run(args, capture_output=True, text=True) + + +def _prev_tag(): + r = _run("git", "describe", "--tags", "--match", "v*", "--abbrev=0", "HEAD^") + return r.stdout.strip() if r.returncode == 0 else "" + + +def _name_version(ref, path): + if ref: + r = _run("git", "show", f"{ref}:{path}") + if r.returncode != 0: + return None # package did not exist at prev tag -> treat as new + data = tomllib.loads(r.stdout) + else: + with open(path, "rb") as fh: + data = tomllib.load(fh) + return data["project"]["name"], data["project"]["version"] + + +def main() -> int: + prev = _prev_tag() + if not prev: + print("no previous v* tag; first release, nothing to check") + return 0 + offenders = [] + for dist, (pyproj, paths) in PACKAGES.items(): + changed = _run("git", "diff", "--quiet", f"{prev}..HEAD", "--", *paths).returncode != 0 + old = _name_version(prev, pyproj) + new = _name_version("", pyproj) + if changed and old is not None and old[0] == new[0] and old[1] == new[1]: + offenders.append(f"{dist} ({', '.join(paths)} changed since {prev} but version stayed {new[1]})") + if offenders: + print("Bump the version of changed packages before tagging:\n " + "\n ".join(offenders), + file=sys.stderr) + return 1 + print(f"All packages changed since {prev} were version-bumped.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 394f53ffb29e0efbd664386dac47791447ce690d Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:29:52 +0200 Subject: [PATCH 04/10] build: engine images install versioned libraries, not source Dockerfile.postgres/.trino install the pinned continuo-python-runtime + continuo--adapter versions (from PyPI at release, from a CI-built wheelhouse on PR) instead of pip-installing the repo tree. The pins live in image-requirements-.txt, guarded equal to pyproject by a new test. The wheelhouse branch installs its three first-party wheels (contract, runtime, adapter) by exact local file first, with --no-index --no-deps, before falling through to a normal `pip install -r req.txt` for the third-party deps. A plain `--find-links=/tmp/wheelhouse -r req.txt` is not enough: pip does not prefer a find-links wheel over an index match at the same name+version, and continuo-python-runtime / continuo-engine-contract are already published on PyPI under the version this repo's HEAD currently carries (a routine dependency bump landed between releases without a version bump). Left unpinned-by-file, pip silently installs the last PyPI release's transitive pins instead of the wheel this build just produced, defeating the point of testing an unreleased change on a PR. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- Dockerfile.postgres | 55 +++++++++++++++++++++------ Dockerfile.trino | 55 +++++++++++++++++++++------ image-requirements-postgres.txt | 2 + image-requirements-trino.txt | 2 + tests/test_image_requirements_sync.py | 36 ++++++++++++++++++ 5 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 image-requirements-postgres.txt create mode 100644 image-requirements-trino.txt create mode 100644 tests/test_image_requirements_sync.py diff --git a/Dockerfile.postgres b/Dockerfile.postgres index 68b64d2..032a788 100644 --- a/Dockerfile.postgres +++ b/Dockerfile.postgres @@ -1,16 +1,47 @@ FROM python:3.14-slim -# One dual-role image per engine: the python-node runtime harness (default -# command `run`, domain repos build FROM this) and the blue/green validation -# runner (`validation-op`, continuo's executor sets it as the container -# command). Everything installs from this repo's own workspace paths in one -# pip transaction — no PyPI propagation lag; images always build from repo head. -# docker build -f Dockerfile.postgres . -COPY pyproject.toml README.md /src/ -COPY contract /src/contract -COPY continuo_python_runtime /src/continuo_python_runtime -COPY adapters/postgres /src/adapters/postgres -RUN pip install --no-cache-dir /src/contract /src/adapters/postgres /src \ - && rm -rf /src +# One dual-role image per engine — the python-node runtime harness (default +# command `run`) and the blue/green validation runner (`validation-op`). It +# installs the PUBLISHED, versioned libraries, never the repo source: +# WHEEL_SOURCE=pypi (release) installs the pins from PyPI. PyPI versions +# are immutable and the image is consumed +# digest-pinned, so no hash lock is needed here. +# WHEEL_SOURCE=wheelhouse (CI/PR) installs the same pins from ./wheelhouse +# (built by CI) so an unreleased change is testable. +# The wheelhouse holds only this repo's own first-party wheels (built via +# `uv build --all-packages`); third-party dependencies always resolve from +# PyPI in both branches, so the wheelhouse branch never passes --no-index to +# the requirements install below. +# The pinned versions live in image-requirements-postgres.txt, kept equal to the +# repo's pyproject versions by tests/test_image_requirements_sync.py. +# +# The wheelhouse branch installs its three first-party wheels (contract, +# runtime, adapter) by exact local file first, with --no-index --no-deps: a +# plain `--find-links=/tmp/wheelhouse -r req.txt` is not enough, because pip +# does not prefer a find-links wheel over an index match at the same +# name+version, and continuo-python-runtime / continuo-engine-contract are +# already published on PyPI under the version this repo's HEAD currently +# carries (a routine dependency bump lands between releases without a version +# bump). Left unpinned-by-file, pip silently installs the last PyPI release's +# transitive pins instead of the wheel this build just produced, defeating +# the point of testing an unreleased change. Installing the exact local files +# first (no index contacted at all for this step) is unambiguous; the second +# install then only has PyPI-only third-party deps left to resolve, and finds +# the three first-party packages already satisfied. +ARG WHEEL_SOURCE=pypi +COPY image-requirements-postgres.txt /tmp/req.txt +# CI always creates ./wheelhouse (empty on release) so this COPY never fails. +COPY wheelhouse /tmp/wheelhouse +RUN set -eu; \ + if [ "$WHEEL_SOURCE" = "wheelhouse" ]; then \ + pip install --no-cache-dir --no-index --no-deps \ + /tmp/wheelhouse/continuo_engine_contract-*.whl \ + /tmp/wheelhouse/continuo_python_runtime-*.whl \ + /tmp/wheelhouse/continuo_postgres_adapter-*.whl; \ + pip install --no-cache-dir -r /tmp/req.txt; \ + else \ + pip install --no-cache-dir -r /tmp/req.txt; \ + fi; \ + rm -rf /tmp/req.txt /tmp/wheelhouse # PYTHONPATH is belt-and-braces: the harness also inserts APP_ROOT and the # node script's own directory at the front of sys.path before executing it. ENV CONTRACT_DIR=/app/contracts APP_ROOT=/app PYTHONPATH=/app diff --git a/Dockerfile.trino b/Dockerfile.trino index 0102259..2261b3a 100644 --- a/Dockerfile.trino +++ b/Dockerfile.trino @@ -1,16 +1,47 @@ FROM python:3.14-slim -# One dual-role image per engine: the python-node runtime harness (default -# command `run`, domain repos build FROM this) and the blue/green validation -# runner (`validation-op`, continuo's executor sets it as the container -# command). Everything installs from this repo's own workspace paths in one -# pip transaction — no PyPI propagation lag; images always build from repo head. -# docker build -f Dockerfile.trino . -COPY pyproject.toml README.md /src/ -COPY contract /src/contract -COPY continuo_python_runtime /src/continuo_python_runtime -COPY adapters/trino /src/adapters/trino -RUN pip install --no-cache-dir /src/contract /src/adapters/trino /src \ - && rm -rf /src +# One dual-role image per engine — the python-node runtime harness (default +# command `run`) and the blue/green validation runner (`validation-op`). It +# installs the PUBLISHED, versioned libraries, never the repo source: +# WHEEL_SOURCE=pypi (release) installs the pins from PyPI. PyPI versions +# are immutable and the image is consumed +# digest-pinned, so no hash lock is needed here. +# WHEEL_SOURCE=wheelhouse (CI/PR) installs the same pins from ./wheelhouse +# (built by CI) so an unreleased change is testable. +# The wheelhouse holds only this repo's own first-party wheels (built via +# `uv build --all-packages`); third-party dependencies always resolve from +# PyPI in both branches, so the wheelhouse branch never passes --no-index to +# the requirements install below. +# The pinned versions live in image-requirements-trino.txt, kept equal to the +# repo's pyproject versions by tests/test_image_requirements_sync.py. +# +# The wheelhouse branch installs its three first-party wheels (contract, +# runtime, adapter) by exact local file first, with --no-index --no-deps: a +# plain `--find-links=/tmp/wheelhouse -r req.txt` is not enough, because pip +# does not prefer a find-links wheel over an index match at the same +# name+version, and continuo-python-runtime / continuo-engine-contract are +# already published on PyPI under the version this repo's HEAD currently +# carries (a routine dependency bump lands between releases without a version +# bump). Left unpinned-by-file, pip silently installs the last PyPI release's +# transitive pins instead of the wheel this build just produced, defeating +# the point of testing an unreleased change. Installing the exact local files +# first (no index contacted at all for this step) is unambiguous; the second +# install then only has PyPI-only third-party deps left to resolve, and finds +# the three first-party packages already satisfied. +ARG WHEEL_SOURCE=pypi +COPY image-requirements-trino.txt /tmp/req.txt +# CI always creates ./wheelhouse (empty on release) so this COPY never fails. +COPY wheelhouse /tmp/wheelhouse +RUN set -eu; \ + if [ "$WHEEL_SOURCE" = "wheelhouse" ]; then \ + pip install --no-cache-dir --no-index --no-deps \ + /tmp/wheelhouse/continuo_engine_contract-*.whl \ + /tmp/wheelhouse/continuo_python_runtime-*.whl \ + /tmp/wheelhouse/continuo_trino_adapter-*.whl; \ + pip install --no-cache-dir -r /tmp/req.txt; \ + else \ + pip install --no-cache-dir -r /tmp/req.txt; \ + fi; \ + rm -rf /tmp/req.txt /tmp/wheelhouse # PYTHONPATH is belt-and-braces: the harness also inserts APP_ROOT and the # node script's own directory at the front of sys.path before executing it. ENV CONTRACT_DIR=/app/contracts APP_ROOT=/app PYTHONPATH=/app diff --git a/image-requirements-postgres.txt b/image-requirements-postgres.txt new file mode 100644 index 0000000..41cd509 --- /dev/null +++ b/image-requirements-postgres.txt @@ -0,0 +1,2 @@ +continuo-python-runtime==0.4.0 +continuo-postgres-adapter==0.2.0 diff --git a/image-requirements-trino.txt b/image-requirements-trino.txt new file mode 100644 index 0000000..24f84db --- /dev/null +++ b/image-requirements-trino.txt @@ -0,0 +1,2 @@ +continuo-python-runtime==0.4.0 +continuo-trino-adapter==0.2.0 diff --git a/tests/test_image_requirements_sync.py b/tests/test_image_requirements_sync.py new file mode 100644 index 0000000..6a57203 --- /dev/null +++ b/tests/test_image_requirements_sync.py @@ -0,0 +1,36 @@ +"""The engine image installs pinned versions from image-requirements-.txt. +Guard that those pins equal the repo's own pyproject versions, so the image can +never ship a version different from what this tag publishes. +""" +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _v(rel: str) -> str: + with open(ROOT / rel, "rb") as fh: + return tomllib.load(fh)["project"]["version"] + + +def _pins(rel: str) -> dict[str, str]: + pins = {} + for line in (ROOT / rel).read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + name, _, ver = line.partition("==") + pins[name.strip()] = ver.strip() + return pins + + +def test_postgres_image_requirements_match_pyproject(): + pins = _pins("image-requirements-postgres.txt") + assert pins["continuo-python-runtime"] == _v("pyproject.toml") + assert pins["continuo-postgres-adapter"] == _v("adapters/postgres/pyproject.toml") + + +def test_trino_image_requirements_match_pyproject(): + pins = _pins("image-requirements-trino.txt") + assert pins["continuo-python-runtime"] == _v("pyproject.toml") + assert pins["continuo-trino-adapter"] == _v("adapters/trino/pyproject.toml") From 5d0afb826102a307ef833b9132364e48d0f954a5 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:38:35 +0200 Subject: [PATCH 05/10] ci: build release images from PyPI after the wheel publish PR smoke builds the image from a locally-built wheelhouse (the real wheel path, no source install, nothing published). The tag-time image publish now runs on publish-pypi success (workflow_run) and waits until the pinned versions are installable before building+pushing, so image === PyPI. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/images.yml | 63 +++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 7be2ebe..45f487a 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -12,6 +12,9 @@ on: push: tags: - "v*" + workflow_run: + workflows: ["publish-pypi"] + types: [completed] jobs: build: @@ -21,8 +24,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + - name: Build wheels into a wheelhouse + run: uv build --all-packages -o wheelhouse - name: Build ${{ matrix.engine }} image - run: docker build -f Dockerfile.${{ matrix.engine }} -t cpr-smoke-${{ matrix.engine }} . + run: docker build -f Dockerfile.${{ matrix.engine }} --build-arg WHEEL_SOURCE=wheelhouse -t cpr-smoke-${{ matrix.engine }} . - name: Save image run: docker save cpr-smoke-${{ matrix.engine }} -o /tmp/cpr-smoke-${{ matrix.engine }}.tar - uses: actions/upload-artifact@v7 @@ -153,21 +159,29 @@ jobs: if: always() run: docker compose -f tests/smoke/trino-stack/docker-compose.yml down -v - # On tag push: build both engine images for linux/amd64 and linux/arm64 and - # push them to ghcr.io//continuo-python-runtime-:. The - # engine belongs in the image NAME, not the tag: Continuo's Helm chart pins an - # image as `:vX.Y.Z@sha256:`, so the tag must be the bare - # version. Both platforms are required: domain repos build their own images - # FROM these, and Continuo's e2e runs them in a kind cluster on aarch64 - # developer machines as well as on amd64 CI runners, so a single-arch publish - # is unusable off amd64. The smoke jobs above stay single-arch — they load a + # Runs after publish-pypi.yml finishes publishing this tag's wheels to the + # real PyPI index (workflow_run, not push:tags — this job must not race the + # publish it depends on). It builds both engine images for linux/amd64 and + # linux/arm64 installing the pinned versions FROM PyPI (WHEEL_SOURCE=pypi, + # the Dockerfile default), so the published image is never ahead of what's + # actually installable, then pushes them to + # ghcr.io//continuo-python-runtime-:. The engine belongs + # in the image NAME, not the tag: Continuo's Helm chart pins an image as + # `:vX.Y.Z@sha256:`, so the tag must be the bare version. Both + # platforms are required: domain repos build their own images FROM these, + # and Continuo's e2e runs them in a kind cluster on aarch64 developer + # machines as well as on amd64 CI runners, so a single-arch publish is + # unusable off amd64. The smoke jobs above stay single-arch — they load a # `docker save` tarball, which has no multi-platform equivalent. - # continuo-engine-contract and the two engine adapters are installed from the - # build context (see Dockerfile.postgres / Dockerfile.trino) — nothing pending - # here. + # -test tags publish to TestPyPI only (see publish-pypi.yml), so this job + # skips them: there is no real-PyPI installable version to build an image + # from for those. publish: - if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-test') - needs: [smoke-postgres, smoke-trino] + if: >- + github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && startsWith(github.event.workflow_run.head_branch, 'v') + && !contains(github.event.workflow_run.head_branch, '-test') strategy: matrix: engine: [postgres, trino] @@ -177,6 +191,8 @@ jobs: packages: write steps: - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 - name: Log in to ghcr.io @@ -185,6 +201,23 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # The Dockerfiles unconditionally COPY ./wheelhouse (see Dockerfile.postgres + # / Dockerfile.trino); this build installs from PyPI (WHEEL_SOURCE + # defaults to pypi below), so the directory only needs to exist, not + # contain anything. + - name: Create empty wheelhouse (release installs from PyPI, not this dir) + run: mkdir -p wheelhouse + - name: Wait until the pinned versions are installable from PyPI + run: | + set -eu + req="image-requirements-${{ matrix.engine }}.txt" + for i in $(seq 1 30); do + if pip download --no-deps --dest /tmp/probe -r "$req" >/tmp/probe.log 2>&1; then + echo "installable"; exit 0 + fi + echo "attempt $i: not yet on index; sleeping"; sleep 20 + done + echo "pinned versions never became installable"; cat /tmp/probe.log; exit 1 - name: Build and push ${{ matrix.engine }} image uses: docker/build-push-action@v7 with: @@ -192,4 +225,4 @@ jobs: file: Dockerfile.${{ matrix.engine }} platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.repository_owner }}/continuo-python-runtime-${{ matrix.engine }}:${{ github.ref_name }} + tags: ghcr.io/${{ github.repository_owner }}/continuo-python-runtime-${{ matrix.engine }}:${{ github.event.workflow_run.head_branch }} From 76f01cb98d721969333d11927e2a2a70aee3fd8a Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:42:22 +0200 Subject: [PATCH 06/10] ci: skip build/smoke jobs on the workflow_run trigger The workflow_run trigger (added for publish's release gating) was also firing build/smoke-postgres/smoke-trino on every publish-pypi completion, including -test tags and failed/cancelled runs, checking out the default branch instead of the tag commit. Nothing depends on those jobs anymore (publish's needs: was already removed), so this only wasted CI and polluted the checks list. Guard all three with if: github.event_name != 'workflow_run' so they run only on pull_request/push. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/images.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 45f487a..6c9e694 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -18,6 +18,12 @@ on: jobs: build: + # This workflow also triggers on workflow_run (publish-pypi completion) to + # run the `publish` job below; that trigger checks out the default branch, + # not the tag commit, and has no downstream consumer for this job's + # smoke-test images (publish's needs: was removed), so skip it here rather + # than run a wrong-ref, pointless build on every release. + if: github.event_name != 'workflow_run' strategy: matrix: engine: [postgres, trino] @@ -39,6 +45,10 @@ jobs: smoke-postgres: needs: build + # Redundant with build's own guard skipping it via `needs`, but explicit + # here too: this job must never run against a workflow_run-triggered, + # default-branch checkout. + if: github.event_name != 'workflow_run' runs-on: ubuntu-latest services: postgres: @@ -108,6 +118,10 @@ jobs: smoke-trino: needs: build + # Redundant with build's own guard skipping it via `needs`, but explicit + # here too: this job must never run against a workflow_run-triggered, + # default-branch checkout. + if: github.event_name != 'workflow_run' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From f2c73cfa34327e730e375a602d93e63967734691 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:46:44 +0200 Subject: [PATCH 07/10] template: add Shape-2 (own base + pip, hash-locked) variant A deterministic own-base Dockerfile for domain repos that cannot build FROM the engine image: install continuo-python-runtime + continuo--adapter from a hash-locked requirements file. README documents Shape 1 vs Shape 2. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- template/Dockerfile.pip | 17 ++++++++++++++ template/README.md | 46 ++++++++++++++++++++++++++++++++++++++ template/requirements.lock | 22 ++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 template/Dockerfile.pip create mode 100644 template/requirements.lock diff --git a/template/Dockerfile.pip b/template/Dockerfile.pip new file mode 100644 index 0000000..88fc1d2 --- /dev/null +++ b/template/Dockerfile.pip @@ -0,0 +1,17 @@ +# Shape 2 — build a python-node image on YOUR OWN base, installing the Continuo +# runtime + one engine adapter from PyPI. Deterministic via a hash-locked +# requirements file (regenerate with the command in template/README.md). +# Shape 1 (FROM the engine image, see template/Dockerfile) is simpler; use this +# only when you must control the base image. +FROM python:3.14-slim +COPY requirements.lock /tmp/requirements.lock +RUN pip install --no-cache-dir --require-hashes -r /tmp/requirements.lock \ + && rm -f /tmp/requirements.lock +ENV CONTRACT_DIR=/app/contracts APP_ROOT=/app PYTHONPATH=/app +WORKDIR /app +COPY contracts/ /app/contracts/ +COPY scripts/ /app/scripts/ +RUN useradd --uid 65532 --no-create-home --shell /usr/sbin/nologin nonroot +USER 65532:65532 +ENTRYPOINT ["continuo-runtime"] +CMD ["run"] diff --git a/template/README.md b/template/README.md index 2e03e3a..1151b9e 100644 --- a/template/README.md +++ b/template/README.md @@ -20,6 +20,52 @@ This is a copy-ready template for implementing a [Continuo Python domain repo](h 5. **Write your contracts** in `contracts/` and **implement scripts** in `scripts/` 6. **Push to main** to trigger the release pipeline +## Choosing a base + +This template ships two Dockerfiles that produce the same kind of image +(`ENTRYPOINT ["continuo-runtime"]`, `CMD ["run"]`, contracts + scripts baked +in, running as uid 65532) via two different build shapes. Pick one; you only +need one Dockerfile in your repo. + +**Shape 1 — `Dockerfile`, `FROM` the engine image (simplest).** Builds +`FROM ghcr.io/carolsimone/continuo-python-runtime-:vX.Y.Z`, an image +that already has the Continuo runtime and one engine adapter installed and +pinned by the publisher. You only add your `contracts/` and `scripts/` (and +any extra dependency your script needs). Pin by tag or digest +(`:vX.Y.Z@sha256:`) for reproducibility. Use this unless you have a +specific reason not to. + +**Shape 2 — `Dockerfile.pip`, your own base (hash-locked).** Builds +`FROM python:3.14-slim` (or another base you control) and installs +`continuo-python-runtime` plus one `continuo--adapter` from PyPI via +`pip install --require-hashes -r requirements.lock`. Use this when you must +control the base image yourself — e.g. your org mandates a specific base, +you need OS packages the engine image doesn't carry, or you're building on a +platform the published engine images don't target. `requirements.lock` is +the one place in this template where `--require-hashes` and a committed +hash-lock are used (the engine images themselves pin by version only); this +is what makes the Shape-2 build deterministic without depending on the +publisher's image layers. + +Regenerate `requirements.lock` for your engine and versions with: + +```bash +uv pip compile --generate-hashes --python-version 3.14 - -o requirements.lock <<'EOF' +continuo-python-runtime==0.4.0 +continuo--adapter==X.Y.Z +EOF +``` + +The committed `requirements.lock` in this template is a **placeholder**: the +adapters were not yet published to PyPI when it was written, so it has no +`--hash` entries and `pip install --require-hashes` will refuse to install it +as-is. Regenerate it once your chosen adapter version is actually on PyPI. + +The image name (`continuo-python-runtime-`, what Shape 1 pulls) and +the pip distribution name (`continuo--adapter`, what Shape 2 +installs) are two artifacts published from the same adapter source — same +engine, same version, same runtime behavior, different packaging. + ## Pipeline Overview The CI/CD pipeline (`release.yml`) performs the six-step orchestration: diff --git a/template/requirements.lock b/template/requirements.lock new file mode 100644 index 0000000..9dc015c --- /dev/null +++ b/template/requirements.lock @@ -0,0 +1,22 @@ +# PLACEHOLDER — regenerate with --generate-hashes once the versions are on PyPI. +# +# This file is what template/Dockerfile.pip (Shape 2) installs with +# `pip install --require-hashes`, which refuses to install any requirement +# that lacks a --hash entry. The two lines below are illustrative pins only +# (postgres is the engine used throughout this template, matching Shape 1's +# Dockerfile) — they carry no hashes yet, so installing from this file AS-IS +# WILL FAIL. Do not ship it unmodified. +# +# Once continuo-python-runtime==0.4.0 and continuo-postgres-adapter==0.2.0 (or +# whichever versions you depend on) are published on PyPI, regenerate a real +# hash-locked file for your engine and versions with: +# +# uv pip compile --generate-hashes --python-version 3.14 - -o template/requirements.lock <<'EOF' +# continuo-python-runtime==0.4.0 +# continuo--adapter==X.Y.Z +# EOF +# +# Swap /X.Y.Z for the adapter you actually use (e.g. trino==0.2.0), +# then commit the regenerated file with its --hash entries. +continuo-python-runtime==0.4.0 +continuo-postgres-adapter==0.2.0 From 5b22bf89270bd7c25e765ee58338e81ad126f298 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 13:54:45 +0200 Subject: [PATCH 08/10] docs: adapter package names, BYO shapes, image<->pip cross-link Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- README.md | 79 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7fcc2f2..a198c55 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ and register the release with Continuo. ## What this repo is -Four artifacts come out of this repository: +Five artifacts come out of this repository: - **The `continuo-python-runtime` PyPI package** — the `continuo-runtime` CLI (`validate` / `merge` / `hash` / `lint` / `run` / `validation-op`) and the @@ -21,6 +21,13 @@ Four artifacts come out of this repository: - **The `continuo-engine-contract` PyPI package** — the `WarehouseAdapter` port, the contract schema, the shared SQL/type/config guards, and the sentinel result-block format. Adapter authors outside this repo pin it. +- **The two engine-adapter PyPI packages** (`continuo-postgres-adapter`, + `continuo-trino-adapter`) — one `WarehouseAdapter` implementation per + warehouse engine, each published independently under the same tag. A + domain repo normally never installs these directly (the engine image + already has the matching one baked in); they exist as standalone PyPI + packages for the "build your own container" shape (see below) and for + third-party adapter authors to reference. - **Per-engine base images**, one per warehouse engine (`continuo-python-runtime-postgres`, `continuo-python-runtime-trino`), that domain repos build `FROM`. Each image bakes in the runtime and a single @@ -30,10 +37,11 @@ Four artifacts come out of this repository: - **`template/`** — a copy-ready domain repo: `Dockerfile`, `contracts/`, `scripts/`, and the `release.yml` CI/CD workflow. -One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds both -distributions into a single `dist/` and publishes them together, and -`images.yml` builds and pushes both engine images multi-arch under the same -tag. +One `vX.Y.Z` git tag releases all of it: `publish-pypi.yml` builds all four +PyPI distributions into a single `dist/` and publishes them together, and +`images.yml` builds and pushes both engine images — each installing its +matching pinned adapter version from that same release — multi-arch under +the same tag. ### What this repo owns @@ -50,22 +58,27 @@ validation-side port, adapter class, entry-point group, or image. One | --- | --- | --- | --- | | `continuo-python-runtime` | `continuo_python_runtime` | this repo (root) | Harness (CLI, `conform()`, `RunContext`, error taxonomy) **and** the validation runner (`continuo-runtime validation-op`). Published to PyPI. | | `continuo-engine-contract` | `continuo_engine_contract` | this repo, `contract/` | The `WarehouseAdapter` port, contract schema, the SQL/type/config guards adapters must run, and the result-block format. Published to PyPI. | -| `continuo-postgres-adapter` | `continuo_postgres_adapter` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. **Not published to PyPI** — built from source into the image. | -| `continuo-trino-adapter` | `continuo_trino_adapter` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. **Not published to PyPI** — built from source into the image. | +| `continuo-postgres-adapter` | `continuo_postgres_adapter` | this repo, `adapters/postgres/` | `PostgresAdapter` — one class, both roles. Published to PyPI. | +| `continuo-trino-adapter` | `continuo_trino_adapter` | this repo, `adapters/trino/` | `TrinoAdapter` — one class, both roles, for Trino/Iceberg. Published to PyPI. | All four are uv workspace members (`[tool.uv.workspace]` in the root `pyproject.toml`), so `uv sync --all-packages --all-groups` at the repo root installs everything for local development. -**Only `continuo-python-runtime` and `continuo-engine-contract` are published -to PyPI.** The two engine adapters are built **from source into the engine -images**: `Dockerfile.postgres` and `Dockerfile.trino` install them out of the -build context, so each image ships exactly one adapter and the runtime -discovers it through the `continuo_engine.adapters` entry-point group at run -time. Nothing installs them from an index — the harness package does not -depend on them, and domain repos get their adapter by building `FROM` a -published base image. They are still built, type-checked, and tested by CI on -every change. +**All four packages in the table above are published to PyPI**, under the +same `vX.Y.Z` tag. The two engine images then **install the matching pinned +adapter version from PyPI** — `Dockerfile.postgres` installs +`continuo-postgres-adapter==X.Y.Z`, `Dockerfile.trino` installs +`continuo-trino-adapter==X.Y.Z` — rather than building it from this repo's +source tree, so each image still ships exactly one adapter and the runtime +still discovers it through the `continuo_engine.adapters` entry-point group +at run time. The image **name** (`continuo-python-runtime-`) and the +adapter's pip **distribution** name (`continuo--adapter`) are two +different artifacts of the same adapter — same engine, same version, same +runtime behavior, different packaging; see "Build your own container" below +for a build shape that installs the pip package directly instead of `FROM` +the image. All four packages are still built, type-checked, and tested by CI +on every change. ### The result block is a frozen wire contract @@ -250,18 +263,38 @@ FROM ghcr.io/carolsimone/continuo-python-runtime-trino:v0.4.0 The engine is part of the image **name**; the tag is the bare version, so Continuo's Helm chart can pin an image as `:vX.Y.Z@sha256:`. -Each image bakes in exactly one `WarehouseAdapter` for that engine — installed -from this repo's `adapters/postgres/` or `adapters/trino/` package (see the -table above) — registered under the `continuo_engine.adapters` entry-point -group (entry names `postgres` / `trino`). The runtime discovers it via -`discover_adapter()` at run time, so a single image serves every node in the -service and the release-time validation Job for it. The executor injects the -warehouse connection as environment variables (engine-native, e.g. +Each image bakes in exactly one `WarehouseAdapter` for that engine — the +pinned PyPI version of the `continuo-postgres-adapter` or +`continuo-trino-adapter` package built from this repo's `adapters/postgres/` +or `adapters/trino/` source (see the table above) — registered under the +`continuo_engine.adapters` entry-point group (entry names `postgres` / +`trino`). The runtime discovers it via `discover_adapter()` at run time, so a +single image serves every node in the service and the release-time +validation Job for it. The executor injects the warehouse connection as +environment variables (engine-native, e.g. `POSTGRES_HOST`/`POSTGRES_DB`/`POSTGRES_USER`) plus the node-selection environment (`NODE_ID`, `TABLE_NAME`, `TARGET_SCHEMA`, and optionally `CONTRACT_DIR`/`APP_ROOT`) that `continuo-runtime run` reads to dispatch the right node's script. +### Build your own container + +A domain repo does not have to build `FROM` the published engine image. +`template/` ships two Dockerfiles for the two build shapes (see +`template/README.md` § "Choosing a base" for the full comparison): + +- **Shape 1 — `template/Dockerfile`** — `FROM` the published engine image + (`continuo-python-runtime-`), as shown above. Simplest; the image + already has the runtime and adapter installed and pinned. +- **Shape 2 — `template/Dockerfile.pip`** — your own base image, installing + `continuo-python-runtime` and one `continuo--adapter` from PyPI + via a hash-locked `requirements.lock` (`template/requirements.lock`). Use + this when you must control the base image yourself. + +Both shapes end up running the same runtime and the same adapter version; +which one you pick only changes who controls the base OS layer underneath +them. + ## Further reading - `docs/superpowers/specs/2026-07-31-python-runtime-design.md` — this From 243442c362d9a10017de0d3ab837083c4657c673 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 14:10:48 +0200 Subject: [PATCH 09/10] fix(ci): revert images.yml publish to tag-push, drop workflow_run indirection release.yml gates the GitHub Release by polling images.yml's run at ?head_sha=&event=push for success. Moving image-publish onto a workflow_run trigger (fired by publish-pypi completion) broke that contract silently: the push-triggered run only ran build+smoke and concluded success with no image ever pushed, so release.yml could cut a Release before or without the ghcr.io images existing. Put publish back on the tag-push run of images.yml, ordered after the wheels via the existing wait-until-installable poll rather than a cross-workflow job dependency. This restores exactly the push-run contract release.yml already polls, so release.yml needs no change; verified its header comment about not depending on workflow_run head_branch semantics is accurate again now that workflow_run is gone from images.yml entirely. Also: - Drop --no-deps from the wait probe's pip download so it also confirms the transitive continuo-engine-contract dependency is installable before building the image. - Fix publish-pypi.yml's header undercounting the release surface as two PyPI projects; there are now four (runtime, contract, postgres adapter, trino adapter). - Mirror template/Dockerfile's transitive-in-repo-import caveat into template/Dockerfile.pip, which was missing it despite the same CI shared_code_hash behavior applying to both Dockerfile shapes. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/images.yml | 63 +++++++++++------------------- .github/workflows/publish-pypi.yml | 4 +- template/Dockerfile.pip | 6 +++ 3 files changed, 31 insertions(+), 42 deletions(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 6c9e694..d9bdf2b 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -12,18 +12,9 @@ on: push: tags: - "v*" - workflow_run: - workflows: ["publish-pypi"] - types: [completed] jobs: build: - # This workflow also triggers on workflow_run (publish-pypi completion) to - # run the `publish` job below; that trigger checks out the default branch, - # not the tag commit, and has no downstream consumer for this job's - # smoke-test images (publish's needs: was removed), so skip it here rather - # than run a wrong-ref, pointless build on every release. - if: github.event_name != 'workflow_run' strategy: matrix: engine: [postgres, trino] @@ -45,10 +36,6 @@ jobs: smoke-postgres: needs: build - # Redundant with build's own guard skipping it via `needs`, but explicit - # here too: this job must never run against a workflow_run-triggered, - # default-branch checkout. - if: github.event_name != 'workflow_run' runs-on: ubuntu-latest services: postgres: @@ -118,10 +105,6 @@ jobs: smoke-trino: needs: build - # Redundant with build's own guard skipping it via `needs`, but explicit - # here too: this job must never run against a workflow_run-triggered, - # default-branch checkout. - if: github.event_name != 'workflow_run' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -173,29 +156,31 @@ jobs: if: always() run: docker compose -f tests/smoke/trino-stack/docker-compose.yml down -v - # Runs after publish-pypi.yml finishes publishing this tag's wheels to the - # real PyPI index (workflow_run, not push:tags — this job must not race the - # publish it depends on). It builds both engine images for linux/amd64 and - # linux/arm64 installing the pinned versions FROM PyPI (WHEEL_SOURCE=pypi, - # the Dockerfile default), so the published image is never ahead of what's - # actually installable, then pushes them to - # ghcr.io//continuo-python-runtime-:. The engine belongs - # in the image NAME, not the tag: Continuo's Helm chart pins an image as - # `:vX.Y.Z@sha256:`, so the tag must be the bare version. Both - # platforms are required: domain repos build their own images FROM these, - # and Continuo's e2e runs them in a kind cluster on aarch64 developer - # machines as well as on amd64 CI runners, so a single-arch publish is - # unusable off amd64. The smoke jobs above stay single-arch — they load a - # `docker save` tarball, which has no multi-platform equivalent. + # Runs on this same tag push, after the smoke jobs above pass. It builds both + # engine images for linux/amd64 and linux/arm64 installing the pinned + # versions FROM PyPI (WHEEL_SOURCE=pypi, the Dockerfile default), then + # pushes them to ghcr.io//continuo-python-runtime-:. The + # engine belongs in the image NAME, not the tag: Continuo's Helm chart pins + # an image as `:vX.Y.Z@sha256:`, so the tag must be the bare + # version. Both platforms are required: domain repos build their own images + # FROM these, and Continuo's e2e runs them in a kind cluster on aarch64 + # developer machines as well as on amd64 CI runners, so a single-arch + # publish is unusable off amd64. The smoke jobs above stay single-arch — + # they load a `docker save` tarball, which has no multi-platform + # equivalent. + # + # publish-pypi.yml runs in parallel on this same tag push, not before this + # job: there is no cross-workflow `needs:`, so this job cannot wait on that + # workflow directly. Instead the wait-until-installable step below polls + # PyPI until the pinned versions this tag just published are actually + # installable, which is what orders this build after publish-pypi.yml + # completes without a cross-workflow dependency. # -test tags publish to TestPyPI only (see publish-pypi.yml), so this job # skips them: there is no real-PyPI installable version to build an image # from for those. publish: - if: >- - github.event_name == 'workflow_run' - && github.event.workflow_run.conclusion == 'success' - && startsWith(github.event.workflow_run.head_branch, 'v') - && !contains(github.event.workflow_run.head_branch, '-test') + needs: [smoke-postgres, smoke-trino] + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-test') strategy: matrix: engine: [postgres, trino] @@ -205,8 +190,6 @@ jobs: packages: write steps: - uses: actions/checkout@v7 - with: - ref: ${{ github.event.workflow_run.head_sha }} - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 - name: Log in to ghcr.io @@ -226,7 +209,7 @@ jobs: set -eu req="image-requirements-${{ matrix.engine }}.txt" for i in $(seq 1 30); do - if pip download --no-deps --dest /tmp/probe -r "$req" >/tmp/probe.log 2>&1; then + if pip download --dest /tmp/probe -r "$req" >/tmp/probe.log 2>&1; then echo "installable"; exit 0 fi echo "attempt $i: not yet on index; sleeping"; sleep 20 @@ -239,4 +222,4 @@ jobs: file: Dockerfile.${{ matrix.engine }} platforms: linux/amd64,linux/arm64 push: true - tags: ghcr.io/${{ github.repository_owner }}/continuo-python-runtime-${{ matrix.engine }}:${{ github.event.workflow_run.head_branch }} + tags: ghcr.io/${{ github.repository_owner }}/continuo-python-runtime-${{ matrix.engine }}:${{ github.ref_name }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index e4ce3c5..ce5ad1c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -18,9 +18,9 @@ name: publish-pypi # claims every tag beginning with "v"; no other pattern may be introduced. # Tag `v-test` publishes to TestPyPI; `v` publishes to real PyPI. # The GitHub environment name is what the PyPI "pending publisher" is -# registered against — both project names need one on each index. +# registered against — all four project names need one on each index. # -# Both distributions are built into a single `dist/` and uploaded in one +# All four distributions are built into a single `dist/` and uploaded in one # publish call, so there is no ordering constraint between them. The runtime # wheel declares continuo-engine-contract as a dependency and resolves it from # the index at install time ([tool.uv.sources] is dev-only and is not embedded diff --git a/template/Dockerfile.pip b/template/Dockerfile.pip index 88fc1d2..adc249e 100644 --- a/template/Dockerfile.pip +++ b/template/Dockerfile.pip @@ -11,6 +11,12 @@ ENV CONTRACT_DIR=/app/contracts APP_ROOT=/app PYTHONPATH=/app WORKDIR /app COPY contracts/ /app/contracts/ COPY scripts/ /app/scripts/ +# COPY every OTHER directory your scripts import from, too. CI folds a script's +# transitive in-repo import closure into shared_code_hash, so a helper module in +# e.g. lib/ or common/ is hashed whether or not it is in the image — a helper +# left out here produces a valid release artifact and a node that dies with +# ModuleNotFoundError on its first run. Add one line per such directory, e.g.: +# COPY lib/ /app/lib/ RUN useradd --uid 65532 --no-create-home --shell /usr/sbin/nologin nonroot USER 65532:65532 ENTRYPOINT ["continuo-runtime"] From b5a0b95061225ce346916654cd482d75b88538b3 Mon Sep 17 00:00:00 2001 From: Simone Carolini Date: Tue, 25 Aug 2026 15:08:23 +0200 Subject: [PATCH 10/10] =?UTF-8?q?fix(ci):=20address=20PR=20#50=20review=20?= =?UTF-8?q?=E2=80=94=203.14=20probe,=20optional=20wheelhouse=20COPY,=20ada?= =?UTF-8?q?pter=20test=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - images.yml [P1]: set up Python 3.14 for the PyPI availability probe. The pins declare Requires-Python >=3.14, so the runner's default (older) python would reject `pip download` even after publication, spinning out all 30 retries and failing the image workflow (blocking release.yml). Probe now runs under 3.14. - Dockerfile.postgres/.trino [P2]: make the wheelhouse COPY optional via a bracket glob (`COPY wheelhous[e]`), so a default WHEEL_SOURCE=pypi `docker build -f Dockerfile. .` needs no pre-created directory; the now-unneeded CI `mkdir -p wheelhouse` step is removed. - publish-pypi.yml [P2]: run both adapter suites in the publish gate so an adapter regression blocks its own immutable PyPI upload (images.yml runs in parallel and cannot). Separate pytest invocations avoid the workspace tests-package/conftest collision documented in pyproject.toml. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Simone Carolini --- .github/workflows/images.yml | 17 ++++++++++------- .github/workflows/publish-pypi.yml | 16 +++++++++++++--- Dockerfile.postgres | 7 +++++-- Dockerfile.trino | 7 +++++-- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index d9bdf2b..9ab0792 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -198,18 +198,21 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # The Dockerfiles unconditionally COPY ./wheelhouse (see Dockerfile.postgres - # / Dockerfile.trino); this build installs from PyPI (WHEEL_SOURCE - # defaults to pypi below), so the directory only needs to exist, not - # contain anything. - - name: Create empty wheelhouse (release installs from PyPI, not this dir) - run: mkdir -p wheelhouse + # The pinned distributions declare Requires-Python >= 3.14, so the + # availability probe must resolve them under a 3.14 interpreter — the + # runner's default python is older and pip would reject them even after + # they are published, spinning out every retry. (The image build itself + # installs inside python:3.14-slim, so it is unaffected; this is only for + # the host-side probe below.) + - uses: actions/setup-python@v5 + with: + python-version: '3.14' - name: Wait until the pinned versions are installable from PyPI run: | set -eu req="image-requirements-${{ matrix.engine }}.txt" for i in $(seq 1 30); do - if pip download --dest /tmp/probe -r "$req" >/tmp/probe.log 2>&1; then + if python -m pip download --dest /tmp/probe -r "$req" >/tmp/probe.log 2>&1; then echo "installable"; exit 0 fi echo "attempt $i: not yet on index; sleeping"; sleep 20 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ce5ad1c..0c753b1 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -48,11 +48,21 @@ jobs: - name: Refuse a tag that changed a package without bumping its version run: python scripts/check_version_bumps.py - name: Test before publishing - # `-m "not image"` deselects the image smoke tests; images.yml runs - # those against a built engine image. + # Gate all four published projects, including both adapter suites — an + # adapter regression must block its own immutable PyPI upload, and + # images.yml runs in parallel so its smoke failure cannot. Each suite is + # a SEPARATE pytest invocation: every workspace member's tests/ is its + # own `tests` package (with __init__.py + conftest.py), so running two + # of them in one process collides on the `tests.conftest` module name + # (see the [tool.pytest.ini_options] note in pyproject.toml). `not image + # and not integration` deselects the tests needing a built engine image + # or a live warehouse (images.yml / the smoke jobs cover those). The + # step's default `set -e` fails the gate if any invocation fails. run: | uv sync --all-packages --all-groups - uv run pytest tests contract/tests -m "not image" -q + uv run pytest tests contract/tests -m "not image and not integration" -q + uv run pytest adapters/postgres/tests -m "not image and not integration" -q + uv run pytest adapters/trino/tests -m "not image and not integration" -q - name: Build the contract sdist + wheel run: uv build --package continuo-engine-contract -o dist - name: Build the runtime sdist + wheel diff --git a/Dockerfile.postgres b/Dockerfile.postgres index 032a788..850f7ed 100644 --- a/Dockerfile.postgres +++ b/Dockerfile.postgres @@ -29,8 +29,11 @@ FROM python:3.14-slim # the three first-party packages already satisfied. ARG WHEEL_SOURCE=pypi COPY image-requirements-postgres.txt /tmp/req.txt -# CI always creates ./wheelhouse (empty on release) so this COPY never fails. -COPY wheelhouse /tmp/wheelhouse +# Optional COPY: the bracket glob matches ./wheelhouse when it exists (the CI/PR +# WHEEL_SOURCE=wheelhouse path builds it) and no-ops when it is absent, so a +# release build or a plain `docker build -f Dockerfile.postgres .` needs no +# pre-created directory. Only the wheelhouse branch below reads its contents. +COPY wheelhous[e] /tmp/wheelhouse RUN set -eu; \ if [ "$WHEEL_SOURCE" = "wheelhouse" ]; then \ pip install --no-cache-dir --no-index --no-deps \ diff --git a/Dockerfile.trino b/Dockerfile.trino index 2261b3a..1d90623 100644 --- a/Dockerfile.trino +++ b/Dockerfile.trino @@ -29,8 +29,11 @@ FROM python:3.14-slim # the three first-party packages already satisfied. ARG WHEEL_SOURCE=pypi COPY image-requirements-trino.txt /tmp/req.txt -# CI always creates ./wheelhouse (empty on release) so this COPY never fails. -COPY wheelhouse /tmp/wheelhouse +# Optional COPY: the bracket glob matches ./wheelhouse when it exists (the CI/PR +# WHEEL_SOURCE=wheelhouse path builds it) and no-ops when it is absent, so a +# release build or a plain `docker build -f Dockerfile.trino .` needs no +# pre-created directory. Only the wheelhouse branch below reads its contents. +COPY wheelhous[e] /tmp/wheelhouse RUN set -eu; \ if [ "$WHEEL_SOURCE" = "wheelhouse" ]; then \ pip install --no-cache-dir --no-index --no-deps \