diff --git a/.github/workflows/build-base-images.yml b/.github/workflows/build-base-images.yml new file mode 100644 index 0000000000..3bf5ba9526 --- /dev/null +++ b/.github/workflows/build-base-images.yml @@ -0,0 +1,128 @@ +name: Build Base Docker Images + +on: + workflow_call: + inputs: + force-rebuild: + description: 'Force rebuild even if images exist' + required: false + type: boolean + default: false + outputs: + build-base-image: + description: 'Full build-base image reference' + value: ${{ jobs.build-base.outputs.image }} + runtime-base-image: + description: 'Full runtime-base image reference' + value: ${{ jobs.build-base.outputs.runtime-image }} + workflow_dispatch: + inputs: + force-rebuild: + description: 'Force rebuild even if images exist' + required: false + type: boolean + default: false + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ghcr.io/opengovsg/formsg + +jobs: + build-base: + name: Build base images + runs-on: ubuntu-latest + outputs: + image: ${{ steps.set-outputs.outputs.build-image }} + runtime-image: ${{ steps.set-outputs.outputs.runtime-image }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute dependency hash + id: deps-hash + run: | + HASH=$(cat \ + pnpm-lock.yaml \ + package.json \ + pnpm-workspace.yaml \ + Dockerfile.base \ + apps/backend/package.json \ + apps/frontend/package.json \ + packages/shared/package.json \ + packages/sdk/package.json \ + | sha256sum | cut -c1-16) + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + echo "Dependency hash: $HASH" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if images already exist + id: check-exists + if: ${{ !inputs.force-rebuild }} + run: | + BUILD_EXISTS=false + RUNTIME_EXISTS=false + + if docker manifest inspect "${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }}" > /dev/null 2>&1; then + BUILD_EXISTS=true + echo "build-base image already exists" + fi + + if docker manifest inspect "${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }}" > /dev/null 2>&1; then + RUNTIME_EXISTS=true + echo "runtime-base image already exists" + fi + + if [[ "$BUILD_EXISTS" == "true" && "$RUNTIME_EXISTS" == "true" ]]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Both images exist, skipping build" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + if: steps.check-exists.outputs.skip != 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build and push build-base image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.base + target: build-base + push: true + tags: | + ${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }} + ${{ env.IMAGE_NAME }}:build-latest + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:build-latest + + - name: Build and push runtime-base image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.base + target: runtime-base + push: true + tags: | + ${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }} + ${{ env.IMAGE_NAME }}:runtime-latest + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:runtime-latest + + - name: Set outputs + id: set-outputs + run: | + echo "build-image=${{ env.IMAGE_NAME }}:build-${{ steps.deps-hash.outputs.hash }}" >> "$GITHUB_OUTPUT" + echo "runtime-image=${{ env.IMAGE_NAME }}:runtime-${{ steps.deps-hash.outputs.hash }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/build-release-image.yml b/.github/workflows/build-release-image.yml new file mode 100644 index 0000000000..01e6a9173b --- /dev/null +++ b/.github/workflows/build-release-image.yml @@ -0,0 +1,84 @@ +name: Build Release Image + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: 'Version tag to build (e.g., v7.4.5)' + required: true + type: string + +permissions: + contents: read + packages: write + +env: + IMAGE_NAME: ghcr.io/opengovsg/formsg + +jobs: + ensure-base-images: + name: Ensure base images + uses: ./.github/workflows/build-base-images.yml + permissions: + contents: read + packages: write + + build-release: + name: Build release image + needs: ensure-base-images + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Determine version tag + id: version + run: | + if [[ "${{ github.event_name }}" == "push" ]]; then + TAG="${GITHUB_REF#refs/tags/}" + else + TAG="${{ inputs.tag }}" + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Building release image for $TAG" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if release image already exists + id: check-exists + run: | + IMAGE="${{ env.IMAGE_NAME }}:release-${{ steps.version.outputs.tag }}" + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Release image already exists: $IMAGE" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Docker Buildx + if: steps.check-exists.outputs.skip != 'true' + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build and push release image + if: steps.check-exists.outputs.skip != 'true' + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + file: Dockerfile.production + push: true + tags: | + ${{ env.IMAGE_NAME }}:release-${{ steps.version.outputs.tag }} + build-args: | + BUILD_BASE_IMAGE=${{ needs.ensure-base-images.outputs.build-base-image }} + APP_VERSION=${{ steps.version.outputs.tag }} + DD_RUM_APP_ID=${{ secrets.DD_RUM_APP_ID }} + DD_RUM_CLIENT_TOKEN=${{ secrets.DD_RUM_CLIENT_TOKEN }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:build-latest diff --git a/.github/workflows/deploy-ecs.yml b/.github/workflows/deploy-ecs.yml index 508d5579ce..503549955f 100644 --- a/.github/workflows/deploy-ecs.yml +++ b/.github/workflows/deploy-ecs.yml @@ -114,10 +114,19 @@ on: permissions: id-token: write contents: read + packages: write jobs: + ensure-base-images: + name: Ensure base images + uses: ./.github/workflows/build-base-images.yml + permissions: + contents: read + packages: write + deploy: name: Deploy to ECS + needs: ensure-base-images runs-on: ubuntu-latest environment: ${{ inputs.gha-environment }} env: @@ -133,24 +142,6 @@ jobs: run: | echo "APP_VERSION=$(jq -r .version package.json)-$(echo ${GITHUB_REF##*/})-$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_ENV - - name: Inject frontend build env vars - env: - VITE_APP_DD_RUM_APP_ID: ${{ secrets.dd-rum-app-id }} - VITE_APP_DD_RUM_CLIENT_TOKEN: ${{ secrets.dd-rum-client-token }} - VITE_APP_DD_RUM_ENV: ${{ inputs.dd-env }} - VITE_APP_DD_SAMPLE_RATE: ${{ secrets.dd-sample-rate }} - VITE_APP_GA_TRACKING_ID: ${{ secrets.ga-tracking-id }} - VITE_APP_FORMSG_SDK_MODE: ${{ inputs.react-app-form-sg-sdk-mode }} - VITE_APP_URL: ${{ inputs.app-url }} - run: | - sed -i -e "s|@VITE_APP_URL|${{ env.VITE_APP_URL }}|g" -e "s/@VITE_APP_DD_RUM_APP_ID/$VITE_APP_DD_RUM_APP_ID/g" -e "s/@VITE_APP_DD_RUM_CLIENT_TOKEN/$VITE_APP_DD_RUM_CLIENT_TOKEN/g" -e "s/@VITE_APP_DD_RUM_ENV/$VITE_APP_DD_RUM_ENV/g" -e "s/@VITE_APP_VERSION/${{env.APP_VERSION}}/g" -e "s/@VITE_APP_DD_SAMPLE_RATE/$VITE_APP_DD_SAMPLE_RATE/g" apps/frontend/datadog-chunk.ts - echo VITE_APP_VERSION=${{ inputs.app-version }} > apps/frontend/.env - echo VITE_APP_URL=$VITE_APP_URL > apps/frontend/.env - echo VITE_APP_GA_TRACKING_ID=$VITE_APP_GA_TRACKING_ID >> apps/frontend/.env - echo VITE_APP_FORMSG_SDK_MODE=$VITE_APP_FORMSG_SDK_MODE >> apps/frontend/.env - echo VITE_APP_DD_RUM_CLIENT_TOKEN=$VITE_APP_DD_RUM_CLIENT_TOKEN >> apps/frontend/.env - echo VITE_APP_DD_RUM_ENV=$VITE_APP_DD_RUM_ENV >> apps/frontend/.env - - name: Grant runner AWS credentials uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: @@ -161,27 +152,61 @@ jobs: id: login-ecr uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for pre-built release image + id: check-release-image + run: | + VERSION="v$(jq -r .version package.json)" + IMAGE="ghcr.io/opengovsg/formsg:release-${VERSION}" + echo "Looking for pre-built release image: $IMAGE" + if docker manifest inspect "$IMAGE" > /dev/null 2>&1; then + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" + echo "Found pre-built release image, will skip Docker build" + else + echo "image=" >> "$GITHUB_OUTPUT" + echo "No pre-built release image found, will build from scratch" + fi + + - name: Pull and push pre-built image to ECR + if: steps.check-release-image.outputs.image != '' + env: + ECR_REPOSITORY: ${{ inputs.ecr-repository }} + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + run: | + docker pull ${{ steps.check-release-image.outputs.image }} + docker tag ${{ steps.check-release-image.outputs.image }} $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + - name: Set up Docker Buildx + if: steps.check-release-image.outputs.image == '' uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build and push Docker image + if: steps.check-release-image.outputs.image == '' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 env: - DD_API_KEY: ${{ secrets.DD_API_KEY }} - DD_ENV: ${{ inputs.dd-env }} ECR_REPOSITORY: ${{ inputs.ecr-repository }} ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} with: context: . file: Dockerfile.production push: true + load: true tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} build-args: | - APP_VERSION=${{ inputs.app-version }} - APP_URL=${{ inputs.app-url }} - REPO_URL=${{ github.server_url }}/${{ github.repository }} - secrets: | - "dd_api_key=${{ secrets.DD_API_KEY }}" + BUILD_BASE_IMAGE=${{ needs.ensure-base-images.outputs.build-base-image }} + RUNTIME_BASE_IMAGE=${{ needs.ensure-base-images.outputs.runtime-base-image }} + APP_VERSION=${{ env.APP_VERSION }} + DD_RUM_APP_ID=${{ secrets.dd-rum-app-id }} + DD_RUM_CLIENT_TOKEN=${{ secrets.dd-rum-client-token }} + DD_SAMPLE_RATE=${{ secrets.dd-sample-rate }} + GA_TRACKING_ID=${{ secrets.ga-tracking-id }} - name: Replace variables in task definition file id: replace-variables @@ -220,6 +245,18 @@ jobs: docker run --rm -v /tmp/s3static:/tmp/s3static $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG cp -r ./apps/frontend/dist/. /tmp/s3static aws s3 sync /tmp/s3static s3://$S3_STATIC_ASSETS_BUCKET_NAME + - name: Upload Datadog sourcemaps + env: + DATADOG_API_KEY: ${{ secrets.DD_API_KEY }} + run: | + curl -L --fail "https://github.com/DataDog/datadog-ci/releases/download/v5.11.0/datadog-ci_linux-x64" --output /usr/local/bin/datadog-ci + chmod +x /usr/local/bin/datadog-ci + datadog-ci sourcemaps upload /tmp/s3static \ + --service=formsg-react \ + --release-version=${{ env.APP_VERSION }} \ + --minified-path-prefix=${{ inputs.app-url }} \ + --repository-url=${{ github.server_url }}/${{ github.repository }} + - name: Deploy Amazon ECS task definition uses: aws-actions/amazon-ecs-deploy-task-definition@df9643053eda01f169e64a0e60233aacca83799a # v1.4.11 with: diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000000..e1ef503958 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# build-base: Full build toolchain with pre-installed node_modules +# ============================================================================= +FROM node:22.22.2-alpine3.22 AS build-base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate + +# Install build dependencies (same as Dockerfile.production build stage) +RUN apk upgrade --no-cache && \ + apk --no-cache add --virtual native-deps \ + g++ gcc libgcc libstdc++ linux-headers autoconf automake make nasm python3 git curl \ + build-base cairo-dev pango-dev giflib-dev libjpeg-turbo-dev librsvg-dev && \ + pnpm add -g node-gyp@12.2.0 + +WORKDIR /build + +# Copy only dependency-related files for layer caching +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/backend/package.json ./apps/backend/ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/sdk/package.json ./packages/sdk/ +COPY apps/frontend/package.json ./apps/frontend/ + +RUN pnpm install --frozen-lockfile --filter "!services/*" --filter "!packages/react-email-preview/*" + +# ============================================================================= +# runtime-base: Minimal runtime image with system deps and non-root user +# ============================================================================= +FROM node:22.22.2-alpine3.22 AS runtime-base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@10.30.3 --activate + +RUN apk upgrade --no-cache && \ + apk add --no-cache \ + nss \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + tini \ + cairo \ + pango \ + librsvg \ + giflib + +# Pre-create non-root user +RUN addgroup -S formsguser && adduser -S -g formsguser formsguser diff --git a/Dockerfile.production b/Dockerfile.production index abd8a701f8..7eecc0def7 100644 --- a/Dockerfile.production +++ b/Dockerfile.production @@ -1,55 +1,31 @@ # syntax=docker/dockerfile:1 -FROM node:22.22.2-alpine3.22 AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -# Install pnpm -RUN corepack enable && corepack prepare pnpm@10.30.3 --activate +ARG BUILD_BASE_IMAGE=ghcr.io/opengovsg/formsg:build-latest +ARG RUNTIME_BASE_IMAGE=ghcr.io/opengovsg/formsg:runtime-latest -FROM base AS build # node-modules-builder stage installs/compiles the node_modules folder -# Python version must be specified starting in alpine3.12 -RUN apk --no-cache add --virtual native-deps \ - g++ gcc libgcc libstdc++ linux-headers autoconf automake make nasm python3 git curl \ - build-base cairo-dev pango-dev giflib-dev libjpeg-turbo-dev librsvg-dev && \ - pnpm add -g node-gyp@12.2.0 +# Build stage: node_modules already present in base image — just copy source and build +FROM ${BUILD_BASE_IMAGE} AS build +ARG APP_VERSION="" +ARG DD_RUM_APP_ID="" +ARG DD_RUM_CLIENT_TOKEN="" WORKDIR /build -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/backend/package.json ./apps/backend/ -COPY packages/shared/package.json ./packages/shared/ -COPY packages/sdk/package.json ./packages/sdk/ -COPY apps/frontend/package.json ./apps/frontend/ - -RUN pnpm install --frozen-lockfile --filter "!services/*" --filter "!packages/react-email-preview/*" - COPY . ./ # These options are only used in the build stage, not the start stage. ENV NODE_OPTIONS="--max-old-space-size=4096" +ENV VITE_APP_VERSION=$APP_VERSION +ENV VITE_APP_DD_RUM_APP_ID=$DD_RUM_APP_ID +ENV VITE_APP_DD_RUM_CLIENT_TOKEN=$DD_RUM_CLIENT_TOKEN RUN pnpm build -ARG APP_VERSION -ARG APP_URL -ARG REPO_URL - -# Upload datadog source maps from secret mount, id MUST be `id=dd_api_key` in the docker build command -# Mount creates a file inside /run/secrets/dd_api_key, and source to load the environment variables -# For the content of the mounted file is only accessible in the RUN command where it’s referred in, use && command. -RUN --mount=type=secret,id=dd_api_key \ - export DATADOG_API_KEY=$(cat /run/secrets/dd_api_key) && \ - pnpm exec datadog-ci sourcemaps upload ./apps/frontend/dist \ - --service=formsg-react \ - --release-version=$APP_VERSION \ - --minified-path-prefix=$APP_URL \ - --repository-url=$REPO_URL -# This stage builds the final container -FROM base +# Final stage: runtime deps and user already in base image +FROM ${RUNTIME_BASE_IMAGE} LABEL maintainer=FormSG