Skip to content

Commit a7f1ce0

Browse files
committed
feat: add version command and update check via GitHub releases API
Inject version/commit/date into the binary via ldflags across all build paths (Makefile, goreleaser, Dockerfile). Add `runscaler version` command with --short, --json, and --check flags, plus a non-blocking startup notification when a newer release is available.
1 parent 3811cd7 commit a7f1ce0

File tree

8 files changed

+304
-11
lines changed

8 files changed

+304
-11
lines changed

.goreleaser.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ builds:
33
- main: ./cmd/runscaler
44
ldflags:
55
- -s -w
6+
- -X main.version={{.Version}}
7+
- -X main.commit={{.ShortCommit}}
8+
- -X main.date={{.Date}}
69
goos:
710
- linux
811
- darwin

Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ WORKDIR /build
44
COPY go.mod go.sum ./
55
RUN go mod download
66
COPY . ./
7-
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o runscaler ./cmd/runscaler
7+
ARG VERSION=dev
8+
ARG COMMIT=unknown
9+
ARG BUILD_DATE=unknown
10+
RUN CGO_ENABLED=0 go build \
11+
-ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
12+
-o runscaler ./cmd/runscaler
813

914
FROM alpine:3
1015
RUN apk add --no-cache ca-certificates

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ BINARY_NAME := runscaler
22
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
33
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
44
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
5-
LDFLAGS := -s -w
5+
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(BUILD_DATE)
66
GOFLAGS := -trimpath
77

88
PLATFORMS := \

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,15 @@ jobs:
149149
150150
## Commands
151151
152-
| Command | Description |
153-
| -------------------- | ------------------------------------------------------ |
154-
| `runscaler` | Start the auto-scaler (default) |
155-
| `runscaler init` | Generate a config file interactively |
156-
| `runscaler validate` | Validate configuration and connectivity |
157-
| `runscaler status` | Show current runner status via health endpoint |
158-
| `runscaler doctor` | Diagnose and clean up orphaned containers/VMs |
152+
| Command | Description |
153+
| -------------------------- | ------------------------------------------------------ |
154+
| `runscaler` | Start the auto-scaler (default) |
155+
| `runscaler init` | Generate a config file interactively |
156+
| `runscaler validate` | Validate configuration and connectivity |
157+
| `runscaler status` | Show current runner status via health endpoint |
158+
| `runscaler doctor` | Diagnose and clean up orphaned containers/VMs |
159+
| `runscaler version` | Show version, commit, build date, and runtime info |
160+
| `runscaler version --check`| Check GitHub for newer releases |
159161

160162
### Troubleshooting with `doctor`
161163

@@ -334,12 +336,13 @@ Built on top of [actions/scaleset](https://github.com/actions/scaleset), the off
334336
Key components:
335337

336338
```
337-
cmd/runscaler/ CLI entry point, commands (init, validate, status, doctor)
339+
cmd/runscaler/ CLI entry point, commands (init, validate, status, doctor, version)
338340
internal/
339341
config/ Configuration management with Viper (flags + TOML)
340342
backend/ RunnerBackend interface + Docker/Tart implementations
341343
scaler/ Implements listener.Scaler for runner lifecycle
342344
health/ Health check HTTP server
345+
versioncheck/ GitHub releases API client for update notifications
343346
```
344347

345348
The `RunnerBackend` interface abstracts container/VM lifecycle:

cmd/runscaler/cmd_version.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"runtime"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/ysya/runscaler/internal/versioncheck"
11+
)
12+
13+
var versionCmd = &cobra.Command{
14+
Use: "version",
15+
Short: "Show version information",
16+
Long: `Display the version, commit hash, build date, and runtime info.
17+
18+
Use --check to query the GitHub releases API for newer versions.`,
19+
Example: ` runscaler version # Show version info
20+
runscaler version --short # Print version number only
21+
runscaler version --json # Output as JSON
22+
runscaler version --check # Check for updates`,
23+
RunE: runVersion,
24+
}
25+
26+
func init() {
27+
versionCmd.Flags().Bool("json", false, "Output as JSON")
28+
versionCmd.Flags().Bool("short", false, "Print version number only")
29+
versionCmd.Flags().Bool("check", false, "Check for newer version on GitHub")
30+
}
31+
32+
type versionInfo struct {
33+
Version string `json:"version"`
34+
Commit string `json:"commit,omitempty"`
35+
Date string `json:"date,omitempty"`
36+
Go string `json:"go"`
37+
OS string `json:"os"`
38+
Arch string `json:"arch"`
39+
}
40+
41+
func runVersion(cmd *cobra.Command, _ []string) error {
42+
jsonOutput, _ := cmd.Flags().GetBool("json")
43+
short, _ := cmd.Flags().GetBool("short")
44+
check, _ := cmd.Flags().GetBool("check")
45+
46+
if short {
47+
fmt.Fprintln(cmd.OutOrStdout(), version)
48+
return nil
49+
}
50+
51+
info := versionInfo{
52+
Version: version,
53+
Commit: commit,
54+
Date: date,
55+
Go: runtime.Version(),
56+
OS: runtime.GOOS,
57+
Arch: runtime.GOARCH,
58+
}
59+
60+
if jsonOutput {
61+
enc := json.NewEncoder(cmd.OutOrStdout())
62+
enc.SetIndent("", " ")
63+
return enc.Encode(info)
64+
}
65+
66+
fmt.Fprintf(cmd.OutOrStdout(), "runscaler %s\n", info.Version)
67+
if info.Commit != "" {
68+
fmt.Fprintf(cmd.OutOrStdout(), " commit: %s\n", info.Commit)
69+
}
70+
if info.Date != "" {
71+
fmt.Fprintf(cmd.OutOrStdout(), " built: %s\n", info.Date)
72+
}
73+
fmt.Fprintf(cmd.OutOrStdout(), " go: %s\n", info.Go)
74+
fmt.Fprintf(cmd.OutOrStdout(), " os: %s/%s\n", info.OS, info.Arch)
75+
76+
if check {
77+
return checkLatestVersion(cmd)
78+
}
79+
80+
return nil
81+
}
82+
83+
func checkLatestVersion(cmd *cobra.Command) error {
84+
release, err := versioncheck.Latest(cmd.Context())
85+
if err != nil {
86+
fmt.Fprintf(cmd.ErrOrStderr(), "\nCould not check for updates: %s\n", err)
87+
return nil // non-fatal
88+
}
89+
90+
if versioncheck.IsNewer(version, release.TagName) {
91+
fmt.Fprintf(cmd.OutOrStdout(),
92+
"\nA newer version is available: %s (you have %s)\n"+
93+
" Upgrade: curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | sh\n"+
94+
" Release: %s\n",
95+
release.TagName, version, release.HTMLURL)
96+
} else {
97+
fmt.Fprintln(cmd.OutOrStdout(), "\nYou are up to date.")
98+
}
99+
return nil
100+
}

cmd/runscaler/main.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/ysya/runscaler/internal/config"
2727
"github.com/ysya/runscaler/internal/health"
2828
"github.com/ysya/runscaler/internal/scaler"
29+
"github.com/ysya/runscaler/internal/versioncheck"
2930
)
3031

3132
var (
@@ -98,7 +99,7 @@ func init() {
9899
viper.BindPFlag("tart.pool-size", flags.Lookup("tart-pool-size"))
99100

100101
// Register subcommands
101-
cmd.AddCommand(initCmd, validateCmd, statusCmd, doctorCmd)
102+
cmd.AddCommand(initCmd, validateCmd, statusCmd, doctorCmd, versionCmd)
102103
}
103104

104105
func main() {
@@ -271,6 +272,20 @@ func run(ctx context.Context, cfg config.Config) error {
271272

272273
logger.Info("Starting scale sets", slog.Int("count", len(scaleSets)))
273274

275+
// Non-blocking version check at startup
276+
go func() {
277+
release, err := versioncheck.Latest(ctx)
278+
if err != nil {
279+
return
280+
}
281+
if versioncheck.IsNewer(version, release.TagName) {
282+
logger.Warn("A newer version of runscaler is available",
283+
slog.String("current", version),
284+
slog.String("latest", release.TagName),
285+
)
286+
}
287+
}()
288+
274289
// Run each scale set in its own goroutine
275290
var wg sync.WaitGroup
276291
errs := make(chan error, len(scaleSets))

internal/versioncheck/check.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package versioncheck
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"strings"
9+
"time"
10+
)
11+
12+
const (
13+
githubRepo = "ysya/runscaler"
14+
apiTimeout = 5 * time.Second
15+
)
16+
17+
// ReleaseInfo holds metadata about a GitHub release.
18+
type ReleaseInfo struct {
19+
TagName string `json:"tag_name"`
20+
HTMLURL string `json:"html_url"`
21+
}
22+
23+
// Latest queries the GitHub releases API for the latest release.
24+
func Latest(ctx context.Context) (*ReleaseInfo, error) {
25+
ctx, cancel := context.WithTimeout(ctx, apiTimeout)
26+
defer cancel()
27+
28+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
29+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
30+
if err != nil {
31+
return nil, err
32+
}
33+
req.Header.Set("Accept", "application/vnd.github+json")
34+
35+
resp, err := http.DefaultClient.Do(req)
36+
if err != nil {
37+
return nil, fmt.Errorf("failed to check for updates: %w", err)
38+
}
39+
defer resp.Body.Close()
40+
41+
if resp.StatusCode != http.StatusOK {
42+
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
43+
}
44+
45+
var release ReleaseInfo
46+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
47+
return nil, fmt.Errorf("failed to parse release info: %w", err)
48+
}
49+
return &release, nil
50+
}
51+
52+
// IsNewer reports whether remote is a different (presumably newer) version
53+
// than local. For "dev" builds, always returns true.
54+
func IsNewer(local, remote string) bool {
55+
local = strings.TrimPrefix(local, "v")
56+
remote = strings.TrimPrefix(remote, "v")
57+
if local == "dev" || local == "" {
58+
return true
59+
}
60+
return local != remote
61+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package versioncheck
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
func TestIsNewer(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
local string
15+
remote string
16+
want bool
17+
}{
18+
{"same version", "0.2.5", "0.2.5", false},
19+
{"same with v prefix", "v0.2.5", "v0.2.5", false},
20+
{"mixed v prefix", "0.2.5", "v0.2.5", false},
21+
{"mixed v prefix reverse", "v0.2.5", "0.2.5", false},
22+
{"remote is newer", "0.2.5", "0.3.0", true},
23+
{"remote is older", "0.3.0", "0.2.5", true}, // IsNewer only checks inequality
24+
{"dev build", "dev", "0.2.5", true},
25+
{"empty local", "", "0.2.5", true},
26+
{"dev with v remote", "dev", "v0.2.5", true},
27+
}
28+
29+
for _, tt := range tests {
30+
t.Run(tt.name, func(t *testing.T) {
31+
got := IsNewer(tt.local, tt.remote)
32+
if got != tt.want {
33+
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.local, tt.remote, got, tt.want)
34+
}
35+
})
36+
}
37+
}
38+
39+
func TestLatest(t *testing.T) {
40+
t.Run("success", func(t *testing.T) {
41+
release := ReleaseInfo{
42+
TagName: "v0.3.0",
43+
HTMLURL: "https://github.com/ysya/runscaler/releases/tag/v0.3.0",
44+
}
45+
46+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
w.Header().Set("Content-Type", "application/json")
48+
json.NewEncoder(w).Encode(release)
49+
}))
50+
defer srv.Close()
51+
52+
origTransport := http.DefaultTransport
53+
http.DefaultTransport = &testTransport{url: srv.URL, transport: origTransport}
54+
defer func() { http.DefaultTransport = origTransport }()
55+
56+
got, err := Latest(context.Background())
57+
if err != nil {
58+
t.Fatalf("Latest() error = %v", err)
59+
}
60+
if got.TagName != release.TagName {
61+
t.Errorf("TagName = %q, want %q", got.TagName, release.TagName)
62+
}
63+
if got.HTMLURL != release.HTMLURL {
64+
t.Errorf("HTMLURL = %q, want %q", got.HTMLURL, release.HTMLURL)
65+
}
66+
})
67+
68+
t.Run("server error", func(t *testing.T) {
69+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70+
w.WriteHeader(http.StatusInternalServerError)
71+
}))
72+
defer srv.Close()
73+
74+
origTransport := http.DefaultTransport
75+
http.DefaultTransport = &testTransport{url: srv.URL, transport: origTransport}
76+
defer func() { http.DefaultTransport = origTransport }()
77+
78+
_, err := Latest(context.Background())
79+
if err == nil {
80+
t.Fatal("Latest() expected error for 500 response")
81+
}
82+
})
83+
84+
t.Run("cancelled context", func(t *testing.T) {
85+
ctx, cancel := context.WithCancel(context.Background())
86+
cancel()
87+
88+
_, err := Latest(ctx)
89+
if err == nil {
90+
t.Fatal("Latest() expected error for cancelled context")
91+
}
92+
})
93+
}
94+
95+
// testTransport redirects all requests to the test server URL.
96+
type testTransport struct {
97+
url string
98+
transport http.RoundTripper
99+
}
100+
101+
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
102+
req = req.Clone(req.Context())
103+
req.URL.Scheme = "http"
104+
req.URL.Host = t.url[len("http://"):]
105+
return t.transport.RoundTrip(req)
106+
}

0 commit comments

Comments
 (0)