1+ name : CI
2+
3+ # Unified CI pipeline.
4+ # Consolidates: Smoke Test (build), ESLint & Prettier (lint),
5+ # Jest Tests, Cypress E2E, and Security Scans into a single workflow,
6+ # plus commit-message linting (commitlint).
7+ #
8+ # Trigger note: uses `pull_request` (NOT pull_request_target) so untrusted
9+ # fork code is never executed with repository secrets or a write-scoped token.
10+ # See the Codecov note in ci.yml's `test` job re: fork PRs.
11+
12+ on :
13+ push :
14+ branches : [master]
15+ pull_request :
16+ branches : [master]
17+
18+ # One in-flight run per PR / ref; newer pushes cancel older runs.
19+ concurrency :
20+ group : ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
21+ cancel-in-progress : true
22+
23+ # Least privilege by default; individual jobs do not need more.
24+ permissions :
25+ contents : read
26+
27+ jobs :
28+ # ----------------------------------------------------------------------
29+ # Commit message linting (conventional commits)
30+ # ----------------------------------------------------------------------
31+ commitlint :
32+ name : Lint commit messages
33+ runs-on : ubuntu-latest
34+ timeout-minutes : 30
35+ steps :
36+ - name : Checkout code
37+ uses : actions/checkout@v4
38+ with :
39+ fetch-depth : 0
40+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
41+
42+ - name : Set up Node.js
43+ uses : actions/setup-node@v5
44+ with :
45+ node-version : ' 22'
46+ cache : ' npm'
47+
48+ - name : Install dependencies
49+ run : npm ci
50+
51+ - name : Lint PR commits
52+ if : github.event_name == 'pull_request'
53+ run : >-
54+ npx commitlint
55+ --from ${{ github.event.pull_request.base.sha }}
56+ --to ${{ github.event.pull_request.head.sha }}
57+ --verbose
58+
59+ # ----------------------------------------------------------------------
60+ # ESLint + Prettier on changed JS files only
61+ # ----------------------------------------------------------------------
62+ lint :
63+ name : Lint & format-check changed JS files
64+ runs-on : ubuntu-latest
65+ timeout-minutes : 30
66+ steps :
67+ - name : Checkout code
68+ uses : actions/checkout@v4
69+ with :
70+ fetch-depth : 0
71+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
72+
73+ - name : Set up Node.js
74+ uses : actions/setup-node@v5
75+ with :
76+ node-version : ' 22.x'
77+ cache : ' npm'
78+
79+ - name : Install dependencies
80+ run : npm ci
81+
82+ - name : Get changed JavaScript files
83+ id : get_files
84+ run : |
85+ if [ "${{ github.event_name }}" = "pull_request" ]; then
86+ BASE="${{ github.event.pull_request.base.sha }}"
87+ HEAD="${{ github.event.pull_request.head.sha }}"
88+ else
89+ BASE="${{ github.event.before }}"
90+ HEAD="${{ github.sha }}"
91+ fi
92+
93+ # Guard against the all-zero SHA on first push of a new branch.
94+ if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
95+ BASE="$(git rev-parse "$HEAD^" 2>/dev/null || echo "$HEAD")"
96+ fi
97+
98+ git diff --diff-filter=ACMRT --name-only "$BASE" "$HEAD" \
99+ -- '*.js' '*.mjs' ':!node_modules/**' > changed-js-files.txt || true
100+
101+ if [ -s changed-js-files.txt ]; then
102+ echo "has_files=true" >> "$GITHUB_OUTPUT"
103+ else
104+ echo "has_files=false" >> "$GITHUB_OUTPUT"
105+ fi
106+
107+ - name : Run ESLint on changed files
108+ if : steps.get_files.outputs.has_files == 'true'
109+ run : |
110+ echo "Linting the following files:"
111+ cat changed-js-files.txt
112+ xargs -r npx eslint < changed-js-files.txt
113+
114+ - name : Run Prettier check on changed files
115+ if : steps.get_files.outputs.has_files == 'true'
116+ run : |
117+ echo "Checking formatting for the following files:"
118+ cat changed-js-files.txt
119+ xargs -r npx prettier --check < changed-js-files.txt
120+
121+ # ----------------------------------------------------------------------
122+ # Build / smoke test across Node versions
123+ # ----------------------------------------------------------------------
124+ build :
125+ name : Build (Node ${{ matrix.node-version }})
126+ runs-on : ubuntu-latest
127+ timeout-minutes : 30
128+ strategy :
129+ fail-fast : false
130+ matrix :
131+ node-version : [20.x, 22.x]
132+ steps :
133+ - name : Checkout code
134+ uses : actions/checkout@v4
135+ with :
136+ fetch-depth : 0
137+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
138+
139+ - name : Use Node.js ${{ matrix.node-version }}
140+ uses : actions/setup-node@v5
141+ with :
142+ node-version : ${{ matrix.node-version }}
143+ cache : ' npm'
144+
145+ - name : Install dependencies
146+ run : npm ci
147+
148+ - name : Build project
149+ run : npm run build --if-present
150+
151+ # ----------------------------------------------------------------------
152+ # Jest unit tests + coverage threshold + Codecov upload
153+ # ----------------------------------------------------------------------
154+ test :
155+ name : Jest tests & coverage
156+ runs-on : ubuntu-latest
157+ timeout-minutes : 30
158+ steps :
159+ - name : Checkout code
160+ uses : actions/checkout@v4
161+ with :
162+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
163+
164+ - name : Setup Node.js
165+ uses : actions/setup-node@v5
166+ with :
167+ node-version : ' 22'
168+ cache : ' npm'
169+
170+ - name : Install dependencies
171+ run : npm ci
172+
173+ # Coverage thresholds are enforced inside Jest (coverageThreshold in
174+ # your Jest config), so this step fails the job if coverage drops
175+ # below the configured minimums.
176+ - name : Run Jest with coverage
177+ run : npm test -- --coverage --ci
178+
179+ # Upload to Codecov for the PR-vs-base coverage diff + status check.
180+ # NOTE: secrets (CODECOV_TOKEN) are NOT available to pull_request runs
181+ # originating from forks. The threshold enforcement above still runs
182+ # on every PR; only the upload is skipped on forks. fail_ci_if_error
183+ # is false so a flaky/absent upload never blocks the PR.
184+ - name : Upload coverage to Codecov
185+ uses : codecov/codecov-action@v5
186+ with :
187+ token : ${{ secrets.CODECOV_TOKEN }}
188+ files : ./coverage/lcov.info
189+ fail_ci_if_error : false
190+ verbose : true
191+
192+ # ----------------------------------------------------------------------
193+ # Cypress end-to-end tests
194+ # ----------------------------------------------------------------------
195+ e2e :
196+ name : Cypress E2E
197+ runs-on : ubuntu-latest
198+ timeout-minutes : 30
199+ steps :
200+ - name : Checkout code
201+ uses : actions/checkout@v4
202+ with :
203+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
204+
205+ - name : Setup Node.js
206+ uses : actions/setup-node@v5
207+ with :
208+ node-version : ' 22'
209+ cache : ' npm'
210+
211+ - name : Run Cypress E2E tests
212+ uses : cypress-io/github-action@v6
213+ with :
214+ install-command : npm ci
215+ start : npm start
216+ wait-on : ' http://127.0.0.1:3000'
217+ wait-on-timeout : 120
218+ browser : chrome
219+ config : video=false
220+
221+ - name : Upload Cypress screenshots on failure
222+ if : failure()
223+ uses : actions/upload-artifact@v4
224+ with :
225+ name : cypress-screenshots
226+ path : cypress/screenshots
227+ if-no-files-found : ignore
228+
229+ # ----------------------------------------------------------------------
230+ # Dependency security audit
231+ # (Branch trigger fixed to `master` here — the standalone file's `main`
232+ # trigger no longer applies once this job replaces it.)
233+ # ----------------------------------------------------------------------
234+ security :
235+ name : Security audit
236+ runs-on : ubuntu-latest
237+ timeout-minutes : 30
238+ steps :
239+ - name : Checkout code
240+ uses : actions/checkout@v4
241+ with :
242+ ref : ${{ github.event.pull_request.head.sha || github.sha }}
243+
244+ - name : Setup Node.js
245+ uses : actions/setup-node@v5
246+ with :
247+ node-version : ' 22'
248+
249+ - name : Run npm audit
250+ run : npm audit --omit=dev --audit-level=high
0 commit comments