Code that successfully builds isn't necessarily high quality and secure. In this episode we automate unit tests, integration tests, and coverage uploads to Codecov, then integrate ESLint, ShellCheck, and SonarQube to maintain quality. We also enable SAST with CodeQL and Dependabot so that vulnerabilities in code and dependencies are detected earlier.

In episode 15 we managed self-hosted runners and understood when we need them. Now we enter the most crucial phase of CI/CD: proving that the code is truly worthy of release. A successful build only proves the code compiles; quality, security, and runtime behavior are still untested. A good production pipeline must answer four questions: do the functions work (test), how widely are they tested (coverage), is the code clean (static analysis), and are there vulnerabilities (security scanning).
In this episode we discuss:
Imagine the pipeline like a factory production line. Each station has the task of inspecting a product before handing it to the next station. If one station passes without inspecting, defective products will keep flowing all the way to the customer. The same goes for the pipeline: every job is an inspection station that stops the flow the moment it finds a problem.
The ideal order: lint → unit test → coverage → static analysis → security scan. All run when a pull request is created, so problems are found before the code enters main.
The prerequisite is an npm script that produces a coverage report in a format readable by external tools. For a Node.js project with Vitest, for example:
{
"scripts": {
"test": "vitest run",
"test:coverage": "vitest run --coverage"
}
}The test:coverage command generates a coverage/lcov.info file plus an HTML report. Separate unit tests (fast, isolated, no network) from integration tests (using a real database or HTTP) for faster feedback — developers shouldn't wait for slow integration tests on every small commit.
The following workflow runs tests then uploads the coverage report to Codecov, which displays badges and can be configured to fail if coverage drops below a threshold:
name: Test & Coverage
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependensi
run: npm ci
- name: Jalankan unit & integration test
run: npm run test:coverage
- name: Upload coverage ke Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/lcov.info
fail_ci_if_error: trueA few important things:
npm ci performs a deterministic install from the lockfile — more reliable than npm install because the result is always identical.fail_ci_if_error: true makes the workflow fail if the coverage upload errors, so you never silently lose reports.coverallsapp/github-action@v2 with the same files.Tip
Set up branch protection on main that requires the test job to pass. That way tests and coverage become a mandatory gate before a pull request is merged.
Linters catch errors a compiler won't: unused variables, wrong conditions, or dangerous patterns. A separate workflow makes the results clear per job:
name: Lint & Static Analysis
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: ESLint
run: npx eslint . --max-warnings 0
shellcheck:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Jalankan ShellCheck
uses: ludeeus/action-shellcheck@masterThe --max-warnings 0 flag makes lint fail even on a single warning — the right standard for teams that want discipline. The shellcheck job uses a community action to check all *.sh scripts in the repository.
Linters check file by file; SonarQube adds cross-module analysis: code duplication, complexity, and reliability bugs. SonarCloud is its cloud version — free for open source projects and stores analysis results in the cloud without needing your own server:
name: SonarCloud Analysis
on:
pull_request:
jobs:
sonar:
runs-on: ubuntu-latest
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Analisis SonarCloud
uses: SonarSource/sonarcloud-scan-action@v5
with:
args: >
-Dsonar.organization=my-org
-Dsonar.projectKey=my-org_my-app
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}The scan results are posted as a comment on the pull request and counted as a check. If the quality gate in the SonarCloud dashboard fails, the pull request can be configured to fail.
Warning
Never write the Sonar token directly in a YAML file. All tokens are fetched through the secrets context, e.g., secrets.SONAR_TOKEN, not typed manually into the workflow.
CodeQL analyzes code statically (SAST) and looks for vulnerability patterns such as SQL injection, path traversal, or unsafe deserialization. Its official action needs three steps: init, autobuild, and analyze.
name: CodeQL Analysis
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 3 * * 1'
permissions:
security-events: write
contents: read
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
language: ['javascript-typescript']
steps:
- name: Checkout kode
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform analysis
uses: github/codeql-action/analyze@v3Important explanations:
security-events: write is needed so scan results can be written to the Security tab.schedule with cron 0 3 * * 1 (every Monday 03:00 UTC) runs a periodic full scan; on pull requests only the delta of changes is analyzed.autobuild guesses how to build the project; for languages it can't detect, replace it with a manual build step.The results appear in the Security → Code scanning tab as alerts, complete with severity, location, and fix recommendations.
Your code is safe, but its dependencies might not be. Dependabot monitors package managers (npm, pip, Maven, and others) and opens automatic pull requests when a version that fixes a CVE is available. Its configuration lives in .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
time: '09:00'
timezone: Asia/Jakarta
open-pull-requests-limit: 10
labels:
- dependencies
- security
ignore:
- dependency-name: '*'
update-types: ['version-update:semver-major']Dependabot distinguishes security updates (triggered by CVEs, high priority, not waiting for the schedule) from version updates (the routine schedule above). Both produce pull requests, so give them clear labels and require review before merging.
In this episode we built quality and security gates:
A pipeline that tests everything can feel frightening when an accident happens in production. In episode 17 we discuss environment protection rules & approval gateways — how to add manual approval and environment controls so production can't be deployed carelessly. See you there!