From 28804f52d75e12d8761c740c6217a2b69d2357a9 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Thu, 20 Aug 2026 14:45:52 -0400 Subject: [PATCH] Make the Go adapter modules actually resolvable, and gate the four publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go/fiber` and `go/gin` are separate Go modules that both carried: require github.com/SmooAI/observability/go v0.0.0 replace github.com/SmooAI/observability/go => ../ Go honours `replace` only in the MAIN module's go.mod. In a PUBLISHED module it is invisible: a consumer running `go get .../go/fiber@vX` resolves the core at `v0.0.0`, a version that has never existed, and fails. Both adapters were unpublishable, and nothing in the repo could tell — inside the repo the replace makes everything build. Demonstrated rather than assumed: dropping the replace on a scratch copy and building reproduces the consumer's exact error, go: downloading github.com/SmooAI/observability/go v0.0.0 reading .../go.mod at revision go/v0.0.0: unknown revision go/v0.0.0 Fix: the adapters now require the core at its real released version and carry NO replace. `go/go.work` supplies the sibling source for local builds and CI, and a workspace is not published. (The replace has to be version-specific — Go rejects an all-versions replace of a workspace module — so the go.work line is version-bearing too and joins sync-versions.mjs's TARGETS, alongside both require lines. 14 files now, up from 11.) `scripts/check-go-modules.sh` is the guard, run by BOTH pr-checks and the publish gate so the two cannot drift: 1. no `replace` in any published go.mod 2. each module path matches its directory 3. Spec B's /vN rule, checked in BOTH directions — a major >= 2 must carry the suffix, a 0.x/1.x must not. It reads the major from package.json, so it flips over automatically at the 2.0 bump instead of waiting to be remembered. Today it asserts the suffix is ABSENT, which is correct at 0.19.x and is the check that would have caught fetch/file/logger. Negative-controlled by hand: adding a replace back to go/fiber exits 1; setting package.json to 2.0.0 exits 1 naming all three modules. publish.yml also gains: - `go/fiber/v*` and `go/gin/v*` tag triggers, and a `go` job condition widened to `refs/tags/go/` — the adapters had no way to be published at all - a `verify` gate every publish job depends on: all 14 version-bearing files agree with package.json AND the tag names that same version. A mistyped tag now fails on a bare runner before any toolchain boots, which matters because crates.io/PyPI/NuGet publishes are irreversible - `cargo package/publish --locked` instead of bare. --locked, never --allow-dirty: a publish that has to mutate the lockfile is publishing something the tag never built - proxy warming that derives the module path from the tag and retries, so it serves all three modules and a slow proxy no longer reports a good release as broken RELEASING.md documents the whole sequence, including the ordering constraint that falls out of the above: `go/vX` must be tagged and on the proxy BEFORE `go/fiber/vX` and `go/gin/vX`, because the adapters require it. DELIBERATELY NOT DONE: no first publish. crates.io, PyPI and NuGet first publishes are irreversible and are the user's call. Names verified free on all three; dry runs all clean (`cargo publish --dry-run --locked` packaged 29 files and built from the tarball, `uv build` + `twine check` PASSED on both artifacts, `dotnet pack` produced .nupkg + .snupkg with README and all three TFMs). The repo is one `git tag && git push` per language away. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0152bbE1veqfG1SVJdyLCBxC --- .github/workflows/pr-checks.yml | 10 +- .github/workflows/publish.yml | 178 ++++++++++++++++++++++++-------- README.md | 32 +++--- RELEASING.md | 110 ++++++++++++++++++++ go/fiber/go.mod | 4 +- go/gin/go.mod | 4 +- go/go.work | 9 ++ scripts/check-go-modules.sh | 76 ++++++++++++++ scripts/sync-versions.mjs | 15 ++- 9 files changed, 371 insertions(+), 67 deletions(-) create mode 100644 RELEASING.md create mode 100644 go/go.work create mode 100755 scripts/check-go-modules.sh diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c5b92b3..0d3987a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -72,7 +72,7 @@ jobs: # rather than warning. # # It runs on every PR because drift is introduced by editing any one of - # eleven files across five languages, and a path filter would let exactly + # fourteen files across five languages, and a path filter would let exactly # the interesting case through: a PR that touches only Go, in a repo where # the version came from a TypeScript manifest. versions: @@ -201,6 +201,14 @@ jobs: echo "::group::go test ($m)"; (cd "$m" && go test ./...); echo "::endgroup::" done + # Catch an unpublishable module at review time rather than at + # release. A `replace` directive or a mismatched module path builds + # fine in here and resolves nothing for anyone outside — which is + # how both adapters shipped requiring a core version that has never + # existed. Same script publish.yml runs. + - name: Verify the modules are publishable + run: bash ../scripts/check-go-modules.sh + # Python lane — ruff lint + format, pytest. Package in python/. python: needs: changes diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 16fa543..a41d5bf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,16 +28,27 @@ name: Publish SDKs # ───────────────────────────────────────────────────────────────────────────── # # TAGGING CONVENTION (push a tag to publish that language): -# rust-v e.g. rust-v0.1.0 → crates.io -# python-v e.g. python-v0.1.0 → PyPI -# dotnet-v e.g. dotnet-v0.1.0 → NuGet -# go/v e.g. go/v0.1.0 → pkg.go.dev (module proxy, no push) +# rust-v e.g. rust-v0.19.0 → crates.io +# python-v e.g. python-v0.19.0 → PyPI +# dotnet-v e.g. dotnet-v0.19.0 → NuGet +# go/v e.g. go/v0.19.0 → pkg.go.dev (module proxy) +# go/fiber/v e.g. go/fiber/v0.19.0 → pkg.go.dev (Fiber adapter) +# go/gin/v e.g. go/gin/v0.19.0 → pkg.go.dev (Gin adapter) # -# The Go tag prefix is `go/` (not `go-v`) because the module lives in the `go/` -# subdirectory; Go's module proxy requires the tag to be `/v`. +# The Go tag prefixes are directory paths (not `go-v`) because Go's module proxy +# requires a submodule's tag to be `/v`. `go/fiber` and `go/gin` +# are separate modules with their own go.mod, so they get their own tags. # -# Each tag should match the version declared in that language's manifest. Bump -# the manifest version, commit, then push the matching tag. +# ⚠️ ORDER MATTERS for Go: push `go/v` FIRST and let the proxy pick it +# up, then `go/fiber/v` and `go/gin/v`. The adapters' go.mod +# files `require` the core module at that exact version — they cannot resolve +# until it exists. +# +# Every tag must match the version in `packages/core/package.json`, which is the +# single source of truth for all five SDKs (see scripts/sync-versions.mjs). The +# `verify` job below enforces that and every publish job depends on it, so a +# mistyped tag fails before anything is uploaded — which matters because +# crates.io, PyPI and NuGet publishes are IRREVERSIBLE. on: workflow_dispatch: @@ -62,6 +73,8 @@ on: - 'python-v*' - 'dotnet-v*' - 'go/v*' + - 'go/fiber/v*' + - 'go/gin/v*' # Never run two publishes for the same ref at once. concurrency: @@ -69,8 +82,62 @@ concurrency: cancel-in-progress: false jobs: + # ── Gate: the tag must mean what it says ───────────────────────────────── + # + # Every publish job depends on this. crates.io, PyPI and NuGet publishes are + # IRREVERSIBLE — a version pushed under a mistyped tag can be yanked but + # never replaced — so the cheap check runs first, on its own runner, before + # any language toolchain boots. + # + # Two assertions: + # 1. all 14 version-bearing files across the five SDKs agree with + # packages/core/package.json (the same check pr-checks.yml runs) + # 2. the tag's version IS that version + # + # Together they mean: whatever this tag publishes reports the version the + # tag claims. That is precisely what was NOT true before — every SDK + # hard-coded 0.1.0 while npm shipped 0.19.0. + verify: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.expected.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Check version sync across all five SDKs + run: node scripts/sync-versions.mjs --check + + - name: Read the expected version + id: expected + run: echo "version=$(node -p "require('./packages/core/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Verify the tag matches + if: startsWith(github.ref, 'refs/tags/') + run: | + TAG="${GITHUB_REF#refs/tags/}" + # Strip whichever language prefix this tag carries. Anchored and + # explicit rather than a greedy strip, so an unrecognised tag + # shape fails loudly instead of silently yielding the whole tag. + VERSION="$(printf '%s' "$TAG" | sed -E 's#^(rust-v|python-v|dotnet-v|go/(fiber/|gin/)?v)##')" + EXPECTED="${{ steps.expected.outputs.version }}" + echo "tag=$TAG tag-version=$VERSION package.json=$EXPECTED" + if [ "$VERSION" = "$TAG" ]; then + echo "::error::tag '$TAG' does not match any known prefix (rust-v, python-v, dotnet-v, go/v, go/fiber/v, go/gin/v)" + exit 1 + fi + if [ "$VERSION" != "$EXPECTED" ]; then + echo "::error::tag '$TAG' says version $VERSION but packages/core/package.json says $EXPECTED — nothing published" + exit 1 + fi + # ── Rust → crates.io ───────────────────────────────────────────────────── rust: + needs: verify if: >- startsWith(github.ref, 'refs/tags/rust-v') || (github.event_name == 'workflow_dispatch' && inputs.language == 'rust') @@ -105,17 +172,23 @@ jobs: # Verifies the crate packages cleanly (metadata, included files, # builds from the packaged tarball) without uploading. + # --locked, not --allow-dirty. A publish that needs to mutate the + # lockfile is publishing something the tag never built; the sibling + # repos reach for --allow-dirty precisely because their version sync + # runs after publish and leaves the tree dirty. Ours runs in the + # `version` lifecycle, so the tree is clean and the lock is current. - name: Package (verify) - run: cargo package -p smooai-observability + run: cargo package --locked -p smooai-observability - name: Publish to crates.io if: ${{ !inputs.dry_run }} - run: cargo publish -p smooai-observability + run: cargo publish --locked -p smooai-observability env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} # ── Python → PyPI ──────────────────────────────────────────────────────── python: + needs: verify if: >- startsWith(github.ref, 'refs/tags/python-v') || (github.event_name == 'workflow_dispatch' && inputs.language == 'python') @@ -172,6 +245,7 @@ jobs: # ── .NET → NuGet ───────────────────────────────────────────────────────── dotnet: + needs: verify if: >- startsWith(github.ref, 'refs/tags/dotnet-v') || (github.event_name == 'workflow_dispatch' && inputs.language == 'dotnet') @@ -218,11 +292,18 @@ jobs: # Go modules are "published" implicitly: once a SemVer tag exists on the # repo, the Go module proxy (proxy.golang.org) fetches and caches it, and # pkg.go.dev indexes the docs. There is no registry to push to and no secret - # required. This job just validates the module is sound and warms the proxy - # so the new version is immediately resolvable. + # required. This job validates the modules are sound and warms the proxy so + # the new version is immediately resolvable. + # + # THREE modules live under go/: the core, plus the `fiber` and `gin` + # adapters, each with its own go.mod and therefore its own tag. The adapters + # depend on the core by its published path and version — no `replace`, which + # a consumer would ignore anyway — so `go/v` must be tagged and on + # the proxy BEFORE the adapter tags. go: + needs: verify if: >- - startsWith(github.ref, 'refs/tags/go/v') || + startsWith(github.ref, 'refs/tags/go/') || (github.event_name == 'workflow_dispatch' && inputs.language == 'go') runs-on: ubuntu-latest timeout-minutes: 20 @@ -238,7 +319,9 @@ jobs: go-version: '1.25' cache-dependency-path: go/go.mod - # Gate on the PR-lane checks before warming the proxy. + # Gate on the PR-lane checks before warming the proxy. gofmt is + # recursive, so one run covers all three modules; vet and test are + # per-module because each has its own go.mod. - name: Format check run: | unformatted=$(gofmt -l .) @@ -248,37 +331,48 @@ jobs: exit 1 fi - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... - - # Confirm the module path declared in go.mod matches the - # tag-derived path Go expects (github.com/SmooAI/observability/go). - - name: Verify module path + - name: Vet + test every module run: | - EXPECTED="github.com/SmooAI/observability/go" - ACTUAL=$(go list -m) - echo "module path: $ACTUAL" - if [ "$ACTUAL" != "$EXPECTED" ]; then - echo "::error::go.mod module path '$ACTUAL' != expected '$EXPECTED'" - exit 1 - fi + for m in . fiber gin; do + echo "::group::go vet + test ($m)" + (cd "$m" && go vet ./... && go test ./...) + echo "::endgroup::" + done + + # The two things that make a Go module unresolvable for anyone + # outside this repo — a `replace` directive (consumers ignore it) and + # a module path that does not match its directory — plus the /vN + # suffix rule for majors >= 2. Same script the PR lane runs, so the + # two cannot drift. + - name: Verify the modules are publishable + run: bash ../scripts/check-go-modules.sh # Ask the module proxy to fetch the just-pushed tag so pkg.go.dev # indexes it right away instead of on first external `go get`. Only - # meaningful on a tag push; harmless to skip on dispatch/dry-run. + # meaningful on a tag push; skipped on dispatch/dry-run. + # + # The module path is derived from the tag, so one step serves all + # three: `go/v0.19.0` -> …/go, `go/fiber/v0.19.0` -> …/go/fiber. - name: Warm module proxy - if: ${{ startsWith(github.ref, 'refs/tags/go/v') && !inputs.dry_run }} + if: ${{ startsWith(github.ref, 'refs/tags/go/') && !inputs.dry_run }} run: | - VERSION="${GITHUB_REF#refs/tags/go/}" - echo "Requesting github.com/SmooAI/observability/go@$VERSION from the proxy" - GOPROXY=proxy.golang.org GOFLAGS=-mod=mod \ - go install "github.com/SmooAI/observability/go@$VERSION" 2>/dev/null || true - # Touch the proxy version endpoint directly as a fallback so the - # version is registered even if the package has no installable - # main; a library has none, so the curl is the reliable path. - curl -fsSL "https://proxy.golang.org/github.com/!smoo!a!i/observability/go/@v/${VERSION}.info" \ - && echo "proxy registered $VERSION" \ - || echo "proxy fetch returned non-zero (it may catch up shortly)" + TAG="${GITHUB_REF#refs/tags/}" # e.g. go/fiber/v0.19.0 + VERSION="${TAG##*/}" # e.g. v0.19.0 + SUBDIR="${TAG%/*}" # e.g. go/fiber + MODULE="github.com/SmooAI/observability/${SUBDIR}" + echo "Requesting ${MODULE}@${VERSION} from the proxy" + # The proxy escapes uppercase as !, so SmooAI is + # !smoo!a!i. Hand-writing that is why this is a literal. + ESCAPED="github.com/!smoo!a!i/observability/${SUBDIR}" + for attempt in 1 2 3 4 5; do + if curl -fsSL "https://proxy.golang.org/${ESCAPED}/@v/${VERSION}.info"; then + echo "proxy registered ${MODULE}@${VERSION}" + exit 0 + fi + echo "proxy has not caught up yet (attempt $attempt); retrying in 15s" + sleep 15 + done + # Non-fatal: the tag exists either way and the proxy fetches on + # first external `go get`. Failing the job here would report a + # successful release as broken. + echo "::warning::proxy did not serve ${MODULE}@${VERSION} within ~75s; it should catch up shortly" diff --git a/README.md b/README.md index aa945c4..672d7a1 100644 --- a/README.md +++ b/README.md @@ -45,21 +45,23 @@ A monorepo of observability SDKs — **TypeScript** (the reference, on npm), **P Every SDK ships the same core: `captureException` (+ each runtime's global crash hooks), breadcrumbs, a request/task-scoped context that doesn't leak across requests, a batched retrying webhook transport, PII scrubbing, and OTLP trace + metric export. What differs per language is the framework glue: -| | TypeScript | Python | Rust | Go | .NET | -| ----------------------------------------------- | ------------ | ------------------------------ | ------------------- | ---------------------- | ------------------- | -| Error capture + crash handlers | ✅ | ✅ | ✅ | ✅ | ✅ | -| Breadcrumbs + scoped context | ✅ | ✅ | ✅ | ✅ | ✅ | -| Batched webhook transport | ✅ | ✅ | ✅ | ✅ | ✅ | -| PII scrub + per-org HMAC hashing | ✅ | ✅ | ✅ | ✅ | ✅ | -| OTel traces + metrics (OTLP, M2M auth) | ✅ | ✅ | ✅ | ✅ | ✅ | -| GenAI `gen_ai.*` helpers | ✅ | ✅ | ✅ | ✅ | ✅ | -| HTTP middleware | Hono | FastAPI / Starlette | tower · reqwest | net/http · Fiber · Gin | ASP.NET Core | -| LLM client instrumentation | `wrapOpenAI` | LangChain / LangGraph callback | — | — | — | -| Log/session sampling (FNV-1a parity corpus) | ✅ | ✅ | ✅ | ✅ | ✅ | -| Source-map upload | ✅ | n/a | n/a | n/a | n/a | -| React / Next.js bindings | ✅ | n/a | n/a | n/a | n/a | -| Browser: beacon flush + IndexedDB offline queue | ✅ | n/a | n/a | n/a | n/a | -| **Published** | npm | in-repo, unreleased | in-repo, unreleased | in-repo, unreleased | in-repo, unreleased | +| | TypeScript | Python | Rust | Go | .NET | +| ----------------------------------------------- | ------------ | ------------------------------ | ----------------------------- | ----------------------------- | ----------------------------- | +| Error capture + crash handlers | ✅ | ✅ | ✅ | ✅ | ✅ | +| Breadcrumbs + scoped context | ✅ | ✅ | ✅ | ✅ | ✅ | +| Batched webhook transport | ✅ | ✅ | ✅ | ✅ | ✅ | +| PII scrub + per-org HMAC hashing | ✅ | ✅ | ✅ | ✅ | ✅ | +| OTel traces + metrics (OTLP, M2M auth) | ✅ | ✅ | ✅ | ✅ | ✅ | +| GenAI `gen_ai.*` helpers | ✅ | ✅ | ✅ | ✅ | ✅ | +| HTTP middleware | Hono | FastAPI / Starlette | tower · reqwest | net/http · Fiber · Gin | ASP.NET Core | +| LLM client instrumentation | `wrapOpenAI` | LangChain / LangGraph callback | — | — | — | +| Log/session sampling (FNV-1a parity corpus) | ✅ | ✅ | ✅ | ✅ | ✅ | +| Source-map upload | ✅ | n/a | n/a | n/a | n/a | +| React / Next.js bindings | ✅ | n/a | n/a | n/a | n/a | +| Browser: beacon flush + IndexedDB offline queue | ✅ | n/a | n/a | n/a | n/a | +| **Published** | npm | release-ready, not yet pushed | release-ready, not yet pushed | release-ready, not yet pushed | release-ready, not yet pushed | + +The four non-npm SDKs are **release-ready but deliberately unpublished**: manifests, metadata, dry runs and the `publish.yml` gates are all in place, and the names are free on crates.io, PyPI and NuGet — but a first publish to those registries is irreversible, so it stays a human decision. See [`RELEASING.md`](RELEASING.md) for the one command each. Browser extras (TypeScript only): `window.onerror` / `unhandledrejection` / `console.error` taps, `fetch`/XHR/click/navigation breadcrumbs, release tagging with the git sha, `navigator.sendBeacon` flush at `pagehide`, and an IndexedDB offline queue that retries on focus. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..bfffda2 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,110 @@ +# Releasing + +Five SDKs, four registries, **one version number**. + +That version lives in `packages/core/package.json` and nowhere else. Every other +version-bearing file in this repo — three manifests, two lockfiles, five +reported-version constants, two Go `require` lines, one `go.work` replace — is +derived from it by `scripts/sync-versions.mjs`, which runs inside the changesets +`version` lifecycle so the synced files land in the release commit. `--check` +mode runs on every PR and fails on drift. + +One version across languages is the org's existing convention, not a local +invention: `@smooai/fetch` is `3.4.1` on npm, crates.io and PyPI alike. + +## TypeScript — automatic + +Merge a changeset to `main`. `release.yml` opens (and auto-merges) a +"🦋 New version release" PR, then publishes `@smooai/observability` to npm. +Nothing to do by hand. + +## Rust, Python, .NET, Go — tag-driven + +`publish.yml` is dormant until a language-prefixed tag is pushed. Pushing to a +branch publishes nothing. + +| tag | goes to | secret needed | +| -------------------- | --------------------------------------- | ------------------------------ | +| `rust-v` | crates.io (`smooai-observability`) | `CARGO_REGISTRY_TOKEN` | +| `python-v` | PyPI (`smooai-observability`) | none — OIDC Trusted Publishing | +| `dotnet-v` | NuGet (`SmooAI.Observability`) | `NUGET_API_KEY` | +| `go/v` | pkg.go.dev (`…/observability/go`) | none — the module proxy | +| `go/fiber/v` | pkg.go.dev (`…/observability/go/fiber`) | none | +| `go/gin/v` | pkg.go.dev (`…/observability/go/gin`) | none | + +Every job depends on a `verify` gate that asserts (a) all version-bearing files +agree with `packages/core/package.json` and (b) the tag names that same version. +A mistyped tag fails before any toolchain boots — which matters, because +**crates.io, PyPI and NuGet publishes are irreversible**. A version can be +yanked; it can never be replaced. + +### The steps + +1. Let the npm release land first, so `packages/core/package.json` carries the + version you are about to publish everywhere else. +2. Confirm the tree agrees: + ```bash + node scripts/sync-versions.mjs --check + ``` +3. Dry-run each language from a `main` checkout at that commit: + ```bash + (cd rust && cargo publish --locked -p smooai-observability --dry-run) + (cd python && uv build --wheel --sdist && uvx twine check dist/*) + (cd dotnet && dotnet pack src/SmooAI.Observability/SmooAI.Observability.csproj -c Release -o nupkg) + (cd go && bash ../scripts/check-go-modules.sh) + ``` + Or push the button without a tag: **Actions → Publish SDKs → Run workflow**, + pick a language, tick `dry_run`. Same gates, no upload. +4. Tag and push. `V` is the version from step 1, with no `v` prefix of its own: + ```bash + V=0.19.2 + git tag "rust-v$V" && git push origin "rust-v$V" + git tag "python-v$V" && git push origin "python-v$V" + git tag "dotnet-v$V" && git push origin "dotnet-v$V" + ``` + +### Go: order matters + +`go/fiber` and `go/gin` are separate modules whose `go.mod` files `require` the +core at the exact version being released. They cannot resolve until it is on the +proxy, so: + +```bash +V=0.19.2 +git tag "go/v$V" && git push origin "go/v$V" +# wait for the `go` job's proxy-warm step to report success (~1 min) +git tag "go/fiber/v$V" && git push origin "go/fiber/v$V" +git tag "go/gin/v$V" && git push origin "go/gin/v$V" +``` + +There is no `replace` directive in any published `go.mod`, deliberately: Go +honours `replace` only in the main module, so one there is invisible to +consumers and the module resolves nothing. Local builds get the sibling source +from `go/go.work`, which is not published. `scripts/check-go-modules.sh` runs on +every PR and again at publish to keep it that way. + +### Verify afterwards + +```bash +curl -s https://crates.io/api/v1/crates/smooai-observability | head -c 200 +curl -s https://pypi.org/pypi/smooai-observability/json | head -c 200 +curl -s https://api.nuget.org/v3-flatcontainer/smooai.observability/index.json +GOPROXY=proxy.golang.org go list -m github.com/SmooAI/observability/go@v$V +GOPROXY=proxy.golang.org go list -m github.com/SmooAI/observability/go/fiber@v$V +``` + +## Desktop studio + +Separate track, separate version: push a `studio-v*` tag and `build-desktop.yml` +bundles macOS / Linux / Windows into a GitHub Release. Bundles are **unsigned**; +signing and notarization are still to do. The studio is not on any package +registry, so its version does not participate in the SDK lockstep. + +## If a publish half-fails + +Registries are independent — a failed NuGet push does not roll back crates.io. +Re-push the same tag (delete it locally and remotely first) after fixing the +cause; the jobs are idempotent up to the registry (`dotnet nuget push` uses +`--skip-duplicate`, `cargo publish` errors on an existing version rather than +overwriting). If a bad version reached a registry, publish a fixed patch — do +not try to reuse the number. diff --git a/go/fiber/go.mod b/go/fiber/go.mod index 97800bb..8055cb1 100644 --- a/go/fiber/go.mod +++ b/go/fiber/go.mod @@ -3,7 +3,7 @@ module github.com/SmooAI/observability/go/fiber go 1.25.0 require ( - github.com/SmooAI/observability/go v0.0.0 + github.com/SmooAI/observability/go v0.19.2 github.com/gofiber/fiber/v2 v2.52.5 ) @@ -47,5 +47,3 @@ require ( google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) - -replace github.com/SmooAI/observability/go => ../ diff --git a/go/gin/go.mod b/go/gin/go.mod index 78d0bef..7a104dd 100644 --- a/go/gin/go.mod +++ b/go/gin/go.mod @@ -3,7 +3,7 @@ module github.com/SmooAI/observability/go/gin go 1.25.0 require ( - github.com/SmooAI/observability/go v0.0.0 + github.com/SmooAI/observability/go v0.19.2 github.com/gin-gonic/gin v1.10.0 ) @@ -60,5 +60,3 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/SmooAI/observability/go => ../ diff --git a/go/go.work b/go/go.work new file mode 100644 index 0000000..ffdbde2 --- /dev/null +++ b/go/go.work @@ -0,0 +1,9 @@ +go 1.25.0 + +use ( + . + ./fiber + ./gin +) + +replace github.com/SmooAI/observability/go v0.19.2 => . diff --git a/scripts/check-go-modules.sh b/scripts/check-go-modules.sh new file mode 100755 index 0000000..dfbf732 --- /dev/null +++ b/scripts/check-go-modules.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# The two things that make a Go module in this repo unresolvable for anyone +# outside it — both invisible from inside, because inside the repo everything +# builds. +# +# Run from `go/`. Called by BOTH the PR lane and the publish gate: the PR lane +# so a broken module is caught before merge, the publish gate so it is caught +# before a tag exists. One script, two callers, so the two cannot drift. +set -uo pipefail + +MODULES=(. fiber gin) +ROOT_PATH="github.com/SmooAI/observability/go" +status=0 + +# 1. No `replace` directives in a published module. +# +# Go honours `replace` only in the MAIN module's go.mod. A `replace … => ../` in +# a published module is therefore invisible to every consumer: `go get +# …/go/fiber@vX` resolves the core at whatever the require line names, ignores +# the replace, and fails. Both adapters shipped exactly that shape, requiring a +# `v0.0.0` that has never existed. +# +# Local builds get the sibling source from `go/go.work`, which is not published. +for m in "${MODULES[@]}"; do + [ -f "$m/go.mod" ] || continue + if go mod edit -json "$m/go.mod" | grep -qE '"Replace": *\['; then + echo "::error::$m/go.mod has a replace directive. Consumers ignore it, so the module will not resolve. Put the override in go/go.work instead." + status=1 + fi +done + +# 2. Each go.mod declares the module path Go derives from its directory. +# +# A mismatch means the module resolves nothing at all — and nothing inside the +# repo notices, because the workspace resolves by disk path. +for m in "${MODULES[@]}"; do + [ -f "$m/go.mod" ] || continue + if [ "$m" = "." ]; then expected="$ROOT_PATH"; else expected="$ROOT_PATH/$m"; fi + # Read the module line straight out of go.mod. `go list -m` reports every + # module in the workspace, not the one in this directory. + actual=$(awk '$1 == "module" { print $2; exit }' "$m/go.mod") + printf ' %-6s %s\n' "$m" "$actual" + if [ "$actual" != "$expected" ]; then + echo "::error::$m/go.mod module path '$actual' != expected '$expected'" + status=1 + fi +done + +# 3. Majors ≥ 2 need a /vN suffix on the module path. +# +# Go requires it, and without it `go get …@v2.x` resolves nothing. Today every +# module here is 0.x so the suffix must be ABSENT; this check flips over +# automatically at the 2.0 bump rather than waiting to be remembered. +# Read the version without a JSON parser so this stays runnable from any lane, +# including the Go one, with nothing but coreutils on PATH. +version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' ../packages/core/package.json | head -1) +major="${version%%.*}" +for m in "${MODULES[@]}"; do + [ -f "$m/go.mod" ] || continue + path=$(awk '$1 == "module" { print $2; exit }' "$m/go.mod") + if [ "$major" -ge 2 ]; then + if [ "${path##*/}" != "v$major" ]; then + echo "::error::version is $version but $m/go.mod path '$path' has no /v$major suffix — Go will not resolve it" + status=1 + fi + elif [[ "${path##*/}" =~ ^v[0-9]+$ ]]; then + echo "::error::version is $version (major 0/1) but $m/go.mod path '$path' carries a /vN suffix" + status=1 + fi +done + +if [ "$status" -eq 0 ]; then + echo "Go modules are publishable: no replace directives, paths match directories, /vN suffix matches major $major." +fi +exit "$status" diff --git a/scripts/sync-versions.mjs b/scripts/sync-versions.mjs index a2e9558..5a65cb8 100644 --- a/scripts/sync-versions.mjs +++ b/scripts/sync-versions.mjs @@ -60,10 +60,19 @@ const TARGETS = [ { file: 'python/uv.lock', pattern: /(name = "smooai-observability"\nversion = ")[^"]+(")/ }, // ---- Go -------------------------------------------------------------- - // Go has no manifest version — a module's version IS its git tag — so the - // reported SDK version is the only thing to sync. The tag/manifest - // agreement is enforced separately, in publish.yml. + // The core module has no manifest version — a module's version IS its git + // tag — so its reported SDK version is the only thing to sync there. The + // tag/version agreement is enforced separately, in publish.yml. { file: 'go/types.go', pattern: /(sdkVersion = ")[^"]+(")/ }, + // The fiber/gin adapters are separate modules that depend on the core by + // its PUBLISHED path and version. These lines are what make them + // resolvable for anyone outside this repo, so they are version-bearing in + // the strongest sense: get them wrong and `go get` fails outright. + { file: 'go/fiber/go.mod', pattern: /(\tgithub\.com\/SmooAI\/observability\/go v)[^\n]+(\n)/ }, + { file: 'go/gin/go.mod', pattern: /(\tgithub\.com\/SmooAI\/observability\/go v)[^\n]+(\n)/ }, + // The workspace replace is version-specific (Go rejects an all-versions + // replace of a workspace module), so it has to track the require lines. + { file: 'go/go.work', pattern: /(replace github\.com\/SmooAI\/observability\/go v)[^\s]+( => \.)/ }, // ---- .NET ------------------------------------------------------------ { file: 'dotnet/src/SmooAI.Observability/SmooAI.Observability.csproj', pattern: /()[^<]+(<\/Version>)/ },