From c5040f42425c815518e8b3879a0c722977e6801c Mon Sep 17 00:00:00 2001 From: "Derek Palmer (Creative)" Date: Fri, 29 May 2026 14:29:41 -0400 Subject: [PATCH 1/3] fix(docker): build publishable image on python:3.13-slim The image never published: the Dockerfile built FROM dhi.io/python:3.13, a distroless base with no /bin/sh, so `RUN pip install` died with `exec: "/bin/sh": ... no such file or directory` on every tag. Drop the hardened distroless base (it solved a network-service threat model this CLI image doesn't have, can't pin Python 3.13, and republishes licensed DHI layers to public registries). Build multistage on python:3.13-slim: builder installs the wheel into a venv, runtime copies only the venv. Install the wheel (not -e .) so package-data (prompts, tasks.json) is bundled and no source tree is needed at runtime. /workspace stays a bind-mount point for the caller's repo. Publish to Docker Hub (heyderekp/codeforerunner) alongside GHCR; the DHI registry login is gone. DOCKER_USERNAME/DOCKER_PASSWORD are repurposed as Docker Hub username + PAT. Closes #72 --- .github/workflows/docker-publish.yml | 15 ++++++++------- Dockerfile | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ee01443..11e84d9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -13,12 +13,6 @@ jobs: packages: write steps: - uses: actions/checkout@v6.0.2 - - name: Log in to DHI registry - uses: docker/login-action@v3 - with: - registry: dhi.io - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - uses: actions/setup-python@v6.2.0 with: python-version: "3.11" @@ -41,11 +35,18 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/codeforerunner + images: | + ghcr.io/${{ github.repository_owner }}/codeforerunner + heyderekp/codeforerunner tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/Dockerfile b/Dockerfile index 24dbf5c..f931c9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,27 @@ -FROM dhi.io/python:3.13 +FROM python:3.13-slim AS builder ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -WORKDIR /workspace +WORKDIR /build +COPY . . + +# Install the wheel into an isolated venv so only it (no build toolchain or +# source tree) is carried into the runtime stage. +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir . + +FROM python:3.13-slim -COPY . /workspace +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" -RUN python -m pip install --upgrade pip \ - && python -m pip install --no-cache-dir -e . +COPY --from=builder /opt/venv /opt/venv + +# /workspace is a mount point: `forerunner` runs against the caller's repo +# (Path.cwd()), bind-mounted here. Source is not baked in. +WORKDIR /workspace ENTRYPOINT ["forerunner"] From ba660fde872def43709428a6bc545d13b8d096b9 Mon Sep 17 00:00:00 2001 From: "Derek Palmer (Creative)" Date: Fri, 29 May 2026 14:36:49 -0400 Subject: [PATCH 2/3] test(docker): assert GHCR+Docker Hub publish, drop dhi.io expectation The workflow shape test asserted a dhi.io login, which encoded the broken distroless setup. Assert the new targets instead: a ghcr.io login, a Docker Hub login (default docker.io registry), no dhi.io, and both image names in the metadata. Refs #72 --- tests/test_workflows_yaml.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_workflows_yaml.py b/tests/test_workflows_yaml.py index 934764e..bde73a5 100644 --- a/tests/test_workflows_yaml.py +++ b/tests/test_workflows_yaml.py @@ -108,11 +108,23 @@ def test_docker_publish_workflow_uses_version_tag_and_ghcr(): step for step in steps if isinstance(step, dict) and step.get("uses") == "docker/login-action@v3" ] - assert any(step.get("with", {}).get("registry") == "dhi.io" for step in login_steps) + # Publishes to GHCR (registry ghcr.io) and Docker Hub (login-action with no + # `registry`, which defaults to docker.io). The distroless DHI base was + # dropped (#72), so there must be no dhi.io login left. + registries = [step.get("with", {}).get("registry") for step in login_steps] + assert "ghcr.io" in registries, f"expected a ghcr.io login, got {registries!r}" + assert any(r in (None, "docker.io") for r in registries), ( + f"expected a Docker Hub login (no registry / docker.io), got {registries!r}" + ) + assert "dhi.io" not in registries, "dhi.io login should be removed (#72)" + steps_text = "\n".join(str(step) for step in publish.get("steps", [])) assert "docker/login-action" in steps_text assert "docker/build-push-action" in steps_text assert "scripts/check_versions.py" in steps_text + # Both publish targets must appear in the image metadata. + assert "ghcr.io" in steps_text + assert "heyderekp/codeforerunner" in steps_text def test_release_pr_workflow_requires_release_signal_and_uploads_artifacts(): From 30bcc0a80a62fb840883c8ad4a5b9395547a5927 Mon Sep 17 00:00:00 2001 From: "Derek Palmer (Creative)" Date: Fri, 29 May 2026 14:52:27 -0400 Subject: [PATCH 3/3] test(docker): match registries/images by equality not host substring CodeQL py/incomplete-url-substring-sanitization fired on `"ghcr.io" in `. Compare login registries with == and match full image refs against the parsed metadata-action images list instead. Refs #72 --- tests/test_workflows_yaml.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/test_workflows_yaml.py b/tests/test_workflows_yaml.py index bde73a5..73523b5 100644 --- a/tests/test_workflows_yaml.py +++ b/tests/test_workflows_yaml.py @@ -110,21 +110,32 @@ def test_docker_publish_workflow_uses_version_tag_and_ghcr(): ] # Publishes to GHCR (registry ghcr.io) and Docker Hub (login-action with no # `registry`, which defaults to docker.io). The distroless DHI base was - # dropped (#72), so there must be no dhi.io login left. + # dropped (#72), so there must be no dhi.io login left. Compare registry + # values with equality (not substring `in`) to avoid host-substring checks. registries = [step.get("with", {}).get("registry") for step in login_steps] - assert "ghcr.io" in registries, f"expected a ghcr.io login, got {registries!r}" - assert any(r in (None, "docker.io") for r in registries), ( + assert any(r == "ghcr.io" for r in registries), ( + f"expected a ghcr.io login, got {registries!r}" + ) + assert any(r is None or r == "docker.io" for r in registries), ( f"expected a Docker Hub login (no registry / docker.io), got {registries!r}" ) - assert "dhi.io" not in registries, "dhi.io login should be removed (#72)" + assert all(r != "dhi.io" for r in registries), "dhi.io login should be removed (#72)" steps_text = "\n".join(str(step) for step in publish.get("steps", [])) assert "docker/login-action" in steps_text assert "docker/build-push-action" in steps_text assert "scripts/check_versions.py" in steps_text - # Both publish targets must appear in the image metadata. - assert "ghcr.io" in steps_text - assert "heyderekp/codeforerunner" in steps_text + + # Both publish targets must appear in the image metadata. Parse the + # metadata-action `images` list and match full image refs exactly. + meta_step = next( + step for step in steps + if isinstance(step, dict) and str(step.get("uses", "")).startswith("docker/metadata-action") + ) + images = [ln.strip() for ln in str(meta_step["with"]["images"]).splitlines() if ln.strip()] + expected_ghcr = "ghcr.io/${{ github.repository_owner }}/codeforerunner" + assert expected_ghcr in images, f"expected GHCR image, got {images!r}" + assert "heyderekp/codeforerunner" in images, f"expected Docker Hub image, got {images!r}" def test_release_pr_workflow_requires_release_signal_and_uploads_artifacts():