From 054551b677fbc0b1ff305bdabb306004c95a2375 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 22 Jun 2026 16:42:48 -0500 Subject: [PATCH] ci: eliminate cold pnpm install via pre-baked CI base image (Phase 1) Every pipeline ran a cold pnpm install (network fetch + musl native rebuilds + apk add python3 make g++), median ~731s, paid twice per push. Phase 1 (no cluster access, repo commits only): - Dockerfile.ci: node:22-alpine + python3/make/g++/postgresql-client + pnpm@10.6.2 + pnpm fetch to warm the store and compile natives once. - .woodpecker/ci-image.yml: kaniko build/push of ci-base:latest + a lockfile-hash tag, triggered only when pnpm-lock.yaml or Dockerfile.ci change. Reuses the publish.yml kaniko/auth pattern. - ci.yml + publish.yml: install from the baked ci-base:latest, drop the per-run apk add, use pnpm install --frozen-lockfile --prefer-offline. - Framework monorepo template: single cached install other steps depend on instead of re-running npm ci across 6 steps. Node 22->24 bump is a separate follow-up PR. Phase 2 (RWX Longhorn PVC) is out of scope. Expected install ~731s -> ~30-60s. Refs #634 Co-Authored-By: Claude Opus 4.8 --- .woodpecker/ci-image.yml | 40 +++++++++++++++++++ .woodpecker/ci.yml | 14 ++++--- .woodpecker/publish.yml | 7 +++- Dockerfile.ci | 36 +++++++++++++++++ .../templates/monorepo/.woodpecker.yml | 25 +++++++----- 5 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 .woodpecker/ci-image.yml create mode 100644 Dockerfile.ci diff --git a/.woodpecker/ci-image.yml b/.woodpecker/ci-image.yml new file mode 100644 index 0000000..c89e24a --- /dev/null +++ b/.woodpecker/ci-image.yml @@ -0,0 +1,40 @@ +# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea +# registry CI already publishes to. Reuses the exact kaniko + auth pattern +# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker +# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest` +# for their install step. +# +# Rebuild ONLY when the dependency set or the image recipe changes — a normal +# code push must not trigger a 25-min image build. `path` applies to push/PR +# events; `event: tag` (releases) rebuilds unconditionally so a tagged release +# always ships a fresh base. +when: + - event: tag + - event: [push, manual] + branch: main + path: + include: + - 'pnpm-lock.yaml' + - 'Dockerfile.ci' + +steps: + build-ci-base: + image: gcr.io/kaniko-project/executor:debug + environment: + REGISTRY_USER: + from_secret: gitea_username + REGISTRY_PASS: + from_secret: gitea_password + CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH} + CI_COMMIT_TAG: ${CI_COMMIT_TAG} + CI_COMMIT_SHA: ${CI_COMMIT_SHA} + commands: + - mkdir -p /kaniko/.docker + - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json + - | + # Lockfile-hash tag: an immutable identity for the exact dep set baked + # into this image. `:latest` is the mutable pointer pipelines consume. + LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12) + DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest" + DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH" + /kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 92bb311..46a839f 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -1,5 +1,9 @@ +# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml: +# node:22-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm +# store. The install step resolves from the baked store (--prefer-offline) +# instead of paying a ~731s cold fetch + native compile every run. variables: - - &node_image 'node:22-alpine' + - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest' - &enable_pnpm 'corepack enable' when: @@ -15,8 +19,9 @@ steps: image: *node_image commands: - corepack enable - - apk add --no-cache python3 make g++ - - pnpm install --frozen-lockfile + # python3/make/g++ are baked into ci-base; --prefer-offline resolves from + # the baked pnpm store. + - pnpm install --frozen-lockfile --prefer-offline # Blocking gate: public framework package must contain no operator-specific # personal data or private $HOME defaults. Runs early (no node_modules needed). @@ -64,8 +69,7 @@ steps: DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic commands: - *enable_pnpm - # Install postgresql-client for pg_isready - - apk add --no-cache postgresql-client + # postgresql-client (pg_isready) is baked into ci-base. # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up. - | ready=0 diff --git a/.woodpecker/publish.yml b/.woodpecker/publish.yml index 8eb4685..9adc6e3 100644 --- a/.woodpecker/publish.yml +++ b/.woodpecker/publish.yml @@ -2,7 +2,9 @@ # Runs only on main branch push/tag variables: - - &node_image 'node:22-alpine' + # Pre-baked CI base (see .woodpecker/ci-image.yml): node:22-alpine + + # toolchain + warm pnpm store. Kills the second cold install publish pays. + - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest' - &enable_pnpm 'corepack enable' # Heavy kaniko image builds (~25 min) — gate them so a merge that only touches # the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform @@ -31,7 +33,8 @@ steps: image: *node_image commands: - corepack enable - - pnpm install --frozen-lockfile + # Resolve from the baked pnpm store instead of a cold network fetch. + - pnpm install --frozen-lockfile --prefer-offline build: image: *node_image diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..1bf38fa --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,36 @@ +# Pre-baked CI base image for Woodpecker pipelines. +# +# Purpose: eliminate the cold `pnpm install` that dominates every pipeline +# (~731s median). This image ships the native toolchain (no per-run `apk add`) +# AND a warm, content-addressable pnpm store with the dependency tree already +# fetched and its musl native modules (better-sqlite3, node-pty, sqlite3, +# canvas, sharp) compiled ONCE at build time. A pipeline `pnpm install +# --frozen-lockfile --prefer-offline` then resolves from local hard-links in +# tens of seconds. +# +# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change +# (see .woodpecker/ci-image.yml). +# +# Node version is intentionally pinned to 22 (Active LTS at time of writing). +# The node:22 -> node:24 bump lands as a SEPARATE follow-up PR so the cache +# change carries zero runtime-version variables. +FROM node:22-alpine + +# Native toolchain required to compile node-gyp deps on musl, plus the +# postgresql-client used by the test step's pg_isready readiness probe. +RUN apk add --no-cache python3 make g++ postgresql-client + +# Pin pnpm to the repo's packageManager version via corepack. +RUN corepack enable && corepack prepare pnpm@10.6.2 --activate + +WORKDIR /app + +# Pin the store location so the pipeline can point `store-dir` at the same path. +ENV PNPM_HOME=/root/.local/share/pnpm +RUN pnpm config set store-dir /root/.local/share/pnpm/store + +# Warm the store + compile native modules once. `pnpm fetch` populates the +# content-addressable store directly from the lockfile (no package.json / +# workspace needed), so a baked store stays valid until the lockfile changes. +COPY pnpm-lock.yaml ./ +RUN pnpm fetch --frozen-lockfile diff --git a/packages/mosaic/framework/tools/quality/templates/monorepo/.woodpecker.yml b/packages/mosaic/framework/tools/quality/templates/monorepo/.woodpecker.yml index 13d19a1..2c37c5b 100644 --- a/packages/mosaic/framework/tools/quality/templates/monorepo/.woodpecker.yml +++ b/packages/mosaic/framework/tools/quality/templates/monorepo/.woodpecker.yml @@ -2,12 +2,20 @@ when: - event: [push, pull_request, manual] +# Dependencies are installed ONCE in the `install` step and every downstream +# step depends on it, reusing the populated node_modules from the shared +# workspace volume. Do NOT re-run `npm ci` per step — that pays the full cold +# install (network fetch + native rebuilds) N times and is the dominant cost +# in a pipeline. +# +# For best results, replace `&node_image` with a pre-baked CI base image that +# ships your toolchain (python3/make/g++ for native modules) and a warm npm +# cache, then keep `--prefer-offline` so installs resolve from the cache. See +# the Mosaic Stack repo's Dockerfile.ci + .woodpecker/ci-image.yml for the +# baked-image pattern. variables: - &node_image 'node:20-alpine' - &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0' - - &install_deps | - corepack enable - npm ci --ignore-scripts steps: # Secret scanning (runs in parallel with install, no deps) @@ -17,15 +25,18 @@ steps: - gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD" depends_on: [] + # Single cached install. Every other step depends on this and reuses the + # node_modules it produces in the shared workspace. install: image: *node_image commands: - - *install_deps + - corepack enable + - npm ci --ignore-scripts --prefer-offline + depends_on: [] security-audit: image: *node_image commands: - - *install_deps - npm audit --audit-level=high depends_on: - install @@ -35,7 +46,6 @@ steps: environment: SKIP_ENV_VALIDATION: 'true' commands: - - *install_deps - npm run lint depends_on: - install @@ -45,7 +55,6 @@ steps: environment: SKIP_ENV_VALIDATION: 'true' commands: - - *install_deps - npm run type-check depends_on: - install @@ -55,7 +64,6 @@ steps: environment: SKIP_ENV_VALIDATION: 'true' commands: - - *install_deps - npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}' depends_on: - install @@ -66,7 +74,6 @@ steps: SKIP_ENV_VALIDATION: 'true' NODE_ENV: 'production' commands: - - *install_deps - npm run build depends_on: - lint