From f409d93a6a0a4d9117a3ff7fff6139c083daca1e Mon Sep 17 00:00:00 2001 From: "alex.stanfield" <13949480+chaptersix@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:16:19 -0600 Subject: [PATCH 1/3] feat: add CI validation for server dependency version Adds a new CI job that validates the go.temporal.io/server dependency: - Ensures it's a tagged version (not a pseudo-version with commit hash) - Ensures it doesn't exceed the next server release (major.minor+1 from latest) Uses actions/github-script to fetch the latest release from temporalio/temporal and compare versions using semver. --- .github/workflows/ci.yaml | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 90f2f5265..75db9832d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,6 +10,80 @@ permissions: actions: write jobs: + validate-server-version: + name: Validate Server Version + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Validate server dependency version + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const semver = require('semver'); + + const SERVER_MODULE = 'go.temporal.io/server'; + const TEMPORAL_REPO = 'temporalio/temporal'; + + // 1. Read and parse go.mod + const goModContent = fs.readFileSync('go.mod', 'utf-8'); + const serverVersionMatch = goModContent.match(new RegExp(`${SERVER_MODULE.replace(/\//g, '\\/')}\\s+(v[^\\s]+)`)); + + if (!serverVersionMatch) { + core.setFailed(`Could not find ${SERVER_MODULE} dependency in go.mod`); + return; + } + + const serverVersion = serverVersionMatch[1]; + core.info(`Found server dependency: ${SERVER_MODULE}@${serverVersion}`); + + // 2. Validate it's a tagged version (not a pseudo-version) + const pseudoPattern = /-\d{14}-[a-f0-9]{12}$/; + if (pseudoPattern.test(serverVersion)) { + core.setFailed(`Server dependency must be a tagged version, not a pseudo-version: ${serverVersion}`); + return; + } + core.info('✓ Version is a valid tagged version'); + + // 3. Fetch latest release from GitHub + const { data: release } = await github.rest.repos.getLatestRelease({ + owner: 'temporalio', + repo: 'temporal', + }); + const latestRelease = release.tag_name; + core.info(`Latest GitHub release: ${latestRelease}`); + + // 4. Validate version doesn't exceed next release (major.minor+1) + const serverSemver = semver.coerce(serverVersion.replace(/^v/, '')); + const latestSemver = semver.parse(latestRelease.replace(/^v/, '')); + + if (!serverSemver || !latestSemver) { + core.setFailed(`Could not parse versions: server=${serverVersion}, latest=${latestRelease}`); + return; + } + + const maxAllowedMinor = latestSemver.minor + 1; + core.info(` Server version: ${serverSemver.major}.${serverSemver.minor}.x`); + core.info(` Latest release: ${latestSemver.major}.${latestSemver.minor}.x`); + core.info(` Max allowed: ${latestSemver.major}.${maxAllowedMinor}.x`); + + const exceedsVersion = + serverSemver.major > latestSemver.major || + (serverSemver.major === latestSemver.major && serverSemver.minor > maxAllowedMinor); + + if (exceedsVersion) { + core.setFailed( + `Server dependency version ${serverVersion} exceeds allowed range.\n` + + `Max allowed: ${latestSemver.major}.${maxAllowedMinor}.x (latest release + 1 minor)\n` + + `Latest release: ${latestRelease}` + ); + return; + } + + core.info('✓ Server dependency version validation passed!') + build-test: strategy: fail-fast: false From 89bff515722263170a46c30118ba21593e2e9295 Mon Sep 17 00:00:00 2001 From: "alex.stanfield" <13949480+chaptersix@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:19:35 -0600 Subject: [PATCH 2/3] fix: remove semver dependency, use simple regex parsing --- .github/workflows/ci.yaml | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 75db9832d..49da1a78f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,10 +22,15 @@ jobs: with: script: | const fs = require('fs'); - const semver = require('semver'); const SERVER_MODULE = 'go.temporal.io/server'; - const TEMPORAL_REPO = 'temporalio/temporal'; + + // Parse major.minor from a version string like "v1.30.0-148.4" or "v1.29.2" + function parseMajorMinor(version) { + const match = version.match(/^v?(\d+)\.(\d+)/); + if (!match) return null; + return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) }; + } // 1. Read and parse go.mod const goModContent = fs.readFileSync('go.mod', 'utf-8'); @@ -56,27 +61,27 @@ jobs: core.info(`Latest GitHub release: ${latestRelease}`); // 4. Validate version doesn't exceed next release (major.minor+1) - const serverSemver = semver.coerce(serverVersion.replace(/^v/, '')); - const latestSemver = semver.parse(latestRelease.replace(/^v/, '')); + const serverVer = parseMajorMinor(serverVersion); + const latestVer = parseMajorMinor(latestRelease); - if (!serverSemver || !latestSemver) { + if (!serverVer || !latestVer) { core.setFailed(`Could not parse versions: server=${serverVersion}, latest=${latestRelease}`); return; } - const maxAllowedMinor = latestSemver.minor + 1; - core.info(` Server version: ${serverSemver.major}.${serverSemver.minor}.x`); - core.info(` Latest release: ${latestSemver.major}.${latestSemver.minor}.x`); - core.info(` Max allowed: ${latestSemver.major}.${maxAllowedMinor}.x`); + const maxAllowedMinor = latestVer.minor + 1; + core.info(` Server version: ${serverVer.major}.${serverVer.minor}.x`); + core.info(` Latest release: ${latestVer.major}.${latestVer.minor}.x`); + core.info(` Max allowed: ${latestVer.major}.${maxAllowedMinor}.x`); const exceedsVersion = - serverSemver.major > latestSemver.major || - (serverSemver.major === latestSemver.major && serverSemver.minor > maxAllowedMinor); + serverVer.major > latestVer.major || + (serverVer.major === latestVer.major && serverVer.minor > maxAllowedMinor); if (exceedsVersion) { core.setFailed( `Server dependency version ${serverVersion} exceeds allowed range.\n` + - `Max allowed: ${latestSemver.major}.${maxAllowedMinor}.x (latest release + 1 minor)\n` + + `Max allowed: ${latestVer.major}.${maxAllowedMinor}.x (latest release + 1 minor)\n` + `Latest release: ${latestRelease}` ); return; From 224cee145e07aaecadb54c8515bce74a616546ec Mon Sep 17 00:00:00 2001 From: "alex.stanfield" <13949480+chaptersix@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:17:51 -0600 Subject: [PATCH 3/3] feat: add CI validation for server dependency version Adds a Go tool that validates the go.temporal.io/server dependency: - Ensures it's a tagged version (not a pseudo-version) - Ensures it doesn't exceed one minor version ahead of the latest temporalio/temporal GitHub release Runs on push to main and PRs targeting main. --- .github/workflows/ci.yaml | 77 ++-------- go.mod | 2 +- internal/cmd/validate-server-version/main.go | 133 ++++++++++++++++++ .../cmd/validate-server-version/main_test.go | 61 ++++++++ 4 files changed, 203 insertions(+), 70 deletions(-) create mode 100644 internal/cmd/validate-server-version/main.go create mode 100644 internal/cmd/validate-server-version/main_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49da1a78f..10f9618c2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,82 +12,21 @@ permissions: 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: Validate server dependency version - uses: actions/github-script@v7 + - name: Setup Go + uses: actions/setup-go@v5 with: - script: | - const fs = require('fs'); - - const SERVER_MODULE = 'go.temporal.io/server'; - - // Parse major.minor from a version string like "v1.30.0-148.4" or "v1.29.2" - function parseMajorMinor(version) { - const match = version.match(/^v?(\d+)\.(\d+)/); - if (!match) return null; - return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) }; - } - - // 1. Read and parse go.mod - const goModContent = fs.readFileSync('go.mod', 'utf-8'); - const serverVersionMatch = goModContent.match(new RegExp(`${SERVER_MODULE.replace(/\//g, '\\/')}\\s+(v[^\\s]+)`)); - - if (!serverVersionMatch) { - core.setFailed(`Could not find ${SERVER_MODULE} dependency in go.mod`); - return; - } - - const serverVersion = serverVersionMatch[1]; - core.info(`Found server dependency: ${SERVER_MODULE}@${serverVersion}`); - - // 2. Validate it's a tagged version (not a pseudo-version) - const pseudoPattern = /-\d{14}-[a-f0-9]{12}$/; - if (pseudoPattern.test(serverVersion)) { - core.setFailed(`Server dependency must be a tagged version, not a pseudo-version: ${serverVersion}`); - return; - } - core.info('✓ Version is a valid tagged version'); - - // 3. Fetch latest release from GitHub - const { data: release } = await github.rest.repos.getLatestRelease({ - owner: 'temporalio', - repo: 'temporal', - }); - const latestRelease = release.tag_name; - core.info(`Latest GitHub release: ${latestRelease}`); - - // 4. Validate version doesn't exceed next release (major.minor+1) - const serverVer = parseMajorMinor(serverVersion); - const latestVer = parseMajorMinor(latestRelease); - - if (!serverVer || !latestVer) { - core.setFailed(`Could not parse versions: server=${serverVersion}, latest=${latestRelease}`); - return; - } - - const maxAllowedMinor = latestVer.minor + 1; - core.info(` Server version: ${serverVer.major}.${serverVer.minor}.x`); - core.info(` Latest release: ${latestVer.major}.${latestVer.minor}.x`); - core.info(` Max allowed: ${latestVer.major}.${maxAllowedMinor}.x`); - - const exceedsVersion = - serverVer.major > latestVer.major || - (serverVer.major === latestVer.major && serverVer.minor > maxAllowedMinor); - - if (exceedsVersion) { - core.setFailed( - `Server dependency version ${serverVersion} exceeds allowed range.\n` + - `Max allowed: ${latestVer.major}.${maxAllowedMinor}.x (latest release + 1 minor)\n` + - `Latest release: ${latestRelease}` - ); - return; - } + go-version-file: go.mod - core.info('✓ Server dependency version validation passed!') + - name: Validate server dependency version + run: go run ./internal/cmd/validate-server-version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} build-test: strategy: diff --git a/go.mod b/go.mod index 91aed58b0..582906ffa 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( go.temporal.io/sdk v1.38.0 go.temporal.io/sdk/contrib/envconfig v0.1.0 go.temporal.io/server v1.30.0-148.4 + golang.org/x/mod v0.31.0 golang.org/x/term v0.38.0 golang.org/x/tools v0.40.0 google.golang.org/grpc v1.72.2 @@ -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) + } + }) + } +}