go/utils/cigotests: add unified Go test runner

A small Go program that reads an embedded config (config.yaml) and
drives the test matrix for the new ci-go-tests workflow. Three
subcommands:

  list                                          -> JSON {os, shard} cells
  plan --shard X --os Y --event Z               -> describe-only (no exec)
  run  --shard X --os Y --event Z               -> exec go test

Each shard declares packages (with optional ./... + exclude),
timeout, env, and race_on conditions over (os, event). The list
command emits one matrix cell per (os, shard) the workflow should
run, with shards able to narrow OS coverage via runs_on. Adding,
splitting, or retuning a shard is a single-file edit — the
workflow re-discovers cells on every run.

This commit is the runner only; the workflow that invokes it is
in the next commit.
This commit is contained in:
Aaron Son
2026-05-07 16:33:02 -07:00
parent aef1dcc673
commit 3ee83403e5
4 changed files with 585 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2026 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
_ "embed"
"fmt"
"os/exec"
"slices"
"strings"
"gopkg.in/yaml.v3"
)
//go:embed config.yaml
var configBytes []byte
type Config struct {
DefaultOS []string `yaml:"default_os"`
Shards []Shard `yaml:"shards"`
}
type Shard struct {
Name string `yaml:"name"`
Packages []string `yaml:"packages"`
Exclude []string `yaml:"exclude,omitempty"`
Timeout string `yaml:"timeout,omitempty"`
Env map[string]string `yaml:"env,omitempty"`
EnvWithRace map[string]string `yaml:"env_with_race,omitempty"`
RunsOn *Condition `yaml:"runs_on,omitempty"`
RaceOn *Condition `yaml:"race_on,omitempty"`
}
// Condition is a predicate over (os, event). An empty list for either
// field means "any value." A nil Condition is true.
type Condition struct {
OS []string `yaml:"os,omitempty"`
Events []string `yaml:"events,omitempty"`
}
// Combo is one cell of the GitHub Actions matrix.
type Combo struct {
OS string `json:"os"`
Shard string `json:"shard"`
}
func LoadConfig() (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(configBytes, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if len(cfg.DefaultOS) == 0 {
return nil, fmt.Errorf("config: default_os must be non-empty")
}
seen := map[string]bool{}
for _, s := range cfg.Shards {
if s.Name == "" {
return nil, fmt.Errorf("config: shard with empty name")
}
if seen[s.Name] {
return nil, fmt.Errorf("config: duplicate shard name %q", s.Name)
}
seen[s.Name] = true
if len(s.Packages) == 0 {
return nil, fmt.Errorf("config: shard %q has no packages", s.Name)
}
}
return &cfg, nil
}
func (c *Config) FindShard(name string) *Shard {
for i := range c.Shards {
if c.Shards[i].Name == name {
return &c.Shards[i]
}
}
return nil
}
// ListCombos returns the {os, shard} cells the workflow should run.
// A shard's runs_on.os overrides default_os when specified.
func (c *Config) ListCombos() []Combo {
var combos []Combo
for _, s := range c.Shards {
oses := c.DefaultOS
if s.RunsOn != nil && len(s.RunsOn.OS) > 0 {
oses = s.RunsOn.OS
}
for _, o := range oses {
combos = append(combos, Combo{OS: o, Shard: s.Name})
}
}
return combos
}
func (cond *Condition) Matches(osName, eventName string) bool {
if cond == nil {
return true
}
if len(cond.OS) > 0 && !slices.Contains(cond.OS, osName) {
return false
}
if len(cond.Events) > 0 && !slices.Contains(cond.Events, eventName) {
return false
}
return true
}
func (s *Shard) ResolveRace(osName, eventName string) bool {
if s.RaceOn == nil {
return false
}
return s.RaceOn.Matches(osName, eventName)
}
// ResolvePackages expands `./...` patterns and applies excludes by
// shelling out to `go list`. Cwd should be the Go module root.
func (s *Shard) ResolvePackages() ([]string, error) {
if !needsExpansion(s.Packages) && len(s.Exclude) == 0 {
return s.Packages, nil
}
includes, err := goList(s.Packages...)
if err != nil {
return nil, fmt.Errorf("expand includes: %w", err)
}
if len(s.Exclude) == 0 {
return includes, nil
}
excludes, err := goList(s.Exclude...)
if err != nil {
return nil, fmt.Errorf("expand excludes: %w", err)
}
excludeSet := make(map[string]bool, len(excludes))
for _, e := range excludes {
excludeSet[e] = true
}
filtered := includes[:0]
for _, p := range includes {
if !excludeSet[p] {
filtered = append(filtered, p)
}
}
return filtered, nil
}
func needsExpansion(patterns []string) bool {
for _, p := range patterns {
if strings.Contains(p, "...") {
return true
}
}
return false
}
// goList runs `go list <pat>...` and returns the resolved package
// import paths, one per line.
func goList(pats ...string) ([]string, error) {
args := append([]string{"list"}, pats...)
cmd := exec.Command("go", args...)
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("go list failed: %s", strings.TrimSpace(string(ee.Stderr)))
}
return nil, err
}
var lines []string
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
return lines, nil
}
+63
View File
@@ -0,0 +1,63 @@
# Shard configuration for the unified Go test workflow. Each entry
# describes a set of packages, the OSes it runs on, and when -race is
# applied. The runner (./go/utils/cigotests) reads this file and emits
# {os, shard} matrix combinations for the workflow.
#
# Editing rules:
# - Adding/removing a shard, packages, or env var: just edit this file;
# the workflow re-discovers shards on every run.
# - Adding a new heavy package: split it into its own shard so the
# other matrix cells aren't blocked by it.
# OSes a shard runs on by default. A shard can override with `runs_on.os`.
default_os:
- macos-latest
- ubuntu-22.04
- windows-latest
shards:
# SQL engine tests are the slowest single package; they get their own shard.
# On a push to main we also exercise them with -race; the prepared variant
# is too slow under race so we skip it via DOLT_SKIP_PREPARED_ENGINETESTS.
- name: enginetest
packages: [./libraries/doltcore/sqle/enginetest]
timeout: 60m
env:
DOLT_DEFAULT_BIN_FORMAT: __DOLT__
env_with_race:
DOLT_SKIP_PREPARED_ENGINETESTS: "1"
race_on:
os: [ubuntu-22.04]
events: [push, workflow_dispatch]
# binlogreplication has been Linux-only historically. Preserve that until
# someone validates it on macOS/Windows. Race coverage on ubuntu.
- name: binlogreplication
packages: [./libraries/doltcore/sqle/binlogreplication/...]
timeout: 60m
runs_on:
os: [ubuntu-22.04]
race_on:
os: [ubuntu-22.04]
# integration_test contains tests gated by SkipByDefaultInCI() that need
# DOLT_TEST_RUN_NON_RACE_TESTS=true to run and that don't tolerate -race.
# The "rest" shard exercises this package's non-gated tests with -race;
# this shard exercises the gated ones on every OS without -race.
- name: integration_test_gated
packages: [./libraries/doltcore/sqle/integration_test]
timeout: 30m
env:
DOLT_TEST_RUN_NON_RACE_TESTS: "true"
# Everything else. enginetest and binlogreplication are excluded since
# they have their own shards. integration_test stays in here so its
# non-gated tests run with -race on ubuntu.
- name: rest
packages: [./...]
exclude:
- ./libraries/doltcore/sqle/enginetest
- ./libraries/doltcore/sqle/binlogreplication/...
timeout: 45m
race_on:
os: [ubuntu-22.04]
+146
View File
@@ -0,0 +1,146 @@
// Copyright 2026 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"testing"
"gopkg.in/yaml.v3"
)
func TestConditionMatches(t *testing.T) {
cases := []struct {
name string
cond *Condition
os string
event string
want bool
}{
{"nil condition matches anything", nil, "ubuntu-22.04", "push", true},
{"empty condition matches anything", &Condition{}, "ubuntu-22.04", "push", true},
{"os match", &Condition{OS: []string{"ubuntu-22.04"}}, "ubuntu-22.04", "push", true},
{"os miss", &Condition{OS: []string{"ubuntu-22.04"}}, "macos-latest", "push", false},
{"event match", &Condition{Events: []string{"push"}}, "ubuntu-22.04", "push", true},
{"event miss", &Condition{Events: []string{"push"}}, "ubuntu-22.04", "pull_request", false},
{
"both match",
&Condition{OS: []string{"ubuntu-22.04"}, Events: []string{"push", "workflow_dispatch"}},
"ubuntu-22.04", "push", true,
},
{
"os match, event miss",
&Condition{OS: []string{"ubuntu-22.04"}, Events: []string{"push"}},
"ubuntu-22.04", "pull_request", false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.cond.Matches(c.os, c.event); got != c.want {
t.Fatalf("Matches(%q, %q) = %v, want %v", c.os, c.event, got, c.want)
}
})
}
}
func TestListCombos(t *testing.T) {
cfg := &Config{
DefaultOS: []string{"macos-latest", "ubuntu-22.04", "windows-latest"},
Shards: []Shard{
{Name: "all", Packages: []string{"./..."}},
{Name: "ubuntu_only", Packages: []string{"./..."}, RunsOn: &Condition{OS: []string{"ubuntu-22.04"}}},
},
}
combos := cfg.ListCombos()
want := []Combo{
{OS: "macos-latest", Shard: "all"},
{OS: "ubuntu-22.04", Shard: "all"},
{OS: "windows-latest", Shard: "all"},
{OS: "ubuntu-22.04", Shard: "ubuntu_only"},
}
if len(combos) != len(want) {
t.Fatalf("got %d combos, want %d: %+v", len(combos), len(want), combos)
}
for i, c := range combos {
if c != want[i] {
t.Errorf("combo[%d] = %+v, want %+v", i, c, want[i])
}
}
}
func TestResolveRace(t *testing.T) {
s := Shard{
Name: "x",
RaceOn: &Condition{OS: []string{"ubuntu-22.04"}, Events: []string{"push"}},
}
if !s.ResolveRace("ubuntu-22.04", "push") {
t.Error("expected race on ubuntu push")
}
if s.ResolveRace("ubuntu-22.04", "pull_request") {
t.Error("expected no race on ubuntu pull_request")
}
if s.ResolveRace("macos-latest", "push") {
t.Error("expected no race on macos push")
}
noRace := Shard{Name: "no_race"}
if noRace.ResolveRace("ubuntu-22.04", "push") {
t.Error("expected no race when RaceOn is nil")
}
}
func TestEmbeddedConfigParses(t *testing.T) {
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if len(cfg.Shards) == 0 {
t.Fatal("embedded config has no shards")
}
if len(cfg.DefaultOS) == 0 {
t.Fatal("embedded config has no default_os")
}
// Sanity: every shard has a name and at least one package.
for _, s := range cfg.Shards {
if s.Name == "" {
t.Error("shard with empty name")
}
if len(s.Packages) == 0 {
t.Errorf("shard %q has no packages", s.Name)
}
}
}
func TestLoadConfigRejectsDuplicateShardNames(t *testing.T) {
bad := []byte(`
default_os: [ubuntu-22.04]
shards:
- name: x
packages: [./a]
- name: x
packages: [./b]
`)
var cfg Config
if err := yaml.Unmarshal(bad, &cfg); err != nil {
t.Fatalf("yaml.Unmarshal: %v", err)
}
// Re-route through LoadConfig path by swapping configBytes.
old := configBytes
configBytes = bad
defer func() { configBytes = old }()
_, err := LoadConfig()
if err == nil {
t.Fatal("expected duplicate-name error")
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright 2026 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// cigotests is the test runner used by .github/workflows/ci-go-tests.yaml.
//
// It reads config.yaml (embedded at build time) and supports two
// subcommands:
//
// list
// Prints a JSON array of {"os": "...", "shard": "..."} entries to
// stdout, one per matrix cell the workflow should run.
//
// run --shard <name> --os <os> --event <github-event>
// Resolves the shard's package list, decides whether to enable -race
// based on the (os, event) pair, sets per-shard env vars, and execs
// `go test`. Cwd must be the Go module root (./go in this repo).
//
// Editing the matrix shape is a config-only operation: edit config.yaml
// and the workflow re-discovers shards on the next run.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"strings"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "list":
cmdList()
case "plan":
cmdPlan(os.Args[2:])
case "run":
cmdRun(os.Args[2:])
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:")
fmt.Fprintln(os.Stderr, " cigotests list")
fmt.Fprintln(os.Stderr, " cigotests plan --shard <name> --os <os> --event <event>")
fmt.Fprintln(os.Stderr, " cigotests run --shard <name> --os <os> --event <event>")
}
func cmdList() {
cfg, err := LoadConfig()
if err != nil {
die("load config: %v", err)
}
combos := cfg.ListCombos()
out, err := json.Marshal(combos)
if err != nil {
die("marshal combos: %v", err)
}
fmt.Println(string(out))
}
// resolvedPlan is the materialized go-test invocation for a (shard, os,
// event) triple: the resolved package list, the final go-test args, and
// the env additions that should be layered on os.Environ().
type resolvedPlan struct {
Shard *Shard
OSName string
Event string
RaceOn bool
Pkgs []string
Args []string
EnvAdds []string
}
func resolveShardArgs(args []string) (*resolvedPlan, error) {
fs := flag.NewFlagSet("", flag.ExitOnError)
shardName := fs.String("shard", "", "shard name (matches a name in config.yaml)")
osName := fs.String("os", "", "matrix os value, e.g. ubuntu-22.04")
eventName := fs.String("event", "", "github event name, e.g. pull_request, push")
if err := fs.Parse(args); err != nil {
return nil, err
}
if *shardName == "" || *osName == "" || *eventName == "" {
fs.Usage()
return nil, fmt.Errorf("--shard, --os, and --event are required")
}
cfg, err := LoadConfig()
if err != nil {
return nil, fmt.Errorf("load config: %w", err)
}
shard := cfg.FindShard(*shardName)
if shard == nil {
return nil, fmt.Errorf("unknown shard %q", *shardName)
}
pkgs, err := shard.ResolvePackages()
if err != nil {
return nil, fmt.Errorf("resolve packages: %w", err)
}
if len(pkgs) == 0 {
return nil, fmt.Errorf("shard %q resolved to zero packages", shard.Name)
}
raceOn := shard.ResolveRace(*osName, *eventName)
testArgs := []string{"test", "-vet=off"}
if shard.Timeout != "" {
testArgs = append(testArgs, "-timeout", shard.Timeout)
}
if raceOn {
testArgs = append(testArgs, "-race")
}
testArgs = append(testArgs, pkgs...)
var envAdds []string
for k, v := range shard.Env {
envAdds = append(envAdds, k+"="+v)
}
if raceOn {
for k, v := range shard.EnvWithRace {
envAdds = append(envAdds, k+"="+v)
}
}
return &resolvedPlan{
Shard: shard, OSName: *osName, Event: *eventName,
RaceOn: raceOn, Pkgs: pkgs, Args: testArgs, EnvAdds: envAdds,
}, nil
}
func (p *resolvedPlan) describe(w *os.File) {
fmt.Fprintf(w, "shard=%s os=%s event=%s race=%v packages=%d\n",
p.Shard.Name, p.OSName, p.Event, p.RaceOn, len(p.Pkgs))
for _, e := range p.EnvAdds {
fmt.Fprintf(w, "env: %s\n", e)
}
fmt.Fprintf(w, "+ go %s\n", strings.Join(p.Args, " "))
}
func cmdPlan(args []string) {
plan, err := resolveShardArgs(args)
if err != nil {
die("%v", err)
}
plan.describe(os.Stdout)
}
func cmdRun(args []string) {
plan, err := resolveShardArgs(args)
if err != nil {
die("%v", err)
}
plan.describe(os.Stderr)
cmd := exec.Command("go", plan.Args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), plan.EnvAdds...)
if err := cmd.Run(); err != nil {
os.Exit(1)
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}