-
Notifications
You must be signed in to change notification settings - Fork 6
544 lines (498 loc) · 21.6 KB
/
Copy pathci.yml
File metadata and controls
544 lines (498 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# CI Workflow - Build, Test, Lint, Security
#
# This workflow runs on every pull request and push to main/develop branches.
# It performs comprehensive quality checks before allowing code to be merged.
#
# Required GitHub Secrets: None (all checks run without cloud credentials)
#
# Triggered by:
# - Pull requests to main/develop
# - Pushes to main/develop
# - Manual workflow dispatch
name: CI - Build & Test
permissions:
contents: read
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
workflow_dispatch:
jobs:
lint:
name: Lint Code
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Install golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
with:
version: v2.10.1
install-only: true
- name: Run golangci-lint
run: |
set -euo pipefail
export GOLANGCI_LINT_CACHE="$HOME/.cache/golangci-lint"
for mod in .; do
echo "==> golangci-lint in $mod"
(cd "$mod" && golangci-lint config verify && golangci-lint run --timeout=10m)
done
- name: Run go vet
run: |
set -euo pipefail
for mod in .; do
echo "==> go vet in $mod"
(cd "$mod" && go vet ./...)
done
- name: Install gocyclo
run: go install github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0
- name: Check cyclomatic complexity
run: |
echo "Checking for functions with cyclomatic complexity over 10..."
# Exclude _test.go files to stay consistent with .golangci.yml, which
# excludes gocyclo on test files (test helpers/table-driven tests are
# allowed higher complexity). Production code is still gated at >10.
COMPLEXITY_ISSUES=$(gocyclo -over 10 -ignore "_test\.go" . 2>&1 || true)
if [ -n "$COMPLEXITY_ISSUES" ]; then
echo "❌ Found functions with cyclomatic complexity over 10:"
echo "$COMPLEXITY_ISSUES"
echo ""
echo "⚠️ Please refactor these functions to reduce complexity."
echo "📖 Tip: Extract helper functions, use early returns, or simplify logic."
exit 1
fi
echo "✅ All functions have acceptable cyclomatic complexity (≤10)"
workflow-lint:
name: Lint Workflows
runs-on: ubuntu-latest
permissions:
contents: read
env:
# Pinned by digest, not only by tag. A tag is mutable, and a linter whose
# ruleset changes without a change in this repo turns main red on its own
# schedule -- the hadolint :latest failure in #1695.
ACTIONLINT_IMAGE: 'rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667'
ZIZMOR_VERSION: '1.29.0'
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Assert there are workflows to lint
run: |
set -euo pipefail
count=$(find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d ' ')
# Defence in depth, not the only guard: both linters do exit 3 on an
# empty input set. This catches the case they cannot, where the path
# still resolves but the set silently shrinks, and it prints the count
# so a drop is visible in the log rather than inferred from silence.
if [ "$count" -eq 0 ]; then
echo "::error::no workflow files found under .github/workflows"
exit 1
fi
echo "Linting $count workflow files"
- name: Assert shellcheck is available to actionlint
# actionlint does not fail when shellcheck is missing from PATH: it
# silently skips every run: block and still exits 0. That silent skip is
# the failure mode this job exists to close, so assert the binary is
# present rather than trusting the image to keep bundling it.
run: |
set -euo pipefail
docker run --rm --entrypoint sh "$ACTIONLINT_IMAGE" -c '
command -v shellcheck >/dev/null || {
echo "::error::shellcheck is not present in the actionlint image; shell linting would be silently skipped"
exit 1
}
shellcheck --version | sed -n "1,3p"'
- name: Run actionlint
# No file arguments: actionlint discovers .github/workflows itself, so
# it also covers .yaml files and any workflow added later. Passing an
# explicit *.yml glob would silently skip a .yaml workflow that the
# count step above still counts.
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo "$ACTIONLINT_IMAGE" -color
- name: Run zizmor
# --offline on purpose: the online audits query the GitHub API for action
# metadata, so findings could change without a change in this repo and
# redden main, the same class of failure the hadolint digest pin fixed.
#
# Coverage note: zizmor reads the directory non-recursively, while
# actionlint walks it. A workflow under .github/workflows/sub/ would
# therefore reach actionlint but not zizmor. GitHub itself ignores
# workflows in subdirectories, so this is not a live hole, and the
# -maxdepth 1 count above fails loud if the set ever moves down a level.
#
# Two independent filters, and it matters which does what.
#
# --persona=pedantic rather than the default regular: regular hides
# three high-severity findings this repo actually had (workflow-level
# id-token: write in both sanity workflows, and an unpinned postgres
# service image). Those are fixed rather than filtered, so the stricter
# persona costs nothing today and gates more. auditor is the one level
# up and is documented as accepting false positives, so it is not used.
#
# --min-severity=medium is a threshold, not a suppression: no baseline
# file, no per-finding ignore, no only-new-issues. Every medium and
# high finding the pedantic persona surfaces fails this job, and the
# injection class this job exists to catch scores high. Below the line
# sit 106 findings, none above low: 66 template-injection on values
# #1649 already assessed as non-injectable (github.actor, github.sha
# and similar), plus undocumented-permissions, concurrency-limits and
# anonymous-definition. Clearing those means rewriting the deploy
# workflows, so they are left to a follow-up rather than silenced here.
run: |
set -euo pipefail
# `pipx run --spec` rather than `pipx install`: it pins the version in
# the same statement that invokes it and does not assume pipx's bin
# directory is on PATH.
pipx run --spec "zizmor==${ZIZMOR_VERSION}" zizmor \
--offline --persona=pedantic --min-severity=medium \
--color=always .github/workflows/
# Unit tests with race detection
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Download dependencies
run: |
set -e
for mod in .; do
echo "==> go mod download/verify in $mod"
(cd "$mod" && go mod download && go mod verify)
done
- name: Run unit tests (all modules)
run: |
set -uo pipefail
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
log="$RUNNER_TEMP/unit-${tag}.log"
echo "==> unit tests in $mod"
# Profiles go to $RUNNER_TEMP, not the checkout: never leave working
# files in the repository root (repo coding guideline).
if ! (cd "$mod" && go test -v -race -short \
-coverprofile="$RUNNER_TEMP/coverage-${tag}.out" \
-covermode=atomic ./...) 2>&1 | tee "$log"; then
echo "::error::unit tests failed in $mod"
status=1
fi
# A module that runs zero tests is issue #1751 wearing a new
# costume: the loop would "cover" it while asserting nothing. Count
# top-level results (subtest lines are indented) and fail loudly.
ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true)
echo "==> $mod ran $ran top-level test(s)"
if [ "$ran" -eq 0 ]; then
echo "::error::$mod ran zero tests -- it is in the loop but asserting nothing"
status=1
fi
done
shopt -s nullglob
profiles=("$RUNNER_TEMP"/coverage-*.out)
if [ "${#profiles[@]}" -eq 0 ]; then
echo "::error::no module produced a coverage profile" >&2
exit 1
fi
merged="$RUNNER_TEMP/coverage.out"
head -n 1 "${profiles[0]}" > "$merged"
for p in "${profiles[@]}"; do
tail -n +2 "$p" >> "$merged"
done
echo "Merged ${#profiles[@]} coverage profile(s), $(( $(wc -l < "$merged") - 1 )) block(s)"
exit "$status"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ${{ runner.temp }}/coverage.out
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
- name: Generate coverage report
run: |
go tool cover -html="$RUNNER_TEMP/coverage.out" -o "$RUNNER_TEMP/coverage.html"
- name: Upload coverage artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: coverage-report
path: |
${{ runner.temp }}/coverage.out
${{ runner.temp }}/coverage.html
retention-days: 30
- name: Check coverage threshold
run: |
coverage=$(go tool cover -func="$RUNNER_TEMP/coverage.out" | grep total | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${coverage}%"
if (( $(echo "$coverage < 80" | bc -l) )); then
echo "::warning::Coverage is below 80% (current: ${coverage}%)"
fi
integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
cache: true
- name: Run integration tests
run: |
set -uo pipefail
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
log="$RUNNER_TEMP/integration-${tag}.log"
echo "==> integration tests in $mod"
if ! (cd "$mod" && go test -v -race -tags=integration \
-coverprofile="$RUNNER_TEMP/coverage-integration-${tag}.out" \
./...) 2>&1 | tee "$log"; then
echo "::error::integration tests failed in $mod"
status=1
fi
ran=$(grep -cE '^--- (PASS|FAIL|SKIP)' "$log" || true)
echo "==> $mod ran $ran top-level test(s)"
if [ "$ran" -eq 0 ]; then
echo "::error::$mod ran zero tests -- it is in the loop but asserting nothing"
status=1
fi
done
shopt -s nullglob
profiles=("$RUNNER_TEMP"/coverage-integration-*.out)
if [ "${#profiles[@]}" -eq 0 ]; then
echo "::error::no module produced an integration coverage profile" >&2
exit 1
fi
merged="$RUNNER_TEMP/coverage-integration.out"
head -n 1 "${profiles[0]}" > "$merged"
for p in "${profiles[@]}"; do
tail -n +2 "$p" >> "$merged"
done
echo "Merged ${#profiles[@]} integration coverage profile(s)"
exit "$status"
- name: Upload integration coverage
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: integration-coverage
path: ${{ runner.temp }}/coverage-integration.out
retention-days: 30
security-scan:
name: Security Scanning
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- name: Checkout code
id: checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
id: setup_go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Run govulncheck CVE scanner (all modules)
# Independent scanners still run after another scanner fails.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
run: |
# Pinned (not @latest): a govulncheck release with new
# detection logic could silently change the gate's verdict
# between PRs without an intentional bump in this repo.
# Bumping is a deliberate review item, not a drift.
go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
# Scan each owned module independently.
set -e
for mod in .; do
echo "==> govulncheck in $mod"
(cd "$mod" && govulncheck ./...)
done
- name: Run gosec Security Scanner
id: gosec
# always(): don't let an earlier scanner's failure (e.g. govulncheck)
# skip Go SAST coverage. The job still fails if gosec itself fails.
# Still requires checkout and Go setup to have succeeded.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
run: |
# Install pinned gosec using the job's existing setup-go.
# The securego/gosec Docker action bundles its own Go toolchain which
# cannot satisfy the module's go directive, causing a toolchain mismatch.
go install github.com/securego/gosec/v2/cmd/gosec@v2.28.0
# Scan each owned module independently.
set -e
status=0
for mod in .; do
tag=$(echo "$mod" | tr './' '--' | sed 's/^-/root/')
out="$RUNNER_TEMP/gosec-${tag}.sarif"
echo "==> gosec in $mod"
if ! (cd "$mod" && gosec -fmt sarif -out "$out" ./...); then
echo "::warning::gosec exited non-zero in $mod (findings or a scan error)"
status=1
fi
if [ ! -f "$out" ]; then
echo "::error::gosec produced no SARIF output for $mod"
status=1
continue
fi
# Code scanning rejects a SARIF file whose runs share a category
# (github.blog changelog 2025-07-21), so give each module's run a
# unique automationDetails.id before merging.
jq --arg id "gosec-${tag}/" '.runs |= map(.automationDetails = {id: $id})' \
"$out" > "$out.tmp" && mv "$out.tmp" "$out"
done
# Merge per-module SARIF runs into one file for the upload step.
# Only modules that actually produced a file are included; if
# gosec crashed before writing any of them, fail loud here rather
# than uploading an empty result silently.
shopt -s nullglob
sarif_files=("$RUNNER_TEMP"/gosec-*.sarif)
if [ "${#sarif_files[@]}" -eq 0 ]; then
echo "::error::no gosec SARIF output was produced by any module" >&2
exit 1
fi
# jq is preinstalled on the GitHub Ubuntu runner image (no new deps).
# Written to $RUNNER_TEMP, not the checkout root: never save working
# files in the repository root (repo coding guideline), and this
# file is purely a hand-off to the upload step below.
merged="$RUNNER_TEMP/gosec-results.sarif"
jq -s '{version: "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", runs: [.[].runs[]]}' \
"${sarif_files[@]}" > "$merged"
echo "Merged $(jq '.runs | length' "$merged") SARIF runs"
exit "$status"
- name: Upload gosec results to GitHub Security
# Tolerate a missing SARIF only when the gosec step itself never ran
# (e.g. checkout/setup-go failed, so gosec's own `if:` above skipped
# it). If gosec ran and the file is still missing, fail loud instead
# of masking it as a skip.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.gosec.outcome != 'skipped'
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: ${{ runner.temp }}/gosec-results.sarif
- name: Run Trivy vulnerability scanner (filesystem)
id: trivy_fs
# Filesystem findings remain advisory; scanner execution is required.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
# $RUNNER_TEMP, not the checkout root (repo coding guideline: never
# save working files in the repository root).
output: '${{ runner.temp }}/trivy-results.sarif'
severity: 'CRITICAL,HIGH'
# Keep the existing scanner binary pinned.
version: 'v0.72.0'
- name: Upload Trivy results to GitHub Security
# Tolerate a missing SARIF only when the Trivy fs step never ran.
if: always() && steps.checkout.outcome == 'success' && steps.setup_go.outcome == 'success' && steps.trivy_fs.outcome != 'skipped'
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: '${{ runner.temp }}/trivy-results.sarif'
snyk-scan:
name: Snyk Security Scan
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Run Snyk to check for vulnerabilities
uses: snyk/actions/golang@b98d498629f1c368650224d6d212bf7dfa89e4bf # 0.4.0
continue-on-error: true
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
cli-build:
name: Build CLI
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
- name: Build CLI
run: make build
- name: Check CLI help
run: ./cudly --help
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs:
- lint
- workflow-lint
- unit-tests
- integration-tests
- security-scan
- cli-build
if: always()
permissions:
contents: read
steps:
- name: Check all jobs
# Allowlist on success instead of denylisting 'failure' and
# 'cancelled'. A job that never dispatched reports 'skipped', and the
# denylist form reported that as a pass -- so a gate could satisfy this
# summary by not running at all, which is the same shape as the scanner
# gap in #1836. Anything that is not exactly 'success' now fails, and
# names itself in the log.
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
# jq is preinstalled on the GitHub Ubuntu runner image (no new deps).
not_success="$(jq -r 'to_entries[]
| select(.value.result != "success")
| " \(.key): \(.value.result)"' <<<"$NEEDS_JSON")"
if [[ -n "$not_success" ]]; then
echo "::error::not every required CI job succeeded"
echo "$not_success"
exit 1
fi
echo "All CI checks passed!"
- name: Post status
run: |
{
echo "## CI Status"
echo ""
echo "✅ All CI checks completed successfully!"
} >> "$GITHUB_STEP_SUMMARY"