From 09af69243333356f146880c5eed5ef1b171d44f9 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 14:31:28 -0400 Subject: [PATCH 1/2] fix(companion): build a CGO-free CLI so companion publish links statically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion publisher cross-compiles the CLI with CGO_ENABLED=0 into bin/linux/$TARGETARCH/codefly for the base + language images. That build failed to link because pkg/engine unconditionally installed the tree-sitter semantic analyzer (core/code/semantic), which requires cgo — so `codefly companion publish` (and any static CLI build) errored out with tree-sitter bindings excluded under CGO_ENABLED=0. Split newSource by the cgo build constraint: the cgo build keeps the analyzer (unchanged behavior for the normal CLI); the !cgo build uses core's CGO-free default. The companion CLI only builds and runs services in-container and never serves the semantic gateway, so it loses nothing. Co-Authored-By: Claude Opus 4.8 --- pkg/engine/source.go | 9 --------- pkg/engine/source_cgo.go | 20 ++++++++++++++++++++ pkg/engine/source_nocgo.go | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 pkg/engine/source_cgo.go create mode 100644 pkg/engine/source_nocgo.go diff --git a/pkg/engine/source.go b/pkg/engine/source.go index f08040fd..9546390b 100644 --- a/pkg/engine/source.go +++ b/pkg/engine/source.go @@ -9,7 +9,6 @@ import ( "github.com/codefly-dev/cli/pkg/sourceworkspace" codecore "github.com/codefly-dev/core/code" - "github.com/codefly-dev/core/code/semantic" codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" ) @@ -20,14 +19,6 @@ type Source struct { server *codecore.DefaultCodeServer } -// Core omits the tree-sitter analyzer by default so Go service agents stay -// CGO-free. The CLI is the workspace-wide source behavior behind the gateway — -// semantic index and symbol mutation are part of its contract — so it installs -// the analyzer explicitly. See core/code.WithSemanticAnalyzer. -func newSource(root string) *Source { - return &Source{server: codecore.NewDefaultCodeServer(root, codecore.WithSemanticAnalyzer(semantic.New()))} -} - // ExecuteCode executes a language-neutral Code request. func (s *Source) ExecuteCode(ctx context.Context, request *codev0.CodeRequest) (*codev0.CodeResponse, error) { if s == nil { diff --git a/pkg/engine/source_cgo.go b/pkg/engine/source_cgo.go new file mode 100644 index 00000000..e2b0de30 --- /dev/null +++ b/pkg/engine/source_cgo.go @@ -0,0 +1,20 @@ +//go:build cgo + +package engine + +import ( + codecore "github.com/codefly-dev/core/code" + "github.com/codefly-dev/core/code/semantic" +) + +// Core omits the tree-sitter analyzer by default so Go service agents stay +// CGO-free. The CLI is the workspace-wide source behavior behind the gateway — +// semantic index and symbol mutation are part of its contract — so it installs +// the analyzer explicitly. See core/code.WithSemanticAnalyzer. +// +// The analyzer's tree-sitter bindings require cgo, so this variant is only +// compiled for cgo builds. The !cgo variant (used by the statically linked +// companion CLI) drops it — see source_nocgo.go. +func newSource(root string) *Source { + return &Source{server: codecore.NewDefaultCodeServer(root, codecore.WithSemanticAnalyzer(semantic.New()))} +} diff --git a/pkg/engine/source_nocgo.go b/pkg/engine/source_nocgo.go new file mode 100644 index 00000000..456d226b --- /dev/null +++ b/pkg/engine/source_nocgo.go @@ -0,0 +1,15 @@ +//go:build !cgo + +package engine + +import codecore "github.com/codefly-dev/core/code" + +// Without cgo the tree-sitter semantic analyzer cannot be linked, so the source +// behavior runs with core's CGO-free default. This is the build the companion +// publisher produces (CGO_ENABLED=0, statically linked for alpine): the +// companion CLI only builds and runs services in-container and never serves the +// semantic gateway, so the analyzer is not needed. The cgo build installs it — +// see source_cgo.go. +func newSource(root string) *Source { + return &Source{server: codecore.NewDefaultCodeServer(root)} +} From 6ab3e1e365ae7548a2f330c8ba35cf2420d3ffa1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 15:22:19 -0400 Subject: [PATCH 2/2] fix(companion): gate CGO-free build on explicit codefly_nosemantic tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static companion/self-cross build dropped the tree-sitter semantic analyzer implicitly, by splitting newSource on the automatic `cgo` build constraint (`//go:build cgo` / `!cgo`). That made CGO_ENABLED=0 a silent switch: any build without cgo — including an *accidental* one, e.g. `go install` on a machine with no C compiler, where Go auto-sets CGO_ENABLED=0 — quietly produced a semantic-less CLI. Before this PR that same build failed loudly ("build constraints exclude all Go files"), which is the correct signal that a normal CLI needs cgo. Trading a loud build failure for a silent capability downgrade is the exact "safe default" that ships regressions. Gate on an explicit intent tag instead: - source_semantic.go //go:build !codefly_nosemantic (default; keeps the analyzer; still fails to link under CGO_ENABLED=0, restoring the loud signal) - source_nosemantic.go //go:build codefly_nosemantic (CGO-free) The two builds that legitimately want the analyzer-free variant now opt in explicitly with `-tags codefly_nosemantic`: `codefly companion build/publish` (buildLinuxCLI) and `codefly self build --os/--arch` (buildCLICross). Renamed the files from _cgo/_nocgo to _semantic/ _nosemantic so the names track the real boundary, and named both opt-in consumers in the CGO-free variant's doc (previously only companion was mentioned). Verification: add TestNewSourceExecutesLanguageNeutralOperation, which asserts newSource returns a live Source whose base behavior runs; it covers the analyzer variant in the normal suite and the analyzer-free variant when run with -tags codefly_nosemantic. Wire a coverage-gate CI step that builds the CGO-free variant statically and runs that test under the tag, so a regression in the analyzer-free path is caught without the Docker-dependent companions.yaml job. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/go.yml | 14 +++++ cmd/companion/build.go | 8 +++ cmd/self/build.go | 8 +++ pkg/engine/source_nocgo.go | 15 ------ pkg/engine/source_nosemantic.go | 23 +++++++++ .../{source_cgo.go => source_semantic.go} | 12 +++-- pkg/engine/source_test.go | 51 +++++++++++++++++++ 7 files changed, 112 insertions(+), 19 deletions(-) delete mode 100644 pkg/engine/source_nocgo.go create mode 100644 pkg/engine/source_nosemantic.go rename pkg/engine/{source_cgo.go => source_semantic.go} (50%) create mode 100644 pkg/engine/source_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 5e78568b..cce01d97 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -93,6 +93,20 @@ jobs: with: config: ./.testcoverage.yaml + # The CGO-free companion/self-cross build (`-tags codefly_nosemantic`, + # CGO_ENABLED=0) selects pkg/engine/source_nosemantic.go. `codefly + # companion publish` exercises it only in companions.yaml, which needs + # Docker; verify here, unconditionally, that the variant both links + # statically and runs its base source behavior. Without this a regression + # in the analyzer-free variant would surface only at publish time. + - name: Verify CGO-free (companion) build variant + if: matrix.gate == 'coverage' + env: + CGO_ENABLED: "0" + run: | + go build -tags codefly_nosemantic -ldflags '-s -w -extldflags "-static"' -o /dev/null . + go test -tags codefly_nosemantic ./pkg/engine/ -run '^TestNewSourceExecutesLanguageNeutralOperation$' -count=1 + - name: Test with race detection if: matrix.gate == 'race' run: go test -failfast -race ./... -v diff --git a/cmd/companion/build.go b/cmd/companion/build.go index a054dae8..1a0776e6 100644 --- a/cmd/companion/build.go +++ b/cmd/companion/build.go @@ -279,6 +279,13 @@ func needsLinuxCLI(targets []*Companion) bool { // the alpine images can run without glibc. Flag stripping (-s -w) // drops the symbol table and DWARF info; ~25-30% size reduction with // no runtime cost. +// +// The `codefly_nosemantic` tag drops the tree-sitter semantic analyzer, which +// cannot link without cgo. It is required (not merely implied by CGO_ENABLED=0): +// without it this CGO-free build would select the analyzer variant and fail to +// link. The companion CLI only builds and runs services in-container and never +// serves the semantic gateway, so the analyzer is not needed here. See +// pkg/engine/source_nosemantic.go. func buildLinuxCLI(coreDir, arch string) error { cliDir := filepath.Join(coreDir, "..", "cli") if info, err := os.Stat(cliDir); err != nil || !info.IsDir() { @@ -290,6 +297,7 @@ func buildLinuxCLI(coreDir, arch string) error { } cmd := exec.Command("go", "build", + "-tags", "codefly_nosemantic", "-ldflags", `-s -w -extldflags "-static"`, "-o", outBin, ".", diff --git a/cmd/self/build.go b/cmd/self/build.go index 3599914d..2864f19a 100644 --- a/cmd/self/build.go +++ b/cmd/self/build.go @@ -245,12 +245,20 @@ func defaultCrossOutput(cliSrcDir, goos string) string { // buildCLICross compiles a static CLI binary for goos/goarch. Static // (CGO_ENABLED=0 + -extldflags "-static") so alpine images can run it // without glibc; stripped (-s -w) for size. Replaces scripts/build/linux.sh. +// +// The `codefly_nosemantic` tag drops the tree-sitter semantic analyzer, which +// cannot link without cgo. It is required (not merely implied by CGO_ENABLED=0): +// without it this CGO-free build would select the analyzer variant and fail to +// link. This cross binary is an in-container artifact that never serves the +// semantic gateway, and it is never installed over the running CLI, so the +// analyzer is not needed here. See pkg/engine/source_nosemantic.go. func buildCLICross(ctx context.Context, srcDir, output, goos, goarch string) error { if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { return fmt.Errorf("create output dir: %w", err) } start := time.Now() build := exec.CommandContext(ctx, "go", "build", + "-tags", "codefly_nosemantic", "-ldflags", `-s -w -extldflags "-static"`, "-o", output, ".", diff --git a/pkg/engine/source_nocgo.go b/pkg/engine/source_nocgo.go deleted file mode 100644 index 456d226b..00000000 --- a/pkg/engine/source_nocgo.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !cgo - -package engine - -import codecore "github.com/codefly-dev/core/code" - -// Without cgo the tree-sitter semantic analyzer cannot be linked, so the source -// behavior runs with core's CGO-free default. This is the build the companion -// publisher produces (CGO_ENABLED=0, statically linked for alpine): the -// companion CLI only builds and runs services in-container and never serves the -// semantic gateway, so the analyzer is not needed. The cgo build installs it — -// see source_cgo.go. -func newSource(root string) *Source { - return &Source{server: codecore.NewDefaultCodeServer(root)} -} diff --git a/pkg/engine/source_nosemantic.go b/pkg/engine/source_nosemantic.go new file mode 100644 index 00000000..6ddee1c9 --- /dev/null +++ b/pkg/engine/source_nosemantic.go @@ -0,0 +1,23 @@ +//go:build codefly_nosemantic + +package engine + +import codecore "github.com/codefly-dev/core/code" + +// Without the tree-sitter semantic analyzer the source behavior runs on core's +// CGO-free default. This variant is selected only when a build explicitly sets +// the `codefly_nosemantic` tag; the tag is the intent, and cgo being disabled is +// merely how these builds also happen to be linked. Gating on an explicit tag +// (rather than on `!cgo`) means an accidental CGO_ENABLED=0 build still fails +// loudly instead of silently producing an analyzer-less CLI — see +// source_semantic.go. +// +// Two build paths opt in, both producing statically linked binaries for alpine +// (CGO_ENABLED=0, -extldflags "-static") that only build and run services +// in-container and never serve the semantic gateway, so the analyzer is not +// needed: `codefly companion build`/`publish` (cmd/companion/build.go) and +// `codefly self build --os/--arch` (cmd/self/build.go, buildCLICross). Neither +// installs over the user's running CLI, which keeps cgo and the analyzer. +func newSource(root string) *Source { + return &Source{server: codecore.NewDefaultCodeServer(root)} +} diff --git a/pkg/engine/source_cgo.go b/pkg/engine/source_semantic.go similarity index 50% rename from pkg/engine/source_cgo.go rename to pkg/engine/source_semantic.go index e2b0de30..2eb53058 100644 --- a/pkg/engine/source_cgo.go +++ b/pkg/engine/source_semantic.go @@ -1,4 +1,4 @@ -//go:build cgo +//go:build !codefly_nosemantic package engine @@ -12,9 +12,13 @@ import ( // semantic index and symbol mutation are part of its contract — so it installs // the analyzer explicitly. See core/code.WithSemanticAnalyzer. // -// The analyzer's tree-sitter bindings require cgo, so this variant is only -// compiled for cgo builds. The !cgo variant (used by the statically linked -// companion CLI) drops it — see source_nocgo.go. +// This is the default variant: it is selected for every build that does NOT set +// the `codefly_nosemantic` tag. The analyzer's tree-sitter bindings require cgo, +// so a build that disables cgo without also setting the tag selects this file +// and fails to link ("build constraints exclude all Go files") — a loud signal +// that a normal CLI needs cgo, rather than a silent drop of the gateway. Only +// the in-container static builds that opt in with `codefly_nosemantic` get the +// analyzer-free variant — see source_nosemantic.go. func newSource(root string) *Source { return &Source{server: codecore.NewDefaultCodeServer(root, codecore.WithSemanticAnalyzer(semantic.New()))} } diff --git a/pkg/engine/source_test.go b/pkg/engine/source_test.go new file mode 100644 index 00000000..fb2df6fa --- /dev/null +++ b/pkg/engine/source_test.go @@ -0,0 +1,51 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "testing" + + codev0 "github.com/codefly-dev/core/generated/go/codefly/services/code/v0" +) + +// TestNewSourceExecutesLanguageNeutralOperation locks the newSource contract: +// whichever build variant is compiled (analyzer via source_semantic.go, or the +// CGO-free source_nosemantic.go selected by -tags codefly_nosemantic), the +// constructor must return a live Source whose base, language-neutral behavior +// works. Run under `-tags codefly_nosemantic` this is the only cli-side test +// that exercises the analyzer-free variant's runtime, closing the gap where the +// static companion build was verified to link but never to function. +func TestNewSourceExecutesLanguageNeutralOperation(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "marker.txt"), []byte("hello"), 0o644); err != nil { + t.Fatalf("seed source tree: %v", err) + } + + source := newSource(root) + if source == nil { + t.Fatal("newSource returned nil") + } + t.Cleanup(func() { _ = source.Close() }) + + response, err := source.ExecuteCode(context.Background(), &codev0.CodeRequest{ + Operation: &codev0.CodeRequest_ListFiles{ListFiles: &codev0.ListFilesRequest{}}, + }) + if err != nil { + t.Fatalf("ListFiles execute: %v", err) + } + if failure := response.GetFailure(); failure != nil { + t.Fatalf("ListFiles reported failure: %v", failure) + } + + found := false + for _, file := range response.GetListFiles().GetFiles() { + if filepath.Base(file.GetPath()) == "marker.txt" { + found = true + break + } + } + if !found { + t.Fatalf("ListFiles did not return the seeded file; got %+v", response.GetListFiles().GetFiles()) + } +}