diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0c02933..50958f2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -233,7 +233,8 @@ jobs: # Phase 1: Build into local Docker daemon for scanning. # EXACT same parameters as pr.yml production-simulation: # target: production, build-args: NODE_ENV=production, GHA cache. - # The shared GHA cache means layers built in PR are reused here. + # CACHE_BUSTER forces rebuild when package-lock.json changes (prevents stale deps). + # Cache scoped to production to prevent cross-branch contamination from PR builds. - name: Build Docker image (pre-scan, no push) uses: docker/build-push-action@v6 with: @@ -242,17 +243,30 @@ jobs: target: production build-args: | NODE_ENV=production + CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} push: false load: true tags: | fieldtrack-backend:${{ steps.meta.outputs.sha_short }} - fieldtrack-backend:latest - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=production + cache-to: type=gha,mode=max,scope=production + + # Verify OpenSSL version in PRODUCTION stage (not builder or runtime-deps). + # Confirms dependencies were rebuilt AND are present in final distroless image. + - name: Verify OpenSSL in production image + run: | + IMAGE_NAME="fieldtrack-backend:${{ steps.meta.outputs.sha_short }}" + # Run against production image (distroless) — would fail if deps layer missed rebuild + OPENSSL_VERSION=$(docker run --rm "$IMAGE_NAME" openssl version 2>&1) + if [ $? -ne 0 ] || [ -z "$OPENSSL_VERSION" ]; then + echo "::error::OpenSSL check failed — dependencies were not rebuilt or not copied to production stage" + echo "Output: $OPENSSL_VERSION" + exit 1 + fi + echo "✓ Production image verified: $OPENSSL_VERSION" # Capture the content-addressable image digest. - # When source + cache + build-args are identical between PR and deploy, - # this digest will match the one stored in the PR simulation artifact. + # With cache scoping and cache busting, digest should always reproduce correctly. - name: Capture image digest id: digest run: | diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9593662..a672ec1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -96,6 +96,9 @@ jobs: run: | docker build \ --target production \ + --build-arg CACHE_BUSTER=${{ hashFiles('**/package-lock.json') }} \ + --cache-from=type=gha,scope=pr \ + --cache-to=type=gha,mode=max,scope=pr \ -t fieldtrack-backend:ci-validation \ -f apps/api/Dockerfile \ . diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f988d07..0c87880 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -15,8 +15,17 @@ # ---- Stage 1: Build -------------------------------------------------------- # Pinned to specific version to prevent supply chain attacks. +# NOTE: To pin by digest and prevent base image drift: +# 1. Run: docker pull node:24.2.0-bookworm-slim +# 2. Run: docker inspect node:24.2.0-bookworm-slim | grep RepoDigests +# 3. Replace node:24.2.0-bookworm-slim with node:24.2.0-bookworm-slim@sha256:DIGEST +# This ensures identical binaries even if the tag is re-released. FROM node:24.2.0-bookworm-slim AS builder +# Cache buster: force rebuild when package-lock.json changes. +# Prevents stale dependency layers from being reused on deployment. +ARG CACHE_BUSTER=1 + WORKDIR /workspace # Copy package manifests first for layer-cached dependency install. @@ -41,8 +50,12 @@ RUN npm run build # Separate stage: installs --omit=dev so distroless never needs npm or a shell. # mkdir -p guards ensure workspace subdirectories always exist for the COPY in # stage 3, even when npm hoists all deps to the root node_modules. +# NOTE: Must use SAME base image tag as Stage 1 to ensure consistency. FROM node:24.2.0-bookworm-slim AS runtime-deps +# Cache buster: force rebuild when package-lock.json changes. +ARG CACHE_BUSTER=1 + WORKDIR /workspace COPY package.json package-lock.json ./ @@ -63,6 +76,11 @@ RUN npm ci \ # • Minimal glibc + libssl from Debian 12 # • No shell, no package manager, no OS utilities # Trivy finds near-zero OS CVEs in this image. +# NOTE: To pin by digest and prevent base image drift: +# 1. Run: docker pull gcr.io/distroless/nodejs24-debian12:nonroot +# 2. Run: docker inspect gcr.io/distroless/nodejs24-debian12:nonroot | grep RepoDigests +# 3. Replace tag with @sha256:DIGEST in FROM statement +# This prevents "same Dockerfile → different result" risks. # ENTRYPOINT is ["/nodejs/bin/node"]; CMD supplies the script path argument. FROM gcr.io/distroless/nodejs24-debian12:nonroot AS production