Sunday, 17 May 2026

GITLAB CI

TASK J: GitLab CI — CI Pipeline Guide

Overview

This guide gets your Continuous Integration (CI) pipeline running end-to-end.

You will complete this guide first. The CD (Continuous Delivery) components — Kustomize overlay updates, Argo CD sync — are covered in a separate guide. This separation is intentional: get CI green and your image in the registry first, then layer in CD.

By the end of this guide you will have:

  • A GitLab Runner installed and registered on your EC2
  • A .gitlab-ci.yml that installs, lints, tests, scans, builds, publishes, and image-scans your Docker image automatically on every push
  • Your image visible in GitLab's built-in Container Registry
  • Your compiled app uploaded to JFrog Artifactory

What this guide does NOT cover yet:

  • Kustomize overlay updates
  • CD repo commits
  • Argo CD syncing
  • Kubernetes deployments

Those come in the next guide once this pipeline is green.


Pipeline Overview

The CI pipeline you will build runs these stages in order:

install
   └── npm ci — downloads and caches dependencies

test  (parallel)
   ├── lint       — ESLint checks
   └── unit-tests — Vitest + coverage report

scan  (parallel)
   ├── gitleaks      — scans for committed secrets
   ├── sonarqube     — code quality + coverage gate
   └── snyk-security — dependency vulnerability scan

build
   └── build-app — compiles React/TypeScript → dist/

publish  (parallel)
   ├── docker-publish      — builds Docker image, pushes to GitLab registry
   └── artifactory-upload  — uploads dist/ to JFrog Artifactory

scan-image
   └── image-scan — scans the built Docker image for OS-level CVEs (Trivy)

At the end of a successful run you will see:

  • A green pipeline in GitLab → CI/CD → Pipelines
  • Your image tagged <pipeline-number>-<git-sha> in GitLab → Deploy → Container Registry
  • Your build artefacts in JFrog Artifactory
  • A Trivy scan report confirming no HIGH or CRITICAL CVEs in the image

Two-Repo Setup — Quick Recap

You have two GitLab repositories:

RepoWhat lives there
CI repoSource code, Dockerfile, .gitlab-ci.yml, tests
CD repokubernetes/ — Kustomize overlays, Argo CD Application CRDs

This guide works entirely in the CI repo. You will not touch the CD repo until the next guide.


Step 1: What is GitLab CI and Why Is It Relevant?

GitLab is more than a Git host

GitLab is a unified DevOps platform — it ships Git hosting, CI/CD, a container registry, security scanning, and environment dashboards in a single product. You do not need to wire together separate tools.

For the HealthPulse capstone this matters because:

  • Your .gitlab-ci.yml sits in the same repo as your source code — no separate CI server to configure
  • GitLab's built-in Container Registry gives you a free private Docker registry with zero setup ($CI_REGISTRY is pre-populated automatically)
  • CI/CD Variables store your secrets — no Vault, no .env files committed by accident
  • The Pipelines page shows every run, its logs, and pass/fail status in real time

How GitLab CI works — the big picture

Developer pushes to 'develop'
        │
        ▼
GitLab reads .gitlab-ci.yml
        │
        ▼
GitLab queues jobs for your Runner
        │
        ▼
Runner (your EC2) picks up each job
        │
        ▼
Runner starts a fresh Docker container per job
        │
        ▼
Scripts run inside the container
        │
        ▼
Container is destroyed, logs streamed back to GitLab
        │
        ▼
Pipeline shows green ✅ or red ❌

How jobs actually run — the container model

Each job is a fresh, isolated container that starts when the job begins and is destroyed when it ends.

The pipeline is the orchestrator. The containers are the workers. Here is what the CI pipeline looks like as containers:

Pipeline
├── stage: install
│     └── container: node:24-alpine       → runs npm ci → destroyed
├── stage: test (parallel)
│     ├── container: node:24-alpine       → runs lint → destroyed
│     └── container: node:24-alpine       → runs vitest → destroyed
├── stage: scan (parallel)
│     ├── container: gitleaks:v8.30.1     → scans repo for secrets → destroyed
│     ├── container: sonar-scanner-cli    → sends data to SonarQube server → destroyed
│     └── container: node:24-alpine       → npx downloads + runs snyk → destroyed
├── stage: build
│     └── container: node:24-alpine       → runs npm build → destroyed
├── stage: publish (parallel)
│     ├── container: docker:29.4.3-dind   → builds + pushes image → destroyed
│     └── container: jfrog-cli-v2-jf      → uploads dist/ to Artifactory → destroyed
└── stage: scan-image
      └── container: aquasec/trivy        → pulls image from registry, scans for CVEs → destroyed

The only things that survive between containers:

WhatHow
ArtifactsFiles uploaded at end of job, downloaded at start of the next job that needs: it
Cachenode_modules/ reused between pipeline runs
The registryThe built Docker image — lives there permanently after docker push

This is why image: is one of the most important keys in a job — it chooses the operating system and pre-installed tools for that container's run. Almost nothing needs to be pre-installed on your EC2 host.

GitLab's built-in container registry — is it free?

Yes. Included on every plan. Container registry storage is not counted against GitLab's 10 GiB project storage quota — it is tracked separately with no published hard limit on the free tier.

GitLab pre-populates three variables in every pipeline automatically:

VariableWhat it contains
$CI_REGISTRYRegistry hostname only — e.g. registry.gitlab.com
$CI_REGISTRY_IMAGEFull registry path for this project — e.g. registry.gitlab.com/your-group/your-project
$CI_REGISTRY_USERTemporary username — valid for this pipeline run only
$CI_REGISTRY_PASSWORDTemporary password — valid for this job only

You do not need to add any variables to use it.

$CI_REGISTRY vs $CI_REGISTRY_IMAGE — this is a common mistake. $CI_REGISTRY is only the hostname (registry.gitlab.com). If you tag your image as $CI_REGISTRY/$APP_NAME:version, GitLab will reject the push with denied: requested access to the resource is denied because the path has no namespace. Always use $CI_REGISTRY_IMAGE/$APP_NAME:version — it includes the full project path and is the only form GitLab will accept for pushing.

Registry options — if you prefer a different registry

This guide uses the GitLab built-in registry by default. The only change needed for other registries is the login command and image tag prefix:

JCR (JFrog): Add variables JCR_REGISTRY, JCR_USER, JCR_TOKEN, then:

before_script:
  - echo "$JCR_TOKEN" | docker login -u "$JCR_USER" --password-stdin "$JCR_REGISTRY"
script:
  - docker build -t $JCR_REGISTRY/$APP_NAME:$BUILD_VERSION .
  - docker push $JCR_REGISTRY/$APP_NAME:$BUILD_VERSION

DockerHub: Add variables DOCKERHUB_USER, DOCKERHUB_TOKEN, then:

before_script:
  - echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USER" --password-stdin
script:
  - docker build -t $DOCKERHUB_USER/$APP_NAME:$BUILD_VERSION .
  - docker push $DOCKERHUB_USER/$APP_NAME:$BUILD_VERSION

The rest of the pipeline is identical regardless of which registry you choose.


Step 2: What is a GitLab Runner?

The runner is the machine that does the work

GitLab.com is the coordinator — it stores your code, reads your .gitlab-ci.yml, and decides which jobs to run. But it does not execute the jobs itself.

A GitLab Runner is a separate agent process that runs on a machine you control. It polls GitLab for jobs, picks them up, runs the scripts, and streams the logs back.

GitLab Server                    Your EC2 (runner)
─────────────────                ──────────────────────────
Reads .gitlab-ci.yml
Queues jobs                      Runner polls: "any jobs for me?"
Assigns job to runner   ──────►  Runner receives job
                                 Runner spins up Docker container
                                 Runner executes scripts inside it
                        ◄──────  Runner streams logs back
Stores artefacts                 Runner uploads artefacts
Shows pass/fail in UI

Executor types

When you register a runner you choose an executor — how each job is isolated:

ExecutorHow it worksUse when
DockerEach job runs in a fresh container✅ Recommended — clean, isolated every time
ShellScripts run directly on the host OSLegacy only — no isolation between jobs
KubernetesA new pod per jobProduction-grade at scale — overkill for this project
Docker MachineAutoscaling VMs❌ Deprecated by GitLab

Modern standard: Kubernetes executor is used by large organisations and GitLab.com's own shared runners. For your EC2-based setup, Docker executor is the right choice — it gives you identical container-per-job isolation without the cluster management overhead, and the concepts transfer directly to Kubernetes executor when you reach a production environment.

For the HealthPulse capstone you will use:

  • Docker executor for all test, scan, and build jobs
  • Docker-in-Docker (dind) as a service inside the docker-publish job

Registration token — removed since GitLab 17.0

Before GitLab 17.0 you copied a "Registration Token" from Settings and passed it as --registration-token. That method is disabled by default since GitLab 17.0 and will be fully removed in GitLab 20.0.

New flow: create the runner in the GitLab UI first, get an authentication token (glrt- prefix), then register on EC2 using --token. This guide uses the new flow.


Step 3: Prepare Your EC2 for the Runner

3.1 — Check your EC2 specs

ResourceMinimumRecommended
CPU1 vCPU2 vCPU (t3.medium)
RAM2 GB4 GB
Disk20 GB30 GB (Docker image layers cache up fast)
OSUbuntu 20.04 LTSUbuntu 22.04 / 24.04 LTS

t2.micro will not work. The docker-publish job runs Docker-in-Docker and needs at least 2 GB RAM to build the image without running out of memory.

3.2 — Install Docker Engine

Do not use docker.io — it is an unofficial Ubuntu-maintained package that lags on security patches and can conflict with official Docker packages. Docker's own documentation explicitly says to remove it first.

SSH into your runner EC2 and run:

# Step 1 — Remove any unofficial Docker packages if present
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
  sudo apt-get remove -y $pkg 2>/dev/null || true
done

# Step 2 — Install required packages
sudo apt-get update
sudo apt-get install -y ca-certificates curl

# Step 3 — Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Step 4 — Add the Docker CE repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# Step 5 — Install Docker CE
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

# Step 6 — Start Docker and enable on boot
sudo systemctl start docker
sudo systemctl enable docker

Verify it works:

docker --version
# Expected: Docker version 29.x.x, build ...

sudo docker run hello-world
# Expected: "Hello from Docker!" message

3.3 — Why Docker is required

This guide uses the Docker executor. Every CI job specifies an image: — the Runner pulls that image and runs the job scripts inside a fresh container. Without Docker on the host, every job fails with:

Cannot connect to the Docker daemon at unix:///var/run/docker.sock

What needs to be on the host vs what arrives via Docker images:

ToolNeeded on host?How it arrives
Docker Engine✅ Yes — install it nowStep 3.2 above
Node.js❌ Nonode:24-alpine image per job
SonarScanner❌ Nosonarsource/sonar-scanner-cli:12.1 image
Gitleaks❌ Noghcr.io/gitleaks/gitleaks:v8.30.1 image
Docker CLI (for builds)❌ Nodocker:29.4.3-dind image
git✅ Pre-installed on Ubuntu
curl✅ Pre-installed on Ubuntu

Step 4: Install and Register a GitLab Runner

4.1 — Install the runner binary

On the EC2:

# Add the GitLab Runner package repository
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash

# Install
sudo apt-get install -y gitlab-runner

# Verify
gitlab-runner --version
# Expected: gitlab-runner 18.x.x (current stable: 18.11)

4.2 — Create the runner in GitLab UI

  1. Go to your CI repo on GitLab
  2. Click SettingsCI/CDRunners
  3. Click New project runner
  4. Fill in:
    • Platform: Linux
    • Tags: healthpulse,docker
    • Description: healthpulse-runner
    • Tick Run untagged jobs
  5. Click Create runner
  6. GitLab shows a gitlab-runner register command with a pre-filled glrt- token — copy the full command

4.3 — Register the runner on EC2

Paste the command GitLab gave you, but add --docker-privileged before running:

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --token "glrt-t3_xxxxxxxxxxxxxxxxxxxx" \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --docker-privileged \
  --description "healthpulse-runner"

Why --docker-privileged? The docker-publish job runs Docker-in-Docker — a container that runs docker build inside itself. This requires elevated permissions to talk to the Docker daemon. Without this flag the image build step fails.

Only enable privileged mode on a dedicated runner EC2, not on a shared or production server.

4.4 — Start and verify the runner

sudo gitlab-runner start
sudo gitlab-runner status
# Expected: gitlab-runner: Service is running

sudo gitlab-runner list
# Expected: healthpulse-runner   Executor=docker  Token=glrt-...

Go to SettingsCI/CDRunners in GitLab. The runner should show a green circle (online).

4.5 — Give the runner access to Docker

sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner

# Verify
sudo -u gitlab-runner docker ps
# Expected: empty table (no error)

Step 5: Configure CI/CD Variables

5.1 — Open the Variables settings

Go to your CI repoSettingsCI/CD → expand Variables → click Add variable.

5.2 — Variables required for the CI pipeline

Security scanning:

KeyValueProtectedMasked
SONAR_TOKENYour SonarQube user tokenYesYes
SONAR_HOST_URLhttp://<SONARQUBE_IP>:9000NoNo
SNYK_TOKENYour Snyk API tokenYesYes

Artifactory upload:

KeyValueProtectedMasked
ARTIFACTORY_URLhttp://<JCR_IP>:8082/artifactoryNoNo
ARTIFACTORY_USERhealthpulse-deployerNoNo
ARTIFACTORY_PASSWORDJCR access tokenYesYes

GitLab registry — nothing to add. $CI_REGISTRY, $CI_REGISTRY_USER, and $CI_REGISTRY_PASSWORD are injected automatically by GitLab into every pipeline.

Variables for CD components (CD_REPO_TOKEN, CD_REPO_URL, Ansible Tower variables) are added in the CD guide. You do not need them yet.

5.3 — What Protected and Masked mean

Protected — the variable is only injected on protected branches (e.g. main, develop). Use this for tokens and passwords.

Masked — the value is replaced with [MASKED] in job logs. Always mask passwords and tokens.

5.4 — Where to get your Snyk token

  1. Go to app.snyk.ioAccount Settings
  2. Under Auth Token, click Click to show
  3. Copy the token and paste it as SNYK_TOKEN in GitLab

Never call snyk auth in CI. It opens a browser OAuth flow and hangs in headless environments. The SNYK_TOKEN environment variable is the correct approach — the Snyk CLI picks it up automatically.


Step 6: Understanding the .gitlab-ci.yml

Read this section before creating the file. It explains every concept you will use.

What is it and where does it live?

It is a YAML file at the root of your CI repo. GitLab reads it automatically on every push and uses it to decide what pipeline to run.

healthpulse-ci/            ← your CI repo root
├── src/
├── docker/
│   ├── Dockerfile
│   └── nginx.conf
├── tests/
├── .gitlab-ci.yml         ← here, at the root
└── package.json

The Dockerfile — what it is and what it does

The pipeline has two separate steps that together produce the Docker image:

build-app (node:24-alpine)           docker-publish (docker:29.4.3-dind)
──────────────────────────           ───────────────────────────────────
npm run build                   →    docker build -f docker/Dockerfile
produces dist/ ─────────────────►    Dockerfile copies dist/ into Nginx
                  artifact

build-app runs npm run build inside a Node container. Vite compiles your React/TypeScript source into optimised static files in dist/. That folder is uploaded as a GitLab artifact.

docker-publish receives the dist/ artifact, then runs docker build. The Dockerfile packages those pre-built static files into a lightweight Nginx image.

Create docker/Dockerfile in your CI repo:

# =============================================================
#  HealthPulse Portal — Dockerfile
#  Serves the pre-compiled React app via Nginx.
#
#  The dist/ folder is built by the 'build-app' CI job and
#  passed here as a GitLab artifact. This image just packages
#  and serves it — no Node.js needed at runtime.
# =============================================================

FROM nginx:1.27-alpine

# Remove the default Nginx welcome page
RUN rm -rf /usr/share/nginx/html/*

# Copy the compiled React app from the CI build-app artifact
COPY dist/ /usr/share/nginx/html

# Copy the custom Nginx config (handles React Router client-side routing)
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

Create docker/nginx.conf in your CI repo:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    # React Router — serve index.html for all routes
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache static assets aggressively
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Do not cache index.html — always serve the latest version
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Health check endpoint — used by Kubernetes liveness probe
    location /health {
        return 200 'OK';
        add_header Content-Type text/plain;
    }
}

Commit both files before creating the pipeline:

mkdir -p docker
# create docker/Dockerfile and docker/nginx.conf with the content above
git add docker/Dockerfile docker/nginx.conf
git commit -m "feat: add Dockerfile and Nginx config for React app"
git push origin develop

Top-level structure

variables:      # Global variables available to all jobs
  KEY: value

stages:         # Order stages run in
  - install
  - test

job-name:       # A job definition
  stage: test
  script:
    - echo "hello"

Anatomy of a job

job-name:
  stage: test              # Which stage this job belongs to
  image: node:24-alpine    # Docker image — the container for this job
  needs: [install]         # Jobs that must finish before this one starts
  variables:               # Job-level variables (override globals)
    MY_VAR: value
  before_script:           # Runs before script — use for setup (login, installs)
    - npm config set ...
  script:                  # The actual commands — this is the job's work
    - npm run test
  artifacts:               # Files to keep and pass to later jobs
    paths:
      - coverage/
    expire_in: 7 days
  cache:                   # Files to reuse between pipeline runs
    paths:
      - node_modules/
  services:                # Sidecar containers (e.g. Docker daemon)
    - docker:29.4.3-dind
  allow_failure: false     # If true, pipeline stays green even if this job fails

Key concepts explained

image — The Docker image that becomes the container for this job. It provides the OS and pre-installed tools. Each job can use a different image.

needs — Creates a dependency. The job waits for those jobs to finish and downloads their artifacts before starting. Without needs:, jobs in the same stage run in parallel.

artifacts — Files uploaded to GitLab after the job. Any job that needs: this job can download them. This is how dist/ travels from build-app to docker-publish.

cache — Files persisted between pipeline runs (not between jobs). Use for node_modules/ so npm ci does not re-download packages on every push.

services — Sidecar containers that run alongside the job. docker:29.4.3-dind starts a Docker daemon the job's Docker CLI can talk to — this is what Docker-in-Docker means.

allow_failure — Controls whether pipeline fails if this job fails. false (default) means a failing job stops the pipeline. true means the job is informational — it reports findings but does not block.

YAML anchors — avoiding repetition

.node_cache: &node_cache       # Define once
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/

install:
  <<: *node_cache              # Merge in — same as writing it out
  script:
    - npm ci

&node_cache defines the anchor. *node_cache references it. <<: merges it into the job. This prevents repeating the same cache config in every job.

How each scan job works

gitleaks

The runner pulls the gitleaks image, starts a container, and GitLab automatically clones your repo into it before your script: runs. The full scan — all files and commit history — happens entirely inside the container.

entrypoint: [""] is required because the gitleaks image sets its default entrypoint to the gitleaks binary. Without overriding it, Docker would pass your script: lines as arguments to gitleaks instead of shell commands — breaking GitLab's wrapper.

needs: [] means it starts the moment the scan stage begins with no dependencies.

sonarqube

Same container mechanism, but needs: [unit-tests] does two things: waits for the job to finish and downloads its artifacts. That is how coverage/lcov.info arrives in this container.

The sonar-scanner CLI is a client — it reads your source files and coverage report, then ships everything to your SonarQube server ($SONAR_HOST_URL). The actual analysis runs on the server. -Dsonar.qualitygate.wait=true tells the scanner to keep polling the server until it returns a pass or fail result before the job exits.

snyk-security

The node:24-alpine image does not contain the Snyk CLI. That is why npx snyk is used. npx is the Node Package Runner — it checks node_modules/.bin/snyk first, then downloads Snyk from npm on the fly if not found.

needs: [install] brings node_modules/ into this container so Snyk can see the exact resolved version of every dependency and transitive dependency — not just the version ranges in package.json. Exact versions are what Snyk checks against its vulnerability database.

JobImage contains the tool?How the tool arrives
gitleaks✅ Yes — the image is gitleaksBuilt into the image
sonarqube✅ Yes — baked into CLI imagePre-installed in the image
snyk-security❌ No — plain Node imagenpx downloads it at runtime

Source code scans vs image scan — why you need both

The three scans above all run before the Docker image is built. They look at your source code and your node_modules/ dependencies. They cannot see what is inside the final image.

The image scan runs after docker-publish. It pulls the image that was just pushed to the registry and scans everything inside it — including the OS packages in nginx:1.27-alpine. These are completely different from your npm dependencies.

Source code scans (gitleaks, sonarqube, snyk)
  └── What they see: your .ts files, node_modules/, git history
  └── What they miss: Alpine Linux packages, Nginx binaries, OpenSSL in the image

Image scan (Trivy)
  └── What it sees: every layer of the built Docker image
  └── Catches: CVEs in nginx, Alpine libc, OpenSSL, libcrypto — anything in the base image
  └── Does not care about: your TypeScript source code

A real-world example: your source code could be perfectly clean, but nginx:1.27-alpine could ship with a version of libssl that has a known CVE. Only an image scan catches that.

Trivy (by Aqua Security) is the de-facto standard for container image scanning. It is free, fast, and runs entirely inside the container — no external service needed. It downloads its vulnerability database at runtime from Aqua's public DB.

image-scan:
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  needs: [docker-publish]
  variables:
    TRIVY_USERNAME: $CI_REGISTRY_USER      # Trivy uses these to pull the image
    TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD  # from the GitLab registry for scanning
  script:
    - trivy image --exit-code 1 --severity HIGH,CRITICAL
        --no-progress --format table
        --output trivy-results.txt
        $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION

--exit-code 1 means Trivy exits with code 1 (fail) if it finds any HIGH or CRITICAL CVEs — blocking the pipeline. --severity HIGH,CRITICAL ignores LOW and MEDIUM findings. You can tighten or loosen this threshold as your team's policy dictates.

TRIVY_USERNAME and TRIVY_PASSWORD are set to GitLab's auto-injected registry credentials so Trivy can pull the image it needs to scan. Without these, Trivy cannot authenticate with a private registry.


Step 7: Create the .gitlab-ci.yml

Before you create the pipeline file — create .gitleaks.toml

The gitleaks job references a .gitleaks.toml config file. If this file does not exist in your repo, the job will fail immediately with:

FTL unable to load gitleaks config, err: open .gitleaks.toml: no such file or directory

Create .gitleaks.toml at the root of your repo (same level as .gitlab-ci.yml, not inside the app subfolder):

# .gitleaks.toml
# Gitleaks configuration for HealthPulse CI pipeline.
# This file must exist at the repo root.
# The gitleaks job scans the entire repo from root — not the app subfolder —
# which is why it uses before_script: [] to override the default cd into healthpulse-app.

title = "HealthPulse Gitleaks Config"

[extend]
# Use gitleaks' built-in default ruleset.
# These rules detect AWS keys, GitHub tokens, private keys, connection strings, etc.
useDefault = true

# Add allowlist entries here to suppress false positives.
# Example — suppress a specific file:
# [[allowlists]]
# description = "Ignore test fixtures"
# paths = ["tests/fixtures/sample-data.json"]

Commit it before pushing .gitlab-ci.yml:

# Create the file at repo root (not inside healthpulse-app/)
git add .gitleaks.toml
git commit -m "feat: add gitleaks config"
git push origin develop

Why before_script: [] on the gitleaks job?

The default: before_script in your pipeline does cd healthpulse-app — which moves every job into the app subfolder. But gitleaks should scan the entire repository (all files, all commit history), not just the app subfolder. Setting before_script: [] on the gitleaks job overrides the default for that job only, keeping it at the repo root where it belongs.


In the root of your CI repo, create .gitlab-ci.yml with the following content.

⚠️ Where this file must live

GitLab always runs CI jobs from the repository root — the directory that contains the .git/ folder. It does not matter where .gitlab-ci.yml itself is; the working directory for every job is always the repo root.

If your app code is inside a subfolder (e.g. healthpulse-app/), npm ci will fail because it cannot find package-lock.json at the root. Fix this by adding default: before_script: [cd healthpulse-app] at the top of the file and prefixing all artifact paths with healthpulse-app/. See the Troubleshooting section for the full fix.

The cleanest setup is a single-app repo where all source files are at the root — no subdirectory needed.

Option A — on your local machine:

cd ~/projects/healthpulse-ci    # adjust to wherever you cloned it
touch .gitlab-ci.yml
# open in your editor, paste the content below, save

Option B — GitLab web editor:

  1. Go to your CI repo on GitLab
  2. Click +New file
  3. File name: .gitlab-ci.yml
  4. Paste the content below

# =============================================================
#  HealthPulse Portal — GitLab CI Pipeline (CI only)
#  Repo: CI repo (source code)
#  Flow: Install → Test → Scan → Build → Publish
#
#  CD components (Kustomize overlay updates, Argo CD sync)
#  are added in the next guide after this pipeline is green.
# =============================================================

# ─────────────── GLOBAL VARIABLES ───────────────
variables:
  APP_NAME: healthpulse-portal
  NODE_IMAGE: node:24-alpine

# ─────────────── PIPELINE STAGES ───────────────
stages:
  - install        # npm ci — download dependencies
  - test           # lint + unit tests (parallel)
  - scan           # gitleaks + sonarqube + snyk (parallel)
  - build          # npm run build — compile the React app
  - publish        # docker build + push + artifactory upload (parallel)
  - scan-image     # trivy — scan the built Docker image for OS-level CVEs

# ─────────────── YAML ANCHOR — node cache ───────────────
.node_cache: &node_cache
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/

# =============================================================
# STAGE: INSTALL
# Downloads npm dependencies and caches them.
# Uploads node_modules/ as an artifact so later jobs get it
# without repeating the install.
# =============================================================
install:
  stage: install
  image: $NODE_IMAGE
  <<: *node_cache
  script:
    - npm ci
  artifacts:
    paths:
      - node_modules/
    expire_in: 1 hour

# =============================================================
# STAGE: TEST
# lint and unit-tests run in parallel — both need node_modules.
# unit-tests uploads the coverage report as an artifact for
# SonarQube to use in the scan stage.
# =============================================================
lint:
  stage: test
  image: $NODE_IMAGE
  needs: [install]
  script:
    - npm run lint

unit-tests:
  stage: test
  image: $NODE_IMAGE
  needs: [install]
  script:
    - npm run test:coverage
  artifacts:
    when: always       # Upload coverage even if tests fail (useful for debugging)
    paths:
      - coverage/
    reports:
      junit: test-results/*.xml
    expire_in: 7 days
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'

# =============================================================
# STAGE: SCAN
# Three scans run in parallel after tests pass.
#
# gitleaks:      checks for secrets committed to the repo
# sonarqube:     code quality + coverage gate
# snyk-security: dependency vulnerability scan
#
# gitleaks and sonarqube are blocking (allow_failure: false).
# snyk is informational (allow_failure: true).
# =============================================================
gitleaks:
  stage: scan
  image:
    name: ghcr.io/gitleaks/gitleaks:v8.30.1
    entrypoint: [""]    # Override image entrypoint so GitLab can run its own shell script
  before_script: []     # Override default — gitleaks must scan from REPO ROOT, not the app subfolder
  needs: []             # No dependency — runs immediately when scan stage starts
  script:
    - gitleaks detect --source . --config .gitleaks.toml --verbose
  allow_failure: false  # Pipeline fails if secrets are found

sonarqube:
  stage: scan
  image:
    name: sonarsource/sonar-scanner-cli:12.1
    entrypoint: [""]
  needs: [unit-tests]   # Needs coverage report from unit-tests artifacts
  variables:
    SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"   # Cache scanner rules/plugins
  script:
    - sonar-scanner
      -Dsonar.projectKey=$APP_NAME
      -Dsonar.projectName="HealthPulse Portal"
      -Dsonar.sources=src
      -Dsonar.exclusions="**/test/**,**/node_modules/**"
      -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
      -Dsonar.host.url=$SONAR_HOST_URL
      -Dsonar.token=$SONAR_TOKEN
      -Dsonar.qualitygate.wait=true   # Block pipeline until quality gate result
  allow_failure: false

snyk-security:
  stage: scan
  image: $NODE_IMAGE
  needs: [install]      # Needs node_modules for accurate dependency tree scanning
  variables:
    # SNYK_TOKEN is set as a CI/CD Variable (masked + protected).
    # The Snyk CLI picks it up automatically — no 'snyk auth' call needed in CI.
    # 'snyk auth' opens a browser OAuth flow and will hang in headless environments.
    SNYK_TOKEN: $SNYK_TOKEN
  script:
    - npx snyk test --severity-threshold=high --json > snyk-results.json || true
    - npx snyk monitor --project-name=$APP_NAME
  artifacts:
    paths:
      - snyk-results.json
    expire_in: 30 days
  allow_failure: true   # Snyk findings are reported but do not block the pipeline

# =============================================================
# STAGE: BUILD
# Compiles the React/TypeScript app to static files in dist/.
# Waits for lint and unit-tests to both pass before building.
# The dist/ folder is uploaded as an artifact for docker-publish.
# =============================================================
build-app:
  stage: build
  image: $NODE_IMAGE
  needs: [install, lint, unit-tests]
  script:
    - npm run build
  artifacts:
    paths:
      - dist/
    expire_in: 7 days

# =============================================================
# STAGE: PUBLISH
# Two jobs run in parallel:
#
# docker-publish:     builds the Docker image and pushes to
#                     GitLab's built-in container registry.
#                     Also tags as :latest for convenience.
#
# artifactory-upload: uploads the compiled dist/ folder to
#                     JFrog Artifactory for traceability.
#
# BUILD_VERSION format: {pipeline-number}-{git-sha}
# Example: 42-a1b2c3d — unique, traceable, sortable
# =============================================================
docker-publish:
  stage: publish
  image: docker:29.4.3-dind
  services:
    - docker:29.4.3-dind    # Starts the Docker daemon this job will use
  needs: [build-app, sonarqube]
  variables:
    BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
  before_script:
    # This job defines its own before_script (docker login), so the default
    # 'cd healthpulse-app' does NOT run here. The job runs from repo root.
    # $CI_REGISTRY_PASSWORD is a short-lived token GitLab generates per job.
    - echo "${CI_REGISTRY_PASSWORD}" | docker login -u "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"
  script:
    # $CI_REGISTRY_IMAGE = full registry path including namespace + project
    #   e.g. registry.gitlab.com/your-group/your-project
    # Do NOT use $CI_REGISTRY here — that is only the hostname (registry.gitlab.com)
    #   and GitLab will deny the push with "access denied" if the namespace is missing.
    #
    # -f healthpulse-app/docker/Dockerfile  — path to Dockerfile from repo root
    # healthpulse-app/                       — build context set to app subfolder
    #   This means COPY dist/ and COPY docker/nginx.conf inside the Dockerfile
    #   resolve relative to healthpulse-app/ — where the build-app artifact landed.
    #   If your app is at repo root (no subfolder), use: -f docker/Dockerfile .
    - docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/
    # Push the versioned tag — this is what Argo CD will deploy later
    - docker push $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
    # Also tag and push :latest — useful for manual pulls
    - docker tag $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION $CI_REGISTRY_IMAGE/$APP_NAME:latest
    - docker push $CI_REGISTRY_IMAGE/$APP_NAME:latest

artifactory-upload:
  stage: publish
  image: releases-docker.jfrog.io/jfrog/jfrog-cli-v2-jf:2.103.0
  needs: [build-app]
  variables:
    BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
  script:
    - jfrog rt upload "dist/**" "healthpulse-builds/$BUILD_VERSION/"
      --url=$ARTIFACTORY_URL
      --user=$ARTIFACTORY_USER
      --password=$ARTIFACTORY_PASSWORD
      --build-name=$APP_NAME
      --build-number=$BUILD_VERSION

# =============================================================
# STAGE: SCAN-IMAGE
# Scans the built Docker image for OS-level CVEs using Trivy.
# Runs AFTER docker-publish — it pulls the image from the
# registry and scans every layer including the base OS packages.
#
# This is separate from the source code scans (gitleaks, snyk)
# which cannot see inside the Docker image. Trivy catches CVEs
# in nginx, Alpine Linux, OpenSSL — anything in the base image.
#
# TRIVY_USERNAME / TRIVY_PASSWORD — GitLab's auto-injected
# registry credentials. Trivy needs these to pull the private
# image from the GitLab Container Registry for scanning.
# =============================================================
image-scan:
  stage: scan-image
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  needs: [docker-publish]
  variables:
    BUILD_VERSION: "${CI_PIPELINE_IID}-${CI_COMMIT_SHORT_SHA}"
    TRIVY_USERNAME: $CI_REGISTRY_USER      # Authenticate with GitLab registry
    TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD  # to pull the image for scanning
  script:
    # Scan the image — fail pipeline on any HIGH or CRITICAL CVE
    # --exit-code 1     → exit with failure if findings match severity filter
    # --severity        → only report HIGH and CRITICAL (ignore LOW/MEDIUM)
    # --no-progress     → cleaner CI log output
    # --format table    → human-readable table output
    # --output          → save report to file (uploaded as artifact)
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --no-progress
        --format table
        --output trivy-results.txt
        $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
    - cat trivy-results.txt
  artifacts:
    when: always       # Save report even if scan fails — useful for review
    paths:
      - trivy-results.txt
    expire_in: 30 days
  allow_failure: false  # Pipeline fails if HIGH or CRITICAL CVEs are found

Step 8: Push and Watch Your First Pipeline

8.1 — Commit and push

git add .gitlab-ci.yml
git commit -m "feat: add GitLab CI pipeline"
git push origin develop

8.2 — Watch the pipeline run

Go to your CI repo → CI/CDPipelines. A pipeline appears within a few seconds.

Click on it to see the stage graph. Jobs turn orange (running) and then green (passed) or red (failed) in real time.

Expected run order:

install
   └─► lint (parallel with unit-tests)
   └─► unit-tests (parallel with lint)
          └─► sonarqube (waits for coverage)
   └─► gitleaks (starts immediately)
   └─► snyk-security (waits for node_modules)
          └─► build-app (waits for lint + unit-tests)
                 └─► docker-publish (parallel with artifactory-upload)
                 └─► artifactory-upload (parallel with docker-publish)
                        └─► image-scan (waits for docker-publish)

8.3 — Read job logs

Click any job to see its full log output. Useful things to look for:

JobWhat a successful log looks like
installadded N packages — npm finished
lintNo output or N problems found
unit-testsN tests passed with coverage summary
gitleaksNo leaks found
sonarqubeQuality Gate status: PASSED
build-appdist/ built in Ns
docker-publishdigest: sha256:... — image pushed
image-scanTotal: 0 (HIGH: 0, CRITICAL: 0) — no CVEs found

Step 9: When CI Catches Real Code Issues

This is CI working correctly — not a CI configuration problem.

Your job as a DevOps engineer is to understand what CI caught, communicate it clearly to the development team, and keep the pipeline moving. On a real project you would raise these as tickets for the developers to fix. For this lab, the fixes are provided below as ready-to-paste file replacements — copy them over and move on.


What CI caught and why it matters

The lint job failed with 5 problems (3 errors, 2 warnings):

src/components/ui/input.tsx:4       error    Empty interface — does nothing
src/hooks/use-toast.ts:14           error    Variable declared but only used as a type
tests/unit/utils.test.ts:10         error    Constant in && always evaluates the same way
src/components/ui/badge.tsx:39      warning  File mixes component and non-component exports
src/components/ui/button.tsx:55     warning  File mixes component and non-component exports

The unit-tests job failed with coverage below threshold:

Lines:      25.33%  (required: 80%)
Functions:  53.84%  (required: 80%)
Statements: 25.33%  (required: 80%)
Branches:   64.70%  (required: 80%)

What this means in plain terms:

The development team shipped code with TypeScript/ESLint violations and an insufficient test suite. These are standard code quality gates that exist on every professional project. CI caught them before deployment — which is exactly what it is designed to do. On a live production system, these issues would have reached users.


Option A — Skip the fixes for now (temporary bypass)

If you want to see the rest of the pipeline run without applying the code fixes yet, add allow_failure: true to the failing jobs in your .gitlab-ci.yml.

When a job has allow_failure: true, GitLab marks it with an orange warning instead of a red failure — the pipeline continues through all remaining stages as if the job passed.

lint:
  stage: test
  image: $NODE_IMAGE
  needs: [install]
  script:
    - npm run lint
  allow_failure: true   # ← pipeline continues even if lint errors are found

unit-tests:
  stage: test
  image: $NODE_IMAGE
  needs: [install]
  script:
    - npm run test:coverage
  allow_failure: true   # ← pipeline continues even if coverage threshold is not met
  artifacts:
    when: always
    paths:
      - coverage/
    reports:
      junit: test-results/*.xml
    expire_in: 7 days
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'

Push this change and the pipeline will run all the way through to image-scan.

This is a temporary measure only. On a real project, allow_failure: true on a lint or test job means you have consciously accepted that broken code can proceed to build and deploy. It is used while fixes are being worked on — not as a permanent state. Remove it once the code fixes below are applied.


Option B — Apply the permanent fixes

The rest of this step provides complete replacement files for every issue CI found. Apply these when you are ready to close the gaps properly.


Fix 1 — src/components/ui/input.tsx

What CI caught: An empty interface (InputProps) that declares no members. It is identical to its parent type and adds no value. ESLint rule: @typescript-eslint/no-empty-object-type.

Go to: src/components/ui/input.tsx Replace the entire file with:

import * as React from "react";
import { cn } from "@/lib/utils";

export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        ref={ref}
        {...props}
      />
    );
  },
);
Input.displayName = "Input";

export { Input };

What changed: Line 4 — interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} became type InputProps = React.InputHTMLAttributes<HTMLInputElement>. One word change, same runtime behaviour.


Fix 2 — src/hooks/use-toast.ts

What CI caught: actionTypes is declared as a runtime constant but is only ever used in TypeScript type positions (typeof actionTypes). The JavaScript value is bundled and shipped to users but never actually executed. ESLint rule: @typescript-eslint/no-unused-vars.

Go to: src/hooks/use-toast.ts Replace the entire file with:

import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";

const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;

type ToasterToast = ToastProps & {
  id: string;
  title?: React.ReactNode;
  description?: React.ReactNode;
  action?: ToastActionElement;
};

const actionTypes = {
  ADD_TOAST: "ADD_TOAST",
  UPDATE_TOAST: "UPDATE_TOAST",
  DISMISS_TOAST: "DISMISS_TOAST",
  REMOVE_TOAST: "REMOVE_TOAST",
} as const;

let count = 0;

function genId() {
  count = (count + 1) % Number.MAX_SAFE_INTEGER;
  return count.toString();
}

type ActionType = typeof actionTypes;

type Action =
  | { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
  | { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
  | { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
  | { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };

interface State {
  toasts: ToasterToast[];
}

const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();

const addToRemoveQueue = (toastId: string) => {
  if (toastTimeouts.has(toastId)) return;

  const timeout = setTimeout(() => {
    toastTimeouts.delete(toastId);
    dispatch({ type: actionTypes.REMOVE_TOAST, toastId });
  }, TOAST_REMOVE_DELAY);

  toastTimeouts.set(toastId, timeout);
};

export const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case actionTypes.ADD_TOAST:
      return {
        ...state,
        toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
      };
    case actionTypes.UPDATE_TOAST:
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === action.toast.id ? { ...t, ...action.toast } : t,
        ),
      };
    case actionTypes.DISMISS_TOAST: {
      const { toastId } = action;
      if (toastId) {
        addToRemoveQueue(toastId);
      } else {
        state.toasts.forEach((t) => addToRemoveQueue(t.id));
      }
      return {
        ...state,
        toasts: state.toasts.map((t) =>
          t.id === toastId || toastId === undefined
            ? { ...t, open: false }
            : t,
        ),
      };
    }
    case actionTypes.REMOVE_TOAST:
      if (action.toastId === undefined) return { ...state, toasts: [] };
      return {
        ...state,
        toasts: state.toasts.filter((t) => t.id !== action.toastId),
      };
  }
};

const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };

function dispatch(action: Action) {
  memoryState = reducer(memoryState, action);
  listeners.forEach((listener) => listener(memoryState));
}

function toast({ ...props }: Omit<ToasterToast, "id">) {
  const id = genId();
  const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id });

  dispatch({
    type: actionTypes.ADD_TOAST,
    toast: {
      ...props,
      id,
      open: true,
      onOpenChange: (open) => {
        if (!open) dismiss();
      },
    },
  });

  return {
    id,
    dismiss,
    update: (props: ToasterToast) =>
      dispatch({ type: actionTypes.UPDATE_TOAST, toast: { ...props, id } }),
  };
}

function useToast() {
  const [state, setState] = React.useState<State>(memoryState);

  React.useEffect(() => {
    listeners.push(setState);
    return () => {
      const index = listeners.indexOf(setState);
      if (index > -1) listeners.splice(index, 1);
    };
  }, [state]);

  return {
    ...state,
    toast,
    dismiss: (toastId?: string) =>
      dispatch({ type: actionTypes.DISMISS_TOAST, toastId }),
  };
}

export { useToast, toast };

What changed: All dispatch calls and switch cases now use actionTypes.ADD_TOAST, actionTypes.DISMISS_TOAST etc. instead of string literals. This makes actionTypes a runtime value, not just a type reference — the linter error disappears.


Fix 3 — tests/unit/utils.test.ts

What CI caught: The test at line 10 uses false && "hidden" directly in the function call. false is a constant — it can never be anything other than false, so the && always produces false. ESLint rule: no-constant-binary-expression. This means the test was not actually testing what the developer intended.

Go to: tests/unit/utils.test.ts Replace the entire file with:

import { describe, it, expect } from "vitest";
import { cn, formatDate, getInitials } from "@/lib/utils";

describe("cn", () => {
  it("merges class names", () => {
    expect(cn("foo", "bar")).toBe("foo bar");
  });

  it("handles conditional classes", () => {
    const isHidden = false;
    expect(cn("base", isHidden && "hidden", "visible")).toBe("base visible");
  });

  it("merges tailwind conflicts correctly", () => {
    expect(cn("px-4", "px-6")).toBe("px-6");
  });
});

describe("formatDate", () => {
  it("formats a date string", () => {
    const result = formatDate("2026-03-14");
    expect(result).toContain("Mar");
    expect(result).toContain("14");
    expect(result).toContain("2026");
  });

  it("formats a Date object", () => {
    const result = formatDate(new Date("2026-01-01"));
    expect(result).toContain("Jan");
  });
});

describe("getInitials", () => {
  it("returns initials from full name", () => {
    expect(getInitials("Sarah Johnson")).toBe("SJ");
  });

  it("handles single name", () => {
    expect(getInitials("Sarah")).toBe("S");
  });

  it("limits to two characters", () => {
    expect(getInitials("Sarah Marie Johnson")).toBe("SM");
  });
});

What changed: Line 10 — false && "hidden" became const isHidden = false assigned on the previous line, then isHidden && "hidden" used in the call. The test now tests real conditional logic. Same runtime behaviour, no more lint error.


Fix 4 — src/components/ui/badge.tsx

What CI caught: The file exports both Badge (a React component) and badgeVariants (a styling function). React's Fast Refresh — the feature that hot-reloads components during development without losing state — cannot reliably handle files that mix component and non-component exports. ESLint rule: react-refresh/only-export-components.

Go to: src/components/ui/badge.tsx Replace the entire file with:

/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive:
          "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
        outline: "text-foreground",
        success:
          "border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200",
        warning:
          "border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return (
    <div className={cn(badgeVariants({ variant }), className)} {...props} />
  );
}

export { Badge, badgeVariants };

What changed: /* eslint-disable react-refresh/only-export-components */ added at the top. This is a deliberate, documented suppression — badgeVariants must be exported from this file because other components import it. Moving it to a separate file would break imports across the app. The disable comment makes the exception explicit and visible in code review.


Fix 5 — src/components/ui/button.tsx

What CI caught: Same issue as badge.tsx — the file exports both Button (a React component) and buttonVariants (a styling function). ESLint rule: react-refresh/only-export-components.

Go to: src/components/ui/button.tsx Replace the entire file with:

/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  },
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  },
);
Button.displayName = "Button";

export { Button, buttonVariants };

What changed: /* eslint-disable react-refresh/only-export-components */ added at the top. Same reason as badge.tsxbuttonVariants is used throughout the app and cannot be moved.


Fix 6 — vitest.config.ts (coverage threshold)

What CI caught: The test suite only covers 25% of the codebase. The config requires 80%. Every page component (Dashboard.tsx, Appointments.tsx, Login.tsx, etc.) has 0% test coverage — meaning no automated verification exists for the core user-facing screens.

Reading the coverage report:

File               | % Stmts | % Lines  ← what CI measured
Dashboard.tsx      |       0 |       0  ← zero tests for this page
Appointments.tsx   |       0 |       0  ← zero tests for this page
Login.tsx          |       0 |       0  ← zero tests for the login screen
api.ts             |     100 |     100  ← well tested
use-auth-store.ts  |   94.73 |   94.73  ← well tested

What this means for production: If a developer breaks the Dashboard page, CI would not catch it. It would deploy. Users would see a broken health portal.

The correct fix is for the development team to write tests for every page component. That is developer work and takes time.

Temporary fix for this lab — lower the thresholds in vitest.config.ts to match the current actual coverage so the pipeline can pass while tests are being written:

Go to: vitest.config.ts Replace the entire file with:

import path from "path";
import { defineConfig } from "vitest/config";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./tests/unit/setup.ts"],
    include: ["tests/unit/**/*.{test,spec}.{ts,tsx}"],
    reporters: ["default", "junit"],
    outputFile: {
      junit: "./test-results/junit.xml",
    },
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/main.tsx",
        "src/vite-env.d.ts",
        "src/**/*.d.ts",
        "src/components/ui/**",
      ],
      thresholds: {
        // TODO: raise these back to 80% once page component tests are written
        // Current actual coverage: lines 25%, functions 53%, branches 64%
        branches: 60,
        lines: 20,
        functions: 50,
        statements: 20,
      },
    },
  },
});

What changed:

  • Thresholds lowered to just below current actual coverage — pipeline passes, but the gap is documented in a TODO comment so it is visible to everyone
  • reporters: ["default", "junit"] and outputFile added — this generates the JUnit XML file that GitLab uses to display the Tests tab on each pipeline run

Summary — what to copy and where

FileWhat to do
src/components/ui/input.tsxReplace full file — Fix 1 above
src/hooks/use-toast.tsReplace full file — Fix 2 above
tests/unit/utils.test.tsReplace full file — Fix 3 above
src/components/ui/badge.tsxReplace full file — Fix 4 above
src/components/ui/button.tsxReplace full file — Fix 5 above
vitest.config.tsReplace full file — Fix 6 above

After replacing all six files, commit and push:

git add src/components/ui/input.tsx \
        src/hooks/use-toast.ts \
        tests/unit/utils.test.ts \
        src/components/ui/badge.tsx \
        src/components/ui/button.tsx \
        vitest.config.ts
git commit -m "fix: resolve CI lint errors and lower coverage thresholds"
git push origin main

The lint and unit-tests jobs will pass. The pipeline will continue to the scan and build stages.


Step 10: Verify the Image in GitLab Container Registry

After docker-publish completes, navigate to your image in the GitLab sidebar:

Your CI repo (left sidebar)
└── Deploy
    └── Container Registry   ← your images live here

Can't find it? Older GitLab versions show it under Packages and registries → Container Registry instead. If neither appears, go to Settings → General → Visibility, project features, permissions and confirm Container Registry is enabled for the project.

  1. Go to your CI repo → DeployContainer Registry
  2. You should see:
healthpulse-portal
  latest       pushed just now    registry.gitlab.com/your-group/healthpulse-ci:latest
  42-a1b2c3d   pushed just now    registry.gitlab.com/your-group/healthpulse-ci:42-a1b2c3d

Pull the image to verify it runs

On any machine with Docker installed and access to GitLab:

# Log in
echo "<your-personal-access-token>" | docker login registry.gitlab.com \
  -u <your-gitlab-username> --password-stdin

# Pull the versioned image
docker pull registry.gitlab.com/<your-group>/healthpulse-ci:latest

# Run it locally
docker run -p 8080:80 registry.gitlab.com/<your-group>/healthpulse-ci:latest

Open http://localhost:8080 in your browser. You should see the HealthPulse Portal.

If the image runs correctly locally, your Dockerfile and Nginx config are working. This is the same image that will be deployed to Kubernetes in the CD guide.


Step 11: Verify Artifactory Upload

After artifactory-upload completes:

  1. Open http://<JCR_IP>:8082 in your browser
  2. Log in to JFrog Artifactory
  3. Go to ArtifactoryArtifacts
  4. Browse to healthpulse-builds/<pipeline-number>-<sha>/

You should see the contents of dist/ uploaded there — index.html, JS bundles, CSS, assets.


What's Next — Adding CD Components

Now that CI is green and your image is in the registry, you are ready to add the CD pipeline.

The CD guide covers:

  • Creating and configuring the CD repo (healthpulse-cd)
  • Adding CD_REPO_TOKEN and CD_REPO_URL variables
  • Adding update-dev-manifest stage — CI commits the new image tag to the CD repo
  • Adding update-uat-manifest stage — runs on release/* branches
  • Connecting Argo CD to watch the CD repo
  • The [skip ci] rules pattern that prevents an infinite loop
  • Prod promotion via merge request — no CI stage writes to overlays/prod/

Do not add CD stages to this pipeline yet. Complete the CD guide as a separate step. Mixing them before CI is stable makes debugging significantly harder.


Acceptance Criteria

Before marking this step complete, verify every item:

  •  GitLab Runner installed on EC2, Docker executor, privileged mode enabled
  •  Runner shows green (online) in SettingsCI/CDRunners
  •  All CI/CD Variables configured: SONAR_TOKEN, SONAR_HOST_URL, SNYK_TOKEN, ARTIFACTORY_URL, ARTIFACTORY_USER, ARTIFACTORY_PASSWORD
  •  docker/Dockerfile and docker/nginx.conf committed to the CI repo
  •  .gitlab-ci.yml committed to the root of the CI repo
  •  Push to develop triggers a pipeline
  •  Lint errors resolved — no eslint errors in the codebase (or allow_failure: true applied temporarily with a fix ticket open)
  •  Coverage threshold met — or threshold intentionally lowered with a plan to increase it
  •  All six stages pass: install → test → scan → build → publish → scan-image
  •  Docker image appears in DeployContainer Registry tagged <iid>-<sha>
  •  Image tagged latest also present
  •  Image runs locally and serves the HealthPulse Portal on port 8080
  •  Build artefacts visible in JFrog Artifactory under healthpulse-builds/<version>/
  •  image-scan job passes — trivy-results.txt artifact downloadable from the job
  •  Trivy report shows Total: 0 (HIGH: 0, CRITICAL: 0) or all findings are acknowledged

Troubleshooting

Runner is offline (grey circle in GitLab)

sudo gitlab-runner status
sudo gitlab-runner start

sudo gitlab-runner verify
sudo journalctl -u gitlab-runner -f

install fails — "package-lock.json not found" even though the file exists in the repo

This happens when your app lives inside a subfolder of the repository (e.g. healthpulse-app/) rather than at the root.

GitLab always runs CI jobs from the repository root, not from the folder where .gitlab-ci.yml lives. So npm ci runs at /repo-root/ — but package-lock.json is at /repo-root/healthpulse-app/package-lock.json. GitLab cannot find it.

You can confirm this by checking the commit message shown in the job's right panel:

Update healthpulse-app/.gitlab-ci.yml file   ← subfolder is the giveaway

Three ways to fix it — pick one:

Option 1 — Recommended: default: before_script

Add this at the very top of .gitlab-ci.yml, before variables::

default:
  before_script:
    - cd healthpulse-app    # replace with your actual subfolder name

This runs cd healthpulse-app automatically before every job. Important: script: and before_script: run inside the cd'd directory, but artifact and cache paths are always resolved from the repo root. You must prefix every artifact and cache path:

# cache anchor
.node_cache: &node_cache
  cache:
    key:
      files:
        - healthpulse-app/package-lock.json
    paths:
      - healthpulse-app/node_modules/

# install
artifacts:
  paths:
    - healthpulse-app/node_modules/

# unit-tests
artifacts:
  paths:
    - healthpulse-app/coverage/
  reports:
    junit: healthpulse-app/test-results/*.xml

# build-app
artifacts:
  paths:
    - healthpulse-app/dist/

# snyk-security
artifacts:
  paths:
    - healthpulse-app/snyk-results.json

# image-scan
artifacts:
  paths:
    - healthpulse-app/trivy-results.txt

If you see WARNING: node_modules/: no matching files in the install log — this is exactly the cause. The cd changed your script directory but GitLab is still looking for the path from the repo root.

Option 2 — Per-job: add cd to each script

Add cd healthpulse-app as the first line of every job's script: block. More repetitive but explicit.

Option 3 — Best long-term: move app files to the repo root

If the repo contains only this one app, move everything out of the subfolder to the root. No cd needed anywhere and the pipeline works without any changes:

repo-root/
├── .gitlab-ci.yml
├── package.json
├── package-lock.json
├── src/
└── docker/

Jobs fail with "Cannot connect to the Docker daemon" or "permission denied on docker.sock"

ERROR: Preparation failed: getting docker info: permission denied while trying to connect
to the Docker daemon socket at unix:///var/run/docker.sock: dial unix /var/run/docker.sock:
connect: permission denied

or

Cannot connect to the Docker daemon at unix:///var/run/docker.sock

Both errors mean the gitlab-runner user cannot access the Docker socket. This happens on your EC2 runner — not on shared runners.

Two possible causes:

  1. Docker is not installed — run Step 3.2
  2. gitlab-runner is not in the docker group (most common):
sudo usermod -aG docker gitlab-runner
sudo systemctl restart gitlab-runner

# Verify — should return an empty table, not a permission error
sudo -u gitlab-runner docker ps

If you also see ERROR: Failed to remove network for build — this is a leftover from a previous failed job where Docker couldn't clean up. It clears automatically once the permission issue is resolved and the runner can connect to Docker again.

docker-publish fails with "permission denied" on docker socket

The runner was not registered with --docker-privileged. Re-register:

sudo gitlab-runner unregister --name "healthpulse-runner"

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --token "glrt-..." \
  --executor "docker" \
  --docker-image "alpine:latest" \
  --docker-privileged \
  --description "healthpulse-runner"

sonarqube job fails — "Could not connect to SonarQube server"

  1. Check SONAR_HOST_URL is set correctly (no trailing slash, correct port)
  2. Check the SonarQube server is running: curl http://<SONARQUBE_IP>:9000/api/system/status
  3. Check the security group on the SonarQube EC2 allows inbound TCP 9000 from the runner EC2

gitleaks fails — "unable to load gitleaks config, no such file or directory"

FTL unable to load gitleaks config, err: open .gitleaks.toml: no such file or directory

The .gitleaks.toml config file does not exist in your repo. The job references it with --config .gitleaks.toml but the file was never committed.

Fix: Create .gitleaks.toml at the repo root and commit it. See Step 7 above for the full file content.

# Quick check — does the file exist at repo root?
ls .gitleaks.toml

Also check your gitleaks job has before_script: [] — without this, the default: before_script will cd healthpulse-app before gitleaks runs, making it look for the config inside the subfolder and scan only that folder instead of the whole repo:

gitleaks:
  stage: scan
  image:
    name: ghcr.io/gitleaks/gitleaks:v8.30.1
    entrypoint: [""]
  before_script: []    # ← required — overrides the default cd into healthpulse-app
  needs: []
  script:
    - gitleaks detect --source . --config .gitleaks.toml --verbose
  allow_failure: false

gitleaks fails — real secrets found

This is the correct behaviour. Gitleaks found actual secrets in your code or commit history. You must:

  1. Rotate the exposed credential immediately
  2. Remove the secret from the codebase
  3. If it is in git history, you need to rewrite history with git filter-repo
  4. Add the pattern to .gitleaks.toml to allowlist false positives

unit-tests pass locally but fail in CI

Common causes:

  • Tests depend on environment variables not set in CI — add them as CI/CD Variables
  • Tests use Date.now() or timezone-sensitive functions — CI runner timezone may differ
  • Test imports use absolute paths that work locally but fail in Alpine container

docker-publish fails — "pnpm-lock.yaml: not found" or "package.json: not found"

ERROR: failed to calculate checksum of ref ...: "/pnpm-lock.yaml": not found

Your Dockerfile is a multi-stage build that tries to compile the app from source inside Docker:

FROM node:20-alpine AS build   ← compiling inside Docker
COPY pnpm-lock.yaml package.json ./
RUN pnpm install --frozen-lockfile
RUN pnpm build

This conflicts with the CI pipeline design. The build-app CI job already compiled the app and produced dist/ as an artifact. The docker-publish job receives that dist/ folder and Docker's only job is to package it into an Nginx image — no Node.js, no compilation needed.

Using a multi-stage build here means:

  • Docker tries to re-compile the app from scratch inside the container
  • It looks for pnpm-lock.yaml (or package.json) in the build context, which only contains the dist/ artifact — not the full source
  • The build fails because the source files are not there

Fix: Replace your Dockerfile with the simple packaging version:

FROM nginx:1.27-alpine

RUN rm -rf /usr/share/nginx/html/*

# Copy the pre-compiled dist/ from the build-app CI artifact
COPY dist/ /usr/share/nginx/html

# Copy Nginx config
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

This Dockerfile has no build stage — it just takes the already-compiled dist/ and serves it via Nginx. The CI pipeline handles compilation; Docker handles packaging.

If your project uses pnpm instead of npm: The CI pipeline also needs updating. Change npm cipnpm install --frozen-lockfile and npm run buildpnpm build in the install and build-app jobs. But the Dockerfile issue above still applies — keep the single-stage packaging Dockerfile regardless of which package manager you use.

docker-publish fails — "dist: not found" or "CopyIgnoredFile: dist excluded by .dockerignore"

CopyIgnoredFile: Attempting to Copy file "dist" that is excluded by .dockerignore (line 6)
ERROR: failed to build: failed to solve: failed to compute cache key: "/dist": not found

The dist/ folder is listed in .dockerignore, so Docker strips it from the build context before the build starts. When the Dockerfile then tries to COPY dist/ /usr/share/nginx/html, there is nothing to copy.

Having dist in .dockerignore is normal practice for local development — it stops Docker from accidentally picking up a stale locally-compiled build. But in CI, dist/ is the artifact the build-app job specifically produced and that Docker needs to package.

Fix: Remove the dist line from .dockerignore:

# Open healthpulse-app/.dockerignore in your editor
# Delete the line that says:  dist  or  dist/
# Save and commit

git add healthpulse-app/.dockerignore
git commit -m "fix: remove dist from .dockerignore for CI artifact packaging"
git push origin main

The CI pipeline always produces a clean dist/ from the build-app job — removing it from .dockerignore is safe because Docker is never run locally with stale files in this workflow.

docker-publish fails — "lstat docker: no such file or directory"

ERROR: failed to build: resolve : lstat docker: no such file or directory

Docker cannot find docker/Dockerfile because it is looking from the repo root, but your Dockerfile lives inside the app subfolder at healthpulse-app/docker/Dockerfile.

This job defines its own before_script (for docker login), so the default cd healthpulse-app does not run — the job always runs from the repo root.

Fix: Update the docker build command to prefix both the -f path and the build context with the subfolder:

# Before (fails if Dockerfile is in a subfolder):
- docker build -f docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION .

# After (works with subfolder setup):
- docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/

Setting the build context to healthpulse-app/ means the COPY dist/ and COPY docker/nginx.conf commands inside the Dockerfile resolve correctly without any changes to the Dockerfile itself.

docker-publish fails — "denied: requested access to the resource is denied"

denied: requested access to the resource is denied

The image was built successfully but the push was rejected. This almost always means the image tag is using $CI_REGISTRY instead of $CI_REGISTRY_IMAGE.

Check the image name logged before the push. If it looks like:

registry.gitlab.com/healthpulse-portal:10-abc1234

it is missing the namespace and project path. GitLab has no record of that registry path and rejects the push.

It should look like:

registry.gitlab.com/your-group/your-project/healthpulse-portal:10-abc1234

Fix: Replace $CI_REGISTRY with $CI_REGISTRY_IMAGE in all three docker commands:

script:
  - docker build -f healthpulse-app/docker/Dockerfile -t $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION healthpulse-app/
  - docker push $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION
  - docker tag $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION $CI_REGISTRY_IMAGE/$APP_NAME:latest
  - docker push $CI_REGISTRY_IMAGE/$APP_NAME:latest
VariableContainsUse for
$CI_REGISTRYregistry.gitlab.comdocker login only
$CI_REGISTRY_IMAGEregistry.gitlab.com/group/projectImage tags — build, push, pull

docker-publish fails — image push rejected (wrong credentials)

denied: access forbidden

Check that $CI_REGISTRY_PASSWORD is being passed correctly. The --password-stdin flag expects it from stdin, not as a positional argument:

- echo "${CI_REGISTRY_PASSWORD}" | docker login -u "${CI_REGISTRY_USER}" --password-stdin "${CI_REGISTRY}"

image-scan fails — "unable to find image" / "UNAUTHORIZED" pulling from registry

FATAL  run error: image scan error: unable to initialize artifact: unable to find the specified image
  docker error: failed to connect to the docker API at unix:///var/run/docker.sock
  remote error: GET https://registry.gitlab.com/v2/healthpulse-portal/manifests/...: UNAUTHORIZED

Two things look alarming here but only one is the real problem:

The Docker socket errors are expected and harmless. Trivy tries local Docker first (/var/run/docker.sock), then containerd, then podman. On GitLab shared runners none of these are available. Trivy then falls back to pulling the image directly from the registry (remote mode). This is normal — ignore those socket errors.

The UNAUTHORIZED error is the real problem. Look at the registry path in the error:

registry.gitlab.com/v2/healthpulse-portal/manifests/...

The namespace is missing — it should be registry.gitlab.com/v2/your-group/your-project/healthpulse-portal/manifests/.... Trivy is attempting to pull an image path that doesn't exist in your registry.

Cause: The image-scan job is using $CI_REGISTRY/$APP_NAME instead of $CI_REGISTRY_IMAGE/$APP_NAME in the trivy command.

Fix:

image-scan:
  script:
    - trivy image
        --exit-code 1
        --severity HIGH,CRITICAL
        --no-progress
        --format table
        --output trivy-results.txt
        $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION   # ← not $CI_REGISTRY

The "no matching files" artifact warning is a consequence, not a separate problem. Trivy exits with an error before writing trivy-results.txt, so there is nothing to upload. Once the image reference is corrected and Trivy completes the scan, the file will be written and the artifact will upload.

image-scan fails — CVEs found in the image

INFO  Detected OS  family="alpine" version="3.21.3"
INFO  [alpine] Detecting vulnerabilities...  pkg_num=68
trivy-results.txt: found 1 matching artifact files   ← scan completed and uploaded
ERROR: Job failed: exit code 1

This is the pipeline working correctly. Trivy scanned the image, found HIGH or CRITICAL CVEs in the base OS packages, and exited with code 1 as configured by --exit-code 1. The report was uploaded as an artifact.

This is what DevOps is for. CI caught a security issue in the base image before it reached any environment. On a real project, this becomes a ticket: "Upgrade nginx base image — Trivy found CVE-XXXX-XXXX (HIGH) in libssl3."

Step 1 — Read the report:

Go to the image-scan job → Browse (top right of job page) → download trivy-results.txt. It shows a table:

nginx:1.27-alpine (alpine 3.21.3)
Library     Vulnerability   Severity   Installed   Fixed Version
────────────────────────────────────────────────────────────────
libssl3     CVE-2024-XXXX   HIGH       3.3.1-r0    3.3.2-r0
libcrypto3  CVE-2024-XXXX   CRITICAL   3.3.1-r0    3.3.2-r0

Step 2 — Pick a resolution:

Option A — Update the base image (the correct fix):

# Use the latest nginx alpine to get newest OS security patches
FROM nginx:alpine

Commit, push, let the pipeline rebuild and re-scan. If the CVE has a fixed version available, the newer patch release will include it.

Option B — Temporarily allow failures while the fix is being worked on:

image-scan:
  allow_failure: true   # CVEs found — acknowledged, fix tracked in ticket #XX

Option C — Lower the severity gate if no fix exists yet (Trivy shows "fixed version: none"):

# Only block on CRITICAL, not HIGH — document the reason
- trivy image --exit-code 1 --severity CRITICAL
    --no-progress --format table --output trivy-results.txt
    $CI_REGISTRY_IMAGE/$APP_NAME:$BUILD_VERSION

Always add a comment explaining why the threshold was changed and link to the tracking ticket.

image-scan fails — "unauthorized" pulling image from registry

Trivy cannot authenticate with the GitLab Container Registry. Check that TRIVY_USERNAME and TRIVY_PASSWORD are set correctly in the job variables:

variables:
  TRIVY_USERNAME: $CI_REGISTRY_USER
  TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD

These are GitLab-injected variables — they should always be available. If the job runs on an unprotected branch and your registry is set to "Private", confirm the runner has access to the project.

artifactory-upload fails — authentication error

  1. Verify ARTIFACTORY_URL does not have a trailing slash
  2. Verify the user has deploy permissions on the repository in JFrog
  3. Test the credentials manually: curl -u $ARTIFACTORY_USER:$ARTIFACTORY_PASSWORD $ARTIFACTORY_URL/api/system/ping

GITLAB CI

TASK J: GitLab CI — CI Pipeline Guide Overview This guide gets your Continuous Integration (CI) pipeline running end-to-end. You will compl...