diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 69918ed..c8e8c6f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -209,7 +209,7 @@ composition with ordinary build identity and ambient flags. ## Stage 4: Override alternate module-file selection during downloads **Status:** complete. `go test ./internal/gotool` passes offline. Actual module -graph selection evidence remains deferred to Stage 5. +graph selection evidence passed in Stage 5. **Files:** `internal/gotool/gotool.go`, `internal/gotool/gotool_test.go`, `preparation.go` GoDoc, and `docs/internal/gotool.md`. @@ -234,7 +234,10 @@ graph selection evidence remains deferred to Stage 5. ## Stage 5: Verify selected-module preparation and build composition -**Status:** pending. **Depends on:** Stages 3 and 4. +**Status:** complete. Local-proxy graph, negative-control, inherited and +persistent settings, standalone discovery, and public build identity checks +pass offline, as do the focused binary CI and credential-order checks. +**Depends on:** Stages 3 and 4. **Files:** `preparation_test.go` and small test-local helpers beside it. The existing local module-proxy construction in `cmd/releasekit/woodpecker_test.go` diff --git a/preparation_graph_test.go b/preparation_graph_test.go new file mode 100644 index 0000000..444c48a --- /dev/null +++ b/preparation_graph_test.go @@ -0,0 +1,164 @@ +package releasekit_test + +import ( + "archive/zip" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + releasekit "go.maximumdirect.net/releasekit" +) + +func TestPrepareBuildDependenciesSelectedModuleGraphs(t *testing.T) { + repo := t.TempDir() + nested := filepath.Join(repo, "nested") + child := filepath.Join(nested, "child") + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatal(err) + } + write := func(path, value string) { + t.Helper() + if err := os.WriteFile(path, []byte(value), 0644); err != nil { + t.Fatal(err) + } + } + write(filepath.Join(repo, "go.mod"), "module example.invalid/root\n\ngo 1.25.0\n\nrequire example.invalid/rootdep v1.0.0\n") + write(filepath.Join(nested, "go.mod"), "module example.invalid/nested\n\ngo 1.25.0\n\nrequire example.invalid/nesteddep v1.0.0\n") + alt := filepath.Join(repo, "alternate.mod") + write(alt, "module example.invalid/alternate\n\ngo 1.25.0\n") + buildGit(t, repo, "init", "-b", "main") + buildGit(t, repo, "config", "user.name", "Test") + buildGit(t, repo, "config", "user.email", "test@example.invalid") + buildGit(t, repo, "add", ".") + buildGit(t, repo, "commit", "-m", "fixture") + proxy := t.TempDir() + for _, dep := range []string{"rootdep", "nesteddep"} { + writePreparationProxy(t, proxy, "example.invalid/"+dep) + } + t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy)) + t.Setenv("GOSUMDB", "off") + t.Setenv("GOPRIVATE", "") + t.Setenv("GONOPROXY", "none") + t.Setenv("GONOSUMDB", "*") + t.Setenv("GOTOOLCHAIN", "local") + t.Setenv("GOWORK", "off") + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, module, dep, modulePath string + persistent bool + }{ + {"inherited root", "", "rootdep", "example.invalid/root", false}, + {"persistent nested", "nested", "nesteddep", "example.invalid/nested", true}, + } { + t.Run(tc.name, func(t *testing.T) { + cache := filepath.Join(t.TempDir(), "cache") + t.Setenv("GOMODCACHE", cache) + envFile := filepath.Join(t.TempDir(), "goenv") + flags := "-modfile=" + alt + " -modcacherw" + if tc.persistent { + write(envFile, "GOFLAGS="+flags+"\n") + t.Setenv("GOENV", envFile) + t.Setenv("GOFLAGS", "") + if err := os.Unsetenv("GOFLAGS"); err != nil { + t.Fatal(err) + } + } else { + t.Setenv("GOENV", "off") + t.Setenv("GOFLAGS", flags) + } + // Without the explicit override, the alternate file has no dependencies. + negativeCache := filepath.Join(t.TempDir(), "negative") + command := exec.Command(realGo, "mod", "download") + command.Dir = repo + command.Env = append(os.Environ(), "GOMODCACHE="+negativeCache) + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("negative control: %v: %s", err, out) + } + if _, err := os.Stat(filepath.Join(negativeCache, "cache", "download", "example.invalid", tc.dep)); !os.IsNotExist(err) { + t.Fatalf("alternate graph unexpectedly downloaded %s: %v", tc.dep, err) + } + if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: child, ModuleDir: tc.module}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cache, "cache", "download", "example.invalid", tc.dep, "@v", "v1.0.0.zip")); err != nil { + t.Fatalf("selected dependency missing: %v", err) + } + other := "rootdep" + if tc.dep == other { + other = "nesteddep" + } + if _, err := os.Stat(filepath.Join(cache, "cache", "download", "example.invalid", other)); !os.IsNotExist(err) { + t.Fatalf("unexpected other graph: %v", err) + } + info, err := os.Stat(filepath.Join(cache, "example.invalid", tc.dep+"@v1.0.0")) + if err != nil || info.Mode().Perm()&0200 == 0 { + t.Fatalf("-modcacherw did not take effect: %v, %v", info, err) + } + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: child, ModuleDir: tc.module, Version: "v1.2.3"}) + expected := repo + if tc.module != "" { + expected = nested + } + if err != nil || result.RepositoryDir != repo || result.ModuleDir != expected || result.ModulePath != tc.modulePath { + t.Fatalf("build identity = %#v, error = %v", result, err) + } + }) + } + // Standalone package subdirectories retain Go's upward module discovery. + standalone := t.TempDir() + pkg := filepath.Join(standalone, "pkg") + if err := os.Mkdir(pkg, 0755); err != nil { + t.Fatal(err) + } + write(filepath.Join(standalone, "go.mod"), "module example.invalid/standalone\n\ngo 1.25.0\n\nrequire example.invalid/rootdep v1.0.0\n") + t.Setenv("GOENV", "off") + t.Setenv("GOFLAGS", "-modfile="+alt+" -modcacherw") + cache := filepath.Join(t.TempDir(), "cache") + t.Setenv("GOMODCACHE", cache) + if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: pkg}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(cache, "cache", "download", "example.invalid", "rootdep", "@v", "v1.0.0.zip")); err != nil { + t.Fatal(err) + } +} + +func writePreparationProxy(t *testing.T, proxy, module string) { + t.Helper() + dir := filepath.Join(proxy, filepath.FromSlash(module), "@v") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + mod := "module " + module + "\n\ngo 1.25.0\n" + for name, contents := range map[string]string{"v1.0.0.mod": mod, "v1.0.0.info": `{"Version":"v1.0.0","Time":"2026-01-01T00:00:00Z"}`, "list": "v1.0.0\n"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0644); err != nil { + t.Fatal(err) + } + } + file, err := os.Create(filepath.Join(dir, "v1.0.0.zip")) + if err != nil { + t.Fatal(err) + } + z := zip.NewWriter(file) + for name, contents := range map[string]string{"go.mod": mod, "dep.go": "package dep\n"} { + w, err := z.Create(fmt.Sprintf("%s@v1.0.0/%s", module, name)) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(contents)); err != nil { + t.Fatal(err) + } + } + if err := z.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +}