From e41ab37778260f3940fe6e266138251037067f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=C3=BA=20Anh?= Date: Wed, 14 Oct 2026 08:43:07 +0000 Subject: [PATCH 1/3] build: Docker base images built from this repository, pushed on release The base images WN-Sense builds FROM (django-whiteneuron-{alpine,debian} :v) were built in WN-Sense from a git+https install of a tag. They now live with the package: - docker/Dockerfile.base-{alpine,debian}: install the package from the build context (bind mount, no source layer), run as appuser (/app) like before, and fail if WHITENEURON_VERSION != the installed version. Alpine build tools are a virtual package removed in the same layer. - docker/build-base.sh / push-base.sh: same variables as WN-Sense (IMAGE_NAMESPACE, PYTHON_VERSION, PLATFORM) plus GIT_REF and VARIANTS; the context is 'git archive' of the ref, so local files (db.sqlite3, dist/, node_modules) never reach an image; the tag defaults to v. - .github/workflows/docker-base.yml: on a published GitHub Release, build and push both images with provenance + SBOM (tag must equal the pyproject version); manual runs for any ref; PRs touching docker/ build only. Needs DOCKERHUB_USERNAME / DOCKERHUB_TOKEN secrets. - .dockerignore allowlist; README / CHANGELOG / release notes. --- .dockerignore | 10 ++ .github/workflows/docker-base.yml | 105 +++++++++++++++++++ CHANGELOG.md | 1 + README.md | 30 ++++++ docker/Dockerfile.base-alpine | 40 +++++++ docker/Dockerfile.base-debian | 41 ++++++++ docker/build-base.sh | 69 ++++++++++++ docker/push-base.sh | 4 + docs/reports/releases/2026-10-13-v0.4.0.0.md | 5 + 9 files changed, 305 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-base.yml create mode 100644 docker/Dockerfile.base-alpine create mode 100644 docker/Dockerfile.base-debian create mode 100755 docker/build-base.sh create mode 100755 docker/push-base.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d0b43e8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Base images only need what `pip install .` builds (allowlist) +* +!pyproject.toml +!README.md +!LICENSE +!whiteneuron/ +whiteneuron/**/__pycache__ +whiteneuron/**/*.pyc +whiteneuron/db.sqlite3 +whiteneuron/media diff --git a/.github/workflows/docker-base.yml b/.github/workflows/docker-base.yml new file mode 100644 index 0000000..d7bfc22 --- /dev/null +++ b/.github/workflows/docker-base.yml @@ -0,0 +1,105 @@ +name: Docker base images + +# Publishes ${namespace}/django-whiteneuron-{alpine,debian}:v, the base images +# WN-Sense (and other projects) build FROM. Same images as docker/push-base.sh. +# +# GitHub Release published -> build + push (tag must equal "v" + pyproject version) +# Run workflow (manual) -> build the chosen ref, push only if "push" is ticked +# Pull request (docker/**) -> build only, to catch a broken Dockerfile before release +# +# Needs repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub access +# token with write access). Optional repository variable DOCKERHUB_NAMESPACE (default anhneuron). + +on: + release: + types: [published] + workflow_dispatch: + inputs: + ref: + description: "Git ref to build (tag, branch or SHA); empty = the selected branch" + required: false + default: "" + push: + description: "Push the images to Docker Hub" + type: boolean + default: false + pull_request: + paths: + - "docker/**" + - ".dockerignore" + - "pyproject.toml" + - ".github/workflows/docker-base.yml" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || 'anhneuron' }} + PYTHON_VERSION: "3.13" + PLATFORMS: linux/amd64 + +permissions: + contents: read + +concurrency: + group: docker-base-${{ github.event.release.tag_name || inputs.ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: django-whiteneuron-${{ matrix.variant }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [alpine, debian] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || inputs.ref || '' }} + + - name: Resolve version and push mode + id: meta + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + EVENT: ${{ github.event_name }} + PUSH_INPUT: ${{ inputs.push }} + run: | + version="v$(sed -n 's/^version *= *"\(.*\)"/\1/p' pyproject.toml | head -1)" + if [ "$version" = "v" ]; then echo "::error::No version in pyproject.toml"; exit 1; fi + if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "$version" ]; then + echo "::error::Release tag $RELEASE_TAG does not match pyproject version $version" + exit 1 + fi + push=false + if [ "$EVENT" = "release" ] || { [ "$EVENT" = "workflow_dispatch" ] && [ "$PUSH_INPUT" = "true" ]; }; then + push=true + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "push=$push" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE_NAMESPACE}/django-whiteneuron-${{ matrix.variant }}:$version" >> "$GITHUB_OUTPUT" + echo "### django-whiteneuron-${{ matrix.variant }}:$version (push: $push)" >> "$GITHUB_STEP_SUMMARY" + + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: steps.meta.outputs.push == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build${{ steps.meta.outputs.push == 'true' && ' and push' || '' }} + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile.base-${{ matrix.variant }} + platforms: ${{ env.PLATFORMS }} + build-args: | + PYTHON_VERSION=${{ env.PYTHON_VERSION }} + WHITENEURON_VERSION=${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.image }} + pull: true + push: ${{ steps.meta.outputs.push == 'true' }} + provenance: ${{ steps.meta.outputs.push == 'true' && 'mode=max' || 'false' }} + sbom: ${{ steps.meta.outputs.push == 'true' }} + cache-from: type=gha,scope=base-${{ matrix.variant }} + cache-to: type=gha,mode=max,scope=base-${{ matrix.variant }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff9987..9f81303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ **Feature release: daisyUI multi-theme admin, server monitoring dashboard, django-unfold 0.109, security hardening and performance work** - **Added**: 35 daisyUI themes selectable from the user menu (**Themes**); each theme also re-colors Unfold and drives its dark mode. `WN_ADMIN_THEMES` limits the picker (`[]` hides it); `WN_THEMES="nord,dracula" bash scripts/tailwind.sh` builds a smaller CSS bundle. - **Added**: `whiteneuron.monitoring` — Grafana-style **Server monitoring** dashboard (CPU, memory, disk, load, network, disk I/O, request throughput, p50/p95/p99 latency, error rate, Apdex, slowest endpoints, DB/cache latency) with time ranges, auto-refresh, host selector and shared crosshair. Collect with `manage.py monitoring_collector` or the Celery task `whiteneuron.monitoring.tasks.collect_metrics`; configure with `WN_MONITORING`. New dependency: `psutil`. +- **Added**: Docker base images `django-whiteneuron-{alpine,debian}:v` built from this repository (`docker/`, moved from WN-Sense); `.github/workflows/docker-base.yml` builds and pushes them on every GitHub Release (secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`). - **Changed**: `django-unfold>=0.109.0,<0.111` (the cap is raised after each Unfold release is re-tested against the template overrides); template overrides ported; uses the avatar badge (unread notifications), sidebar sub-navigation (Celery "Schedules"), `SITE_VERSION`, theme-aware dashboard charts. - **Security**: escaped notification/grid HTML (stored XSS), whitelisted `?display_fields` (a GET could call `obj.delete()`) and limited the column picker to fields shown on the change form (never `password`; extra exclusions via `ModelAdmin.field_selection_exclude`), staff-only per-user notification WebSocket with origin check, sandboxed uploaded-HTML preview, private exports scoped to the selected rows, `X-Forwarded-For` read from the right (`TRUSTED_PROXY_COUNT`, `IGNORE_X_FORWARDED_FOR`), open-redirect fix in guest login, new-user signal no longer overwrites passwords, `secrets`-based passwords redacted after sending (never passed to the task broker), POST-only feedback resolve, notification IDOR fix. - **Performance**: activity logging 1 INSERT per request (was 3–4 queries), dashboard 4 queries (was ~16) + 60s cache, one `bulk_create` + one broadcast per notification, new indexes, atomic rate-limit counters, request-scoped admin state (grid view / page size no longer shared between users), thumbnails built at upload (regenerated if the file is missing), N+1 fixes, 14KB logo instead of 187KB. diff --git a/README.md b/README.md index fd63322..1482cce 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ v0.4.0.0 **Feature release: daisyUI multi-theme admin, server monitoring dashboard, django-unfold 0.109, security hardening and performance work** - **Added**: 35 daisyUI themes selectable from the user menu (**Themes**); each theme also re-colors Unfold and drives its dark mode. `WN_ADMIN_THEMES` limits the picker (`[]` hides it); `WN_THEMES="nord,dracula" bash scripts/tailwind.sh` builds a smaller CSS bundle. - **Added**: `whiteneuron.monitoring` — Grafana-style **Server monitoring** dashboard (CPU, memory, disk, load, network, disk I/O, request throughput, p50/p95/p99 latency, error rate, Apdex, slowest endpoints, DB/cache latency) with time ranges, auto-refresh, host selector and shared crosshair. Collect with `manage.py monitoring_collector` or the Celery task `whiteneuron.monitoring.tasks.collect_metrics`; configure with `WN_MONITORING`. New dependency: `psutil`. +- **Added**: Docker base images `django-whiteneuron-{alpine,debian}:v` built from this repository (`docker/`, moved from WN-Sense); `.github/workflows/docker-base.yml` builds and pushes them on every GitHub Release (secrets `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN`). - **Changed**: `django-unfold>=0.109.0,<0.111` (the cap is raised after each Unfold release is re-tested against the template overrides); template overrides ported; uses the avatar badge (unread notifications), sidebar sub-navigation (Celery "Schedules"), `SITE_VERSION`, theme-aware dashboard charts. - **Security**: escaped notification/grid HTML (stored XSS), whitelisted `?display_fields` (a GET could call `obj.delete()`) and limited the column picker to fields shown on the change form (never `password`; extra exclusions via `ModelAdmin.field_selection_exclude`), staff-only per-user notification WebSocket with origin check, sandboxed uploaded-HTML preview, private exports scoped to the selected rows, `X-Forwarded-For` read from the right (`TRUSTED_PROXY_COUNT`, `IGNORE_X_FORWARDED_FOR`), open-redirect fix in guest login, new-user signal no longer overwrites passwords, `secrets`-based passwords redacted after sending (never passed to the task broker), POST-only feedback resolve, notification IDOR fix. - **Performance**: activity logging 1 INSERT per request (was 3–4 queries), dashboard 4 queries (was ~16) + 60s cache, one `bulk_create` + one broadcast per notification, new indexes, atomic rate-limit counters, request-scoped admin state (grid view / page size no longer shared between users), thumbnails built at upload (regenerated if the file is missing), N+1 fixes, 14KB logo instead of 187KB. @@ -408,6 +409,35 @@ bash scripts/build.sh This builds Tailwind CSS assets, runs migrations, and creates the Python package via `uv build`. +Publishing a **GitHub Release** (tag `v`, equal to `version` in `pyproject.toml`) runs two +workflows: `python-publish.yml` uploads the package to PyPI, and `docker-base.yml` builds and pushes +the Docker base images. + +### Docker base images + +`anhneuron/django-whiteneuron-alpine:v` and `anhneuron/django-whiteneuron-debian:v` +contain Python 3.13 with this package and all its dependencies installed, and run as user +`appuser` (home `/app`). Projects build `FROM` them, for example WN-Sense's web (Alpine) and +core (Debian) images. + +- **CI/CD**: `.github/workflows/docker-base.yml` builds and pushes both images when a GitHub + Release is published. The release tag must match the `pyproject.toml` version. Set the + repository secrets `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` (a Docker Hub access token with + write access). The optional repository variable `DOCKERHUB_NAMESPACE` changes the namespace + (default `anhneuron`). The workflow also runs from *Actions → Docker base images → Run workflow* + for any ref (pushing is opt-in there). Pull requests that touch `docker/` only build the images. +- **By hand**: the scripts build from a committed git ref, never from local files: + +```bash +bash docker/build-base.sh # HEAD, load into local docker +GIT_REF=v0.4.0.0 bash docker/build-base.sh # a tag / branch / commit +bash docker/push-base.sh # build + push with provenance and SBOM +VARIANTS=alpine IMAGE_NAMESPACE=myorg bash docker/push-base.sh +``` + +Each image fails to build if `WHITENEURON_VERSION` doesn't match the version of the source it +installs, so a tag can't point at different code. + ## Error Pages Templates in `whiteneuron/templates/` are used automatically when `DEBUG=False`: diff --git a/docker/Dockerfile.base-alpine b/docker/Dockerfile.base-alpine new file mode 100644 index 0000000..c675ccb --- /dev/null +++ b/docker/Dockerfile.base-alpine @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# ============================================================ +# django-whiteneuron base image — Alpine (used by Sense "web" targets) +# Built from this repository by docker/build-base.sh or .github/workflows/docker-base.yml +# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-alpine:${WHITENEURON_VERSION} +# ============================================================ + +ARG PYTHON_VERSION=3.13 + +FROM python:${PYTHON_VERSION}-alpine3.23 AS base + +# vX.Y.Z.W — must match pyproject's version (checked below) +ARG WHITENEURON_VERSION + +LABEL org.opencontainers.image.title="django-whiteneuron-alpine" \ + org.opencontainers.image.source="https://github.com/White-Neuron/django-whiteneuron" \ + org.opencontainers.image.version="${WHITENEURON_VERSION}" \ + org.opencontainers.image.licenses="MIT" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TZ=Asia/Ho_Chi_Minh \ + AUTOBAHN_USE_NVX=0 + +# Build tools only live inside this RUN (packages without musl wheels compile here) +RUN --mount=type=bind,target=/tmp/src,rw \ + apk upgrade --no-cache && \ + apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev libffi-dev openssl-dev linux-headers && \ + pip install --no-cache-dir /tmp/src && \ + installed="v$(python -c 'import importlib.metadata as m; print(m.version("django-whiteneuron"))')" && \ + if [ -n "${WHITENEURON_VERSION}" ] && [ "${installed}" != "${WHITENEURON_VERSION}" ]; then \ + echo "WHITENEURON_VERSION=${WHITENEURON_VERSION} but the source is ${installed}" >&2; exit 1; \ + fi && \ + apk del .build-deps && \ + addgroup -S appuser && \ + adduser -S -G appuser -h /app appuser + +USER appuser diff --git a/docker/Dockerfile.base-debian b/docker/Dockerfile.base-debian new file mode 100644 index 0000000..4fc5637 --- /dev/null +++ b/docker/Dockerfile.base-debian @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# ============================================================ +# django-whiteneuron base image — Debian (glibc; used by Sense "core" targets) +# Built from this repository by docker/build-base.sh or .github/workflows/docker-base.yml +# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-debian:${WHITENEURON_VERSION} +# ============================================================ + +ARG PYTHON_VERSION=3.13 + +FROM python:${PYTHON_VERSION}-slim-bookworm AS base + +# vX.Y.Z.W — must match pyproject's version (checked below) +ARG WHITENEURON_VERSION + +LABEL org.opencontainers.image.title="django-whiteneuron-debian" \ + org.opencontainers.image.source="https://github.com/White-Neuron/django-whiteneuron" \ + org.opencontainers.image.version="${WHITENEURON_VERSION}" \ + org.opencontainers.image.licenses="MIT" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + DEBIAN_FRONTEND=noninteractive \ + TZ=Asia/Ho_Chi_Minh + +# The package is installed from the build context (bind mount: no source layer in the image) +RUN --mount=type=bind,target=/tmp/src,rw \ + apt-get update && \ + apt-get upgrade -y && \ + apt-get install -y --no-install-recommends ca-certificates && \ + pip install --no-cache-dir /tmp/src && \ + installed="v$(python -c 'import importlib.metadata as m; print(m.version("django-whiteneuron"))')" && \ + if [ -n "${WHITENEURON_VERSION}" ] && [ "${installed}" != "${WHITENEURON_VERSION}" ]; then \ + echo "WHITENEURON_VERSION=${WHITENEURON_VERSION} but the source is ${installed}" >&2; exit 1; \ + fi && \ + rm -rf /var/lib/apt/lists/* && \ + addgroup --system appuser && \ + adduser --system --ingroup appuser --home /app appuser + +USER appuser diff --git a/docker/build-base.sh b/docker/build-base.sh new file mode 100755 index 0000000..0ac9489 --- /dev/null +++ b/docker/build-base.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Build the django-whiteneuron base images (Alpine + Debian) from a git ref of this repo. +# +# bash docker/build-base.sh # HEAD, load into the local docker +# GIT_REF=v0.4.0.0 bash docker/build-base.sh # a tag / branch / commit +# PUSH=1 bash docker/build-base.sh # push with provenance + SBOM (= push-base.sh) +# VARIANTS=alpine bash docker/build-base.sh # only one variant (alpine | debian) +# +# Images: ${IMAGE_NAMESPACE}/django-whiteneuron-{alpine,debian}:v +# The release workflow (.github/workflows/docker-base.yml) does the same on every GitHub Release. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-anhneuron}" +PYTHON_VERSION="${PYTHON_VERSION:-3.13}" +PLATFORM="${PLATFORM:-linux/amd64}" +GIT_REF="${GIT_REF:-HEAD}" +PUSH="${PUSH:-0}" +VARIANTS="${VARIANTS:-alpine debian}" + +# Build from committed code only (no db.sqlite3, dist/, node_modules or local edits) +if ! git -C "$ROOT_DIR" rev-parse --verify --quiet "${GIT_REF}^{commit}" >/dev/null; then + echo "Unknown GIT_REF: ${GIT_REF}" >&2 + exit 1 +fi +if [ "$GIT_REF" = "HEAD" ] && [ -n "$(git -C "$ROOT_DIR" status --porcelain -- whiteneuron pyproject.toml)" ]; then + echo "warning: uncommitted changes under whiteneuron/ or pyproject.toml are NOT included (building HEAD)" >&2 +fi + +# Tag = v read from pyproject at that ref (override with WHITENEURON_VERSION) +REF_VERSION="$(git -C "$ROOT_DIR" show "${GIT_REF}:pyproject.toml" | sed -n 's/^version *= *"\(.*\)"/\1/p' | head -1)" +WHITENEURON_VERSION="${WHITENEURON_VERSION:-v${REF_VERSION}}" +if [ -z "$REF_VERSION" ]; then + echo "Could not read the version from pyproject.toml at ${GIT_REF}" >&2 + exit 1 +fi + +if [ "$PUSH" = "1" ]; then + OUTPUT=(--provenance=mode=max --sbom=true --push) + echo "==> Building and pushing base images ${WHITENEURON_VERSION} from ${GIT_REF}..." +else + OUTPUT=(--load) + echo "==> Building base images ${WHITENEURON_VERSION} from ${GIT_REF} (local only)..." +fi + +# Context = the tree at GIT_REF; Dockerfiles come from the working copy so older tags build too +CONTEXT_DIR="$(mktemp -d)" +trap 'rm -rf "$CONTEXT_DIR"' EXIT +git -C "$ROOT_DIR" archive --format=tar "$GIT_REF" | tar -x -C "$CONTEXT_DIR" +cp "${ROOT_DIR}/.dockerignore" "$CONTEXT_DIR/.dockerignore" + +for variant in $VARIANTS; do + case "$variant" in alpine|debian) ;; *) echo "Unknown variant: $variant" >&2; exit 1 ;; esac + image="${IMAGE_NAMESPACE}/django-whiteneuron-${variant}:${WHITENEURON_VERSION}" + docker buildx build \ + --platform "${PLATFORM}" \ + --file "${ROOT_DIR}/docker/Dockerfile.base-${variant}" \ + --build-arg "PYTHON_VERSION=${PYTHON_VERSION}" \ + --build-arg "WHITENEURON_VERSION=${WHITENEURON_VERSION}" \ + --tag "$image" \ + --pull \ + "${OUTPUT[@]}" \ + "$CONTEXT_DIR" + echo "==> ${image}" +done + +echo "==> Base images ready." diff --git a/docker/push-base.sh b/docker/push-base.sh new file mode 100755 index 0000000..f229d50 --- /dev/null +++ b/docker/push-base.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Build and push the base images with provenance + SBOM. Same options as build-base.sh. +set -euo pipefail +PUSH=1 exec bash "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/build-base.sh" "$@" diff --git a/docs/reports/releases/2026-10-13-v0.4.0.0.md b/docs/reports/releases/2026-10-13-v0.4.0.0.md index 2494296..6e9eece 100644 --- a/docs/reports/releases/2026-10-13-v0.4.0.0.md +++ b/docs/reports/releases/2026-10-13-v0.4.0.0.md @@ -40,6 +40,11 @@ and the monitoring table). - **Unfold 0.109 features** — avatar badge with today's unread notifications, sidebar sub-navigation (Celery "Schedules"), `SITE_VERSION`, dashboard card icon/description, theme-aware chart colors. +- **Docker base images**: `django-whiteneuron-alpine` / `django-whiteneuron-debian:v` + (Python 3.13, the package and its dependencies, user `appuser`) are now built from this + repository (`docker/Dockerfile.base-*`, `docker/build-base.sh`, `docker/push-base.sh`, moved + from WN-Sense). `.github/workflows/docker-base.yml` pushes them when a GitHub Release is + published. - **`base.queue.enqueue`** — email, export and GPT translation run on Celery when `WN_ASYNC_TASKS=True`, inline otherwise; a broker error after commit falls back to inline. From 3c844af0c1693908ab925d44a86a19d9f45d57d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=C3=BA=20Anh?= Date: Wed, 14 Oct 2026 09:08:42 +0000 Subject: [PATCH 2/3] fix: review findings on the Docker base images - Dependencies pinned from the ref's uv.lock (uv export -> docker-requirements.txt), so images match what CI tests and a tag always rebuilds the same set (unpinned, 49 of 97 packages drifted, e.g. openai 2 -> 3). - Pushing requires GIT_REF to be the release tag v (and a clean docker/), so a published tag can't be overwritten with other code; the version check compares normalized versions. - The workflow runs docker/build-base.sh (one build path; Dockerfiles from the checkout, so older tags build too) and no longer uses a write-only GHA cache. - Variants validated before the first build; PUSH=1 uses a docker-container builder when the current one can't attest. - WN_PYTHON_VERSION / WN_PLATFORM (python images export PYTHON_VERSION); the tag is always v (no WHITENEURON_VERSION override). - AUTOBAHN_USE_NVX=0 only while installing; DEBIAN_FRONTEND as ARG; no redundant ca-certificates; Alpine build deps reduced to gcc musl-dev; appuser pinned to uid 100 / gid 101 (the ids earlier images got) and USER is numeric. --- .dockerignore | 3 +- .github/workflows/docker-base.yml | 69 ++++++++++------------ README.md | 42 ++++++++------ docker/Dockerfile.base-alpine | 31 +++++----- docker/Dockerfile.base-debian | 27 +++++---- docker/build-base.sh | 96 ++++++++++++++++++++----------- 6 files changed, 147 insertions(+), 121 deletions(-) diff --git a/.dockerignore b/.dockerignore index d0b43e8..f74a321 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,9 @@ -# Base images only need what `pip install .` builds (allowlist) +# Base images only need what `pip install .` builds plus the pinned requirements (allowlist) * !pyproject.toml !README.md !LICENSE +!docker-requirements.txt !whiteneuron/ whiteneuron/**/__pycache__ whiteneuron/**/*.pyc diff --git a/.github/workflows/docker-base.yml b/.github/workflows/docker-base.yml index d7bfc22..efe7ffb 100644 --- a/.github/workflows/docker-base.yml +++ b/.github/workflows/docker-base.yml @@ -1,10 +1,11 @@ name: Docker base images # Publishes ${namespace}/django-whiteneuron-{alpine,debian}:v, the base images -# WN-Sense (and other projects) build FROM. Same images as docker/push-base.sh. +# WN-Sense (and other projects) build FROM. The build itself is docker/build-base.sh, so a +# local `bash docker/push-base.sh` produces the same images. # -# GitHub Release published -> build + push (tag must equal "v" + pyproject version) -# Run workflow (manual) -> build the chosen ref, push only if "push" is ticked +# GitHub Release published -> build + push the release tag (must equal "v" + pyproject version) +# Run workflow (manual) -> build any ref; "push" only works for a release tag v # Pull request (docker/**) -> build only, to catch a broken Dockerfile before release # # Needs repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub access @@ -16,7 +17,7 @@ on: workflow_dispatch: inputs: ref: - description: "Git ref to build (tag, branch or SHA); empty = the selected branch" + description: "Git ref to build (tag, branch or SHA); empty = the selected branch. Pushing needs a release tag." required: false default: "" push: @@ -28,13 +29,12 @@ on: - "docker/**" - ".dockerignore" - "pyproject.toml" + - "uv.lock" - ".github/workflows/docker-base.yml" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true IMAGE_NAMESPACE: ${{ vars.DOCKERHUB_NAMESPACE || 'anhneuron' }} - PYTHON_VERSION: "3.13" - PLATFORMS: linux/amd64 permissions: contents: read @@ -52,54 +52,43 @@ jobs: matrix: variant: [alpine, debian] steps: + # Full history + tags: the script archives GIT_REF and checks it against tag v. + # The checked-out commit only provides the Dockerfiles and the script. - uses: actions/checkout@v4 with: - ref: ${{ github.event.release.tag_name || inputs.ref || '' }} + fetch-depth: 0 - - name: Resolve version and push mode - id: meta + - name: Resolve ref and push mode + id: mode env: - RELEASE_TAG: ${{ github.event.release.tag_name }} EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_REF: ${{ inputs.ref }} PUSH_INPUT: ${{ inputs.push }} run: | - version="v$(sed -n 's/^version *= *"\(.*\)"/\1/p' pyproject.toml | head -1)" - if [ "$version" = "v" ]; then echo "::error::No version in pyproject.toml"; exit 1; fi - if [ -n "$RELEASE_TAG" ] && [ "$RELEASE_TAG" != "$version" ]; then - echo "::error::Release tag $RELEASE_TAG does not match pyproject version $version" - exit 1 - fi - push=false - if [ "$EVENT" = "release" ] || { [ "$EVENT" = "workflow_dispatch" ] && [ "$PUSH_INPUT" = "true" ]; }; then - push=true - fi - echo "version=$version" >> "$GITHUB_OUTPUT" + case "$EVENT" in + release) ref="refs/tags/$RELEASE_TAG"; push=1 ;; + workflow_dispatch) ref="${INPUT_REF:-$GITHUB_SHA}"; push=$([ "$PUSH_INPUT" = "true" ] && echo 1 || echo 0) ;; + *) ref="$GITHUB_SHA"; push=0 ;; + esac + echo "ref=$ref" >> "$GITHUB_OUTPUT" echo "push=$push" >> "$GITHUB_OUTPUT" - echo "image=${IMAGE_NAMESPACE}/django-whiteneuron-${{ matrix.variant }}:$version" >> "$GITHUB_OUTPUT" - echo "### django-whiteneuron-${{ matrix.variant }}:$version (push: $push)" >> "$GITHUB_STEP_SUMMARY" + echo "### django-whiteneuron-${{ matrix.variant }} from \`$ref\` (push: $push)" >> "$GITHUB_STEP_SUMMARY" + + - uses: astral-sh/setup-uv@v5 - uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - if: steps.meta.outputs.push == 'true' + if: steps.mode.outputs.push == '1' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build${{ steps.meta.outputs.push == 'true' && ' and push' || '' }} - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile.base-${{ matrix.variant }} - platforms: ${{ env.PLATFORMS }} - build-args: | - PYTHON_VERSION=${{ env.PYTHON_VERSION }} - WHITENEURON_VERSION=${{ steps.meta.outputs.version }} - tags: ${{ steps.meta.outputs.image }} - pull: true - push: ${{ steps.meta.outputs.push == 'true' }} - provenance: ${{ steps.meta.outputs.push == 'true' && 'mode=max' || 'false' }} - sbom: ${{ steps.meta.outputs.push == 'true' }} - cache-from: type=gha,scope=base-${{ matrix.variant }} - cache-to: type=gha,mode=max,scope=base-${{ matrix.variant }} + - name: Build${{ steps.mode.outputs.push == '1' && ' and push' || '' }} + env: + GIT_REF: ${{ steps.mode.outputs.ref }} + PUSH: ${{ steps.mode.outputs.push }} + VARIANTS: ${{ matrix.variant }} + run: bash docker/build-base.sh diff --git a/README.md b/README.md index 1482cce..cc09b22 100644 --- a/README.md +++ b/README.md @@ -416,28 +416,36 @@ the Docker base images. ### Docker base images `anhneuron/django-whiteneuron-alpine:v` and `anhneuron/django-whiteneuron-debian:v` -contain Python 3.13 with this package and all its dependencies installed, and run as user -`appuser` (home `/app`). Projects build `FROM` them, for example WN-Sense's web (Alpine) and -core (Debian) images. - -- **CI/CD**: `.github/workflows/docker-base.yml` builds and pushes both images when a GitHub - Release is published. The release tag must match the `pyproject.toml` version. Set the - repository secrets `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` (a Docker Hub access token with - write access). The optional repository variable `DOCKERHUB_NAMESPACE` changes the namespace - (default `anhneuron`). The workflow also runs from *Actions → Docker base images → Run workflow* - for any ref (pushing is opt-in there). Pull requests that touch `docker/` only build the images. -- **By hand**: the scripts build from a committed git ref, never from local files: +contain Python 3.13 with this package installed. Its dependencies are pinned from `uv.lock`, the +same set CI tests. The images run as `appuser` (uid 100, gid 101, home `/app`). Projects build +`FROM` them, for example WN-Sense's web (Alpine) and core (Debian) images. + +Every build goes through `docker/build-base.sh`: +- The source is a `git archive` of the chosen ref, never local files. +- Dependencies come from that ref's `uv.lock` (`uv export`). +- The Dockerfiles come from the current checkout, so older tags can be built too. +- Pushing is only allowed for the release tag `v`, which must match `pyproject.toml`. + A published tag is never rebuilt from other code. +- Each image fails to build if the installed version differs from its tag. + +**CI/CD**: `.github/workflows/docker-base.yml` builds and pushes both images when a GitHub +Release is published. +- Required repository secrets: `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` (a Docker Hub access + token with write access). +- Optional repository variable: `DOCKERHUB_NAMESPACE` (default `anhneuron`). +- *Actions → Docker base images → Run workflow* builds any ref. Pushing from there also needs a + release tag. +- Pull requests that touch `docker/`, `pyproject.toml` or `uv.lock` only build the images. + +**By hand** (needs git, uv and docker buildx): ```bash bash docker/build-base.sh # HEAD, load into local docker -GIT_REF=v0.4.0.0 bash docker/build-base.sh # a tag / branch / commit -bash docker/push-base.sh # build + push with provenance and SBOM -VARIANTS=alpine IMAGE_NAMESPACE=myorg bash docker/push-base.sh +GIT_REF=v0.3.8.0 bash docker/build-base.sh # a tag / branch / commit +GIT_REF=v0.4.0.0 bash docker/push-base.sh # push a release, with provenance and SBOM +VARIANTS=alpine IMAGE_NAMESPACE=myorg WN_PYTHON_VERSION=3.13 bash docker/build-base.sh ``` -Each image fails to build if `WHITENEURON_VERSION` doesn't match the version of the source it -installs, so a tag can't point at different code. - ## Error Pages Templates in `whiteneuron/templates/` are used automatically when `DEBUG=False`: diff --git a/docker/Dockerfile.base-alpine b/docker/Dockerfile.base-alpine index c675ccb..9e01399 100644 --- a/docker/Dockerfile.base-alpine +++ b/docker/Dockerfile.base-alpine @@ -1,15 +1,15 @@ # syntax=docker/dockerfile:1 # ============================================================ # django-whiteneuron base image — Alpine (used by Sense "web" targets) -# Built from this repository by docker/build-base.sh or .github/workflows/docker-base.yml -# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-alpine:${WHITENEURON_VERSION} +# Built by docker/build-base.sh (locally and in .github/workflows/docker-base.yml) +# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-alpine:v # ============================================================ ARG PYTHON_VERSION=3.13 FROM python:${PYTHON_VERSION}-alpine3.23 AS base -# vX.Y.Z.W — must match pyproject's version (checked below) +# v of the source being installed (checked below) ARG WHITENEURON_VERSION LABEL org.opencontainers.image.title="django-whiteneuron-alpine" \ @@ -21,20 +21,21 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ - TZ=Asia/Ho_Chi_Minh \ - AUTOBAHN_USE_NVX=0 + TZ=Asia/Ho_Chi_Minh -# Build tools only live inside this RUN (packages without musl wheels compile here) +# Dependencies pinned from uv.lock (docker-requirements.txt, written by build-base.sh), then +# the package itself from the build context (bind mount: no source layer in the image). +# A C compiler is only needed for the few packages without musl wheels; it is removed in +# the same layer. AUTOBAHN_USE_NVX=0 only applies if autobahn has to be built from source. RUN --mount=type=bind,target=/tmp/src,rw \ apk upgrade --no-cache && \ - apk add --no-cache --virtual .build-deps gcc musl-dev python3-dev libffi-dev openssl-dev linux-headers && \ - pip install --no-cache-dir /tmp/src && \ - installed="v$(python -c 'import importlib.metadata as m; print(m.version("django-whiteneuron"))')" && \ - if [ -n "${WHITENEURON_VERSION}" ] && [ "${installed}" != "${WHITENEURON_VERSION}" ]; then \ - echo "WHITENEURON_VERSION=${WHITENEURON_VERSION} but the source is ${installed}" >&2; exit 1; \ - fi && \ + apk add --no-cache --virtual .build-deps gcc musl-dev && \ + AUTOBAHN_USE_NVX=0 pip install --no-cache-dir -r /tmp/src/docker-requirements.txt && \ + pip install --no-cache-dir --no-deps /tmp/src && \ + python -c 'import importlib.metadata as m, os, sys; from pip._vendor.packaging.version import Version as V; w = os.environ.get("WHITENEURON_VERSION", ""); i = m.version("django-whiteneuron"); sys.exit(f"WHITENEURON_VERSION={w} but the source is {i}" if w and V(w.lstrip("v")) != V(i) else 0)' && \ apk del .build-deps && \ - addgroup -S appuser && \ - adduser -S -G appuser -h /app appuser + addgroup -S -g 101 appuser && \ + adduser -S -u 100 -G appuser -h /app appuser -USER appuser +# numeric, so runAsNonRoot can verify it (same ids as earlier releases: volumes stay writable) +USER 100:101 diff --git a/docker/Dockerfile.base-debian b/docker/Dockerfile.base-debian index 4fc5637..f65f01d 100644 --- a/docker/Dockerfile.base-debian +++ b/docker/Dockerfile.base-debian @@ -1,16 +1,17 @@ # syntax=docker/dockerfile:1 # ============================================================ # django-whiteneuron base image — Debian (glibc; used by Sense "core" targets) -# Built from this repository by docker/build-base.sh or .github/workflows/docker-base.yml -# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-debian:${WHITENEURON_VERSION} +# Built by docker/build-base.sh (locally and in .github/workflows/docker-base.yml) +# Published as: ${IMAGE_NAMESPACE}/django-whiteneuron-debian:v # ============================================================ ARG PYTHON_VERSION=3.13 FROM python:${PYTHON_VERSION}-slim-bookworm AS base -# vX.Y.Z.W — must match pyproject's version (checked below) +# v of the source being installed (checked below) ARG WHITENEURON_VERSION +ARG DEBIAN_FRONTEND=noninteractive LABEL org.opencontainers.image.title="django-whiteneuron-debian" \ org.opencontainers.image.source="https://github.com/White-Neuron/django-whiteneuron" \ @@ -21,21 +22,19 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ - DEBIAN_FRONTEND=noninteractive \ TZ=Asia/Ho_Chi_Minh -# The package is installed from the build context (bind mount: no source layer in the image) +# Dependencies pinned from uv.lock (docker-requirements.txt, written by build-base.sh), then +# the package itself from the build context (bind mount: no source layer in the image) RUN --mount=type=bind,target=/tmp/src,rw \ apt-get update && \ apt-get upgrade -y && \ - apt-get install -y --no-install-recommends ca-certificates && \ - pip install --no-cache-dir /tmp/src && \ - installed="v$(python -c 'import importlib.metadata as m; print(m.version("django-whiteneuron"))')" && \ - if [ -n "${WHITENEURON_VERSION}" ] && [ "${installed}" != "${WHITENEURON_VERSION}" ]; then \ - echo "WHITENEURON_VERSION=${WHITENEURON_VERSION} but the source is ${installed}" >&2; exit 1; \ - fi && \ + pip install --no-cache-dir -r /tmp/src/docker-requirements.txt && \ + pip install --no-cache-dir --no-deps /tmp/src && \ + python -c 'import importlib.metadata as m, os, sys; from pip._vendor.packaging.version import Version as V; w = os.environ.get("WHITENEURON_VERSION", ""); i = m.version("django-whiteneuron"); sys.exit(f"WHITENEURON_VERSION={w} but the source is {i}" if w and V(w.lstrip("v")) != V(i) else 0)' && \ rm -rf /var/lib/apt/lists/* && \ - addgroup --system appuser && \ - adduser --system --ingroup appuser --home /app appuser + addgroup --system --gid 101 appuser && \ + adduser --system --uid 100 --ingroup appuser --home /app appuser -USER appuser +# numeric, so runAsNonRoot can verify it (same ids as earlier releases: volumes stay writable) +USER 100:101 diff --git a/docker/build-base.sh b/docker/build-base.sh index 0ac9489..c71f619 100755 --- a/docker/build-base.sh +++ b/docker/build-base.sh @@ -1,64 +1,92 @@ #!/usr/bin/env bash # Build the django-whiteneuron base images (Alpine + Debian) from a git ref of this repo. +# Used as-is by .github/workflows/docker-base.yml, so local and CI builds are the same. # # bash docker/build-base.sh # HEAD, load into the local docker # GIT_REF=v0.4.0.0 bash docker/build-base.sh # a tag / branch / commit -# PUSH=1 bash docker/build-base.sh # push with provenance + SBOM (= push-base.sh) -# VARIANTS=alpine bash docker/build-base.sh # only one variant (alpine | debian) +# GIT_REF=v0.4.0.0 bash docker/push-base.sh # push with provenance + SBOM +# VARIANTS=alpine bash docker/build-base.sh # one variant (alpine | debian) # -# Images: ${IMAGE_NAMESPACE}/django-whiteneuron-{alpine,debian}:v -# The release workflow (.github/workflows/docker-base.yml) does the same on every GitHub Release. +# Images: ${IMAGE_NAMESPACE}/django-whiteneuron-{alpine,debian}:v +# +# - Source: `git archive` of GIT_REF (never local files such as db.sqlite3, dist/, node_modules). +# - Dependencies: pinned from that ref's uv.lock (`uv export`), the set CI tests. +# - Dockerfiles / .dockerignore: this working copy, so older tags build too. +# - Pushing requires GIT_REF to be the release tag v, so a published tag is +# never overwritten with other code, and a clean docker/ directory. +# +# Requires: git, uv, docker buildx. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" IMAGE_NAMESPACE="${IMAGE_NAMESPACE:-anhneuron}" -PYTHON_VERSION="${PYTHON_VERSION:-3.13}" -PLATFORM="${PLATFORM:-linux/amd64}" +# WN_-prefixed: python images (devcontainers, CI runners) export their own PYTHON_VERSION +WN_PYTHON_VERSION="${WN_PYTHON_VERSION:-3.13}" +WN_PLATFORM="${WN_PLATFORM:-linux/amd64}" GIT_REF="${GIT_REF:-HEAD}" PUSH="${PUSH:-0}" VARIANTS="${VARIANTS:-alpine debian}" -# Build from committed code only (no db.sqlite3, dist/, node_modules or local edits) -if ! git -C "$ROOT_DIR" rev-parse --verify --quiet "${GIT_REF}^{commit}" >/dev/null; then - echo "Unknown GIT_REF: ${GIT_REF}" >&2 - exit 1 -fi -if [ "$GIT_REF" = "HEAD" ] && [ -n "$(git -C "$ROOT_DIR" status --porcelain -- whiteneuron pyproject.toml)" ]; then - echo "warning: uncommitted changes under whiteneuron/ or pyproject.toml are NOT included (building HEAD)" >&2 -fi +die() { printf 'error: %b\n' "$*" >&2; exit 1; } -# Tag = v read from pyproject at that ref (override with WHITENEURON_VERSION) -REF_VERSION="$(git -C "$ROOT_DIR" show "${GIT_REF}:pyproject.toml" | sed -n 's/^version *= *"\(.*\)"/\1/p' | head -1)" -WHITENEURON_VERSION="${WHITENEURON_VERSION:-v${REF_VERSION}}" -if [ -z "$REF_VERSION" ]; then - echo "Could not read the version from pyproject.toml at ${GIT_REF}" >&2 - exit 1 -fi +for variant in $VARIANTS; do + case "$variant" in alpine|debian) ;; *) die "unknown variant '$variant' (alpine | debian)" ;; esac +done +command -v uv >/dev/null || die "uv is required (https://docs.astral.sh/uv/)" + +COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify --quiet "${GIT_REF}^{commit}")" || die "unknown GIT_REF: ${GIT_REF}" +REF_VERSION="$(git -C "$ROOT_DIR" show "${COMMIT}:pyproject.toml" | sed -n 's/^version *= *"\(.*\)"/\1/p' | head -1)" +[ -n "$REF_VERSION" ] || die "no version in pyproject.toml at ${GIT_REF}" +TAG="v${REF_VERSION}" +dirty_docker="$(git -C "$ROOT_DIR" status --porcelain -- docker .dockerignore)" if [ "$PUSH" = "1" ]; then - OUTPUT=(--provenance=mode=max --sbom=true --push) - echo "==> Building and pushing base images ${WHITENEURON_VERSION} from ${GIT_REF}..." + # A published image must be exactly the released code + TAG_COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/${TAG}^{commit}")" \ + || die "tag ${TAG} does not exist: tag the release before pushing its images" + [ "$TAG_COMMIT" = "$COMMIT" ] \ + || die "${GIT_REF} (${COMMIT:0:9}) is not the release tag ${TAG} (${TAG_COMMIT:0:9}); push with GIT_REF=${TAG}" + [ -z "$dirty_docker" ] || die "uncommitted changes in docker/ or .dockerignore:\n${dirty_docker}" else - OUTPUT=(--load) - echo "==> Building base images ${WHITENEURON_VERSION} from ${GIT_REF} (local only)..." + [ -z "$dirty_docker" ] || echo "warning: building with uncommitted changes in docker/ or .dockerignore" >&2 + if [ "$GIT_REF" = "HEAD" ] && [ -n "$(git -C "$ROOT_DIR" status --porcelain -- whiteneuron pyproject.toml uv.lock)" ]; then + echo "warning: uncommitted changes under whiteneuron/, pyproject.toml or uv.lock are NOT included (building HEAD)" >&2 + fi fi -# Context = the tree at GIT_REF; Dockerfiles come from the working copy so older tags build too CONTEXT_DIR="$(mktemp -d)" trap 'rm -rf "$CONTEXT_DIR"' EXIT -git -C "$ROOT_DIR" archive --format=tar "$GIT_REF" | tar -x -C "$CONTEXT_DIR" +git -C "$ROOT_DIR" archive --format=tar "$COMMIT" | tar -x -C "$CONTEXT_DIR" cp "${ROOT_DIR}/.dockerignore" "$CONTEXT_DIR/.dockerignore" +[ -f "$CONTEXT_DIR/uv.lock" ] || die "no uv.lock at ${GIT_REF}" +(cd "$CONTEXT_DIR" && uv export --frozen --no-dev --no-emit-project --no-hashes \ + --format requirements-txt --quiet --output-file docker-requirements.txt) + +BUILDER_ARGS=() +if [ "$PUSH" = "1" ]; then + # Attestations need a BuildKit container builder (the plain "docker" driver can't on + # the classic image store). CI (setup-buildx-action) already runs one. + if docker buildx inspect 2>/dev/null | grep -qE '^Driver: +docker$'; then + docker buildx inspect wn-base >/dev/null 2>&1 || docker buildx create --name wn-base --driver docker-container >/dev/null + BUILDER_ARGS=(--builder wn-base) + fi + OUTPUT=(--provenance=mode=max --sbom=true --push) + echo "==> Building and pushing base images ${TAG} (${COMMIT:0:9})..." +else + OUTPUT=(--load) + echo "==> Building base images ${TAG} from ${GIT_REF} (${COMMIT:0:9}, local only)..." +fi for variant in $VARIANTS; do - case "$variant" in alpine|debian) ;; *) echo "Unknown variant: $variant" >&2; exit 1 ;; esac - image="${IMAGE_NAMESPACE}/django-whiteneuron-${variant}:${WHITENEURON_VERSION}" - docker buildx build \ - --platform "${PLATFORM}" \ - --file "${ROOT_DIR}/docker/Dockerfile.base-${variant}" \ - --build-arg "PYTHON_VERSION=${PYTHON_VERSION}" \ - --build-arg "WHITENEURON_VERSION=${WHITENEURON_VERSION}" \ + image="${IMAGE_NAMESPACE}/django-whiteneuron-${variant}:${TAG}" + docker buildx build "${BUILDER_ARGS[@]}" \ + --platform "${WN_PLATFORM}" \ + --file "${SCRIPT_DIR}/Dockerfile.base-${variant}" \ + --build-arg "PYTHON_VERSION=${WN_PYTHON_VERSION}" \ + --build-arg "WHITENEURON_VERSION=${TAG}" \ + --label "org.opencontainers.image.revision=${COMMIT}" \ --tag "$image" \ --pull \ "${OUTPUT[@]}" \ From 435cf839019146012bbdbca3eb27ab265cd7c5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20T=C3=BA=20Anh?= Date: Wed, 14 Oct 2026 09:11:59 +0000 Subject: [PATCH 3/3] fix: clear error when a release tag doesn't match the pyproject version; release notes --- docker/build-base.sh | 4 ++++ docs/reports/releases/2026-10-13-v0.4.0.0.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docker/build-base.sh b/docker/build-base.sh index c71f619..d919b01 100755 --- a/docker/build-base.sh +++ b/docker/build-base.sh @@ -44,6 +44,10 @@ TAG="v${REF_VERSION}" dirty_docker="$(git -C "$ROOT_DIR" status --porcelain -- docker .dockerignore)" if [ "$PUSH" = "1" ]; then # A published image must be exactly the released code + ref_tag="${GIT_REF#refs/tags/}" + if [ "$ref_tag" != "$TAG" ] && git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/${ref_tag}" >/dev/null; then + die "tag ${ref_tag} has version ${REF_VERSION} in pyproject.toml: the release tag must be ${TAG}" + fi TAG_COMMIT="$(git -C "$ROOT_DIR" rev-parse --verify --quiet "refs/tags/${TAG}^{commit}")" \ || die "tag ${TAG} does not exist: tag the release before pushing its images" [ "$TAG_COMMIT" = "$COMMIT" ] \ diff --git a/docs/reports/releases/2026-10-13-v0.4.0.0.md b/docs/reports/releases/2026-10-13-v0.4.0.0.md index 6e9eece..3e9597d 100644 --- a/docs/reports/releases/2026-10-13-v0.4.0.0.md +++ b/docs/reports/releases/2026-10-13-v0.4.0.0.md @@ -41,7 +41,8 @@ and the monitoring table). sub-navigation (Celery "Schedules"), `SITE_VERSION`, dashboard card icon/description, theme-aware chart colors. - **Docker base images**: `django-whiteneuron-alpine` / `django-whiteneuron-debian:v` - (Python 3.13, the package and its dependencies, user `appuser`) are now built from this + (Python 3.13, the package with its dependencies pinned from `uv.lock`, user `appuser` + uid 100 / gid 101) are now built from this repository (`docker/Dockerfile.base-*`, `docker/build-base.sh`, `docker/push-base.sh`, moved from WN-Sense). `.github/workflows/docker-base.yml` pushes them when a GitHub Release is published.