diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 90f2f5265..10f9618c2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,24 @@ permissions: actions: write jobs: + validate-server-version: + name: Validate Server Version + if: github.event_name == 'push' || github.base_ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate server dependency version + run: go run ./internal/cmd/validate-server-version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-test: strategy: fail-fast: false diff --git a/go.mod b/go.mod index bfd360cdb..53d5eb404 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( go.temporal.io/api v1.60.1 go.temporal.io/sdk v1.38.0 go.temporal.io/sdk/contrib/envconfig v0.1.0 + golang.org/x/mod v0.31.0 go.temporal.io/server v1.30.0 golang.org/x/term v0.38.0 golang.org/x/tools v0.40.0 @@ -156,7 +157,6 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/internal/cmd/validate-server-version/main.go b/internal/cmd/validate-server-version/main.go new file mode 100644 index 000000000..6c06332c2 --- /dev/null +++ b/internal/cmd/validate-server-version/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +const ( + serverModule = "go.temporal.io/server" + releaseURL = "https://api.github.com/repos/temporalio/temporal/releases/latest" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + serverVersion, err := getServerVersion() + if err != nil { + return err + } + fmt.Printf("Found server dependency: %s@%s\n", serverModule, serverVersion) + + if module.IsPseudoVersion(serverVersion) { + return fmt.Errorf("server dependency must be a tagged version, not a pseudo-version: %s", serverVersion) + } + fmt.Println("✓ Version is a valid tagged version") + + latestRelease, err := fetchLatestRelease() + if err != nil { + return err + } + fmt.Printf("Latest GitHub release: %s\n", latestRelease) + + if err := validateVersionConstraint(serverVersion, latestRelease); err != nil { + return err + } + + fmt.Println("✓ Server dependency version validation passed!") + return nil +} + +func getServerVersion() (string, error) { + data, err := os.ReadFile("go.mod") + if err != nil { + return "", fmt.Errorf("failed to read go.mod: %w", err) + } + + f, err := modfile.Parse("go.mod", data, nil) + if err != nil { + return "", fmt.Errorf("failed to parse go.mod: %w", err) + } + + for _, req := range f.Require { + if req.Mod.Path == serverModule { + return req.Mod.Version, nil + } + } + + return "", fmt.Errorf("server dependency %s not found in go.mod", serverModule) +} + +func fetchLatestRelease() (string, error) { + req, err := http.NewRequest("GET", releaseURL, nil) + if err != nil { + return "", err + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + + return release.TagName, nil +} + +// validateVersionConstraint ensures serverVersion is at most one minor version ahead of latestRelease. +func validateVersionConstraint(serverVersion, latestRelease string) error { + if !semver.IsValid(serverVersion) { + return fmt.Errorf("invalid server version: %s", serverVersion) + } + if !semver.IsValid(latestRelease) { + return fmt.Errorf("invalid latest release version: %s", latestRelease) + } + + serverMM := semver.MajorMinor(serverVersion) + latestMM := semver.MajorMinor(latestRelease) + + var latestMajor, latestMinor int + fmt.Sscanf(latestMM, "v%d.%d", &latestMajor, &latestMinor) + maxAllowedMM := fmt.Sprintf("v%d.%d", latestMajor, latestMinor+1) + + fmt.Printf(" Server version: %s.x\n", serverMM) + fmt.Printf(" Latest release: %s.x\n", latestMM) + fmt.Printf(" Max allowed: %s.x\n", maxAllowedMM) + + if semver.Compare(serverMM, maxAllowedMM) > 0 { + return fmt.Errorf( + "server dependency version %s exceeds allowed range\n"+ + " Max allowed: %s.x (latest release + 1 minor)\n"+ + " Latest release: %s", + serverVersion, maxAllowedMM, latestRelease, + ) + } + + return nil +} diff --git a/internal/cmd/validate-server-version/main_test.go b/internal/cmd/validate-server-version/main_test.go new file mode 100644 index 000000000..ff7fe12e3 --- /dev/null +++ b/internal/cmd/validate-server-version/main_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + + "golang.org/x/mod/module" +) + +func TestIsPseudoVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + version string + want bool + }{ + {"v1.30.0-148.4", false}, + {"v1.29.2", false}, + {"v1.30.0-rc.1", false}, + {"v0.0.0-20240101120000-abcdef123456", true}, + {"v1.29.1-0.20240101120000-abcdef123456", true}, + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + t.Parallel() + if got := module.IsPseudoVersion(tt.version); got != tt.want { + t.Errorf("IsPseudoVersion(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} + +func TestValidateVersionConstraint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverVersion string + latestRelease string + wantErr bool + }{ + {"same minor", "v1.29.0-142.0", "v1.29.2", false}, + {"one minor ahead", "v1.30.0-148.4", "v1.29.2", false}, + {"two minors ahead", "v1.31.0-150.0", "v1.29.2", true}, + {"major ahead", "v2.0.0-1.0", "v1.29.2", true}, + {"exact match", "v1.29.2", "v1.29.2", false}, + {"invalid server", "invalid", "v1.29.2", true}, + {"invalid release", "v1.30.0", "invalid", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateVersionConstraint(tt.serverVersion, tt.latestRelease) + if (err != nil) != tt.wantErr { + t.Errorf("validateVersionConstraint(%q, %q) error = %v, wantErr %v", + tt.serverVersion, tt.latestRelease, err, tt.wantErr) + } + }) + } +}