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>)/ },