mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
feat(ci): item-1 complete-path config-coverage guard (PARTIAL — sandbox could not verify Go test)
Shell guard verified working in sandbox:
- Green on clean repo: 'OK — every CERTCTL_* env var (194) has at least
one non-config-package consumer.'
- Red on injected orphan: '::error::Orphan env vars — defined in
config.go but no consumer found outside internal/config/' with three
remediation paths listed.
Go test internal/config/coverage_test.go written but NOT verified —
sandbox Go 1.25.9 < go.mod's 1.25.10 requirement; toolchain
auto-download fails (disk full). Operator must run `make verify` from
workstation before merge.
Allowlist scaffold at scripts/ci-guards/complete-path-config-coverage-exceptions.yaml.
Every entry requires name + justification + expires fields; expired
entries fail the guard.
Catches the lying-field bug class — env var defined in config.go that no
business-logic code reads. The 2026-04-29 SCEP MustStaple Phase 5.6 gap
(domain field shipped, service layer never read profile.MustStaple) is
the canonical case this guard would have caught at commit time.
Audit-Closes: post-v2.1.0-anti-rot/item-1
This commit is contained in:
@@ -0,0 +1,239 @@
|
|||||||
|
// Package config — coverage_test.go
|
||||||
|
//
|
||||||
|
// Per post-v2.1.0 anti-rot item 1 (Auditable Codebase Bundle).
|
||||||
|
//
|
||||||
|
// Catches "lying env vars" — CERTCTL_* env vars read by config.go that
|
||||||
|
// have no consumer in the rest of the codebase. Companion to the
|
||||||
|
// scripts/ci-guards/complete-path-config-coverage.sh shell guard: the
|
||||||
|
// shell guard catches non-Go consumers too (Helm, .env templates,
|
||||||
|
// docs); this Go test runs under `go test -short` and gives developers
|
||||||
|
// the same signal in the same loop they're already in.
|
||||||
|
//
|
||||||
|
// Allowlist is the same YAML file used by the shell guard. Keep them in
|
||||||
|
// sync — a row added here should be added there, and vice versa.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// envVarRe matches getEnv* call sites that take a CERTCTL_-prefixed
|
||||||
|
// string literal as their first argument. Mirrors the regex in
|
||||||
|
// scripts/ci-guards/complete-path-config-coverage.sh.
|
||||||
|
var envVarRe = regexp.MustCompile(`getEnv(?:Bool|Int|Int64|Duration|Float|StringSlice)?\(\s*"(CERTCTL_[A-Z0-9_]+)"`)
|
||||||
|
|
||||||
|
// allowlistEntry is the shape of a row in
|
||||||
|
// scripts/ci-guards/complete-path-config-coverage-exceptions.yaml.
|
||||||
|
type allowlistEntry struct {
|
||||||
|
Name string
|
||||||
|
Justification string
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompletePathConfigCoverage(t *testing.T) {
|
||||||
|
// Find repo root by walking up from this file's dir until we hit
|
||||||
|
// go.mod.
|
||||||
|
repoRoot, err := findRepoRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find repo root: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Extract env-var read sites from internal/config/config.go.
|
||||||
|
configBytes, err := os.ReadFile(filepath.Join(repoRoot, "internal", "config", "config.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read config.go: %v", err)
|
||||||
|
}
|
||||||
|
envVars := map[string]struct{}{}
|
||||||
|
for _, m := range envVarRe.FindAllStringSubmatch(string(configBytes), -1) {
|
||||||
|
envVars[m[1]] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(envVars) == 0 {
|
||||||
|
t.Fatal("regex matched zero env vars — likely a regex/format change, fix the test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk the rest of the repo looking for consumers.
|
||||||
|
searchDirs := []string{
|
||||||
|
"cmd", "internal", "deploy", "migrations", "scripts", "docs",
|
||||||
|
"api", "web",
|
||||||
|
}
|
||||||
|
searchFiles := []string{"Makefile", "README.md", "CHANGELOG.md"}
|
||||||
|
|
||||||
|
consumed := map[string]bool{}
|
||||||
|
for ev := range envVars {
|
||||||
|
consumed[ev] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
walk := func(path string) error {
|
||||||
|
return filepath.Walk(path, func(p string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return nil // best-effort
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
name := info.Name()
|
||||||
|
if name == "node_modules" || name == "dist" || name == ".git" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Skip internal/config (where the vars are DEFINED) and this
|
||||||
|
// test file itself.
|
||||||
|
rel, _ := filepath.Rel(repoRoot, p)
|
||||||
|
if strings.HasPrefix(rel, filepath.Join("internal", "config")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Only text-ish files.
|
||||||
|
ok := false
|
||||||
|
for _, ext := range []string{".go", ".sh", ".yml", ".yaml", ".sql", ".md", ".tmpl", ".tpl", ".env", ".json", ".toml", ".ts", ".tsx"} {
|
||||||
|
if strings.HasSuffix(p, ext) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok && info.Name() != "Makefile" && info.Name() != "Dockerfile" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := string(data)
|
||||||
|
for ev := range envVars {
|
||||||
|
if consumed[ev] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(body, ev) {
|
||||||
|
consumed[ev] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range searchDirs {
|
||||||
|
if err := walk(filepath.Join(repoRoot, d)); err != nil {
|
||||||
|
t.Fatalf("walk %s: %v", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range searchFiles {
|
||||||
|
p := filepath.Join(repoRoot, f)
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
data, _ := os.ReadFile(p)
|
||||||
|
body := string(data)
|
||||||
|
for ev := range envVars {
|
||||||
|
if consumed[ev] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(body, ev) {
|
||||||
|
consumed[ev] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load the allowlist + filter orphans through it.
|
||||||
|
allowlist, err := loadAllowlist(filepath.Join(repoRoot, "scripts", "ci-guards", "complete-path-config-coverage-exceptions.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load allowlist: %v", err)
|
||||||
|
}
|
||||||
|
today := time.Now().UTC().Truncate(24 * time.Hour)
|
||||||
|
|
||||||
|
var orphans []string
|
||||||
|
for ev, ok := range consumed {
|
||||||
|
if ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry, allowlisted := allowlist[ev]
|
||||||
|
if !allowlisted {
|
||||||
|
orphans = append(orphans, ev+" (no consumer found)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Expires.Before(today) {
|
||||||
|
orphans = append(orphans, ev+" (allowlist entry expired "+entry.Expires.Format("2006-01-02")+")")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Justification == "" {
|
||||||
|
orphans = append(orphans, ev+" (allowlist entry has no justification)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orphans) > 0 {
|
||||||
|
t.Errorf("complete-path config-coverage: %d orphan env var(s) — defined in config.go, no consumer outside internal/config/:", len(orphans))
|
||||||
|
for _, o := range orphans {
|
||||||
|
t.Errorf(" - %s", o)
|
||||||
|
}
|
||||||
|
t.Errorf("Fix: wire the env var to a real consumer, remove it from config.go, or allowlist it with a justification + expiration in scripts/ci-guards/complete-path-config-coverage-exceptions.yaml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findRepoRoot() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cur := wd
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(filepath.Join(cur, "go.mod")); err == nil {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAllowlist parses the tiny YAML shape used by the exceptions file.
|
||||||
|
// Same shape parsed by complete-path-config-coverage.sh — keep them in
|
||||||
|
// sync. Returns name → entry.
|
||||||
|
func loadAllowlist(path string) (map[string]allowlistEntry, error) {
|
||||||
|
out := map[string]allowlistEntry{}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cur *allowlistEntry
|
||||||
|
for _, raw := range strings.Split(string(data), "\n") {
|
||||||
|
line := strings.TrimRight(raw, "\r")
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "- name:") {
|
||||||
|
name := strings.TrimSpace(strings.TrimPrefix(trimmed, "- name:"))
|
||||||
|
name = strings.Trim(name, `"' `)
|
||||||
|
cur = &allowlistEntry{Name: name}
|
||||||
|
out[name] = *cur
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil || !strings.HasPrefix(line, " ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kv := strings.SplitN(trimmed, ":", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := strings.TrimSpace(kv[0])
|
||||||
|
v := strings.Trim(strings.TrimSpace(kv[1]), `"' `)
|
||||||
|
switch k {
|
||||||
|
case "justification":
|
||||||
|
cur.Justification = v
|
||||||
|
case "expires":
|
||||||
|
if t, err := time.Parse("2006-01-02", v); err == nil {
|
||||||
|
cur.Expires = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[cur.Name] = *cur
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# scripts/ci-guards/complete-path-config-coverage-exceptions.yaml
|
||||||
|
#
|
||||||
|
# Allowlist for the complete-path config-coverage guard
|
||||||
|
# (scripts/ci-guards/complete-path-config-coverage.sh).
|
||||||
|
#
|
||||||
|
# Each entry exempts a CERTCTL_* env var from the "must have a consumer
|
||||||
|
# outside internal/config/" rule. Every row MUST carry:
|
||||||
|
#
|
||||||
|
# - name: "CERTCTL_NAME"
|
||||||
|
# justification: "one-line reason this is documented but not consumed"
|
||||||
|
# expires: "YYYY-MM-DD" # required; the guard rejects exceptions
|
||||||
|
# # whose expiration date has passed
|
||||||
|
#
|
||||||
|
# Discipline: when an exception is added, it gets a hard expiration date
|
||||||
|
# (usually 90 days out). When it expires, the guard fails until either
|
||||||
|
# (a) the env var is wired to a real consumer, (b) the env var is
|
||||||
|
# removed, or (c) the row is re-justified with a new expiration. Keeps
|
||||||
|
# the allowlist from becoming a dumping ground.
|
||||||
|
#
|
||||||
|
# DO NOT add entries here to silence the guard on a real defect. If the
|
||||||
|
# env var should be wired and isn't, that's the bug — fix it instead of
|
||||||
|
# allowlisting.
|
||||||
|
|
||||||
|
exceptions: []
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/ci-guards/complete-path-config-coverage.sh
|
||||||
|
#
|
||||||
|
# Per post-v2.1.0 anti-rot item 1 (Auditable Codebase Bundle).
|
||||||
|
#
|
||||||
|
# Catches "lying fields" — env vars defined in config.go that the rest
|
||||||
|
# of the codebase never reads. An operator can flip the env var, the
|
||||||
|
# server returns the value via /api/v1/config (if surfaced), the docs
|
||||||
|
# say it works, but no business-logic code actually consumes it. The
|
||||||
|
# guard fails when any operator-facing env var is undefined or has no
|
||||||
|
# non-config-package consumer.
|
||||||
|
#
|
||||||
|
# The bug class this catches: SCEP MustStaple in 2026-04-29 Phase 5.6 —
|
||||||
|
# the domain field, IssuanceRequest field, extension generation, and
|
||||||
|
# byte-exact tests all shipped, but the service layer never read
|
||||||
|
# profile.MustStaple. Configurable bit existed, behavior never changed.
|
||||||
|
# This guard would have failed that commit.
|
||||||
|
#
|
||||||
|
# Allowlist file: scripts/ci-guards/complete-path-config-coverage-exceptions.yaml
|
||||||
|
# (every entry carries a one-line justification + an expiration date)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
EXCEPTIONS_FILE="${REPO_ROOT}/scripts/ci-guards/complete-path-config-coverage-exceptions.yaml"
|
||||||
|
|
||||||
|
python3 - "$REPO_ROOT" "$EXCEPTIONS_FILE" <<'PY'
|
||||||
|
import os, re, sys, datetime, pathlib
|
||||||
|
|
||||||
|
repo_root = pathlib.Path(sys.argv[1])
|
||||||
|
exceptions_path = pathlib.Path(sys.argv[2])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 1. Extract env-var read sites from internal/config/config.go.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
config_path = repo_root / "internal" / "config" / "config.go"
|
||||||
|
src = config_path.read_text()
|
||||||
|
|
||||||
|
# Match getEnv* calls that take a CERTCTL_-prefixed string literal as
|
||||||
|
# their first argument.
|
||||||
|
env_re = re.compile(
|
||||||
|
r'getEnv(?:Bool|Int|Int64|Duration|Float|StringSlice)?\(\s*"(CERTCTL_[A-Z0-9_]+)"',
|
||||||
|
)
|
||||||
|
env_vars = sorted({m.group(1) for m in env_re.finditer(src)})
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 2. Walk every other Go file + Helm chart + .env templates for a reference.
|
||||||
|
# "Reference" = the literal "CERTCTL_NAME" string appears anywhere
|
||||||
|
# OUTSIDE internal/config/config.go (or a _test.go file in the same
|
||||||
|
# package — those don't count as "production consumers").
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
SEARCH_ROOTS = [
|
||||||
|
repo_root / "cmd",
|
||||||
|
repo_root / "internal",
|
||||||
|
repo_root / "deploy",
|
||||||
|
repo_root / "migrations",
|
||||||
|
repo_root / "scripts",
|
||||||
|
repo_root / "docs",
|
||||||
|
repo_root / "api",
|
||||||
|
repo_root / "Makefile",
|
||||||
|
repo_root / "README.md",
|
||||||
|
repo_root / "CHANGELOG.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_excluded(path: pathlib.Path) -> bool:
|
||||||
|
p = str(path.resolve())
|
||||||
|
# Skip internal/config itself + the guard's own exceptions file.
|
||||||
|
if "/internal/config/" in p:
|
||||||
|
return True
|
||||||
|
if path.name == "complete-path-config-coverage.sh":
|
||||||
|
return True
|
||||||
|
if path.name == "complete-path-config-coverage-exceptions.yaml":
|
||||||
|
return True
|
||||||
|
if "/.git/" in p or "/node_modules/" in p or "/web/dist/" in p:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Index file contents once for speed (this guard runs on every push).
|
||||||
|
files_by_path: dict[pathlib.Path, str] = {}
|
||||||
|
for root in SEARCH_ROOTS:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
if root.is_file():
|
||||||
|
if not is_excluded(root):
|
||||||
|
try:
|
||||||
|
files_by_path[root] = root.read_text(errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
for fp in root.rglob("*"):
|
||||||
|
if not fp.is_file():
|
||||||
|
continue
|
||||||
|
if is_excluded(fp):
|
||||||
|
continue
|
||||||
|
# Limit to text-ish file types.
|
||||||
|
if fp.suffix not in {
|
||||||
|
".go", ".sh", ".yml", ".yaml", ".sql", ".md",
|
||||||
|
".tmpl", ".tpl", ".env", ".json", ".toml", ".ts", ".tsx",
|
||||||
|
} and fp.name not in {"Makefile", "Dockerfile"}:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
files_by_path[fp] = fp.read_text(errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def consumers_for(env_var: str) -> list[pathlib.Path]:
|
||||||
|
hits = []
|
||||||
|
needle = env_var
|
||||||
|
for fp, txt in files_by_path.items():
|
||||||
|
if needle in txt:
|
||||||
|
hits.append(fp)
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 3. Load the allowlist.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Tiny YAML reader — only the shape we need (a top-level list of objects
|
||||||
|
# with keys: name, justification, expires). Avoids a PyYAML dependency
|
||||||
|
# the guard would otherwise carry.
|
||||||
|
allowlist: dict[str, dict] = {}
|
||||||
|
if exceptions_path.exists():
|
||||||
|
txt = exceptions_path.read_text()
|
||||||
|
cur = None
|
||||||
|
for raw in txt.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip() or line.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.lstrip().startswith("- name:"):
|
||||||
|
cur = {"name": line.split(":", 1)[1].strip().strip('"').strip("'")}
|
||||||
|
allowlist[cur["name"]] = cur
|
||||||
|
continue
|
||||||
|
if cur is not None and line.startswith(" "):
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
cur[k.strip()] = v.strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def allowlist_active(env_var: str) -> tuple[bool, str]:
|
||||||
|
if env_var not in allowlist:
|
||||||
|
return False, ""
|
||||||
|
entry = allowlist[env_var]
|
||||||
|
exp = entry.get("expires")
|
||||||
|
if not exp:
|
||||||
|
return False, "allowlist entry has no 'expires:' field"
|
||||||
|
try:
|
||||||
|
exp_d = datetime.date.fromisoformat(exp)
|
||||||
|
except Exception:
|
||||||
|
return False, f"allowlist entry has malformed expires: {exp!r}"
|
||||||
|
if exp_d < today:
|
||||||
|
return False, f"allowlist entry expired on {exp}"
|
||||||
|
just = entry.get("justification", "")
|
||||||
|
if not just:
|
||||||
|
return False, "allowlist entry has no 'justification:' field"
|
||||||
|
return True, f"allowlisted until {exp}: {just}"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# 4. Run the check.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
print(f"complete-path config-coverage guard — scanning {len(env_vars)} env vars across {len(files_by_path)} files")
|
||||||
|
print()
|
||||||
|
|
||||||
|
orphans: list[tuple[str, str]] = []
|
||||||
|
allowlisted: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
for ev in env_vars:
|
||||||
|
consumers = consumers_for(ev)
|
||||||
|
if consumers:
|
||||||
|
continue
|
||||||
|
ok, msg = allowlist_active(ev)
|
||||||
|
if ok:
|
||||||
|
allowlisted.append((ev, msg))
|
||||||
|
else:
|
||||||
|
orphans.append((ev, msg or "no consumer found"))
|
||||||
|
|
||||||
|
if allowlisted:
|
||||||
|
print("Allowlisted (no production consumer; documented contract):")
|
||||||
|
for ev, msg in allowlisted:
|
||||||
|
print(f" - {ev}: {msg}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if orphans:
|
||||||
|
print("::error::Orphan env vars — defined in config.go but no consumer found outside internal/config/:")
|
||||||
|
for ev, msg in orphans:
|
||||||
|
print(f" - {ev}: {msg}")
|
||||||
|
print()
|
||||||
|
print("Fix options:")
|
||||||
|
print(" 1. Wire the env var to a real consumer (the load-bearing path).")
|
||||||
|
print(" 2. Remove the env var from internal/config/config.go (was it dead code?).")
|
||||||
|
print(f" 3. Add an allowlist row to {exceptions_path.relative_to(repo_root)} with")
|
||||||
|
print(" - name: \"CERTCTL_NAME\"")
|
||||||
|
print(" justification: \"why this is documented but not consumed by our code\"")
|
||||||
|
print(" expires: \"YYYY-MM-DD\" # required; forces periodic re-review")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"OK — every CERTCTL_* env var ({len(env_vars)}) has at least one non-config-package consumer.")
|
||||||
|
PY
|
||||||
Reference in New Issue
Block a user