From 3d43043284db1041aaa64811e0bb40c3673cfc6d Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:12:03 +0100 Subject: [PATCH] feat: make the Containerfile and devcontainer runnable (wave-2 3/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Containerfile was a template stub (commented-out build/copy/entrypoint, chainguard/static base — no Julia package exists for it). Rebase it on the official docker.io/library/julia:1.10 image (pinned by digest, matches the Manifest.toml-pinned julia_version 1.10.11), instantiate+precompile the committed Manifest.toml, run as a non-root user, and set ENTRYPOINT to the real main() entry point (src/tools/chat.jl). Verified locally with `docker build` + `docker run` (WSL Debian): the image builds in ~2 min and the entrypoint reaches the documented offline-demo fallback and exits 0. devcontainer.json gets Julia via the JuliaLang devcontainer feature (pinned to the 1.10 channel) and a real postCreateCommand (`just setup`, which wave-1 PR #34 already made a real Pkg.instantiate() rather than a no-op). Add .github/workflows/container-build.yml (build + entrypoint smoke test, no push/registry) and a .dockerignore for the build context. Reconcile QUICKSTART-USER.adoc (native vs. container install options) and QUICKSTART-MAINTAINER.adoc (working podman build/run commands) with the now- functional image, including a verified caveat: STATISTIKLES_LM_URL set at `run` time has no effect on a precompiled image or native install, because Pkg.precompile() bakes the ENV-derived BASE_URL constant into the package's precompile cache — confirmed by testing both the container and a native WSL Julia run. That's a pre-existing src/tools/lmstudio.jl behavior, out of scope here; documented rather than silently left broken. Local verification: `docker build -f Containerfile .` and `docker run` (WSL Debian, docker 29.6.1) — build green, entrypoint reaches main(), prints the documented banner + offline demo, exits 0, runs as uid 1000. devcontainer.json validated as syntactically valid JSON (comments stripped) via jq; container-build.yml validated via PyYAML. Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/README.adoc | 4 +- .devcontainer/devcontainer.json | 8 ++- .dockerignore | 18 +++++++ .github/workflows/container-build.yml | 60 +++++++++++++++++++++++ Containerfile | 70 ++++++++++++++------------- QUICKSTART-MAINTAINER.adoc | 37 ++++++++++++-- QUICKSTART-USER.adoc | 19 ++++++++ 7 files changed, 175 insertions(+), 41 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/container-build.yml diff --git a/.devcontainer/README.adoc b/.devcontainer/README.adoc index edf06be..ed9db42 100644 --- a/.devcontainer/README.adoc +++ b/.devcontainer/README.adoc @@ -5,7 +5,7 @@ == Overview -This dev container uses `cgr.dev/chainguard/wolfi-base` with git, curl, bash, and just pre-installed. Dev container features add git, just, and nickel automatically. +This dev container uses `cgr.dev/chainguard/wolfi-base` with git, curl, bash, and just pre-installed. Dev container features add git, just, nickel, and Julia 1.10 (via Juliaup) automatically. == VS Code (Local) @@ -25,4 +25,4 @@ This dev container uses `cgr.dev/chainguard/wolfi-base` with git, curl, bash, an == Customization -Replace `Statistikles` placeholders in both `devcontainer.json` and `Containerfile` with your actual project name. Run `just deps` to verify the environment after first launch. +Run `just setup` to verify the environment after first launch (already run automatically by `postCreateCommand`). diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 31230a8..efb1683 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -18,10 +18,13 @@ "version": "latest" }, "ghcr.io/jdx/devcontainer-features/just:1": {}, - "ghcr.io/nickel-lang/devcontainer-feature:0": {} + "ghcr.io/nickel-lang/devcontainer-feature:0": {}, + "ghcr.io/julialang/devcontainer-features/julia:1": { + "channel": "1.10" + } }, - "postCreateCommand": "just deps", + "postCreateCommand": "just setup", "remoteUser": "nonroot", @@ -34,6 +37,7 @@ "vscode": { "extensions": [ "EditorConfig.EditorConfig", + "julialang.language-julia", "eamodio.gitlens", "streetsidesoftware.code-spell-checker", "timonwong.shellcheck", diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7a46de5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: MPL-2.0 +# Build-context excludes for `docker build`/`podman build` (Containerfile, +# .devcontainer/Containerfile). Podman falls back to this file when no +# .containerignore is present. + +.git/ +.github/ +.devcontainer/ +docs/ +proofs/ +ffi/ +benches/ +examples/ +generated/ +.claude/ +node_modules/ +*.log +/tmp/ diff --git a/.github/workflows/container-build.yml b/.github/workflows/container-build.yml new file mode 100644 index 0000000..7f154c7 --- /dev/null +++ b/.github/workflows/container-build.yml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Statistikles — Container Build +# +# Builds the root Containerfile so it cannot silently rot back into a +# template stub. Build only — no push, no registry login. + +name: Container Build + +on: + push: + branches: [main] + paths: + - 'Containerfile' + - '.dockerignore' + - 'Project.toml' + - 'Manifest.toml' + - 'src/**' + - '.github/workflows/container-build.yml' + pull_request: + branches: [main] + paths: + - 'Containerfile' + - '.dockerignore' + - 'Project.toml' + - 'Manifest.toml' + - 'src/**' + - '.github/workflows/container-build.yml' + workflow_dispatch: + +permissions: read-all + +concurrency: + group: container-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: docker build . + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Build Containerfile + run: docker build -f Containerfile -t statistikles:ci . + + - name: Smoke — run the entrypoint + run: | + # No LM Studio endpoint is reachable from the runner, so main() + # (src/tools/chat.jl) takes its documented offline-demo fallback + # and exits 0 — see the Containerfile's ENTRYPOINT comment and + # QUICKSTART-USER.adoc "First Run". + docker run --rm statistikles:ci | tee /tmp/container-run.log + grep -q "Statistikles v0.1.0" /tmp/container-run.log + grep -q "Cannot connect to LM Studio" /tmp/container-run.log + grep -q "All numbers above were computed by Julia, not an LLM." /tmp/container-run.log diff --git a/Containerfile b/Containerfile index 985834d..d9c0372 100644 --- a/Containerfile +++ b/Containerfile @@ -5,37 +5,39 @@ # Build: podman build -t statistikles:latest -f Containerfile . # Run: podman run --rm -it statistikles:latest # Seal: selur seal statistikles:latest - -# --- Build stage --- -FROM cgr.dev/chainguard/wolfi-base:latest AS build - -# TODO: Install build dependencies for your stack -# Examples: -# RUN apk add --no-cache rust cargo # Rust -# RUN apk add --no-cache elixir erlang # Elixir -# RUN apk add --no-cache zig # Zig - -WORKDIR /build -COPY . . - -# TODO: Replace with your build command -# Examples: -# RUN cargo build --release -# RUN mix deps.get && MIX_ENV=prod mix release -# RUN zig build -Doptimize=ReleaseSafe - -# --- Runtime stage --- -FROM cgr.dev/chainguard/static:latest - -# Copy built artifact from build stage -# TODO: Replace with your binary/artifact path -# Examples: -# COPY --from=build /build/target/release/statistikles /usr/local/bin/ -# COPY --from=build /build/_build/prod/rel/statistikles /app/ -# COPY --from=build /build/zig-out/bin/statistikles /usr/local/bin/ - -# Non-root user (chainguard images default to nonroot) -USER nonroot - -# TODO: Replace with your entrypoint -# ENTRYPOINT ["/usr/local/bin/statistikles"] +# +# Statistikles is a pure Julia package with no compiled binary artifact (see +# "Build from Source" in QUICKSTART-MAINTAINER.adoc) — the `julia` executable +# itself is the runtime, so there is nothing a separate build stage could +# hand off that the runtime doesn't also need. A single stage on the official +# Julia image is therefore correct here, not a shortcut. Chainguard ships no +# Julia package, so the org-default Wolfi/static base (docs/AI-CONVENTIONS.md) +# does not apply to this stack. + +# docker.io/library/julia:1.10 == Julia 1.10.11 (Debian trixie), matching the +# `julia_version` pinned in the committed Manifest.toml and .tool-versions. +FROM docker.io/library/julia:1.10@sha256:12477d07306333f0c59c04274196432a3834f8c44a0a10585de659fbf8ab0e54 + +# Non-root runtime user +RUN useradd --create-home --uid 1000 --shell /bin/bash statistikles +WORKDIR /app +COPY --chown=statistikles:statistikles . . +USER statistikles +ENV JULIA_DEPOT_PATH=/home/statistikles/.julia + +# Resolve the committed Manifest.toml exactly (no re-resolution) and +# precompile so the entrypoint starts promptly. +RUN julia --project=. -e 'using Pkg; Pkg.instantiate(); Pkg.precompile()' + +# `main()` (src/tools/chat.jl) prints the startup banner, then tries to reach +# an LM Studio-compatible endpoint (STATISTIKLES_LM_URL, default +# http://localhost:1234/v1). With no LLM endpoint reachable from the +# container it prints "Cannot connect..." and runs the offline +# `run_examples()` demo instead, then exits 0 — it does not hang waiting for +# input. Run with `--network=host` to reach a host-side LM Studio at the +# default address for the interactive chat session instead. NOTE: setting +# STATISTIKLES_LM_URL at `docker/podman run` time has no effect — Julia's +# Pkg.precompile() above already baked the default URL into the package's +# precompile cache; to change it, set STATISTIKLES_LM_URL as an image build +# ENV before the RUN Pkg.precompile() line and rebuild. +ENTRYPOINT ["julia", "--project=/app", "-e", "using Statistikles; main()"] diff --git a/QUICKSTART-MAINTAINER.adoc b/QUICKSTART-MAINTAINER.adoc index f2ee482..053cc34 100644 --- a/QUICKSTART-MAINTAINER.adoc +++ b/QUICKSTART-MAINTAINER.adoc @@ -38,9 +38,40 @@ repository plus `Project.toml`, consumed through Julia's `Pkg`. There is no install step beyond `just setup` — the project environment lives inside the checkout, and compiled artifacts live in the user's Julia depot (`~/.julia`). -Container (`Containerfile`) and Guix (`guix.scm`) packaging surfaces exist in -the repository but are not yet functional — do not base a distribution package -on them until they are completed. +The `Containerfile` builds a runnable image (see "Container" below). Guix +(`guix.scm`) is a separate, not-yet-functional packaging surface — do not +base a distribution package on it until it is completed. + +== Container + +[source,bash] +---- +podman build -t statistikles:latest -f Containerfile . +podman run --rm -it statistikles:latest +---- + +(`docker` works identically in place of `podman`.) The image is built from +the official `docker.io/library/julia:1.10` base (pinned by digest in the +Containerfile), runs as a non-root user, and its `ENTRYPOINT` is the same +`main()` entry point as `just run`. With no LLM endpoint reachable from the +container it falls back to the offline `run_examples()` demo and exits 0 — +see "First Run" in QUICKSTART-USER.adoc for the exact expected output. To +reach a host-side LM Studio instance at the default address instead: + +[source,bash] +---- +podman run --rm -it --network=host statistikles:latest +---- + +NOTE: `STATISTIKLES_LM_URL` set at `podman run` time has **no effect** on a +precompiled image or a precompiled native install — verified by testing both. +`Pkg.precompile()` evaluates and caches `BASE_URL` (`src/tools/lmstudio.jl`) +into the package's precompile image at the time it runs; loading the package +afterwards deserializes that cache rather than re-reading `ENV`. This is not +container-specific — the same applies to `just build && just run` natively. +To point a built image at a non-default endpoint, set `STATISTIKLES_LM_URL` +as a build-time `ENV` *before* the `Pkg.precompile()` step in the +Containerfile and rebuild. == Configuration diff --git a/QUICKSTART-USER.adoc b/QUICKSTART-USER.adoc index 87d3ba4..14c3599 100644 --- a/QUICKSTART-USER.adoc +++ b/QUICKSTART-USER.adoc @@ -37,6 +37,8 @@ Before you begin, ensure you have: == Install +=== Option 1: Native Julia + [source,bash] ---- # Clone and set up @@ -56,6 +58,23 @@ If you prefer not to use just, the equivalent raw command is: julia --project=. -e 'using Pkg; Pkg.instantiate()' ---- +=== Option 2: Container + +No local Julia install required — just Podman or Docker: + +[source,bash] +---- +git clone https://github.com/hyperpolymath/statistikles.git +cd statistikles +podman build -t statistikles:latest -f Containerfile . +podman run --rm -it statistikles:latest +---- + +(`docker` works identically in place of `podman`.) This builds the official +`docker.io/library/julia:1.10` base image with the project instantiated and +precompiled, then runs it as a non-root user. Expected output matches +"First Run" below. + == First Run [source,bash]