diff --git a/.github/workflows/release-main.yml b/.github/workflows/release-main.yml index 4aece70..f8c0cb9 100644 --- a/.github/workflows/release-main.yml +++ b/.github/workflows/release-main.yml @@ -68,7 +68,13 @@ jobs: mingw-w64-ucrt-x86_64-gcc - name: Build shared library - run: go build -buildmode=c-shared -o ${{ matrix.output }} ./cmd/moss-ffi + # Stamp the tag so Moss_Version reports it. A host loads this library by + # path at runtime, so without the stamp there is no way to tell which + # build it actually got. + run: >- + go build -buildmode=c-shared + -ldflags "-X main.buildVersion=${{ github.ref_name }}" + -o ${{ matrix.output }} ./cmd/moss-ffi - name: Package shared artifact shell: pwsh diff --git a/cmd/moss-ffi/main.go b/cmd/moss-ffi/main.go index 6da5ca9..eecfd7a 100644 --- a/cmd/moss-ffi/main.go +++ b/cmd/moss-ffi/main.go @@ -80,6 +80,11 @@ import ( const relayFFITimeout = 5 * time.Second +// buildVersion is stamped at link time by the release workflow +// (-ldflags "-X main.buildVersion=v0.8.17"). A library built any other way says +// so rather than claiming a version it cannot know. +var buildVersion = "dev" + var ( handleCounter atomic.Int64 registryMu sync.RWMutex @@ -371,6 +376,20 @@ func Moss_GetNATType(handle C.MossHandle) *C.char { // allocated C string (free with Moss_Free), or NULL if the handle is unknown. // Call it before Moss_Stop, which removes the handle from the registry. // +// Moss_Version returns the version this library was built at, as a newly +// allocated C string (free with Moss_Free). Release builds carry their tag; +// anything else reports "dev". +// +// A host loads moss by path at runtime, so nothing stops an old library from +// sitting next to a new host — and the symptoms of that are transport bugs the +// host cannot diagnose. This lets a host say which library it got instead of +// guessing. Callers must treat a missing symbol as "older than v0.8.17". +// +//export Moss_Version +func Moss_Version() *C.char { + return C.CString(buildVersion) +} + //export Moss_LastError func Moss_LastError(handle C.MossHandle) *C.char { node, code := getNode(int64(handle)) diff --git a/cmd/moss-ffi/version_test.go b/cmd/moss-ffi/version_test.go new file mode 100644 index 0000000..a582fc9 --- /dev/null +++ b/cmd/moss-ffi/version_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +// An unstamped build must say "dev" rather than claim a version it cannot know. +// The release workflow is the only thing that knows the tag, so this is the +// value a hand-built library carries — and a host can tell the two apart. +func TestUnstampedBuildReportsDev(t *testing.T) { + if buildVersion != "dev" { + t.Fatalf("an unstamped build reports %q; the default must stay %q so a "+ + "library built outside the release pipeline never claims a release", + buildVersion, "dev") + } +} + +// The stamp has to survive the linker flag the release workflow passes, in a +// real c-shared build rather than a plain test binary. Without it Moss_Version +// reports "dev" for every published release and a host cannot tell which +// library it loaded — which is the whole reason the symbol exists. +func TestVersionStampReachesTheSharedLibrary(t *testing.T) { + if testing.Short() { + t.Skip("skipping shared build in short mode") + } + const stamp = "v9.9.9-stamp-probe" + + outDir := t.TempDir() + var libraryName string + switch runtime.GOOS { + case "windows": + libraryName = "moss.dll" + case "darwin": + libraryName = "libmoss.dylib" + default: + libraryName = "libmoss.so" + } + output := filepath.Join(outDir, libraryName) + cacheDir := filepath.Join(outDir, "gocache") + tmpDir := filepath.Join(outDir, "gotmp") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatalf("mkdir cache failed: %v", err) + } + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + t.Fatalf("mkdir tmp failed: %v", err) + } + + cmd := exec.Command("go", "build", "-buildmode=c-shared", + "-ldflags", "-X main.buildVersion="+stamp, "-o", output, ".") + cmd.Dir = "." + cmd.Env = append(os.Environ(), "GOCACHE="+cacheDir, "GOTMPDIR="+tmpDir) + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("c-shared build failed: %v\n%s", err, combined) + } + + built, err := os.ReadFile(output) + if err != nil { + t.Fatalf("reading the built library failed: %v", err) + } + if !bytes.Contains(built, []byte(stamp)) { + t.Fatal("the linker stamp did not reach the shared library — " + + "Moss_Version would report \"dev\" for every release") + } +}