mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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 ```
226 lines
7.2 KiB
Go
226 lines
7.2 KiB
Go
// 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)
|
|
}
|