Skip to content

Commit 76ccaa1

Browse files
authored
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 ```
1 parent ccde551 commit 76ccaa1

4 files changed

Lines changed: 494 additions & 46 deletions

File tree

Lines changed: 16 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
name: Check Release Dependencies
22
on:
3-
pull_request: {}
3+
pull_request:
4+
branches:
5+
- main
6+
- "release/**"
7+
- "cloud/**"
48

59
permissions:
610
contents: read
@@ -11,49 +15,15 @@ jobs:
1115
steps:
1216
- name: Checkout code
1317
uses: actions/checkout@v6
14-
if: >-
15-
startsWith('release/', github.event.pull_request.base.ref) ||
16-
startsWith('cloud/', github.event.pull_request.base.ref)
1718

18-
- name: Check temporal dependencies use tagged versions
19-
if: >-
20-
startsWith('release/', github.event.pull_request.base.ref) ||
21-
startsWith('cloud/', github.event.pull_request.base.ref)
22-
run: |
23-
echo "Checking that temporal dependencies use tagged versions..."
24-
25-
# Semantic version regex pattern (e.g., v1.2.3)
26-
SEMVER_PATTERN="^v[0-9]+\.[0-9]+\.[0-9]+$"
27-
28-
DEPENDENCIES=(
29-
"go.temporal.io/api"
30-
"go.temporal.io/sdk"
31-
)
32-
33-
ERRORS=""
34-
35-
for DEPENDENCY in "${DEPENDENCIES[@]}"; do
36-
VERSION=$(grep "^[[:space:]]*$DEPENDENCY" go.mod | awk '{print $2}')
37-
38-
if [ -z "$VERSION" ]; then
39-
echo "Error: $DEPENDENCY dependency not found in go.mod"
40-
exit 1
41-
fi
42-
43-
if ! echo "$VERSION" | grep -qE "$SEMVER_PATTERN"; then
44-
ERRORS="${ERRORS} $DEPENDENCY version '$VERSION' is not using a tagged version\n"
45-
fi
46-
done
47-
48-
if [ -n "$ERRORS" ]; then
49-
echo "Dependency version check failed:"
50-
echo -e "$ERRORS"
51-
echo ""
52-
echo "For release branches, temporal dependencies must point to tagged"
53-
echo "versions (e.g., v1.2.3) rather than specific commits."
54-
echo ""
55-
echo "Please update your go.mod file to use proper semantic version tags."
56-
exit 1
57-
fi
58-
59-
echo "All temporal dependencies are using tagged versions"
19+
- name: Setup Go
20+
uses: actions/setup-go@v6
21+
with:
22+
go-version-file: "go.mod"
23+
check-latest: true
24+
cache: true
25+
26+
- name: Validate dependency versions for PR base branch
27+
run: >-
28+
go run ./cmd/tools/check-dependencies
29+
--base-branch "${{ github.event.pull_request.base.ref }}"
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// check-dependencies validates that key Go module dependencies (go.temporal.io/api
2+
// and go.temporal.io/sdk) meet version policies for the PR's base branch:
3+
//
4+
// - release/* and cloud/* branches: dependencies must be tagged semver releases.
5+
// - main: tagged releases are accepted; pseudo-versions must reference a commit
6+
// on the dependency's default branch.
7+
// - Other branches: no policy enforced.
8+
package main
9+
10+
import (
11+
"context"
12+
"errors"
13+
"flag"
14+
"fmt"
15+
"os"
16+
"os/exec"
17+
"strings"
18+
"time"
19+
20+
"golang.org/x/mod/modfile"
21+
"golang.org/x/mod/module"
22+
"golang.org/x/mod/semver"
23+
)
24+
25+
const defaultGoModPath = "go.mod"
26+
27+
type moduleSpec struct {
28+
modulePath string
29+
repoURL string
30+
defaultBranch string
31+
}
32+
33+
var knownModules = []moduleSpec{
34+
{
35+
modulePath: "go.temporal.io/api",
36+
repoURL: "https://github.com/temporalio/api-go.git",
37+
defaultBranch: "master",
38+
},
39+
{
40+
modulePath: "go.temporal.io/sdk",
41+
repoURL: "https://github.com/temporalio/sdk-go.git",
42+
defaultBranch: "master",
43+
},
44+
}
45+
46+
func main() {
47+
baseBranch := flag.String("base-branch", "", "PR base branch (e.g. main, release/v1.31)")
48+
goModPath := flag.String("go-mod", defaultGoModPath, "Path to go.mod")
49+
flag.Parse()
50+
51+
branch := strings.TrimSpace(*baseBranch)
52+
if branch == "" {
53+
fmt.Fprintln(os.Stderr, "Error: base branch is required; pass --base-branch")
54+
os.Exit(1)
55+
}
56+
57+
modPath := strings.TrimSpace(*goModPath)
58+
goModData, err := os.ReadFile(modPath)
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "Error: failed to read %s: %v\n", modPath, err)
61+
os.Exit(1)
62+
}
63+
64+
modFile, err := modfile.Parse(modPath, goModData, nil)
65+
if err != nil {
66+
fmt.Fprintf(os.Stderr, "Error: failed to parse %s: %v\n", modPath, err)
67+
os.Exit(1)
68+
}
69+
70+
var validateErr error
71+
switch {
72+
case strings.HasPrefix(branch, "release/") || strings.HasPrefix(branch, "cloud/"):
73+
validateErr = validateReleaseBranch(modFile)
74+
case branch == "main":
75+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
76+
defer cancel()
77+
validateErr = validateMainBranch(ctx, modFile)
78+
default:
79+
fmt.Printf("No dependency policy for base branch %q; skipping validation\n", branch)
80+
}
81+
82+
if validateErr != nil {
83+
fmt.Fprintf(os.Stderr, "Error: %v\n", validateErr)
84+
os.Exit(1)
85+
}
86+
}
87+
88+
func validateReleaseBranch(modFile *modfile.File) error {
89+
var failures []string
90+
for _, mod := range knownModules {
91+
modVersion, ok := findRequiredModuleVersion(modFile, mod.modulePath)
92+
if !ok {
93+
failures = append(failures, fmt.Sprintf("%s: dependency not found in go.mod", mod.modulePath))
94+
continue
95+
}
96+
97+
if !semver.IsValid(modVersion.Version) || module.IsPseudoVersion(modVersion.Version) {
98+
failures = append(failures, fmt.Sprintf("%s: version %q must be a tagged semver release", mod.modulePath, modVersion.Version))
99+
continue
100+
}
101+
102+
fmt.Printf(" - %s@%s (ok)\n", mod.modulePath, modVersion.Version)
103+
}
104+
105+
if len(failures) > 0 {
106+
return fmt.Errorf("release dependency validation failed:\n - %s", strings.Join(failures, "\n - "))
107+
}
108+
109+
fmt.Println("All required dependencies use tagged releases")
110+
return nil
111+
}
112+
113+
func validateMainBranch(
114+
ctx context.Context,
115+
modFile *modfile.File,
116+
) error {
117+
var failures []string
118+
for _, mod := range knownModules {
119+
if err := validateMainModule(ctx, modFile, mod); err != nil {
120+
failures = append(failures, err.Error())
121+
}
122+
}
123+
124+
if len(failures) > 0 {
125+
return fmt.Errorf("main branch dependency validation failed:\n - %s", strings.Join(failures, "\n - "))
126+
}
127+
128+
fmt.Println("All required dependencies are valid for main branch")
129+
return nil
130+
}
131+
132+
func validateMainModule(
133+
ctx context.Context,
134+
modFile *modfile.File,
135+
mod moduleSpec,
136+
) error {
137+
modVersion, ok := findRequiredModuleVersion(modFile, mod.modulePath)
138+
if !ok {
139+
return fmt.Errorf("%s: dependency not found in go.mod", mod.modulePath)
140+
}
141+
version := modVersion.Version
142+
143+
fmt.Printf("Found %s version: %s\n", mod.modulePath, version)
144+
145+
if !module.IsPseudoVersion(version) {
146+
if !semver.IsValid(version) {
147+
return fmt.Errorf("%s@%s: not a valid semver tag", mod.modulePath, version)
148+
}
149+
fmt.Printf(" - %s@%s is a tagged release (ok)\n", mod.modulePath, version)
150+
return nil
151+
}
152+
153+
shortHash, err := module.PseudoVersionRev(version)
154+
if err != nil {
155+
return fmt.Errorf("%s@%s: failed to parse pseudo-version revision: %v", mod.modulePath, version, err)
156+
}
157+
158+
onDefault, err := resolveModuleOriginForSpec(ctx, mod, shortHash)
159+
if err != nil {
160+
return fmt.Errorf("%s@%s: failed to resolve module origin: %v", mod.modulePath, version, err)
161+
}
162+
163+
if !onDefault {
164+
return fmt.Errorf("%s@%s: commit %s is not on the default branch (%s) of %s",
165+
mod.modulePath, version, shortHash, mod.defaultBranch, mod.repoURL)
166+
}
167+
168+
fmt.Printf(" - %s@%s is on %s (ok)\n", mod.modulePath, version, mod.defaultBranch)
169+
return nil
170+
}
171+
172+
func findRequiredModuleVersion(modFile *modfile.File, modulePath string) (module.Version, bool) {
173+
for _, req := range modFile.Require {
174+
if req.Mod.Path == modulePath {
175+
return req.Mod, true
176+
}
177+
}
178+
return module.Version{}, false
179+
}
180+
181+
// resolveModuleOriginForSpec reports whether shortHash is reachable from the
182+
// default branch of mod's repository.
183+
//
184+
// It runs two git commands:
185+
//
186+
// 1. git clone --bare --filter=blob:none --single-branch --branch <defaultBranch> <repoURL> <tmpDir>
187+
// --bare: clone without a working tree; only the git object store and refs
188+
// are written to tmpDir.
189+
// --filter=blob:none: partial clone — fetch commits and trees but skip file
190+
// blobs entirely, since we only need commit graph reachability.
191+
// --single-branch: fetch only the ref for --branch, not all remote branches.
192+
// --branch <defaultBranch>: which branch to fetch.
193+
//
194+
// 2. git -C <tmpDir> merge-base --is-ancestor <shortHash> refs/heads/<defaultBranch>
195+
// -C <tmpDir>: run in the cloned bare repo.
196+
// merge-base --is-ancestor: tests reachability rather than finding a common
197+
// ancestor — exits 0 if <shortHash> is an ancestor of (or equal to) the
198+
// branch tip, exits 1 if it is not.
199+
// <shortHash>: the abbreviated commit hash extracted from the pseudo-version.
200+
// refs/heads/<defaultBranch>: the branch tip to check ancestry against.
201+
// Any other exit code indicates an error (e.g. the object does not exist).
202+
func resolveModuleOriginForSpec(ctx context.Context, mod moduleSpec, shortHash string) (bool, error) {
203+
tmpRepo, err := os.MkdirTemp("", "check-dependencies-*")
204+
if err != nil {
205+
return false, fmt.Errorf("failed to create temp repo dir: %w", err)
206+
}
207+
defer func() { _ = os.RemoveAll(tmpRepo) }()
208+
209+
cmd := exec.CommandContext(ctx, "git", "clone", "--bare", "--filter=blob:none", "--single-branch", "--branch", mod.defaultBranch, mod.repoURL, tmpRepo)
210+
out, err := cmd.CombinedOutput()
211+
if err != nil {
212+
return false, fmt.Errorf("git clone failed: %w: %s", err, strings.TrimSpace(string(out)))
213+
}
214+
215+
out, err = exec.CommandContext(ctx, "git", "-C", tmpRepo, "merge-base", "--is-ancestor", shortHash, "refs/heads/"+mod.defaultBranch).CombinedOutput()
216+
if err == nil {
217+
return true, nil
218+
}
219+
var exitErr *exec.ExitError
220+
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
221+
return false, nil
222+
}
223+
fmt.Printf("git merge-base --is-ancestor output: %s\n", strings.TrimSpace(string(out)))
224+
return false, fmt.Errorf("git merge-base --is-ancestor failed: %w", err)
225+
}

0 commit comments

Comments
 (0)