From 42c8aa50a40727377b483b9b7059638fa790ae94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 10 Jun 2026 11:44:09 +0000 Subject: [PATCH] Add py-image-builder CI: tests + lint, and a Docker-backed demo job on PRs Co-authored-by: Jason Hall --- .github/workflows/py-image-builder.yaml | 178 ++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 .github/workflows/py-image-builder.yaml diff --git a/.github/workflows/py-image-builder.yaml b/.github/workflows/py-image-builder.yaml new file mode 100644 index 0000000..390fd17 --- /dev/null +++ b/.github/workflows/py-image-builder.yaml @@ -0,0 +1,178 @@ +name: py-image-builder + +on: + pull_request: + paths: + - "py-image-builder/**" + - ".github/workflows/py-image-builder.yaml" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-go@v6.2.0 + with: + go-version-file: py-image-builder/go.mod + cache-dependency-path: py-image-builder/go.sum + + - name: Run tests + working-directory: py-image-builder + run: go test -race ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9.2.0 + with: + working-directory: py-image-builder + version: latest + + demo: + # Demonstrates the docker-less builder end to end against a real registry, + # then pulls and RUNS the built image with Docker to prove it works. + runs-on: ubuntu-latest + services: + registry: + image: registry:2 + ports: + - 5000:5000 + env: + REGISTRY: localhost:5000 + BASE: python:3.12-slim + PYTHON: python3.12 + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-go@v6.2.0 + with: + go-version-file: py-image-builder/go.mod + cache-dependency-path: py-image-builder/go.sum + + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" + + - name: Build the CLI + working-directory: py-image-builder + run: go build -o pib . + + - name: Prepare a demo app, wheelhouse, and hashed lock + working-directory: py-image-builder + run: | + set -euo pipefail + mkdir -p demo/wheelhouse demo/src + + cat > demo/src/app.py <<'PY' + import click + import six + + print("py-image-builder demo OK | six=%s click=%s" % (six.__version__, click.__version__)) + PY + + python -m pip install --upgrade pip + # Pure-python wheels (py3-none-any), resolved locally to keep the build hermetic. + pip download --no-deps --only-binary=:all: \ + six==1.16.0 click==8.1.7 -d demo/wheelhouse + + six_whl=$(ls demo/wheelhouse/six-*.whl) + click_whl=$(ls demo/wheelhouse/click-*.whl) + { + echo "six==1.16.0 --hash=sha256:$(sha256sum "$six_whl" | awk '{print $1}')" + echo "click==8.1.7 --hash=sha256:$(sha256sum "$click_whl" | awk '{print $1}')" + } > demo/requirements.txt + echo "----- requirements.txt -----" + cat demo/requirements.txt + + - name: Build and push the image (no Docker daemon involved) + working-directory: py-image-builder + run: | + set -euo pipefail + ./pib build \ + --base "$BASE" \ + --lock demo/requirements.txt \ + --find-links demo/wheelhouse \ + --source demo/src \ + --python "$PYTHON" \ + --entrypoint python --entrypoint /app/app.py \ + --sbom demo/sbom.json \ + --insecure \ + -t "$REGISTRY/demo:v1" + echo "----- SBOM -----" + cat demo/sbom.json + + - name: Verify the build is reproducible (same digest twice) + working-directory: py-image-builder + run: | + set -euo pipefail + common=(--base "$BASE" --lock demo/requirements.txt --find-links demo/wheelhouse \ + --source demo/src --python "$PYTHON" \ + --entrypoint python --entrypoint /app/app.py --insecure --push=false) + d1=$(./pib build "${common[@]}" --print-digest) + d2=$(./pib build "${common[@]}" --print-digest) + echo "digest #1: $d1" + echo "digest #2: $d2" + if [ "$d1" != "$d2" ]; then + echo "::error::build is not reproducible: $d1 != $d2" + exit 1 + fi + echo "Reproducible: $d1" + + - name: Pull and RUN the image with Docker + run: | + set -euo pipefail + docker pull "$REGISTRY/demo:v1" + out=$(docker run --rm "$REGISTRY/demo:v1") + echo "container output: $out" + echo "$out" | grep -q "py-image-builder demo OK" + + - name: Install crane (for manifest inspection) + run: go install github.com/google/go-containerregistry/cmd/crane@v0.19.2 + + - name: Adding a dependency reuses existing layers (only one new layer) + working-directory: py-image-builder + run: | + set -euo pipefail + # Resolve one more pure-python wheel and append it to the lock. + pip download --no-deps --only-binary=:all: idna==3.7 -d demo/wheelhouse + idna_whl=$(ls demo/wheelhouse/idna-*.whl) + echo "idna==3.7 --hash=sha256:$(sha256sum "$idna_whl" | awk '{print $1}')" >> demo/requirements.txt + + # Build the new image (now with the extra dependency) and push it. + ./pib build \ + --base "$BASE" \ + --lock demo/requirements.txt \ + --find-links demo/wheelhouse \ + --source demo/src \ + --python "$PYTHON" \ + --entrypoint python --entrypoint /app/app.py \ + --insecure \ + -t "$REGISTRY/demo:v2" + + # The new image still runs. + docker pull "$REGISTRY/demo:v2" + out=$(docker run --rm "$REGISTRY/demo:v2") + echo "container output: $out" + echo "$out" | grep -q "py-image-builder demo OK" + + # Compare layer sets: every layer from v1 must still be present in v2 + # (unchanged digests), and v2 must have exactly one more layer. + crane manifest --insecure "$REGISTRY/demo:v1" | jq -r '.layers[].digest' | sort > v1.layers + crane manifest --insecure "$REGISTRY/demo:v2" | jq -r '.layers[].digest' | sort > v2.layers + echo "v1 layers:"; cat v1.layers + echo "v2 layers:"; cat v2.layers + + missing=$(comm -23 v1.layers v2.layers || true) + if [ -n "$missing" ]; then + echo "::error::these v1 layers changed/disappeared in v2 (should be reused):" + echo "$missing" + exit 1 + fi + + n1=$(wc -l < v1.layers) + n2=$(wc -l < v2.layers) + echo "layer counts: v1=$n1 v2=$n2" + if [ "$n2" -ne "$((n1 + 1))" ]; then + echo "::error::expected exactly one new layer, got $n1 -> $n2" + exit 1 + fi + echo "Confirmed: adding a dependency reused all existing layers and added exactly one."