···11+when:
22+ - event: ["push", "manual"]
33+ branch: ["develop"]
44+ - event: ["push"]
55+ tag: ["v*"]
66+77+engine: "nixery"
88+99+clone:
1010+ depth: 0
1111+1212+dependencies:
1313+ nixpkgs:
1414+ - python313
1515+ - gcc
1616+ - gnumake
1717+ - pkg-config
1818+ - zlib
1919+ - libjpeg
2020+ - gmp
2121+ - gettext
2222+ - curl
2323+ - wget
2424+ - git
2525+ - jq
2626+ - skopeo
2727+ - nix
2828+ - postgresql_15
2929+ - libffi
3030+ - openjpeg
3131+ - pango
3232+ - harfbuzz
3333+ - fontconfig
3434+ - pipenv
3535+ - cacert
3636+3737+environment:
3838+ PIPENV_VENV_IN_PROJECT: "1"
3939+ PIPENV_CACHE_DIR: "/tmp/pipenv-cache"
4040+ PIP_CACHE_DIR: "/tmp/pip-cache"
4141+ NIX_SSL_CERT_FILE: "/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt"
4242+ SSL_CERT_FILE: "/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt"
4343+4444+steps:
4545+ - name: "Print build info"
4646+ command: |
4747+ echo "=== CARE Build Pipeline ==="
4848+ echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
4949+ echo "SHA: ${TANGLED_SHA:-unknown}"
5050+ echo "Ref: ${TANGLED_REF:-unknown}"
5151+ echo "Ref Name: ${TANGLED_REF_NAME:-unknown}"
5252+ echo "Ref Type: ${TANGLED_REF_TYPE:-unknown}"
5353+ echo "Repo: ${TANGLED_REPO_NAME:-unknown}"
5454+ echo ""
5555+ echo "=== Tool versions ==="
5656+ python3 --version
5757+ pipenv --version
5858+ nix --version
5959+ skopeo --version
6060+ git --version
6161+ gcc --version | head -1
6262+ echo ""
6363+ echo "=== Workspace ==="
6464+ pwd
6565+ ls -la
6666+6767+ - name: "Create virtualenv and install dependencies"
6868+ command: |
6969+ echo "=== Creating virtualenv ==="
7070+ python3 -m venv .venv
7171+ echo "=== Installing production dependencies ==="
7272+ pipenv install --deploy --categories "packages"
7373+ echo "=== Virtualenv contents ==="
7474+ .venv/bin/python --version
7575+ .venv/bin/pip list --format=columns | head -20
7676+ echo "... (truncated)"
7777+ echo "=== Total packages ==="
7878+ .venv/bin/pip list --format=columns | tail -n +3 | wc -l
7979+8080+ - name: "Install plugins"
8181+ command: |
8282+ echo "=== Installing plugins ==="
8383+ echo "ADDITIONAL_PLUGS=${ADDITIONAL_PLUGS:-<not set>}"
8484+ .venv/bin/python3 install_plugins.py
8585+ echo "=== Plugin installation complete ==="
8686+8787+ - name: "Compute image tags"
8888+ command: |
8989+ echo "=== Computing image tags ==="
9090+9191+ TAGS=""
9292+ SHA="${TANGLED_SHA:-unknown}"
9393+ SHORT_SHA="${SHA:0:7}"
9494+ REF="${TANGLED_REF:-}"
9595+ REF_NAME="${TANGLED_REF_NAME:-}"
9696+ REF_TYPE="${TANGLED_REF_TYPE:-}"
9797+ DATE=$(date -u +%Y%m%d)
9898+9999+ if [ "$REF_TYPE" = "tag" ]; then
100100+ # Tag push (e.g. v25.28.0)
101101+ # Produce: production-latest, production-latest-{date}-{sha}, and the version tag
102102+ VERSION="${REF_NAME}"
103103+ TAGS="production-latest"
104104+ TAGS="${TAGS} production-latest-${DATE}-${SHORT_SHA}"
105105+ TAGS="${TAGS} ${VERSION}"
106106+ echo "Tag push detected: ${VERSION}"
107107+108108+ elif [ "$REF_TYPE" = "branch" ]; then
109109+ case "$REF_NAME" in
110110+ develop)
111111+ TAGS="latest"
112112+ TAGS="${TAGS} latest-${DATE}-${SHORT_SHA}"
113113+ echo "Develop branch push detected"
114114+ ;;
115115+ staging)
116116+ TAGS="staging-latest"
117117+ TAGS="${TAGS} staging-latest-${DATE}-${SHORT_SHA}"
118118+ echo "Staging branch push detected"
119119+ ;;
120120+ production)
121121+ TAGS="production-latest"
122122+ TAGS="${TAGS} production-latest-${DATE}-${SHORT_SHA}"
123123+ echo "Production branch push detected"
124124+ ;;
125125+ *)
126126+ TAGS="branch-${REF_NAME}-${SHORT_SHA}"
127127+ echo "Other branch push detected: ${REF_NAME}"
128128+ ;;
129129+ esac
130130+131131+ else
132132+ # Fallback for manual triggers or unexpected ref types
133133+ TAGS="dev-${SHORT_SHA}"
134134+ echo "Manual or unknown trigger"
135135+ fi
136136+137137+ echo ""
138138+ echo "Computed tags:"
139139+ for TAG in $TAGS; do
140140+ echo " - ${TAG}"
141141+ done
142142+143143+ # Persist tags for later steps — /tmp is NOT shared between Spindle
144144+ # steps, only the workspace directory (/tangled/workspace) is.
145145+ # Since the repo is cloned into the workspace, we can write files
146146+ # here and they'll be available in subsequent steps.
147147+ mkdir -p .build-meta
148148+ echo "${TAGS}" > .build-meta/image-tags.txt
149149+ echo "${SHA}" > .build-meta/image-sha.txt
150150+151151+ - name: "Build OCI image with Nix"
152152+ command: |
153153+ echo "=== Building OCI image ==="
154154+155155+ SHA=$(cat .build-meta/image-sha.txt)
156156+ SHORT_SHA="${SHA:0:7}"
157157+158158+ echo "App version: ${SHORT_SHA}"
159159+ echo "Source dir: $(pwd)"
160160+ echo "Venv dir: $(pwd)/.venv"
161161+ echo ""
162162+163163+ # Build the image using the standalone nix expression.
164164+ # We override the default arguments so it picks up the CI-built venv
165165+ # and the checked-out source tree rather than the flake's defaults.
166166+ #
167167+ # --arg expects a Nix expression. Absolute paths (starting with /)
168168+ # are valid Nix path literals — Nix will copy them into the store.
169169+ # We resolve $(pwd) to get the absolute workspace path.
170170+ WORKSPACE="$(pwd)"
171171+172172+ nix-build nix/docker-image.nix \
173173+ --arg pkgs 'import <nixpkgs> {}' \
174174+ --argstr appVersion "${SHORT_SHA}" \
175175+ --arg venvPath "${WORKSPACE}/.venv" \
176176+ --arg appSrc "${WORKSPACE}" \
177177+ --out-link result \
178178+ --show-trace \
179179+ --option sandbox false
180180+181181+ echo ""
182182+ echo "=== Build complete ==="
183183+ ls -lh result
184184+ echo ""
185185+186186+ # Inspect the image
187187+ echo "=== Image layers ==="
188188+ if command -v skopeo &> /dev/null; then
189189+ skopeo inspect docker-archive:result | jq '{Layers: .Layers | length, Digest: .Digest, Created: .Created}' 2>/dev/null || echo "(inspection skipped)"
190190+ fi
191191+192192+ - name: "Push image to registry"
193193+ command: |
194194+ echo "=== Push step ==="
195195+196196+ TAGS=$(cat .build-meta/image-tags.txt)
197197+ SHA=$(cat .build-meta/image-sha.txt)
198198+199199+ # Registry configuration — expects secrets to be set via Tangled repo settings:
200200+ # REGISTRY_URL - e.g. ghcr.io or a custom registry
201201+ # REGISTRY_IMAGE - e.g. ohcnetwork/care
202202+ # REGISTRY_USERNAME - auth username
203203+ # REGISTRY_TOKEN - auth token/password
204204+ REGISTRY_URL="${REGISTRY_URL:-}"
205205+ REGISTRY_IMAGE="${REGISTRY_IMAGE:-}"
206206+ REGISTRY_USERNAME="${REGISTRY_USERNAME:-}"
207207+ REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
208208+209209+ if [ -z "$REGISTRY_URL" ] || [ -z "$REGISTRY_IMAGE" ] || [ -z "$REGISTRY_TOKEN" ]; then
210210+ echo "Registry credentials not configured. Skipping push."
211211+ echo ""
212212+ echo "To enable pushing, set these secrets in your Tangled repo settings:"
213213+ echo " REGISTRY_URL (e.g. ghcr.io)"
214214+ echo " REGISTRY_IMAGE (e.g. ohcnetwork/care)"
215215+ echo " REGISTRY_USERNAME (e.g. your-username)"
216216+ echo " REGISTRY_TOKEN (e.g. your-token)"
217217+ echo ""
218218+ echo "The following tags would have been pushed:"
219219+ for TAG in $TAGS; do
220220+ echo " - ${REGISTRY_URL:-<registry>}/${REGISTRY_IMAGE:-<image>}:${TAG}"
221221+ done
222222+ exit 0
223223+ fi
224224+225225+ echo "Logging in to ${REGISTRY_URL}..."
226226+ echo "${REGISTRY_TOKEN}" | skopeo login "${REGISTRY_URL}" \
227227+ --username "${REGISTRY_USERNAME}" \
228228+ --password-stdin
229229+230230+ echo ""
231231+ echo "Pushing image..."
232232+ for TAG in $TAGS; do
233233+ DEST="${REGISTRY_URL}/${REGISTRY_IMAGE}:${TAG}"
234234+ echo " -> ${DEST}"
235235+ skopeo copy \
236236+ "docker-archive:result" \
237237+ "docker://${DEST}" \
238238+ --retry-times 3
239239+ done
240240+241241+ echo ""
242242+ echo "=== All tags pushed successfully ==="
+470
care/CLAUDE.md
···11+# CLAUDE.md — Tangled Spindle Build Workflow Context
22+33+This document captures all the important context about the CARE project's build infrastructure, specifically for implementing the Tangled Spindle CI/CD pipeline that produces OCI container images using Nixery/Nix `dockerTools`.
44+55+## Project Overview
66+77+CARE (Care is a Digital Public Good) is a Django application enabling TeleICU & Decentralised Administration of Healthcare Capacity. It uses Python 3.13, Django 6.0, PostgreSQL, Redis, Celery, and is deployed as a Docker container running gunicorn on port 9000.
88+99+The repository is hosted on Tangled at `tangled.org` and also mirrored on GitHub at `ohcnetwork/care`.
1010+1111+---
1212+1313+## Repository Structure (Key Paths)
1414+1515+```
1616+care/
1717+├── .tangled/workflows/ # Tangled Spindle CI workflows
1818+│ ├── build.yml # THE BUILD WORKFLOW (to be implemented)
1919+│ └── test.yml # Existing test workflow (basic health check currently)
2020+├── .github/workflows/ # Existing GitHub Actions (reference implementation)
2121+│ ├── deploy.yml # Current build+push pipeline (GHCR, multi-arch)
2222+│ ├── release.yml # Tag creation on production branch (vYY.WW.MINOR)
2323+│ ├── reusable-test.yml # Reusable test job
2424+│ └── ...
2525+├── docker/
2626+│ ├── prod.Dockerfile # Current production Dockerfile (THE REFERENCE)
2727+│ └── dev.Dockerfile # Dev Dockerfile
2828+├── scripts/
2929+│ ├── start.sh # Entrypoint: gunicorn on 0.0.0.0:9000
3030+│ ├── healthcheck.sh # Reads /tmp/container-role, curls /ping/ for api
3131+│ ├── celery_worker.sh # Celery worker entrypoint
3232+│ ├── celery_beat.sh # Celery beat entrypoint
3333+│ ├── wait_for_db.sh # Waits for PostgreSQL
3434+│ ├── wait_for_redis.sh # Waits for Redis
3535+│ └── ...
3636+├── plugs/ # Plugin system
3737+│ ├── __init__.py
3838+│ ├── manager.py # PlugManager: installs additional pip packages
3939+│ └── plug.py # Plug dataclass
4040+├── install_plugins.py # Entry point: `from plug_config import manager; manager.install()`
4141+├── plug_config.py # Defines plugs list and creates PlugManager
4242+├── Pipfile # Python dependencies (pipenv)
4343+├── Pipfile.lock # Locked dependencies
4444+├── flake.nix # Nix flake for dev environment (NOT yet for Docker image)
4545+├── flake.lock # Pinned nixpkgs (nixos-unstable)
4646+├── config/ # Django config (settings, wsgi, celery_app, gunicorn)
4747+└── manage.py # Django management
4848+```
4949+5050+---
5151+5252+## Current Production Dockerfile (`docker/prod.Dockerfile`)
5353+5454+This is the golden reference for what the Nix-built OCI image must replicate.
5555+5656+### Build stage (`builder`):
5757+- Base: `python:3.13-slim-bookworm`
5858+- Installs build deps: `build-essential libjpeg-dev zlib1g-dev libgmp-dev libpq-dev git wget libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libharfbuzz-subset0 libffi-dev libopenjp2-7-dev`
5959+- Installs `pipenv==2025.1.1`
6060+- Creates venv at `/app/.venv`
6161+- Runs `pipenv install --deploy --categories "packages"`
6262+- Copies `plugs/` and `install_plugins.py`, `plug_config.py`
6363+- Accepts `ADDITIONAL_PLUGS` build arg (JSON array of extra plugin specs)
6464+- Runs `python3 install_plugins.py` (which pip-installs any plugins)
6565+6666+### Runtime stage (`runtime`):
6767+- Base: `python:3.13-slim-bookworm`
6868+- Creates `django` user/group
6969+- Runtime deps: `libpq-dev libgmp-dev libpangoft2-1.0-0 gettext wget curl gnupg`
7070+- Copies `.venv` from builder
7171+- Sets `APP_VERSION` env var (git SHA)
7272+- Copies `scripts/*.sh` with execute perms
7373+- Copies full repo source to `/app`
7474+- Runs as user `django`
7575+- Healthcheck: `./healthcheck.sh` (30s interval, 5s timeout, 10s start, 12 retries)
7676+- Exposes port 9000
7777+7878+### Key environment variables in the image:
7979+- `BUILD_ENVIRONMENT=production`
8080+- `PYTHONUNBUFFERED=1`
8181+- `PYTHONDONTWRITEBYTECODE=1`
8282+- `PIPENV_VENV_IN_PROJECT=1`
8383+- `PATH=/app/.venv/bin:$PATH`
8484+- `HOME=/app`
8585+8686+---
8787+8888+## Current GitHub Actions Deploy Pipeline (`.github/workflows/deploy.yml`)
8989+9090+### Trigger:
9191+- Push to `develop` branch
9292+- Push of `v*` tags
9393+- Manual `workflow_dispatch`
9494+- Ignores `docs/**` changes
9595+9696+### Jobs:
9797+1. **test** — runs reusable test workflow
9898+2. **build** — matrix build for `linux/amd64` and `linux/arm64` (separate runners), pushes digests to GHCR
9999+3. **merge-manifests** — creates multi-arch manifest, pushes tagged images to GHCR, creates Sentry release
100100+4. **notify-release** — (for tag pushes only) placeholder notification
101101+102102+### Image tags (from `docker/metadata-action`):
103103+- `production-latest` + `production-latest-{run}-{date}-{sha}` — for tag pushes (`v*`)
104104+- `staging-latest` + `staging-latest-{run}-{date}-{sha}` — for `staging` branch
105105+- `latest` + `latest-{run}` — for `develop` branch
106106+- Semver `{{version}}` — for tag pushes
107107+108108+### Registry: `ghcr.io/ohcnetwork/care`
109109+110110+### Build args passed:
111111+- `APP_VERSION=${{ github.sha }}`
112112+- `ADDITIONAL_PLUGS=${{ env.ADDITIONAL_PLUGS }}`
113113+114114+---
115115+116116+## Release Tagging (`.github/workflows/release.yml`)
117117+118118+On push to `production` branch:
119119+- Calculates next tag as `vYY.WW.MINOR` (year.week.patch, auto-incrementing)
120120+- Creates and pushes git tag
121121+- Creates draft GitHub release with auto-generated notes
122122+123123+---
124124+125125+## Plugin System
126126+127127+- `plug_config.py` defines `plugs = []` (empty list by default) and creates `manager = PlugManager(plugs)`
128128+- `PlugManager.__init__` also reads `ADDITIONAL_PLUGS` env var (JSON array of plug dicts)
129129+- Each `Plug` has: `name`, `package_name`, `version` (default `@main`), `configs`
130130+- `manager.install()` runs `pip install` for each plug's `package_name + version`
131131+- `install_plugins.py` just calls `manager.install()`
132132+133133+---
134134+135135+## Existing Nix Setup (`flake.nix`)
136136+137137+### Inputs:
138138+- `nixpkgs` — `github:NixOS/nixpkgs/nixos-unstable`
139139+- `flake-utils` — `github:numtide/flake-utils`
140140+141141+### What exists today:
142142+- `devShells.default` — full dev environment with PostgreSQL 15, Redis, MinIO, Python 3.13, ruff, and many helper scripts
143143+- `packages.default` — a trivial `writeShellApplication` that just prints a message
144144+145145+### What does NOT exist yet:
146146+- `packages.dockerImage` — the Nix expression to build an OCI image (TO BE CREATED)
147147+- `nix/docker-image.nix` — standalone Nix expression for the image (TO BE CREATED)
148148+149149+### Key nixpkgs packages used in dev shell (reference for build deps):
150150+- `python313`, `postgresql_15`, `libpq`, `redis`, `pkg-config`, `zlib`, `libjpeg`, `gmp`, `gettext`, `curl`, `wget`, `git`, `gcc`, `gnumake`
151151+152152+### Important env vars set in dev shell:
153153+- `PG_CONFIG = "${pkgs.postgresql_15}/bin/pg_config"`
154154+- `LDFLAGS = "-L${pkgs.postgresql_15}/lib"`
155155+- `CPPFLAGS = "-I${pkgs.postgresql_15}/include"`
156156+157157+---
158158+159159+## Tangled Spindle Workflow System
160160+161161+### Workflow location: `.tangled/workflows/*.yml`
162162+163163+### Workflow YAML schema:
164164+165165+```yaml
166166+when: # REQUIRED - trigger conditions
167167+ - event: ["push", "pull_request", "manual"]
168168+ branch: ["main", "develop"] # glob patterns supported
169169+ tag: ["v*"] # for push events
170170+171171+engine: "nixery" # REQUIRED - only "nixery" supported currently
172172+173173+clone: # OPTIONAL
174174+ skip: false # default false
175175+ depth: 1 # default 1 (shallow)
176176+ submodules: false # default false
177177+178178+dependencies: # OPTIONAL - nix packages
179179+ nixpkgs: # registry key
180180+ - package1
181181+ - package2
182182+ nixpkgs/nixpkgs-unstable: # can specify channel
183183+ - package3
184184+ git+https://tangled.org/@user/repo: # custom flake registries
185185+ - my_pkg
186186+187187+environment: # OPTIONAL - global env vars (PUBLIC, not for secrets)
188188+ KEY: "value"
189189+190190+steps: # OPTIONAL - list of steps
191191+ - name: "Step name"
192192+ command: | # runs in bash shell
193193+ echo "hello"
194194+ environment: # OPTIONAL - per-step env vars (also PUBLIC)
195195+ KEY: "value"
196196+```
197197+198198+### Built-in environment variables (always available):
199199+- `CI=true`
200200+- `TANGLED_PIPELINE_ID` — AT URI of the pipeline
201201+- `TANGLED_REPO_KNOT` — repository's knot hostname
202202+- `TANGLED_REPO_DID` — DID of repo owner
203203+- `TANGLED_REPO_NAME` — repository name
204204+- `TANGLED_REPO_DEFAULT_BRANCH` — default branch
205205+- `TANGLED_REPO_URL` — full URL to the repository
206206+207207+### Push-only environment variables:
208208+- `TANGLED_REF` — full git ref (e.g., `refs/heads/main`)
209209+- `TANGLED_REF_NAME` — short name (e.g., `main`)
210210+- `TANGLED_REF_TYPE` — `branch` or `tag`
211211+- `TANGLED_SHA` / `TANGLED_COMMIT_SHA` — commit SHA
212212+213213+### PR-only environment variables:
214214+- `TANGLED_PR_SOURCE_BRANCH`, `TANGLED_PR_TARGET_BRANCH`, `TANGLED_PR_SOURCE_SHA`
215215+216216+### Secrets:
217217+- Secrets are managed via the repository settings on Tangled's web UI
218218+- Backend can be SQLite or OpenBao (Hashicorp Vault fork)
219219+- **Secrets are NOT defined in the workflow YAML** — they're injected as env vars at runtime
220220+- Do NOT put secrets in `environment:` blocks (those are public/visible)
221221+222222+### Execution model:
223223+- Spindle uses Docker/Podman under the hood
224224+- Each step runs in a fresh container
225225+- State persists across steps in `/tangled/workspace` directory
226226+- Base image is constructed on-the-fly using Nixery based on `dependencies`
227227+- Default workflow timeout: 5 minutes (configurable on spindle)
228228+229229+### Existing test.yml:
230230+```yaml
231231+when:
232232+ - event: ["push", "manual"]
233233+ branch: ["develop"]
234234+235235+engine: "nixery"
236236+237237+dependencies:
238238+ nixpkgs:
239239+ - coreutils
240240+241241+steps:
242242+ - name: "Spindle health check"
243243+ command: |
244244+ echo "=== Spindle is working! ==="
245245+ # ... prints env vars, date, hostname, etc.
246246+```
247247+248248+---
249249+250250+## Implemented Files
251251+252252+### 1. `nix/docker-image.nix` — Nix Expression for OCI Image (CREATED)
253253+254254+Standalone Nix expression using `pkgs.dockerTools.buildLayeredImage`. Can be
255255+invoked from the flake (`nix build .#dockerImage`) or directly with `nix-build`.
256256+257257+**Arguments:**
258258+| Arg | Type | Default | Description |
259259+|---|---|---|---|
260260+| `pkgs` | attrset | `import <nixpkgs> {}` | nixpkgs package set |
261261+| `appVersion` | string | `"unknown"` | Git SHA or version tag, becomes `APP_VERSION` env var |
262262+| `venvPath` | path | `./../.venv` | Pre-built Python virtualenv |
263263+| `appSrc` | path | `./..` | Full application source tree |
264264+265265+**Runtime packages included in image:**
266266+| Package | Why |
267267+|---|---|
268268+| `bash`, `coreutils`, `findutils`, `gnugrep`, `gnused` | Scripts use `#!/bin/bash`, standard utils |
269269+| `libpq` | PostgreSQL client library (`psycopg`) |
270270+| `gmp` | GNU MP (cryptography deps) |
271271+| `pango`, `harfbuzz`, `fontconfig`, `freetype` | WeasyPrint / PDF rendering |
272272+| `gettext` | Django `compilemessages` |
273273+| `curl`, `wget` | Healthcheck + general HTTP |
274274+| `cacert` | TLS certificates (set via `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, etc.) |
275275+| `procps` | `celery inspect ping` in healthcheck |
276276+| `libjpeg`, `openjpeg` | Pillow runtime |
277277+| `libffi` | cffi runtime |
278278+| `zlib` | Various packages runtime |
279279+280280+**Image assembly (`appDir` derivation):**
281281+- Uses `rsync` to copy source, excluding `.venv`, `.git`, `.nix-data`, `__pycache__`, `.pyc`, `.ruff_cache`, `result`, `node_modules`, `.build-meta`
282282+- Copies `.venv` separately from explicit `venvPath` arg (avoids staleness when venv is rebuilt in CI)
283283+- Sets execute permissions on `scripts/*.sh` and top-level `*.sh` files
284284+285285+**User setup (`etcDir` derivation):**
286286+- Creates `/etc/passwd` and `/etc/group` with `django` user (UID 1000, GID 1000)
287287+- Creates world-writable `/tmp` with container-role placeholder
288288+289289+**Image config:**
290290+| Key | Value |
291291+|---|---|
292292+| `WorkingDir` | `/app` |
293293+| `Env.BUILD_ENVIRONMENT` | `production` |
294294+| `Env.PYTHONUNBUFFERED` | `1` |
295295+| `Env.PYTHONDONTWRITEBYTECODE` | `1` |
296296+| `Env.PIPENV_VENV_IN_PROJECT` | `1` |
297297+| `Env.APP_VERSION` | `${appVersion}` |
298298+| `Env.HOME` | `/app` |
299299+| `Env.PATH` | `/app/.venv/bin:/bin:/usr/bin:/sbin:/usr/sbin` |
300300+| `Env.SSL_CERT_FILE` | Nix store path to `ca-bundle.crt` |
301301+| `Env.FONTCONFIG_PATH` | Nix store path to fontconfig config |
302302+| `ExposedPorts` | `9000/tcp` |
303303+| `User` | `django` |
304304+| `Healthcheck` | `CMD /app/scripts/healthcheck.sh` (30s interval, 5s timeout, 10s start, 12 retries) |
305305+306306+**Layering:** `maxLayers = 100`, with `enableFakechroot = true` for `fakeRootCommands`.
307307+308308+### 2. `flake.nix` — Added `packages.dockerImage` Output (MODIFIED)
309309+310310+Added after `packages.default`:
311311+312312+```nix
313313+packages.dockerImage = import ./nix/docker-image.nix {
314314+ inherit pkgs;
315315+ appVersion = "dev";
316316+ venvPath = ./.venv;
317317+ appSrc = ./.;
318318+};
319319+```
320320+321321+Local usage: `nix build .#dockerImage` (requires a `.venv` at repo root).
322322+323323+In CI the image is built via `nix-build nix/docker-image.nix` with `--arg` / `--argstr` overrides pointing at the workspace-built venv and source.
324324+325325+### 3. `.tangled/workflows/build.yml` — Build Workflow (CREATED)
326326+327327+**Triggers:**
328328+- Push to `develop` branch + manual trigger
329329+- Push of `v*` tags
330330+331331+**Engine:** `nixery`
332332+333333+**Clone depth:** `0` (full clone — needed for tag computation)
334334+335335+**Dependencies (nixpkgs):** `python313`, `gcc`, `gnumake`, `pkg-config`, `zlib`, `libjpeg`, `gmp`, `gettext`, `curl`, `wget`, `git`, `jq`, `skopeo`, `nix`, `postgresql_15`, `libffi`, `openjpeg`, `pango`, `harfbuzz`, `fontconfig`, `pipenv`, `cacert`
336336+337337+**Global environment:**
338338+- `PIPENV_VENV_IN_PROJECT=1`
339339+- `PIPENV_CACHE_DIR=/tmp/pipenv-cache`
340340+- `PIP_CACHE_DIR=/tmp/pip-cache`
341341+- `NIX_SSL_CERT_FILE` and `SSL_CERT_FILE` — point to Nix profile cert bundle
342342+343343+**Steps (6 steps):**
344344+345345+1. **Print build info** — Echoes SHA, ref, ref type, tool versions, workspace listing
346346+2. **Create virtualenv and install dependencies** — `python3 -m venv .venv` then `pipenv install --deploy --categories "packages"`. Prints package count.
347347+3. **Install plugins** — Runs `.venv/bin/python3 install_plugins.py`. Respects `ADDITIONAL_PLUGS` env var (set as secret if needed).
348348+4. **Compute image tags** — Shell logic based on `TANGLED_REF_TYPE` and `TANGLED_REF_NAME`:
349349+ - Tag push (`v*`): `production-latest`, `production-latest-{date}-{sha}`, `{version}`
350350+ - `develop` branch: `latest`, `latest-{date}-{sha}`
351351+ - `staging` branch: `staging-latest`, `staging-latest-{date}-{sha}`
352352+ - `production` branch: `production-latest`, `production-latest-{date}-{sha}`
353353+ - Other branches: `branch-{name}-{sha}`
354354+ - Manual/fallback: `dev-{sha}`
355355+ - Tags persisted to `.build-meta/image-tags.txt` and `.build-meta/image-sha.txt` (workspace-relative, survives across Spindle steps)
356356+5. **Build OCI image with Nix** — `nix-build nix/docker-image.nix` with `--arg pkgs`, `--argstr appVersion`, `--arg venvPath`, `--arg appSrc`, `--option sandbox false`. Output is `./result` (symlink to tarball). Inspects with `skopeo inspect docker-archive:result`.
357357+6. **Push image to registry** — Reads `REGISTRY_URL`, `REGISTRY_IMAGE`, `REGISTRY_USERNAME`, `REGISTRY_TOKEN` from env (secrets). If not set, prints what would have been pushed and exits 0 (graceful skip). Otherwise: `skopeo login`, then `skopeo copy docker-archive:result docker://{dest}` for each tag with `--retry-times 3`.
358358+359359+---
360360+361361+## Secrets Required (to be set in Tangled repo settings)
362362+363363+| Secret | Used for |
364364+|---|---|
365365+| `REGISTRY_URL` | Registry hostname (e.g. `ghcr.io`) |
366366+| `REGISTRY_IMAGE` | Image path (e.g. `ohcnetwork/care`) |
367367+| `REGISTRY_USERNAME` | `skopeo login` username |
368368+| `REGISTRY_TOKEN` | `skopeo login` password/token |
369369+| `ADDITIONAL_PLUGS` | (Optional) JSON array of extra plugin specs |
370370+371371+Sentry is explicitly out of scope for this workflow.
372372+373373+**The push step gracefully skips if registry secrets are not configured.** This means the workflow can be tested/iterated on without any secrets set — it will build the image and report what tags would have been pushed.
374374+375375+---
376376+377377+## What's Deferred
378378+379379+| Feature | Why |
380380+|---|---|
381381+| **Multi-arch** (arm64) | Spindle runners are single-arch; add when matrix support exists |
382382+| **Sentry release** | Explicitly excluded per project decision |
383383+| **Test job dependency** | Tangled may not support cross-workflow dependencies yet |
384384+| **ECS deploy** | Already commented out in GitHub Actions; out of scope |
385385+386386+---
387387+388388+## Key Technical Notes
389389+390390+### Pipenv in CI:
391391+- `PIPENV_VENV_IN_PROJECT=1` makes pipenv create `.venv` inside the project dir
392392+- `pipenv install --deploy --categories "packages"` installs only production deps
393393+- The `Pipfile.lock` must exist and match (--deploy enforces this)
394394+395395+### Plugin installation:
396396+- Happens AFTER pipenv install
397397+- Reads `ADDITIONAL_PLUGS` env var (JSON array)
398398+- Runs `pip install` for each plugin into the existing venv
399399+- Default `plug_config.py` has an empty `plugs = []` list
400400+401401+### Nix `dockerTools.buildLayeredImage`:
402402+- Produces a tarball (not a running container)
403403+- Automatically splits Nix store paths into Docker layers
404404+- System libraries → shared/cacheable layers
405405+- App source + venv → top layers (change frequently)
406406+- The tarball can be loaded with `docker load` or pushed with `skopeo`
407407+- `skopeo copy docker-archive:./result docker://registry/image:tag`
408408+409409+### Workspace persistence in Spindle:
410410+- Each Spindle step runs in a fresh container
411411+- State persists **only** in the workspace directory (where the repo is cloned)
412412+- `/tmp` is NOT shared between steps
413413+- Cross-step state (tags, SHA) is persisted to `.build-meta/` inside the workspace
414414+- The `.venv` built in step 2 is available in step 5 because it's inside the workspace
415415+416416+### nix-build path arguments:
417417+- `--arg` evaluates its value as a Nix expression
418418+- Absolute paths (starting with `/`) are valid Nix path literals
419419+- Nix copies them into the store automatically
420420+- `--option sandbox false` is needed because the workspace paths are impure
421421+- `$(pwd)` is resolved to get the absolute workspace path for `venvPath` and `appSrc`
422422+423423+### Source filtering in the image:
424424+- `rsync` with `--exclude` filters prevents build artifacts, `.git`, `__pycache__`, etc. from entering the image
425425+- `.venv` is excluded from the source copy and added separately from `venvPath` — this avoids double-copying and ensures the CI-built venv is always used
426426+427427+### nixpkgs version:
428428+- Flake is pinned to `nixos-unstable` (rev `5e2a59a5b1a82f89f2c7e598302a9cacebb72a67`)
429429+- Python 3.13 is available in this channel
430430+- All packages referenced in the dev shell are available
431431+- In CI, `nix-build` uses `<nixpkgs>` from the Nixery environment (which also uses nixpkgs)
432432+433433+---
434434+435435+## Reference: Tangled Core's Own build.yml
436436+437437+The Tangled project itself uses this simple build workflow (for reference on syntax/conventions):
438438+439439+```yaml
440440+when:
441441+ - event: ["push", "pull_request"]
442442+ branch: master
443443+444444+engine: nixery
445445+446446+dependencies:
447447+ nixpkgs:
448448+ - go
449449+ - gcc
450450+451451+environment:
452452+ CGO_ENABLED: 1
453453+454454+steps:
455455+ - name: patch static dir
456456+ command: |
457457+ mkdir -p appview/pages/static; touch appview/pages/static/x
458458+459459+ - name: build appview
460460+ command: |
461461+ go build -o appview.out ./cmd/appview
462462+463463+ - name: build knot
464464+ command: |
465465+ go build -o knot.out ./cmd/knot
466466+467467+ - name: build spindle
468468+ command: |
469469+ go build -o spindle.out ./cmd/spindle
470470+```
+15
flake.nix
···559559 echo "Use 'nix develop' to enter the development shell"
560560 '';
561561 };
562562+563563+ # Production OCI image built with dockerTools.
564564+ #
565565+ # In CI the image is built via nix-build with --argstr overrides so
566566+ # that the pre-built .venv and checked-out source are injected.
567567+ #
568568+ # Locally you can test with:
569569+ # nix build .#dockerImage
570570+ # (requires a .venv to exist at the repo root)
571571+ packages.dockerImage = import ./nix/docker-image.nix {
572572+ inherit pkgs;
573573+ appVersion = "dev";
574574+ venvPath = ./.venv;
575575+ appSrc = ./.;
576576+ };
562577 }
563578 );
564579}
+212
nix/docker-image.nix
···11+# nix/docker-image.nix
22+#
33+# Builds a layered OCI image for CARE that is functionally equivalent to
44+# docker/prod.Dockerfile. The image is intended to be built in CI where a
55+# pre-built virtualenv (.venv) and the full application source tree are passed
66+# in as arguments.
77+#
88+# Usage (standalone):
99+# nix-build nix/docker-image.nix \
1010+# --arg pkgs 'import <nixpkgs> {}' \
1111+# --argstr appVersion "abc1234" \
1212+# --argstr venvPath /path/to/.venv \
1313+# --argstr appSrc /path/to/repo
1414+#
1515+# Usage (from flake.nix):
1616+# Imported and called with the appropriate arguments.
1717+1818+{ pkgs ? import <nixpkgs> { }
1919+, appVersion ? "unknown"
2020+, venvPath ? ./../.venv
2121+, appSrc ? ./..
2222+}:
2323+2424+let
2525+ # -------------------------------------------------------------------------
2626+ # Runtime dependencies — mirrors the `apt-get install` in the runtime stage
2727+ # of docker/prod.Dockerfile:
2828+ # libpq-dev libgmp-dev libpangoft2-1.0-0 gettext wget curl gnupg
2929+ #
3030+ # Plus essentials the scripts and Python runtime need.
3131+ # -------------------------------------------------------------------------
3232+ runtimeDeps = with pkgs; [
3333+ # Core utilities — scripts use #!/bin/bash, cat, ls, touch, etc.
3434+ bash
3535+ coreutils
3636+ findutils
3737+ gnugrep
3838+ gnused
3939+4040+ # PostgreSQL client library (psycopg at runtime)
4141+ libpq
4242+4343+ # GNU MP — required by cryptography / gmpy2 at runtime
4444+ gmp
4545+4646+ # WeasyPrint / PDF rendering runtime
4747+ pango
4848+ harfbuzz
4949+ fontconfig
5050+ freetype
5151+5252+ # Django compilemessages
5353+ gettext
5454+5555+ # Healthcheck + general HTTP
5656+ curl
5757+ wget
5858+5959+ # TLS root certificates
6060+ cacert
6161+6262+ # Used by celery inspect in healthcheck.sh
6363+ procps
6464+6565+ # libjpeg + openjpeg — Pillow runtime
6666+ libjpeg
6767+ openjpeg
6868+6969+ # libffi — cffi runtime
7070+ libffi
7171+7272+ # zlib — runtime dependency for various packages
7373+ zlib
7474+ ];
7575+7676+ # -------------------------------------------------------------------------
7777+ # Construct the image contents.
7878+ # We create a derivation that assembles the /app directory exactly as the
7979+ # Dockerfile COPY steps would, with correct permissions.
8080+ # -------------------------------------------------------------------------
8181+ appDir = pkgs.runCommand "care-app" {
8282+ nativeBuildInputs = [ pkgs.rsync ];
8383+ } ''
8484+ mkdir -p $out/app
8585+8686+ # Copy the application source, excluding directories that should not
8787+ # end up in the production image (build artifacts, dev tooling, the
8888+ # venv itself — we copy that separately to avoid staleness issues
8989+ # when venvPath differs from appSrc/.venv).
9090+ rsync -a --chmod=D755,F644 \
9191+ --exclude '.venv' \
9292+ --exclude '.nix-data' \
9393+ --exclude '.git' \
9494+ --exclude '.build-meta' \
9595+ --exclude '__pycache__' \
9696+ --exclude '*.pyc' \
9797+ --exclude '.ruff_cache' \
9898+ --exclude 'result' \
9999+ --exclude 'node_modules' \
100100+ ${appSrc}/ $out/app/
101101+102102+ # Copy the pre-built virtualenv (always from the explicit venvPath
103103+ # argument so CI can point it at the freshly-built venv even if it
104104+ # lives inside the workspace / appSrc).
105105+ cp -r ${venvPath} $out/app/.venv
106106+107107+ # Make all scripts executable (mirrors --chmod=0755 in Dockerfile)
108108+ if [ -d "$out/app/scripts" ]; then
109109+ chmod -R +x $out/app/scripts/
110110+ fi
111111+112112+ # Make top-level shell scripts executable (healthcheck.sh etc. are
113113+ # copied to /app in the Dockerfile)
114114+ for f in $out/app/*.sh; do
115115+ [ -f "$f" ] && chmod +x "$f"
116116+ done
117117+ '';
118118+119119+ # -------------------------------------------------------------------------
120120+ # Create /etc/passwd and /etc/group entries for the django user.
121121+ # Mirrors: addgroup --system django && adduser --system --ingroup django django
122122+ # -------------------------------------------------------------------------
123123+ passwdContents = ''
124124+ root:x:0:0:root:/root:/bin/bash
125125+ nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
126126+ django:x:1000:1000:django:/app:/bin/bash
127127+ '';
128128+129129+ groupContents = ''
130130+ root:x:0:
131131+ nogroup:x:65534:
132132+ django:x:1000:django
133133+ '';
134134+135135+ etcDir = pkgs.runCommand "care-etc" { } ''
136136+ mkdir -p $out/etc
137137+ echo '${passwdContents}' > $out/etc/passwd
138138+ echo '${groupContents}' > $out/etc/group
139139+ mkdir -p $out/tmp
140140+ chmod 1777 $out/tmp
141141+ '';
142142+143143+in
144144+pkgs.dockerTools.buildLayeredImage {
145145+ name = "care";
146146+ tag = appVersion;
147147+148148+ # Maximum number of layers. The default is 100 which is fine — nix will
149149+ # automatically bin-pack store paths into layers. System libraries land in
150150+ # shared / cacheable layers; the app + venv go into the top layers.
151151+ maxLayers = 100;
152152+153153+ contents = runtimeDeps ++ [
154154+ appDir
155155+ etcDir
156156+ ];
157157+158158+ # fakeRootCommands runs inside a fakeroot environment during image
159159+ # construction — we use it to set ownership on /app to the django user
160160+ # and create runtime directories.
161161+ fakeRootCommands = ''
162162+ # Ensure /tmp exists and is world-writable
163163+ mkdir -p ./tmp
164164+ chmod 1777 ./tmp
165165+166166+ # Create /tmp/container-role placeholder (used by healthcheck.sh)
167167+ touch ./tmp/container-role
168168+ chmod 666 ./tmp/container-role
169169+ '';
170170+171171+ # Enable fakeRootCommands
172172+ enableFakechroot = true;
173173+174174+ config = {
175175+ WorkingDir = "/app";
176176+177177+ Env = [
178178+ "BUILD_ENVIRONMENT=production"
179179+ "PYTHONUNBUFFERED=1"
180180+ "PYTHONDONTWRITEBYTECODE=1"
181181+ "PIPENV_VENV_IN_PROJECT=1"
182182+ "APP_VERSION=${appVersion}"
183183+ "HOME=/app"
184184+185185+ # PATH: venv first, then nix profile bins, then standard paths
186186+ "PATH=/app/.venv/bin:/bin:/usr/bin:/sbin:/usr/sbin"
187187+188188+ # TLS certificates — required for any outbound HTTPS (boto3, requests, etc.)
189189+ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
190190+ "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
191191+ "CURL_CA_BUNDLE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
192192+ "REQUESTS_CA_BUNDLE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
193193+194194+ # Fontconfig — needed by WeasyPrint / pango at runtime
195195+ "FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts"
196196+ ];
197197+198198+ ExposedPorts = {
199199+ "9000/tcp" = { };
200200+ };
201201+202202+ User = "django";
203203+204204+ Healthcheck = {
205205+ Test = [ "CMD" "/app/scripts/healthcheck.sh" ];
206206+ Interval = 30000000000; # 30s in nanoseconds
207207+ Timeout = 5000000000; # 5s
208208+ StartPeriod = 10000000000; # 10s
209209+ Retries = 12;
210210+ };
211211+ };
212212+}