diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2afa409..84e6641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,8 @@ jobs: push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # The tag, branch or commit the image is published as, so lightchain-worker version names it. + build-args: VERSION=${{ steps.meta.outputs.version }} # A version tag only, and after the image, so a release never exists without the image of the same version. A tag # with a hyphen (v1.2.3-rc.1) becomes a pre-release, which GitHub does not serve as "latest", so the installer's diff --git a/Dockerfile b/Dockerfile index b8b9e59..9e9761a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,10 @@ COPY worker/go.mod worker/go.sum* worker/ RUN cd worker && GOWORK=off go mod download COPY pkg/ pkg/ COPY worker/ worker/ +ARG VERSION=dev RUN printf 'go 1.26\n\nuse (\n\t./pkg\n\t./worker\n)\n' > go.work -RUN cd worker && CGO_ENABLED=0 GOOS=linux go build -o /bin/worker ./cmd/sidecar -RUN cd worker && CGO_ENABLED=0 GOOS=linux go build -o /bin/lightchain-worker ./cmd/cli +RUN cd worker && CGO_ENABLED=0 GOOS=linux go build -ldflags="-X github.com/lightchain/worker/internal/buildinfo.Version=${VERSION}" -o /bin/worker ./cmd/sidecar +RUN cd worker && CGO_ENABLED=0 GOOS=linux go build -ldflags="-X github.com/lightchain/worker/internal/buildinfo.Version=${VERSION}" -o /bin/lightchain-worker ./cmd/cli FROM alpine:3.19 RUN apk add --no-cache ca-certificates && adduser -D -u 1000 appuser diff --git a/Makefile b/Makefile index 6cf61cb..1d93860 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ # from the orchestrator checkout, where go.work already joins both. DIST ?= dist PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 +# The release tag on a tag build in CI, otherwise the nearest tag from git (or +# "dev"). Override with make release VERSION=v1.2.3. +VERSION ?= $(shell if [ "$$GITHUB_REF_TYPE" = tag ]; then echo "$$GITHUB_REF_NAME"; else git describe --tags --always --dirty 2>/dev/null || echo dev; fi) +LDFLAGS := -s -w -X github.com/lightchain/worker/internal/buildinfo.Version=$(VERSION) SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256) .PHONY: release test-install @@ -11,7 +15,7 @@ SHA256 := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || e release: rm -rf $(DIST) && mkdir -p $(DIST) for p in $(PLATFORMS); do \ - CGO_ENABLED=0 GOOS=$${p%/*} GOARCH=$${p#*/} go build -trimpath -ldflags="-s -w" \ + CGO_ENABLED=0 GOOS=$${p%/*} GOARCH=$${p#*/} go build -trimpath -ldflags="$(LDFLAGS)" \ -o $(DIST)/lightchain-worker-$${p%/*}-$${p#*/} ./cmd/cli || exit 1; \ done cp scripts/install.sh $(DIST)/ diff --git a/README.md b/README.md index f5a9942..e887146 100644 --- a/README.md +++ b/README.md @@ -44,4 +44,6 @@ git tag v1.2.3 && git push origin v1.2.3 CI runs the checks on the tag, publishes the image as `ghcr.io/lightchain-protocol/worker:v1.2.3`, and creates the GitHub release with everything `make release` writes to `dist/`: one CLI binary per platform (`lightchain-worker-{linux,darwin}-{amd64,arm64}`), `install.sh`, and a `checksums.txt` (SHA-256) covering them all. The release notes are generated from the merged pull requests and can be edited afterwards. +Release binaries and the image carry the tag: `lightchain-worker version` prints it, with the commit when the build recorded one, and the worker logs it on start. A plain `go build` reports `dev`. + The installer downloads from `…/releases/latest/download/` (or `…/releases/download//` with `LIGHTCHAIN_WORKER_VERSION`), so a release must carry all of those files. A tag with a hyphen (`v1.2.3-rc.1`) becomes a pre-release: it can be installed by naming it, and `latest` stays where it was. diff --git a/cmd/cli/main.go b/cmd/cli/main.go index b55cb47..e3f3573 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -23,6 +23,7 @@ // status Check on-chain registration status // preflight Read-only go-live checks (RPC, registration, stake, models, gateway, Ollama, beacon) // watch Read-only daemon that posts webhook alerts when the worker stops being able to claim +// version Print the build: release tag, commit, Go version, platform package main import ( @@ -42,6 +43,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/redis/go-redis/v9" + "github.com/lightchain/worker/internal/buildinfo" "github.com/lightchain/worker/internal/chain" "github.com/lightchain/worker/internal/cli" "github.com/lightchain/worker/internal/config" @@ -100,6 +102,8 @@ func main() { runWithdraw() case "release": runRelease() + case "version", "--version": + fmt.Println(buildinfo.String()) case "help", "-h", "--help": printUsage() default: @@ -143,6 +147,8 @@ Commands: watch Daemon: alert a Discord-compatible webhook when liveness, heartbeat, Ollama, registration, suspension, stake or recent claims go bad, and again on recovery. Never remediates (no tx, no drain). + version Print the build: release tag, commit, Go version, platform. + Include it when reporting a problem. balance/withdraw/release additionally require JOB_REGISTRY_ADDRESS. release additionally reads RELEASE_STATE_PATH (and other RELEASE_* vars). diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..f2c7ce7 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,81 @@ +// Package buildinfo reports which build of the worker is running, so an +// operator (and anyone helping them) can tell which release a host is on. +package buildinfo + +import ( + "fmt" + "runtime" + "runtime/debug" +) + +// Version is the release tag, stamped at build time: +// +// go build -ldflags "-X github.com/lightchain/worker/internal/buildinfo.Version=v1.2.3" +// +// make release and the Dockerfile set it. A plain go build leaves "dev". +var Version = "dev" + +// Info describes the running build. +type Info struct { + Version string // release tag, or "dev" + Revision string // VCS commit, when the Go toolchain recorded one + Modified bool // the build's working tree had uncommitted changes + Go string // Go toolchain version + Platform string // GOOS/GOARCH +} + +// Get returns the running build's Info. +func Get() Info { + return fromBuildInfo(Version, debug.ReadBuildInfo) +} + +// String is Get formatted for one line of output. +func String() string { + return Get().String() +} + +func fromBuildInfo(version string, read func() (*debug.BuildInfo, bool)) Info { + info := Info{ + Version: version, + Go: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } + if version == "" { + info.Version = "dev" + } + bi, ok := read() + if !ok || bi == nil { + return info + } + if bi.GoVersion != "" { + info.Go = bi.GoVersion + } + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + info.Revision = s.Value + case "vcs.modified": + info.Modified = s.Value == "true" + } + } + return info +} + +// String formats the build as, for example, +// "lightchain-worker v1.2.3 (commit 1a2b3c4d5e6f, go1.26.0, linux/amd64)". +// The commit is left out when the build did not record one, as in the +// Docker image, which builds without the .git directory. +func (i Info) String() string { + details := "" + if i.Revision != "" { + rev := i.Revision + if len(rev) > 12 { + rev = rev[:12] + } + if i.Modified { + rev += "-dirty" + } + details = "commit " + rev + ", " + } + return fmt.Sprintf("lightchain-worker %s (%s%s, %s)", i.Version, details, i.Go, i.Platform) +} diff --git a/internal/buildinfo/buildinfo_test.go b/internal/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..3d106fe --- /dev/null +++ b/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,90 @@ +package buildinfo + +import ( + "runtime" + "runtime/debug" + "testing" +) + +func TestFromBuildInfo(t *testing.T) { + t.Parallel() + + platform := runtime.GOOS + "/" + runtime.GOARCH + + tests := []struct { + name string + version string + read func() (*debug.BuildInfo, bool) + want Info + str string + }{ + { + name: "release with commit", + version: "v1.2.3", + read: func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + GoVersion: "go1.26.0", + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b"}, + {Key: "vcs.modified", Value: "false"}, + }, + }, true + }, + want: Info{Version: "v1.2.3", Revision: "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", Go: "go1.26.0", Platform: platform}, + str: "lightchain-worker v1.2.3 (commit 1a2b3c4d5e6f, go1.26.0, " + platform + ")", + }, + { + name: "local build with uncommitted changes", + version: "dev", + read: func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{ + GoVersion: "go1.26.0", + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "abc123"}, + {Key: "vcs.modified", Value: "true"}, + }, + }, true + }, + want: Info{Version: "dev", Revision: "abc123", Modified: true, Go: "go1.26.0", Platform: platform}, + str: "lightchain-worker dev (commit abc123-dirty, go1.26.0, " + platform + ")", + }, + { + name: "no VCS data, as in the Docker image", + version: "v1.2.3", + read: func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{GoVersion: "go1.26.0"}, true + }, + want: Info{Version: "v1.2.3", Go: "go1.26.0", Platform: platform}, + str: "lightchain-worker v1.2.3 (go1.26.0, " + platform + ")", + }, + { + name: "build info unavailable, empty version", + version: "", + read: func() (*debug.BuildInfo, bool) { return nil, false }, + want: Info{Version: "dev", Go: runtime.Version(), Platform: platform}, + str: "lightchain-worker dev (" + runtime.Version() + ", " + platform + ")", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := fromBuildInfo(tc.version, tc.read) + if got != tc.want { + t.Errorf("fromBuildInfo() = %+v, want %+v", got, tc.want) + } + if s := got.String(); s != tc.str { + t.Errorf("String() = %q, want %q", s, tc.str) + } + }) + } +} + +func TestGetDefaultsToDev(t *testing.T) { + t.Parallel() + + // Tests are built without the release ldflags, so Version keeps its default. + if got := Get().Version; got != "dev" { + t.Errorf("Get().Version = %q, want %q", got, "dev") + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 4ac35ae..bb10060 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -28,6 +28,7 @@ import ( pkgtypes "github.com/lightchain/pkg/types" "github.com/lightchain/worker/internal/blob" + "github.com/lightchain/worker/internal/buildinfo" "github.com/lightchain/worker/internal/chain" "github.com/lightchain/worker/internal/config" gw "github.com/lightchain/worker/internal/gateway" @@ -1461,6 +1462,8 @@ func checksumHexNoPrefix(addr common.Address) string { // down and returns the process exit code. The sidecar binary and the CLI's // `run` command both use it, so a host can serve with the CLI binary alone. func Main() int { + slog.Info("lightchain worker starting", "build", buildinfo.String()) + cfg, err := config.Load() if err != nil { slog.Error("config load failed", "error", err)