Rewrite dependency version check as Go tool, extend to main branch (#9816)

Rewrites the release dependency check as a Go tool and extends it to
cover the main branch.

The original check was a shell script using `grep`/`awk` to extract
versions from `go.mod`. Moving to Go lets us use
`golang.org/x/mod/modfile` (proper AST parsing),
`module.IsPseudoVersion`/`PseudoVersionRev` (pseudo-version
decomposition), and `semver.IsValid` — none of which are feasible to
replicate reliably in shell. Using a Go tool is also consistent with the
pattern in `cmd/tools/`.

The rewrite also extends validation: the original script only enforced
tagged releases on `release/*` and `cloud/*` branches. The new tool adds
a `main` branch policy: pseudo-versions are allowed on main, but the
referenced commit must be on the dependency's default branch (not a
feature branch or a fork).

## Policies enforced

- `release/*` and `cloud/*`: must be tagged semver releases
- `main`: tagged releases accepted; pseudo-versions must reference a
commit on the dependency's default branch
- other branches: skipped

## Why

If an API or SDK references a commit that's not on the main branch or a
tag, it creates problems when bumping the version later on. There was a
recent occurrence of this.

## Running locally

```
go run ./cmd/tools/check-dependencies --base-branch main
```

Pass the branch you're targeting as `--base-branch`. For example, to
simulate a PR against a release branch:

```
go run ./cmd/tools/check-dependencies --base-branch release/v1.31
```
This commit is contained in:
Alex Stanfield
2026-04-22 11:35:00 -05:00
committed by GitHub
parent ccde551872
commit 76ccaa1ab4
4 changed files with 493 additions and 45 deletions

View File

@@ -1,6 +1,10 @@
name: Check Release Dependencies
on:
pull_request: {}
pull_request:
branches:
- main
- "release/**"
- "cloud/**"
permissions:
contents: read
@@ -11,49 +15,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
if: >-
startsWith('release/', github.event.pull_request.base.ref) ||
startsWith('cloud/', github.event.pull_request.base.ref)
- name: Check temporal dependencies use tagged versions
if: >-
startsWith('release/', github.event.pull_request.base.ref) ||
startsWith('cloud/', github.event.pull_request.base.ref)
run: |
echo "Checking that temporal dependencies use tagged versions..."
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
check-latest: true
cache: true
# Semantic version regex pattern (e.g., v1.2.3)
SEMVER_PATTERN="^v[0-9]+\.[0-9]+\.[0-9]+$"
DEPENDENCIES=(
"go.temporal.io/api"
"go.temporal.io/sdk"
)
ERRORS=""
for DEPENDENCY in "${DEPENDENCIES[@]}"; do
VERSION=$(grep "^[[:space:]]*$DEPENDENCY" go.mod | awk '{print $2}')
if [ -z "$VERSION" ]; then
echo "Error: $DEPENDENCY dependency not found in go.mod"
exit 1
fi
if ! echo "$VERSION" | grep -qE "$SEMVER_PATTERN"; then
ERRORS="${ERRORS} $DEPENDENCY version '$VERSION' is not using a tagged version\n"
fi
done
if [ -n "$ERRORS" ]; then
echo "Dependency version check failed:"
echo -e "$ERRORS"
echo ""
echo "For release branches, temporal dependencies must point to tagged"
echo "versions (e.g., v1.2.3) rather than specific commits."
echo ""
echo "Please update your go.mod file to use proper semantic version tags."
exit 1
fi
echo "All temporal dependencies are using tagged versions"
- name: Validate dependency versions for PR base branch
run: >-
go run ./cmd/tools/check-dependencies
--base-branch "${{ github.event.pull_request.base.ref }}"

View File

@@ -0,0 +1,225 @@
// check-dependencies validates that key Go module dependencies (go.temporal.io/api
// and go.temporal.io/sdk) meet version policies for the PR's base branch:
//
// - release/* and cloud/* branches: dependencies must be tagged semver releases.
// - main: tagged releases are accepted; pseudo-versions must reference a commit
// on the dependency's default branch.
// - Other branches: no policy enforced.
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"golang.org/x/mod/semver"
)
const defaultGoModPath = "go.mod"
type moduleSpec struct {
modulePath string
repoURL string
defaultBranch string
}
var knownModules = []moduleSpec{
{
modulePath: "go.temporal.io/api",
repoURL: "https://github.com/temporalio/api-go.git",
defaultBranch: "master",
},
{
modulePath: "go.temporal.io/sdk",
repoURL: "https://github.com/temporalio/sdk-go.git",
defaultBranch: "master",
},
}
func main() {
baseBranch := flag.String("base-branch", "", "PR base branch (e.g. main, release/v1.31)")
goModPath := flag.String("go-mod", defaultGoModPath, "Path to go.mod")
flag.Parse()
branch := strings.TrimSpace(*baseBranch)
if branch == "" {
fmt.Fprintln(os.Stderr, "Error: base branch is required; pass --base-branch")
os.Exit(1)
}
modPath := strings.TrimSpace(*goModPath)
goModData, err := os.ReadFile(modPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read %s: %v\n", modPath, err)
os.Exit(1)
}
modFile, err := modfile.Parse(modPath, goModData, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to parse %s: %v\n", modPath, err)
os.Exit(1)
}
var validateErr error
switch {
case strings.HasPrefix(branch, "release/") || strings.HasPrefix(branch, "cloud/"):
validateErr = validateReleaseBranch(modFile)
case branch == "main":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
validateErr = validateMainBranch(ctx, modFile)
default:
fmt.Printf("No dependency policy for base branch %q; skipping validation\n", branch)
}
if validateErr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", validateErr)
os.Exit(1)
}
}
func validateReleaseBranch(modFile *modfile.File) error {
var failures []string
for _, mod := range knownModules {
modVersion, ok := findRequiredModuleVersion(modFile, mod.modulePath)
if !ok {
failures = append(failures, fmt.Sprintf("%s: dependency not found in go.mod", mod.modulePath))
continue
}
if !semver.IsValid(modVersion.Version) || module.IsPseudoVersion(modVersion.Version) {
failures = append(failures, fmt.Sprintf("%s: version %q must be a tagged semver release", mod.modulePath, modVersion.Version))
continue
}
fmt.Printf(" - %s@%s (ok)\n", mod.modulePath, modVersion.Version)
}
if len(failures) > 0 {
return fmt.Errorf("release dependency validation failed:\n - %s", strings.Join(failures, "\n - "))
}
fmt.Println("All required dependencies use tagged releases")
return nil
}
func validateMainBranch(
ctx context.Context,
modFile *modfile.File,
) error {
var failures []string
for _, mod := range knownModules {
if err := validateMainModule(ctx, modFile, mod); err != nil {
failures = append(failures, err.Error())
}
}
if len(failures) > 0 {
return fmt.Errorf("main branch dependency validation failed:\n - %s", strings.Join(failures, "\n - "))
}
fmt.Println("All required dependencies are valid for main branch")
return nil
}
func validateMainModule(
ctx context.Context,
modFile *modfile.File,
mod moduleSpec,
) error {
modVersion, ok := findRequiredModuleVersion(modFile, mod.modulePath)
if !ok {
return fmt.Errorf("%s: dependency not found in go.mod", mod.modulePath)
}
version := modVersion.Version
fmt.Printf("Found %s version: %s\n", mod.modulePath, version)
if !module.IsPseudoVersion(version) {
if !semver.IsValid(version) {
return fmt.Errorf("%s@%s: not a valid semver tag", mod.modulePath, version)
}
fmt.Printf(" - %s@%s is a tagged release (ok)\n", mod.modulePath, version)
return nil
}
shortHash, err := module.PseudoVersionRev(version)
if err != nil {
return fmt.Errorf("%s@%s: failed to parse pseudo-version revision: %v", mod.modulePath, version, err)
}
onDefault, err := resolveModuleOriginForSpec(ctx, mod, shortHash)
if err != nil {
return fmt.Errorf("%s@%s: failed to resolve module origin: %v", mod.modulePath, version, err)
}
if !onDefault {
return fmt.Errorf("%s@%s: commit %s is not on the default branch (%s) of %s",
mod.modulePath, version, shortHash, mod.defaultBranch, mod.repoURL)
}
fmt.Printf(" - %s@%s is on %s (ok)\n", mod.modulePath, version, mod.defaultBranch)
return nil
}
func findRequiredModuleVersion(modFile *modfile.File, modulePath string) (module.Version, bool) {
for _, req := range modFile.Require {
if req.Mod.Path == modulePath {
return req.Mod, true
}
}
return module.Version{}, false
}
// resolveModuleOriginForSpec reports whether shortHash is reachable from the
// default branch of mod's repository.
//
// It runs two git commands:
//
// 1. git clone --bare --filter=blob:none --single-branch --branch <defaultBranch> <repoURL> <tmpDir>
// --bare: clone without a working tree; only the git object store and refs
// are written to tmpDir.
// --filter=blob:none: partial clone — fetch commits and trees but skip file
// blobs entirely, since we only need commit graph reachability.
// --single-branch: fetch only the ref for --branch, not all remote branches.
// --branch <defaultBranch>: which branch to fetch.
//
// 2. git -C <tmpDir> merge-base --is-ancestor <shortHash> refs/heads/<defaultBranch>
// -C <tmpDir>: run in the cloned bare repo.
// merge-base --is-ancestor: tests reachability rather than finding a common
// ancestor — exits 0 if <shortHash> is an ancestor of (or equal to) the
// branch tip, exits 1 if it is not.
// <shortHash>: the abbreviated commit hash extracted from the pseudo-version.
// refs/heads/<defaultBranch>: the branch tip to check ancestry against.
// Any other exit code indicates an error (e.g. the object does not exist).
func resolveModuleOriginForSpec(ctx context.Context, mod moduleSpec, shortHash string) (bool, error) {
tmpRepo, err := os.MkdirTemp("", "check-dependencies-*")
if err != nil {
return false, fmt.Errorf("failed to create temp repo dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpRepo) }()
cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--filter=blob:none", "--single-branch", "--branch", mod.defaultBranch, mod.repoURL, tmpRepo)
out, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("git clone failed: %w: %s", err, strings.TrimSpace(string(out)))
}
out, err = exec.CommandContext(ctx, "git", "-C", tmpRepo, "merge-base", "--is-ancestor", shortHash, "refs/heads/"+mod.defaultBranch).CombinedOutput()
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
fmt.Printf("git merge-base --is-ancestor output: %s\n", strings.TrimSpace(string(out)))
return false, fmt.Errorf("git merge-base --is-ancestor failed: %w", err)
}

View File

@@ -0,0 +1,252 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/mod/modfile"
)
func parseGoMod(t *testing.T, content string) *modfile.File {
t.Helper()
f, err := modfile.Parse("go.mod", []byte(content), nil)
require.NoError(t, err)
return f
}
func makeGoMod(deps map[string]string) string {
s := "module test\n\ngo 1.21\n\nrequire (\n"
for mod, ver := range deps {
s += fmt.Sprintf("\t%s %s\n", mod, ver)
}
return s + ")\n"
}
func TestFindRequiredModuleVersion(t *testing.T) {
f := parseGoMod(t, makeGoMod(map[string]string{
"go.temporal.io/api": "v1.2.3",
"go.temporal.io/sdk": "v1.4.0",
}))
t.Run("found", func(t *testing.T) {
v, ok := findRequiredModuleVersion(f, "go.temporal.io/api")
require.True(t, ok)
require.Equal(t, "v1.2.3", v.Version)
})
t.Run("not found", func(t *testing.T) {
_, ok := findRequiredModuleVersion(f, "go.temporal.io/missing")
require.False(t, ok)
})
}
func TestValidateReleaseBranch(t *testing.T) {
tests := []struct {
name string
deps map[string]string
wantErr bool
errContains []string
errNotContains []string
}{
{
name: "tagged semver passes",
deps: map[string]string{
"go.temporal.io/api": "v1.40.0",
"go.temporal.io/sdk": "v1.31.0",
},
},
{
name: "pseudo-version fails",
deps: map[string]string{
"go.temporal.io/api": "v1.40.1-0.20240101000000-abcdef012345",
"go.temporal.io/sdk": "v1.31.0",
},
wantErr: true,
errContains: []string{"go.temporal.io/api", "tagged semver release"},
},
{
name: "both modules missing fails",
deps: nil, // empty go.mod
wantErr: true,
errContains: []string{"go.temporal.io/api", "go.temporal.io/sdk"},
},
{
name: "one pseudo one tagged fails with one error",
deps: map[string]string{
"go.temporal.io/api": "v1.40.0",
"go.temporal.io/sdk": "v1.31.1-0.20240101000000-abcdef012345",
},
wantErr: true,
errContains: []string{"go.temporal.io/sdk"},
errNotContains: []string{"go.temporal.io/api"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var content string
if tc.deps == nil {
content = "module test\n\ngo 1.21\n"
} else {
content = makeGoMod(tc.deps)
}
f := parseGoMod(t, content)
err := validateReleaseBranch(f)
if !tc.wantErr {
require.NoError(t, err)
return
}
require.Error(t, err)
for _, s := range tc.errContains {
require.Contains(t, err.Error(), s)
}
for _, s := range tc.errNotContains {
require.NotContains(t, err.Error(), s)
}
})
}
}
// localRepo is a bare git repo with commits for testing.
type localRepo struct {
// Path to the bare repo.
path string
// Hash of the commit on the default branch.
onBranchHash string
// Hash of a commit that exists in the repo but is NOT on the default branch.
offBranchHash string
}
// initLocalRepo creates a bare git repo with one commit on the default branch
// and one commit on a side branch. Both commits exist as objects in the bare
// repo, but only onBranchHash is reachable from refs/heads/<branch>.
func initLocalRepo(t *testing.T, branch string) localRepo {
t.Helper()
work := t.TempDir()
run := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = work
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v: %s", args, out)
return string(out)
}
run("init", "-b", branch)
run("config", "user.email", "test@test.com")
run("config", "user.name", "Test")
require.NoError(t, os.WriteFile(filepath.Join(work, "file.txt"), []byte("hello"), 0o600))
run("add", ".")
run("commit", "-m", "initial")
onHash := run("rev-parse", "HEAD")
// Create a side branch with its own commit.
run("checkout", "-b", "side")
require.NoError(t, os.WriteFile(filepath.Join(work, "side.txt"), []byte("side"), 0o600))
run("add", ".")
run("commit", "-m", "side commit")
offHash := run("rev-parse", "HEAD")
run("checkout", branch)
// Clone to a bare repo without --single-branch so that git fetches all
// branches, making the side-branch commit reachable as an object. This
// mirrors the scenario where a pseudo-version references a commit that
// exists in the repo but is not on the default branch.
bare := t.TempDir()
cmd := exec.Command("git", "clone", "--bare", work, bare)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git clone --bare: %s", out)
return localRepo{
path: bare,
onBranchHash: onHash[:len(onHash)-1],
offBranchHash: offHash[:len(offHash)-1],
}
}
func TestResolveModuleOriginForSpec(t *testing.T) {
const branch = "master"
repo := initLocalRepo(t, branch)
spec := moduleSpec{
modulePath: "go.temporal.io/api",
repoURL: repo.path,
defaultBranch: branch,
}
tests := []struct {
name string
hash string
onDefault bool
}{
{"commit on default branch", repo.onBranchHash[:12], true},
{"commit not on default branch", repo.offBranchHash[:12], false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
onDefault, err := resolveModuleOriginForSpec(context.Background(), spec, tc.hash)
require.NoError(t, err)
require.Equal(t, tc.onDefault, onDefault)
})
}
}
// setupLocalKnownModules replaces knownModules with specs pointing at the local
// bare repo, restoring the original on cleanup.
func setupLocalKnownModules(t *testing.T, repo localRepo, branch string) {
t.Helper()
orig := knownModules
t.Cleanup(func() { knownModules = orig })
knownModules = []moduleSpec{
{modulePath: "go.temporal.io/api", repoURL: repo.path, defaultBranch: branch},
{modulePath: "go.temporal.io/sdk", repoURL: repo.path, defaultBranch: branch},
}
}
func TestValidateMainBranch(t *testing.T) {
t.Run("tagged release passes", func(t *testing.T) {
f := parseGoMod(t, makeGoMod(map[string]string{
"go.temporal.io/api": "v1.40.0",
"go.temporal.io/sdk": "v1.31.0",
}))
require.NoError(t, validateMainBranch(context.Background(), f))
})
t.Run("missing module fails", func(t *testing.T) {
f := parseGoMod(t, "module test\n\ngo 1.21\n")
err := validateMainBranch(context.Background(), f)
require.Error(t, err)
require.Contains(t, err.Error(), "go.temporal.io/api")
})
const branch = "master"
repo := initLocalRepo(t, branch)
setupLocalKnownModules(t, repo, branch)
t.Run("pseudo-version on default branch passes", func(t *testing.T) {
ver := fmt.Sprintf("v0.0.0-20240101000000-%s", repo.onBranchHash[:12])
f := parseGoMod(t, makeGoMod(map[string]string{
"go.temporal.io/api": ver,
"go.temporal.io/sdk": ver,
}))
require.NoError(t, validateMainBranch(context.Background(), f))
})
t.Run("pseudo-version not on default branch fails", func(t *testing.T) {
ver := fmt.Sprintf("v0.0.0-20240101000000-%s", repo.offBranchHash[:12])
f := parseGoMod(t, makeGoMod(map[string]string{
"go.temporal.io/api": ver,
"go.temporal.io/sdk": ver,
}))
err := validateMainBranch(context.Background(), f)
require.Error(t, err)
require.Contains(t, err.Error(), "not on the default branch")
})
}

1
go.mod
View File

@@ -71,6 +71,7 @@ require (
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/mod v0.35.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.36.0