Use jscpd in CI
A pull request is the last point where a copy-paste is cheap to undo, so this is where most teams put the check. On a runner jscpd behaves like any other command: it scans the checkout, writes the reports you ask for and exits with a code the CI system reads. This page sets that up for GitHub Actions, GitLab CI and any runner that can run a shell command, and covers the three ways to decide what fails. The same check on the developer's machine is on the pre-commit page; a scheduled job that watches the trend is on the history page.
Choose the gate
| Gate | What fails the job | When to use it |
|---|---|---|
--threshold N | Duplication above N percent of the lines, strictly greater: 5.0 % passes --threshold 5 | One number for the whole tree, on a small or new codebase, or in a scheduled full scan |
A committed baseline, --baseline with --fail-on-new-clones | A clone whose fingerprint is not in .jscpd-baseline.json | The codebase has duplication you will not remove now and a pull request must not add more; one scan per run |
A baseline from a git ref, --baseline-from-ref origin/main --fail-on-new-clones | A clone that the base branch does not have | The same goal with nothing to commit; the job scans twice and needs the base ref fetched |
The gates add up: with --threshold and --fail-on-new-clones in one run, either one failing fails the job. --exit-code is the strictest form, exit 1 on any clone, and fits a fresh project. On fixtures/mcp-demo, three exact clones make 13 % of the lines, so a threshold of 10 trips:
jscpd . --threshold 10 --reporters ai
Clones:
src/ invoice.js:1-6 ~ print/invoice.js:1-6
src/ invoice.js:6-12 ~ print/invoice.js:7-13
src/ orders.js:1-12 ~ reports/orders.js:1-12
---
3 clones · 13.0% duplication
ERROR: jscpd found too many duplicates (13.0%) over threshold (10.0%)
The run exits 1. Exit codes lists every code and what controls it; the baseline guide walks through both baselines with the output of each step.
Minimal setup
GitHub Actions
name: Duplication
on: [push, pull_request]
jobs:
jscpd:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: kucherenko/jscpd@v5
with:
threshold: 5
The Action installs the release binary, through the installer on Linux and macOS runners and through npm on Windows, scans the repository with the default settings, prints the console report in the job log and fails the step when duplication is above 5 percent. Every input is a CLI flag of the same name, and the outputs (duplication-percentage, clones-found, exit-code and more) are there for the next steps of the job; the GitHub Action reference lists them all. @v5 follows the latest 5.x release; pin kucherenko/[email protected] when every run must use the same binary.
GitLab CI
jscpd:
stage: test
image: node:22
before_script:
- npm install -g jscpd@5
script:
- jscpd . --threshold 5 --reporters console,codeclimate,openmetrics --output report
artifacts:
reports:
codequality: report/gl-code-quality-report.json
metrics: report/jscpd-metrics.txt
paths:
- report/
The job installs jscpd from npm, fails on duplication above 5 percent and hands GitLab two files it knows how to read: the codeclimate report (alias gitlab) turns each clone into a Code Quality issue in the merge request, and the openmetrics report feeds the Metrics widget with the duplication numbers and their change against the target branch (both reporters since jscpd 5.1.0). Without Node on the runner, use the release image, image: { name: "ghcr.io/kucherenko/jscpd:5", entrypoint: [""] }; it holds only the static binary, so --blame and --baseline-from-ref, which run git, do not work inside it.
Any other runner
curl -fsSL https://jscpd.dev/install.sh | bash
jscpd . --threshold 5 --reporters console,sarif
The installer puts the binary into ~/.local/bin (--prefix changes that); npm install -g jscpd@5, pip install jscpd and docker run --rm -v "$PWD:/src" ghcr.io/kucherenko/jscpd:5 . are the other ways in, and the installation page has all of them, including PowerShell for Windows. The job fails through the exit code alone, so Jenkins, CircleCI, Azure Pipelines and Buildkite need nothing else.
Make it useful
Upload SARIF to code scanning
permissions:
contents: read
security-events: write
jobs:
jscpd:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: kucherenko/jscpd@v5
with:
reporters: console,sarif
threshold: 5
The Action uploads report/jscpd-report.sarif with github/codeql-action/upload-sarif when the file exists, so sarif has to be in reporters (the default, console, writes no file) and the job needs security-events: write; the upload step runs with continue-on-error, so a failed upload shows in its log and does not fail the job. Each clone becomes an alert with the counterpart location linked. Against a baseline, new clones arrive as error and the rest as warning, and --sarif-error-tokens N raises large clones to error as well; the SARIF reporter page has the rule ids. Outside the Action, run the same reporter and call upload-sarif yourself.
Fetch enough history
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: kucherenko/jscpd@v5
with:
baseline-from-ref: origin/${{ github.base_ref }}
fail-on-new-clones: true
actions/checkout fetches one commit by default. --baseline-from-ref needs the base branch's tree and --history every commit of its range, so give both fetch-depth: 0; github.base_ref is the target branch of the pull request. On GitLab, set GIT_DEPTH: "0" in the job's variables, or fetch the one branch you need and point jscpd at it: git fetch origin main && jscpd . --baseline-from-ref FETCH_HEAD --fail-on-new-clones.
Keep the run short
- uses: kucherenko/jscpd@v5
with:
format: typescript,tsx
ignore: "**/generated/**,**/__snapshots__/**"
version: "5.4.0"
jscpd scans a large repository in seconds, so most of a job's time is the checkout and the install. format limits the run to the languages you gate on, ignore keeps generated code out (inside a git checkout jscpd already skips what .gitignore lists), and --workers sets the thread count, which is automatic by default. version installs the same release on every run, and skip-install: true uses a binary your image already has. A semantic run is the one slow start: --semantic needs the 548 MB model, so keep the jscpd cache directory between runs, ~/.cache/jscpd on a Linux runner with actions/cache; the semantic clones guide has the cache step and the directories of the other systems.
Reference
- GitHub Action, every input and output, generated from
action.yml. - CLI options, every flag with its config key and default.
- Exit codes, what makes a run exit 0, 1 or 2.
- Reporters, the file each one writes.
Troubleshooting
The job is green although jscpd printed an error
A pipe hides the exit code: jscpd . | tee jscpd.log returns the code of tee. Put set -o pipefail at the top of the script, or write the log with a reporter (--reporters console,json) and drop the pipe. GitHub Actions turns pipefail on only when the step sets shell: bash explicitly; the default shell runs bash -e {0} without it. || true and continue-on-error: true hide the code as well.
git ref 'origin/main' not found
The checkout is shallow, so the ref that --baseline-from-ref names is not there; jscpd exits 1 and its message says to fetch it first. Set fetch-depth: 0 on actions/checkout, or fetch the branch in the step before and use FETCH_HEAD. --history needs the commits of its range present for the same reason.
Windows runners
The Action's Windows step installs with npm, so the runner needs Node.js; GitHub-hosted Windows images have it. If you run the curl installer yourself, make sure bash is Git Bash: when it resolves to WSL, the installer sees Linux, installs a Linux binary inside the WSL filesystem, and the PowerShell steps never find it. The PowerShell installer on the installation page is the native route.
Every run downloads the semantic model
The model lives in the jscpd cache directory, which a fresh runner does not have. Restore the directory with actions/cache before the scan; with the model in place, --semantic-download has nothing to fetch, and the vectors of unchanged functions come from the same cache.
A green job that scanned nothing
A wrong path, a --format that matches no file or an --ignore that swallows everything leaves an empty report and exit 0, with Warning: jscpd analyzed no files: check the paths and the --format, --ignore and --pattern filters in the log. --fail-on-empty (fail-on-empty: true in the Action, jscpd 5.2.1+) turns that into exit 1:
ERROR: jscpd analyzed no files (--fail-on-empty): check the paths and the --format, --ignore and --pattern filters
A path that does not exist, an unknown format name and a reporter that cannot write its file exit 1 on their own since jscpd 5.2.1.
Related
- Pre-commit hooks for the same check on the developer's machine.
- Fail only on new duplication for both baselines in detail, with the output of each step.
- Duplication over git history for a scheduled job that shows where the number is heading.
- Exit codes for every code and the flag behind it.