From 96a404a173ebb0d16f360f55e1cf2519c0d4790a Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Tue, 13 Aug 2024 15:39:45 -0400 Subject: [PATCH 01/12] ci: add test runner Run the tests even on broken platforms for science. --- .github/run-tests/expected_failures_arm64.go | 13 ++++ .github/run-tests/expected_failures_other.go | 8 ++ .github/run-tests/expected_failures_s390x.go | 72 +++++++++++++++++ .github/run-tests/main.go | 81 ++++++++++++++++++++ .github/workflows/go.yml | 33 +------- 5 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 .github/run-tests/expected_failures_arm64.go create mode 100644 .github/run-tests/expected_failures_other.go create mode 100644 .github/run-tests/expected_failures_s390x.go create mode 100644 .github/run-tests/main.go diff --git a/.github/run-tests/expected_failures_arm64.go b/.github/run-tests/expected_failures_arm64.go new file mode 100644 index 0000000..ad4a972 --- /dev/null +++ b/.github/run-tests/expected_failures_arm64.go @@ -0,0 +1,13 @@ +package main + +import "regexp" + +func expectedFailures() (failureMatchers, error) { + // Race detector binaries crash under QEMU. See + // https://github.com/golang/go/issues/29948. + return failureMatchers{ + "goid.race.test": regexp.MustCompile( + `^FATAL: ThreadSanitizer: unsupported VMA range\nFATAL: Found 47 - Supported 48$`, + ), + }, nil +} diff --git a/.github/run-tests/expected_failures_other.go b/.github/run-tests/expected_failures_other.go new file mode 100644 index 0000000..2d78cd1 --- /dev/null +++ b/.github/run-tests/expected_failures_other.go @@ -0,0 +1,8 @@ +//go:build !arm64 && !s390x +// +build !arm64,!s390x + +package main + +func expectedFailures() (failureMatchers, error) { + return nil, nil +} diff --git a/.github/run-tests/expected_failures_s390x.go b/.github/run-tests/expected_failures_s390x.go new file mode 100644 index 0000000..c0c31e8 --- /dev/null +++ b/.github/run-tests/expected_failures_s390x.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "regexp" + "syscall" + "unsafe" +) + +func expectedFailures() (failureMatchers, error) { + canMap, err := canMapS390xTSANMeta() + if err != nil { + return nil, err + } + if canMap { + return nil, nil + } + + // Race detector binaries crash under QEMU on hosts that cannot map the + // ThreadSanitizer metadata address. See + // https://github.com/golang/go/issues/67881. + return failureMatchers{ + "goid.race.test": regexp.MustCompile( + `^==[0-9]+==ERROR: ThreadSanitizer failed to allocate 0x[0-9a-f]+ \([0-9]+\) bytes at address [0-9a-f]+ \(errno: 12\)$`, + ), + }, nil +} + +func canMapS390xTSANMeta() (bool, error) { + // QEMU linux-user maps fixed guest addresses into the host address space. + // Probe Go's current s390x metadata base to distinguish hosts with 47-bit + // user address spaces. + const ( + metaShadowAddress = 0x900000000000 + mappingSize = 64 << 10 + // MAP_FIXED_NOREPLACE was added after the oldest Go version tested here. + mapFixedNoReplace = 0x100000 + ) + mmapArguments := [6]uintptr{ + metaShadowAddress, + mappingSize, + syscall.PROT_READ | syscall.PROT_WRITE, + syscall.MAP_PRIVATE | syscall.MAP_ANON | syscall.MAP_NORESERVE | mapFixedNoReplace, + ^uintptr(0), + 0, + } + address, _, err := syscall.Syscall( + syscall.SYS_MMAP, + uintptr(unsafe.Pointer(&mmapArguments[0])), + 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, address, mappingSize, 0) + if err != 0 { + return false, fmt.Errorf("unmap ThreadSanitizer metadata probe: %s", err) + } + if address != metaShadowAddress { + return false, fmt.Errorf( + "probe ThreadSanitizer metadata mapping: got address %#x, want %#x", + address, + metaShadowAddress, + ) + } + return true, nil +} diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go new file mode 100644 index 0000000..77497ec --- /dev/null +++ b/.github/run-tests/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +type failureMatchers map[string]*regexp.Regexp + +func main() { + matchers, err := expectedFailures() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + tests := os.Args[1:] + testArguments := [][]string{ + {"-test.v"}, + {"-test.bench=.", "-test.benchmem", "-test.v"}, + } + + var failures []error + if len(tests) == 0 { + failures = append(failures, fmt.Errorf("no test binaries specified")) + } + for _, test := range tests { + if expectedFailure, ok := matchers[filepath.Base(test)]; ok { + if err := run(test, expectedFailure, "-test.v"); err != nil { + failures = append(failures, err) + } + continue + } + + for _, args := range testArguments { + if err := run(test, nil, args...); err != nil { + failures = append(failures, err) + } + } + } + + for _, err := range failures { + fmt.Fprintln(os.Stderr, err) + } + if len(failures) != 0 { + os.Exit(1) + } +} + +func run(binary string, expectedFailure *regexp.Regexp, args ...string) error { + command := exec.Command(binary, args...) + if expectedFailure == nil { + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s with arguments %q: %s", binary, args, err) + } + return nil + } + + var stderr bytes.Buffer + command.Stdout = os.Stdout + command.Stderr = io.MultiWriter(os.Stderr, &stderr) + err := command.Run() + if err == nil { + return fmt.Errorf("%s unexpectedly succeeded", binary) + } + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("%s: %s", binary, err) + } + if !expectedFailure.MatchString(strings.TrimRight(stderr.String(), "\n")) { + return fmt.Errorf("%s failed differently than expected", binary) + } + return nil +} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index c05e4d3..1403a1a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -180,19 +180,6 @@ jobs: 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-') && @@ -251,19 +238,6 @@ jobs: 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() && @@ -284,12 +258,9 @@ jobs: arch: ${{ matrix.arch }} distro: bookworm dockerRunArgs: --mount type=bind,source="$(pwd)",target=/checkout,readonly + setup: go build -o run-tests ./.github/run-tests 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' + run: /checkout/run-tests /checkout/*.test report: name: build status From acf738cab88633c483562164ed41cde33c3b66c7 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Thu, 18 Jun 2026 10:10:19 -0400 Subject: [PATCH 02/12] coalesce --- .../{ => execute}/expected_failures_arm64.go | 0 .../{ => execute}/expected_failures_other.go | 0 .../{ => execute}/expected_failures_s390x.go | 0 .github/run-tests/execute/main.go | 81 +++ .github/run-tests/main.go | 676 +++++++++++++++++- .github/run-tests/main_test.go | 217 ++++++ .github/workflows/go.yml | 455 ++++++------ 7 files changed, 1181 insertions(+), 248 deletions(-) rename .github/run-tests/{ => execute}/expected_failures_arm64.go (100%) rename .github/run-tests/{ => execute}/expected_failures_other.go (100%) rename .github/run-tests/{ => execute}/expected_failures_s390x.go (100%) create mode 100644 .github/run-tests/execute/main.go create mode 100644 .github/run-tests/main_test.go diff --git a/.github/run-tests/expected_failures_arm64.go b/.github/run-tests/execute/expected_failures_arm64.go similarity index 100% rename from .github/run-tests/expected_failures_arm64.go rename to .github/run-tests/execute/expected_failures_arm64.go diff --git a/.github/run-tests/expected_failures_other.go b/.github/run-tests/execute/expected_failures_other.go similarity index 100% rename from .github/run-tests/expected_failures_other.go rename to .github/run-tests/execute/expected_failures_other.go diff --git a/.github/run-tests/expected_failures_s390x.go b/.github/run-tests/execute/expected_failures_s390x.go similarity index 100% rename from .github/run-tests/expected_failures_s390x.go rename to .github/run-tests/execute/expected_failures_s390x.go diff --git a/.github/run-tests/execute/main.go b/.github/run-tests/execute/main.go new file mode 100644 index 0000000..77497ec --- /dev/null +++ b/.github/run-tests/execute/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +type failureMatchers map[string]*regexp.Regexp + +func main() { + matchers, err := expectedFailures() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + tests := os.Args[1:] + testArguments := [][]string{ + {"-test.v"}, + {"-test.bench=.", "-test.benchmem", "-test.v"}, + } + + var failures []error + if len(tests) == 0 { + failures = append(failures, fmt.Errorf("no test binaries specified")) + } + for _, test := range tests { + if expectedFailure, ok := matchers[filepath.Base(test)]; ok { + if err := run(test, expectedFailure, "-test.v"); err != nil { + failures = append(failures, err) + } + continue + } + + for _, args := range testArguments { + if err := run(test, nil, args...); err != nil { + failures = append(failures, err) + } + } + } + + for _, err := range failures { + fmt.Fprintln(os.Stderr, err) + } + if len(failures) != 0 { + os.Exit(1) + } +} + +func run(binary string, expectedFailure *regexp.Regexp, args ...string) error { + command := exec.Command(binary, args...) + if expectedFailure == nil { + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s with arguments %q: %s", binary, args, err) + } + return nil + } + + var stderr bytes.Buffer + command.Stdout = os.Stdout + command.Stderr = io.MultiWriter(os.Stderr, &stderr) + err := command.Run() + if err == nil { + return fmt.Errorf("%s unexpectedly succeeded", binary) + } + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("%s: %s", binary, err) + } + if !expectedFailure.MatchString(strings.TrimRight(stderr.String(), "\n")) { + return fmt.Errorf("%s failed differently than expected", binary) + } + return nil +} diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index 77497ec..ad83fe0 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -2,80 +2,678 @@ package main import ( "bytes" + "encoding/json" + "flag" "fmt" "io" "os" "os/exec" "path/filepath" + "reflect" "regexp" + "runtime" + "strconv" "strings" ) -type failureMatchers map[string]*regexp.Regexp +const resultSchema = 1 + +type resultStatus string + +const ( + statusPending resultStatus = "pending" + statusSuccess resultStatus = "success" + statusFailure resultStatus = "failure" + statusNotApplicable resultStatus = "not_applicable" +) + +type resultFile struct { + Schema int `json:"schema"` + Go string `json:"go"` + RunAttempt int `json:"run_attempt"` + Results map[string]resultStatus `json:"results"` +} + +type goVersion struct { + gccgo bool + minor int +} + +type architecture struct { + name string + goarch string + goarm string + minimumMinor int + emulated bool + image string + platform string + raceMinor int + crossCompiler string + crossCompilerPkg string +} + +var architectures = []architecture{ + { + name: "armv6", + goarch: "arm", + goarm: "6", + minimumMinor: 5, + emulated: true, + image: "balenalib/rpi-raspbian:bookworm", + }, + { + name: "armv7", + goarch: "arm", + goarm: "7", + minimumMinor: 5, + emulated: true, + image: "arm32v7/debian:bookworm", + platform: "linux/arm/v7", + }, + { + name: "aarch64", + goarch: "arm64", + minimumMinor: 5, + emulated: true, + image: "arm64v8/debian:bookworm", + platform: "linux/arm64", + // Race detector support on linux/arm64 was added in Go 1.12. + // See https://go.dev/doc/go1.12. + raceMinor: 12, + crossCompiler: "aarch64-linux-gnu", + crossCompilerPkg: "gcc-aarch64-linux-gnu", + }, + { + name: "s390x", + goarch: "s390x", + // Support for s390x was added in Go 1.7. + // See https://go.dev/doc/go1.7#ports. + minimumMinor: 7, + emulated: true, + image: "s390x/debian:bookworm", + platform: "linux/s390x", + // Race detector support on linux/s390x was added in Go 1.19. + // See https://go.dev/doc/go1.19. + raceMinor: 19, + crossCompiler: "s390x-linux-gnu", + crossCompilerPkg: "gcc-s390x-linux-gnu", + }, + { + name: "386", + goarch: "386", + minimumMinor: 5, + }, + { + name: "x64", + goarch: "amd64", + minimumMinor: 3, + // Race builds with Go 1.4 and below are broken with newer C + // compilers. Several fixes only reached go1.4-bootstrap. See + // https://github.com/golang/go/compare/go1.4.3...release-branch.go1.4. + raceMinor: 5, + }, +} + +var ( + inlineGetPattern = regexp.MustCompile(`(?m)can inline Get$`) + goVersionPattern = regexp.MustCompile(`\bgo(1\.[0-9]+(?:\.[0-9]+)?)\b`) +) func main() { - matchers, err := expectedFailures() + if len(os.Args) < 2 { + fatal(fmt.Errorf("usage: run-tests -go -attempt -output ")) + } + + flags := flag.NewFlagSet(os.Args[1], flag.ContinueOnError) + goLabel := flags.String("go", "", "matrix Go version label") + output := flags.String("output", "", "result JSON path") + runAttempt := flags.Int("attempt", 0, "workflow run attempt") + if err := flags.Parse(os.Args[2:]); err != nil { + fatal(err) + } + if *goLabel == "" || *output == "" || *runAttempt <= 0 || flags.NArg() != 0 { + fatal(fmt.Errorf("usage: run-tests %s -go -attempt -output ", os.Args[1])) + } + + switch os.Args[1] { + case "plan": + results, err := newPlan(*goLabel, *runAttempt) + if err != nil { + fatal(err) + } + if err := writeResults(*output, results); err != nil { + fatal(err) + } + case "run": + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + fatal(fmt.Errorf("run requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) + } + + version, err := parseGoVersion(*goLabel) + if err != nil { + fatal(err) + } + expected, err := newPlan(*goLabel, *runAttempt) + if err != nil { + fatal(err) + } + results, err := readResults(*output) + if err != nil { + fatal(err) + } + if !reflect.DeepEqual(results, expected) { + fatal(fmt.Errorf("%s does not contain the plan for %s", *output, *goLabel)) + } + + coordinator := coordinator{ + version: version, + results: &results, + output: *output, + execute: runCommand, + } + failures := coordinator.run() + for _, err := range failures { + fmt.Fprintln(os.Stderr, err) + } + if len(failures) != 0 { + os.Exit(1) + } + default: + fatal(fmt.Errorf("unknown command %q", os.Args[1])) + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) +} + +func parseGoVersion(label string) (goVersion, error) { + if strings.HasPrefix(label, "gccgo-") { + if _, err := strconv.Atoi(strings.TrimPrefix(label, "gccgo-")); err != nil { + return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + } + return goVersion{gccgo: true}, nil + } + + parts := strings.Split(label, ".") + if len(parts) < 2 || len(parts) > 3 || parts[0] != "1" { + return goVersion{}, fmt.Errorf("invalid Go version %q", label) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + } + if minor < 3 { + return goVersion{}, fmt.Errorf("unsupported Go version %q", label) + } + if len(parts) == 3 { + if _, err := strconv.Atoi(parts[2]); err != nil { + return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + } + } + return goVersion{minor: minor}, nil +} + +func newPlan(label string, runAttempt int) (resultFile, error) { + if runAttempt <= 0 { + return resultFile{}, fmt.Errorf("invalid run attempt %d", runAttempt) + } + version, err := parseGoVersion(label) + if err != nil { + return resultFile{}, err + } + + results := resultFile{ + Schema: resultSchema, + Go: label, + RunAttempt: runAttempt, + Results: make(map[string]resultStatus, len(architectures)), + } + for _, architecture := range architectures { + status := statusNotApplicable + if architecture.applicable(version) { + status = statusPending + } + results.Results[architecture.name] = status + } + return results, nil +} + +func (architecture architecture) applicable(version goVersion) bool { + if version.gccgo { + // gccgo cross-compilation requires a target-specific GCC toolchain + // rather than GOARCH, so only native x64 builds are included. + return architecture.name == "x64" + } + // Cross-compilation became possible in Go 1.5 with the removal of C + // code from the compiler. See https://go.dev/doc/go1.5#c. + return version.minor >= architecture.minimumMinor +} + +func (architecture architecture) supportsRace(version goVersion) bool { + return !version.gccgo && architecture.raceMinor != 0 && version.minor >= architecture.raceMinor +} + +func readResults(path string) (resultFile, error) { + file, err := os.Open(path) + if err != nil { + return resultFile{}, fmt.Errorf("open %s: %s", path, err) + } + defer file.Close() + + var results resultFile + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&results); err != nil { + return resultFile{}, fmt.Errorf("decode %s: %s", path, err) + } + return results, nil +} + +func writeResults(path string, results resultFile) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create result directory: %s", err) + } + file, err := os.CreateTemp(filepath.Dir(path), ".run-tests-results-") if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + return fmt.Errorf("create result file: %s", err) } + temporaryPath := file.Name() + defer os.Remove(temporaryPath) - tests := os.Args[1:] - testArguments := [][]string{ - {"-test.v"}, - {"-test.bench=.", "-test.benchmem", "-test.v"}, + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(results); err != nil { + file.Close() + return fmt.Errorf("encode results: %s", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close result file: %s", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("replace %s: %s", path, err) } + return nil +} + +type coordinator struct { + version goVersion + results *resultFile + output string + execute commandRunner +} + +type commandRunner func([]string, string, ...string) error +func (coordinator coordinator) run() []error { + workDirectory, err := os.MkdirTemp("", "goid-run-tests-") + if err != nil { + return []error{fmt.Errorf("create working directory: %s", err)} + } + defer os.RemoveAll(workDirectory) + + raceFailures := coordinator.prepareRace() + emulationFailures := coordinator.prepareEmulation() var failures []error - if len(tests) == 0 { - failures = append(failures, fmt.Errorf("no test binaries specified")) + + for _, architecture := range architectures { + if coordinator.results.Results[architecture.name] == statusNotApplicable { + continue + } + + fmt.Printf("::group::%s\n", architecture.name) + architectureFailures := coordinator.runArchitecture( + workDirectory, + architecture, + raceFailures[architecture.name], + emulationFailures[architecture.name], + ) + if len(architectureFailures) == 0 { + coordinator.results.Results[architecture.name] = statusSuccess + } else { + coordinator.results.Results[architecture.name] = statusFailure + for _, err := range architectureFailures { + failures = append(failures, fmt.Errorf("%s: %s", architecture.name, err)) + } + } + if err := writeResults(coordinator.output, *coordinator.results); err != nil { + failures = append(failures, err) + fmt.Println("::endgroup::") + break + } + fmt.Println("::endgroup::") } - for _, test := range tests { - if expectedFailure, ok := matchers[filepath.Base(test)]; ok { - if err := run(test, expectedFailure, "-test.v"); err != nil { - failures = append(failures, err) + return failures +} + +func (coordinator coordinator) prepareRace() map[string][]error { + failures := make(map[string][]error) + var targets []architecture + var packages []string + for _, architecture := range architectures { + if architecture.applicable(coordinator.version) && + architecture.crossCompiler != "" && + architecture.supportsRace(coordinator.version) { + targets = append(targets, architecture) + packages = append(packages, architecture.crossCompilerPkg) + } + } + if len(targets) == 0 { + return failures + } + + fmt.Println("::group::cross-race setup") + packagesReady := true + if err := coordinator.execute(nil, "sudo", "apt", "update"); err != nil { + packagesReady = false + for _, architecture := range targets { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("update package index: %s", err), + ) + } + } + if packagesReady { + arguments := append([]string{"apt", "install", "-y"}, packages...) + if err := coordinator.execute(nil, "sudo", arguments...); err != nil { + for _, architecture := range targets { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("install cross compiler: %s", err), + ) } - continue } + } + + version, err := resolvedGoVersion() + if err != nil { + for _, architecture := range targets { + failures[architecture.name] = append(failures[architecture.name], err) + } + fmt.Println("::endgroup::") + return failures + } + goroot, err := commandOutput(nil, "go", "env", "GOROOT") + if err != nil { + for _, architecture := range targets { + failures[architecture.name] = append(failures[architecture.name], err) + } + fmt.Println("::endgroup::") + return failures + } + for _, architecture := range targets { + // Non-host *.syso files are missing from the Go toolchains provided + // by setup-go. See https://github.com/actions/setup-go/issues/181. + path := filepath.Join( + strings.TrimSpace(goroot), + "src", + "runtime", + "race", + "race_linux_"+architecture.goarch+".syso", + ) + url := fmt.Sprintf( + "https://github.com/golang/go/raw/refs/tags/go%s/src/runtime/race/race_linux_%s.syso", + version, + architecture.goarch, + ) + if err := coordinator.execute(nil, "curl", "--fail", "--location", "--output", path, url); err != nil { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("download race runtime: %s", err), + ) + } + } + fmt.Println("::endgroup::") + return failures +} + +func (coordinator coordinator) prepareEmulation() map[string][]error { + failures := make(map[string][]error) + // 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 coordinator.version.gccgo || coordinator.version.minor < 9 { + return failures + } + + var targets []architecture + for _, architecture := range architectures { + if architecture.emulated && architecture.applicable(coordinator.version) { + targets = append(targets, architecture) + } + } + if len(targets) == 0 { + return failures + } - for _, args := range testArguments { - if err := run(test, nil, args...); err != nil { + fmt.Println("::group::QEMU setup") + if err := coordinator.execute(nil, "docker", "run", "--rm", "--privileged", "tonistiigi/binfmt", "--install", "all"); err != nil { + for _, architecture := range targets { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("configure QEMU: %s", err), + ) + } + } + fmt.Println("::endgroup::") + return failures +} + +func (coordinator coordinator) runArchitecture( + workDirectory string, + architecture architecture, + raceFailures []error, + emulationFailures []error, +) []error { + directory := filepath.Join(workDirectory, architecture.name) + if err := os.MkdirAll(directory, 0o755); err != nil { + return []error{fmt.Errorf("create output directory: %s", err)} + } + environment := targetEnvironment(architecture, false) + var failures []error + + if !coordinator.version.gccgo && coordinator.version.minor >= 12 { + // Go 1.12 introduced the inliner that should inline Get. See + // https://go.dev/doc/go1.12#compiler. + if err := checkInlining(environment); err != nil { + failures = append(failures, err) + } + } + + normalBinary := filepath.Join(directory, "goid.test") + normalReady := true + if err := coordinator.execute(environment, "go", "build", "-v", "./..."); err != nil { + failures = append(failures, err) + normalReady = false + } + if normalReady { + if err := coordinator.execute(environment, "go", "test", "-c", "-o", normalBinary, "./..."); err != nil { + failures = append(failures, err) + normalReady = false + } + } + + var binaries []string + if normalReady { + binaries = append(binaries, normalBinary) + } + + if architecture.supportsRace(coordinator.version) { + if len(raceFailures) != 0 { + failures = append(failures, raceFailures...) + } else { + raceEnvironment := targetEnvironment(architecture, true) + raceBinary := filepath.Join(directory, "goid.race.test") + raceReady := true + if err := coordinator.execute(raceEnvironment, "go", "build", "-v", "-race", "./..."); err != nil { failures = append(failures, err) + raceReady = false + } + if raceReady { + if err := coordinator.execute(raceEnvironment, "go", "test", "-c", "-race", "-o", raceBinary, "./..."); err != nil { + failures = append(failures, err) + raceReady = false + } + } + if raceReady { + binaries = append(binaries, raceBinary) } } } - for _, err := range failures { - fmt.Fprintln(os.Stderr, err) + if !architecture.emulated { + return append(failures, runNative(coordinator.execute, binaries)...) } - if len(failures) != 0 { - os.Exit(1) + if coordinator.version.minor < 9 { + return failures } + if len(emulationFailures) != 0 { + return append(failures, emulationFailures...) + } + if len(binaries) == 0 { + return failures + } + + executor := filepath.Join(directory, "execute") + if err := coordinator.execute(environment, "go", "build", "-o", executor, "./.github/run-tests/execute"); err != nil { + return append(failures, err) + } + if err := runDocker(coordinator.execute, workDirectory, architecture, binaries); err != nil { + failures = append(failures, err) + } + return failures } -func run(binary string, expectedFailure *regexp.Regexp, args ...string) error { - command := exec.Command(binary, args...) - if expectedFailure == nil { - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - return fmt.Errorf("%s with arguments %q: %s", binary, args, err) +func checkInlining(environment []string) error { + command := exec.Command("go", "build", "-gcflags=-m") + command.Env = environment + var output bytes.Buffer + command.Stdout = io.MultiWriter(os.Stdout, &output) + command.Stderr = io.MultiWriter(os.Stderr, &output) + if err := command.Run(); err != nil { + return fmt.Errorf("go build -gcflags=-m: %s", err) + } + if !inlineGetPattern.Match(output.Bytes()) { + return fmt.Errorf("go build -gcflags=-m did not report that Get can be inlined") + } + return nil +} + +func runNative(execute commandRunner, binaries []string) []error { + var failures []error + for _, binary := range binaries { + if err := execute(nil, binary, "-test.v"); err != nil { + failures = append(failures, err) + } + if err := execute(nil, binary, "-test.bench=.", "-test.benchmem", "-test.v"); err != nil { + failures = append(failures, err) } - return nil } + return failures +} - var stderr bytes.Buffer - command.Stdout = os.Stdout - command.Stderr = io.MultiWriter(os.Stderr, &stderr) - err := command.Run() - if err == nil { - return fmt.Errorf("%s unexpectedly succeeded", binary) +func runDocker( + execute commandRunner, + workDirectory string, + architecture architecture, + binaries []string, +) error { + checkout, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %s", err) + } + arguments := []string{ + "run", + "--rm", + "--workdir", + checkout, + "--mount", + fmt.Sprintf("type=bind,source=%s,target=%s", checkout, checkout), } - if _, ok := err.(*exec.ExitError); !ok { - return fmt.Errorf("%s: %s", binary, err) + if architecture.platform != "" { + arguments = append(arguments, "--platform", architecture.platform) } - if !expectedFailure.MatchString(strings.TrimRight(stderr.String(), "\n")) { - return fmt.Errorf("%s failed differently than expected", binary) + arguments = append( + arguments, + "--mount", + fmt.Sprintf("type=bind,source=%s,target=/run-tests,readonly", workDirectory), + architecture.image, + filepath.ToSlash(filepath.Join("/run-tests", architecture.name, "execute")), + ) + for _, binary := range binaries { + arguments = append( + arguments, + filepath.ToSlash(filepath.Join("/run-tests", architecture.name, filepath.Base(binary))), + ) + } + return execute(nil, "docker", arguments...) +} + +func targetEnvironment(architecture architecture, race bool) []string { + removed := map[string]bool{ + "CC": true, + "CC_FOR_TARGET": true, + "CGO_ENABLED": true, + "GOARCH": true, + "GOARM": true, + "GOOS": true, + } + environment := make([]string, 0, len(os.Environ())+6) + for _, assignment := range os.Environ() { + key, _, _ := strings.Cut(assignment, "=") + if !removed[key] { + environment = append(environment, assignment) + } + } + environment = append(environment, "GOOS=linux", "GOARCH="+architecture.goarch) + if architecture.goarm != "" { + environment = append(environment, "GOARM="+architecture.goarm) + } + if race && architecture.crossCompiler != "" { + environment = append( + environment, + "CGO_ENABLED=1", + "CC="+architecture.crossCompiler+"-gcc", + "CC_FOR_TARGET=gcc-"+architecture.crossCompiler, + ) + } + return environment +} + +func resolvedGoVersion() (string, error) { + output, err := commandOutput(nil, "go", "version") + if err != nil { + return "", err + } + match := goVersionPattern.FindStringSubmatch(output) + if match == nil { + return "", fmt.Errorf("parse go version output %q", strings.TrimSpace(output)) + } + return match[1], nil +} + +func commandOutput(environment []string, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + if environment != nil { + command.Env = environment + } + command.Stderr = os.Stderr + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("%s %q: %s", name, arguments, err) + } + return string(output), nil +} + +func runCommand(environment []string, name string, arguments ...string) error { + command := exec.Command(name, arguments...) + if environment != nil { + command.Env = environment + } + command.Stdout = os.Stdout + command.Stderr = 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_test.go b/.github/run-tests/main_test.go new file mode 100644 index 0000000..a05ca00 --- /dev/null +++ b/.github/run-tests/main_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "fmt" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestPlanApplicability(t *testing.T) { + tests := []struct { + version string + applicable map[string]bool + }{ + { + version: "1.3", + applicable: map[string]bool{"x64": true}, + }, + { + version: "1.5", + applicable: map[string]bool{ + "armv6": true, + "armv7": true, + "aarch64": true, + "386": true, + "x64": true, + }, + }, + { + version: "1.7", + applicable: map[string]bool{ + "armv6": true, + "armv7": true, + "aarch64": true, + "s390x": true, + "386": true, + "x64": true, + }, + }, + { + version: "gccgo-14", + applicable: map[string]bool{"x64": true}, + }, + } + + for _, test := range tests { + t.Run(test.version, func(t *testing.T) { + plan, err := newPlan(test.version, 2) + if err != nil { + t.Fatal(err) + } + if plan.RunAttempt != 2 { + t.Fatalf("got run attempt %d, want 2", plan.RunAttempt) + } + for _, architecture := range architectures { + want := statusNotApplicable + if test.applicable[architecture.name] { + want = statusPending + } + if got := plan.Results[architecture.name]; got != want { + t.Errorf("%s: got %q, want %q", architecture.name, got, want) + } + } + }) + } +} + +func TestPlanRejectsInvalidVersions(t *testing.T) { + for _, version := range []string{"1.2", "2.0", "gccgo-x"} { + t.Run(version, func(t *testing.T) { + if _, err := newPlan(version, 1); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +func TestPlanRejectsInvalidRunAttempt(t *testing.T) { + if _, err := newPlan("1.26", 0); err == nil { + t.Fatal("expected an error") + } +} + +func TestArchitectureBoundaries(t *testing.T) { + tests := []struct { + version string + architecture string + applicable bool + race bool + }{ + {version: "1.4", architecture: "armv6"}, + {version: "1.5", architecture: "armv6", applicable: true}, + {version: "1.6", architecture: "s390x"}, + {version: "1.7", architecture: "s390x", applicable: true}, + {version: "1.4", architecture: "x64", applicable: true}, + {version: "1.5", architecture: "x64", applicable: true, race: true}, + {version: "1.11", architecture: "aarch64", applicable: true}, + {version: "1.12", architecture: "aarch64", applicable: true, race: true}, + {version: "1.18", architecture: "s390x", applicable: true}, + {version: "1.19", architecture: "s390x", applicable: true, race: true}, + {version: "gccgo-14", architecture: "aarch64"}, + {version: "gccgo-14", architecture: "x64", applicable: true}, + } + + architectureByName := make(map[string]architecture, len(architectures)) + for _, architecture := range architectures { + architectureByName[architecture.name] = architecture + } + for _, test := range tests { + name := test.version + "/" + test.architecture + t.Run(name, func(t *testing.T) { + version, err := parseGoVersion(test.version) + if err != nil { + t.Fatal(err) + } + architecture, ok := architectureByName[test.architecture] + if !ok { + t.Fatalf("unknown architecture %q", test.architecture) + } + if got := architecture.applicable(version); got != test.applicable { + t.Errorf("applicable: got %t, want %t", got, test.applicable) + } + if got := architecture.supportsRace(version); got != test.race { + t.Errorf("race: got %t, want %t", got, test.race) + } + }) + } +} + +func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { + plan, err := newPlan("1.5", 1) + if err != nil { + t.Fatal(err) + } + output := filepath.Join(t.TempDir(), "results.json") + if err := writeResults(output, plan); err != nil { + t.Fatal(err) + } + + checkpointObserved := false + raceBuildObserved := false + raceBinaryObserved := false + normalBinaryObserved := false + execute := func(environment []string, name string, arguments ...string) error { + var goarch, goarm string + for _, assignment := range environment { + if value, ok := strings.CutPrefix(assignment, "GOARCH="); ok { + goarch = value + } + if value, ok := strings.CutPrefix(assignment, "GOARM="); ok { + goarm = value + } + } + normalBuild := name == "go" && reflect.DeepEqual(arguments, []string{"build", "-v", "./..."}) + raceBuild := name == "go" && reflect.DeepEqual(arguments, []string{"build", "-v", "-race", "./..."}) + + if normalBuild && goarch == "arm" && goarm == "6" { + return fmt.Errorf("armv6 build failed") + } + if normalBuild && goarch == "arm" && goarm == "7" { + checkpoint, err := readResults(output) + if err != nil { + t.Fatal(err) + } + if got := checkpoint.Results["armv6"]; got != statusFailure { + t.Fatalf("armv6 checkpoint: got %q, want %q", got, statusFailure) + } + checkpointObserved = true + } + if normalBuild && goarch == "amd64" { + return fmt.Errorf("x64 build failed") + } + if raceBuild && goarch == "amd64" { + raceBuildObserved = true + } + if filepath.Base(name) == "goid.race.test" { + raceBinaryObserved = true + } + if filepath.Base(name) == "goid.test" && filepath.Base(filepath.Dir(name)) == "x64" { + normalBinaryObserved = true + } + return nil + } + + coordinator := coordinator{ + version: goVersion{minor: 5}, + results: &plan, + output: output, + execute: execute, + } + failures := coordinator.run() + if len(failures) != 2 { + t.Fatalf("got %d failures, want 2", len(failures)) + } + for architecture, want := range map[string]resultStatus{ + "armv6": statusFailure, + "armv7": statusSuccess, + "aarch64": statusSuccess, + "s390x": statusNotApplicable, + "386": statusSuccess, + "x64": statusFailure, + } { + if got := plan.Results[architecture]; got != want { + t.Errorf("%s: got %q, want %q", architecture, got, want) + } + } + if !checkpointObserved { + t.Error("armv6 result was not checkpointed before armv7 started") + } + if !raceBuildObserved || !raceBinaryObserved { + t.Error("race build did not complete after the normal x64 build failed") + } + if normalBinaryObserved { + t.Error("normal x64 binary ran after its build failed") + } +} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1403a1a..2182cb6 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -10,43 +10,43 @@ on: schedule: - cron: 00 4 * * * +permissions: + contents: read + jobs: + prepare: + name: prepare test runner + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6.4.0 + with: + go-version: '1.26' + check-latest: true + cache: false + - name: Test and build test runner + shell: bash + run: | + set -euxo pipefail + + go test ./.github/run-tests/... + go build -o run-tests ./.github/run-tests + - uses: actions/upload-artifact@v7 + with: + name: run-tests + path: run-tests + overwrite: true + build: - name: build (${{ matrix.go }}, ${{ matrix.arch }}) + name: build (${{ matrix.go }}) + needs: prepare 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 - go: '1.3' - - arch: x64 - 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.3' + - '1.4' - '1.5' - '1.6' - '1.7' @@ -69,15 +69,30 @@ jobs: - '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' + - gccgo-9 + - gccgo-10 + - gccgo-11 + - gccgo-12 + - gccgo-13 + - gccgo-14 steps: - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: run-tests + - name: Configure test runner + shell: bash + run: chmod +x run-tests + - name: Plan build + shell: bash + run: | + set -euxo pipefail + + ./run-tests plan \ + -go '${{ matrix.go }}' \ + -attempt '${{ github.run_attempt }}' \ + -output 'build-status-${{ matrix.go }}.json' # 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. @@ -113,197 +128,229 @@ jobs: echo ${{ matrix.go }} | sed 's/^gcc//' | xargs -I % ln -s /usr/bin/% /usr/local/bin/go go version - - name: Configure environment - id: configure_environment + - name: Build and run tests 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 - - - name: Check that Get is inlined - if: | - !startsWith(matrix.go, 'gccgo-') && - steps.configure_environment.outputs.BETTER_INLINING_AVAILABLE - shell: bash - 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: | - 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 - 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 ./... - - 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 \ + -go '${{ matrix.go }}' \ + -attempt '${{ github.run_attempt }}' \ + -output 'build-status-${{ matrix.go }}.json' + - name: Upload build status + if: ${{ always() }} + uses: actions/upload-artifact@v7 with: - arch: ${{ matrix.arch }} - distro: bookworm - dockerRunArgs: --mount type=bind,source="$(pwd)",target=/checkout,readonly - setup: go build -o run-tests ./.github/run-tests - shell: /bin/bash - run: /checkout/run-tests /checkout/*.test + name: build-status-${{ matrix.go }} + path: build-status-${{ matrix.go }}.json + if-no-files-found: error + overwrite: true report: name: build status if: ${{ always() }} - needs: build + needs: + - prepare + - build permissions: actions: read contents: write runs-on: ubuntu-latest steps: + - uses: actions/download-artifact@v8 + with: + pattern: build-status-* + path: build-status + merge-multiple: true + - uses: actions/download-artifact@v8 + with: + name: run-tests + - name: Configure test runner + shell: bash + run: chmod +x run-tests - uses: actions/github-script@v9 with: script: | + const {execFileSync} = require('child_process'); + const fs = require('fs'); + const path = require('path'); + + const symbols = { + action_required: '⚠️', + cancelled: '⏹️', + failure: '❌', + in_progress: '⏳', + neutral: '➖', + pending: '⏳', + queued: '⏳', + skipped: '⏭️', + stale: '➖', + startup_failure: '❌', + success: '✅', + timed_out: '⏱️', + waiting: '⏳', + }; + const producerStatuses = new Set([ + 'failure', + 'not_applicable', + 'pending', + 'success', + ]); + + function parseResult(file) { + let result; + try { + result = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`Invalid build status ${file}: ${err.message}`); + } + if (result.schema !== 1) { + throw new Error(`Unsupported build status schema in ${file}`); + } + if (typeof result.go !== 'string' || result.go.length === 0) { + throw new Error(`Missing Go version in ${file}`); + } + if (!Number.isSafeInteger(result.run_attempt) || result.run_attempt <= 0) { + throw new Error(`Invalid run attempt in ${file}`); + } + if (!result.results || typeof result.results !== 'object' || Array.isArray(result.results)) { + throw new Error(`Missing architecture results in ${file}`); + } + const entries = Object.entries(result.results); + if (entries.length === 0) { + throw new Error(`Empty architecture results in ${file}`); + } + for (const [architecture, status] of entries) { + if (architecture.length === 0 || typeof status !== 'string' || !producerStatuses.has(status)) { + throw new Error(`Invalid ${architecture || 'architecture'} result in ${file}`); + } + } + return result; + } + const jobs = await github.paginate( github.rest.actions.listJobsForWorkflowRun, { owner: context.repo.owner, repo: context.repo.repo, run_id: context.runId, - filter: 'latest', + filter: 'all', per_page: 100, }, ); const builds = new Map(); - const architectureSet = new Set(); for (const job of jobs) { - const match = /^build \(([^,]+), ([^)]+)\)$/.exec(job.name); + const match = /^build \(([^)]+)\)$/.exec(job.name); if (!match) { continue; } - const [, version, architecture] = match; - if (!builds.has(version)) { - builds.set(version, new Map()); + const version = match[1]; + if (!Number.isSafeInteger(job.run_attempt) || job.run_attempt <= 0) { + throw new Error(`Invalid run attempt for ${version}`); + } + const previous = builds.get(version); + if (!previous || job.run_attempt > previous.job.run_attempt) { + builds.set(version, {job}); + } else if (job.run_attempt === previous.job.run_attempt) { + throw new Error( + `Duplicate build job for ${version} attempt ${job.run_attempt}`, + ); } - builds.get(version).set(architecture, job); - architectureSet.add(architecture); } if (builds.size === 0) { throw new Error('No matrix build jobs found'); } + const artifacts = new Map(); + const artifactEntries = fs.existsSync('build-status') + ? fs.readdirSync('build-status', {withFileTypes: true}) + : []; + for (const entry of artifactEntries) { + if (!entry.isFile() || path.extname(entry.name) !== '.json') { + continue; + } + const file = path.join('build-status', entry.name); + const result = parseResult(file); + if (artifacts.has(result.go)) { + throw new Error(`Duplicate build status for ${result.go}`); + } + artifacts.set(result.go, result); + } + for (const version of artifacts.keys()) { + if (!builds.has(version)) { + throw new Error(`Build status has no matching job for ${version}`); + } + } + + const architectureSet = new Set(); + for (const [version, build] of builds) { + let result = artifacts.get(version); + if (!result || result.run_attempt !== build.job.run_attempt) { + if (result) { + core.notice( + `Ignoring build status for ${version} from attempt ${result.run_attempt}; ` + + `the latest job is from attempt ${build.job.run_attempt}.`, + ); + } + const fallback = path.join( + process.env.RUNNER_TEMP, + `build-status-${version}-${build.job.run_attempt}.json`, + ); + execFileSync( + './run-tests', + [ + 'plan', + '-go', version, + '-attempt', String(build.job.run_attempt), + '-output', fallback, + ], + {stdio: 'inherit'}, + ); + result = parseResult(fallback); + } + if (result.go !== version) { + throw new Error(`Build status version does not match job for ${version}`); + } + if (result.run_attempt !== build.job.run_attempt) { + throw new Error(`Build status attempt does not match job for ${version}`); + } + + const conclusion = build.job.conclusion || build.job.status; + build.results = new Map(); + const entries = Object.entries(result.results); + const applicable = entries.filter(([, status]) => status !== 'not_applicable'); + if (applicable.length === 0) { + throw new Error(`No applicable architectures for ${version}`); + } + const overrideSuccess = + conclusion !== 'success' && + applicable.every(([, status]) => status === 'success'); + for (const [architecture, reportedStatus] of entries) { + let status = reportedStatus; + if (status === 'pending') { + if (conclusion === 'success') { + throw new Error(`Successful build for ${version} has a pending ${architecture} result`); + } + status = conclusion; + } else if ( + conclusion === 'success' && + status !== 'success' && + status !== 'not_applicable' + ) { + throw new Error(`Successful build for ${version} has ${status} for ${architecture}`); + } else if (overrideSuccess && status === 'success') { + status = conclusion; + } + if (!symbols[status] && status !== 'not_applicable') { + throw new Error(`Cannot render ${status} for ${version}/${architecture}`); + } + build.results.set(architecture, status); + architectureSet.add(architecture); + } + } + const architectures = [...architectureSet].sort(); const versions = [...builds.keys()].sort((a, b) => { const aGccgo = a.startsWith('gccgo-'); @@ -317,20 +364,6 @@ jobs: return bParts[0] - aParts[0] || (bParts[1] || 0) - (aParts[1] || 0); }); - const symbols = { - action_required: '⚠️', - cancelled: '⏹️', - failure: '❌', - in_progress: '⏳', - neutral: '➖', - queued: '⏳', - skipped: '⏭️', - stale: '➖', - startup_failure: '❌', - success: '✅', - timed_out: '⏱️', - waiting: '⏳', - }; const summary = [ '## Build status', '', @@ -345,20 +378,24 @@ jobs: const summaryCells = []; const readmeCells = []; for (const architecture of architectures) { - const job = builds.get(version).get(architecture); - if (!job) { + const build = builds.get(version); + const status = build.results.get(architecture); + if (status === 'not_applicable') { summaryCells.push('—'); readmeCells.push('—'); continue; } + if (!status) { + throw new Error(`Missing ${architecture} result for ${version}`); + } - const conclusion = job.conclusion || job.status; - const symbol = symbols[conclusion] || '❔'; - summaryCells.push(`[${symbol}](${job.html_url} "${conclusion}")`); + const symbol = symbols[status]; + const link = `[${symbol}](${build.job.html_url} "${status}")`; + summaryCells.push(link); readmeCells.push( - conclusion === 'success' + status === 'success' ? symbol - : `[${symbol}](${job.html_url} "${conclusion}")`, + : link, ); } summary.push(`| ${version} | ${summaryCells.join(' | ')} |`); From 507e16273b7e6aadad297674146fe77442dd7d5f Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Thu, 18 Jun 2026 15:11:15 -0400 Subject: [PATCH 03/12] zig --- .github/run-tests/main.go | 89 +++++++++++++++------------------- .github/run-tests/main_test.go | 56 +++++++++++++++++++++ .github/workflows/go.yml | 13 +++++ 3 files changed, 109 insertions(+), 49 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index ad83fe0..3772dae 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -28,10 +28,11 @@ const ( ) type resultFile struct { - Schema int `json:"schema"` - Go string `json:"go"` - RunAttempt int `json:"run_attempt"` - Results map[string]resultStatus `json:"results"` + Schema int `json:"schema"` + Go string `json:"go"` + RunAttempt int `json:"run_attempt"` + CrossCompiler string `json:"cross_compiler,omitempty"` + Results map[string]resultStatus `json:"results"` } type goVersion struct { @@ -40,16 +41,15 @@ type goVersion struct { } type architecture struct { - name string - goarch string - goarm string - minimumMinor int - emulated bool - image string - platform string - raceMinor int - crossCompiler string - crossCompilerPkg string + name string + goarch string + goarm string + minimumMinor int + emulated bool + image string + platform string + raceMinor int + zigTarget string } var architectures = []architecture{ @@ -79,9 +79,8 @@ var architectures = []architecture{ platform: "linux/arm64", // Race detector support on linux/arm64 was added in Go 1.12. // See https://go.dev/doc/go1.12. - raceMinor: 12, - crossCompiler: "aarch64-linux-gnu", - crossCompilerPkg: "gcc-aarch64-linux-gnu", + raceMinor: 12, + zigTarget: "aarch64-linux-gnu", }, { name: "s390x", @@ -94,9 +93,8 @@ var architectures = []architecture{ platform: "linux/s390x", // Race detector support on linux/s390x was added in Go 1.19. // See https://go.dev/doc/go1.19. - raceMinor: 19, - crossCompiler: "s390x-linux-gnu", - crossCompilerPkg: "gcc-s390x-linux-gnu", + raceMinor: 19, + zigTarget: "s390x-linux-gnu", }, { name: "386", @@ -236,6 +234,11 @@ func newPlan(label string, runAttempt int) (resultFile, error) { status = statusPending } results.Results[architecture.name] = status + if status == statusPending && + architecture.zigTarget != "" && + architecture.supportsRace(version) { + results.CrossCompiler = "zig" + } } return results, nil } @@ -350,13 +353,11 @@ func (coordinator coordinator) run() []error { func (coordinator coordinator) prepareRace() map[string][]error { failures := make(map[string][]error) var targets []architecture - var packages []string for _, architecture := range architectures { if architecture.applicable(coordinator.version) && - architecture.crossCompiler != "" && + architecture.zigTarget != "" && architecture.supportsRace(coordinator.version) { targets = append(targets, architecture) - packages = append(packages, architecture.crossCompilerPkg) } } if len(targets) == 0 { @@ -364,28 +365,6 @@ func (coordinator coordinator) prepareRace() map[string][]error { } fmt.Println("::group::cross-race setup") - packagesReady := true - if err := coordinator.execute(nil, "sudo", "apt", "update"); err != nil { - packagesReady = false - for _, architecture := range targets { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("update package index: %s", err), - ) - } - } - if packagesReady { - arguments := append([]string{"apt", "install", "-y"}, packages...) - if err := coordinator.execute(nil, "sudo", arguments...); err != nil { - for _, architecture := range targets { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("install cross compiler: %s", err), - ) - } - } - } - version, err := resolvedGoVersion() if err != nil { for _, architecture := range targets { @@ -481,13 +460,25 @@ func (coordinator coordinator) runArchitecture( } normalBinary := filepath.Join(directory, "goid.test") + testArguments := []string{"test", "-c", "-o", normalBinary, "./..."} + if !coordinator.version.gccgo && coordinator.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 append(failures, fmt.Errorf("get working directory: %s", err)) + } + normalBinary = filepath.Join(checkout, filepath.Base(checkout)+".test") + testArguments = []string{"test", "-c", "./..."} + defer os.Remove(normalBinary) + } normalReady := true if err := coordinator.execute(environment, "go", "build", "-v", "./..."); err != nil { failures = append(failures, err) normalReady = false } if normalReady { - if err := coordinator.execute(environment, "go", "test", "-c", "-o", normalBinary, "./..."); err != nil { + if err := coordinator.execute(environment, "go", testArguments...); err != nil { failures = append(failures, err) normalReady = false } @@ -629,12 +620,12 @@ func targetEnvironment(architecture architecture, race bool) []string { if architecture.goarm != "" { environment = append(environment, "GOARM="+architecture.goarm) } - if race && architecture.crossCompiler != "" { + if race && architecture.zigTarget != "" { + compiler := "zig cc -target " + architecture.zigTarget environment = append( environment, "CGO_ENABLED=1", - "CC="+architecture.crossCompiler+"-gcc", - "CC_FOR_TARGET=gcc-"+architecture.crossCompiler, + "CC="+compiler, ) } return environment diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index a05ca00..8c10ec4 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "path/filepath" "reflect" "strings" @@ -82,6 +83,27 @@ func TestPlanRejectsInvalidRunAttempt(t *testing.T) { } } +func TestPlanCrossCompiler(t *testing.T) { + for _, test := range []struct { + version string + want string + }{ + {version: "1.11"}, + {version: "1.12", want: "zig"}, + {version: "gccgo-14"}, + } { + t.Run(test.version, func(t *testing.T) { + plan, err := newPlan(test.version, 1) + if err != nil { + t.Fatal(err) + } + if plan.CrossCompiler != test.want { + t.Errorf("got cross compiler %q, want %q", plan.CrossCompiler, test.want) + } + }) + } +} + func TestArchitectureBoundaries(t *testing.T) { tests := []struct { version string @@ -128,6 +150,40 @@ func TestArchitectureBoundaries(t *testing.T) { } } +func TestGo13TestBinary(t *testing.T) { + checkout := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(checkout); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Error(err) + } + }) + + var testArguments []string + coordinator := coordinator{ + version: goVersion{minor: 3}, + execute: func(environment []string, name string, arguments ...string) error { + if name == "go" && len(arguments) != 0 && arguments[0] == "test" { + testArguments = append([]string(nil), arguments...) + } + return nil + }, + } + failures := coordinator.runArchitecture(t.TempDir(), architectures[len(architectures)-1], nil, nil) + if len(failures) != 0 { + t.Fatalf("got %d failures, want 0", len(failures)) + } + if want := []string{"test", "-c", "./..."}; !reflect.DeepEqual(testArguments, want) { + t.Errorf("go test arguments: got %q, want %q", testArguments, want) + } +} + func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { plan, err := newPlan("1.5", 1) if err != nil { diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 2182cb6..34ec98e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,6 +24,11 @@ jobs: go-version: '1.26' check-latest: true cache: false + - name: Preload Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.15.2 + use-cache: false - name: Test and build test runner shell: bash run: | @@ -85,6 +90,7 @@ jobs: shell: bash run: chmod +x run-tests - name: Plan build + id: plan shell: bash run: | set -euxo pipefail @@ -93,6 +99,13 @@ jobs: -go '${{ matrix.go }}' \ -attempt '${{ github.run_attempt }}' \ -output 'build-status-${{ matrix.go }}.json' + echo "cross_compiler=$(jq -r '.cross_compiler // empty' 'build-status-${{ matrix.go }}.json')" >> "$GITHUB_OUTPUT" + - name: Set up Zig + if: ${{ steps.plan.outputs.cross_compiler == 'zig' }} + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.15.2 + use-cache: false # 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. From 779f0a9e050d2eaac75d9354b6529eb11b1b040f Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Thu, 18 Jun 2026 22:11:25 -0400 Subject: [PATCH 04/12] binstub --- .github/run-tests/main.go | 67 +++++++++++++++++++++++++++++---- .github/run-tests/main_test.go | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index 3772dae..d9ac8e7 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -16,7 +16,10 @@ import ( "strings" ) -const resultSchema = 1 +const ( + resultSchema = 1 + zigCCPrefix = "zig-cc-" +) type resultStatus string @@ -118,6 +121,16 @@ var ( ) func main() { + if target, ok := strings.CutPrefix(filepath.Base(os.Args[0]), zigCCPrefix); ok { + if target == "" { + fatal(fmt.Errorf("missing Zig target in %q", os.Args[0])) + } + if err := runZigCC(runCommand, target, os.Args[1:]); err != nil { + fatal(err) + } + return + } + if len(os.Args) < 2 { fatal(fmt.Errorf("usage: run-tests -go -attempt -output ")) } @@ -316,7 +329,7 @@ func (coordinator coordinator) run() []error { } defer os.RemoveAll(workDirectory) - raceFailures := coordinator.prepareRace() + raceFailures := coordinator.prepareRace(workDirectory) emulationFailures := coordinator.prepareEmulation() var failures []error @@ -350,7 +363,7 @@ func (coordinator coordinator) run() []error { return failures } -func (coordinator coordinator) prepareRace() map[string][]error { +func (coordinator coordinator) prepareRace(workDirectory string) map[string][]error { failures := make(map[string][]error) var targets []architecture for _, architecture := range architectures { @@ -365,6 +378,27 @@ func (coordinator coordinator) prepareRace() map[string][]error { } fmt.Println("::group::cross-race setup") + // Go 1.17 and below keep only the first word of CC when invoking cgo. + // Give them a single executable path that main dispatches back to Zig. + executable, err := os.Executable() + if err != nil { + for _, architecture := range targets { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("find test runner executable: %s", err), + ) + } + } else { + for _, architecture := range targets { + if err := os.Symlink(executable, zigCCPath(workDirectory, architecture)); err != nil { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("create Zig compiler trampoline: %s", err), + ) + } + } + } + version, err := resolvedGoVersion() if err != nil { for _, architecture := range targets { @@ -448,7 +482,7 @@ func (coordinator coordinator) runArchitecture( if err := os.MkdirAll(directory, 0o755); err != nil { return []error{fmt.Errorf("create output directory: %s", err)} } - environment := targetEnvironment(architecture, false) + environment := targetEnvironment(workDirectory, architecture, false) var failures []error if !coordinator.version.gccgo && coordinator.version.minor >= 12 { @@ -493,7 +527,7 @@ func (coordinator coordinator) runArchitecture( if len(raceFailures) != 0 { failures = append(failures, raceFailures...) } else { - raceEnvironment := targetEnvironment(architecture, true) + raceEnvironment := targetEnvironment(workDirectory, architecture, true) raceBinary := filepath.Join(directory, "goid.race.test") raceReady := true if err := coordinator.execute(raceEnvironment, "go", "build", "-v", "-race", "./..."); err != nil { @@ -600,7 +634,7 @@ func runDocker( return execute(nil, "docker", arguments...) } -func targetEnvironment(architecture architecture, race bool) []string { +func targetEnvironment(workDirectory string, architecture architecture, race bool) []string { removed := map[string]bool{ "CC": true, "CC_FOR_TARGET": true, @@ -621,16 +655,32 @@ func targetEnvironment(architecture architecture, race bool) []string { environment = append(environment, "GOARM="+architecture.goarm) } if race && architecture.zigTarget != "" { - compiler := "zig cc -target " + architecture.zigTarget environment = append( environment, "CGO_ENABLED=1", - "CC="+compiler, + "CC="+zigCCPath(workDirectory, architecture), ) } return environment } +func zigCCPath(workDirectory string, architecture architecture) string { + return filepath.Join(workDirectory, zigCCPrefix+architecture.zigTarget) +} + +func runZigCC(execute commandRunner, target string, compilerArguments []string) error { + arguments := []string{"cc", "-target", target} + for _, argument := range compilerArguments { + // Unlike GCC, Zig rejects object files with the .syso extension. + // Pass them directly to the linker instead. + if filepath.Ext(argument) == ".syso" { + argument = "-Wl," + argument + } + arguments = append(arguments, argument) + } + return execute(nil, "zig", arguments...) +} + func resolvedGoVersion() (string, error) { output, err := commandOutput(nil, "go", "version") if err != nil { @@ -661,6 +711,7 @@ func runCommand(environment []string, name string, arguments ...string) error { if environment != nil { command.Env = environment } + command.Stdin = os.Stdin command.Stdout = os.Stdout command.Stderr = os.Stderr if err := command.Run(); err != nil { diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index 8c10ec4..fe738c1 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "os" "path/filepath" "reflect" @@ -104,6 +105,74 @@ func TestPlanCrossCompiler(t *testing.T) { } } +func TestZigCC(t *testing.T) { + var name string + var arguments []string + err := runZigCC( + func(environment []string, command string, commandArguments ...string) error { + name = command + arguments = append([]string(nil), commandArguments...) + return nil + }, + "aarch64-linux-gnu", + []string{"-o", "output", "race_linux_arm64.syso", "other.o"}, + ) + if err != nil { + t.Fatal(err) + } + if name != "zig" { + t.Errorf("got command %q, want %q", name, "zig") + } + want := []string{ + "cc", + "-target", "aarch64-linux-gnu", + "-o", "output", + "-Wl,race_linux_arm64.syso", + "other.o", + } + if !reflect.DeepEqual(arguments, want) { + t.Errorf("got arguments %q, want %q", arguments, want) + } +} + +func TestRunCommandForwardsStdin(t *testing.T) { + const helperEnvironment = "GOID_TEST_RUN_COMMAND_STDIN" + if os.Getenv(helperEnvironment) == "1" { + input, err := io.ReadAll(os.Stdin) + if err != nil { + t.Fatal(err) + } + if string(input) != "input" { + t.Errorf("got stdin %q, want %q", input, "input") + } + return + } + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = stdin + if err := reader.Close(); err != nil { + t.Error(err) + } + }) + if _, err := io.WriteString(writer, "input"); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + t.Setenv(helperEnvironment, "1") + if err := runCommand(os.Environ(), os.Args[0], "-test.run=^TestRunCommandForwardsStdin$"); err != nil { + t.Fatal(err) + } +} + func TestArchitectureBoundaries(t *testing.T) { tests := []struct { version string From edfb16eae8fae49d3e43e851c2b324c084d36183 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 10:28:23 -0400 Subject: [PATCH 05/12] ci: alias race objects for Zig Zig classifies linker inputs by filename and rejects Go race runtime objects with the .syso extension, even when they are forwarded through the linker argument interface. Create a hard-linked .o alias for each downloaded race runtime and have the compiler trampoline substitute that path. --- .github/run-tests/main.go | 9 +++++++-- .github/run-tests/main_test.go | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index d9ac8e7..f2db7c4 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -435,6 +435,11 @@ func (coordinator coordinator) prepareRace(workDirectory string) map[string][]er failures[architecture.name], fmt.Errorf("download race runtime: %s", err), ) + } else if err := os.Link(path, path+".o"); err != nil { + failures[architecture.name] = append( + failures[architecture.name], + fmt.Errorf("create Zig race object alias: %s", err), + ) } } fmt.Println("::endgroup::") @@ -672,9 +677,9 @@ func runZigCC(execute commandRunner, target string, compilerArguments []string) arguments := []string{"cc", "-target", target} for _, argument := range compilerArguments { // Unlike GCC, Zig rejects object files with the .syso extension. - // Pass them directly to the linker instead. + // prepareRace creates an object alias that Zig recognizes. if filepath.Ext(argument) == ".syso" { - argument = "-Wl," + argument + argument += ".o" } arguments = append(arguments, argument) } diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index fe738c1..96e88be 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -127,7 +127,7 @@ func TestZigCC(t *testing.T) { "cc", "-target", "aarch64-linux-gnu", "-o", "output", - "-Wl,race_linux_arm64.syso", + "race_linux_arm64.syso.o", "other.o", } if !reflect.DeepEqual(arguments, want) { From bfd5bad11f0b0e5bb76e427fb78826fb919eace4 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 10:33:23 -0400 Subject: [PATCH 06/12] ci: use Zig for cross-race linking Zig can compile and partially link the cross-race runtime, but the Go internal linker cannot resolve its libc references. Force external link mode for Zig-backed race builds so Zig performs the final link as well. --- .github/run-tests/main.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index f2db7c4..553da0a 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -534,13 +534,23 @@ func (coordinator coordinator) runArchitecture( } else { raceEnvironment := targetEnvironment(workDirectory, architecture, true) raceBinary := filepath.Join(directory, "goid.race.test") + raceBuildArguments := []string{"build", "-v", "-race"} + raceTestArguments := []string{"test", "-c", "-race"} + if architecture.zigTarget != "" { + // The Go internal linker cannot resolve libc references in + // cross-race objects linked by Zig. Use Zig for the final link. + raceBuildArguments = append(raceBuildArguments, "-ldflags=-linkmode=external") + raceTestArguments = append(raceTestArguments, "-ldflags=-linkmode=external") + } + raceBuildArguments = append(raceBuildArguments, "./...") + raceTestArguments = append(raceTestArguments, "-o", raceBinary, "./...") raceReady := true - if err := coordinator.execute(raceEnvironment, "go", "build", "-v", "-race", "./..."); err != nil { + if err := coordinator.execute(raceEnvironment, "go", raceBuildArguments...); err != nil { failures = append(failures, err) raceReady = false } if raceReady { - if err := coordinator.execute(raceEnvironment, "go", "test", "-c", "-race", "-o", raceBinary, "./..."); err != nil { + if err := coordinator.execute(raceEnvironment, "go", raceTestArguments...); err != nil { failures = append(failures, err) raceReady = false } From 73bfe73fe07bfac843b7d78501ec22c32b0c86bf Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 11:10:22 -0400 Subject: [PATCH 07/12] ci: run architectures concurrently Coalescing architecture jobs lets each Go version reuse toolchain setup, but serial execution gives back most of the wall-clock savings. Run the architectures concurrently after shared race and emulation setup. Keep result mutation and checkpoint writes in the coordinator so completion order does not affect the report or diagnostics. --- .github/run-tests/main.go | 69 ++++++++++++++++++++-------- .github/run-tests/main_test.go | 84 ++++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 38 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index 553da0a..d891ee6 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -331,35 +331,64 @@ func (coordinator coordinator) run() []error { raceFailures := coordinator.prepareRace(workDirectory) emulationFailures := coordinator.prepareEmulation() - var failures []error + type completedArchitecture struct { + architecture architecture + failures []error + } + completed := make(chan completedArchitecture, len(architectures)) + pending := 0 - for _, architecture := range architectures { - if coordinator.results.Results[architecture.name] == statusNotApplicable { + fmt.Println("::group::architectures") + for _, target := range architectures { + if coordinator.results.Results[target.name] == statusNotApplicable { continue } - fmt.Printf("::group::%s\n", architecture.name) - architectureFailures := coordinator.runArchitecture( - workDirectory, - architecture, - raceFailures[architecture.name], - emulationFailures[architecture.name], - ) - if len(architectureFailures) == 0 { - coordinator.results.Results[architecture.name] = statusSuccess - } else { - coordinator.results.Results[architecture.name] = statusFailure - for _, err := range architectureFailures { - failures = append(failures, fmt.Errorf("%s: %s", architecture.name, err)) + pending++ + go func(target architecture) { + fmt.Printf("--- %s started ---\n", target.name) + failures := coordinator.runArchitecture( + workDirectory, + target, + raceFailures[target.name], + emulationFailures[target.name], + ) + fmt.Printf("--- %s finished ---\n", target.name) + completed <- completedArchitecture{ + architecture: target, + failures: failures, } + }(target) + } + + completedFailures := make(map[string][]error, pending) + var writeFailures []error + checkpointFailed := false + for ; pending > 0; pending-- { + result := <-completed + completedFailures[result.architecture.name] = result.failures + status := statusSuccess + if len(result.failures) != 0 { + status = statusFailure + } + coordinator.results.Results[result.architecture.name] = status + if checkpointFailed { + continue } if err := writeResults(coordinator.output, *coordinator.results); err != nil { - failures = append(failures, err) - fmt.Println("::endgroup::") - break + writeFailures = append(writeFailures, err) + checkpointFailed = true + } + } + fmt.Println("::endgroup::") + + var failures []error + for _, architecture := range architectures { + for _, err := range completedFailures[architecture.name] { + failures = append(failures, fmt.Errorf("%s: %s", architecture.name, err)) } - fmt.Println("::endgroup::") } + failures = append(failures, writeFailures...) return failures } diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index 96e88be..12610fd 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -7,7 +7,9 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" + "time" ) func TestPlanApplicability(t *testing.T) { @@ -263,10 +265,17 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { t.Fatal(err) } - checkpointObserved := false - raceBuildObserved := false - raceBinaryObserved := false - normalBinaryObserved := false + type observations struct { + sync.Mutex + overlapObserved bool + checkpointObserved bool + raceBuildObserved bool + raceBinaryObserved bool + normalBinaryObserved bool + } + var observed observations + armv7Started := make(chan struct{}) + var armv7StartedOnce sync.Once execute := func(environment []string, name string, arguments ...string) error { var goarch, goarm string for _, assignment := range environment { @@ -281,29 +290,55 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { raceBuild := name == "go" && reflect.DeepEqual(arguments, []string{"build", "-v", "-race", "./..."}) if normalBuild && goarch == "arm" && goarm == "6" { + select { + case <-armv7Started: + observed.Lock() + observed.overlapObserved = true + observed.Unlock() + case <-time.After(5 * time.Second): + return fmt.Errorf("armv7 build did not start") + } return fmt.Errorf("armv6 build failed") } if normalBuild && goarch == "arm" && goarm == "7" { - checkpoint, err := readResults(output) - if err != nil { - t.Fatal(err) - } - if got := checkpoint.Results["armv6"]; got != statusFailure { - t.Fatalf("armv6 checkpoint: got %q, want %q", got, statusFailure) + armv7StartedOnce.Do(func() { + close(armv7Started) + }) + deadline := time.Now().Add(5 * time.Second) + for { + checkpoint, err := readResults(output) + if err != nil { + return fmt.Errorf("read checkpoint: %s", err) + } + if checkpoint.Results["armv6"] == statusFailure { + observed.Lock() + observed.checkpointObserved = true + observed.Unlock() + break + } + if time.Now().After(deadline) { + return fmt.Errorf("armv6 result was not checkpointed") + } + time.Sleep(time.Millisecond) } - checkpointObserved = true } if normalBuild && goarch == "amd64" { return fmt.Errorf("x64 build failed") } if raceBuild && goarch == "amd64" { - raceBuildObserved = true + observed.Lock() + observed.raceBuildObserved = true + observed.Unlock() } if filepath.Base(name) == "goid.race.test" { - raceBinaryObserved = true + observed.Lock() + observed.raceBinaryObserved = true + observed.Unlock() } if filepath.Base(name) == "goid.test" && filepath.Base(filepath.Dir(name)) == "x64" { - normalBinaryObserved = true + observed.Lock() + observed.normalBinaryObserved = true + observed.Unlock() } return nil } @@ -318,6 +353,14 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { if len(failures) != 2 { t.Fatalf("got %d failures, want 2", len(failures)) } + for index, want := range []string{ + "armv6: armv6 build failed", + "x64: x64 build failed", + } { + if got := failures[index].Error(); got != want { + t.Errorf("failure %d: got %q, want %q", index, got, want) + } + } for architecture, want := range map[string]resultStatus{ "armv6": statusFailure, "armv7": statusSuccess, @@ -330,13 +373,18 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { t.Errorf("%s: got %q, want %q", architecture, got, want) } } - if !checkpointObserved { - t.Error("armv6 result was not checkpointed before armv7 started") + observed.Lock() + defer observed.Unlock() + if !observed.overlapObserved { + t.Error("armv6 and armv7 builds did not overlap") + } + if !observed.checkpointObserved { + t.Error("armv6 result was not checkpointed while armv7 was running") } - if !raceBuildObserved || !raceBinaryObserved { + if !observed.raceBuildObserved || !observed.raceBinaryObserved { t.Error("race build did not complete after the normal x64 build failed") } - if normalBinaryObserved { + if observed.normalBinaryObserved { t.Error("normal x64 binary ran after its build failed") } } From 51443d4febb27cb3590fbfab271499e34e91713f Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 12:43:05 -0400 Subject: [PATCH 08/12] ci: avoid false inlining annotations The Go compiler emits expected -m diagnostics with file positions. GitHub Actions interprets them as failures, creating ten bogus annotations in every inlining-enabled job. Keep successful diagnostics silent. Replay stdout and stderr through their original streams if the command or inlining assertion fails. --- .github/run-tests/main.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index d891ee6..6f259d1 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -3,9 +3,9 @@ package main import ( "bytes" "encoding/json" + "errors" "flag" "fmt" - "io" "os" "os/exec" "path/filepath" @@ -616,14 +616,29 @@ func (coordinator coordinator) runArchitecture( func checkInlining(environment []string) error { command := exec.Command("go", "build", "-gcflags=-m") command.Env = environment - var output bytes.Buffer - command.Stdout = io.MultiWriter(os.Stdout, &output) - command.Stderr = io.MultiWriter(os.Stderr, &output) + // Optimization diagnostics are expected output. Replay them only if the + // command or assertion fails. + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + replayOutput := func() error { + var failures []error + if _, err := os.Stdout.Write(stdout.Bytes()); err != nil { + failures = append(failures, fmt.Errorf("write go build stdout: %s", err)) + } + if _, err := os.Stderr.Write(stderr.Bytes()); err != nil { + failures = append(failures, fmt.Errorf("write go build stderr: %s", err)) + } + return errors.Join(failures...) + } if err := command.Run(); err != nil { - return fmt.Errorf("go build -gcflags=-m: %s", err) + return errors.Join(fmt.Errorf("go build -gcflags=-m: %s", err), replayOutput()) } - if !inlineGetPattern.Match(output.Bytes()) { - return fmt.Errorf("go build -gcflags=-m did not report that Get can be inlined") + if !inlineGetPattern.Match(stderr.Bytes()) { + return errors.Join( + fmt.Errorf("go build -gcflags=-m did not report that Get can be inlined"), + replayOutput(), + ) } return nil } From 7c442f6e3ec3ad9bd6d2bedc41494c8be922d54c Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 14:27:12 -0400 Subject: [PATCH 09/12] ci: shard Go versions Hosted runner setup dominates this compatibility matrix. Replace the one-job-per-version layout with balanced version pairs that install exact toolchains through golang.org/dl. Keep Go 1.3 and 1.4 on setup-go and install all gccgo packages once. Emit and checkpoint one result file per Go version before acquisition. The report can then preserve successful siblings when a shard fails while still using the job conclusion for pending work. --- .github/run-tests/main.go | 509 +++++++++++++++++++++++++++------ .github/run-tests/main_test.go | 329 +++++++++++++++++++-- .github/workflows/go.yml | 324 +++++++++++++-------- 3 files changed, 938 insertions(+), 224 deletions(-) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index 6f259d1..a08d50a 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -9,16 +9,17 @@ import ( "os" "os/exec" "path/filepath" - "reflect" "regexp" "runtime" "strconv" "strings" + "sync" ) const ( - resultSchema = 1 - zigCCPrefix = "zig-cc-" + goReleasesURL = "https://go.dev/dl/?mode=json&include=all" + resultSchema = 1 + zigCCPrefix = "zig-cc-" ) type resultStatus string @@ -31,11 +32,10 @@ const ( ) type resultFile struct { - Schema int `json:"schema"` - Go string `json:"go"` - RunAttempt int `json:"run_attempt"` - CrossCompiler string `json:"cross_compiler,omitempty"` - Results map[string]resultStatus `json:"results"` + Schema int `json:"schema"` + Go string `json:"go"` + RunAttempt int `json:"run_attempt"` + Results map[string]resultStatus `json:"results"` } type goVersion struct { @@ -43,6 +43,31 @@ type goVersion struct { minor int } +type toolchain struct { + label string + command string +} + +type plannedToolchain struct { + toolchain + version goVersion + results resultFile + output string +} + +type goRelease struct { + Version string `json:"version"` + Stable bool `json:"stable"` + Files []goReleaseFile `json:"files"` +} + +type goReleaseFile struct { + Filename string `json:"filename"` + OS string `json:"os"` + Arch string `json:"arch"` + Kind string `json:"kind"` +} + type architecture struct { name string goarch string @@ -116,8 +141,11 @@ var architectures = []architecture{ } var ( - inlineGetPattern = regexp.MustCompile(`(?m)can inline Get$`) - goVersionPattern = regexp.MustCompile(`\bgo(1\.[0-9]+(?:\.[0-9]+)?)\b`) + inlineGetPattern = regexp.MustCompile(`(?m)can inline Get$`) + goLabelPattern = regexp.MustCompile(`^1\.([0-9]+)(?:\.([0-9]+))?$`) + gccgoLabelPattern = regexp.MustCompile(`^gccgo-([0-9]+)$`) + goVersionPattern = regexp.MustCompile(`\bgo(1\.[0-9]+(?:\.[0-9]+)?)\b`) + releaseVersionPattern = regexp.MustCompile(`^go1\.([0-9]+)\.([0-9]+)$`) ) func main() { @@ -132,22 +160,21 @@ func main() { } if len(os.Args) < 2 { - fatal(fmt.Errorf("usage: run-tests -go -attempt -output ")) - } - - flags := flag.NewFlagSet(os.Args[1], flag.ContinueOnError) - goLabel := flags.String("go", "", "matrix Go version label") - output := flags.String("output", "", "result JSON path") - runAttempt := flags.Int("attempt", 0, "workflow run attempt") - if err := flags.Parse(os.Args[2:]); err != nil { - fatal(err) - } - if *goLabel == "" || *output == "" || *runAttempt <= 0 || flags.NArg() != 0 { - fatal(fmt.Errorf("usage: run-tests %s -go -attempt -output ", os.Args[1])) + fatal(fmt.Errorf("usage: run-tests [arguments]")) } switch os.Args[1] { case "plan": + flags := flag.NewFlagSet("plan", flag.ContinueOnError) + goLabel := flags.String("go", "", "matrix Go version label") + output := flags.String("output", "", "result JSON path") + runAttempt := flags.Int("attempt", 0, "workflow run attempt") + if err := flags.Parse(os.Args[2:]); err != nil { + fatal(err) + } + if *goLabel == "" || *output == "" || *runAttempt <= 0 || flags.NArg() != 0 { + fatal(fmt.Errorf("usage: run-tests plan -go -attempt -output ")) + } results, err := newPlan(*goLabel, *runAttempt) if err != nil { fatal(err) @@ -155,39 +182,46 @@ func main() { if err := writeResults(*output, results); err != nil { fatal(err) } - case "run": - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - fatal(fmt.Errorf("run requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) - } - - version, err := parseGoVersion(*goLabel) - if err != nil { + case "download": + flags := flag.NewFlagSet("download", flag.ContinueOnError) + bootstrapGo := flags.String("bootstrap-go", "", "bootstrap Go command") + output := flags.String("output", "", "result JSON directory") + runAttempt := flags.Int("attempt", 0, "workflow run attempt") + if err := flags.Parse(os.Args[2:]); err != nil { fatal(err) } - expected, err := newPlan(*goLabel, *runAttempt) - if err != nil { - fatal(err) + if *bootstrapGo == "" || *output == "" || *runAttempt <= 0 || flags.NArg() == 0 { + fatal(fmt.Errorf("usage: run-tests download -bootstrap-go -attempt -output ...")) } - results, err := readResults(*output) - if err != nil { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + fatal(fmt.Errorf("download requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) + } + if err := runDownload( + *bootstrapGo, + *runAttempt, + *output, + flags.Args(), + fetchGoReleases, + runCommand, + runPlannedToolchain, + ); err != nil { fatal(err) } - if !reflect.DeepEqual(results, expected) { - fatal(fmt.Errorf("%s does not contain the plan for %s", *output, *goLabel)) + case "installed": + flags := flag.NewFlagSet("installed", flag.ContinueOnError) + output := flags.String("output", "", "result JSON directory") + runAttempt := flags.Int("attempt", 0, "workflow run attempt") + if err := flags.Parse(os.Args[2:]); err != nil { + fatal(err) } - - coordinator := coordinator{ - version: version, - results: &results, - output: *output, - execute: runCommand, + if *output == "" || *runAttempt <= 0 || flags.NArg() == 0 { + fatal(fmt.Errorf("usage: run-tests installed -attempt -output ...")) } - failures := coordinator.run() - for _, err := range failures { - fmt.Fprintln(os.Stderr, err) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + fatal(fmt.Errorf("installed requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) } - if len(failures) != 0 { - os.Exit(1) + if err := runInstalled(*runAttempt, *output, flags.Args(), runPlannedToolchain); err != nil { + fatal(err) } default: fatal(fmt.Errorf("unknown command %q", os.Args[1])) @@ -200,32 +234,347 @@ func fatal(err error) { } func parseGoVersion(label string) (goVersion, error) { - if strings.HasPrefix(label, "gccgo-") { - if _, err := strconv.Atoi(strings.TrimPrefix(label, "gccgo-")); err != nil { - return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + if match := gccgoLabelPattern.FindStringSubmatch(label); match != nil { + major, err := strconv.Atoi(match[1]) + if err != nil || major == 0 || strconv.Itoa(major) != match[1] { + return goVersion{}, fmt.Errorf("invalid Go version %q", label) } return goVersion{gccgo: true}, nil } - parts := strings.Split(label, ".") - if len(parts) < 2 || len(parts) > 3 || parts[0] != "1" { + match := goLabelPattern.FindStringSubmatch(label) + if match == nil { return goVersion{}, fmt.Errorf("invalid Go version %q", label) } - minor, err := strconv.Atoi(parts[1]) + minor, err := strconv.Atoi(match[1]) if err != nil { return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) } - if minor < 3 { + if minor < 3 || strconv.Itoa(minor) != match[1] { return goVersion{}, fmt.Errorf("unsupported Go version %q", label) } - if len(parts) == 3 { - if _, err := strconv.Atoi(parts[2]); err != nil { - return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + if match[2] != "" { + patch, err := strconv.Atoi(match[2]) + if err != nil || strconv.Itoa(patch) != match[2] { + return goVersion{}, fmt.Errorf("invalid Go version %q", label) } } return goVersion{minor: minor}, nil } +type releaseFetcher func() ([]goRelease, error) +type toolchainRunner func(*plannedToolchain) []error + +func parseDownloadLabels(labels []string) ([]toolchain, error) { + if len(labels) == 0 { + return nil, fmt.Errorf("no Go versions specified") + } + + seen := make(map[string]bool, len(labels)) + toolchains := make([]toolchain, 0, len(labels)) + for _, label := range labels { + version, err := parseGoVersion(label) + if err != nil { + return nil, err + } + if version.gccgo || label != fmt.Sprintf("1.%d", version.minor) { + return nil, fmt.Errorf("download requires a Go minor label, got %q", label) + } + if version.minor < 5 { + return nil, fmt.Errorf("download does not support Go version %q; use installed", label) + } + if seen[label] { + return nil, fmt.Errorf("duplicate Go version %q", label) + } + seen[label] = true + toolchains = append(toolchains, toolchain{label: label}) + } + return toolchains, nil +} + +func parseInstalledSpecs(specs []string) ([]toolchain, error) { + if len(specs) == 0 { + return nil, fmt.Errorf("no Go toolchains specified") + } + + seen := make(map[string]bool, len(specs)) + toolchains := make([]toolchain, 0, len(specs)) + for _, spec := range specs { + label, command, ok := strings.Cut(spec, "=") + if !ok || command == "" { + return nil, fmt.Errorf("invalid installed toolchain %q; want label=command", spec) + } + if _, err := parseGoVersion(label); err != nil { + return nil, err + } + if seen[label] { + return nil, fmt.Errorf("duplicate Go version %q", label) + } + seen[label] = true + toolchains = append(toolchains, toolchain{label: label, command: command}) + } + return toolchains, nil +} + +func planToolchains(toolchains []toolchain, runAttempt int, outputDirectory string) ([]*plannedToolchain, error) { + plans := make([]*plannedToolchain, 0, len(toolchains)) + var failures []error + for _, toolchain := range toolchains { + version, err := parseGoVersion(toolchain.label) + if err != nil { + failures = append(failures, err) + continue + } + results, err := newPlan(toolchain.label, runAttempt) + if err != nil { + failures = append(failures, err) + continue + } + output := filepath.Join(outputDirectory, "build-status-"+toolchain.label+".json") + if err := writeResults(output, results); err != nil { + failures = append(failures, fmt.Errorf("plan %s: %s", toolchain.label, err)) + continue + } + plans = append(plans, &plannedToolchain{ + toolchain: toolchain, + version: version, + results: results, + output: output, + }) + } + return plans, errors.Join(failures...) +} + +func runToolchains(plans []*plannedToolchain, runner toolchainRunner) error { + var failures []error + for _, plan := range plans { + for _, err := range runner(plan) { + failures = append(failures, fmt.Errorf("%s: %s", plan.label, err)) + } + } + return errors.Join(failures...) +} + +func runPlannedToolchain(plan *plannedToolchain) []error { + coordinator := coordinator{ + version: plan.version, + goCommand: plan.command, + results: &plan.results, + output: plan.output, + execute: runCommand, + } + return coordinator.run() +} + +func runInstalled(runAttempt int, outputDirectory string, specs []string, runner toolchainRunner) error { + toolchains, err := parseInstalledSpecs(specs) + if err != nil { + return err + } + plans, err := planToolchains(toolchains, runAttempt, outputDirectory) + var failures []error + if err != nil { + failures = append(failures, err) + } + if err := runToolchains(plans, runner); err != nil { + failures = append(failures, err) + } + return errors.Join(failures...) +} + +func runDownload( + bootstrapGo string, + runAttempt int, + outputDirectory string, + labels []string, + fetch releaseFetcher, + execute commandRunner, + runner toolchainRunner, +) error { + toolchains, err := parseDownloadLabels(labels) + if err != nil { + return err + } + plans, err := planToolchains(toolchains, runAttempt, outputDirectory) + var failures []error + if err != nil { + failures = append(failures, err) + } + if len(plans) == 0 { + return errors.Join(failures...) + } + + releases, err := fetch() + if err != nil { + failures = append(failures, fmt.Errorf("fetch Go releases: %s", err)) + return errors.Join(failures...) + } + plannedLabels := make([]string, 0, len(plans)) + for _, plan := range plans { + plannedLabels = append(plannedLabels, plan.label) + } + selected, err := selectGoReleases(releases, plannedLabels) + if err != nil { + failures = append(failures, err) + } + + workDirectory, err := os.MkdirTemp("", "goid-download-") + if err != nil { + failures = append(failures, fmt.Errorf("create download directory: %s", err)) + return errors.Join(failures...) + } + defer os.RemoveAll(workDirectory) + binDirectory := filepath.Join(workDirectory, "bin") + if err := os.Mkdir(binDirectory, 0o755); err != nil { + failures = append(failures, fmt.Errorf("create wrapper directory: %s", err)) + return errors.Join(failures...) + } + + var ready []*plannedToolchain + installArguments := []string{"install"} + for _, plan := range plans { + exact, ok := selected[plan.label] + if !ok { + continue + } + plan.command = filepath.Join(binDirectory, exact) + installArguments = append(installArguments, "golang.org/dl/"+exact+"@latest") + ready = append(ready, plan) + } + if len(ready) == 0 { + return errors.Join(failures...) + } + if err := execute(toolchainEnvironment("GOBIN="+binDirectory), bootstrapGo, installArguments...); err != nil { + failures = append(failures, fmt.Errorf("install Go download wrappers: %s", err)) + return errors.Join(failures...) + } + + downloadFailures := make([]error, len(ready)) + var downloads sync.WaitGroup + for index, plan := range ready { + downloads.Add(1) + go func(index int, plan *plannedToolchain) { + defer downloads.Done() + if err := execute(toolchainEnvironment(), plan.command, "download"); err != nil { + downloadFailures[index] = fmt.Errorf("%s: download toolchain: %s", plan.label, err) + } + }(index, plan) + } + downloads.Wait() + + downloaded := ready[:0] + for index, plan := range ready { + if downloadFailures[index] != nil { + failures = append(failures, downloadFailures[index]) + continue + } + downloaded = append(downloaded, plan) + } + if err := runToolchains(downloaded, runner); err != nil { + failures = append(failures, err) + } + return errors.Join(failures...) +} + +func fetchGoReleases() ([]goRelease, error) { + output, err := commandOutput( + nil, + "curl", + "--fail", + "--location", + "--max-time", "30", + "--show-error", + "--silent", + goReleasesURL, + ) + if err != nil { + return nil, err + } + + var releases []goRelease + if err := json.NewDecoder(strings.NewReader(output)).Decode(&releases); err != nil { + return nil, fmt.Errorf("decode %s: %s", goReleasesURL, err) + } + return releases, nil +} + +func selectGoReleases(releases []goRelease, labels []string) (map[string]string, error) { + requested := make(map[string]bool, len(labels)) + for _, label := range labels { + requested[label] = true + } + type selection struct { + version string + patch int + } + selections := make(map[string]selection, len(labels)) + for _, release := range releases { + if !release.Stable { + continue + } + match := releaseVersionPattern.FindStringSubmatch(release.Version) + if match == nil { + continue + } + minor, err := strconv.Atoi(match[1]) + if err != nil || strconv.Itoa(minor) != match[1] { + continue + } + label := fmt.Sprintf("1.%d", minor) + if !requested[label] { + continue + } + patch, err := strconv.Atoi(match[2]) + if err != nil || strconv.Itoa(patch) != match[2] { + continue + } + archive := release.Version + ".linux-amd64.tar.gz" + available := false + for _, file := range release.Files { + if file.Filename == archive && file.OS == "linux" && file.Arch == "amd64" && file.Kind == "archive" { + available = true + break + } + } + if !available { + continue + } + selected, ok := selections[label] + if !ok || patch > selected.patch { + selections[label] = selection{version: release.Version, patch: patch} + } + } + + selected := make(map[string]string, len(selections)) + var failures []error + for _, label := range labels { + selection, ok := selections[label] + if !ok { + failures = append(failures, fmt.Errorf("no stable linux/amd64 release found for Go %s", label)) + continue + } + selected[label] = selection.version + } + return selected, errors.Join(failures...) +} + +func toolchainEnvironment(assignments ...string) []string { + assignments = append([]string{"GOTOOLCHAIN=local"}, assignments...) + overridden := make(map[string]bool, len(assignments)) + for _, assignment := range assignments { + key, _, _ := strings.Cut(assignment, "=") + overridden[key] = true + } + environment := make([]string, 0, len(os.Environ())+len(assignments)) + for _, assignment := range os.Environ() { + key, _, _ := strings.Cut(assignment, "=") + if key != "GOROOT" && !overridden[key] { + environment = append(environment, assignment) + } + } + return append(environment, assignments...) +} + func newPlan(label string, runAttempt int) (resultFile, error) { if runAttempt <= 0 { return resultFile{}, fmt.Errorf("invalid run attempt %d", runAttempt) @@ -247,11 +596,6 @@ func newPlan(label string, runAttempt int) (resultFile, error) { status = statusPending } results.Results[architecture.name] = status - if status == statusPending && - architecture.zigTarget != "" && - architecture.supportsRace(version) { - results.CrossCompiler = "zig" - } } return results, nil } @@ -314,10 +658,11 @@ func writeResults(path string, results resultFile) error { } type coordinator struct { - version goVersion - results *resultFile - output string - execute commandRunner + version goVersion + goCommand string + results *resultFile + output string + execute commandRunner } type commandRunner func([]string, string, ...string) error @@ -428,7 +773,7 @@ func (coordinator coordinator) prepareRace(workDirectory string) map[string][]er } } - version, err := resolvedGoVersion() + version, err := resolvedGoVersion(coordinator.goCommand) if err != nil { for _, architecture := range targets { failures[architecture.name] = append(failures[architecture.name], err) @@ -436,7 +781,7 @@ func (coordinator coordinator) prepareRace(workDirectory string) map[string][]er fmt.Println("::endgroup::") return failures } - goroot, err := commandOutput(nil, "go", "env", "GOROOT") + goroot, err := commandOutput(toolchainEnvironment(), coordinator.goCommand, "env", "GOROOT") if err != nil { for _, architecture := range targets { failures[architecture.name] = append(failures[architecture.name], err) @@ -522,7 +867,7 @@ func (coordinator coordinator) runArchitecture( if !coordinator.version.gccgo && coordinator.version.minor >= 12 { // Go 1.12 introduced the inliner that should inline Get. See // https://go.dev/doc/go1.12#compiler. - if err := checkInlining(environment); err != nil { + if err := checkInlining(coordinator.goCommand, environment); err != nil { failures = append(failures, err) } } @@ -541,12 +886,12 @@ func (coordinator coordinator) runArchitecture( defer os.Remove(normalBinary) } normalReady := true - if err := coordinator.execute(environment, "go", "build", "-v", "./..."); err != nil { + if err := coordinator.execute(environment, coordinator.goCommand, "build", "-v", "./..."); err != nil { failures = append(failures, err) normalReady = false } if normalReady { - if err := coordinator.execute(environment, "go", testArguments...); err != nil { + if err := coordinator.execute(environment, coordinator.goCommand, testArguments...); err != nil { failures = append(failures, err) normalReady = false } @@ -574,12 +919,12 @@ func (coordinator coordinator) runArchitecture( raceBuildArguments = append(raceBuildArguments, "./...") raceTestArguments = append(raceTestArguments, "-o", raceBinary, "./...") raceReady := true - if err := coordinator.execute(raceEnvironment, "go", raceBuildArguments...); err != nil { + if err := coordinator.execute(raceEnvironment, coordinator.goCommand, raceBuildArguments...); err != nil { failures = append(failures, err) raceReady = false } if raceReady { - if err := coordinator.execute(raceEnvironment, "go", raceTestArguments...); err != nil { + if err := coordinator.execute(raceEnvironment, coordinator.goCommand, raceTestArguments...); err != nil { failures = append(failures, err) raceReady = false } @@ -604,7 +949,7 @@ func (coordinator coordinator) runArchitecture( } executor := filepath.Join(directory, "execute") - if err := coordinator.execute(environment, "go", "build", "-o", executor, "./.github/run-tests/execute"); err != nil { + if err := coordinator.execute(environment, coordinator.goCommand, "build", "-o", executor, "./.github/run-tests/execute"); err != nil { return append(failures, err) } if err := runDocker(coordinator.execute, workDirectory, architecture, binaries); err != nil { @@ -613,8 +958,8 @@ func (coordinator coordinator) runArchitecture( return failures } -func checkInlining(environment []string) error { - command := exec.Command("go", "build", "-gcflags=-m") +func checkInlining(goCommand string, environment []string) error { + command := exec.Command(goCommand, "build", "-gcflags=-m") command.Env = environment // Optimization diagnostics are expected output. Replay them only if the // command or assertion fails. @@ -632,11 +977,11 @@ func checkInlining(environment []string) error { return errors.Join(failures...) } if err := command.Run(); err != nil { - return errors.Join(fmt.Errorf("go build -gcflags=-m: %s", err), replayOutput()) + return errors.Join(fmt.Errorf("%s build -gcflags=-m: %s", goCommand, err), replayOutput()) } if !inlineGetPattern.Match(stderr.Bytes()) { return errors.Join( - fmt.Errorf("go build -gcflags=-m did not report that Get can be inlined"), + fmt.Errorf("%s build -gcflags=-m did not report that Get can be inlined", goCommand), replayOutput(), ) } @@ -700,7 +1045,9 @@ func targetEnvironment(workDirectory string, architecture architecture, race boo "CGO_ENABLED": true, "GOARCH": true, "GOARM": true, + "GOROOT": true, "GOOS": true, + "GOTOOLCHAIN": true, } environment := make([]string, 0, len(os.Environ())+6) for _, assignment := range os.Environ() { @@ -709,7 +1056,7 @@ func targetEnvironment(workDirectory string, architecture architecture, race boo environment = append(environment, assignment) } } - environment = append(environment, "GOOS=linux", "GOARCH="+architecture.goarch) + environment = append(environment, "GOTOOLCHAIN=local", "GOOS=linux", "GOARCH="+architecture.goarch) if architecture.goarm != "" { environment = append(environment, "GOARM="+architecture.goarm) } @@ -740,8 +1087,8 @@ func runZigCC(execute commandRunner, target string, compilerArguments []string) return execute(nil, "zig", arguments...) } -func resolvedGoVersion() (string, error) { - output, err := commandOutput(nil, "go", "version") +func resolvedGoVersion(goCommand string) (string, error) { + output, err := commandOutput(toolchainEnvironment(), goCommand, "version") if err != nil { return "", err } diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index 12610fd..ce15f87 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -66,6 +66,11 @@ func TestPlanApplicability(t *testing.T) { t.Errorf("%s: got %q, want %q", architecture.name, got, want) } } + for architecture := range test.applicable { + if _, ok := plan.Results[architecture]; !ok { + t.Errorf("missing %s result", architecture) + } + } }) } } @@ -86,24 +91,288 @@ func TestPlanRejectsInvalidRunAttempt(t *testing.T) { } } -func TestPlanCrossCompiler(t *testing.T) { - for _, test := range []struct { - version string - want string - }{ - {version: "1.11"}, - {version: "1.12", want: "zig"}, - {version: "gccgo-14"}, +func TestSelectGoReleases(t *testing.T) { + archive := func(version string) goReleaseFile { + return goReleaseFile{ + Filename: version + ".linux-amd64.tar.gz", + OS: "linux", + Arch: "amd64", + Kind: "archive", + } + } + releases := []goRelease{ + {Version: "go1.25.10", Stable: true, Files: []goReleaseFile{archive("go1.25.10")}}, + {Version: "go1.25.9", Stable: true, Files: []goReleaseFile{archive("go1.25.9")}}, + {Version: "go1.25.11", Stable: false, Files: []goReleaseFile{archive("go1.25.11")}}, + {Version: "go1.26.4", Stable: true, Files: []goReleaseFile{{ + Filename: "go1.26.4.darwin-amd64.tar.gz", + OS: "darwin", + Arch: "amd64", + Kind: "archive", + }}}, + {Version: "go1.26.3", Stable: true, Files: []goReleaseFile{archive("go1.26.3")}}, + {Version: "go1.24", Stable: true, Files: []goReleaseFile{archive("go1.24")}}, + } + + selected, err := selectGoReleases(releases, []string{"1.25", "1.26", "1.24"}) + if err == nil { + t.Fatal("expected a missing Go 1.24 error") + } + if !strings.Contains(err.Error(), "Go 1.24") { + t.Fatalf("got error %s, want missing Go 1.24", err) + } + want := map[string]string{"1.25": "go1.25.10", "1.26": "go1.26.3"} + if !reflect.DeepEqual(selected, want) { + t.Errorf("got selections %q, want %q", selected, want) + } +} + +func TestParseInstalledSpecs(t *testing.T) { + got, err := parseInstalledSpecs([]string{ + "1.3=/opt/go 1.3/bin/go", + "gccgo-14=/usr/bin/go-14=debug", + }) + if err != nil { + t.Fatal(err) + } + want := []toolchain{ + {label: "1.3", command: "/opt/go 1.3/bin/go"}, + {label: "gccgo-14", command: "/usr/bin/go-14=debug"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("got toolchains %q, want %q", got, want) + } + + for _, specs := range [][]string{ + {"1.25"}, + {"1.25="}, + {"../1.25=go"}, + {"1.25=go", "1.25=other-go"}, + {"gccgo-0=go"}, } { - t.Run(test.version, func(t *testing.T) { - plan, err := newPlan(test.version, 1) + if _, err := parseInstalledSpecs(specs); err == nil { + t.Errorf("specs %q unexpectedly succeeded", specs) + } + } +} + +func TestRunInstalledPreplansAndContinues(t *testing.T) { + output := t.TempDir() + var ran []string + err := runInstalled( + 3, + output, + []string{"1.3=/sdk/go1.3", "1.4=/sdk/go1.4"}, + func(plan *plannedToolchain) []error { + for _, label := range []string{"1.3", "1.4"} { + results, err := readResults(filepath.Join(output, "build-status-"+label+".json")) + if err != nil { + t.Error(err) + continue + } + if results.RunAttempt != 3 || results.Results["x64"] != statusPending { + t.Errorf("%s was not preplanned: %#v", label, results) + } + } + ran = append(ran, plan.label) + if plan.label == "1.3" { + return []error{fmt.Errorf("broken toolchain")} + } + return nil + }, + ) + if err == nil { + t.Fatal("expected a Go 1.3 failure") + } + if !strings.Contains(err.Error(), "1.3: broken toolchain") { + t.Fatalf("got error %s, want Go 1.3 failure", err) + } + if want := []string{"1.3", "1.4"}; !reflect.DeepEqual(ran, want) { + t.Errorf("ran %q, want %q", ran, want) + } +} + +func TestRunInstalledContinuesAfterPlanFailure(t *testing.T) { + output := t.TempDir() + if err := os.Mkdir(filepath.Join(output, "build-status-1.3.json"), 0o755); err != nil { + t.Fatal(err) + } + var ran []string + err := runInstalled( + 1, + output, + []string{"1.3=/sdk/go1.3", "1.4=/sdk/go1.4"}, + func(plan *plannedToolchain) []error { + ran = append(ran, plan.label) + return nil + }, + ) + if err == nil { + t.Fatal("expected a Go 1.3 plan failure") + } + if !strings.Contains(err.Error(), "plan 1.3") { + t.Fatalf("got error %s, want Go 1.3 plan failure", err) + } + if want := []string{"1.4"}; !reflect.DeepEqual(ran, want) { + t.Errorf("ran %q, want %q", ran, want) + } +} + +func TestRunDownloadPreplansAndContinues(t *testing.T) { + t.Setenv("GOROOT", "/ambient/go") + t.Setenv("GOTOOLCHAIN", "auto") + output := t.TempDir() + archive := func(version string) goReleaseFile { + return goReleaseFile{ + Filename: version + ".linux-amd64.tar.gz", + OS: "linux", + Arch: "amd64", + Kind: "archive", + } + } + fetch := func() ([]goRelease, error) { + for _, label := range []string{"1.5", "1.6"} { + results, err := readResults(filepath.Join(output, "build-status-"+label+".json")) if err != nil { t.Fatal(err) } - if plan.CrossCompiler != test.want { - t.Errorf("got cross compiler %q, want %q", plan.CrossCompiler, test.want) + if results.Results["x64"] != statusPending { + t.Fatalf("%s was not preplanned: %#v", label, results) } - }) + } + return []goRelease{ + {Version: "go1.5.4", Stable: true, Files: []goReleaseFile{archive("go1.5.4")}}, + {Version: "go1.6.4", Stable: true, Files: []goReleaseFile{archive("go1.6.4")}}, + }, nil + } + + var observed struct { + sync.Mutex + bootstrapEnvironment []string + installArguments []string + downloads []string + ran []toolchain + } + downloadsStarted := make(chan struct{}, 2) + downloadsReady := make(chan struct{}) + go func() { + <-downloadsStarted + <-downloadsStarted + close(downloadsReady) + }() + execute := func(environment []string, name string, arguments ...string) error { + observed.Lock() + if name == "/bootstrap/go" { + observed.bootstrapEnvironment = append([]string(nil), environment...) + observed.installArguments = append([]string(nil), arguments...) + observed.Unlock() + return nil + } + exact := filepath.Base(name) + observed.downloads = append(observed.downloads, exact) + observed.Unlock() + downloadsStarted <- struct{}{} + select { + case <-downloadsReady: + case <-time.After(5 * time.Second): + return fmt.Errorf("toolchain downloads did not overlap") + } + if exact == "go1.5.4" { + return fmt.Errorf("download failed") + } + return nil + } + err := runDownload( + "/bootstrap/go", + 4, + output, + []string{"1.5", "1.6"}, + fetch, + execute, + func(plan *plannedToolchain) []error { + observed.Lock() + observed.ran = append(observed.ran, plan.toolchain) + observed.Unlock() + return nil + }, + ) + if err == nil { + t.Fatal("expected a Go 1.5 download failure") + } + if !strings.Contains(err.Error(), "1.5: download toolchain: download failed") { + t.Fatalf("got error %s, want Go 1.5 download failure", err) + } + + observed.Lock() + defer observed.Unlock() + wantInstall := []string{ + "install", + "golang.org/dl/go1.5.4@latest", + "golang.org/dl/go1.6.4@latest", + } + if !reflect.DeepEqual(observed.installArguments, wantInstall) { + t.Errorf("got install arguments %q, want %q", observed.installArguments, wantInstall) + } + values := make(map[string]string) + for _, assignment := range observed.bootstrapEnvironment { + key, value, _ := strings.Cut(assignment, "=") + values[key] = value + } + if values["GOTOOLCHAIN"] != "local" || values["GOBIN"] == "" { + t.Errorf("bootstrap environment does not select local toolchains and a private GOBIN: %q", values) + } + if _, ok := values["GOROOT"]; ok { + t.Errorf("bootstrap environment retains GOROOT: %q", values["GOROOT"]) + } + if len(observed.downloads) != 2 { + t.Errorf("got downloads %q, want both toolchains", observed.downloads) + } + if len(observed.ran) != 1 || observed.ran[0].label != "1.6" || filepath.Base(observed.ran[0].command) != "go1.6.4" { + t.Errorf("ran toolchains %q, want only Go 1.6.4", observed.ran) + } +} + +func TestGoHelpersUseExplicitCommand(t *testing.T) { + t.Setenv("GOROOT", "/ambient/go") + t.Setenv("GOTOOLCHAIN", "auto") + command := filepath.Join(t.TempDir(), "explicit go") + script := `#!/bin/sh +test "$GOTOOLCHAIN" = local || exit 10 +test -z "$GOROOT" || exit 11 +case "$1" in +version) + echo 'go version go1.26.4 linux/amd64' + ;; +env) + echo '/explicit/root' + ;; +build) + echo 'goid.go:20:6: can inline Get' >&2 + ;; +*) + exit 12 + ;; +esac +` + if err := os.WriteFile(command, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + version, err := resolvedGoVersion(command) + if err != nil { + t.Fatal(err) + } + if version != "1.26.4" { + t.Errorf("got Go version %q, want 1.26.4", version) + } + root, err := commandOutput(toolchainEnvironment(), command, "env", "GOROOT") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(root) != "/explicit/root" { + t.Errorf("got GOROOT %q, want /explicit/root", strings.TrimSpace(root)) + } + if err := checkInlining(command, toolchainEnvironment()); err != nil { + t.Fatal(err) } } @@ -237,12 +506,29 @@ func TestGo13TestBinary(t *testing.T) { }) var testArguments []string + const goCommand = "/sdk/go1.3/bin/go" + t.Setenv("GOROOT", "/ambient/go") + t.Setenv("GOTOOLCHAIN", "auto") coordinator := coordinator{ - version: goVersion{minor: 3}, + version: goVersion{minor: 3}, + goCommand: goCommand, execute: func(environment []string, name string, arguments ...string) error { - if name == "go" && len(arguments) != 0 && arguments[0] == "test" { + if name == goCommand && len(arguments) != 0 && arguments[0] == "test" { testArguments = append([]string(nil), arguments...) } + if name == goCommand { + values := make(map[string]string) + for _, assignment := range environment { + key, value, _ := strings.Cut(assignment, "=") + values[key] = value + } + if values["GOTOOLCHAIN"] != "local" { + t.Errorf("got GOTOOLCHAIN=%q, want local", values["GOTOOLCHAIN"]) + } + if _, ok := values["GOROOT"]; ok { + t.Errorf("explicit Go command retained GOROOT=%q", values["GOROOT"]) + } + } return nil }, } @@ -286,8 +572,8 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { goarm = value } } - normalBuild := name == "go" && reflect.DeepEqual(arguments, []string{"build", "-v", "./..."}) - raceBuild := name == "go" && reflect.DeepEqual(arguments, []string{"build", "-v", "-race", "./..."}) + normalBuild := name == "test-go" && reflect.DeepEqual(arguments, []string{"build", "-v", "./..."}) + raceBuild := name == "test-go" && reflect.DeepEqual(arguments, []string{"build", "-v", "-race", "./..."}) if normalBuild && goarch == "arm" && goarm == "6" { select { @@ -344,10 +630,11 @@ func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { } coordinator := coordinator{ - version: goVersion{minor: 5}, - results: &plan, - output: output, - execute: execute, + version: goVersion{minor: 5}, + goCommand: "test-go", + results: &plan, + output: output, + execute: execute, } failures := coordinator.run() if len(failures) != 2 { diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 34ec98e..091e5a4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,148 +14,162 @@ permissions: contents: read jobs: - prepare: - name: prepare test runner + test_runner: + name: test runner runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v6.4.0 - with: - go-version: '1.26' - check-latest: true - cache: false - - name: Preload Zig + - name: Test runner + shell: bash + run: | + set -euxo pipefail + + go test ./.github/run-tests/... + + build_downloaded: + name: build (${{ matrix.go }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - id: go-01 + go: '1.26 1.5' + - id: go-02 + go: '1.25 1.6' + - id: go-03 + go: '1.24 1.7' + - id: go-04 + go: '1.23 1.8' + - id: go-05 + go: '1.22 1.9' + - id: go-06 + go: '1.21 1.10' + - id: go-07 + go: '1.20 1.11' + - id: go-08 + go: '1.19 1.12' + - id: go-09 + go: '1.18 1.13' + - id: go-10 + go: '1.17 1.14' + - id: go-11 + go: '1.16 1.15' + + steps: + - uses: actions/checkout@v7 + - name: Build test runner + shell: bash + run: go build -o run-tests ./.github/run-tests + - name: Set up Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.15.2 use-cache: false - - name: Test and build test runner + - name: Download and run tests + timeout-minutes: 10 shell: bash run: | set -euxo pipefail - go test ./.github/run-tests/... - go build -o run-tests ./.github/run-tests - - uses: actions/upload-artifact@v7 + ./run-tests download \ + -bootstrap-go go \ + -attempt '${{ github.run_attempt }}' \ + -output build-status \ + ${{ matrix.go }} + - name: Upload build status + if: ${{ always() }} + uses: actions/upload-artifact@v7 with: - name: run-tests - path: run-tests + name: build-status-${{ matrix.id }} + path: build-status/*.json + if-no-files-found: error overwrite: true - build: + build_setup_go: name: build (${{ matrix.go }}) - needs: prepare runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: go: - '1.3' - '1.4' - - '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' - - gccgo-9 - - gccgo-10 - - gccgo-11 - - gccgo-12 - - gccgo-13 - - gccgo-14 steps: - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - name: run-tests - - name: Configure test runner + - name: Build test runner shell: bash - run: chmod +x run-tests - - name: Plan build - id: plan + run: go build -o run-tests ./.github/run-tests + # 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 + uses: actions/setup-go@v6.4.0 + with: + go-version: ${{ matrix.go }} + check-latest: true + cache: false + - name: Run tests + timeout-minutes: 5 shell: bash run: | set -euxo pipefail - ./run-tests plan \ - -go '${{ matrix.go }}' \ + ./run-tests installed \ -attempt '${{ github.run_attempt }}' \ - -output 'build-status-${{ matrix.go }}.json' - echo "cross_compiler=$(jq -r '.cross_compiler // empty' 'build-status-${{ matrix.go }}.json')" >> "$GITHUB_OUTPUT" - - name: Set up Zig - if: ${{ steps.plan.outputs.cross_compiler == 'zig' }} - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + -output build-status \ + '${{ matrix.go }}=go' + - name: Upload build status + if: ${{ always() }} + uses: actions/upload-artifact@v7 with: - version: 0.15.2 - use-cache: false - # 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-') }} + name: build-status-go-${{ matrix.go }} + path: build-status/*.json + if-no-files-found: error + overwrite: true + + build_gccgo: + name: build (gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - name: Build test runner 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" - - name: Set up Go - id: setup-go - if: ${{ !startsWith(matrix.go, 'gccgo-') }} - uses: actions/setup-go@v6.4.0 - with: - go-version: ${{ matrix.go }} - check-latest: true - cache: ${{ steps.configure_setup_go.outputs.cache }} + run: go build -o run-tests ./.github/run-tests - name: Set up gccgo - if: ${{ startsWith(matrix.go, 'gccgo-') }} + timeout-minutes: 5 shell: bash 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: Build and run tests + sudo apt install -y gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 + - name: Run tests + timeout-minutes: 5 shell: bash run: | set -euxo pipefail - ./run-tests run \ - -go '${{ matrix.go }}' \ + ./run-tests installed \ -attempt '${{ github.run_attempt }}' \ - -output 'build-status-${{ matrix.go }}.json' + -output build-status \ + 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 - name: Upload build status if: ${{ always() }} uses: actions/upload-artifact@v7 with: - name: build-status-${{ matrix.go }} - path: build-status-${{ matrix.go }}.json + name: build-status-gccgo + path: build-status/*.json if-no-files-found: error overwrite: true @@ -163,25 +177,28 @@ jobs: name: build status if: ${{ always() }} needs: - - prepare - - build + - test_runner + - build_downloaded + - build_setup_go + - build_gccgo permissions: actions: read contents: write runs-on: ubuntu-latest + timeout-minutes: 10 steps: + - uses: actions/checkout@v7 + - name: Build test runner + shell: bash + run: go build -o run-tests ./.github/run-tests - uses: actions/download-artifact@v8 with: pattern: build-status-* path: build-status merge-multiple: true - - uses: actions/download-artifact@v8 - with: - name: run-tests - - name: Configure test runner - shell: bash - run: chmod +x run-tests - uses: actions/github-script@v9 + env: + TEST_RUNNER_RESULT: ${{ needs.test_runner.result }} with: script: | const {execFileSync} = require('child_process'); @@ -252,28 +269,67 @@ jobs: }, ); - const builds = new Map(); + const latestJobs = new Map(); for (const job of jobs) { const match = /^build \(([^)]+)\)$/.exec(job.name); if (!match) { continue; } - const version = match[1]; + const versions = match[1].trim().split(/\s+/); + if ( + versions.some(version => version.length === 0) || + new Set(versions).size !== versions.length + ) { + throw new Error(`Invalid version list in ${job.name}`); + } if (!Number.isSafeInteger(job.run_attempt) || job.run_attempt <= 0) { - throw new Error(`Invalid run attempt for ${version}`); + throw new Error(`Invalid run attempt for ${job.name}`); } - const previous = builds.get(version); + + const previous = latestJobs.get(job.name); if (!previous || job.run_attempt > previous.job.run_attempt) { - builds.set(version, {job}); + latestJobs.set(job.name, {job, versions}); } else if (job.run_attempt === previous.job.run_attempt) { - throw new Error( - `Duplicate build job for ${version} attempt ${job.run_attempt}`, - ); + throw new Error(`Duplicate ${job.name} attempt ${job.run_attempt}`); } } - if (builds.size === 0) { - throw new Error('No matrix build jobs found'); + + const builds = new Map(); + for (const shard of latestJobs.values()) { + const {job, versions} = shard; + if (job.conclusion === 'skipped') { + throw new Error(`Selected build job was skipped: ${job.name}`); + } + for (const version of versions) { + const previous = builds.get(version); + if (previous) { + throw new Error( + `Duplicate build jobs for ${version}: ${previous.job.name} and ${job.name}`, + ); + } + builds.set(version, {job, shard}); + } + } + + const expectedVersions = new Set(); + for (let minor = 3; minor <= 26; minor++) { + expectedVersions.add(`1.${minor}`); + } + for (let version = 9; version <= 14; version++) { + expectedVersions.add(`gccgo-${version}`); + } + const unexpectedVersions = [...builds.keys()] + .filter(version => !expectedVersions.has(version)) + .sort(); + const missingVersions = [...expectedVersions] + .filter(version => !builds.has(version)); + if (unexpectedVersions.length > 0 || missingVersions.length > 0) { + throw new Error( + 'Build job labels do not match expected versions. ' + + `Unexpected: ${unexpectedVersions.join(', ') || 'none'}. ` + + `Missing: ${missingVersions.join(', ') || 'none'}.`, + ); } const artifacts = new Map(); @@ -297,7 +353,6 @@ jobs: } } - const architectureSet = new Set(); for (const [version, build] of builds) { let result = artifacts.get(version); if (!result || result.run_attempt !== build.job.run_attempt) { @@ -330,17 +385,37 @@ jobs: throw new Error(`Build status attempt does not match job for ${version}`); } - const conclusion = build.job.conclusion || build.job.status; - build.results = new Map(); - const entries = Object.entries(result.results); - const applicable = entries.filter(([, status]) => status !== 'not_applicable'); - if (applicable.length === 0) { + build.reportedResults = Object.entries(result.results); + build.applicableResults = build.reportedResults.filter( + ([, status]) => status !== 'not_applicable', + ); + if (build.applicableResults.length === 0) { throw new Error(`No applicable architectures for ${version}`); } - const overrideSuccess = + } + + const shards = new Set([...builds.values()].map(build => build.shard)); + for (const shard of shards) { + for (const version of shard.versions) { + if (builds.get(version)?.shard !== shard) { + throw new Error(`Latest build jobs disagree for ${shard.job.name}`); + } + } + const conclusion = shard.job.conclusion || shard.job.status; + shard.overrideSuccess = conclusion !== 'success' && - applicable.every(([, status]) => status === 'success'); - for (const [architecture, reportedStatus] of entries) { + shard.versions.every(version => + builds.get(version).applicableResults.every( + ([, status]) => status === 'success', + ), + ); + } + + const architectureSet = new Set(); + for (const [version, build] of builds) { + const conclusion = build.job.conclusion || build.job.status; + build.results = new Map(); + for (const [architecture, reportedStatus] of build.reportedResults) { let status = reportedStatus; if (status === 'pending') { if (conclusion === 'success') { @@ -353,7 +428,7 @@ jobs: status !== 'not_applicable' ) { throw new Error(`Successful build for ${version} has ${status} for ${architecture}`); - } else if (overrideSuccess && status === 'success') { + } else if (build.shard.overrideSuccess && status === 'success') { status = conclusion; } if (!symbols[status] && status !== 'not_applicable') { @@ -420,6 +495,11 @@ jobs: readme.push('', legend); await core.summary.addRaw(summary.join('\n')).write(); + if (process.env.TEST_RUNNER_RESULT !== 'success') { + core.setFailed(`Test runner job concluded ${process.env.TEST_RUNNER_RESULT}.`); + return; + } + const branch = context.payload.repository.default_branch; if (context.eventName === 'pull_request' || context.ref !== `refs/heads/${branch}`) { return; From 5e4cbc510ae9754eb7294462b910ba0b9e8b3842 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Fri, 19 Jun 2026 15:34:19 -0400 Subject: [PATCH 10/12] ci: simplify version sharding Replace the runner framework with two small source files that own toolchain orchestration and per-architecture execution. Download paired SDKs concurrently, then run versions sequentially and architectures in parallel. Checkpoint one result file per job so the report can preserve completed cells and reconstruct missing plans. Keep setup-go's GOROOT for Go 1.3 and 1.4, and use Zig rather than cross-GCC packages for race builds. --- .github/run-tests/architecture.go | 253 ++++ .../execute/expected_failures_arm64.go | 13 - .../execute/expected_failures_other.go | 8 - .../execute/expected_failures_s390x.go | 72 - .github/run-tests/execute/main.go | 81 -- .github/run-tests/main.go | 1257 ++++------------- .github/run-tests/main_test.go | 677 +-------- .github/workflows/go.yml | 519 +++---- 8 files changed, 702 insertions(+), 2178 deletions(-) create mode 100644 .github/run-tests/architecture.go delete mode 100644 .github/run-tests/execute/expected_failures_arm64.go delete mode 100644 .github/run-tests/execute/expected_failures_other.go delete mode 100644 .github/run-tests/execute/expected_failures_s390x.go delete mode 100644 .github/run-tests/execute/main.go diff --git a/.github/run-tests/architecture.go b/.github/run-tests/architecture.go new file mode 100644 index 0000000..a4c3bfa --- /dev/null +++ b/.github/run-tests/architecture.go @@ -0,0 +1,253 @@ +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$`) + arm64Crash = regexp.MustCompile(`^FATAL: ThreadSanitizer: unsupported VMA range\nFATAL: Found 47 - Supported 48$`) + 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 { + 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 { + 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 { + 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...) + } + } + 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) { + 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/execute/expected_failures_arm64.go b/.github/run-tests/execute/expected_failures_arm64.go deleted file mode 100644 index ad4a972..0000000 --- a/.github/run-tests/execute/expected_failures_arm64.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import "regexp" - -func expectedFailures() (failureMatchers, error) { - // Race detector binaries crash under QEMU. See - // https://github.com/golang/go/issues/29948. - return failureMatchers{ - "goid.race.test": regexp.MustCompile( - `^FATAL: ThreadSanitizer: unsupported VMA range\nFATAL: Found 47 - Supported 48$`, - ), - }, nil -} diff --git a/.github/run-tests/execute/expected_failures_other.go b/.github/run-tests/execute/expected_failures_other.go deleted file mode 100644 index 2d78cd1..0000000 --- a/.github/run-tests/execute/expected_failures_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !arm64 && !s390x -// +build !arm64,!s390x - -package main - -func expectedFailures() (failureMatchers, error) { - return nil, nil -} diff --git a/.github/run-tests/execute/expected_failures_s390x.go b/.github/run-tests/execute/expected_failures_s390x.go deleted file mode 100644 index c0c31e8..0000000 --- a/.github/run-tests/execute/expected_failures_s390x.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "fmt" - "regexp" - "syscall" - "unsafe" -) - -func expectedFailures() (failureMatchers, error) { - canMap, err := canMapS390xTSANMeta() - if err != nil { - return nil, err - } - if canMap { - return nil, nil - } - - // Race detector binaries crash under QEMU on hosts that cannot map the - // ThreadSanitizer metadata address. See - // https://github.com/golang/go/issues/67881. - return failureMatchers{ - "goid.race.test": regexp.MustCompile( - `^==[0-9]+==ERROR: ThreadSanitizer failed to allocate 0x[0-9a-f]+ \([0-9]+\) bytes at address [0-9a-f]+ \(errno: 12\)$`, - ), - }, nil -} - -func canMapS390xTSANMeta() (bool, error) { - // QEMU linux-user maps fixed guest addresses into the host address space. - // Probe Go's current s390x metadata base to distinguish hosts with 47-bit - // user address spaces. - const ( - metaShadowAddress = 0x900000000000 - mappingSize = 64 << 10 - // MAP_FIXED_NOREPLACE was added after the oldest Go version tested here. - mapFixedNoReplace = 0x100000 - ) - mmapArguments := [6]uintptr{ - metaShadowAddress, - mappingSize, - syscall.PROT_READ | syscall.PROT_WRITE, - syscall.MAP_PRIVATE | syscall.MAP_ANON | syscall.MAP_NORESERVE | mapFixedNoReplace, - ^uintptr(0), - 0, - } - address, _, err := syscall.Syscall( - syscall.SYS_MMAP, - uintptr(unsafe.Pointer(&mmapArguments[0])), - 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, address, mappingSize, 0) - if err != 0 { - return false, fmt.Errorf("unmap ThreadSanitizer metadata probe: %s", err) - } - if address != metaShadowAddress { - return false, fmt.Errorf( - "probe ThreadSanitizer metadata mapping: got address %#x, want %#x", - address, - metaShadowAddress, - ) - } - return true, nil -} diff --git a/.github/run-tests/execute/main.go b/.github/run-tests/execute/main.go deleted file mode 100644 index 77497ec..0000000 --- a/.github/run-tests/execute/main.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" -) - -type failureMatchers map[string]*regexp.Regexp - -func main() { - matchers, err := expectedFailures() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - tests := os.Args[1:] - testArguments := [][]string{ - {"-test.v"}, - {"-test.bench=.", "-test.benchmem", "-test.v"}, - } - - var failures []error - if len(tests) == 0 { - failures = append(failures, fmt.Errorf("no test binaries specified")) - } - for _, test := range tests { - if expectedFailure, ok := matchers[filepath.Base(test)]; ok { - if err := run(test, expectedFailure, "-test.v"); err != nil { - failures = append(failures, err) - } - continue - } - - for _, args := range testArguments { - if err := run(test, nil, args...); err != nil { - failures = append(failures, err) - } - } - } - - for _, err := range failures { - fmt.Fprintln(os.Stderr, err) - } - if len(failures) != 0 { - os.Exit(1) - } -} - -func run(binary string, expectedFailure *regexp.Regexp, args ...string) error { - command := exec.Command(binary, args...) - if expectedFailure == nil { - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - return fmt.Errorf("%s with arguments %q: %s", binary, args, err) - } - return nil - } - - var stderr bytes.Buffer - command.Stdout = os.Stdout - command.Stderr = io.MultiWriter(os.Stderr, &stderr) - err := command.Run() - if err == nil { - return fmt.Errorf("%s unexpectedly succeeded", binary) - } - if _, ok := err.(*exec.ExitError); !ok { - return fmt.Errorf("%s: %s", binary, err) - } - if !expectedFailure.MatchString(strings.TrimRight(stderr.String(), "\n")) { - return fmt.Errorf("%s failed differently than expected", binary) - } - return nil -} diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index a08d50a..8aedf97 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -1,13 +1,11 @@ package main import ( - "bytes" "encoding/json" "errors" "flag" "fmt" "os" - "os/exec" "path/filepath" "regexp" "runtime" @@ -16,1112 +14,373 @@ import ( "sync" ) -const ( - goReleasesURL = "https://go.dev/dl/?mode=json&include=all" - resultSchema = 1 - zigCCPrefix = "zig-cc-" -) - -type resultStatus string +const zigPrefix = "zig-cc-" -const ( - statusPending resultStatus = "pending" - statusSuccess resultStatus = "success" - statusFailure resultStatus = "failure" - statusNotApplicable resultStatus = "not_applicable" -) +const pending = "pending" type resultFile struct { - Schema int `json:"schema"` - Go string `json:"go"` - RunAttempt int `json:"run_attempt"` - Results map[string]resultStatus `json:"results"` + Schema int `json:"schema"` + RunAttempt int `json:"run_attempt"` + Results map[string]map[string]string `json:"results"` } -type goVersion struct { +type version struct { gccgo bool minor int } - type toolchain struct { - label string - command string -} - -type plannedToolchain struct { - toolchain - version goVersion - results resultFile - output string -} - -type goRelease struct { - Version string `json:"version"` - Stable bool `json:"stable"` - Files []goReleaseFile `json:"files"` -} - -type goReleaseFile struct { - Filename string `json:"filename"` - OS string `json:"os"` - Arch string `json:"arch"` - Kind string `json:"kind"` + label, command, goroot, release string + version version + installed bool } - type architecture struct { - name string - goarch string - goarm string - minimumMinor int - emulated bool - image string - platform string - raceMinor int - zigTarget string + name, goarch, goarm, image, platform, zigTarget string + minimumMinor, raceMinor int } var architectures = []architecture{ - { - name: "armv6", - goarch: "arm", - goarm: "6", - minimumMinor: 5, - emulated: true, - image: "balenalib/rpi-raspbian:bookworm", - }, - { - name: "armv7", - goarch: "arm", - goarm: "7", - minimumMinor: 5, - emulated: true, - image: "arm32v7/debian:bookworm", - platform: "linux/arm/v7", - }, - { - name: "aarch64", - goarch: "arm64", - minimumMinor: 5, - emulated: true, - image: "arm64v8/debian:bookworm", - platform: "linux/arm64", - // Race detector support on linux/arm64 was added in Go 1.12. - // See https://go.dev/doc/go1.12. - raceMinor: 12, - zigTarget: "aarch64-linux-gnu", - }, - { - name: "s390x", - goarch: "s390x", - // Support for s390x was added in Go 1.7. - // See https://go.dev/doc/go1.7#ports. - minimumMinor: 7, - emulated: true, - image: "s390x/debian:bookworm", - platform: "linux/s390x", - // Race detector support on linux/s390x was added in Go 1.19. - // See https://go.dev/doc/go1.19. - raceMinor: 19, - zigTarget: "s390x-linux-gnu", - }, - { - name: "386", - goarch: "386", - minimumMinor: 5, - }, - { - name: "x64", - goarch: "amd64", - minimumMinor: 3, - // Race builds with Go 1.4 and below are broken with newer C - // compilers. Several fixes only reached go1.4-bootstrap. See - // https://github.com/golang/go/compare/go1.4.3...release-branch.go1.4. - raceMinor: 5, - }, + // 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 ( - inlineGetPattern = regexp.MustCompile(`(?m)can inline Get$`) - goLabelPattern = regexp.MustCompile(`^1\.([0-9]+)(?:\.([0-9]+))?$`) - gccgoLabelPattern = regexp.MustCompile(`^gccgo-([0-9]+)$`) - goVersionPattern = regexp.MustCompile(`\bgo(1\.[0-9]+(?:\.[0-9]+)?)\b`) - releaseVersionPattern = regexp.MustCompile(`^go1\.([0-9]+)\.([0-9]+)$`) + 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]), zigCCPrefix); ok { - if target == "" { - fatal(fmt.Errorf("missing Zig target in %q", os.Args[0])) - } - if err := runZigCC(runCommand, target, os.Args[1:]); err != nil { - fatal(err) + if target, ok := strings.CutPrefix(filepath.Base(os.Args[0]), zigPrefix); ok { + arguments := []string{"cc", "-target", target} + for _, argument := range os.Args[1:] { + if filepath.Ext(argument) == ".syso" { + argument += ".o" + } + arguments = append(arguments, argument) } + fatal(runCommand(nil, "zig", arguments...)) return } - - if len(os.Args) < 2 { - fatal(fmt.Errorf("usage: run-tests [arguments]")) - } - - switch os.Args[1] { - case "plan": - flags := flag.NewFlagSet("plan", flag.ContinueOnError) - goLabel := flags.String("go", "", "matrix Go version label") - output := flags.String("output", "", "result JSON path") - runAttempt := flags.Int("attempt", 0, "workflow run attempt") - if err := flags.Parse(os.Args[2:]); err != nil { - fatal(err) - } - if *goLabel == "" || *output == "" || *runAttempt <= 0 || flags.NArg() != 0 { - fatal(fmt.Errorf("usage: run-tests plan -go -attempt -output ")) - } - results, err := newPlan(*goLabel, *runAttempt) - if err != nil { - fatal(err) - } - if err := writeResults(*output, results); err != nil { - fatal(err) - } - case "download": - flags := flag.NewFlagSet("download", flag.ContinueOnError) - bootstrapGo := flags.String("bootstrap-go", "", "bootstrap Go command") - output := flags.String("output", "", "result JSON directory") - runAttempt := flags.Int("attempt", 0, "workflow run attempt") - if err := flags.Parse(os.Args[2:]); err != nil { - fatal(err) - } - if *bootstrapGo == "" || *output == "" || *runAttempt <= 0 || flags.NArg() == 0 { - fatal(fmt.Errorf("usage: run-tests download -bootstrap-go -attempt -output ...")) - } - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - fatal(fmt.Errorf("download requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) - } - if err := runDownload( - *bootstrapGo, - *runAttempt, - *output, - flags.Args(), - fetchGoReleases, - runCommand, - runPlannedToolchain, - ); err != nil { - fatal(err) - } - case "installed": - flags := flag.NewFlagSet("installed", flag.ContinueOnError) - output := flags.String("output", "", "result JSON directory") - runAttempt := flags.Int("attempt", 0, "workflow run attempt") - if err := flags.Parse(os.Args[2:]); err != nil { - fatal(err) - } - if *output == "" || *runAttempt <= 0 || flags.NArg() == 0 { - fatal(fmt.Errorf("usage: run-tests installed -attempt -output ...")) - } - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - fatal(fmt.Errorf("installed requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) - } - if err := runInstalled(*runAttempt, *output, flags.Args(), runPlannedToolchain); err != nil { - fatal(err) - } - default: - fatal(fmt.Errorf("unknown command %q", os.Args[1])) - } -} - -func fatal(err error) { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) -} - -func parseGoVersion(label string) (goVersion, error) { - if match := gccgoLabelPattern.FindStringSubmatch(label); match != nil { - major, err := strconv.Atoi(match[1]) - if err != nil || major == 0 || strconv.Itoa(major) != match[1] { - return goVersion{}, fmt.Errorf("invalid Go version %q", label) - } - return goVersion{gccgo: true}, nil - } - - match := goLabelPattern.FindStringSubmatch(label) - if match == nil { - return goVersion{}, fmt.Errorf("invalid Go version %q", label) - } - minor, err := strconv.Atoi(match[1]) - if err != nil { - return goVersion{}, fmt.Errorf("invalid Go version %q: %s", label, err) + if len(os.Args) < 2 || (os.Args[1] != "plan" && os.Args[1] != "run") { + fatal(fmt.Errorf("usage: run-tests -attempt N -output FILE ARG...")) } - if minor < 3 || strconv.Itoa(minor) != match[1] { - return goVersion{}, fmt.Errorf("unsupported Go version %q", label) + 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 match[2] != "" { - patch, err := strconv.Atoi(match[2]) - if err != nil || strconv.Itoa(patch) != match[2] { - return goVersion{}, fmt.Errorf("invalid Go version %q", label) - } - } - return goVersion{minor: minor}, nil -} - -type releaseFetcher func() ([]goRelease, error) -type toolchainRunner func(*plannedToolchain) []error - -func parseDownloadLabels(labels []string) ([]toolchain, error) { - if len(labels) == 0 { - return nil, fmt.Errorf("no Go versions specified") + if err := flags.Parse(os.Args[2:]); err != nil { + fatal(err) } - - seen := make(map[string]bool, len(labels)) - toolchains := make([]toolchain, 0, len(labels)) - for _, label := range labels { - version, err := parseGoVersion(label) - if err != nil { - return nil, err - } - if version.gccgo || label != fmt.Sprintf("1.%d", version.minor) { - return nil, fmt.Errorf("download requires a Go minor label, got %q", label) - } - if version.minor < 5 { - return nil, fmt.Errorf("download does not support Go version %q; use installed", label) - } - if seen[label] { - return nil, fmt.Errorf("duplicate Go version %q", label) - } - seen[label] = true - toolchains = append(toolchains, toolchain{label: label}) - } - return toolchains, nil -} - -func parseInstalledSpecs(specs []string) ([]toolchain, error) { - if len(specs) == 0 { - return nil, fmt.Errorf("no Go toolchains specified") - } - - seen := make(map[string]bool, len(specs)) - toolchains := make([]toolchain, 0, len(specs)) - for _, spec := range specs { - label, command, ok := strings.Cut(spec, "=") - if !ok || command == "" { - return nil, fmt.Errorf("invalid installed toolchain %q; want label=command", spec) - } - if _, err := parseGoVersion(label); err != nil { - return nil, err - } - if seen[label] { - return nil, fmt.Errorf("duplicate Go version %q", label) - } - seen[label] = true - toolchains = append(toolchains, toolchain{label: label, command: command}) - } - return toolchains, nil -} - -func planToolchains(toolchains []toolchain, runAttempt int, outputDirectory string) ([]*plannedToolchain, error) { - plans := make([]*plannedToolchain, 0, len(toolchains)) - var failures []error - for _, toolchain := range toolchains { - version, err := parseGoVersion(toolchain.label) - if err != nil { - failures = append(failures, err) - continue - } - results, err := newPlan(toolchain.label, runAttempt) - if err != nil { - failures = append(failures, err) - continue - } - output := filepath.Join(outputDirectory, "build-status-"+toolchain.label+".json") - if err := writeResults(output, results); err != nil { - failures = append(failures, fmt.Errorf("plan %s: %s", toolchain.label, err)) - continue - } - plans = append(plans, &plannedToolchain{ - toolchain: toolchain, - version: version, - results: results, - output: output, - }) + if *attempt <= 0 || *output == "" || flags.NArg() == 0 { + fatal(fmt.Errorf("usage: run-tests %s -attempt N -output FILE ARG...", os.Args[1])) } - return plans, errors.Join(failures...) -} - -func runToolchains(plans []*plannedToolchain, runner toolchainRunner) error { - var failures []error - for _, plan := range plans { - for _, err := range runner(plan) { - failures = append(failures, fmt.Errorf("%s: %s", plan.label, err)) + if os.Args[1] == "plan" { + results, err := newPlan(*attempt, flags.Args()) + if err == nil { + err = writeResults(*output, results) } + fatal(err) + return } - return errors.Join(failures...) -} - -func runPlannedToolchain(plan *plannedToolchain) []error { - coordinator := coordinator{ - version: plan.version, - goCommand: plan.command, - results: &plan.results, - output: plan.output, - execute: runCommand, + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + fatal(fmt.Errorf("run requires linux/amd64, got %s/%s", runtime.GOOS, runtime.GOARCH)) } - return coordinator.run() -} - -func runInstalled(runAttempt int, outputDirectory string, specs []string, runner toolchainRunner) error { - toolchains, err := parseInstalledSpecs(specs) + toolchains, err := parseSpecs(flags.Args(), *bootstrap) if err != nil { - return err + fatal(err) } - plans, err := planToolchains(toolchains, runAttempt, outputDirectory) - var failures []error - if err != nil { - failures = append(failures, err) + results, err := newPlan(*attempt, flags.Args()) + if err == nil { + err = writeResults(*output, results) } - if err := runToolchains(plans, runner); err != nil { - failures = append(failures, err) + if err == nil { + err = run(toolchains, *bootstrap, *output, &results) } - return errors.Join(failures...) + fatal(err) } -func runDownload( - bootstrapGo string, - runAttempt int, - outputDirectory string, - labels []string, - fetch releaseFetcher, - execute commandRunner, - runner toolchainRunner, -) error { - toolchains, err := parseDownloadLabels(labels) - if err != nil { - return err - } - plans, err := planToolchains(toolchains, runAttempt, outputDirectory) - var failures []error - if err != nil { - failures = append(failures, err) - } - if len(plans) == 0 { - return errors.Join(failures...) - } - - releases, err := fetch() - if err != nil { - failures = append(failures, fmt.Errorf("fetch Go releases: %s", err)) - return errors.Join(failures...) - } - plannedLabels := make([]string, 0, len(plans)) - for _, plan := range plans { - plannedLabels = append(plannedLabels, plan.label) - } - selected, err := selectGoReleases(releases, plannedLabels) - if err != nil { - failures = append(failures, err) - } - - workDirectory, err := os.MkdirTemp("", "goid-download-") +func fatal(err error) { if err != nil { - failures = append(failures, fmt.Errorf("create download directory: %s", err)) - return errors.Join(failures...) - } - defer os.RemoveAll(workDirectory) - binDirectory := filepath.Join(workDirectory, "bin") - if err := os.Mkdir(binDirectory, 0o755); err != nil { - failures = append(failures, fmt.Errorf("create wrapper directory: %s", err)) - return errors.Join(failures...) - } - - var ready []*plannedToolchain - installArguments := []string{"install"} - for _, plan := range plans { - exact, ok := selected[plan.label] - if !ok { - continue - } - plan.command = filepath.Join(binDirectory, exact) - installArguments = append(installArguments, "golang.org/dl/"+exact+"@latest") - ready = append(ready, plan) - } - if len(ready) == 0 { - return errors.Join(failures...) + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } - if err := execute(toolchainEnvironment("GOBIN="+binDirectory), bootstrapGo, installArguments...); err != nil { - failures = append(failures, fmt.Errorf("install Go download wrappers: %s", err)) - return errors.Join(failures...) - } - - downloadFailures := make([]error, len(ready)) - var downloads sync.WaitGroup - for index, plan := range ready { - downloads.Add(1) - go func(index int, plan *plannedToolchain) { - defer downloads.Done() - if err := execute(toolchainEnvironment(), plan.command, "download"); err != nil { - downloadFailures[index] = fmt.Errorf("%s: download toolchain: %s", plan.label, err) - } - }(index, plan) - } - downloads.Wait() - - downloaded := ready[:0] - for index, plan := range ready { - if downloadFailures[index] != nil { - failures = append(failures, downloadFailures[index]) - continue - } - downloaded = append(downloaded, plan) - } - if err := runToolchains(downloaded, runner); err != nil { - failures = append(failures, err) - } - return errors.Join(failures...) } -func fetchGoReleases() ([]goRelease, error) { - output, err := commandOutput( - nil, - "curl", - "--fail", - "--location", - "--max-time", "30", - "--show-error", - "--silent", - goReleasesURL, - ) - if err != nil { - return nil, err +func parseVersion(label string) (version, error) { + if gccgoLabel.MatchString(label) { + return version{gccgo: true}, nil } - - var releases []goRelease - if err := json.NewDecoder(strings.NewReader(output)).Decode(&releases); err != nil { - return nil, fmt.Errorf("decode %s: %s", goReleasesURL, err) + 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 releases, nil + return version{minor: minor}, nil } -func selectGoReleases(releases []goRelease, labels []string) (map[string]string, error) { - requested := make(map[string]bool, len(labels)) - for _, label := range labels { - requested[label] = true +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) } - type selection struct { - version string - patch int - } - selections := make(map[string]selection, len(labels)) - for _, release := range releases { - if !release.Stable { - continue - } - match := releaseVersionPattern.FindStringSubmatch(release.Version) - if match == nil { - continue - } - minor, err := strconv.Atoi(match[1]) - if err != nil || strconv.Itoa(minor) != match[1] { - continue - } - label := fmt.Sprintf("1.%d", minor) - if !requested[label] { - continue - } - patch, err := strconv.Atoi(match[2]) - if err != nil || strconv.Itoa(patch) != match[2] { - continue + 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 } - archive := release.Version + ".linux-amd64.tar.gz" - available := false - for _, file := range release.Files { - if file.Filename == archive && file.OS == "linux" && file.Arch == "amd64" && file.Kind == "archive" { - available = true - break - } + if installed && (command == "" || !filepath.IsAbs(command)) { + return nil, fmt.Errorf("Go command for %q is not absolute", label) } - if !available { - continue + if installed && !parsed.gccgo && parsed.minor > 4 { + return nil, fmt.Errorf("installed Go version %q is not supported", label) } - selected, ok := selections[label] - if !ok || patch > selected.patch { - selections[label] = selection{version: release.Version, patch: patch} + 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) } - } - - selected := make(map[string]string, len(selections)) - var failures []error - for _, label := range labels { - selection, ok := selections[label] - if !ok { - failures = append(failures, fmt.Errorf("no stable linux/amd64 release found for Go %s", label)) - continue + if index != 0 && installed != toolchains[0].installed { + return nil, fmt.Errorf("downloaded and installed toolchains cannot be mixed") } - selected[label] = selection.version + toolchains = append(toolchains, toolchain{label: label, command: command, version: parsed, installed: installed}) } - return selected, errors.Join(failures...) -} - -func toolchainEnvironment(assignments ...string) []string { - assignments = append([]string{"GOTOOLCHAIN=local"}, assignments...) - overridden := make(map[string]bool, len(assignments)) - for _, assignment := range assignments { - key, _, _ := strings.Cut(assignment, "=") - overridden[key] = true + if !toolchains[0].installed && bootstrap == "" { + return nil, fmt.Errorf("-bootstrap-go is required for downloaded toolchains") } - environment := make([]string, 0, len(os.Environ())+len(assignments)) - for _, assignment := range os.Environ() { - key, _, _ := strings.Cut(assignment, "=") - if key != "GOROOT" && !overridden[key] { - environment = append(environment, assignment) - } + if toolchains[0].installed && bootstrap != "" { + return nil, fmt.Errorf("-bootstrap-go requires downloaded toolchains") } - return append(environment, assignments...) + return toolchains, nil } -func newPlan(label string, runAttempt int) (resultFile, error) { - if runAttempt <= 0 { - return resultFile{}, fmt.Errorf("invalid run attempt %d", runAttempt) - } - version, err := parseGoVersion(label) - if err != nil { - return resultFile{}, err - } - - results := resultFile{ - Schema: resultSchema, - Go: label, - RunAttempt: runAttempt, - Results: make(map[string]resultStatus, len(architectures)), - } - for _, architecture := range architectures { - status := statusNotApplicable - if architecture.applicable(version) { - status = statusPending +func newPlan(attempt int, arguments []string) (resultFile, error) { + results := resultFile{Schema: 1, 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 } - results.Results[architecture.name] = status } return results, nil } -func (architecture architecture) applicable(version goVersion) bool { - if version.gccgo { +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 architecture.name == "x64" + return target.name == "x64" } - // Cross-compilation became possible in Go 1.5 with the removal of C - // code from the compiler. See https://go.dev/doc/go1.5#c. - return version.minor >= architecture.minimumMinor -} - -func (architecture architecture) supportsRace(version goVersion) bool { - return !version.gccgo && architecture.raceMinor != 0 && version.minor >= architecture.raceMinor -} - -func readResults(path string) (resultFile, error) { - file, err := os.Open(path) - if err != nil { - return resultFile{}, fmt.Errorf("open %s: %s", path, err) - } - defer file.Close() - - var results resultFile - decoder := json.NewDecoder(file) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&results); err != nil { - return resultFile{}, fmt.Errorf("decode %s: %s", path, err) - } - return results, nil + return parsed.minor >= target.minimumMinor } - func writeResults(path string, results resultFile) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("create result directory: %s", err) - } - file, err := os.CreateTemp(filepath.Dir(path), ".run-tests-results-") + data, err := json.MarshalIndent(results, "", " ") if err != nil { - return fmt.Errorf("create result file: %s", err) - } - temporaryPath := file.Name() - defer os.Remove(temporaryPath) - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - if err := encoder.Encode(results); err != nil { - file.Close() return fmt.Errorf("encode results: %s", err) } - if err := file.Close(); err != nil { - return fmt.Errorf("close result file: %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(temporaryPath, path); err != nil { - return fmt.Errorf("replace %s: %s", path, err) + if err := os.Rename(temporary, path); err != nil { + return fmt.Errorf("replace results: %s", err) } return nil } -type coordinator struct { - version goVersion - goCommand string - results *resultFile - output string - execute commandRunner -} - -type commandRunner func([]string, string, ...string) error - -func (coordinator coordinator) run() []error { - workDirectory, err := os.MkdirTemp("", "goid-run-tests-") +func selectWrapper(directory, label string) (string, error) { + prefix := "go" + label + "." + matches, err := filepath.Glob(filepath.Join(directory, prefix+"*")) if err != nil { - return []error{fmt.Errorf("create working directory: %s", err)} - } - defer os.RemoveAll(workDirectory) - - raceFailures := coordinator.prepareRace(workDirectory) - emulationFailures := coordinator.prepareEmulation() - type completedArchitecture struct { - architecture architecture - failures []error - } - completed := make(chan completedArchitecture, len(architectures)) - pending := 0 - - fmt.Println("::group::architectures") - for _, target := range architectures { - if coordinator.results.Results[target.name] == statusNotApplicable { - continue - } - - pending++ - go func(target architecture) { - fmt.Printf("--- %s started ---\n", target.name) - failures := coordinator.runArchitecture( - workDirectory, - target, - raceFailures[target.name], - emulationFailures[target.name], - ) - fmt.Printf("--- %s finished ---\n", target.name) - completed <- completedArchitecture{ - architecture: target, - failures: failures, - } - }(target) + return "", fmt.Errorf("find Go %s wrappers: %s", label, err) } - - completedFailures := make(map[string][]error, pending) - var writeFailures []error - checkpointFailed := false - for ; pending > 0; pending-- { - result := <-completed - completedFailures[result.architecture.name] = result.failures - status := statusSuccess - if len(result.failures) != 0 { - status = statusFailure - } - coordinator.results.Results[result.architecture.name] = status - if checkpointFailed { - continue - } - if err := writeResults(coordinator.output, *coordinator.results); err != nil { - writeFailures = append(writeFailures, err) - checkpointFailed = true + 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 } } - fmt.Println("::endgroup::") - - var failures []error - for _, architecture := range architectures { - for _, err := range completedFailures[architecture.name] { - failures = append(failures, fmt.Errorf("%s: %s", architecture.name, err)) - } + if selected == "" { + return "", fmt.Errorf("no wrapper found for Go %s", label) } - failures = append(failures, writeFailures...) - return failures + return selected, nil } -func (coordinator coordinator) prepareRace(workDirectory string) map[string][]error { - failures := make(map[string][]error) - var targets []architecture - for _, architecture := range architectures { - if architecture.applicable(coordinator.version) && - architecture.zigTarget != "" && - architecture.supportsRace(coordinator.version) { - targets = append(targets, architecture) - } - } - if len(targets) == 0 { - return failures - } - - fmt.Println("::group::cross-race setup") - // Go 1.17 and below keep only the first word of CC when invoking cgo. - // Give them a single executable path that main dispatches back to Zig. - executable, err := os.Executable() +func run(toolchains []toolchain, bootstrap, output string, results *resultFile) error { + work, err := os.MkdirTemp("", "goid-run-tests-") if err != nil { - for _, architecture := range targets { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("find test runner executable: %s", err), - ) - } - } else { - for _, architecture := range targets { - if err := os.Symlink(executable, zigCCPath(workDirectory, architecture)); err != nil { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("create Zig compiler trampoline: %s", err), - ) - } - } + return fmt.Errorf("create working directory: %s", err) } + defer os.RemoveAll(work) - version, err := resolvedGoVersion(coordinator.goCommand) - if err != nil { - for _, architecture := range targets { - failures[architecture.name] = append(failures[architecture.name], err) - } - fmt.Println("::endgroup::") - return failures - } - goroot, err := commandOutput(toolchainEnvironment(), coordinator.goCommand, "env", "GOROOT") - if err != nil { - for _, architecture := range targets { - failures[architecture.name] = append(failures[architecture.name], err) - } - fmt.Println("::endgroup::") - return failures - } - for _, architecture := range targets { - // Non-host *.syso files are missing from the Go toolchains provided - // by setup-go. See https://github.com/actions/setup-go/issues/181. - path := filepath.Join( - strings.TrimSpace(goroot), - "src", - "runtime", - "race", - "race_linux_"+architecture.goarch+".syso", - ) - url := fmt.Sprintf( - "https://github.com/golang/go/raw/refs/tags/go%s/src/runtime/race/race_linux_%s.syso", - version, - architecture.goarch, - ) - if err := coordinator.execute(nil, "curl", "--fail", "--location", "--output", path, url); err != nil { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("download race runtime: %s", err), - ) - } else if err := os.Link(path, path+".o"); err != nil { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("create Zig race object alias: %s", err), - ) - } - } - fmt.Println("::endgroup::") - return failures -} - -func (coordinator coordinator) prepareEmulation() map[string][]error { - failures := make(map[string][]error) - // 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 coordinator.version.gccgo || coordinator.version.minor < 9 { - return failures - } - - var targets []architecture - for _, architecture := range architectures { - if architecture.emulated && architecture.applicable(coordinator.version) { - targets = append(targets, architecture) + var failures []error + if !toolchains[0].installed { + labels := make([]string, len(toolchains)) + for index := range toolchains { + labels[index] = toolchains[index].label } - } - if len(targets) == 0 { - return failures - } - - fmt.Println("::group::QEMU setup") - if err := coordinator.execute(nil, "docker", "run", "--rm", "--privileged", "tonistiigi/binfmt", "--install", "all"); err != nil { - for _, architecture := range targets { - failures[architecture.name] = append( - failures[architecture.name], - fmt.Errorf("configure QEMU: %s", err), - ) + 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) } - } - fmt.Println("::endgroup::") - return failures -} - -func (coordinator coordinator) runArchitecture( - workDirectory string, - architecture architecture, - raceFailures []error, - emulationFailures []error, -) []error { - directory := filepath.Join(workDirectory, architecture.name) - if err := os.MkdirAll(directory, 0o755); err != nil { - return []error{fmt.Errorf("create output directory: %s", err)} - } - environment := targetEnvironment(workDirectory, architecture, false) - var failures []error - - if !coordinator.version.gccgo && coordinator.version.minor >= 12 { - // Go 1.12 introduced the inliner that should inline Get. See - // https://go.dev/doc/go1.12#compiler. - if err := checkInlining(coordinator.goCommand, environment); err != nil { - failures = append(failures, err) + if err == nil && (module.Version == "" || module.Dir == "") { + err = fmt.Errorf("invalid golang.org/dl module metadata") } - } - - normalBinary := filepath.Join(directory, "goid.test") - testArguments := []string{"test", "-c", "-o", normalBinary, "./..."} - if !coordinator.version.gccgo && coordinator.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 append(failures, fmt.Errorf("get working directory: %s", err)) + return err } - normalBinary = filepath.Join(checkout, filepath.Base(checkout)+".test") - testArguments = []string{"test", "-c", "./..."} - defer os.Remove(normalBinary) - } - normalReady := true - if err := coordinator.execute(environment, coordinator.goCommand, "build", "-v", "./..."); err != nil { - failures = append(failures, err) - normalReady = false - } - if normalReady { - if err := coordinator.execute(environment, coordinator.goCommand, testArguments...); err != nil { - failures = append(failures, err) - normalReady = false - } - } - - var binaries []string - if normalReady { - binaries = append(binaries, normalBinary) - } - - if architecture.supportsRace(coordinator.version) { - if len(raceFailures) != 0 { - failures = append(failures, raceFailures...) - } else { - raceEnvironment := targetEnvironment(workDirectory, architecture, true) - raceBinary := filepath.Join(directory, "goid.race.test") - raceBuildArguments := []string{"build", "-v", "-race"} - raceTestArguments := []string{"test", "-c", "-race"} - if architecture.zigTarget != "" { - // The Go internal linker cannot resolve libc references in - // cross-race objects linked by Zig. Use Zig for the final link. - raceBuildArguments = append(raceBuildArguments, "-ldflags=-linkmode=external") - raceTestArguments = append(raceTestArguments, "-ldflags=-linkmode=external") - } - raceBuildArguments = append(raceBuildArguments, "./...") - raceTestArguments = append(raceTestArguments, "-o", raceBinary, "./...") - raceReady := true - if err := coordinator.execute(raceEnvironment, coordinator.goCommand, raceBuildArguments...); err != nil { + selected := make(map[string]string, len(labels)) + for _, label := range labels { + selected[label], err = selectWrapper(module.Dir, label) + if err != nil { failures = append(failures, err) - raceReady = false } - if raceReady { - if err := coordinator.execute(raceEnvironment, coordinator.goCommand, raceTestArguments...); err != nil { - failures = append(failures, err) - raceReady = false + } + bin := filepath.Join(work, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + return errors.Join(errors.Join(failures...), fmt.Errorf("create wrapper directory: %s", err)) + } + arguments := []string{"install"} + var downloads []int + for index := range toolchains { + exact := selected[toolchains[index].label] + if exact != "" { + 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() } - if raceReady { - binaries = append(binaries, raceBinary) + } + for index, err := range downloadErrors { + if err != nil { + toolchains[index].command = "" + failures = append(failures, fmt.Errorf("%s: download toolchain: %s", toolchains[index].label, err)) } } } - if !architecture.emulated { - return append(failures, runNative(coordinator.execute, binaries)...) - } - if coordinator.version.minor < 9 { - return failures - } - if len(emulationFailures) != 0 { - return append(failures, emulationFailures...) - } - if len(binaries) == 0 { - return failures - } - - executor := filepath.Join(directory, "execute") - if err := coordinator.execute(environment, coordinator.goCommand, "build", "-o", executor, "./.github/run-tests/execute"); err != nil { - return append(failures, err) - } - if err := runDocker(coordinator.execute, workDirectory, architecture, binaries); err != nil { - failures = append(failures, 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 + } } - return failures -} - -func checkInlining(goCommand string, environment []string) error { - command := exec.Command(goCommand, "build", "-gcflags=-m") - command.Env = environment - // Optimization diagnostics are expected output. Replay them only if the - // command or assertion fails. - var stdout, stderr bytes.Buffer - command.Stdout = &stdout - command.Stderr = &stderr - replayOutput := func() error { - var failures []error - if _, err := os.Stdout.Write(stdout.Bytes()); err != nil { - failures = append(failures, fmt.Errorf("write go build stdout: %s", err)) + for index := range toolchains { + toolchain := &toolchains[index] + if toolchain.command == "" { + continue } - if _, err := os.Stderr.Write(stderr.Bytes()); err != nil { - failures = append(failures, fmt.Errorf("write go build stderr: %s", err)) + toolchainEnvironment := environment("GOROOT=") + if toolchain.installed && !toolchain.version.gccgo { + toolchainEnvironment = environment() } - return errors.Join(failures...) - } - if err := command.Run(); err != nil { - return errors.Join(fmt.Errorf("%s build -gcflags=-m: %s", goCommand, err), replayOutput()) - } - if !inlineGetPattern.Match(stderr.Bytes()) { - return errors.Join( - fmt.Errorf("%s build -gcflags=-m did not report that Get can be inlined", goCommand), - replayOutput(), - ) - } - return nil -} - -func runNative(execute commandRunner, binaries []string) []error { - var failures []error - for _, binary := range binaries { - if err := execute(nil, binary, "-test.v"); err != nil { - failures = append(failures, err) + value, err := commandOutput(toolchainEnvironment, toolchain.command, "env", "GOROOT") + if err == nil && strings.TrimSpace(value) == "" { + err = fmt.Errorf("empty GOROOT") } - if err := execute(nil, binary, "-test.bench=.", "-test.benchmem", "-test.v"); err != nil { - failures = append(failures, err) + 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 failures + return errors.Join(failures...) } -func runDocker( - execute commandRunner, - workDirectory string, - architecture architecture, - binaries []string, -) error { - checkout, err := os.Getwd() - if err != nil { - return fmt.Errorf("get working directory: %s", err) +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) } - arguments := []string{ - "run", - "--rm", - "--workdir", - checkout, - "--mount", - fmt.Sprintf("type=bind,source=%s,target=%s", checkout, checkout), + type completed struct { + name string + err error } - if architecture.platform != "" { - arguments = append(arguments, "--platform", architecture.platform) - } - arguments = append( - arguments, - "--mount", - fmt.Sprintf("type=bind,source=%s,target=/run-tests,readonly", workDirectory), - architecture.image, - filepath.ToSlash(filepath.Join("/run-tests", architecture.name, "execute")), - ) - for _, binary := range binaries { - arguments = append( - arguments, - filepath.ToSlash(filepath.Join("/run-tests", architecture.name, filepath.Base(binary))), - ) - } - return execute(nil, "docker", arguments...) -} - -func targetEnvironment(workDirectory string, architecture architecture, race bool) []string { - removed := map[string]bool{ - "CC": true, - "CC_FOR_TARGET": true, - "CGO_ENABLED": true, - "GOARCH": true, - "GOARM": true, - "GOROOT": true, - "GOOS": true, - "GOTOOLCHAIN": true, - } - environment := make([]string, 0, len(os.Environ())+6) - for _, assignment := range os.Environ() { - key, _, _ := strings.Cut(assignment, "=") - if !removed[key] { - environment = append(environment, assignment) + 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) } - environment = append(environment, "GOTOOLCHAIN=local", "GOOS=linux", "GOARCH="+architecture.goarch) - if architecture.goarm != "" { - environment = append(environment, "GOARM="+architecture.goarm) - } - if race && architecture.zigTarget != "" { - environment = append( - environment, - "CGO_ENABLED=1", - "CC="+zigCCPath(workDirectory, architecture), - ) - } - return environment -} - -func zigCCPath(workDirectory string, architecture architecture) string { - return filepath.Join(workDirectory, zigCCPrefix+architecture.zigTarget) -} - -func runZigCC(execute commandRunner, target string, compilerArguments []string) error { - arguments := []string{"cc", "-target", target} - for _, argument := range compilerArguments { - // Unlike GCC, Zig rejects object files with the .syso extension. - // prepareRace creates an object alias that Zig recognizes. - if filepath.Ext(argument) == ".syso" { - argument += ".o" + 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) } - arguments = append(arguments, argument) - } - return execute(nil, "zig", arguments...) -} - -func resolvedGoVersion(goCommand string) (string, error) { - output, err := commandOutput(toolchainEnvironment(), goCommand, "version") - if err != nil { - return "", err - } - match := goVersionPattern.FindStringSubmatch(output) - if match == nil { - return "", fmt.Errorf("parse go version output %q", strings.TrimSpace(output)) - } - return match[1], nil -} - -func commandOutput(environment []string, name string, arguments ...string) (string, error) { - command := exec.Command(name, arguments...) - if environment != nil { - command.Env = environment - } - command.Stderr = os.Stderr - output, err := command.Output() - if err != nil { - return "", fmt.Errorf("%s %q: %s", name, arguments, err) - } - return string(output), nil -} - -func runCommand(environment []string, name string, arguments ...string) error { - command := exec.Command(name, arguments...) - if environment != nil { - command.Env = environment } - command.Stdin = os.Stdin - command.Stdout = os.Stdout - command.Stderr = os.Stderr - if err := command.Run(); err != nil { - return fmt.Errorf("%s %q: %s", name, arguments, 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 nil + return errors.Join(append(failures, checkpointFailures...)...) } diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go index ce15f87..5b04f9f 100644 --- a/.github/run-tests/main_test.go +++ b/.github/run-tests/main_test.go @@ -1,677 +1,52 @@ package main import ( - "fmt" - "io" "os" "path/filepath" "reflect" - "strings" - "sync" "testing" - "time" ) -func TestPlanApplicability(t *testing.T) { - tests := []struct { - version string - applicable map[string]bool - }{ - { - version: "1.3", - applicable: map[string]bool{"x64": true}, - }, - { - version: "1.5", - applicable: map[string]bool{ - "armv6": true, - "armv7": true, - "aarch64": true, - "386": true, - "x64": true, - }, - }, - { - version: "1.7", - applicable: map[string]bool{ - "armv6": true, - "armv7": true, - "aarch64": true, - "s390x": true, - "386": true, - "x64": true, - }, - }, - { - version: "gccgo-14", - applicable: map[string]bool{"x64": true}, - }, +func TestPlan(t *testing.T) { + tests := map[string][]string{ + "1.3": {"x64"}, + "1.5": {"armv6", "armv7", "aarch64", "386", "x64"}, + "1.7": {"armv6", "armv7", "aarch64", "s390x", "386", "x64"}, + "gccgo-14": {"x64"}, } - - for _, test := range tests { - t.Run(test.version, func(t *testing.T) { - plan, err := newPlan(test.version, 2) - if err != nil { - t.Fatal(err) - } - if plan.RunAttempt != 2 { - t.Fatalf("got run attempt %d, want 2", plan.RunAttempt) - } - for _, architecture := range architectures { - want := statusNotApplicable - if test.applicable[architecture.name] { - want = statusPending - } - if got := plan.Results[architecture.name]; got != want { - t.Errorf("%s: got %q, want %q", architecture.name, got, want) - } - } - for architecture := range test.applicable { - if _, ok := plan.Results[architecture]; !ok { - t.Errorf("missing %s result", architecture) - } - } - }) - } -} - -func TestPlanRejectsInvalidVersions(t *testing.T) { - for _, version := range []string{"1.2", "2.0", "gccgo-x"} { - t.Run(version, func(t *testing.T) { - if _, err := newPlan(version, 1); err == nil { - t.Fatal("expected an error") - } - }) - } -} - -func TestPlanRejectsInvalidRunAttempt(t *testing.T) { - if _, err := newPlan("1.26", 0); err == nil { - t.Fatal("expected an error") - } -} - -func TestSelectGoReleases(t *testing.T) { - archive := func(version string) goReleaseFile { - return goReleaseFile{ - Filename: version + ".linux-amd64.tar.gz", - OS: "linux", - Arch: "amd64", - Kind: "archive", - } - } - releases := []goRelease{ - {Version: "go1.25.10", Stable: true, Files: []goReleaseFile{archive("go1.25.10")}}, - {Version: "go1.25.9", Stable: true, Files: []goReleaseFile{archive("go1.25.9")}}, - {Version: "go1.25.11", Stable: false, Files: []goReleaseFile{archive("go1.25.11")}}, - {Version: "go1.26.4", Stable: true, Files: []goReleaseFile{{ - Filename: "go1.26.4.darwin-amd64.tar.gz", - OS: "darwin", - Arch: "amd64", - Kind: "archive", - }}}, - {Version: "go1.26.3", Stable: true, Files: []goReleaseFile{archive("go1.26.3")}}, - {Version: "go1.24", Stable: true, Files: []goReleaseFile{archive("go1.24")}}, - } - - selected, err := selectGoReleases(releases, []string{"1.25", "1.26", "1.24"}) - if err == nil { - t.Fatal("expected a missing Go 1.24 error") - } - if !strings.Contains(err.Error(), "Go 1.24") { - t.Fatalf("got error %s, want missing Go 1.24", err) - } - want := map[string]string{"1.25": "go1.25.10", "1.26": "go1.26.3"} - if !reflect.DeepEqual(selected, want) { - t.Errorf("got selections %q, want %q", selected, want) - } -} - -func TestParseInstalledSpecs(t *testing.T) { - got, err := parseInstalledSpecs([]string{ - "1.3=/opt/go 1.3/bin/go", - "gccgo-14=/usr/bin/go-14=debug", - }) - if err != nil { - t.Fatal(err) - } - want := []toolchain{ - {label: "1.3", command: "/opt/go 1.3/bin/go"}, - {label: "gccgo-14", command: "/usr/bin/go-14=debug"}, - } - if !reflect.DeepEqual(got, want) { - t.Errorf("got toolchains %q, want %q", got, want) - } - - for _, specs := range [][]string{ - {"1.25"}, - {"1.25="}, - {"../1.25=go"}, - {"1.25=go", "1.25=other-go"}, - {"gccgo-0=go"}, - } { - if _, err := parseInstalledSpecs(specs); err == nil { - t.Errorf("specs %q unexpectedly succeeded", specs) + for label, expected := range tests { + plan, err := newPlan(1, []string{label}) + if err != nil { + t.Fatal(err) } - } -} - -func TestRunInstalledPreplansAndContinues(t *testing.T) { - output := t.TempDir() - var ran []string - err := runInstalled( - 3, - output, - []string{"1.3=/sdk/go1.3", "1.4=/sdk/go1.4"}, - func(plan *plannedToolchain) []error { - for _, label := range []string{"1.3", "1.4"} { - results, err := readResults(filepath.Join(output, "build-status-"+label+".json")) - if err != nil { - t.Error(err) - continue - } - if results.RunAttempt != 3 || results.Results["x64"] != statusPending { - t.Errorf("%s was not preplanned: %#v", label, results) - } - } - ran = append(ran, plan.label) - if plan.label == "1.3" { - return []error{fmt.Errorf("broken toolchain")} - } - return nil - }, - ) - if err == nil { - t.Fatal("expected a Go 1.3 failure") - } - if !strings.Contains(err.Error(), "1.3: broken toolchain") { - t.Fatalf("got error %s, want Go 1.3 failure", err) - } - if want := []string{"1.3", "1.4"}; !reflect.DeepEqual(ran, want) { - t.Errorf("ran %q, want %q", ran, want) - } -} - -func TestRunInstalledContinuesAfterPlanFailure(t *testing.T) { - output := t.TempDir() - if err := os.Mkdir(filepath.Join(output, "build-status-1.3.json"), 0o755); err != nil { - t.Fatal(err) - } - var ran []string - err := runInstalled( - 1, - output, - []string{"1.3=/sdk/go1.3", "1.4=/sdk/go1.4"}, - func(plan *plannedToolchain) []error { - ran = append(ran, plan.label) - return nil - }, - ) - if err == nil { - t.Fatal("expected a Go 1.3 plan failure") - } - if !strings.Contains(err.Error(), "plan 1.3") { - t.Fatalf("got error %s, want Go 1.3 plan failure", err) - } - if want := []string{"1.4"}; !reflect.DeepEqual(ran, want) { - t.Errorf("ran %q, want %q", ran, want) - } -} - -func TestRunDownloadPreplansAndContinues(t *testing.T) { - t.Setenv("GOROOT", "/ambient/go") - t.Setenv("GOTOOLCHAIN", "auto") - output := t.TempDir() - archive := func(version string) goReleaseFile { - return goReleaseFile{ - Filename: version + ".linux-amd64.tar.gz", - OS: "linux", - Arch: "amd64", - Kind: "archive", + statuses := plan.Results[label] + if len(statuses) != len(architectures) { + t.Fatalf("%s: got %d architectures, want %d", label, len(statuses), len(architectures)) } - } - fetch := func() ([]goRelease, error) { - for _, label := range []string{"1.5", "1.6"} { - results, err := readResults(filepath.Join(output, "build-status-"+label+".json")) - if err != nil { - t.Fatal(err) - } - if results.Results["x64"] != statusPending { - t.Fatalf("%s was not preplanned: %#v", label, results) + var actual []string + for _, architecture := range architectures { + if statuses[architecture.name] == pending { + actual = append(actual, architecture.name) } } - return []goRelease{ - {Version: "go1.5.4", Stable: true, Files: []goReleaseFile{archive("go1.5.4")}}, - {Version: "go1.6.4", Stable: true, Files: []goReleaseFile{archive("go1.6.4")}}, - }, nil - } - - var observed struct { - sync.Mutex - bootstrapEnvironment []string - installArguments []string - downloads []string - ran []toolchain - } - downloadsStarted := make(chan struct{}, 2) - downloadsReady := make(chan struct{}) - go func() { - <-downloadsStarted - <-downloadsStarted - close(downloadsReady) - }() - execute := func(environment []string, name string, arguments ...string) error { - observed.Lock() - if name == "/bootstrap/go" { - observed.bootstrapEnvironment = append([]string(nil), environment...) - observed.installArguments = append([]string(nil), arguments...) - observed.Unlock() - return nil - } - exact := filepath.Base(name) - observed.downloads = append(observed.downloads, exact) - observed.Unlock() - downloadsStarted <- struct{}{} - select { - case <-downloadsReady: - case <-time.After(5 * time.Second): - return fmt.Errorf("toolchain downloads did not overlap") - } - if exact == "go1.5.4" { - return fmt.Errorf("download failed") + if !reflect.DeepEqual(actual, expected) { + t.Errorf("%s: got %v, want %v", label, actual, expected) } - return nil - } - err := runDownload( - "/bootstrap/go", - 4, - output, - []string{"1.5", "1.6"}, - fetch, - execute, - func(plan *plannedToolchain) []error { - observed.Lock() - observed.ran = append(observed.ran, plan.toolchain) - observed.Unlock() - return nil - }, - ) - if err == nil { - t.Fatal("expected a Go 1.5 download failure") - } - if !strings.Contains(err.Error(), "1.5: download toolchain: download failed") { - t.Fatalf("got error %s, want Go 1.5 download failure", err) - } - - observed.Lock() - defer observed.Unlock() - wantInstall := []string{ - "install", - "golang.org/dl/go1.5.4@latest", - "golang.org/dl/go1.6.4@latest", - } - if !reflect.DeepEqual(observed.installArguments, wantInstall) { - t.Errorf("got install arguments %q, want %q", observed.installArguments, wantInstall) - } - values := make(map[string]string) - for _, assignment := range observed.bootstrapEnvironment { - key, value, _ := strings.Cut(assignment, "=") - values[key] = value - } - if values["GOTOOLCHAIN"] != "local" || values["GOBIN"] == "" { - t.Errorf("bootstrap environment does not select local toolchains and a private GOBIN: %q", values) - } - if _, ok := values["GOROOT"]; ok { - t.Errorf("bootstrap environment retains GOROOT: %q", values["GOROOT"]) - } - if len(observed.downloads) != 2 { - t.Errorf("got downloads %q, want both toolchains", observed.downloads) - } - if len(observed.ran) != 1 || observed.ran[0].label != "1.6" || filepath.Base(observed.ran[0].command) != "go1.6.4" { - t.Errorf("ran toolchains %q, want only Go 1.6.4", observed.ran) } } -func TestGoHelpersUseExplicitCommand(t *testing.T) { - t.Setenv("GOROOT", "/ambient/go") - t.Setenv("GOTOOLCHAIN", "auto") - command := filepath.Join(t.TempDir(), "explicit go") - script := `#!/bin/sh -test "$GOTOOLCHAIN" = local || exit 10 -test -z "$GOROOT" || exit 11 -case "$1" in -version) - echo 'go version go1.26.4 linux/amd64' - ;; -env) - echo '/explicit/root' - ;; -build) - echo 'goid.go:20:6: can inline Get' >&2 - ;; -*) - exit 12 - ;; -esac -` - if err := os.WriteFile(command, []byte(script), 0o755); err != nil { - t.Fatal(err) - } - version, err := resolvedGoVersion(command) - if err != nil { - t.Fatal(err) - } - if version != "1.26.4" { - t.Errorf("got Go version %q, want 1.26.4", version) - } - root, err := commandOutput(toolchainEnvironment(), command, "env", "GOROOT") - if err != nil { - t.Fatal(err) - } - if strings.TrimSpace(root) != "/explicit/root" { - t.Errorf("got GOROOT %q, want /explicit/root", strings.TrimSpace(root)) - } - if err := checkInlining(command, toolchainEnvironment()); err != nil { - t.Fatal(err) - } -} - -func TestZigCC(t *testing.T) { - var name string - var arguments []string - err := runZigCC( - func(environment []string, command string, commandArguments ...string) error { - name = command - arguments = append([]string(nil), commandArguments...) - return nil - }, - "aarch64-linux-gnu", - []string{"-o", "output", "race_linux_arm64.syso", "other.o"}, - ) - if err != nil { - t.Fatal(err) - } - if name != "zig" { - t.Errorf("got command %q, want %q", name, "zig") - } - want := []string{ - "cc", - "-target", "aarch64-linux-gnu", - "-o", "output", - "race_linux_arm64.syso.o", - "other.o", - } - if !reflect.DeepEqual(arguments, want) { - t.Errorf("got arguments %q, want %q", arguments, want) - } -} - -func TestRunCommandForwardsStdin(t *testing.T) { - const helperEnvironment = "GOID_TEST_RUN_COMMAND_STDIN" - if os.Getenv(helperEnvironment) == "1" { - input, err := io.ReadAll(os.Stdin) - if err != nil { +func TestSelectWrapper(t *testing.T) { + directory := t.TempDir() + for _, name := range []string{"go1.20.9", "go1.20.12", "go1.20.13rc1"} { + if err := os.Mkdir(filepath.Join(directory, name), 0o755); err != nil { t.Fatal(err) } - if string(input) != "input" { - t.Errorf("got stdin %q, want %q", input, "input") - } - return } - - reader, writer, err := os.Pipe() + actual, err := selectWrapper(directory, "1.20") if err != nil { t.Fatal(err) } - stdin := os.Stdin - os.Stdin = reader - t.Cleanup(func() { - os.Stdin = stdin - if err := reader.Close(); err != nil { - t.Error(err) - } - }) - if _, err := io.WriteString(writer, "input"); err != nil { - t.Fatal(err) - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - - t.Setenv(helperEnvironment, "1") - if err := runCommand(os.Environ(), os.Args[0], "-test.run=^TestRunCommandForwardsStdin$"); err != nil { - t.Fatal(err) - } -} - -func TestArchitectureBoundaries(t *testing.T) { - tests := []struct { - version string - architecture string - applicable bool - race bool - }{ - {version: "1.4", architecture: "armv6"}, - {version: "1.5", architecture: "armv6", applicable: true}, - {version: "1.6", architecture: "s390x"}, - {version: "1.7", architecture: "s390x", applicable: true}, - {version: "1.4", architecture: "x64", applicable: true}, - {version: "1.5", architecture: "x64", applicable: true, race: true}, - {version: "1.11", architecture: "aarch64", applicable: true}, - {version: "1.12", architecture: "aarch64", applicable: true, race: true}, - {version: "1.18", architecture: "s390x", applicable: true}, - {version: "1.19", architecture: "s390x", applicable: true, race: true}, - {version: "gccgo-14", architecture: "aarch64"}, - {version: "gccgo-14", architecture: "x64", applicable: true}, - } - - architectureByName := make(map[string]architecture, len(architectures)) - for _, architecture := range architectures { - architectureByName[architecture.name] = architecture - } - for _, test := range tests { - name := test.version + "/" + test.architecture - t.Run(name, func(t *testing.T) { - version, err := parseGoVersion(test.version) - if err != nil { - t.Fatal(err) - } - architecture, ok := architectureByName[test.architecture] - if !ok { - t.Fatalf("unknown architecture %q", test.architecture) - } - if got := architecture.applicable(version); got != test.applicable { - t.Errorf("applicable: got %t, want %t", got, test.applicable) - } - if got := architecture.supportsRace(version); got != test.race { - t.Errorf("race: got %t, want %t", got, test.race) - } - }) - } -} - -func TestGo13TestBinary(t *testing.T) { - checkout := t.TempDir() - previous, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - if err := os.Chdir(checkout); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := os.Chdir(previous); err != nil { - t.Error(err) - } - }) - - var testArguments []string - const goCommand = "/sdk/go1.3/bin/go" - t.Setenv("GOROOT", "/ambient/go") - t.Setenv("GOTOOLCHAIN", "auto") - coordinator := coordinator{ - version: goVersion{minor: 3}, - goCommand: goCommand, - execute: func(environment []string, name string, arguments ...string) error { - if name == goCommand && len(arguments) != 0 && arguments[0] == "test" { - testArguments = append([]string(nil), arguments...) - } - if name == goCommand { - values := make(map[string]string) - for _, assignment := range environment { - key, value, _ := strings.Cut(assignment, "=") - values[key] = value - } - if values["GOTOOLCHAIN"] != "local" { - t.Errorf("got GOTOOLCHAIN=%q, want local", values["GOTOOLCHAIN"]) - } - if _, ok := values["GOROOT"]; ok { - t.Errorf("explicit Go command retained GOROOT=%q", values["GOROOT"]) - } - } - return nil - }, - } - failures := coordinator.runArchitecture(t.TempDir(), architectures[len(architectures)-1], nil, nil) - if len(failures) != 0 { - t.Fatalf("got %d failures, want 0", len(failures)) - } - if want := []string{"test", "-c", "./..."}; !reflect.DeepEqual(testArguments, want) { - t.Errorf("go test arguments: got %q, want %q", testArguments, want) - } -} - -func TestCoordinatorContinuesAndCheckpoints(t *testing.T) { - plan, err := newPlan("1.5", 1) - if err != nil { - t.Fatal(err) - } - output := filepath.Join(t.TempDir(), "results.json") - if err := writeResults(output, plan); err != nil { - t.Fatal(err) - } - - type observations struct { - sync.Mutex - overlapObserved bool - checkpointObserved bool - raceBuildObserved bool - raceBinaryObserved bool - normalBinaryObserved bool - } - var observed observations - armv7Started := make(chan struct{}) - var armv7StartedOnce sync.Once - execute := func(environment []string, name string, arguments ...string) error { - var goarch, goarm string - for _, assignment := range environment { - if value, ok := strings.CutPrefix(assignment, "GOARCH="); ok { - goarch = value - } - if value, ok := strings.CutPrefix(assignment, "GOARM="); ok { - goarm = value - } - } - normalBuild := name == "test-go" && reflect.DeepEqual(arguments, []string{"build", "-v", "./..."}) - raceBuild := name == "test-go" && reflect.DeepEqual(arguments, []string{"build", "-v", "-race", "./..."}) - - if normalBuild && goarch == "arm" && goarm == "6" { - select { - case <-armv7Started: - observed.Lock() - observed.overlapObserved = true - observed.Unlock() - case <-time.After(5 * time.Second): - return fmt.Errorf("armv7 build did not start") - } - return fmt.Errorf("armv6 build failed") - } - if normalBuild && goarch == "arm" && goarm == "7" { - armv7StartedOnce.Do(func() { - close(armv7Started) - }) - deadline := time.Now().Add(5 * time.Second) - for { - checkpoint, err := readResults(output) - if err != nil { - return fmt.Errorf("read checkpoint: %s", err) - } - if checkpoint.Results["armv6"] == statusFailure { - observed.Lock() - observed.checkpointObserved = true - observed.Unlock() - break - } - if time.Now().After(deadline) { - return fmt.Errorf("armv6 result was not checkpointed") - } - time.Sleep(time.Millisecond) - } - } - if normalBuild && goarch == "amd64" { - return fmt.Errorf("x64 build failed") - } - if raceBuild && goarch == "amd64" { - observed.Lock() - observed.raceBuildObserved = true - observed.Unlock() - } - if filepath.Base(name) == "goid.race.test" { - observed.Lock() - observed.raceBinaryObserved = true - observed.Unlock() - } - if filepath.Base(name) == "goid.test" && filepath.Base(filepath.Dir(name)) == "x64" { - observed.Lock() - observed.normalBinaryObserved = true - observed.Unlock() - } - return nil - } - - coordinator := coordinator{ - version: goVersion{minor: 5}, - goCommand: "test-go", - results: &plan, - output: output, - execute: execute, - } - failures := coordinator.run() - if len(failures) != 2 { - t.Fatalf("got %d failures, want 2", len(failures)) - } - for index, want := range []string{ - "armv6: armv6 build failed", - "x64: x64 build failed", - } { - if got := failures[index].Error(); got != want { - t.Errorf("failure %d: got %q, want %q", index, got, want) - } - } - for architecture, want := range map[string]resultStatus{ - "armv6": statusFailure, - "armv7": statusSuccess, - "aarch64": statusSuccess, - "s390x": statusNotApplicable, - "386": statusSuccess, - "x64": statusFailure, - } { - if got := plan.Results[architecture]; got != want { - t.Errorf("%s: got %q, want %q", architecture, got, want) - } - } - observed.Lock() - defer observed.Unlock() - if !observed.overlapObserved { - t.Error("armv6 and armv7 builds did not overlap") - } - if !observed.checkpointObserved { - t.Error("armv6 result was not checkpointed while armv7 was running") - } - if !observed.raceBuildObserved || !observed.raceBinaryObserved { - t.Error("race build did not complete after the normal x64 build failed") - } - if observed.normalBinaryObserved { - t.Error("normal x64 binary ran after its build failed") + if actual != "go1.20.12" { + t.Errorf("got %q, want go1.20.12", actual) } } diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 091e5a4..08ec99a 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -14,173 +14,112 @@ permissions: contents: read jobs: - test_runner: - name: test runner + build: + name: build (${{ matrix.versions }}) runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - - name: Test runner - shell: bash - run: | - set -euxo pipefail - - go test ./.github/run-tests/... - - build_downloaded: - name: build (${{ matrix.go }}) - runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 strategy: fail-fast: false matrix: include: - - id: go-01 - go: '1.26 1.5' - - id: go-02 - go: '1.25 1.6' - - id: go-03 - go: '1.24 1.7' - - id: go-04 - go: '1.23 1.8' - - id: go-05 - go: '1.22 1.9' - - id: go-06 - go: '1.21 1.10' - - id: go-07 - go: '1.20 1.11' - - id: go-08 - go: '1.19 1.12' - - id: go-09 - go: '1.18 1.13' - - id: go-10 - go: '1.17 1.14' - - id: go-11 - go: '1.16 1.15' - + - 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' + - versions: '1.3' + go: '1.3' + - versions: '1.4' + go: '1.4' + - versions: gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 + gccgo: true steps: - uses: actions/checkout@v7 - - name: Build test runner - shell: bash - run: go build -o run-tests ./.github/run-tests - - name: Set up Zig + timeout-minutes: 2 + - run: go build -o run-tests ./.github/run-tests + timeout-minutes: 2 + - if: ${{ !matrix.go && !matrix.gccgo }} uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + timeout-minutes: 5 with: version: 0.15.2 use-cache: false - - name: Download and run tests - timeout-minutes: 10 - shell: bash - run: | - set -euxo pipefail - - ./run-tests download \ - -bootstrap-go go \ - -attempt '${{ github.run_attempt }}' \ - -output build-status \ - ${{ matrix.go }} - - name: Upload build status - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: build-status-${{ matrix.id }} - path: build-status/*.json - if-no-files-found: error - overwrite: true - - build_setup_go: - name: build (${{ matrix.go }}) - runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - go: - - '1.3' - - '1.4' - - steps: - - uses: actions/checkout@v7 - - name: Build test runner - shell: bash - run: go build -o run-tests ./.github/run-tests # 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 + if: ${{ matrix.go }} + timeout-minutes: 5 uses: actions/setup-go@v6.4.0 with: go-version: ${{ matrix.go }} check-latest: true cache: false - - name: Run tests + - name: Set up gccgo + if: ${{ matrix.gccgo }} timeout-minutes: 5 - shell: bash run: | set -euxo pipefail - ./run-tests installed \ - -attempt '${{ github.run_attempt }}' \ - -output build-status \ - '${{ matrix.go }}=go' - - name: Upload build status - if: ${{ always() }} - uses: actions/upload-artifact@v7 - with: - name: build-status-go-${{ matrix.go }} - path: build-status/*.json - if-no-files-found: error - overwrite: true + sudo apt update + sudo apt install -y gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 + - name: Run downloaded tests + if: ${{ !matrix.go && !matrix.gccgo }} + timeout-minutes: 10 + run: | + set -euxo pipefail - build_gccgo: - name: build (gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14) - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - - name: Build test runner - shell: bash - run: go build -o run-tests ./.github/run-tests - - name: Set up gccgo + ./run-tests run \ + -attempt "$GITHUB_RUN_ATTEMPT" \ + -output 'build-status-${{ strategy.job-index }}.json' \ + -bootstrap-go "$(command -v go)" \ + ${{ matrix.versions }} + - name: Run setup-go tests + if: ${{ matrix.go }} timeout-minutes: 5 - shell: bash run: | set -euxo pipefail - sudo apt update - sudo apt install -y gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 - - name: Run tests + GOROOT="$(go env GOROOT)" + export GOROOT + ./run-tests run \ + -attempt "$GITHUB_RUN_ATTEMPT" \ + -output 'build-status-${{ strategy.job-index }}.json' \ + "${{ matrix.versions }}=$(command -v go)" + - name: Run gccgo tests + if: ${{ matrix.gccgo }} timeout-minutes: 5 - shell: bash run: | set -euxo pipefail - ./run-tests installed \ - -attempt '${{ github.run_attempt }}' \ - -output build-status \ + ./run-tests run \ + -attempt "$GITHUB_RUN_ATTEMPT" \ + -output 'build-status-${{ 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 - - name: Upload build status - if: ${{ always() }} + - if: ${{ always() }} uses: actions/upload-artifact@v7 + timeout-minutes: 5 with: - name: build-status-gccgo - path: build-status/*.json - if-no-files-found: error + name: build-status-${{ strategy.job-index }} + path: build-status-${{ strategy.job-index }}.json + if-no-files-found: ignore overwrite: true report: name: build status if: ${{ always() }} - needs: - - test_runner - - build_downloaded - - build_setup_go - - build_gccgo + needs: build permissions: actions: read contents: write @@ -188,270 +127,141 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v7 - - name: Build test runner - shell: bash - run: go build -o run-tests ./.github/run-tests + - run: go test ./.github/run-tests && go build -o run-tests ./.github/run-tests - uses: actions/download-artifact@v8 + continue-on-error: true with: pattern: build-status-* path: build-status merge-multiple: true - uses: actions/github-script@v9 - env: - TEST_RUNNER_RESULT: ${{ needs.test_runner.result }} with: script: | const {execFileSync} = require('child_process'); const fs = require('fs'); const path = require('path'); - const symbols = { - action_required: '⚠️', - cancelled: '⏹️', - failure: '❌', - in_progress: '⏳', - neutral: '➖', - pending: '⏳', - queued: '⏳', - skipped: '⏭️', - stale: '➖', - startup_failure: '❌', - success: '✅', - timed_out: '⏱️', - waiting: '⏳', - }; - const producerStatuses = new Set([ - 'failure', - 'not_applicable', - 'pending', - 'success', - ]); - - function parseResult(file) { - let result; - try { - result = JSON.parse(fs.readFileSync(file, 'utf8')); - } catch (err) { - throw new Error(`Invalid build status ${file}: ${err.message}`); - } - if (result.schema !== 1) { - throw new Error(`Unsupported build status schema in ${file}`); - } - if (typeof result.go !== 'string' || result.go.length === 0) { - throw new Error(`Missing Go version in ${file}`); - } - if (!Number.isSafeInteger(result.run_attempt) || result.run_attempt <= 0) { - throw new Error(`Invalid run attempt in ${file}`); + function read(file) { + const value = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!value || value.schema !== 1 || !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}`); } - if (!result.results || typeof result.results !== 'object' || Array.isArray(result.results)) { - throw new Error(`Missing architecture results in ${file}`); - } - const entries = Object.entries(result.results); - if (entries.length === 0) { - throw new Error(`Empty architecture results in ${file}`); - } - for (const [architecture, status] of entries) { - if (architecture.length === 0 || typeof status !== 'string' || !producerStatuses.has(status)) { - throw new Error(`Invalid ${architecture || 'architecture'} result in ${file}`); - } - } - return result; + return value; } - 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 versions = [ + ...Array.from({length: 24}, (_, index) => `1.${26 - index}`), + ...Array.from({length: 6}, (_, index) => `gccgo-${14 - index}`), + ]; + const expectedVersions = new Set(versions); - const latestJobs = new Map(); + 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 versions = match[1].trim().split(/\s+/); - if ( - versions.some(version => version.length === 0) || - new Set(versions).size !== versions.length - ) { - throw new Error(`Invalid version list in ${job.name}`); - } - if (!Number.isSafeInteger(job.run_attempt) || job.run_attempt <= 0) { - throw new Error(`Invalid run attempt for ${job.name}`); - } - - const previous = latestJobs.get(job.name); + if (!match) continue; + const previous = latest.get(job.name); if (!previous || job.run_attempt > previous.job.run_attempt) { - latestJobs.set(job.name, {job, versions}); + 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 builds = new Map(); - for (const shard of latestJobs.values()) { - const {job, versions} = shard; - if (job.conclusion === 'skipped') { - throw new Error(`Selected build job was skipped: ${job.name}`); - } - for (const version of versions) { - const previous = builds.get(version); - if (previous) { - throw new Error( - `Duplicate build jobs for ${version}: ${previous.job.name} and ${job.name}`, - ); - } - builds.set(version, {job, shard}); + 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}); } - } - - const expectedVersions = new Set(); - for (let minor = 3; minor <= 26; minor++) { - expectedVersions.add(`1.${minor}`); - } - for (let version = 9; version <= 14; version++) { - expectedVersions.add(`gccgo-${version}`); + shardsByVersions.set(shard.key, shard); } const unexpectedVersions = [...builds.keys()] - .filter(version => !expectedVersions.has(version)) - .sort(); + .filter(version => !expectedVersions.has(version)); const missingVersions = [...expectedVersions] .filter(version => !builds.has(version)); - if (unexpectedVersions.length > 0 || missingVersions.length > 0) { + if (unexpectedVersions.length !== 0 || missingVersions.length !== 0) { throw new Error( - 'Build job labels do not match expected versions. ' + + 'Build version coverage mismatch. ' + `Unexpected: ${unexpectedVersions.join(', ') || 'none'}. ` + `Missing: ${missingVersions.join(', ') || 'none'}.`, ); } - const artifacts = new Map(); - const artifactEntries = fs.existsSync('build-status') - ? fs.readdirSync('build-status', {withFileTypes: true}) - : []; - for (const entry of artifactEntries) { - if (!entry.isFile() || path.extname(entry.name) !== '.json') { - continue; - } - const file = path.join('build-status', entry.name); - const result = parseResult(file); - if (artifacts.has(result.go)) { - throw new Error(`Duplicate build status for ${result.go}`); - } - artifacts.set(result.go, result); - } - for (const version of artifacts.keys()) { - if (!builds.has(version)) { - throw new Error(`Build status has no matching job for ${version}`); - } - } - - for (const [version, build] of builds) { - let result = artifacts.get(version); - if (!result || result.run_attempt !== build.job.run_attempt) { - if (result) { - core.notice( - `Ignoring build status for ${version} from attempt ${result.run_attempt}; ` + - `the latest job is from attempt ${build.job.run_attempt}.`, - ); - } - const fallback = path.join( - process.env.RUNNER_TEMP, - `build-status-${version}-${build.job.run_attempt}.json`, - ); - execFileSync( - './run-tests', - [ - 'plan', - '-go', version, - '-attempt', String(build.job.run_attempt), - '-output', fallback, - ], - {stdio: 'inherit'}, - ); - result = parseResult(fallback); - } - if (result.go !== version) { - throw new Error(`Build status version does not match job for ${version}`); - } - if (result.run_attempt !== build.job.run_attempt) { - throw new Error(`Build status attempt does not match job for ${version}`); - } - - build.reportedResults = Object.entries(result.results); - build.applicableResults = build.reportedResults.filter( - ([, status]) => status !== 'not_applicable', - ); - if (build.applicableResults.length === 0) { - throw new Error(`No applicable architectures for ${version}`); - } + 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 shards = new Set([...builds.values()].map(build => build.shard)); + const producer = new Set(['pending', 'success', 'failure', 'not_applicable']); + let architectures; for (const shard of shards) { - for (const version of shard.versions) { - if (builds.get(version)?.shard !== shard) { - throw new Error(`Latest build jobs disagree for ${shard.job.name}`); + 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 key = Object.keys(value.results).sort().join('\n'); + if (value.run_attempt !== shard.job.run_attempt || key !== shard.key) { + throw new Error(`Invalid fallback for ${shard.job.name}`); } } - const conclusion = shard.job.conclusion || shard.job.status; - shard.overrideSuccess = - conclusion !== 'success' && - shard.versions.every(version => - builds.get(version).applicableResults.every( - ([, status]) => status === 'success', - ), - ); - } - - const architectureSet = new Set(); - for (const [version, build] of builds) { - const conclusion = build.job.conclusion || build.job.status; - build.results = new Map(); - for (const [architecture, reportedStatus] of build.reportedResults) { - let status = reportedStatus; - if (status === 'pending') { - if (conclusion === 'success') { - throw new Error(`Successful build for ${version} has a pending ${architecture} result`); - } - status = conclusion; - } else if ( - conclusion === 'success' && - status !== 'success' && - status !== 'not_applicable' - ) { - throw new Error(`Successful build for ${version} has ${status} for ${architecture}`); - } else if (build.shard.overrideSuccess && status === 'success') { - status = conclusion; + 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 (!symbols[status] && status !== 'not_applicable') { - throw new Error(`Cannot render ${status} for ${version}/${architecture}`); + if (architectures && names.join() !== architectures.join()) { + throw new Error(`Architecture mismatch for ${version}`); } - build.results.set(architecture, status); - architectureSet.add(architecture); + 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 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 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); - }); - + const symbols = { + action_required: '⚠️', + cancelled: '⏹️', + failure: '❌', + neutral: '➖', + skipped: '⏭️', + stale: '➖', + startup_failure: '❌', + success: '✅', + timed_out: '⏱️', + }; const summary = [ '## Build status', '', @@ -463,28 +273,34 @@ 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 build = builds.get(version); - const status = build.results.get(architecture); + let status = build.results[architecture]; if (status === 'not_applicable') { summaryCells.push('—'); readmeCells.push('—'); continue; } - if (!status) { - throw new Error(`Missing ${architecture} result for ${version}`); + 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, - ); + readmeCells.push(status === 'success' ? symbol : link); } summary.push(`| ${version} | ${summaryCells.join(' | ')} |`); readme.push(`| ${version} | ${readmeCells.join(' | ')} |`); @@ -495,11 +311,6 @@ jobs: readme.push('', legend); await core.summary.addRaw(summary.join('\n')).write(); - if (process.env.TEST_RUNNER_RESULT !== 'success') { - core.setFailed(`Test runner job concluded ${process.env.TEST_RUNNER_RESULT}.`); - return; - } - const branch = context.payload.repository.default_branch; if (context.eventName === 'pull_request' || context.ref !== `refs/heads/${branch}`) { return; From 3d47e63b2a4b9ea8d07befe7dbbdd73a061022c1 Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Sun, 21 Jun 2026 07:28:26 -0400 Subject: [PATCH 11/12] ci: simplify workflow configuration Use current release tags for every action and let GitHub apply its normal job lifetime instead of maintaining separate timeout budgets. Document that paired Go versions share one runner to amortize startup and that Go 1.3 and 1.4 use setup-go because golang.org/dl has no wrappers for them. --- .github/workflows/go.yml | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 08ec99a..938c1d8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,11 +17,13 @@ jobs: build: name: build (${{ matrix.versions }}) runs-on: ubuntu-latest - timeout-minutes: 25 strategy: fail-fast: false matrix: include: + # 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' @@ -33,6 +35,8 @@ jobs: - 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' - versions: '1.4' @@ -40,13 +44,10 @@ jobs: - versions: gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 gccgo: true steps: - - uses: actions/checkout@v7 - timeout-minutes: 2 + - uses: actions/checkout@v7.0.0 - run: go build -o run-tests ./.github/run-tests - timeout-minutes: 2 - if: ${{ !matrix.go && !matrix.gccgo }} - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - timeout-minutes: 5 + uses: mlugg/setup-zig@v2.2.1 with: version: 0.15.2 use-cache: false @@ -55,7 +56,6 @@ jobs: # Go versions that predate caching. - name: Set up Go if: ${{ matrix.go }} - timeout-minutes: 5 uses: actions/setup-go@v6.4.0 with: go-version: ${{ matrix.go }} @@ -63,7 +63,6 @@ jobs: cache: false - name: Set up gccgo if: ${{ matrix.gccgo }} - timeout-minutes: 5 run: | set -euxo pipefail @@ -71,7 +70,6 @@ jobs: sudo apt install -y gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 - name: Run downloaded tests if: ${{ !matrix.go && !matrix.gccgo }} - timeout-minutes: 10 run: | set -euxo pipefail @@ -82,7 +80,6 @@ jobs: ${{ matrix.versions }} - name: Run setup-go tests if: ${{ matrix.go }} - timeout-minutes: 5 run: | set -euxo pipefail @@ -94,7 +91,6 @@ jobs: "${{ matrix.versions }}=$(command -v go)" - name: Run gccgo tests if: ${{ matrix.gccgo }} - timeout-minutes: 5 run: | set -euxo pipefail @@ -108,8 +104,7 @@ jobs: gccgo-13=/usr/bin/go-13 \ gccgo-14=/usr/bin/go-14 - if: ${{ always() }} - uses: actions/upload-artifact@v7 - timeout-minutes: 5 + uses: actions/upload-artifact@v7.0.1 with: name: build-status-${{ strategy.job-index }} path: build-status-${{ strategy.job-index }}.json @@ -124,17 +119,16 @@ jobs: actions: read contents: write runs-on: ubuntu-latest - timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.0 - run: go test ./.github/run-tests && go build -o run-tests ./.github/run-tests - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v8.0.1 continue-on-error: true with: pattern: build-status-* path: build-status merge-multiple: true - - uses: actions/github-script@v9 + - uses: actions/github-script@v9.0.0 with: script: | const {execFileSync} = require('child_process'); From 3bb677f60fa2378ae6ce8f176d5afe32cf3db25f Mon Sep 17 00:00:00 2001 From: Tamir Duberstein Date: Sun, 21 Jun 2026 07:57:21 -0400 Subject: [PATCH 12/12] ci: simplify the compatibility workflow Use major action tags and let setup-zig follow the latest tagged Zig release. Make retry artifacts attempt-specific and derive report ordering from the matrix jobs instead of a duplicated version list. Remove the runner unit tests and protocol schema, use conventional long options, and document compatibility behavior that would otherwise look like dead code. --- .github/run-tests/architecture.go | 15 ++++- .github/run-tests/main.go | 44 +++++++-------- .github/run-tests/main_test.go | 52 ----------------- .github/workflows/go.yml | 93 ++++++++++++++----------------- 4 files changed, 76 insertions(+), 128 deletions(-) delete mode 100644 .github/run-tests/main_test.go diff --git a/.github/run-tests/architecture.go b/.github/run-tests/architecture.go index a4c3bfa..0dc4ad6 100644 --- a/.github/run-tests/architecture.go +++ b/.github/run-tests/architecture.go @@ -18,8 +18,10 @@ type binary struct { } var ( - inlineGet = regexp.MustCompile(`(?m)can inline Get$`) + 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\)$`) ) @@ -31,11 +33,15 @@ func runArchitecture(toolchain toolchain, target architecture, work string, qemu 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)...) @@ -74,6 +80,8 @@ func runArchitecture(toolchain toolchain, target architecture, work string, qemu 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") } @@ -101,6 +109,8 @@ func runArchitecture(toolchain toolchain, target architecture, work string, qemu 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...) } @@ -199,6 +209,9 @@ func addFailure(failures *[]error, err error) { } 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) diff --git a/.github/run-tests/main.go b/.github/run-tests/main.go index 8aedf97..7c9fa05 100644 --- a/.github/run-tests/main.go +++ b/.github/run-tests/main.go @@ -19,7 +19,6 @@ const zigPrefix = "zig-cc-" const pending = "pending" type resultFile struct { - Schema int `json:"schema"` RunAttempt int `json:"run_attempt"` Results map[string]map[string]string `json:"results"` } @@ -58,8 +57,12 @@ var ( 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" } @@ -69,7 +72,7 @@ func main() { 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...")) + 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") @@ -82,7 +85,7 @@ func main() { fatal(err) } if *attempt <= 0 || *output == "" || flags.NArg() == 0 { - fatal(fmt.Errorf("usage: run-tests %s -attempt N -output FILE ARG...", os.Args[1])) + 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()) @@ -157,16 +160,16 @@ func parseSpecs(specs []string, bootstrap string) ([]toolchain, error) { 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") + 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 nil, fmt.Errorf("--bootstrap-go requires downloaded toolchains") } return toolchains, nil } func newPlan(attempt int, arguments []string) (resultFile, error) { - results := resultFile{Schema: 1, RunAttempt: attempt, Results: make(map[string]map[string]string, len(arguments))} + 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 { @@ -213,7 +216,7 @@ func writeResults(path string, results resultFile) error { return nil } -func selectWrapper(directory, label string) (string, error) { +func latestReleaseWrapper(directory, label string) (string, error) { prefix := "go" + label + "." matches, err := filepath.Glob(filepath.Join(directory, prefix+"*")) if err != nil { @@ -242,10 +245,6 @@ func run(toolchains []toolchain, bootstrap, output string, results *resultFile) var failures []error if !toolchains[0].installed { - labels := make([]string, len(toolchains)) - for index := range toolchains { - labels[index] = toolchains[index].label - } var module struct{ Version, Dir string } value, err := commandOutput(environment(), bootstrap, "mod", "download", "-json", "golang.org/dl@latest") if err == nil { @@ -257,27 +256,22 @@ func run(toolchains []toolchain, bootstrap, output string, results *resultFile) if err != nil { return err } - selected := make(map[string]string, len(labels)) - for _, label := range labels { - selected[label], err = selectWrapper(module.Dir, label) - if err != nil { - failures = append(failures, err) - } - } bin := filepath.Join(work, "bin") if err := os.Mkdir(bin, 0o755); err != nil { - return errors.Join(errors.Join(failures...), fmt.Errorf("create wrapper directory: %s", err)) + return fmt.Errorf("create wrapper directory: %s", err) } arguments := []string{"install"} var downloads []int for index := range toolchains { - exact := selected[toolchains[index].label] - if exact != "" { - 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) + 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 { diff --git a/.github/run-tests/main_test.go b/.github/run-tests/main_test.go deleted file mode 100644 index 5b04f9f..0000000 --- a/.github/run-tests/main_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestPlan(t *testing.T) { - tests := map[string][]string{ - "1.3": {"x64"}, - "1.5": {"armv6", "armv7", "aarch64", "386", "x64"}, - "1.7": {"armv6", "armv7", "aarch64", "s390x", "386", "x64"}, - "gccgo-14": {"x64"}, - } - for label, expected := range tests { - plan, err := newPlan(1, []string{label}) - if err != nil { - t.Fatal(err) - } - statuses := plan.Results[label] - if len(statuses) != len(architectures) { - t.Fatalf("%s: got %d architectures, want %d", label, len(statuses), len(architectures)) - } - var actual []string - for _, architecture := range architectures { - if statuses[architecture.name] == pending { - actual = append(actual, architecture.name) - } - } - if !reflect.DeepEqual(actual, expected) { - t.Errorf("%s: got %v, want %v", label, actual, expected) - } - } -} - -func TestSelectWrapper(t *testing.T) { - directory := t.TempDir() - for _, name := range []string{"go1.20.9", "go1.20.12", "go1.20.13rc1"} { - if err := os.Mkdir(filepath.Join(directory, name), 0o755); err != nil { - t.Fatal(err) - } - } - actual, err := selectWrapper(directory, "1.20") - if err != nil { - t.Fatal(err) - } - if actual != "go1.20.12" { - t.Errorf("got %q, want go1.20.12", actual) - } -} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 938c1d8..5c62427 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -15,6 +15,7 @@ permissions: jobs: build: + # The report reads the whitespace-separated versions from this name. name: build (${{ matrix.versions }}) runs-on: ubuntu-latest strategy: @@ -44,19 +45,19 @@ jobs: - versions: gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 gccgo: true steps: - - uses: actions/checkout@v7.0.0 - - run: go build -o run-tests ./.github/run-tests + - uses: actions/checkout@v7 + - run: go build -o run-tests -- ./.github/run-tests - if: ${{ !matrix.go && !matrix.gccgo }} - uses: mlugg/setup-zig@v2.2.1 + uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + 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 if: ${{ matrix.go }} - uses: actions/setup-go@v6.4.0 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} check-latest: true @@ -67,18 +68,19 @@ jobs: set -euxo pipefail sudo apt update - sudo apt install -y gccgo-9 gccgo-10 gccgo-11 gccgo-12 gccgo-13 gccgo-14 - - name: Run downloaded tests + sudo apt install -y ${{ matrix.versions }} + - name: Run tests with golang.org/dl toolchains if: ${{ !matrix.go && !matrix.gccgo }} run: | set -euxo pipefail ./run-tests run \ - -attempt "$GITHUB_RUN_ATTEMPT" \ - -output 'build-status-${{ strategy.job-index }}.json' \ - -bootstrap-go "$(command -v go)" \ + --attempt "$GITHUB_RUN_ATTEMPT" \ + --output 'build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json' \ + --bootstrap-go "$(command -v go)" \ + -- \ ${{ matrix.versions }} - - name: Run setup-go tests + - name: Run tests with setup-go toolchain if: ${{ matrix.go }} run: | set -euxo pipefail @@ -86,8 +88,9 @@ jobs: GOROOT="$(go env GOROOT)" export GOROOT ./run-tests run \ - -attempt "$GITHUB_RUN_ATTEMPT" \ - -output 'build-status-${{ strategy.job-index }}.json' \ + --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 }} @@ -95,21 +98,22 @@ jobs: set -euxo pipefail ./run-tests run \ - -attempt "$GITHUB_RUN_ATTEMPT" \ - -output 'build-status-${{ strategy.job-index }}.json' \ + --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 - - if: ${{ always() }} - uses: actions/upload-artifact@v7.0.1 + # 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: - name: build-status-${{ strategy.job-index }} - path: build-status-${{ strategy.job-index }}.json - if-no-files-found: ignore - overwrite: true + name: build-status-${{ github.run_attempt }}-${{ strategy.job-index }} + path: build-status-${{ github.run_attempt }}-${{ strategy.job-index }}.json report: name: build status @@ -120,15 +124,17 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.0 - - run: go test ./.github/run-tests && go build -o run-tests ./.github/run-tests - - uses: actions/download-artifact@v8.0.1 + - 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.0.0 + - uses: actions/github-script@v9 with: script: | const {execFileSync} = require('child_process'); @@ -137,20 +143,14 @@ jobs: function read(file) { const value = JSON.parse(fs.readFileSync(file, 'utf8')); - if (!value || value.schema !== 1 || !Number.isSafeInteger(value.run_attempt) || - value.run_attempt <= 0 || !value.results || + 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 versions = [ - ...Array.from({length: 24}, (_, index) => `1.${26 - index}`), - ...Array.from({length: 6}, (_, index) => `gccgo-${14 - index}`), - ]; - const expectedVersions = new Set(versions); - const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { owner: context.repo.owner, repo: context.repo.repo, @@ -182,17 +182,11 @@ jobs: } shardsByVersions.set(shard.key, shard); } - const unexpectedVersions = [...builds.keys()] - .filter(version => !expectedVersions.has(version)); - const missingVersions = [...expectedVersions] - .filter(version => !builds.has(version)); - if (unexpectedVersions.length !== 0 || missingVersions.length !== 0) { - throw new Error( - 'Build version coverage mismatch. ' + - `Unexpected: ${unexpectedVersions.join(', ') || 'none'}. ` + - `Missing: ${missingVersions.join(', ') || 'none'}.`, - ); - } + 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()) { @@ -212,14 +206,10 @@ jobs: 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, + 'plan', '--attempt', String(shard.job.run_attempt), '--output', output, + '--', ...shard.versions, ], {stdio: 'inherit'}); value = read(output); - const key = Object.keys(value.results).sort().join('\n'); - if (value.run_attempt !== shard.job.run_attempt || key !== shard.key) { - throw new Error(`Invalid fallback for ${shard.job.name}`); - } } for (const [version, results] of Object.entries(value.results)) { const build = builds.get(version); @@ -272,6 +262,9 @@ jobs: const summaryCells = []; const readmeCells = []; for (const architecture of architectures) { + // 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('—');