[object Object]

← back to Cli Printing Press

ci(ci): split fast and full test lanes (#357)

9e260025fa5b357c2750c97d95ca573a6a3a9edf · 2026-04-27 22:54:35 -0700 · Trevin Chow

Files touched

Diff

commit 9e260025fa5b357c2750c97d95ca573a6a3a9edf
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 27 22:54:35 2026 -0700

    ci(ci): split fast and full test lanes (#357)
---
 .github/workflows/lint.yml                        | 115 +++++++++++++++++++++-
 internal/browsersniff/classifier.go               |  11 ++-
 internal/cli/verify_skill_test.go                 |   8 ++
 internal/cli/verify_skill_unknown_command_test.go |   6 ++
 internal/generator/generator_test.go              |  17 ++++
 internal/generator/session_handshake_test.go      |   6 ++
 internal/openapi/parser_test.go                   |   4 +
 7 files changed, 163 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index af73f666..d142f5a1 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,4 +1,4 @@
-name: Lint
+name: CI
 
 on:
   pull_request:
@@ -62,7 +62,7 @@ jobs:
 
   test:
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 10
     steps:
       - uses: actions/checkout@v6
 
@@ -73,4 +73,113 @@ jobs:
           cache-dependency-path: go.sum
 
       - name: Run Go tests
-        run: go test ./...
+        shell: bash
+        run: |
+          set -o pipefail
+
+          go test -short -json -timeout 12m ./... 2>&1 | tee /tmp/go-test.json
+          status=${PIPESTATUS[0]}
+
+          python3 - /tmp/go-test.json <<'PY'
+          import json
+          import sys
+
+          packages = {}
+          tests = 0
+          with open(sys.argv[1]) as events:
+            for line in events:
+              try:
+                  event = json.loads(line)
+              except Exception:
+                  continue
+              if event.get("Action") == "pass" and event.get("Test"):
+                  tests += 1
+              if event.get("Action") in {"pass", "fail"} and event.get("Test") is None:
+                  packages[event["Package"]] = event.get("Elapsed", 0)
+
+          print("Slowest Go packages:")
+          for pkg, elapsed in sorted(packages.items(), key=lambda item: -item[1])[:15]:
+              print(f"{elapsed:6.1f}s {pkg}")
+          print(f"test_pass_events {tests}")
+          PY
+
+          exit "$status"
+
+  generated-test:
+    needs: test
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - name: Check whether full generated tests are needed
+        id: changes
+        shell: bash
+        run: |
+          set -euo pipefail
+
+          git fetch --no-tags --prune origin "${{ github.base_ref }}"
+          mapfile -t changed < <(git diff --name-only --diff-filter=ACMR "origin/${{ github.base_ref }}...HEAD")
+
+          echo "Changed files:"
+          printf '  %s\n' "${changed[@]}"
+
+          needs_full=false
+          for file in "${changed[@]}"; do
+            case "$file" in
+              .github/workflows/lint.yml|go.mod|go.sum|cmd/*|catalog/*|internal/cli/*|internal/generator/*|internal/openapi/*|internal/pipeline/*|internal/spec/*|testdata/*)
+                needs_full=true
+                break
+                ;;
+            esac
+          done
+
+          echo "needs_full=$needs_full" >> "$GITHUB_OUTPUT"
+
+      - name: Skip full generated tests
+        if: steps.changes.outputs.needs_full != 'true'
+        run: |
+          echo "Skipping full generated tests: no generator, parser, pipeline, CLI, module, workflow, catalog, or testdata changes."
+
+      - uses: actions/setup-go@v6
+        if: steps.changes.outputs.needs_full == 'true'
+        with:
+          go-version-file: go.mod
+          cache: true
+          cache-dependency-path: go.sum
+
+      - name: Run full Go tests
+        if: steps.changes.outputs.needs_full == 'true'
+        shell: bash
+        run: |
+          set -o pipefail
+
+          go test -json -timeout 12m ./... 2>&1 | tee /tmp/go-test-full.json
+          status=${PIPESTATUS[0]}
+
+          python3 - /tmp/go-test-full.json <<'PY'
+          import json
+          import sys
+
+          packages = {}
+          tests = 0
+          with open(sys.argv[1]) as events:
+            for line in events:
+              try:
+                  event = json.loads(line)
+              except Exception:
+                  continue
+              if event.get("Action") == "pass" and event.get("Test"):
+                  tests += 1
+              if event.get("Action") in {"pass", "fail"} and event.get("Test") is None:
+                  packages[event["Package"]] = event.get("Elapsed", 0)
+
+          print("Slowest Go packages:")
+          for pkg, elapsed in sorted(packages.items(), key=lambda item: -item[1])[:15]:
+              print(f"{elapsed:6.1f}s {pkg}")
+          print(f"test_pass_events {tests}")
+          PY
+
+          exit "$status"
diff --git a/internal/browsersniff/classifier.go b/internal/browsersniff/classifier.go
index 2606f423..a18b57d3 100644
--- a/internal/browsersniff/classifier.go
+++ b/internal/browsersniff/classifier.go
@@ -6,6 +6,7 @@ import (
 	"net/url"
 	"regexp"
 	"strings"
+	"sync"
 )
 
 type EndpointGroup struct {
@@ -18,6 +19,7 @@ var (
 	uuidSegmentPattern  = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
 	hashSegmentPattern  = regexp.MustCompile(`(?i)^[0-9a-f]{32,}$`)
 	numericPattern      = regexp.MustCompile(`^\d+$`)
+	blocklistMu         sync.RWMutex
 	additionalBlocklist []string
 )
 
@@ -25,7 +27,11 @@ func ClassifyEntries(entries []EnrichedEntry) (api []EnrichedEntry, noise []Enri
 	api = make([]EnrichedEntry, 0, len(entries))
 	noise = make([]EnrichedEntry, 0, len(entries))
 
-	blocklist := append(DefaultBlocklist(), additionalBlocklist...)
+	blocklistMu.RLock()
+	extraBlocklist := append([]string(nil), additionalBlocklist...)
+	blocklistMu.RUnlock()
+
+	blocklist := append(DefaultBlocklist(), extraBlocklist...)
 	for _, entry := range entries {
 		score := scoreEntry(entry, blocklist)
 		classified := entry
@@ -45,6 +51,9 @@ func ClassifyEntries(entries []EnrichedEntry) (api []EnrichedEntry, noise []Enri
 }
 
 func SetAdditionalBlocklist(domains []string) {
+	blocklistMu.Lock()
+	defer blocklistMu.Unlock()
+
 	additionalBlocklist = append([]string(nil), domains...)
 }
 
diff --git a/internal/cli/verify_skill_test.go b/internal/cli/verify_skill_test.go
index 1eed77db..25966649 100644
--- a/internal/cli/verify_skill_test.go
+++ b/internal/cli/verify_skill_test.go
@@ -17,6 +17,8 @@ import (
 // `printing-press verify-skill` catches it at generation time instead of
 // letting it ship to the library.
 func TestVerifySkill_DetectsWrongFlagOnCommand(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 
@@ -82,6 +84,8 @@ fixture-pp-cli search "chicken" --max-time 30m
 // the verifier does NOT report a false-positive flag-commands finding
 // when the SKILL example uses a flag declared on the top-level command.
 func TestVerifySkill_NoFalsePositiveOnSharedLeafName(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
@@ -146,6 +150,8 @@ func newProfileSaveCmd() *cobra.Command {
 // TestVerifySkill_PassesWhenSkillMatches confirms the verifier doesn't
 // false-positive on a well-formed CLI.
 func TestVerifySkill_PassesWhenSkillMatches(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 	require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
@@ -169,6 +175,8 @@ func newSearchCmd() *cobra.Command {
 
 // TestVerifySkill_RejectsMissingInputs confirms usage errors (code 2).
 func TestVerifySkill_RejectsMissingInputs(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 
 	// Missing --dir
diff --git a/internal/cli/verify_skill_unknown_command_test.go b/internal/cli/verify_skill_unknown_command_test.go
index 77607c5f..e8458814 100644
--- a/internal/cli/verify_skill_unknown_command_test.go
+++ b/internal/cli/verify_skill_unknown_command_test.go
@@ -16,6 +16,8 @@ import (
 // path (`<cli> qr get-qrcode`) for a resource the cobra source actually
 // registers as a leaf (`<cli> qr`) is rejected.
 func TestVerifySkill_DetectsUnknownCommand(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 
@@ -68,6 +70,8 @@ description: "fixture"
 // negative case: a SKILL whose command-reference paths all map to real
 // cobra Use: declarations passes the unknown-command check.
 func TestVerifySkill_UnknownCommandPassesWhenAllPathsResolve(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 
@@ -112,6 +116,8 @@ description: "fixture"
 // built-in commands (help, completion, version) are whitelisted — references
 // to `<cli> help` in SKILL.md must NOT fire unknown-command.
 func TestVerifySkill_UnknownCommandSkipsBuiltins(t *testing.T) {
+	t.Parallel()
+
 	bin := buildPrintingPressBinary(t)
 	dir := t.TempDir()
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index bb59ae70..87ff23de 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -71,7 +71,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
 	}
 
 	for _, tt := range tests {
+		tt := tt //nolint:modernize // keep the parallel subtest capture explicit
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
 			apiSpec, err := spec.Parse(tt.specPath)
 			require.NoError(t, err)
 
@@ -523,6 +526,14 @@ func countFiles(t *testing.T, root string) int {
 
 func runGoCommand(t *testing.T, dir string, args ...string) {
 	t.Helper()
+	if testing.Short() && len(args) > 0 && (args[0] == "build" || args[0] == "test") {
+		t.Skip("generated CLI compile tests run in the full generated-test CI lane")
+	}
+	runGoCommandRequired(t, dir, args...)
+}
+
+func runGoCommandRequired(t *testing.T, dir string, args ...string) {
+	t.Helper()
 
 	// Generated-project compile tests exercise module resolution via -mod=mod;
 	// production Validate still owns the go mod tidy quality gate.
@@ -1039,6 +1050,8 @@ func TestGenerateHTMLExtractionPerModeGating(t *testing.T) {
 	}
 
 	t.Run("embedded-json-only omits page+links helpers", func(t *testing.T) {
+		t.Parallel()
+
 		dir := filepath.Join(t.TempDir(), "ejonly-pp-cli")
 		require.NoError(t, New(specWithMode("ejonly", spec.HTMLExtractModeEmbeddedJSON), dir).Generate())
 		body := read(t, dir)
@@ -1064,6 +1077,8 @@ func TestGenerateHTMLExtractionPerModeGating(t *testing.T) {
 	})
 
 	t.Run("page-only omits embedded-json helpers", func(t *testing.T) {
+		t.Parallel()
+
 		dir := filepath.Join(t.TempDir(), "pageonly-pp-cli")
 		require.NoError(t, New(specWithMode("pageonly", spec.HTMLExtractModePage), dir).Generate())
 		body := read(t, dir)
@@ -1082,6 +1097,8 @@ func TestGenerateHTMLExtractionPerModeGating(t *testing.T) {
 	})
 
 	t.Run("mixed modes emit both branches", func(t *testing.T) {
+		t.Parallel()
+
 		// One endpoint per mode in the same spec.
 		mixedSpec := &spec.APISpec{
 			Name:    "mixed",
diff --git a/internal/generator/session_handshake_test.go b/internal/generator/session_handshake_test.go
index 22b8aa40..a3c881fc 100644
--- a/internal/generator/session_handshake_test.go
+++ b/internal/generator/session_handshake_test.go
@@ -13,6 +13,8 @@ import (
 // session.go helper and wires it into the client when auth.type is
 // "session_handshake". This is the WU-2 acceptance test (retro issue #174).
 func TestSessionHandshakeGeneration(t *testing.T) {
+	t.Parallel()
+
 	sp := &spec.APISpec{
 		Name:        "demo",
 		Version:     "1.0.0",
@@ -104,6 +106,8 @@ func TestSessionHandshakeGeneration(t *testing.T) {
 }
 
 func TestSessionHandshakeBrowserTransportSharesJar(t *testing.T) {
+	t.Parallel()
+
 	sp := &spec.APISpec{
 		Name:          "demo",
 		Version:       "1.0.0",
@@ -171,6 +175,8 @@ func TestSessionHandshakeBrowserTransportSharesJar(t *testing.T) {
 // TestSessionHandshakeNotEmittedForOtherAuth verifies the session helper is
 // NOT emitted for non-session auth types — no file bloat for bearer_token CLIs.
 func TestSessionHandshakeNotEmittedForOtherAuth(t *testing.T) {
+	t.Parallel()
+
 	sp := &spec.APISpec{
 		Name:        "demo",
 		Version:     "1.0.0",
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 6052e853..8da9694d 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -168,6 +168,9 @@ resources:
 
 func TestGenerateFromOpenAPICompiles(t *testing.T) {
 	t.Parallel()
+	if testing.Short() {
+		t.Skip("OpenAPI generated CLI compile coverage runs in the generated-test CI lane")
+	}
 
 	tests := []struct {
 		name     string
@@ -178,6 +181,7 @@ func TestGenerateFromOpenAPICompiles(t *testing.T) {
 	}
 
 	for _, tt := range tests {
+		tt := tt //nolint:modernize // keep the parallel subtest capture explicit
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 

← 7f248d59 feat(cli): emit MCP tools for novel CLI features via shell-o  ·  back to Cli Printing Press  ·  fix(cli): manifest emission no longer gated by stale mcp_rea 2e892e36 →