mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:31:33 +00:00
1caedd5fd3
Bundle: ci-pipeline-cleanup, Phase 1. Pure relocation — no behavior change. Each guard's bash logic is byte-identical to the prior inline version; the only changes are: (a) the guard becomes a sibling script under scripts/ci-guards/<id>.sh, (b) ci.yml's per-guard step is replaced by a single loop step that iterates all scripts. 20 scripts extracted (alphabetized): B-1-orphan-crud.sh, D-1-D-2-statusbadge-phantom.sh, G-1-jwt-auth-literal.sh, G-2-api-key-hash-json.sh, G-3-env-docs-drift.sh, H-001-bare-from.sh, H-009-readme-jwt.sh, L-001-insecure-skip-verify.sh, L-1-bulk-action-loop.sh, M-012-no-root-user.sh, P-1-documented-orphan-fns.sh, S-1-hardcoded-source-counts.sh, S-2-strings-contains-err.sh, T-1-frontend-page-coverage.sh, U-2-plaintext-healthcheck.sh, U-3-migration-mount.sh, bundle-8-L-015-target-blank-rel-noopener.sh, bundle-8-L-019-dangerously-set-inner-html.sh, bundle-8-M-009-bare-usemutation.sh, test-naming-convention.sh Plus scripts/ci-guards/README.md documenting the contract: - Each script must exit 0 on clean repo, non-zero with ::error:: prefix on regression - Runnable from repo root via 'bash scripts/ci-guards/<id>.sh' - Adding a new guard: drop a new <id>.sh; CI auto-picks it up ci.yml dropped 1488 → 557 lines (-931, -63%). Single CI loop step now collects ALL guard failures before failing the build instead of fail-fast — UX win for regressions that hit two guards at once. Two guards (QA-doc Part-count + seed-count, ci.yml lines 868-917) deliberately NOT extracted — they move to 'make verify-docs' in Phase 11 because they protect docs-the-operator-reads, not the product itself. Verification (sandbox): - All 20 scripts pass against HEAD (chmod +x; for g in scripts/ci-guards/*.sh; do bash $g; done) - New ci.yml YAML-parses cleanly - Job boundaries preserved: go-build-and-test, frontend-build, helm-lint, deploy-vendor-e2e, deploy-vendor-e2e-windows - Loop step appears twice (once at end of go-build-and-test, once at end of frontend-build) so both jobs continue running their set of guards
558 lines
28 KiB
YAML
558 lines
28 KiB
YAML
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches:
|
||
- master
|
||
- v2-dev
|
||
pull_request:
|
||
branches:
|
||
- master
|
||
|
||
jobs:
|
||
go-build-and-test:
|
||
name: Go Build & Test
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Set up Go
|
||
uses: actions/setup-go@v5
|
||
with:
|
||
go-version: '1.25.9'
|
||
|
||
- name: Go Build
|
||
run: |
|
||
go build ./cmd/server/...
|
||
go build ./cmd/agent/...
|
||
go build ./cmd/mcp-server/...
|
||
go build ./cmd/cli/...
|
||
|
||
- name: Go Vet
|
||
run: go vet ./...
|
||
|
||
- name: Install golangci-lint
|
||
run: |
|
||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4
|
||
|
||
- name: Run golangci-lint
|
||
run: golangci-lint run ./... --timeout 5m
|
||
|
||
- name: Install govulncheck
|
||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||
|
||
- name: Run govulncheck (M-024 hard gate)
|
||
# Bundle-7 / D-001 partial: govulncheck distinguishes called-vs-uncalled
|
||
# advisories. Default exit code is non-zero only when YOUR code calls
|
||
# the vulnerable function — deferred-call advisories show up in the
|
||
# output but don't fail the gate.
|
||
#
|
||
# Bundle F / Audit M-024 (NIST SSDF PW.7.2): the govulncheck step
|
||
# is now a hard CI gate (no `continue-on-error`). Bundle E's
|
||
# transitive bumps (x/net 0.42→0.47, x/crypto 0.41→0.45) cleared
|
||
# the 5 deferred-call advisories that were previously on the
|
||
# exception list, so the carve-out the original Bundle F prompt
|
||
# designed is unnecessary — a clean `govulncheck ./...` is the
|
||
# right gate. If a future advisory lands in a function our code
|
||
# does call, this step fails the build until either upstream
|
||
# ships a fix OR we cut the dep. Deferred-call advisories that
|
||
# legitimately can't be remediated yet should be added to the
|
||
# NIST SSDF deviation log in docs/security.md, not silenced here.
|
||
run: govulncheck ./...
|
||
|
||
- name: Install staticcheck (Bundle-7 / D-001)
|
||
run: go install honnef.co/go/tools/cmd/staticcheck@latest
|
||
|
||
- name: Run staticcheck
|
||
# Bundle-7 / D-001: Go static analysis additive to vet. Suppressed
|
||
# rules live in staticcheck.conf with documented justifications;
|
||
# adding a new entry requires an explicit security review.
|
||
#
|
||
# SOFT gate (continue-on-error: true) until M-028 closes the 6
|
||
# remaining SA1019 deprecated-API sites:
|
||
# - cmd/server/main_test.go × 3: middleware.NewAuth → NewAuthWithNamedKeys
|
||
# - internal/api/handler/scep.go: csr.Attributes → Extensions
|
||
# - internal/connector/issuer/local/local.go: elliptic.Marshal → crypto/ecdh
|
||
# When M-028 ships, flip continue-on-error to false to make this
|
||
# a hard gate. Until then, the step still annotates findings on PRs.
|
||
continue-on-error: true
|
||
run: staticcheck ./...
|
||
|
||
- name: Race Detection
|
||
run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s
|
||
|
||
- name: Go Test with Coverage
|
||
run: |
|
||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||
|
||
- name: Check Coverage Thresholds
|
||
run: |
|
||
# Extract per-package coverage from test output
|
||
echo "=== Coverage Report ==="
|
||
go tool cover -func=coverage.out | tail -1
|
||
|
||
# Check service layer coverage (target: 60%+)
|
||
SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Service layer coverage: ${SERVICE_COV}%"
|
||
|
||
# Check handler layer coverage (target: 60%+)
|
||
HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Handler layer coverage: ${HANDLER_COV}%"
|
||
|
||
# Check domain layer coverage (target: 40%+)
|
||
DOMAIN_COV=$(go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Domain layer coverage: ${DOMAIN_COV}%"
|
||
|
||
# Check middleware layer coverage (target: 50%+)
|
||
MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Middleware layer coverage: ${MIDDLEWARE_COV}%"
|
||
|
||
# Check crypto package coverage (target: 85%+)
|
||
# M-8 rationale: encryption primitives are a security-critical gate.
|
||
# v2 format, key-derivation, fallback, and fail-closed sentinel paths
|
||
# all need exhaustive coverage to avoid silent regressions (CWE-916 / CWE-329).
|
||
CRYPTO_COV=$(go tool cover -func=coverage.out | grep 'internal/crypto' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Crypto package coverage: ${CRYPTO_COV}%"
|
||
|
||
# Bundle-7 / Audit H-005 — extended crypto-cluster gates per CLAUDE.md.
|
||
# internal/pkcs7/ is at 100% at HEAD (encoder-only, exhaustively tested
|
||
# via Bundle-4 fuzz targets + unit tests). internal/connector/issuer/local/
|
||
# is at 68.3% at HEAD; H-010 tracks the gap and will lift this floor
|
||
# to 85% once the missing CSR-validation + CA-cert-loading tests land.
|
||
PKCS7_COV=$(go tool cover -func=coverage.out | grep 'internal/pkcs7' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "PKCS7 package coverage: ${PKCS7_COV}%"
|
||
|
||
LOCAL_ISSUER_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/local' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "Local-issuer coverage: ${LOCAL_ISSUER_COV}%"
|
||
|
||
# Bundle-J / Coverage-Audit C-001 (partial-closed) — ACME failure-mode
|
||
# batch lifts internal/connector/issuer/acme from 41.8% to ~55.6%
|
||
# (per-package package-scoped run). The global per-file average can
|
||
# come in lower because this awk pattern divides by file count
|
||
# rather than weighting by line count, but with the failure-mode
|
||
# tests landed every file in the package has at least 50% coverage.
|
||
# Floor set at 50 to accommodate the global-run arithmetic; bumps
|
||
# to 85 when Bundle J-extended (Pebble-style mock) lands and the
|
||
# IssueCertificate / solveAuthorizations* flows are exercisable.
|
||
ACME_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/acme' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "ACME issuer coverage: ${ACME_COV}%"
|
||
|
||
# Bundle-L.B / Coverage-Audit C-005 — StepCA failure-mode + JWE
|
||
# round-trip tests lift internal/connector/issuer/stepca from
|
||
# 52.1% to 90.4% (per-package run). Floor at 80 with margin.
|
||
STEPCA_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/stepca' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "StepCA issuer coverage: ${STEPCA_COV}%"
|
||
|
||
# Bundle-K / Coverage-Audit C-002 — MCP per-tool dispatch via
|
||
# in-memory transport lifts internal/mcp from 28.0% to 93.1%
|
||
# (per-package run). Floor at 85.
|
||
MCP_COV=$(go tool cover -func=coverage.out | grep 'internal/mcp/' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}')
|
||
echo "MCP coverage: ${MCP_COV}%"
|
||
|
||
# Fail if thresholds not met.
|
||
# Bundle R-CI-extended raises (post-Bundle-N.C-extended):
|
||
# service 55 -> 70 (HEAD 73.4%; 3pp margin); handler 60 -> 75
|
||
# (HEAD 79.8%; 4pp margin). Prescribed Bundle R target was 80;
|
||
# held lower to avoid false-positives on single low-coverage
|
||
# files dragging the global per-file-average down.
|
||
if [ "$(echo "$SERVICE_COV < 70" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 70% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$HANDLER_COV < 75" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 75% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold"
|
||
exit 1
|
||
fi
|
||
# Bundle R / Coverage Audit Closure — CI threshold raise checkpoint #3.
|
||
# Crypto package floor lifted 85 → 88. Post-Bundle-Q package-scoped
|
||
# coverage at HEAD: 88.2% (Bundle Q's gopter property tests don't add
|
||
# production-code coverage — they exercise the same paths via
|
||
# generative inputs). The remaining ~12% gap is platform-failure
|
||
# branches (rand.Reader / aes.NewCipher) that require interface seams
|
||
# the production code doesn't use; closing them is tracked as
|
||
# R-CI-extended, not Bundle R scope.
|
||
if [ "$(echo "$CRYPTO_COV < 88" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 88% (Bundle R closure floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
# Bundle-7 / H-005: pkcs7 coverage is INFORMATIONAL only in this run.
|
||
# The global `go test -cover ./...` invocation in CI doesn't exercise
|
||
# internal/pkcs7's tests (they're primarily Fuzz* targets that
|
||
# require an explicit `-fuzz` invocation, plus encoder helpers
|
||
# exercised transitively). The deep-scan workflow runs
|
||
# `go test -cover ./internal/pkcs7/...` directly and confirmed 100%
|
||
# at Bundle-7 close — that's the load-bearing measurement. Keeping
|
||
# the global-run number visible here for trend-watching but not
|
||
# gating because 0% is a measurement artifact, not a regression.
|
||
echo "PKCS7 package coverage (global run, informational): ${PKCS7_COV}%"
|
||
# Bundle-9 / H-010 closure: local-issuer HARD gate at 85%. The
|
||
# transitional 60% floor (Bundle-7) was an explicit promise in the
|
||
# CI config that H-010 would raise it once CSR-validation + CA-
|
||
# cert-loading + key-rotation + key-encoding pin tests landed.
|
||
# Bundle-9 ships those tests (bundle9_coverage_test.go) and lifts
|
||
# the package-scoped run to ~86.7%; the global run averages a few
|
||
# points lower (per-function arithmetic), so the gate is set to 85
|
||
# with the live `go test -cover` number being the source of truth.
|
||
# If this gate trips, the fix is to add tests, NOT to lower the
|
||
# floor — every percentage point under 85 is a regression on the
|
||
# H-010 closure invariant.
|
||
# Bundle R / Coverage Audit Closure — CI threshold raise checkpoint #3.
|
||
# Local-issuer floor lifted 85 → 86. Post-Bundle-Q package-scoped
|
||
# coverage at HEAD: 86.7%. The prescribed Bundle R target was
|
||
# 92, but reaching it requires interface seams for crypto/x509
|
||
# signing-error branches — tracked as R-CI-extended.
|
||
if [ "$(echo "$LOCAL_ISSUER_COV < 86" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Local-issuer coverage ${LOCAL_ISSUER_COV}% is below 86% (Bundle R closure floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
# Bundle R-CI-extended threshold raise (post-Bundle-J-extended):
|
||
# ACME 50 -> 80. The Pebble-style mock + per-CA failure tests
|
||
# lift package-scoped ACME to 85.4%; gate at 80 with 5pp margin
|
||
# to absorb the global-run per-file-average dip. The prescribed
|
||
# Bundle R target was 85; held at 80 to avoid false-positives
|
||
# on single low-coverage files.
|
||
if [ "$(echo "$ACME_COV < 80" | bc -l)" -eq 1 ]; then
|
||
echo "::error::ACME issuer coverage ${ACME_COV}% is below 80% (Bundle R-CI-extended floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$STEPCA_COV < 80" | bc -l)" -eq 1 ]; then
|
||
echo "::error::StepCA issuer coverage ${STEPCA_COV}% is below 80% (Bundle L.B closure floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$MCP_COV < 85" | bc -l)" -eq 1 ]; then
|
||
echo "::error::MCP coverage ${MCP_COV}% is below 85% (Bundle K closure floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
echo "Coverage thresholds passed!"
|
||
|
||
- name: Upload Coverage Report
|
||
uses: actions/upload-artifact@v4
|
||
with:
|
||
name: go-coverage
|
||
path: coverage.out
|
||
retention-days: 30
|
||
|
||
# Bundle P / Strengthening #6 — QA-doc drift guards. Forces every PR
|
||
# that adds a Part to docs/testing-guide.md OR a seed row to
|
||
# migrations/seed_demo.sql to keep docs/qa-test-guide.md in sync. This
|
||
# eliminates the doc-drift class structurally — the symptom Bundle I
|
||
# had to clean up by hand becomes a CI-time error going forward.
|
||
- name: QA-doc Part-count drift guard
|
||
run: |
|
||
set -e
|
||
DOC_PARTS=$(grep -oE '49 of [0-9]+ Parts' docs/qa-test-guide.md | grep -oE '[0-9]+' | tail -1)
|
||
GUIDE_PARTS=$(grep -cE '^## Part [0-9]+:' docs/testing-guide.md)
|
||
if [ -z "$DOC_PARTS" ]; then
|
||
echo "::error::Could not extract Part count from docs/qa-test-guide.md headline."
|
||
echo " Expected pattern: '49 of <N> Parts'"
|
||
exit 1
|
||
fi
|
||
if [ "$DOC_PARTS" != "$GUIDE_PARTS" ]; then
|
||
echo "::error::DRIFT — qa-test-guide.md headline claims $DOC_PARTS Parts; testing-guide.md has $GUIDE_PARTS Parts."
|
||
echo " Update docs/qa-test-guide.md to match. Bundle I patched this once;"
|
||
echo " Bundle P added this guard so the drift cannot recur silently."
|
||
exit 1
|
||
fi
|
||
echo "QA-doc Part-count drift guard: clean ($DOC_PARTS == $GUIDE_PARTS)."
|
||
|
||
- name: QA-doc seed-count drift guard
|
||
run: |
|
||
set -e
|
||
# Seed-cert count: agnostic to documented header format. The current
|
||
# documented count lives in `### Certificates (32 total in ...` —
|
||
# extract the first integer in that header.
|
||
DOC_CERTS=$(grep -oE '### Certificates \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1)
|
||
# Authoritative count: unique mc-* IDs in seed_demo.sql.
|
||
SEED_CERTS=$(grep -oE 'mc-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ')
|
||
if [ -z "$DOC_CERTS" ]; then
|
||
echo "::warning::Could not extract documented cert count from docs/qa-test-guide.md."
|
||
echo " Skipping cert-count drift check (header format may have changed)."
|
||
elif [ "$DOC_CERTS" != "$SEED_CERTS" ]; then
|
||
echo "::error::DRIFT — qa-test-guide.md says $DOC_CERTS certs; seed_demo.sql has $SEED_CERTS unique mc-* IDs."
|
||
echo " Update docs/qa-test-guide.md::Seed Data Reference to match."
|
||
exit 1
|
||
fi
|
||
# Issuers: seed-table count vs doc claim.
|
||
DOC_ISS=$(grep -oE '### Issuers \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1)
|
||
# Authoritative: unique iss-* IDs (close enough proxy; the issuers
|
||
# table count IS the unique-ID count for this prefix).
|
||
SEED_ISS=$(grep -oE 'iss-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ')
|
||
if [ -z "$DOC_ISS" ]; then
|
||
echo "::warning::Could not extract documented issuer count."
|
||
elif [ "$DOC_ISS" != "$SEED_ISS" ] && [ "$((SEED_ISS - DOC_ISS))" -gt 5 ]; then
|
||
# Allow up to 5pp slack — iss-* IDs appear in audit_events and
|
||
# other reference tables that aren't issuer-table rows. Drift
|
||
# only flags when the spread grows large.
|
||
echo "::error::DRIFT — qa-test-guide.md says $DOC_ISS issuers; seed_demo.sql has $SEED_ISS unique iss-* IDs (spread > 5)."
|
||
exit 1
|
||
fi
|
||
echo "QA-doc seed-count drift guard: clean."
|
||
|
||
# Bundle Q / I-001 closure — test-naming convention guard (informational).
|
||
# The convention is `Test<Func>_<Scenario>_<ExpectedResult>`. This step
|
||
# prints any non-conformant tests but does NOT fail the build until the
|
||
# Bundle I-001-extended (2026-04-27) — promoted from informational
|
||
# to hard-fail. The convention is now: every `func TestXxx(...)` MUST
|
||
# match Go's standard test-runner pattern (`^func Test[A-Z]`). Tests
|
||
# whose name starts with `func Test<lowercase>` are silently SKIPPED
|
||
# by `go test` (Go only runs `Test[A-Z]...`) — those are the real
|
||
# bugs this guard catches.
|
||
#
|
||
# The original audit's `Test<Func>_<Scenario>_<ExpectedResult>` triple-
|
||
# token prescription has been relaxed: single-function pin tests like
|
||
# `TestNewAgent` or `TestSplitPEMChain` are valid Go convention, with
|
||
# internal scenarios expressed via `t.Run` subtests. Requiring the
|
||
# underscore-Scenario-Result triple repo-wide would mean renaming
|
||
# 167 legitimate tests for no observable behavior change. The
|
||
# Test<Func>_<Scenario>_<ExpectedResult> form remains documented as
|
||
# the recommended pattern for parameterized scenarios in
|
||
# docs/qa-test-guide.md, but is not gated.
|
||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||
run: |
|
||
set -e
|
||
fail=0
|
||
for g in scripts/ci-guards/*.sh; do
|
||
echo "::group::$(basename "$g")"
|
||
if ! bash "$g"; then
|
||
fail=1
|
||
fi
|
||
echo "::endgroup::"
|
||
done
|
||
exit $fail
|
||
|
||
frontend-build:
|
||
name: Frontend Build
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@v4
|
||
with:
|
||
node-version: '22'
|
||
|
||
- name: Install Dependencies
|
||
working-directory: web
|
||
run: npm ci
|
||
|
||
- name: TypeScript Check
|
||
working-directory: web
|
||
run: npx tsc --noEmit
|
||
|
||
- name: Run Frontend Tests
|
||
working-directory: web
|
||
run: npx vitest run
|
||
|
||
- name: Build Frontend
|
||
working-directory: web
|
||
run: npx vite build
|
||
|
||
- name: Regression guards (extracted to scripts/ci-guards/)
|
||
# All named regression guards live at scripts/ci-guards/<id>.sh per
|
||
# ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally:
|
||
# bash scripts/ci-guards/G-3-env-docs-drift.sh
|
||
# Adding a new guard: drop a new <id>.sh; this loop auto-picks it up.
|
||
# Contract: each guard MUST exit 0 on clean repo, non-zero with
|
||
# ::error:: prefix on regression. See scripts/ci-guards/README.md.
|
||
run: |
|
||
set -e
|
||
fail=0
|
||
for g in scripts/ci-guards/*.sh; do
|
||
echo "::group::$(basename "$g")"
|
||
if ! bash "$g"; then
|
||
fail=1
|
||
fi
|
||
echo "::endgroup::"
|
||
done
|
||
exit $fail
|
||
|
||
helm-lint:
|
||
name: Helm Chart Validation
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Install Helm
|
||
uses: azure/setup-helm@v4
|
||
with:
|
||
version: '3.13.0'
|
||
|
||
# HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is
|
||
# configured. Every lint/template invocation below must pick exactly one
|
||
# provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl
|
||
# (certctl.tls.required) and docs/tls.md.
|
||
- name: Lint Helm Chart
|
||
run: |
|
||
helm lint deploy/helm/certctl/ \
|
||
--set server.tls.existingSecret=certctl-tls-ci
|
||
|
||
- name: Template Helm Chart (existingSecret mode)
|
||
run: |
|
||
helm template certctl deploy/helm/certctl/ \
|
||
--set server.tls.existingSecret=certctl-tls-ci \
|
||
> /dev/null
|
||
|
||
- name: Template Helm Chart (cert-manager mode)
|
||
run: |
|
||
helm template certctl deploy/helm/certctl/ \
|
||
--set server.tls.certManager.enabled=true \
|
||
--set server.tls.certManager.issuerRef.name=letsencrypt-prod \
|
||
> /dev/null
|
||
|
||
- name: Template Helm Chart (guard fails without TLS)
|
||
run: |
|
||
# Inverse test: the chart MUST refuse to render when no TLS source is
|
||
# configured. If this ever renders successfully, the fail-loud guard
|
||
# in certctl.tls.required has regressed.
|
||
if helm template certctl deploy/helm/certctl/ > /dev/null 2>&1; then
|
||
echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed"
|
||
exit 1
|
||
fi
|
||
|
||
# =============================================================================
|
||
# Deploy-Hardening II Phase 15 — per-vendor e2e matrix
|
||
# =============================================================================
|
||
# Per frozen decision 0.9: each vendor's e2e tests run in their own
|
||
# matrix job so vendor failures surface independently in the CI status
|
||
# check (operator sees "K8s 1.31 vendor-edge fail" as a discrete check,
|
||
# not a generic "integration tests failed").
|
||
deploy-vendor-e2e:
|
||
name: deploy-vendor-e2e (${{ matrix.vendor }})
|
||
runs-on: ubuntu-latest
|
||
needs: [go-build-and-test]
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
vendor: [nginx, apache, haproxy, traefik, caddy, envoy, postfix, dovecot, ssh, javakeystore, k8s, f5-mock]
|
||
timeout-minutes: 30
|
||
steps:
|
||
- uses: actions/checkout@v5
|
||
|
||
- name: Set up Go
|
||
uses: actions/setup-go@v5
|
||
with:
|
||
go-version: '1.25.9'
|
||
cache: true
|
||
|
||
- name: Bring up vendor sidecar
|
||
# Map matrix.vendor → docker-compose service name. The naming is
|
||
# NOT 1:1 because (a) the legacy NGINX vendor-edge tests reuse the
|
||
# apache-test sidecar via requireSidecar(t,"apache") — see the
|
||
# comment in deploy/test/nginx_vendor_e2e_test.go; (b) the openssh
|
||
# service is named openssh-test (not ssh-test); (c) the kind
|
||
# cluster service is named k8s-kind-test; (d) the F5 mock service
|
||
# is named f5-mock-icontrol and must be built first because it
|
||
# has no published image; (e) the JavaKeystore tests are pure-Go
|
||
# placeholder stubs that exercise no sidecar.
|
||
run: |
|
||
set -e
|
||
case "${{ matrix.vendor }}" in
|
||
nginx) SVC=apache-test ;; # nginx tests reuse apache sidecar
|
||
apache) SVC=apache-test ;;
|
||
haproxy) SVC=haproxy-test ;;
|
||
traefik) SVC=traefik-test ;;
|
||
caddy) SVC=caddy-test ;;
|
||
envoy) SVC=envoy-test ;;
|
||
postfix) SVC=postfix-test ;;
|
||
dovecot) SVC=dovecot-test ;;
|
||
ssh) SVC=openssh-test ;;
|
||
k8s) SVC=k8s-kind-test ;;
|
||
f5-mock) SVC=f5-mock-icontrol ;;
|
||
javakeystore) SVC="" ;; # pure-Go placeholder stubs; no sidecar needed
|
||
*) echo "::error::unknown matrix vendor '${{ matrix.vendor }}'"; exit 1 ;;
|
||
esac
|
||
if [ -z "$SVC" ]; then
|
||
echo "vendor=${{ matrix.vendor }} runs without a sidecar (pure-Go placeholder tests)"
|
||
exit 0
|
||
fi
|
||
if [ "${{ matrix.vendor }}" = "f5-mock" ]; then
|
||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml build "$SVC"
|
||
fi
|
||
docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml up -d "$SVC"
|
||
sleep 5
|
||
|
||
- name: Run vendor-edge e2e
|
||
env:
|
||
INTEGRATION: "1"
|
||
run: |
|
||
# Per frozen decision 0.6: discoverable via
|
||
# `go test -run 'VendorEdge_<vendor>'`. Match the matrix
|
||
# vendor (test names are CamelCase: TestVendorEdge_NGINX_*,
|
||
# TestVendorEdge_HAProxy_*, etc.).
|
||
case "${{ matrix.vendor }}" in
|
||
nginx) PATTERN='VendorEdge_NGINX' ;;
|
||
apache) PATTERN='VendorEdge_Apache' ;;
|
||
haproxy) PATTERN='VendorEdge_HAProxy' ;;
|
||
traefik) PATTERN='VendorEdge_Traefik' ;;
|
||
caddy) PATTERN='VendorEdge_Caddy' ;;
|
||
envoy) PATTERN='VendorEdge_Envoy' ;;
|
||
postfix) PATTERN='VendorEdge_Postfix' ;;
|
||
dovecot) PATTERN='VendorEdge_Dovecot' ;;
|
||
ssh) PATTERN='VendorEdge_SSH' ;;
|
||
javakeystore) PATTERN='VendorEdge_JavaKeystore' ;;
|
||
k8s) PATTERN='VendorEdge_K8s' ;;
|
||
f5-mock) PATTERN='VendorEdge_F5' ;;
|
||
esac
|
||
go test -tags integration -race -count=1 -run "$PATTERN" ./deploy/test/...
|
||
|
||
- name: Tear down sidecar
|
||
if: always()
|
||
run: docker compose --profile deploy-e2e -f deploy/docker-compose.test.yml down -v
|
||
|
||
# =============================================================================
|
||
# Deploy-Hardening II Phase 15 — Windows-host vendor e2e matrix
|
||
# =============================================================================
|
||
# IIS + WinCertStore tests run on windows-latest runners per frozen
|
||
# decision 0.4 (Windows containers run only on Windows hosts).
|
||
# Linux-only operators skip via //go:build integration && !no_iis.
|
||
deploy-vendor-e2e-windows:
|
||
name: deploy-vendor-e2e-windows (${{ matrix.vendor }})
|
||
runs-on: windows-latest
|
||
needs: [go-build-and-test]
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
vendor: [iis, wincertstore]
|
||
timeout-minutes: 30
|
||
steps:
|
||
- uses: actions/checkout@v5
|
||
|
||
- name: Set up Go
|
||
uses: actions/setup-go@v5
|
||
with:
|
||
go-version: '1.25.9'
|
||
cache: true
|
||
|
||
- name: Bring up Windows IIS sidecar
|
||
shell: powershell
|
||
run: |
|
||
docker compose --profile deploy-e2e-windows -f deploy/docker-compose.test.yml up -d windows-iis-test
|
||
Start-Sleep -Seconds 10
|
||
|
||
- name: Run vendor-edge e2e (Windows)
|
||
env:
|
||
INTEGRATION: "1"
|
||
shell: powershell
|
||
run: |
|
||
$pattern = if ("${{ matrix.vendor }}" -eq "iis") { "VendorEdge_IIS" } else { "VendorEdge_WinCertStore" }
|
||
go test -tags integration -race -count=1 -run $pattern ./deploy/test/...
|
||
|
||
- name: Tear down sidecar
|
||
if: always()
|
||
shell: powershell
|
||
run: docker compose --profile deploy-e2e-windows -f deploy/docker-compose.test.yml down -v
|