Skip to content

5. Install the wheel in Docker instead of freezing a version file and copying source

  • Status: accepted
  • Date: 2026-08-14

Context

0004 migrated the Docker build's dependency layer to uv: uv sync --frozen --no-install-project --extra forecast installs everything uv.lock pins, reproducibly. --no-install-project is deliberate there — it skips building/installing the project itself, keeping the dependency layer independent of source changes so it stays cache-friendly across commits.

That flag has a side effect nothing in 0004 addressed: pyproject.toml declares [tool.setuptools_scm] version_file = "solaredge2mqtt/_version.py", which only fires as part of an actual build (uv build, uv sync of the project itself, pip install .). Because --no-install-project never builds the project, solaredge2mqtt/_version.py never gets written, and the final Docker stage had nothing to install from — it fell back to copying raw source straight in (COPY solaredge2mqtt/ ./solaredge2mqtt/, COPY pyproject.toml README.md LICENSE ./).

freeze_version.py existed to paper over the missing version file: it called setuptools_scm.get_version(..., version_file=...) directly, and build_project.yml's build-docker job ran it via uv run --no-project --with 'setuptools-scm[toml]>=8' python freeze_version.py before the Docker build, requiring both a full-history (fetch-depth: 0) checkout and its own uv install on the runner just to run one script.

Meanwhile build-check already runs uv build and uploads the resulting wheel as python-artifacts-3.13-${REF_NAME} — a wheel that already contains a correct, git-resolved _version.py, baked in as an ordinary side effect of a real build. build-docker never used it.

The raw source copy also had a quieter problem: [tool.setuptools] in pyproject.toml declares forecast.py, migrate_config.py and monitoring.py as top-level py-modules backing the solaredge2mqtt-monitoring and solaredge2mqtt-migrate console scripts. The COPY solaredge2mqtt/ ./solaredge2mqtt/ line never touched those root-level files, so the scripts were silently broken inside the image — installed as entry points by nothing, since nothing installed the project at all.

Decision

Stop skipping the real build. build-docker downloads the wheel build-check already built and installs it with uv pip install --no-deps on top of the dependencies synced from uv.lock, the same two-layer pattern the sibling learninghouse repo's docker/Dockerfile already uses:

COPY dist/*.whl /app/dist/
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked \
    uv pip install --no-deps /app/dist/*.whl

--no-deps matters here: dependencies are already synced from uv.lock in the layer above, and letting uv pip install resolve them again from the wheel's metadata would both duplicate work and risk drifting from the lockfile's pins.

The final stage's two source-copy COPY lines are removed — the package now comes from the wheel installed above, already inside /venv.

.dockerignore gets an explicit !dist/*.whl allow rule, ordered after the blanket dist/ deny line (Docker ignore-pattern order matters: later patterns override earlier ones), so the downloaded wheel can reach the build context.

build_project.yml's build-docker job replaces its "Generate static solaredge2mqtt/_version.py" step with a "Download python artifacts" step, pulling python-artifacts-3.13-${{ env.REF_NAME }} into dist via actions/download-artifact (pinned to the same SHA already used for the digest download in merge-manifest, rather than inventing a new pin). build-docker already depends on build-check (needs: [variables, build-check]), so the artifact is guaranteed to exist by the time this step runs.

freeze_version.py and tests/test_freeze_version.py are deleted, not simplified further — there is nothing left for them to do. solaredge2mqtt/__init__.py's fallback comment is updated to reflect that the git-query fallback now only matters for a genuinely unbuilt/uninstalled source checkout (e.g. running straight from a clone without uv sync/pip install .); the fallback logic itself is unchanged, since Docker installing a real wheel means it never hits that branch there.

Consequence

build-docker's Checkout step no longer needs fetch-depth: 0 — that was only ever needed so freeze_version.py could query git history directly on the runner. With that step gone, nothing else in the job touches git history: the Dockerfile, pyproject.toml, uv.lock and docker-entrypoint.sh are all present in an ordinary shallow checkout, and the image tags are built from env.VERSION/env.REF_NAME, both computed once in the variables job from github.ref_name, not from anything build-docker itself derives from git. Checked the rest of the job's steps (metadata extraction, tag construction, digest export/upload, the docker/build-push-action call) for anything else that might implicitly depend on tags or history being present — nothing does. The step now defaults to a shallow checkout.

The job's own astral-sh/setup-uv "Install uv" step is removed too — it existed solely to run freeze_version.py via uv run --no-project --with 'setuptools-scm[toml]>=8' ... on the runner. Nothing else in build-docker invokes uv on the runner: the Dockerfile does its own uv install inside the build (COPY --from=ghcr.io/astral-sh/uv:0.12.3 /uv /usr/local/bin/uv), entirely independent of the runner's toolchain.

As a side effect, solaredge2mqtt-monitoring and solaredge2mqtt-migrate become genuinely present and installed inside the image for the first time — the wheel uv build produces includes forecast.py, migrate_config.py and monitoring.py (the py-modules those console scripts are built from), where the old source-copy silently omitted all three.