Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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: |
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
.
Expand Down
18 changes: 18 additions & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ./
Expand All @@ -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

Expand Down
Loading