diff --git a/.github/run-tests/architecture.go b/.github/run-tests/architecture.go new file mode 100644 index 0000000..0dc4ad6 --- /dev/null +++ b/.github/run-tests/architecture.go @@ -0,0 +1,266 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "syscall" +) + +type binary struct { + path string + expected *regexp.Regexp +} + +var ( + inlineGet = regexp.MustCompile(`(?m)can inline Get$`) + // Race binaries crash under QEMU: https://github.com/golang/go/issues/29948. + arm64Crash = regexp.MustCompile(`^FATAL: ThreadSanitizer: unsupported VMA range\nFATAL: Found 47 - Supported 48$`) + // TSan can fail to map metadata under QEMU: https://github.com/golang/go/issues/67881. + s390xCrash = regexp.MustCompile(`^==[0-9]+==ERROR: ThreadSanitizer failed to allocate 0x[0-9a-f]+ \([0-9]+\) bytes at address [0-9a-f]+ \(errno: 12\)$`) +) + +func runArchitecture(toolchain toolchain, target architecture, work string, qemuFailures []error) error { + directory := filepath.Join(work, target.name) + if err := os.Mkdir(directory, 0o755); err != nil { + return fmt.Errorf("create architecture directory: %s", err) + } + environment := targetEnvironment(toolchain.goroot, work, target, false) + var failures []error + if !toolchain.version.gccgo && toolchain.version.minor >= 12 { + // Go 1.12 introduced the inliner that should inline Get. See + // https://go.dev/doc/go1.12#compiler. + addFailure(&failures, checkInlining(toolchain.command, environment)) + } + normal := filepath.Join(directory, "goid.test") + arguments := []string{"test", "-c", "-o", normal, "./..."} + if !toolchain.version.gccgo && toolchain.version.minor == 3 { + // The -o flag was added to go test in Go 1.4. Go 1.3 writes the + // binary to the current directory instead. + checkout, err := os.Getwd() + if err != nil { + return errors.Join(append(failures, err)...) + } + normal = filepath.Join(checkout, filepath.Base(checkout)+".test") + arguments = []string{"test", "-c", "./..."} + defer os.Remove(normal) + } + var binaries []binary + if err := runCommands(environment, toolchain.command, []string{"build", "-v", "./..."}, arguments); err != nil { + failures = append(failures, err) + } else { + binaries = append(binaries, binary{path: normal}) + } + if !toolchain.version.gccgo && target.raceMinor != 0 && toolchain.version.minor >= target.raceMinor { + var raceFailures []error + var expected *regexp.Regexp + if target.name == "aarch64" { + expected = arm64Crash + } else if target.name == "s390x" { + canMap, err := canMapS390xTSANMeta() + if err != nil { + raceFailures = append(raceFailures, err) + } else if !canMap { + expected = s390xCrash + } + } + if target.zigTarget != "" && len(raceFailures) == 0 { + executable, err := os.Executable() + if err == nil { + err = os.Symlink(executable, filepath.Join(work, zigPrefix+target.zigTarget)) + } + path := filepath.Join(toolchain.goroot, "src", "runtime", "race", "race_linux_"+target.goarch+".syso") + if err == nil { + url := "https://github.com/golang/go/raw/refs/tags/go" + toolchain.release + "/src/runtime/race/" + filepath.Base(path) + err = runCommand(nil, "curl", "--fail", "--location", "--output", path, url) + } + if err == nil { + // Zig classifies linker inputs by suffix, so expose the downloaded + // .syso object through a hard-linked .o alias. + os.Remove(path + ".o") + err = os.Link(path, path+".o") + } + if err != nil { + raceFailures = append(raceFailures, fmt.Errorf("prepare cross-race: %s", err)) + } + } + if len(raceFailures) == 0 { + race := filepath.Join(directory, "goid.race.test") + build := []string{"build", "-v", "-race"} + test := []string{"test", "-c", "-race"} + if target.zigTarget != "" { + build = append(build, "-ldflags=-linkmode=external") + test = append(test, "-ldflags=-linkmode=external") + } + build = append(build, "./...") + test = append(test, "-o", race, "./...") + raceEnvironment := targetEnvironment(toolchain.goroot, work, target, true) + if err := runCommands(raceEnvironment, toolchain.command, build, test); err != nil { + failures = append(failures, err) + } else { + binaries = append(binaries, binary{race, expected}) + } + } else { + failures = append(failures, raceFailures...) + } + } + // Go 1.8 and older binaries cannot run under QEMU user emulation. See + // https://github.com/golang/go/commit/2673f9e. + if target.image != "" && toolchain.version.minor < 9 { + return errors.Join(failures...) + } + if target.image != "" && len(qemuFailures) != 0 { + return errors.Join(append(failures, qemuFailures...)...) + } + for _, binary := range binaries { + command := binary.path + var prefix []string + if target.image != "" { + prefix = []string{"run", "--rm", "--workdir", "/test", "--mount", fmt.Sprintf("type=bind,source=%s,target=/test,readonly", directory)} + if target.platform != "" { + prefix = append(prefix, "--platform", target.platform) + } + prefix = append(prefix, target.image, "/test/"+filepath.Base(binary.path)) + command = "docker" + } + runs := [][]string{{"-test.v"}, {"-test.bench=.", "-test.benchmem", "-test.v"}} + if binary.expected != nil { + runs = runs[:1] + } + for _, arguments := range runs { + arguments = append(append([]string(nil), prefix...), arguments...) + if binary.expected != nil { + addFailure(&failures, runExpected(command, arguments, binary.expected)) + } else { + addFailure(&failures, runCommand(nil, command, arguments...)) + } + } + } + return errors.Join(failures...) +} + +func targetEnvironment(goroot, work string, target architecture, race bool) []string { + add := []string{"GOROOT=" + goroot, "GOOS=linux", "GOARCH=" + target.goarch} + if target.goarm != "" { + add = append(add, "GOARM="+target.goarm) + } + if race && target.zigTarget != "" { + add = append(add, "CGO_ENABLED=1", "CC="+filepath.Join(work, zigPrefix+target.zigTarget)) + } + return environment(add...) +} + +func environment(add ...string) []string { + keys := map[string]bool{"CC": true, "CC_FOR_TARGET": true, "CGO_ENABLED": true, "GOARCH": true, "GOARM": true, "GOOS": true, "GOTOOLCHAIN": true} + for _, value := range add { + key, _, _ := strings.Cut(value, "=") + keys[key] = true + } + values := make([]string, 0, len(os.Environ())+len(add)+1) + for _, value := range os.Environ() { + key, _, _ := strings.Cut(value, "=") + if !keys[key] { + values = append(values, value) + } + } + return append(values, append([]string{"GOTOOLCHAIN=local"}, add...)...) +} + +func checkInlining(goCommand string, environment []string) error { + stdout, stderr, err := capture(environment, goCommand, "build", "-gcflags=-m") + if err == nil && inlineGet.Match(stderr.Bytes()) { + return nil + } + if err != nil { + return errors.Join(fmt.Errorf("%s build -gcflags=-m: %s", goCommand, err), replay(stdout, stderr)) + } + return errors.Join(fmt.Errorf("%s build -gcflags=-m did not report that Get can be inlined", goCommand), replay(stdout, stderr)) +} + +func runExpected(name string, arguments []string, expected *regexp.Regexp) error { + stdout, stderr, err := capture(nil, name, arguments...) + if _, ok := err.(*exec.ExitError); ok && stdout.Len() == 0 && expected.MatchString(strings.TrimRight(stderr.String(), "\n")) { + return nil + } + if err == nil { + return errors.Join(fmt.Errorf("%s %q unexpectedly succeeded", name, arguments), replay(stdout, stderr)) + } + return errors.Join(fmt.Errorf("%s %q failed differently than expected: %s", name, arguments, err), replay(stdout, stderr)) +} + +func replay(stdout, stderr *bytes.Buffer) error { + var failures []error + _, err := os.Stdout.Write(stdout.Bytes()) + addFailure(&failures, err) + _, err = os.Stderr.Write(stderr.Bytes()) + addFailure(&failures, err) + return errors.Join(failures...) +} + +func addFailure(failures *[]error, err error) { + if err != nil { + *failures = append(*failures, err) + } +} + +func canMapS390xTSANMeta() (bool, error) { + // QEMU linux-user maps fixed guest addresses into the host address space. + // Probe TSan's s390x metadata base to distinguish 47-bit hosts. See + // https://github.com/golang/go/issues/67881. + address64 := uint64(0x900000000000) + address, size, fixedNoReplace := uintptr(address64), uintptr(64<<10), uintptr(0x100000) + mapped, _, err := syscall.Syscall6(syscall.SYS_MMAP, address, size, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON|syscall.MAP_NORESERVE|fixedNoReplace, ^uintptr(0), 0) + if err == syscall.ENOMEM { + return false, nil + } + if err != 0 { + return false, fmt.Errorf("probe ThreadSanitizer metadata mapping: %s", err) + } + _, _, err = syscall.Syscall(syscall.SYS_MUNMAP, mapped, size, 0) + if err != 0 { + return false, fmt.Errorf("unmap ThreadSanitizer metadata probe: %s", err) + } + if mapped != address { + return false, fmt.Errorf("probe ThreadSanitizer metadata mapping: got address %#x, want %#x", mapped, address) + } + return true, nil +} + +func commandOutput(environment []string, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + command.Env, command.Stderr = environment, os.Stderr + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("%s %q: %s", name, arguments, err) + } + return string(output), nil +} + +func capture(environment []string, name string, arguments ...string) (*bytes.Buffer, *bytes.Buffer, error) { + stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) + command := exec.Command(name, arguments...) + command.Env, command.Stdout, command.Stderr = environment, stdout, stderr + return stdout, stderr, command.Run() +} + +func runCommands(environment []string, name string, commands ...[]string) error { + var failures []error + for _, arguments := range commands { + addFailure(&failures, runCommand(environment, name, arguments...)) + } + return errors.Join(failures...) +} + +func runCommand(environment []string, name string, arguments ...string) error { + command := exec.Command(name, arguments...) + command.Env, command.Stdin, command.Stdout, command.Stderr = environment, os.Stdin, os.Stdout, os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s %q: %s", name, arguments, err) + } + return nil +} diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go new file mode 100644 index 0000000..7c9fa05 --- /dev/null +++ b/.github/run-tests/main.go @@ -0,0 +1,380 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" +) + +const zigPrefix = "zig-cc-" + +const pending = "pending" + +type resultFile struct { + RunAttempt int `json:"run_attempt"` + Results map[string]map[string]string `json:"results"` +} + +type version struct { + gccgo bool + minor int +} +type toolchain struct { + label, command, goroot, release string + version version + installed bool +} +type architecture struct { + name, goarch, goarm, image, platform, zigTarget string + minimumMinor, raceMinor int +} + +var architectures = []architecture{ + // Cross-compilation became possible in Go 1.5: https://go.dev/doc/go1.5#c. + {name: "armv6", goarch: "arm", goarm: "6", minimumMinor: 5, image: "balenalib/rpi-raspbian:bookworm"}, + {name: "armv7", goarch: "arm", goarm: "7", minimumMinor: 5, image: "arm32v7/debian:bookworm", platform: "linux/arm/v7"}, + // Go 1.12 added linux/arm64 race support: https://go.dev/doc/go1.12. + {name: "aarch64", goarch: "arm64", minimumMinor: 5, image: "arm64v8/debian:bookworm", platform: "linux/arm64", raceMinor: 12, zigTarget: "aarch64-linux-gnu"}, + // Go 1.7 added s390x; Go 1.19 added its race detector. + {name: "s390x", goarch: "s390x", minimumMinor: 7, image: "s390x/debian:bookworm", platform: "linux/s390x", raceMinor: 19, zigTarget: "s390x-linux-gnu"}, + {name: "386", goarch: "386", minimumMinor: 5}, + // Go 1.4 race fixes only reached go1.4-bootstrap, not a release. + {name: "x64", goarch: "amd64", minimumMinor: 3, raceMinor: 5}, +} + +var ( + goLabel = regexp.MustCompile(`^1\.([0-9]+)$`) + gccgoLabel = regexp.MustCompile(`^gccgo-[1-9][0-9]*$`) +) + +func main() { + if target, ok := strings.CutPrefix(filepath.Base(os.Args[0]), zigPrefix); ok { + // Go 1.17 and older cannot use "zig cc" as CC. See + // https://github.com/golang/go/issues/43078. + arguments := []string{"cc", "-target", target} + for _, argument := range os.Args[1:] { + // Zig classifies linker inputs by suffix; cross-race setup creates + // a hard-linked .o alias for each .syso object. + if filepath.Ext(argument) == ".syso" { + argument += ".o" + } + arguments = append(arguments, argument) + } + fatal(runCommand(nil, "zig", arguments...)) + return + } + if len(os.Args) < 2 || (os.Args[1] != "plan" && os.Args[1] != "run") { + fatal(fmt.Errorf("usage: run-tests --attempt N --output FILE -- ARG...")) + } + flags := flag.NewFlagSet(os.Args[1], flag.ContinueOnError) + attempt := flags.Int("attempt", 0, "workflow run attempt") + output := flags.String("output", "", "result JSON path") + var bootstrap *string + if os.Args[1] == "run" { + bootstrap = flags.String("bootstrap-go", "", "bootstrap Go command") + } + if err := flags.Parse(os.Args[2:]); err != nil { + fatal(err) + } + if *attempt <= 0 || *output == "" || flags.NArg() == 0 { + fatal(fmt.Errorf("usage: run-tests %s --attempt N --output FILE -- ARG...", os.Args[1])) + } + if os.Args[1] == "plan" { + results, err := newPlan(*attempt, flags.Args()) + if err == nil { + err = writeResults(*output, results) + } + fatal(err) + return + } + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + fatal(fmt.Errorf("run requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) + } + toolchains, err := parseSpecs(flags.Args(), *bootstrap) + if err != nil { + fatal(err) + } + results, err := newPlan(*attempt, flags.Args()) + if err == nil { + err = writeResults(*output, results) + } + if err == nil { + err = run(toolchains, *bootstrap, *output, &results) + } + fatal(err) +} + +func fatal(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func parseVersion(label string) (version, error) { + if gccgoLabel.MatchString(label) { + return version{gccgo: true}, nil + } + match := goLabel.FindStringSubmatch(label) + if match == nil { + return version{}, fmt.Errorf("invalid Go version %q", label) + } + minor, err := strconv.Atoi(match[1]) + if err != nil || minor < 3 { + return version{}, fmt.Errorf("unsupported Go version %q", label) + } + return version{minor: minor}, nil +} + +func parseSpecs(specs []string, bootstrap string) ([]toolchain, error) { + if bootstrap != "" && !filepath.IsAbs(bootstrap) { + return nil, fmt.Errorf("bootstrap Go command is not absolute: %q", bootstrap) + } + toolchains := make([]toolchain, 0, len(specs)) + for index, spec := range specs { + label, command, installed := strings.Cut(spec, "=") + parsed, err := parseVersion(label) + if err != nil { + return nil, err + } + if installed && (command == "" || !filepath.IsAbs(command)) { + return nil, fmt.Errorf("Go command for %q is not absolute", label) + } + if installed && !parsed.gccgo && parsed.minor > 4 { + return nil, fmt.Errorf("installed Go version %q is not supported", label) + } + if !installed && (parsed.gccgo || parsed.minor < 5 || label != fmt.Sprintf("1.%d", parsed.minor)) { + return nil, fmt.Errorf("download requires a Go minor label at least 1.5, got %q", label) + } + if index != 0 && installed != toolchains[0].installed { + return nil, fmt.Errorf("downloaded and installed toolchains cannot be mixed") + } + toolchains = append(toolchains, toolchain{label: label, command: command, version: parsed, installed: installed}) + } + if !toolchains[0].installed && bootstrap == "" { + return nil, fmt.Errorf("--bootstrap-go is required for downloaded toolchains") + } + if toolchains[0].installed && bootstrap != "" { + return nil, fmt.Errorf("--bootstrap-go requires downloaded toolchains") + } + return toolchains, nil +} + +func newPlan(attempt int, arguments []string) (resultFile, error) { + results := resultFile{RunAttempt: attempt, Results: make(map[string]map[string]string, len(arguments))} + for _, argument := range arguments { + label, _, _ := strings.Cut(argument, "=") + if _, ok := results.Results[label]; ok { + return resultFile{}, fmt.Errorf("duplicate Go version %q", label) + } + parsed, err := parseVersion(label) + if err != nil { + return resultFile{}, err + } + results.Results[label] = make(map[string]string, len(architectures)) + for _, target := range architectures { + value := "not_applicable" + if target.applicable(parsed) { + value = pending + } + results.Results[label][target.name] = value + } + } + return results, nil +} + +func (target architecture) applicable(parsed version) bool { + if parsed.gccgo { + // gccgo cross-compilation requires a target-specific GCC toolchain + // rather than GOARCH, so only native x64 builds are included. + return target.name == "x64" + } + return parsed.minor >= target.minimumMinor +} +func writeResults(path string, results resultFile) error { + data, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("encode results: %s", err) + } + temporary := path + ".tmp" + defer os.Remove(temporary) + data = append(data, '\n') + if err := os.WriteFile(temporary, data, 0o644); err != nil { + return fmt.Errorf("write results: %s", err) + } + if err := os.Rename(temporary, path); err != nil { + return fmt.Errorf("replace results: %s", err) + } + return nil +} + +func latestReleaseWrapper(directory, label string) (string, error) { + prefix := "go" + label + "." + matches, err := filepath.Glob(filepath.Join(directory, prefix+"*")) + if err != nil { + return "", fmt.Errorf("find Go %s wrappers: %s", label, err) + } + selected, patch := "", -1 + for _, match := range matches { + value := strings.TrimPrefix(filepath.Base(match), prefix) + candidate, err := strconv.Atoi(value) + if err == nil && strconv.Itoa(candidate) == value && candidate > patch { + selected, patch = filepath.Base(match), candidate + } + } + if selected == "" { + return "", fmt.Errorf("no wrapper found for Go %s", label) + } + return selected, nil +} + +func run(toolchains []toolchain, bootstrap, output string, results *resultFile) error { + work, err := os.MkdirTemp("", "goid-run-tests-") + if err != nil { + return fmt.Errorf("create working directory: %s", err) + } + defer os.RemoveAll(work) + + var failures []error + if !toolchains[0].installed { + var module struct{ Version, Dir string } + value, err := commandOutput(environment(), bootstrap, "mod", "download", "-json", "golang.org/dl@latest") + if err == nil { + err = json.Unmarshal([]byte(value), &module) + } + if err == nil && (module.Version == "" || module.Dir == "") { + err = fmt.Errorf("invalid golang.org/dl module metadata") + } + if err != nil { + return err + } + bin := filepath.Join(work, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + return fmt.Errorf("create wrapper directory: %s", err) + } + arguments := []string{"install"} + var downloads []int + for index := range toolchains { + exact, err := latestReleaseWrapper(module.Dir, toolchains[index].label) + if err != nil { + failures = append(failures, err) + continue + } + toolchains[index].command = filepath.Join(bin, exact) + toolchains[index].release = strings.TrimPrefix(exact, "go") + arguments = append(arguments, "golang.org/dl/"+exact+"@"+module.Version) + downloads = append(downloads, index) + } + downloadErrors := make([]error, len(toolchains)) + if len(downloads) != 0 { + if err := runCommand(environment("GOBIN="+bin), bootstrap, arguments...); err != nil { + return errors.Join(errors.Join(failures...), fmt.Errorf("install Go wrappers: %s", err)) + } else { + var wait sync.WaitGroup + for _, index := range downloads { + wait.Add(1) + go func(index int) { + defer wait.Done() + downloadErrors[index] = runCommand(environment("GOROOT="), toolchains[index].command, "download") + }(index) + } + wait.Wait() + } + } + for index, err := range downloadErrors { + if err != nil { + toolchains[index].command = "" + failures = append(failures, fmt.Errorf("%s: download toolchain: %s", toolchains[index].label, err)) + } + } + } + + var qemuFailures []error + for _, toolchain := range toolchains { + if toolchain.command != "" && !toolchain.version.gccgo && toolchain.version.minor >= 9 { + if err := runCommand(nil, "docker", "run", "--rm", "--privileged", "tonistiigi/binfmt", "--install", "all"); err != nil { + qemuFailures = append(qemuFailures, fmt.Errorf("configure QEMU: %s", err)) + } + break + } + } + for index := range toolchains { + toolchain := &toolchains[index] + if toolchain.command == "" { + continue + } + toolchainEnvironment := environment("GOROOT=") + if toolchain.installed && !toolchain.version.gccgo { + toolchainEnvironment = environment() + } + value, err := commandOutput(toolchainEnvironment, toolchain.command, "env", "GOROOT") + if err == nil && strings.TrimSpace(value) == "" { + err = fmt.Errorf("empty GOROOT") + } + if err != nil { + failures = append(failures, fmt.Errorf("%s: resolve GOROOT: %s", toolchain.label, err)) + continue + } + toolchain.goroot = strings.TrimSpace(value) + if err := runVersion(*toolchain, work, output, results, qemuFailures); err != nil { + failures = append(failures, fmt.Errorf("%s: %s", toolchain.label, err)) + } + } + return errors.Join(failures...) +} + +func runVersion(toolchain toolchain, work, output string, results *resultFile, qemuFailures []error) error { + directory := filepath.Join(work, toolchain.label) + if err := os.Mkdir(directory, 0o755); err != nil { + return fmt.Errorf("create version directory: %s", err) + } + type completed struct { + name string + err error + } + done := make(chan completed, len(architectures)) + count := 0 + for _, target := range architectures { + if !target.applicable(toolchain.version) { + continue + } + count++ + go func(target architecture) { + done <- completed{target.name, runArchitecture(toolchain, target, directory, qemuFailures)} + }(target) + } + byName := make(map[string]error, count) + var checkpointFailures []error + for ; count != 0; count-- { + completed := <-done + byName[completed.name] = completed.err + if len(checkpointFailures) != 0 { + continue + } + value := "success" + if completed.err != nil { + value = "failure" + } + previous := results.Results[toolchain.label][completed.name] + results.Results[toolchain.label][completed.name] = value + if err := writeResults(output, *results); err != nil { + results.Results[toolchain.label][completed.name] = previous + checkpointFailures = append(checkpointFailures, err) + } + } + var failures []error + for _, target := range architectures { + if err := byName[target.name]; err != nil { + failures = append(failures, fmt.Errorf("%s: %s", target.name, err)) + } + } + return errors.Join(append(failures, checkpointFailures...)...) +} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c05e4d3..5c62427 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -10,286 +10,110 @@ on: schedule: - cron: 00 4 * * * +permissions: + contents: read + jobs: build: - name: build (${{ matrix.go }}, ${{ matrix.arch }}) + # The report reads the whitespace-separated versions from this name. + name: build (${{ matrix.versions }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - # Cross-compilation became possible in go1.5 with the removal of C - # code from the compiler. See https://go.dev/doc/go1.5#c. - # - # gccgo cross-compilation requires a target-specific GCC toolchain - # rather than GOARCH, so only native x64 builds are included here. - - arch: x64 + # A cell shares one hosted runner across all listed versions. The + # toolchains download together, then run sequentially; pairing old + # and new releases amortizes startup while balancing shard runtimes. + - versions: '1.26 1.5' + - versions: '1.25 1.6' + - versions: '1.24 1.7' + - versions: '1.23 1.8' + - versions: '1.22 1.9' + - versions: '1.21 1.10' + - versions: '1.20 1.11' + - versions: '1.19 1.12' + - versions: '1.18 1.13' + - versions: '1.17 1.14' + - versions: '1.16 1.15' + # Go 1.3 and 1.4 have no golang.org/dl wrappers, so the go key marks + # cells that setup-go must provision. + - versions: '1.3' go: '1.3' - - arch: x64 + - versions: '1.4' go: '1.4' - - arch: x64 - go: gccgo-9 - - arch: x64 - go: gccgo-10 - - arch: x64 - go: gccgo-11 - - arch: x64 - go: gccgo-12 - - arch: x64 - go: gccgo-13 - - arch: x64 - go: gccgo-14 - arch: - - armv6 - - armv7 - - aarch64 - - s390x - - 386 - - x64 - go: - - '1.5' - - '1.6' - - '1.7' - - '1.8' - - '1.9' - - '1.10' - - '1.11' - - '1.12' - - '1.13' - - '1.14' - - '1.15' - - '1.16' - - '1.17' - - '1.18' - - '1.19' - - '1.20' - - '1.21' - - '1.22' - - '1.23' - - '1.24' - - '1.25' - - '1.26' - exclude: - # Support for s390x was added in Go1.7. See https://go.dev/doc/go1.7#ports. - - arch: s390x - go: '1.5' - - arch: s390x - go: '1.6' - + - versions: gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 + gccgo: true steps: - uses: actions/checkout@v7 - # TODO(https://github.com/actions/setup-go/issues/756): Remove this step - # and the cache input below when upstream stops warning about missing - # cache paths for Go versions that predate caching. - - name: Configure setup-go - id: configure_setup_go - if: ${{ !startsWith(matrix.go, 'gccgo-') }} - shell: bash - run: | - set -euo pipefail - - IFS=. read -r major minor _ <<< "${{ matrix.go }}" - cache=true - if (( major == 1 && minor < 10 )); then - cache=false - fi - echo "cache=$cache" >> "$GITHUB_OUTPUT" + - run: go build -o run-tests -- ./.github/run-tests + - if: ${{ !matrix.go && !matrix.gccgo }} + uses: mlugg/setup-zig@v2 + with: + version: latest + use-cache: false + # TODO(https://github.com/actions/setup-go/issues/756): Remove the cache + # input below when upstream stops warning about missing cache paths for + # Go versions that predate caching. - name: Set up Go - id: setup-go - if: ${{ !startsWith(matrix.go, 'gccgo-') }} - uses: actions/setup-go@v6.4.0 + if: ${{ matrix.go }} + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} check-latest: true - cache: ${{ steps.configure_setup_go.outputs.cache }} + cache: false - name: Set up gccgo - if: ${{ startsWith(matrix.go, 'gccgo-') }} - shell: bash + if: ${{ matrix.gccgo }} run: | set -euxo pipefail sudo apt update - sudo apt install -y ${{ matrix.go }} - echo ${{ matrix.go }} | sed 's/^gcc//' | xargs -I % ln -s /usr/bin/% /usr/local/bin/go - - go version - - name: Configure environment - id: configure_environment - shell: bash - run: | - set -euxo pipefail - - case ${{ matrix.arch }} in - armv6) - echo GOARCH=arm >> "$GITHUB_ENV" - echo GOARM=6 >> "$GITHUB_ENV" - ;; - armv7) - echo GOARCH=arm >> "$GITHUB_ENV" - echo GOARM=7 >> "$GITHUB_ENV" - ;; - aarch64) - echo GOARCH=arm64 >> "$GITHUB_ENV" - ;; - s390x) - echo GOARCH=s390x >> "$GITHUB_ENV" - ;; - 386) - echo GOARCH=386 >> "$GITHUB_ENV" - ;; - x64) - echo GOARCH=amd64 >> "$GITHUB_ENV" - ;; - *) - echo "Unknown architecture: ${{ matrix.arch }}" - exit 1 - ;; - esac - - version_ge() { - version=$1 - - printf "$version\n%s\n" ${{ matrix.go }} | sort -V | head -n1 | xargs test "$version" = - } - - # Race builds are pretty much busted on Go 1.4 and below on systems with newer C - # compilers. A number of fixes were backported to go1.4-bootstrap but not any released - # version. See https://github.com/golang/go/compare/go1.4.3...release-branch.go1.4. - if ! version_ge 1.5; then - echo "RACE_BUILDS_BROKEN=1" >> "$GITHUB_OUTPUT" - fi - - # Go binaries built with Go 1.8 and below are incompatible with QEMU user-level emulation. - # See https://github.com/golang/go/commit/2673f9e. - if version_ge 1.9; then - echo "QEMU_EMULATION_WORKS=1" >> "$GITHUB_OUTPUT" - fi - - if version_ge 1.12; then - # Better inlining in Go 1.12. See https://go.dev/doc/go1.12#compiler. - echo "BETTER_INLINING_AVAILABLE=1" >> "$GITHUB_OUTPUT" - - # Race detector support on linux/arm64 was added in go1.12. See - # https://go.dev/doc/go1.12. - echo "ARM64_RACE_SUPPORTED=1" >> "$GITHUB_OUTPUT" - fi - - if version_ge 1.19; then - # Race detector support on linux/s390x was added in go1.19. See - # https://go.dev/doc/go1.19. - echo "S390X_RACE_SUPPORTED=1" >> "$GITHUB_OUTPUT" - fi - - # Race detector binaries crash with: - # - # FATAL: ThreadSanitizer: unsupported VMA range - # - # See https://github.com/golang/go/issues/29948. - echo "ARM64_UNSUPPORTED_VMA_RANGE=1" >> "$GITHUB_OUTPUT" - - # Race detector binaries crash with: - # - # ==17==ERROR: ThreadSanitizer failed to allocate 0x7f0000 (8323072) bytes at address 9000001a0000 (errno: 12) - # - # See https://github.com/golang/go/issues/67881. - echo "S390X_THREAD_SANITIZER_FAILED_TO_ALLOCATE=1" >> "$GITHUB_OUTPUT" - - name: Check that Get is inlined - if: | - !startsWith(matrix.go, 'gccgo-') && - steps.configure_environment.outputs.BETTER_INLINING_AVAILABLE - shell: bash + sudo apt install -y ${{ matrix.versions }} + - name: Run tests with golang.org/dl toolchains + if: ${{ !matrix.go && !matrix.gccgo }} run: | set -euxo pipefail - go build -gcflags='-m' 2>&1 | grep 'can inline Get$' > /dev/null - - name: go build & go test -c - shell: bash + ./run-tests run \ + --attempt "$GITHUB_RUN_ATTEMPT" \ + --output 'build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json' \ + --bootstrap-go "$(command -v go)" \ + -- \ + ${{ matrix.versions }} + - name: Run tests with setup-go toolchain + if: ${{ matrix.go }} run: | set -euxo pipefail - go build -v ./... - go test -c -o goid.test ./... - - name: go build & go test -c (race) - # Race builds aren't supported on linux/386. - # - # Race builds aren't supported in gccgo. - id: build_goid_race_test - if: | - !cancelled() && - matrix.arch == 'x64' && - !startsWith(matrix.go, 'gccgo-') && - !steps.configure_environment.outputs.RACE_BUILDS_BROKEN - shell: bash + GOROOT="$(go env GOROOT)" + export GOROOT + ./run-tests run \ + --attempt "$GITHUB_RUN_ATTEMPT" \ + --output 'build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json' \ + -- \ + "${{ matrix.versions }}=$(command -v go)" + - name: Run gccgo tests + if: ${{ matrix.gccgo }} run: | set -euxo pipefail - go build -v -race ./... - go test -c -race -o goid.race.test ./... - - name: go build & go test -c (race) - id: build_goid_race_test_cross - if: | - !cancelled() && - matrix.arch == 'aarch64' && - steps.configure_environment.outputs.ARM64_RACE_SUPPORTED || - matrix.arch == 's390x' && - steps.configure_environment.outputs.S390X_RACE_SUPPORTED - shell: bash - run: | - set -euxo pipefail - - # Non-host *.syso files are missing from the Go toolchains provided - # by setup-go. See https://github.com/actions/setup-go/issues/181. - curl --location --output "$(go env GOROOT)"/src/runtime/race/race_linux_"$GOARCH".syso \ - https://github.com/golang/go/raw/refs/tags/go${{ steps.setup-go.outputs.go-version }}/src/runtime/race/race_linux_"$GOARCH".syso - - sudo apt update - sudo apt install -y gcc-${{ matrix.arch }}-linux-gnu - - export CGO_ENABLED=1 - export CC=${{ matrix.arch }}-linux-gnu-gcc - export CC_FOR_TARGET=gcc-${{ matrix.arch }}-linux-gnu - - go build -v -race ./... - go test -c -race -o goid.race.test ./... - - run: rm goid.race.test - if: | - !cancelled() && - (steps.build_goid_race_test.outcome == 'success' || steps.build_goid_race_test_cross.outcome == 'success') && - ( - matrix.arch == 'aarch64' && - steps.configure_environment.outputs.ARM64_RACE_SUPPORTED && - steps.configure_environment.outputs.ARM64_UNSUPPORTED_VMA_RANGE - ) || ( - matrix.arch == 's390x' && - steps.configure_environment.outputs.S390X_RACE_SUPPORTED && - steps.configure_environment.outputs.S390X_THREAD_SANITIZER_FAILED_TO_ALLOCATE - ) - - name: Run tests - if: | - !cancelled() && - (matrix.arch == '386' || matrix.arch == 'x64') - shell: bash - run: | - set -euxo pipefail - find . -name '*.test' -type f -executable -print0 | \ - xargs -t -0 -I '{}' sh -c '{} -test.v && {} -test.bench=. -test.benchmem -test.v' - - name: Run tests - if: | - !cancelled() && - matrix.arch != '386' && - matrix.arch != 'x64' && - steps.configure_environment.outputs.QEMU_EMULATION_WORKS - uses: uraimo/run-on-arch-action@v3 + ./run-tests run \ + --attempt "$GITHUB_RUN_ATTEMPT" \ + --output 'build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json' \ + -- \ + gccgo-9=/usr/bin/go-9 \ + gccgo-10=/usr/bin/go-10 \ + gccgo-11=/usr/bin/go-11 \ + gccgo-12=/usr/bin/go-12 \ + gccgo-13=/usr/bin/go-13 \ + gccgo-14=/usr/bin/go-14 + # Reruns retain artifacts, so names include the attempt. Provisioning can + # fail before the runner creates a result; the report reconstructs it. + - if: ${{ always() && hashFiles('build-status-*.json') != '' }} + uses: actions/upload-artifact@v7 with: - arch: ${{ matrix.arch }} - distro: bookworm - dockerRunArgs: --mount type=bind,source="$(pwd)",target=/checkout,readonly - shell: /bin/bash - run: | - set -euxo pipefail - - find /checkout -name '*.test' -type f -executable -print0 | \ - xargs -t -0 -I '{}' sh -c '{} -test.v && {} -test.bench=. -test.benchmem -test.v' + name: build-status-${{ github.run_attempt }}-${{ strategy.job-index }} + path: build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json report: name: build status @@ -300,65 +124,127 @@ jobs: contents: write runs-on: ubuntu-latest steps: + - uses: actions/checkout@v7 + - run: go build -o run-tests -- ./.github/run-tests + # Failed build jobs may produce no artifact; the report reconstructs + # missing results from their conclusions. + - uses: actions/download-artifact@v8 + continue-on-error: true + with: + pattern: build-status-* + path: build-status + merge-multiple: true - uses: actions/github-script@v9 with: script: | - const jobs = await github.paginate( - github.rest.actions.listJobsForWorkflowRun, - { - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.runId, - filter: 'latest', - per_page: 100, - }, - ); + const {execFileSync} = require('child_process'); + const fs = require('fs'); + const path = require('path'); + + function read(file) { + const value = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!value || !Number.isSafeInteger(value.run_attempt) || value.run_attempt <= 0 || + !value.results || + typeof value.results !== 'object' || Array.isArray(value.results)) { + throw new Error(`Invalid build status ${file}`); + } + return value; + } - const builds = new Map(); - const architectureSet = new Set(); + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + filter: 'all', + per_page: 100, + }); + const latest = new Map(); for (const job of jobs) { - const match = /^build \(([^,]+), ([^)]+)\)$/.exec(job.name); - if (!match) { - continue; + const match = /^build \(([^)]+)\)$/.exec(job.name); + if (!match) continue; + const previous = latest.get(job.name); + if (!previous || job.run_attempt > previous.job.run_attempt) { + latest.set(job.name, {job, versions: match[1].split(' ')}); + } else if (job.run_attempt === previous.job.run_attempt) { + throw new Error(`Duplicate ${job.name} attempt ${job.run_attempt}`); } + } - const [, version, architecture] = match; - if (!builds.has(version)) { - builds.set(version, new Map()); + const builds = new Map(); + const shards = [...latest.values()]; + const shardsByVersions = new Map(); + for (const shard of shards) { + if (shard.job.conclusion === 'skipped') throw new Error(`Skipped ${shard.job.name}`); + shard.key = [...shard.versions].sort().join('\n'); + for (const version of shard.versions) { + if (builds.has(version)) throw new Error(`Duplicate build for ${version}`); + builds.set(version, {job: shard.job, shard}); } - builds.get(version).set(architecture, job); - architectureSet.add(architecture); + shardsByVersions.set(shard.key, shard); } - if (builds.size === 0) { - throw new Error('No matrix build jobs found'); + if (builds.size === 0) throw new Error('No build jobs found'); + const versions = [...builds.keys()].sort((a, b) => { + const group = Number(a.startsWith('gccgo-')) - Number(b.startsWith('gccgo-')); + return group || b.localeCompare(a, undefined, {numeric: true}); + }); + + const files = fs.existsSync('build-status') ? fs.readdirSync('build-status') : []; + for (const name of files.filter(name => name.endsWith('.json')).sort()) { + const value = read(path.join('build-status', name)); + const key = Object.keys(value.results).sort().join('\n'); + const shard = shardsByVersions.get(key); + if (!shard) throw new Error(`Unexpected build status shard ${name}`); + if (value.run_attempt !== shard.job.run_attempt) continue; + if (shard.value) throw new Error(`Duplicate build status for ${shard.job.name}`); + shard.value = value; } - const architectures = [...architectureSet].sort(); - const versions = [...builds.keys()].sort((a, b) => { - const aGccgo = a.startsWith('gccgo-'); - const bGccgo = b.startsWith('gccgo-'); - if (aGccgo !== bGccgo) { - return aGccgo ? 1 : -1; + const producer = new Set(['pending', 'success', 'failure', 'not_applicable']); + let architectures; + for (const shard of shards) { + let value = shard.value; + if (!value) { + const output = path.join(process.env.RUNNER_TEMP, `plan-${shard.job.id}.json`); + execFileSync('./run-tests', [ + 'plan', '--attempt', String(shard.job.run_attempt), '--output', output, + '--', ...shard.versions, + ], {stdio: 'inherit'}); + value = read(output); } - - const aParts = a.replace('gccgo-', '').split('.').map(Number); - const bParts = b.replace('gccgo-', '').split('.').map(Number); - return bParts[0] - aParts[0] || (bParts[1] || 0) - (aParts[1] || 0); - }); + for (const [version, results] of Object.entries(value.results)) { + const build = builds.get(version); + if (!results || typeof results !== 'object' || Array.isArray(results)) { + throw new Error(`Invalid results for ${version}`); + } + const names = Object.keys(results).sort(); + const statuses = Object.values(results); + if (!names.length || names.some(name => !name) || + !statuses.some(status => status !== 'not_applicable')) { + throw new Error(`No applicable architectures for ${version}`); + } + if (architectures && names.join() !== architectures.join()) { + throw new Error(`Architecture mismatch for ${version}`); + } + if (statuses.some(status => !producer.has(status))) throw new Error(`Invalid ${version}`); + architectures = names; + build.results = results; + } + shard.allSuccess = shard.versions.every(version => + Object.values(builds.get(version).results) + .filter(status => status !== 'not_applicable') + .every(status => status === 'success')); + } const symbols = { action_required: '⚠️', cancelled: '⏹️', failure: '❌', - in_progress: '⏳', neutral: '➖', - queued: '⏳', skipped: '⏭️', stale: '➖', startup_failure: '❌', success: '✅', timed_out: '⏱️', - waiting: '⏳', }; const summary = [ '## Build status', @@ -371,24 +257,37 @@ jobs: const readme = summary.slice(4); for (const version of versions) { + const build = builds.get(version); + const conclusion = build.job.conclusion || build.job.status; const summaryCells = []; const readmeCells = []; for (const architecture of architectures) { - const job = builds.get(version).get(architecture); - if (!job) { + // Completed cells remain authoritative when a sibling fails. + // Pending cells inherit the job conclusion; a later job + // failure taints a shard only when every applicable cell succeeded. + let status = build.results[architecture]; + if (status === 'not_applicable') { summaryCells.push('—'); readmeCells.push('—'); continue; } - - const conclusion = job.conclusion || job.status; - const symbol = symbols[conclusion] || '❔'; - summaryCells.push(`[${symbol}](${job.html_url} "${conclusion}")`); - readmeCells.push( - conclusion === 'success' - ? symbol - : `[${symbol}](${job.html_url} "${conclusion}")`, - ); + if (status === 'pending') { + if (conclusion === 'success') { + throw new Error(`Pending ${version}/${architecture}`); + } + status = conclusion; + } else if (conclusion === 'success' && status === 'failure') { + throw new Error(`Successful job reported failure for ${version}/${architecture}`); + } else if (build.shard.allSuccess && conclusion !== 'success') { + status = conclusion; + } + const symbol = symbols[status]; + if (!symbol) { + throw new Error(`Cannot render ${status}`); + } + const link = `[${symbol}](${build.job.html_url} "${status}")`; + summaryCells.push(link); + readmeCells.push(status === 'success' ? symbol : link); } summary.push(`| ${version} | ${summaryCells.join(' | ')} |`); readme.push(`| ${version} | ${readmeCells.join(' | ')} |`);