mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:21:29 +00:00
0c1bccd2dc
L.B closes C-005; L.A defers C-003 (refactor required); L.C operator-required (testcontainers); L.CI raises CI thresholds for ACME / StepCA / MCP.
L.B — StepCA (~580 LoC stepca/jwe_failure_test.go):
Strategy: hermetic test-side RFC 3394 AES Key Wrap implementation
constructs a valid step-ca PBES2-HS256+A128KW + A128GCM provisioner-
key JWE in-test, exercises the full decrypt pipeline end-to-end.
Coverage: 52.1% -> 90.4% (+38.3pp; +5.4 above 85% target)
decryptProvisionerKey: 0% -> 89.7%
aesKeyUnwrap: 0% -> 100.0%
jwkToECDSA: 0% -> 100.0%
loadProvisionerKey: 0% -> 76.9%
Tests (24 functions):
JWE round-trip pinning all 4 0%-covered helpers
decryptProvisionerKey: 10 negative-path cases (malformed JSON,
bad protected b64, malformed header JSON, unsupported alg,
unsupported enc, bad p2s/encrypted_key/IV/ciphertext/tag b64)
Wrong-password path: AES key unwrap integrity check fail
aesKeyUnwrap: too-short, not-mult-of-8, bad-KEK-size, bad-IV
jwkToECDSA: unsupported curve + bad x/y/d b64 + all-curves
loadProvisionerKey: round-trip + file-not-found
IssueCertificate failure modes (network/5xx/401/403)
RevokeCertificate failure modes (network/5xx/403)
L.A — cmd/server (DEFERRED):
cmd/server's 16.1% baseline is dominated by main()'s 1041-LoC
startup body which is 0%-covered. The other named functions
(preflight* + buildFinalHandler + tls.go) are at 85-100% already.
Lifting overall to >=75% requires a production-code refactor
(extract main() into testable Run(*Config)) that exceeds Bundle
L.A's test-only scope. Tracked as 'Bundle L.A-extended'.
L.C — Repository (OPERATOR-REQUIRED):
testcontainers + Docker not available in sandbox. Operator runs
go test -tags integration ./internal/repository/postgres/...
on a workstation with Docker.
L.CI — CI threshold raise #1 (.github/workflows/ci.yml):
ACME issuer: >=50% (Bundle J floor; bumps to 85 with Pebble-mock)
StepCA issuer: >=80% (Bundle L.B floor with 10pp margin from 90.4)
MCP: >=85% (Bundle K floor with 8pp margin from 93.1)
cmd/server raise deferred until Bundle L.A-extended lands.
YAML validated; each gate fails CI with 'add tests, do not lower
the gate' message matching L-010's pattern.
Verification:
go vet ./internal/connector/issuer/stepca/... clean
gofmt -l clean
staticcheck -checks all clean
go test -short ./internal/connector/issuer/stepca/ PASS, 90.4%
go test -race -count=1 PASS, 0 races
python3 -c 'yaml.safe_load(...)' YAML OK
Audit deliverables:
findings.yaml: C-005 status open -> closed; C-003 open -> deferred
gap-backlog.md: closure log + C-005 strikethrough + C-003/C-004 notes
coverage-matrix.md: stepca row at 90.4%
closure-plan.md: Bundle L [~] with per-sub-bundle status
CHANGELOG.md: [unreleased] Bundle L entry
1239 lines
64 KiB
YAML
1239 lines
64 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: Forbidden auth-type literal regression guard (G-1)
|
||
# G-1 closed the JWT silent auth downgrade by removing "jwt" from the
|
||
# accepted CERTCTL_AUTH_TYPE values. This step grep-fails the build
|
||
# if "jwt" reappears in any of the *additive* auth-type surfaces:
|
||
# the validAuthTypes / ValidAuthTypes() set, the OpenAPI enum, the
|
||
# helm chart's allowed-types list, or the .env.example default.
|
||
# Comment lines and the dedicated rejection branch in config.go
|
||
# (`c.Auth.Type == "jwt"`) are intentionally exempt — those are the
|
||
# G-1 fix itself, not a regression.
|
||
#
|
||
# Connector packages (internal/connector/) are exempt because the
|
||
# Google OAuth2 service-account JWT and step-ca provisioner one-
|
||
# time-token JWT are external-protocol uses, unrelated to certctl's
|
||
# own auth shape. Test files (_test.go) are exempt so negative
|
||
# tests can pass the literal.
|
||
#
|
||
# See docs/upgrade-to-v2-jwt-removal.md for the closure rationale,
|
||
# or internal/config/config.go::ValidAuthTypes for the allowed set.
|
||
run: |
|
||
set -e
|
||
|
||
# Scoped patterns that indicate "jwt" being added back to an
|
||
# allowed-set surface. Each catches a regression shape we've
|
||
# actually seen in pre-G-1 code:
|
||
# - Go map/slice literal: "jwt": true or "jwt",
|
||
# - Go switch case: case "jwt"
|
||
# - YAML enum: enum: [..., jwt, ...] or - jwt
|
||
# - .env conditional: AUTH_TYPE.*"jwt"|=jwt$
|
||
BAD=$(grep -rnEH \
|
||
-e '"jwt"\s*:\s*true' \
|
||
-e '"jwt"\s*,' \
|
||
-e 'case\s+"jwt"' \
|
||
-e 'enum:.*\bjwt\b' \
|
||
-e '^\s*-\s*jwt\s*$' \
|
||
-e 'AUTH_TYPE\s*=\s*jwt\s*$' \
|
||
-e 'AUTH_TYPE\s*=\s*jwt\s*#' \
|
||
-e 'auth\.type\s*=\s*jwt\s*$' \
|
||
-e 'AuthType\("jwt"\)' \
|
||
internal/config/ \
|
||
internal/api/ \
|
||
cmd/ \
|
||
api/openapi.yaml \
|
||
.env.example \
|
||
deploy/.env.example \
|
||
deploy/helm/certctl/values.yaml \
|
||
deploy/helm/certctl/templates/ \
|
||
2>/dev/null \
|
||
| grep -v '_test.go' \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
|
||
| grep -v 'is no longer accepted' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "G-1 regression: \"jwt\" reappeared in an allowed-set surface:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "Allowed surface for 'jwt' literals: comment lines, the"
|
||
echo "dedicated rejection branch in internal/config/config.go,"
|
||
echo "and connector packages (Google OAuth2, step-ca)."
|
||
echo "See docs/upgrade-to-v2-jwt-removal.md and"
|
||
echo "internal/config/config.go::ValidAuthTypes()."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden bare InsecureSkipVerify regression guard (L-001)
|
||
# L-001 audited every production InsecureSkipVerify=true call site
|
||
# and documented the justification per site in docs/tls.md. This
|
||
# step grep-fails the build if any new `InsecureSkipVerify: true`
|
||
# lands in a non-test Go file without a `//nolint:gosec` comment
|
||
# carrying the justification. Test files (_test.go) are exempt.
|
||
# Updating the documented surface goes through the docs/tls.md
|
||
# table — net-new sites must be reasoned about before merge.
|
||
run: |
|
||
set -e
|
||
# Find every "InsecureSkipVerify: true" or "InsecureSkipVerify = true"
|
||
# in a non-test .go file. Then for each, check the same line OR the
|
||
# immediately preceding line for `//nolint:gosec`.
|
||
BAD=""
|
||
while IFS= read -r match; do
|
||
file=$(echo "$match" | cut -d: -f1)
|
||
line=$(echo "$match" | cut -d: -f2)
|
||
same=$(sed -n "${line}p" "$file" 2>/dev/null)
|
||
prev=$(sed -n "$((line - 1))p" "$file" 2>/dev/null)
|
||
if echo "$same $prev" | grep -q 'nolint:gosec'; then
|
||
continue
|
||
fi
|
||
BAD="$BAD\n$match"
|
||
done < <(grep -rnE 'InsecureSkipVerify:\s*true|InsecureSkipVerify\s*=\s*true' \
|
||
--include='*.go' \
|
||
--exclude='*_test.go' \
|
||
. || true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "::error::New InsecureSkipVerify=true site without //nolint:gosec justification:"
|
||
echo -e "$BAD"
|
||
echo ""
|
||
echo "Add a //nolint:gosec comment with justification on the same"
|
||
echo "or preceding line, AND add a row to the docs/tls.md table."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden bare FROM regression guard (H-001)
|
||
# Bundle A / Audit H-001 (CWE-829): every FROM line in every
|
||
# Dockerfile in the repo MUST carry an @sha256:... digest pin in
|
||
# addition to the human-readable tag. A registry-side tag swap
|
||
# cannot then change what we pull. This step grep-fails the
|
||
# build if any new FROM lands without the @sha256 suffix.
|
||
run: |
|
||
set -e
|
||
# Match any "FROM image[:tag]" that does NOT contain @sha256.
|
||
# Strip comments and blank lines defensively.
|
||
BAD=$(find . -name 'Dockerfile*' -not -path './web/node_modules/*' \
|
||
-exec grep -HnE '^FROM\s+[^@#]+(\s+AS\s+\S+)?\s*$' {} \; || true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "::error::Dockerfile has bare FROM (no @sha256 digest pin):"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "Pin every FROM to an immutable digest. See the bump"
|
||
echo "procedure in Dockerfile's header comment (Bundle A / H-001)."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden missing USER regression guard (M-012)
|
||
# Bundle A / Audit M-012 (CWE-250): every Dockerfile in the repo
|
||
# MUST end with a `USER <non-root>` directive before the
|
||
# ENTRYPOINT/CMD so the container never runs as uid=0. This step
|
||
# grep-fails the build if any Dockerfile is missing such a USER.
|
||
# `USER root` and `USER 0` are explicitly rejected.
|
||
run: |
|
||
set -e
|
||
BAD=""
|
||
for df in $(find . -name 'Dockerfile*' -not -path './web/node_modules/*'); do
|
||
# Find the LAST USER directive in the file.
|
||
last_user=$(grep -E '^USER\s+\S+' "$df" | tail -1 | awk '{print $2}')
|
||
if [ -z "$last_user" ]; then
|
||
BAD="$BAD\n$df: no USER directive at all"
|
||
continue
|
||
fi
|
||
if [ "$last_user" = "root" ] || [ "$last_user" = "0" ]; then
|
||
BAD="$BAD\n$df: terminal USER is $last_user (must drop privileges)"
|
||
continue
|
||
fi
|
||
done
|
||
if [ -n "$BAD" ]; then
|
||
echo "::error::Dockerfile USER-drop regression:"
|
||
echo -e "$BAD"
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden README JWT advertising regression guard (H-009)
|
||
# H-009 closed by Bundle D as verified-already-clean: at audit time
|
||
# the README does NOT advertise JWT support (certctl does not ship
|
||
# in-process JWT middleware; JWT/OIDC integration is via an
|
||
# authenticating gateway, see docs/architecture.md "Authenticating-
|
||
# gateway pattern"). This step grep-fails the build if README ever
|
||
# re-introduces a sentence advertising JWT as a supported auth mode.
|
||
# Pattern: "JWT" within ~6 words of "support|auth|enabled|mode" in
|
||
# README.md. The architecture / compliance / connector docs that
|
||
# legitimately mention JWT (Google OAuth2 service-account JWT,
|
||
# step-ca provisioner JWT, JWT-via-gateway pattern) are out of
|
||
# scope — they describe what certctl does NOT do, or external
|
||
# protocol uses.
|
||
run: |
|
||
set -e
|
||
if grep -inE 'JWT.{0,40}(support|auth|enabled|mode|provider)' README.md \
|
||
| grep -v 'gateway' | grep -v 'pre-G-1'; then
|
||
echo "::error::README.md appears to advertise JWT auth support."
|
||
echo "certctl does NOT ship in-process JWT middleware. JWT/OIDC"
|
||
echo "integration is via an authenticating gateway — see"
|
||
echo "docs/architecture.md::Authenticating-gateway pattern."
|
||
echo "If you added a sentence about JWT to README, either remove"
|
||
echo "it or rewrite it to point at the gateway pattern."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden api_key_hash JSON-shape regression guard (G-2)
|
||
# G-2 closed cat-s5-apikey_leak by tagging Agent.APIKeyHash
|
||
# `json:"-"` and adding a defense-in-depth Agent.MarshalJSON that
|
||
# zeroes the field on the marshal-time copy. This step grep-fails
|
||
# the build if `api_key_hash` reappears in any of the *additive*
|
||
# JSON-emitting surfaces: a Go struct json tag in internal/domain/,
|
||
# an OpenAPI Agent schema property, a TypeScript field declaration
|
||
# in web/src/, or an enum-list / discriminator in handler
|
||
# production code.
|
||
#
|
||
# Repository, migration, seed, service, integration-test, and
|
||
# unit-test files are exempt — those are server-internal use
|
||
# sites (the DB column stays, the in-memory struct field stays,
|
||
# the auth-lookup path stays). Comment lines are exempt so the
|
||
# G-2 closure rationale can stay in the source.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-s5-apikey_leak for the closure rationale, or
|
||
# internal/domain/connector.go::Agent::MarshalJSON for the
|
||
# redaction enforcement.
|
||
run: |
|
||
set -e
|
||
|
||
# Scoped patterns that indicate api_key_hash being added back
|
||
# to a JSON-emitting surface. Each catches a regression shape
|
||
# that pre-G-2 actually shipped or that a future refactor
|
||
# could plausibly introduce:
|
||
# - Go struct tag: `json:"api_key_hash"`
|
||
# - Frontend interface: api_key_hash[?]: string
|
||
# - OpenAPI schema property: api_key_hash: (column-aligned)
|
||
# - YAML enum / array: - api_key_hash
|
||
BAD=$(grep -rnEH \
|
||
-e 'json:"api_key_hash[",]' \
|
||
-e '^\s*api_key_hash\??\s*:' \
|
||
-e '^\s*-\s*api_key_hash\s*$' \
|
||
internal/domain/ \
|
||
internal/api/ \
|
||
cmd/ \
|
||
api/openapi.yaml \
|
||
web/src/ \
|
||
2>/dev/null \
|
||
| grep -v '_test.go' \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "G-2 regression: api_key_hash reappeared in a JSON-emitting surface:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "Allowed surface for api_key_hash literals: comment lines,"
|
||
echo "the database column (migrations/), the in-memory struct"
|
||
echo "field tagged \`json:\"-\"\`, and the repository / service"
|
||
echo "use sites. See internal/domain/connector.go::Agent and"
|
||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-s5-apikey_leak for the closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden plaintext HEALTHCHECK regression guard (U-2)
|
||
# U-2 closed cat-u-healthcheck_protocol_mismatch by switching the
|
||
# published image's HEALTHCHECK from `curl -f http://localhost:
|
||
# 8443/health` (always failed against the HTTPS-only listener) to
|
||
# `curl -fsk https://localhost:8443/health`. This step grep-fails
|
||
# the build if any Dockerfile in the repo carries the pre-U-2
|
||
# plaintext shape — either explicitly (`http://localhost:8443/
|
||
# health` in a HEALTHCHECK) or via the looser pattern of any
|
||
# HEALTHCHECK that targets `http://` against the certctl server
|
||
# port.
|
||
#
|
||
# Comment lines and the docs/upgrade-to-tls.md:182 expected-to-
|
||
# fail invariant ("plaintext is gone, expect Connection refused")
|
||
# are intentionally exempt — we DO want the upgrade-doc string
|
||
# `http://localhost:8443/health` to remain there, since it
|
||
# documents what operators should test for to confirm plaintext
|
||
# is dead. The guardrail is scoped to Dockerfile* only, so docs
|
||
# are out of its reach.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-u-healthcheck_protocol_mismatch for the closure rationale,
|
||
# or deploy/test/healthcheck_test.go for the binary-image
|
||
# contract the runtime test pins.
|
||
run: |
|
||
set -e
|
||
|
||
# Patterns that catch the actual regression shapes:
|
||
# - HEALTHCHECK directive carrying any http:// (even if the
|
||
# port differs, no plaintext probe should ship).
|
||
# - The exact pre-U-2 string for grep-friendliness.
|
||
BAD=$(grep -rnEH \
|
||
-e 'HEALTHCHECK.*http://' \
|
||
-e 'curl[^|&;]*-f[^|&;]*http://localhost:8443/health' \
|
||
Dockerfile Dockerfile.agent Dockerfile.* 2>/dev/null \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "U-2 regression: plaintext HEALTHCHECK reappeared in a Dockerfile:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "Allowed: HTTPS HEALTHCHECK with -k (acceptable for"
|
||
echo "localhost-to-localhost), or non-HTTP probe shapes"
|
||
echo "(pgrep, /proc check). See Dockerfile / Dockerfile.agent"
|
||
echo "for the post-U-2 reference shape and"
|
||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-u-healthcheck_protocol_mismatch for rationale."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden migration mount in compose initdb (U-3)
|
||
# U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by
|
||
# eliminating the dual-source-of-truth between
|
||
# `migrations/*.up.sql` mounted into postgres
|
||
# `/docker-entrypoint-initdb.d/` and the same files re-applied at
|
||
# runtime by `RunMigrations`. Pre-U-3 every new migration that
|
||
# the seed depended on (000013 added `policy_rules.severity`,
|
||
# 000017 renames `retry_interval_seconds`, etc.) had to be added
|
||
# by hand to the compose mount list; missing the update crashed
|
||
# initdb on first boot, postgres flagged unhealthy, and the
|
||
# whole stack failed to start from a fresh clone. Post-U-3 the
|
||
# server is the single source of truth — `RunMigrations` +
|
||
# `RunSeed` apply everything at boot.
|
||
#
|
||
# This step grep-fails the build if any compose file under
|
||
# `deploy/` re-introduces a `migrations/.*\.sql` mount into
|
||
# `/docker-entrypoint-initdb.d`. Comments are exempt so the
|
||
# post-fix rationale block in the compose files (which
|
||
# documents WHY the mounts were removed) doesn't trip the guard.
|
||
# The demo overlay's `seed_demo.sql` is the explicit exception:
|
||
# it is tolerated only when it lives behind the
|
||
# CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb
|
||
# mounts are NOT tolerated. The grep matches all compose
|
||
# mount-list shapes (`-` indented, `volumes:` indented, both),
|
||
# so any future drift surfaces here before the operator hits it
|
||
# on a fresh clone.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-u-seed_initdb_schema_drift for the closure rationale, or
|
||
# internal/repository/postgres/db.go::RunSeed for the runtime
|
||
# contract.
|
||
run: |
|
||
set -e
|
||
|
||
BAD=$(grep -rnEH \
|
||
-e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \
|
||
-e 'seed.*\.sql:.*docker-entrypoint-initdb' \
|
||
deploy/docker-compose.yml \
|
||
deploy/docker-compose.test.yml \
|
||
deploy/docker-compose.demo.yml \
|
||
2>/dev/null \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*#' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "U-3 regression: migration/seed mount into postgres initdb reappeared:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "The post-U-3 contract is: postgres comes up with an empty"
|
||
echo "schema and the server applies migrations + seed at boot via"
|
||
echo "internal/repository/postgres.RunMigrations + RunSeed. Demo"
|
||
echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed),"
|
||
echo "not an initdb mount. See"
|
||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-u-seed_initdb_schema_drift for the closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2)
|
||
# D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key,
|
||
# 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead'
|
||
# missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead
|
||
# key), cat-f-cert_detail_page_key_render_fallback (render-site
|
||
# uses cert.X directly), and cat-f-ae0d06b6588f (Certificate
|
||
# TS phantom fields). This step grep-fails the build if either
|
||
# half of the closure is reverted:
|
||
#
|
||
# 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance'
|
||
# for Cert) reappearing as map literals, OR
|
||
# 2. The five phantom Certificate TS fields (serial_number,
|
||
# fingerprint_sha256, key_algorithm, key_size, issued_at)
|
||
# reappearing on the `Certificate` interface in types.ts
|
||
# (CertificateVersion legitimately carries them and is
|
||
# explicitly excluded by the awk pre-filter below).
|
||
#
|
||
# Comments are exempt so the closure prose in StatusBadge.tsx +
|
||
# types.ts can stay. Test files are exempt so negative tests
|
||
# asserting the dead keys fall through to neutral keep working.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-d-* / cat-f-* for the closure rationale, or
|
||
# web/src/components/StatusBadge.test.tsx for the live
|
||
# enum-coverage contract.
|
||
run: |
|
||
set -e
|
||
|
||
BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \
|
||
web/src/components/StatusBadge.tsx 2>/dev/null \
|
||
| grep -v '\.test\.' \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||
|| true)
|
||
if [ -n "$BAD_BADGE" ]; then
|
||
echo "D-1 regression: dead StatusBadge key reappeared:"
|
||
echo "$BAD_BADGE"
|
||
echo ""
|
||
echo "Allowed surface: comment lines naming the removed key in"
|
||
echo "the file's preamble. The Go-side AgentStatus values are"
|
||
echo "Online/Offline/Degraded (no Stale); CertificateStatus values"
|
||
echo "are Pending/Active/... (no PendingIssuance). See"
|
||
echo "web/src/components/StatusBadge.test.tsx for the contract."
|
||
exit 1
|
||
fi
|
||
|
||
# Certificate TS phantom-field check. Scoped to the
|
||
# `export interface Certificate {` block in web/src/api/types.ts
|
||
# — CertificateVersion legitimately declares these fields and
|
||
# must NOT trip the guardrail. The awk window opens on the
|
||
# exact `Certificate {` header (not `CertificateVersion {`,
|
||
# not `CertificateProfile {`) and closes at the first `}`,
|
||
# then the grep matches a phantom-field declaration anywhere
|
||
# in that window.
|
||
BAD_TS=$(awk '
|
||
/^export interface Certificate \{/ { flag=1; next }
|
||
flag && /^\}/ { flag=0 }
|
||
flag { print FILENAME":"NR":"$0 }
|
||
' web/src/api/types.ts \
|
||
| grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \
|
||
|| true)
|
||
if [ -n "$BAD_TS" ]; then
|
||
echo "D-1 regression: Certificate TS interface re-added a phantom field:"
|
||
echo "$BAD_TS"
|
||
echo ""
|
||
echo "These fields live on CertificateVersion, not ManagedCertificate."
|
||
echo "The Go-side ManagedCertificate has never carried them; the"
|
||
echo "TS optional declarations were silently undefined on every"
|
||
echo "list response. Render-site consumers (e.g. CertificateDetailPage)"
|
||
echo "use latestVersion?.field as the canonical access path."
|
||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-f-ae0d06b6588f for the closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
# D-2 master closed five diff-05x06-* type-drift findings:
|
||
# Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom)
|
||
# — TRIM half. The Target (2 missing fields) and DiscoveredCertificate
|
||
# (1 missing field) — ADD half is pinned by the literal-construction
|
||
# blocks in web/src/api/types.test.ts, not a CI grep. The phantom-
|
||
# trim regression vector is an awk-windowed grep per interface
|
||
# mirroring the D-1 Certificate check above.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer),
|
||
# diff-05x06-caba9eb3620e (Notification) for the closure rationale.
|
||
|
||
# D-2 Agent phantom-field check. The grep matches `last_heartbeat`
|
||
# but NOT `last_heartbeat_at` (the legitimate Go-emitted field) —
|
||
# the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'`
|
||
# filter handle that.
|
||
BAD_AGENT=$(awk '
|
||
/^export interface Agent \{/ { flag=1; next }
|
||
flag && /^\}/ { flag=0 }
|
||
flag { print FILENAME":"NR":"$0 }
|
||
' web/src/api/types.ts \
|
||
| grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \
|
||
| grep -v 'last_heartbeat_at' \
|
||
|| true)
|
||
if [ -n "$BAD_AGENT" ]; then
|
||
echo "D-2 regression: Agent TS interface re-added a phantom field:"
|
||
echo "$BAD_AGENT"
|
||
echo ""
|
||
echo "The Go-side internal/domain/connector.go::Agent emits exactly:"
|
||
echo "id, name, hostname, status, last_heartbeat_at?, registered_at,"
|
||
echo "os, architecture, ip_address, version, retired_at?, retired_reason?."
|
||
echo "The five fields blocked by this guard (last_heartbeat,"
|
||
echo "capabilities, tags, created_at, updated_at) were TS phantoms"
|
||
echo "the Go struct never emitted. See unified-audit.md"
|
||
echo "diff-05x06-7cdf4e78ae24 for closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
# D-2 Issuer phantom-field check.
|
||
BAD_ISSUER=$(awk '
|
||
/^export interface Issuer \{/ { flag=1; next }
|
||
flag && /^\}/ { flag=0 }
|
||
flag { print FILENAME":"NR":"$0 }
|
||
' web/src/api/types.ts \
|
||
| grep -E '\bstatus\??\s*:' \
|
||
|| true)
|
||
if [ -n "$BAD_ISSUER" ]; then
|
||
echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:"
|
||
echo "$BAD_ISSUER"
|
||
echo ""
|
||
echo "The Go-side internal/domain/connector.go::Issuer has no 'status'"
|
||
echo "field — only 'enabled' (bool). Render sites derive the displayed"
|
||
echo "status from 'enabled' at the call site (see"
|
||
echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md"
|
||
echo "diff-05x06-97fab8783a5c for closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
# D-2 Notification phantom-field check.
|
||
BAD_NOTIF=$(awk '
|
||
/^export interface Notification \{/ { flag=1; next }
|
||
flag && /^\}/ { flag=0 }
|
||
flag { print FILENAME":"NR":"$0 }
|
||
' web/src/api/types.ts \
|
||
| grep -E '\bsubject\??\s*:' \
|
||
|| true)
|
||
if [ -n "$BAD_NOTIF" ]; then
|
||
echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:"
|
||
echo "$BAD_NOTIF"
|
||
echo ""
|
||
echo "The Go-side internal/domain/notification.go::NotificationEvent"
|
||
echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer"
|
||
echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback"
|
||
echo "that always fell through. See unified-audit.md"
|
||
echo "diff-05x06-caba9eb3620e for closure rationale."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden client-side bulk-action loop regression guard (L-1)
|
||
# L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and
|
||
# cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side
|
||
# bulk endpoints (POST /api/v1/certificates/bulk-renew and
|
||
# POST /api/v1/certificates/bulk-reassign) that the GUI calls
|
||
# in a single round-trip. Pre-L-1 the GUI looped per-cert
|
||
# HTTP calls — 100 selected certs = 100 round-trips × ~50–200ms
|
||
# each = a 5–20-second wedge during which the operator stares
|
||
# at a progress bar.
|
||
#
|
||
# This step grep-fails the build if either loop shape reappears
|
||
# in CertificatesPage.tsx. Patterns catch the actual pre-L-1
|
||
# shapes:
|
||
# - `for (const id of ids) { await triggerRenewal(id) }`
|
||
# - `for (const id of ids) { await updateCertificate(id, { owner_id }) }`
|
||
# - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }`
|
||
#
|
||
# Allowed: comment lines explaining the pre-L-1 pattern in the
|
||
# docblock above each handler. Test files (_test.tsx) exempt
|
||
# so negative-pattern tests can keep working.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure
|
||
# rationale, or web/src/api/client.ts::bulkRenewCertificates
|
||
# / bulkReassignCertificates for the canonical call path.
|
||
run: |
|
||
set -e
|
||
|
||
BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \
|
||
| grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \
|
||
| grep -v '\.test\.' \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||
|| true)
|
||
if [ -n "$BAD_LOOP" ]; then
|
||
echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:"
|
||
echo "$BAD_LOOP"
|
||
echo ""
|
||
echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or"
|
||
echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })"
|
||
echo "instead of looping per-item HTTP calls. See"
|
||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Forbidden orphan-CRUD client function regression guard (B-1)
|
||
# B-1 master closed four audit findings — three orphan-update fns
|
||
# (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD
|
||
# surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page
|
||
# Edit modals so every backend write endpoint has at least one
|
||
# GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted
|
||
# the dead `exportCertificatePEM` duplicate.
|
||
#
|
||
# Pre-B-1 the failure mode was: backend ships a CRUD handler,
|
||
# client.ts ships the matching `update*` / `delete*` / `create*`
|
||
# function, but no page imports it. Operators were forced to
|
||
# `psql` directly to edit team names, owner emails, agent-group
|
||
# match rules, issuer names, profile names, or any renewal-policy
|
||
# field — turning a 30-second GUI task into a 30-minute database
|
||
# excursion with audit-trail gaps.
|
||
#
|
||
# This step fails the build if any of the eight previously-orphan
|
||
# client functions loses its page consumer (i.e. a future refactor
|
||
# accidentally re-orphans them). Each fn must have ≥1 non-test
|
||
# consumer under web/src/pages/. Tests (*.test.ts(x)) and the
|
||
# client.ts definition file itself are exempt.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee,
|
||
# cat-b-9b97ffb35ef7 for closure rationale.
|
||
run: |
|
||
set -e
|
||
ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy"
|
||
FAIL=0
|
||
for fn in $ORPHAN_FNS; do
|
||
HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \
|
||
| grep -vE '\.test\.(ts|tsx):' \
|
||
| wc -l)
|
||
if [ "$HITS" -eq 0 ]; then
|
||
echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/."
|
||
echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql."
|
||
echo " Either restore the page consumer or delete the client function in the same commit."
|
||
FAIL=1
|
||
fi
|
||
done
|
||
# cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead
|
||
# duplicate of downloadCertificatePEM. Block resurrection.
|
||
if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then
|
||
echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM."
|
||
echo " If a JSON variant is needed, add an explicit page consumer in the same commit."
|
||
FAIL=1
|
||
fi
|
||
if [ "$FAIL" -ne 0 ]; then
|
||
exit 1
|
||
fi
|
||
echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers."
|
||
|
||
- name: Forbidden strings.Contains(err.Error()) regression guard (S-2)
|
||
# S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle
|
||
# substring-match error-dispatch sites in internal/api/handler/
|
||
# with errors.Is + typed sentinels (repository.ErrNotFound,
|
||
# repository.ErrForeignKeyConstraint via the
|
||
# repository.IsForeignKeyError helper). This step grep-fails
|
||
# the build if any new strings.Contains(err.Error(), "not found")
|
||
# or strings.Contains(err.Error(), "violates foreign key")
|
||
# site appears under internal/api/handler/.
|
||
#
|
||
# Allowed: closure-comments documenting the convention (e.g.
|
||
# bulk_reassignment.go's "post-M-1 errToStatus convention"
|
||
# docblock); domain-specific substring patterns that are
|
||
# legitimately one-off ("cannot approve", "cannot reject",
|
||
# "cannot be parsed", "challenge password") — flagged as
|
||
# deferred follow-ups in the S-2 commit message.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-s6-efc7f6f6bd50 for closure rationale.
|
||
run: |
|
||
set -e
|
||
BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \
|
||
| grep -vE '^\s*[^:]+:[0-9]+:\s*//' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "S-2 regression: brittle substring-match error-dispatch reappeared:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch,"
|
||
echo "or repository.IsForeignKeyError(err) for FK violations."
|
||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-s6-efc7f6f6bd50 for closure rationale."
|
||
exit 1
|
||
fi
|
||
echo "S-2 typed-sentinel error-dispatch guardrail: clean."
|
||
|
||
- 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
|
||
if [ "$(echo "$SERVICE_COV < 55" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Service layer coverage ${SERVICE_COV}% is below 55% threshold"
|
||
exit 1
|
||
fi
|
||
if [ "$(echo "$HANDLER_COV < 60" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Handler layer coverage ${HANDLER_COV}% is below 60% threshold"
|
||
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
|
||
if [ "$(echo "$CRYPTO_COV < 85" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 85% threshold"
|
||
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.
|
||
if [ "$(echo "$LOCAL_ISSUER_COV < 85" | bc -l)" -eq 1 ]; then
|
||
echo "::error::Local-issuer coverage ${LOCAL_ISSUER_COV}% is below 85% (H-010 closure floor — add tests, do not lower the gate)"
|
||
exit 1
|
||
fi
|
||
# Bundle L.CI threshold raise #1 — post-Bundles J / L.B / K floors.
|
||
# Each gate is set with margin below the verified package-scoped
|
||
# coverage so the global per-file-average arithmetic doesn't false-
|
||
# positive on a single low-coverage file dragging the mean.
|
||
if [ "$(echo "$ACME_COV < 50" | bc -l)" -eq 1 ]; then
|
||
echo "::error::ACME issuer coverage ${ACME_COV}% is below 50% (Bundle J partial-closure floor — add Pebble-mock 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
|
||
|
||
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: Forbidden hardcoded source-count prose regression guard (S-1)
|
||
# S-1 master closed cat-s1-9ce1cbe26876 (README + features.md
|
||
# stale numeric counts; explicit CLAUDE.md violation per
|
||
# "version-stamped numbers rot") and
|
||
# cat-s1-features_md_issuer_count_contradiction (features.md
|
||
# self-disagreed on issuer count: 9 vs 12 in the same doc).
|
||
# The fix replaced source-derived numbers in prose with
|
||
# "rebuild via <command>" patterns documented in CLAUDE.md::
|
||
# "Current-state commands". This step grep-fails the build if
|
||
# any of the previously-stale sites reintroduces a hardcoded
|
||
# count.
|
||
#
|
||
# Allowed surfaces: demo-fixture prose in README ("32
|
||
# certificates" — those are seed_demo.sql facts, not live
|
||
# source counts), historical-milestone counts in
|
||
# WORKSPACE-CHANGELOG.md, the testing-guide example phrasing
|
||
# ("README claims 8 issuer connectors but only 6 exist"),
|
||
# and any number that quotes the source command immediately
|
||
# adjacent.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction
|
||
# for closure rationale.
|
||
run: |
|
||
set -e
|
||
BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \
|
||
README.md docs/ 2>/dev/null \
|
||
| grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \
|
||
| grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \
|
||
| grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \
|
||
|| true)
|
||
if [ -n "$BAD" ]; then
|
||
echo "S-1 regression: hardcoded source-count prose reappeared:"
|
||
echo "$BAD"
|
||
echo ""
|
||
echo "CLAUDE.md rule: 'Numeric claims about current state rot.'"
|
||
echo "Replace the count with the grep command from CLAUDE.md::"
|
||
echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')"
|
||
echo "or rephrase to reference the rebuild command on the same line."
|
||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "cat-s1-9ce1cbe26876 for closure rationale."
|
||
exit 1
|
||
fi
|
||
echo "S-1 stale-counts guardrail: clean."
|
||
|
||
- name: Documented orphan client fns sync guard (P-1)
|
||
# P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e
|
||
# by documenting 17 detail-page-candidate orphan client.ts
|
||
# functions in a docblock at the top of web/src/api/client.ts.
|
||
# This step verifies the docblock list ↔ export list relationship:
|
||
# every name listed in the docblock must still be declared as
|
||
# an export below it (catches drift where someone deletes the
|
||
# export but forgets the docblock, or vice versa).
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
||
run: |
|
||
set -e
|
||
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
|
||
MISSING=""
|
||
for fn in $DOCUMENTED; do
|
||
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
|
||
MISSING="${MISSING}${fn} "
|
||
fi
|
||
done
|
||
if [ -n "$MISSING" ]; then
|
||
echo "P-1 regression: documented orphan(s) missing from client.ts exports:"
|
||
echo " $MISSING"
|
||
echo ""
|
||
echo "Either restore the export, or delete the corresponding line"
|
||
echo "in the documented-orphans docblock at the top of client.ts."
|
||
echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md"
|
||
echo "diff-04x03-d24864996ad4 for closure rationale."
|
||
exit 1
|
||
fi
|
||
echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)."
|
||
|
||
- name: Frontend page-coverage regression guard (T-1)
|
||
# T-1 closure (cat-s2-c24a548076c6): pre-T-1 only 3 of 28 pages
|
||
# had Vitest coverage. T-1 lifted that to 11/28 by writing tests
|
||
# for the 8 highest-leverage pages (CertificatesPage filter +
|
||
# pagination state, the new B-1 Edit modals, the D-2 type-trim
|
||
# render sites, etc.). The remaining pages are deferred to per-
|
||
# page commits — when the next feature change touches them, the
|
||
# test gets added in the same commit. This step blocks new
|
||
# pages from landing without tests.
|
||
#
|
||
# Allowlist: pages that are explicitly deferred — listed below
|
||
# with a one-line "why deferred" justification. Each entry must
|
||
# be removed when the page gets its test.
|
||
# - LoginPage: static auth form, no business logic
|
||
# - AuditPage: read-only timeline; D-2 already trimmed
|
||
# - ShortLivedPage: derived view of certs already covered by CertificatesPage
|
||
# - DigestPage: server-rendered digest; minimal client logic
|
||
# - ObservabilityPage: exposes Prometheus / Grafana links only
|
||
# - HealthMonitorPage: wraps M-006 health check timeline; M-006 has its own tests
|
||
# - NetworkScanPage: wraps the network scanner UX; SSRF unit-tested in domain
|
||
# - JobsPage: covered transitively via AgentDetailPage
|
||
# - JobDetailPage: drill-down view; covered transitively via JobsPage
|
||
# - AgentFleetPage: bulk overview; covered transitively via AgentsPage
|
||
# - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered)
|
||
# - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage
|
||
# - IssuerDetailPage: drill-down view; covered transitively via IssuersPage
|
||
# - TargetDetailPage: drill-down view; covered transitively via TargetsPage
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-s2-c24a548076c6 for closure rationale.
|
||
run: |
|
||
set -e
|
||
ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$'
|
||
UNTESTED=""
|
||
for f in web/src/pages/*.tsx; do
|
||
base=$(basename "$f" .tsx)
|
||
case "$f" in *.test.tsx) continue ;; esac
|
||
if [ -f "web/src/pages/${base}.test.tsx" ]; then continue; fi
|
||
if echo "$base" | grep -qE "$ALLOW"; then continue; fi
|
||
UNTESTED="${UNTESTED}${base} "
|
||
done
|
||
if [ -n "$UNTESTED" ]; then
|
||
echo "T-1 regression: page(s) without sibling .test.tsx and not on the deferred allowlist:"
|
||
echo " $UNTESTED"
|
||
echo ""
|
||
echo "Either add web/src/pages/<Page>.test.tsx (mirror NotificationsPage.test.tsx),"
|
||
echo "or add the page to the ALLOW pattern in .github/workflows/ci.yml with a"
|
||
echo "one-line 'why deferred' comment. See"
|
||
echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s2-c24a548076c6"
|
||
echo "for closure rationale."
|
||
exit 1
|
||
fi
|
||
ALLOWLIST_SIZE=$(echo "$ALLOW" | tr '|' '\n' | wc -l)
|
||
echo "T-1 page-coverage guardrail: clean (allowlist size: $ALLOWLIST_SIZE pages deferred)."
|
||
|
||
- name: Bundle-8 / L-015 target=_blank rel=noopener regression guard
|
||
# Audit L-015 / CWE-1022 (reverse-tabnabbing): every <a target="_blank">
|
||
# MUST carry rel="noopener noreferrer" so a malicious page at the
|
||
# target URL cannot navigate the opener window via window.opener.
|
||
# At Bundle-8 close (commit b566355→) all 3 sites in the codebase
|
||
# already comply — this guard prevents regression. The
|
||
# ExternalLink component (web/src/components/ExternalLink.tsx)
|
||
# is the recommended way to add new external links.
|
||
#
|
||
# Test files (web/src/**/*.test.{ts,tsx}) are excluded so test
|
||
# docstrings or fixture data describing the attack vector by
|
||
# name don't trip the guard — symmetric with the L-019 guard.
|
||
run: |
|
||
set -e
|
||
OFFENDERS=$(grep -rnE 'target=["'"'"']?_blank["'"'"']?' web/src/ 2>/dev/null \
|
||
| grep -v 'noopener noreferrer' \
|
||
| grep -v 'web/src/components/ExternalLink.tsx' \
|
||
| grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \
|
||
|| true)
|
||
if [ -n "$OFFENDERS" ]; then
|
||
echo "L-015 regression: target=\"_blank\" without rel=\"noopener noreferrer\":"
|
||
echo "$OFFENDERS"
|
||
echo ""
|
||
echo "Either add rel=\"noopener noreferrer\" inline,"
|
||
echo "or migrate to <ExternalLink> from web/src/components/ExternalLink.tsx."
|
||
exit 1
|
||
fi
|
||
echo "L-015 target=_blank guardrail: clean."
|
||
|
||
- name: Bundle-8 / L-019 dangerouslySetInnerHTML regression guard
|
||
# Audit L-019 / CWE-79 (XSS): no PRODUCTION code may use
|
||
# dangerouslySetInnerHTML directly. At Bundle-8 close the codebase
|
||
# has 0 sites; future genuine needs MUST route through
|
||
# web/src/utils/safeHtml.ts::sanitizeHtml.
|
||
#
|
||
# Test files (web/src/**/*.test.{ts,tsx}) are explicitly excluded:
|
||
# the M-029 Pass 3 XSS-hardening test docstrings legitimately cite
|
||
# the attack vector by name to explain what the test is guarding
|
||
# against (e.g. "a careless refactor to dangerouslySetInnerHTML
|
||
# would let an attacker-controlled CSR deliver an XSS payload").
|
||
# Tests describing the threat aren't using it; the guard's intent
|
||
# is production code only.
|
||
run: |
|
||
set -e
|
||
OFFENDERS=$(grep -rnE 'dangerouslySetInnerHTML' web/src/ 2>/dev/null \
|
||
| grep -v 'web/src/utils/safeHtml.ts' \
|
||
| grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \
|
||
|| true)
|
||
if [ -n "$OFFENDERS" ]; then
|
||
echo "L-019 regression: dangerouslySetInnerHTML used outside safeHtml.ts:"
|
||
echo "$OFFENDERS"
|
||
echo ""
|
||
echo "Route through web/src/utils/safeHtml.ts::sanitizeHtml — see file"
|
||
echo "header for the activation procedure (DOMPurify dependency)."
|
||
exit 1
|
||
fi
|
||
echo "L-019 dangerouslySetInnerHTML guardrail: clean."
|
||
|
||
- name: Bundle-8 / M-009 + M-029 Pass 1 mutation contract guard (hard zero)
|
||
# Audit M-009 + M-029 Pass 1 closure:
|
||
#
|
||
# Pre-Bundle-8 the codebase had 56 bare useMutation sites with
|
||
# discretionary invalidation. Bundle 8 shipped the useTrackedMutation
|
||
# wrapper (web/src/hooks/useTrackedMutation.ts) that requires every
|
||
# caller to declare `invalidates: QueryKey[] | 'noop'`. M-029 Pass 1
|
||
# then migrated all 56 sites to the wrapper across 6 batches.
|
||
#
|
||
# This guard pins the contract going forward: every useMutation call
|
||
# in src/ MUST be inside useTrackedMutation.ts (the wrapper itself
|
||
# is the only legitimate caller of useMutation). Any bare useMutation
|
||
# call elsewhere is a regression — adding a new mutation site means
|
||
# going through the wrapper so the invalidates contract is enforced
|
||
# per-site, not by a soft budget guard.
|
||
#
|
||
# If you genuinely need raw useMutation (extremely unlikely — the
|
||
# wrapper supports invalidates: 'noop' for fire-and-forget mutations),
|
||
# update this guard's exclusion list and document the carve-out.
|
||
run: |
|
||
set -e
|
||
# Test files (web/src/**/*.test.{ts,tsx}) are excluded so existing
|
||
# useMutation-mocking test patterns and the wrapper's own unit
|
||
# tests don't trip the production guard — symmetric with L-015
|
||
# and L-019 above.
|
||
BARE=$(grep -rnE '\buseMutation\(' web/src/ 2>/dev/null \
|
||
| grep -v 'web/src/hooks/useTrackedMutation\.ts' \
|
||
| grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \
|
||
|| true)
|
||
if [ -n "$BARE" ]; then
|
||
echo "M-009 hard-zero regression: bare useMutation() call(s) outside the wrapper:"
|
||
echo "$BARE"
|
||
echo
|
||
echo "Every mutation must go through useTrackedMutation"
|
||
echo "(web/src/hooks/useTrackedMutation.ts) with explicit"
|
||
echo "invalidates: QueryKey[] | 'noop'. See file header for usage."
|
||
exit 1
|
||
fi
|
||
# Sanity counts (informational, not a gate).
|
||
TRACKED=$(grep -rcE '\buseTrackedMutation\(' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}')
|
||
INVALIDATIONS=$(grep -rcE 'invalidateQueries|setQueryData|removeQueries|invalidates:' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}')
|
||
echo "M-009 hard-zero: bare useMutation sites = 0 (wrapper-internal call + test files excluded)."
|
||
echo "M-009 informational: useTrackedMutation sites = $TRACKED; invalidation surface = $INVALIDATIONS."
|
||
|
||
- name: Forbidden env-var docs drift regression guard (G-3)
|
||
# G-3 master closed cat-g-163dae19bc59 (docs-only env vars
|
||
# phantom in features.md), cat-g-b8f8f8796159 (6 config-only
|
||
# env vars never documented), and cat-g-renewal_check_interval_rename_drift
|
||
# (features.md still advertised the pre-rename
|
||
# CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to
|
||
# CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs
|
||
# `comm -23` both ways between the env vars defined in Go
|
||
# source (config.go + cmd/agent + deploy/test fixtures + ACME
|
||
# DNS-01 script env exports) and the env vars mentioned in
|
||
# README + docs/ + deploy/helm/.
|
||
#
|
||
# Allowlist: env vars that are documented as integration-
|
||
# surface contracts (script env exports for ACME DNS-01,
|
||
# OpenSSL CA scripts, StepCA per-issuer-config-blob fields,
|
||
# Webhook per-notifier-config-blob fields, ACME EAB, audit
|
||
# exclusion, demo-stack overrides) but not consumed directly
|
||
# by config.go. Each entry below has a one-line justification
|
||
# — if you add a new entry, add the justification too.
|
||
#
|
||
# See coverage-gap-audit-2026-04-24-v5/unified-audit.md
|
||
# cat-g-* for closure rationale.
|
||
run: |
|
||
set -e
|
||
# Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export
|
||
{
|
||
grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/'
|
||
grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/'
|
||
grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null
|
||
} | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt
|
||
# Documented: README + docs + helm
|
||
grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt
|
||
# Allowlist of env vars documented as external integration contracts.
|
||
# Each entry justifies itself in one line; if you add to this list,
|
||
# add the justification.
|
||
ALLOWED='^(
|
||
CERTCTL_OPENSSL_SIGN_SCRIPT|
|
||
CERTCTL_OPENSSL_REVOKE_SCRIPT|
|
||
CERTCTL_OPENSSL_CRL_SCRIPT|
|
||
CERTCTL_OPENSSL_TIMEOUT_SECONDS|
|
||
CERTCTL_STEPCA_URL|
|
||
CERTCTL_STEPCA_FINGERPRINT|
|
||
CERTCTL_STEPCA_PROVISIONER|
|
||
CERTCTL_STEPCA_PROVISIONER_NAME|
|
||
CERTCTL_STEPCA_PROVISIONER_KEY|
|
||
CERTCTL_STEPCA_PROVISIONER_JWK|
|
||
CERTCTL_STEPCA_PROVISIONER_PASSWORD|
|
||
CERTCTL_STEPCA_PASSWORD|
|
||
CERTCTL_STEPCA_KEY_PATH|
|
||
CERTCTL_STEPCA_ROOT_CA|
|
||
CERTCTL_WEBHOOK_URL|
|
||
CERTCTL_WEBHOOK_SECRET|
|
||
CERTCTL_ACME_EAB_KID|
|
||
CERTCTL_ACME_EAB_HMAC|
|
||
CERTCTL_ACME_DNS_PROPAGATION_WAIT|
|
||
CERTCTL_AUDIT_EXCLUDE_PATHS|
|
||
CERTCTL_TLS_|
|
||
CERTCTL_TLS_INSECURE_SKIP_VERIFY|
|
||
CERTCTL_SERVER_CA_BUNDLE_PATH|
|
||
CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY|
|
||
CERTCTL_QA_[A-Z_]+
|
||
)$'
|
||
# ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* /
|
||
# CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT /
|
||
# CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* /
|
||
# CERTCTL_QA_* sets are documented integration-surface contracts
|
||
# (script invocations, per-issuer config-blob field names,
|
||
# per-notifier config-blob field names, demo-stack overrides,
|
||
# test fixtures) — not server-side env vars in config.go.
|
||
# The audit's "37 docs-only" count over-flagged these; the
|
||
# closure narrows the gate to the specific drift sites
|
||
# (renewal-interval rename + 6 config-only) and allowlists
|
||
# the documented external contracts here.
|
||
ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ')
|
||
DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true)
|
||
CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true)
|
||
if [ -n "$DOCS_ONLY" ]; then
|
||
echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:"
|
||
echo "$DOCS_ONLY"
|
||
echo ""
|
||
echo "Either delete from docs (phantom/typo) or add to config.go,"
|
||
echo "or add to the ALLOWED list with a one-line justification."
|
||
exit 1
|
||
fi
|
||
if [ -n "$CONFIG_ONLY" ]; then
|
||
echo "G-3 regression: env var(s) defined in Go source but never documented:"
|
||
echo "$CONFIG_ONLY"
|
||
echo ""
|
||
echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it."
|
||
exit 1
|
||
fi
|
||
echo "G-3 env-var docs drift guardrail: clean."
|
||
|
||
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
|