diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c421da6..46ad894 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Release tooling implementation plan -Status: Stages 1–2 complete; Stages 3–6 pending. +Status: Stages 1–3 complete; Stages 4–6 pending. Follow the stages in integer order. This is one end-to-end sprint: intermediate stages may temporarily leave scripts, tests, and documentation inconsistent. @@ -152,6 +152,9 @@ module mapping; publication and delivery remain explicitly separate commands. ## Stage 3: Replace release-test scaffolding with focused wiring tests +**Completed.** The focused scripts suite passes with the subprocess fake; the +retired publication exercise and its scaffolding have been removed. + Rewrite `scripts/release_test.go` around the actual scripts and a small fake Go executable at the subprocess boundary. Remove `scripts/release_integration_test.go` and helpers with no remaining users. diff --git a/scripts/release_integration_test.go b/scripts/release_integration_test.go deleted file mode 100644 index af2b0ef..0000000 --- a/scripts/release_integration_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package release_test - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "sort" - "strings" - "testing" - "time" -) - -func TestReleaseIntegration(t *testing.T) { - if os.Getenv("COMMANDKIT_RELEASE_INTEGRATION") != "1" { - t.Skip("set COMMANDKIT_RELEASE_INTEGRATION=1 to run the local publication exercise; prepare Go module caches first") - } - if runtime.GOOS == "windows" { - if _, err := exec.LookPath("sh"); err != nil { - t.Skip("POSIX shell is unavailable") - } - } - shell := requirePOSIXShell(t) - git, err := exec.LookPath("git") - if err != nil { - t.Skip("Git is unavailable") - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - fixture := newPublicationFixture(t, ctx, git) - marker := filepath.Join(fixture.root, "suite marker") - env := fixture.releaseEnv(marker) - - candidate := gitRun(t, ctx, git, fixture.worktree, env, "rev-parse", "HEAD") - stdout, stderr, status := runPublisher(t, ctx, shell, fixture, env, "core", "v0.0.1") - if status != 0 { - t.Fatalf("publish core v0.0.1 status = %d\nstdout:\n%s\nstderr:\n%s", status, stdout, stderr) - } - if !strings.Contains(stdout, "v0.0.1") || !strings.Contains(stdout, candidate) { - t.Fatalf("publication output does not identify version and candidate %s:\n%s", candidate, stdout) - } - assertTag(t, ctx, git, fixture, "v0.0.1", candidate) - assertTags(t, ctx, git, fixture, "v0.0.1") - if got := readLines(t, marker); !equalStrings(got, []string{"candidate suite ran"}) { - t.Fatalf("candidate suite marker = %#v, want one run", got) - } - if got := gitRun(t, ctx, git, fixture.worktree, env, "status", "--porcelain"); got != "" { - t.Fatalf("candidate worktree is dirty after publication: %s", got) - } - if _, err := os.Stat(filepath.Join(fixture.worktree, "dist")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("unexpected binary delivery output at dist: %v", err) - } - if _, err := os.Stat(filepath.Join(fixture.worktree, ".releasekit", "bin", "releasekit")); err != nil { - t.Fatalf("expected ignored bootstrap executable: %v", err) - } - - nestedCandidate := gitRun(t, ctx, git, fixture.worktree, env, "rev-parse", "HEAD") - stdout, stderr, status = runPublisher(t, ctx, shell, fixture, env, "ckconfig", "v0.0.1") - if status != 0 { - t.Fatalf("publish ckconfig v0.0.1 status = %d\nstdout:\n%s\nstderr:\n%s", status, stdout, stderr) - } - nestedTag := "integrations/ckconfig/v0.0.1" - if !strings.Contains(stdout, nestedTag) || !strings.Contains(stdout, nestedCandidate) { - t.Fatalf("nested publication output does not identify tag %s and candidate %s:\n%s", nestedTag, nestedCandidate, stdout) - } - assertTag(t, ctx, git, fixture, nestedTag, nestedCandidate) - assertTags(t, ctx, git, fixture, "v0.0.1", nestedTag) - - fixture.prepareSecondCandidate(t, ctx, git) - stdout, stderr, status = runPublisher(t, ctx, shell, fixture, env, "core", "v0.0.2") - if status != 1 { - t.Fatalf("publish with mismatched endpoint status = %d, want 1\nstdout:\n%s\nstderr:\n%s", status, stdout, stderr) - } - if !strings.Contains(strings.ToLower(stderr), "unexpected publication push url") { - t.Fatalf("endpoint mismatch diagnostic not found in stderr:\n%s", stderr) - } - assertTags(t, ctx, git, fixture, "v0.0.1", nestedTag) - if got := readLines(t, marker); !equalStrings(got, []string{"candidate suite ran"}) { - t.Fatalf("candidate suite ran after endpoint preflight failure: %#v", got) - } -} - -type publicationFixture struct { - root string - worktree string - bare string - outside string - profile string - nestedProfile string - gitConfig string - gitEnv []string -} - -func newPublicationFixture(t *testing.T, ctx context.Context, git string) publicationFixture { - t.Helper() - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("could not locate test source") - } - commandkitRoot := filepath.Dir(filepath.Dir(sourceFile)) - root := t.TempDir() - worktree := filepath.Join(root, "candidate worktree") - bare := filepath.Join(root, "authoritative local.git") - outside := filepath.Join(root, "outside publisher directory") - configPath := filepath.Join(root, "isolated git config") - hooks := filepath.Join(root, "empty hooks") - for _, dir := range []string{worktree, outside, hooks} { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - } - writeFile(t, configPath, fmt.Sprintf(`[user] - name = CommandKit Release Test - email = release-test@example.invalid -[commit] - gpgsign = false -[tag] - gpgSign = false -[core] - hooksPath = %s -`, hooks), 0o600) - gitEnv := isolatedProcessEnv(map[string]string{ - "GIT_CONFIG_GLOBAL": configPath, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_TERMINAL_PROMPT": "0", - }, gitOverrideKeys...) - bareEnv := append([]string(nil), gitEnv...) - gitRun(t, ctx, git, root, bareEnv, "init", "--bare", bare) - gitRun(t, ctx, git, root, bareEnv, "--git-dir", bare, "symbolic-ref", "HEAD", "refs/heads/main") - gitRun(t, ctx, git, worktree, gitEnv, "init") - gitRun(t, ctx, git, worktree, gitEnv, "branch", "-M", "main") - - for _, name := range []string{"go.mod", "go.sum", ".gitignore"} { - copyFile(t, filepath.Join(commandkitRoot, name), filepath.Join(worktree, name), 0o644) - } - for _, name := range []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"} { - target := filepath.Join(worktree, name) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - t.Fatal(err) - } - copyFile(t, filepath.Join(commandkitRoot, name), target, 0o644) - } - script := filepath.Join(worktree, "scripts", "release") - if err := os.MkdirAll(filepath.Dir(script), 0o755); err != nil { - t.Fatal(err) - } - copyFile(t, filepath.Join(commandkitRoot, "scripts", "release"), script, 0o755) - profile := filepath.Join(worktree, "releasekit.json") - profileData, err := os.ReadFile(filepath.Join(commandkitRoot, "releasekit.json")) - if err != nil { - t.Fatal(err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(profileData, &fields); err != nil { - t.Fatal(err) - } - fields["expected_push_url"], err = json.Marshal(bare) - if err != nil { - t.Fatal(err) - } - profileData, err = json.MarshalIndent(fields, "", " ") - if err != nil { - t.Fatal(err) - } - writeFile(t, profile, string(profileData)+"\n", 0o644) - nestedModule := filepath.Join(worktree, "integrations", "ckconfig") - if err := os.MkdirAll(nestedModule, 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(nestedModule, "go.mod"), "module gitea.maximumdirect.net/eric/CommandKit/integrations/ckconfig\n\ngo 1.26.0\n", 0o644) - writeFile(t, filepath.Join(nestedModule, "fixture.go"), "package fixture\n\nfunc Ready() bool { return true }\n", 0o644) - writeFile(t, filepath.Join(nestedModule, "fixture_test.go"), "package fixture\n\nimport \"testing\"\n\nfunc TestReady(t *testing.T) { if !Ready() { t.Fatal(\"not ready\") } }\n", 0o644) - if err := os.MkdirAll(filepath.Join(nestedModule, "docs", "releases"), 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(nestedModule, "docs", "releases", "v0.0.1.md"), releaseNoteFor("CommandKit ckconfig", "v0.0.1"), 0o644) - nestedProfile := filepath.Join(nestedModule, "releasekit.json") - nestedProfileData, err := os.ReadFile(filepath.Join(commandkitRoot, "integrations", "ckconfig", "releasekit.json")) - if err != nil { - t.Fatal(err) - } - var nestedFields map[string]json.RawMessage - if err := json.Unmarshal(nestedProfileData, &nestedFields); err != nil { - t.Fatal(err) - } - nestedFields["expected_push_url"], err = json.Marshal(bare) - if err != nil { - t.Fatal(err) - } - nestedProfileData, err = json.MarshalIndent(nestedFields, "", " ") - if err != nil { - t.Fatal(err) - } - writeFile(t, nestedProfile, string(nestedProfileData)+"\n", 0o644) - writeFile(t, filepath.Join(worktree, "fixture_test.go"), fixtureSuiteTest, 0o644) - if err := os.MkdirAll(filepath.Join(worktree, "docs", "releases"), 0o755); err != nil { - t.Fatal(err) - } - writeFile(t, filepath.Join(worktree, "docs", "releases", "v0.0.1.md"), releaseNote("v0.0.1"), 0o644) - gitRun(t, ctx, git, worktree, gitEnv, "remote", "add", "origin", bare) - gitRun(t, ctx, git, worktree, gitEnv, "add", "--all") - gitRun(t, ctx, git, worktree, gitEnv, "commit", "-m", "Prepare local release candidate") - gitRun(t, ctx, git, worktree, gitEnv, "push", "origin", "HEAD:refs/heads/main") - - return publicationFixture{ - root: root, - worktree: worktree, - bare: bare, - outside: outside, - profile: profile, - nestedProfile: nestedProfile, - gitConfig: configPath, - gitEnv: gitEnv, - } -} - -func (fixture publicationFixture) releaseEnv(marker string) []string { - values := map[string]string{ - "COMMANDKIT_FIXTURE_MARKER": marker, - "GOWORK": "off", - "GOTOOLCHAIN": "local", - "GOPROXY": "off", - "GONOPROXY": "none", - "GOSUMDB": "off", - "GIT_CONFIG_GLOBAL": fixture.gitConfig, - "GIT_CONFIG_NOSYSTEM": "1", - "GIT_TERMINAL_PROMPT": "0", - } - return isolatedProcessEnv(values, gitOverrideKeys...) -} - -func (fixture publicationFixture) prepareSecondCandidate(t *testing.T, ctx context.Context, git string) { - t.Helper() - writeFile(t, filepath.Join(fixture.worktree, "docs", "releases", "v0.0.2.md"), releaseNote("v0.0.2"), 0o644) - profileData, err := os.ReadFile(fixture.profile) - if err != nil { - t.Fatal(err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(profileData, &fields); err != nil { - t.Fatal(err) - } - wrongEndpoint := filepath.Join(fixture.root, "different local destination.git") - fields["expected_push_url"], err = json.Marshal(wrongEndpoint) - if err != nil { - t.Fatal(err) - } - profileData, err = json.MarshalIndent(fields, "", " ") - if err != nil { - t.Fatal(err) - } - writeFile(t, fixture.profile, string(profileData)+"\n", 0o644) - gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "add", "--all") - gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "commit", "-m", "Prepare endpoint mismatch candidate") - gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "push", "origin", "HEAD:refs/heads/main") -} - -func runPublisher(t *testing.T, ctx context.Context, shell string, fixture publicationFixture, env []string, module, version string) (string, string, int) { - t.Helper() - cmd := exec.CommandContext(ctx, shell, filepath.Join(fixture.worktree, "scripts", "release"), module, version) - cmd.Dir = fixture.outside - cmd.Env = env - var stdout, stderr strings.Builder - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - if err == nil { - return stdout.String(), stderr.String(), 0 - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return stdout.String(), stderr.String(), exitErr.ExitCode() - } - t.Fatalf("run release launcher: %v", err) - return stdout.String(), stderr.String(), -1 -} - -func assertTag(t *testing.T, ctx context.Context, git string, fixture publicationFixture, version, candidate string) { - t.Helper() - tagRef := "refs/tags/" + version - tagType := gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "cat-file", "-t", tagRef) - if tagType != "tag" { - t.Fatalf("tag type = %q, want annotated tag", tagType) - } - tagObject := gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "rev-parse", tagRef) - tagData := gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "cat-file", "-p", tagRef) - if !strings.HasPrefix(tagData, "object "+candidate+"\n") || !strings.Contains(tagData, "type commit\n") { - t.Fatalf("annotated tag does not identify candidate %s:\n%s", candidate, tagData) - } - remoteTagObject := gitRun(t, ctx, git, fixture.root, fixture.gitEnv, "--git-dir", fixture.bare, "rev-parse", tagRef) - if remoteTagObject != tagObject { - t.Fatalf("remote tag object = %s, local tag object = %s", remoteTagObject, tagObject) - } - remoteMain := gitRun(t, ctx, git, fixture.root, fixture.gitEnv, "--git-dir", fixture.bare, "rev-parse", "refs/heads/main") - if remoteMain != candidate { - t.Fatalf("remote main = %s, want candidate %s", remoteMain, candidate) - } -} - -func assertTags(t *testing.T, ctx context.Context, git string, fixture publicationFixture, versions ...string) { - t.Helper() - local := gitRun(t, ctx, git, fixture.worktree, fixture.gitEnv, "for-each-ref", "--format=%(refname)", "refs/tags") - remote := gitRun(t, ctx, git, fixture.root, fixture.gitEnv, "--git-dir", fixture.bare, "for-each-ref", "--format=%(refname)", "refs/tags") - wantRefs := make([]string, len(versions)) - for i, version := range versions { - wantRefs[i] = "refs/tags/" + version - } - sort.Strings(wantRefs) - want := strings.Join(wantRefs, "\n") - if local != want || remote != want { - t.Fatalf("tag refs local=%q remote=%q, want only %q", local, remote, want) - } -} - -func gitRun(t *testing.T, ctx context.Context, git, dir string, env []string, args ...string) string { - t.Helper() - cmd := exec.CommandContext(ctx, git, args...) - cmd.Dir = dir - cmd.Env = env - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s in %s: %v\n%s", strings.Join(args, " "), dir, err, output) - } - return strings.TrimSuffix(string(output), "\n") -} - -var gitOverrideKeys = []string{ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_COMMON_DIR", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_CEILING_DIRECTORIES", - "GIT_PREFIX", - "GIT_CONFIG", - "GIT_CONFIG_COUNT", - "GIT_CONFIG_PARAMETERS", -} - -func isolatedProcessEnv(overrides map[string]string, cleared ...string) []string { - blocked := make(map[string]struct{}, len(overrides)+len(cleared)) - for key := range overrides { - blocked[key] = struct{}{} - } - for _, key := range cleared { - blocked[key] = struct{}{} - } - var env []string - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - if _, skip := blocked[key]; !skip { - env = append(env, entry) - } - } - for key, value := range overrides { - env = append(env, key+"="+value) - } - return env -} - -func releaseNote(version string) string { - return releaseNoteFor("CommandKit", version) -} - -func releaseNoteFor(product, version string) string { - return "# " + product + " " + version + "\n\nLocal fixture release candidate.\n" -} - -const fixtureSuiteTest = `package fixture - -import ( - "os" - "testing" -) - -func TestCandidateSuiteMarker(t *testing.T) { - marker := os.Getenv("COMMANDKIT_FIXTURE_MARKER") - if marker == "" { - t.Fatal("COMMANDKIT_FIXTURE_MARKER is empty") - } - file, err := os.OpenFile(marker, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - t.Fatal(err) - } - if _, err := file.WriteString("candidate suite ran\n"); err != nil { - file.Close() - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } -} -` diff --git a/scripts/release_test.go b/scripts/release_test.go index aed812a..905611d 100644 --- a/scripts/release_test.go +++ b/scripts/release_test.go @@ -1,506 +1,200 @@ package release_test import ( - "bufio" - "bytes" - "context" - "errors" + "encoding/json" "fmt" "os" "os/exec" "path/filepath" - "runtime" - "strconv" + "reflect" "strings" "testing" - "time" ) -const ( - releasePackage = "go.maximumdirect.net/releasekit/cmd/releasekit" - releaseVersion = "v1.2.3-rc.1+local" -) +const tool = "go.maximumdirect.net/releasekit/cmd/releasekit" -func TestReleaseLauncherArgumentHandling(t *testing.T) { - shell := requirePOSIXShell(t) - for _, test := range []struct { - name string - args []string - }{ - {name: "no arguments"}, - {name: "one argument", args: []string{"core"}}, - {name: "multiple arguments", args: []string{"core", "v1.2.3", "extra"}}, - {name: "unknown module", args: []string{"unknown", "v1.2.3"}}, - } { - t.Run(test.name, func(t *testing.T) { - fixture := newFixture(t) - result := runLauncher(t, shell, fixture, test.args, nil) +var modules = []struct{ selector, dir, profile, product string }{ + {"core", ".", "releasekit.json", "CommandKit"}, + {"ckconfig", "integrations/ckconfig", "integrations/ckconfig/releasekit.json", "CommandKit ckconfig"}, + {"ckprompt", "integrations/ckprompt", "integrations/ckprompt/releasekit.json", "CommandKit ckprompt"}, +} - if result.status != 2 { - t.Fatalf("status = %d, want 2; stderr: %q", result.status, result.stderr) +type fixture struct{ root, outside, log string } + +func newFixture(t *testing.T) fixture { + t.Helper() + base := t.TempDir() + f := fixture{filepath.Join(base, "repository with spaces"), filepath.Join(base, "outside"), filepath.Join(base, "calls")} + for _, path := range []string{"scripts/release", "scripts/releasekit", "releasekit.json", "integrations/ckconfig/releasekit.json", "integrations/ckprompt/releasekit.json"} { + data, err := os.ReadFile(filepath.Join("..", path)) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(f.root, path) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dest, data, 0755); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(f.outside, 0755); err != nil { + t.Fatal(err) + } + fake := `#!/bin/sh +printf 'called\n' >> "$CALLS" +case "$1 $2" in + 'env GOHOSTOS') echo linux ;; + 'env GOHOSTARCH') echo amd64 ;; + 'env GOFLAGS') echo '-trimpath -mod=mod' ;; + *) printf '%s\n' "$@" > "$CALLS.args" + printf '%s\n' "$PWD" "$GOOS" "$GOARCH" "$GOWORK" "$GOTOOLCHAIN" "$GOFLAGS" > "$CALLS.env" + printf 'tool stdout\n'; printf 'tool stderr\n' >&2; exit "$TOOL_STATUS" ;; +esac +` + if err := os.WriteFile(filepath.Join(base, "go"), []byte(fake), 0755); err != nil { + t.Fatal(err) + } + return f +} + +func (f fixture) run(t *testing.T, script string, status int, args ...string) (string, string, int) { + t.Helper() + cmd := exec.Command(filepath.Join(f.root, "scripts", script), args...) + cmd.Dir = f.outside + cmd.Env = append(os.Environ(), "PATH="+filepath.Dir(f.root)+string(os.PathListSeparator)+os.Getenv("PATH"), "CALLS="+f.log, fmt.Sprintf("TOOL_STATUS=%d", status), "CDPATH=/tmp", "GOOS=windows", "GOARCH=arm64", "GOWORK=auto", "GOTOOLCHAIN=auto", "GOFLAGS=-trimpath -mod=mod") + var stdout, stderr strings.Builder + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if err == nil { + return stdout.String(), stderr.String(), 0 + } + if e, ok := err.(*exec.ExitError); ok { + return stdout.String(), stderr.String(), e.ExitCode() + } + t.Fatal(err) + return "", "", -1 +} + +func lines(t *testing.T, path string) []string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") +} + +func TestProfiles(t *testing.T) { + f := newFixture(t) + for _, m := range modules { + t.Run(m.selector, func(t *testing.T) { + var p struct { + Product string `json:"product"` + Module string `json:"module_dir"` + Remote string `json:"remote"` + URL string `json:"expected_push_url"` + Validation struct { + Shared []string `json:"shared_files"` + Format bool `json:"go_format"` + Markdown map[string]any `json:"markdown"` + Distribution map[string]bool `json:"module_distribution"` + Forbidden []string `json:"forbidden_files"` + } `json:"validation"` + Delivery map[string]string `json:"delivery"` } - if result.stdout != "" { - t.Fatalf("stdout = %q, want empty", result.stdout) + b, err := os.ReadFile(filepath.Join(f.root, m.profile)) + if err != nil { + t.Fatal(err) } - if result.stderr != "usage: ./scripts/release MODULE VERSION\n" { - t.Fatalf("stderr = %q, want usage", result.stderr) + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) } - if _, err := os.Stat(fixture.goLog); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("Go invocation log exists or could not be checked: %v", err) + if p.Product != m.product || p.Module != m.dir || p.Remote != "origin" || p.URL != "ssh://git@forge.maximumdirect.net:22222/base-layer/commandkit.git" { + t.Fatalf("profile identity: %+v", p) + } + if !reflect.DeepEqual(p.Validation.Shared, []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}) || !p.Validation.Format || p.Validation.Markdown == nil || len(p.Validation.Markdown) != 0 || !reflect.DeepEqual(p.Validation.Distribution, map[string]bool{"workspace_files": true, "local_replacements": true}) || !reflect.DeepEqual(p.Validation.Forbidden, []string{".releasekit/gitea-token"}) { + t.Fatalf("validation: %+v", p.Validation) + } + if !reflect.DeepEqual(p.Delivery, map[string]string{"forge": "gitea", "base_url": "https://forge.maximumdirect.net", "owner": "base-layer", "repository": "commandkit", "token_file": ".releasekit/gitea-token"}) { + t.Fatalf("delivery: %v", p.Delivery) } }) } } -func TestReleaseLauncherDelegatesPublication(t *testing.T) { - shell := requirePOSIXShell(t) - for _, module := range []string{"core", "ckconfig", "ckprompt"} { - for _, status := range []int{0, 1, 2, 3} { - t.Run(fmt.Sprintf("%s exit status %d", module, status), func(t *testing.T) { - fixture := newFixture(t) - result := runLauncher(t, shell, fixture, []string{module, releaseVersion}, map[string]string{ - "CDPATH": "/tmp", - "GOOS": "windows", - "GOARCH": "arm64", - "GOWORK": "auto", - "GOTOOLCHAIN": "auto", - "PUBLISH_EXIT": strconv.Itoa(status), - "PUBLISH_STDOUT": "publisher stdout", - "PUBLISH_STDERR": "publisher stderr", - }) - - if result.status != status { - t.Fatalf("status = %d, want %d; stderr: %q", result.status, status, result.stderr) +func TestModuleOperations(t *testing.T) { + for _, m := range modules { + for _, op := range []string{"publish", "check", "deliver"} { + t.Run(m.selector+"/"+op, func(t *testing.T) { + f := newFixture(t) + _, _, status := f.run(t, "releasekit", 0, m.selector, op, "v1.2.3", "--option", "value with spaces") + if status != 0 { + t.Fatalf("status %d", status) } - if result.stdout != "publisher stdout\n" { - t.Fatalf("stdout = %q, want publisher output", result.stdout) + want := []string{"tool", tool, op, "--repo", f.root, "--config", filepath.Join(f.root, m.profile)} + if op == "publish" { + want = append(want, "--expected-module", m.dir) } - if result.stderr != "publisher stderr\n" { - t.Fatalf("stderr = %q, want publisher error output", result.stderr) + want = append(want, "v1.2.3", "--option", "value with spaces") + if got := lines(t, f.log+".args"); !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) } - - if err := checkPublication(readLines(t, fixture.publishLog), fixture.repoRoot, module); err != nil { - t.Fatal(err) + if got := lines(t, f.log+".env"); !reflect.DeepEqual(got, []string{f.root, "linux", "amd64", "off", "local", "-trimpath -mod=mod -mod=readonly"}) { + t.Fatalf("environment: %#v", got) } - if got := readLines(t, fixture.publishEnvLog); !equalStrings(got, []string{"GOWORK=off", "GOTOOLCHAIN=local"}) { - t.Fatalf("publisher environment = %#v, want workspace disabled and local toolchain", got) - } - assertNativeBootstrap(t, fixture) }) } } } -func TestLauncherChecksRejectContractViolations(t *testing.T) { - root := filepath.Join(t.TempDir(), "repository with spaces") - output := filepath.Join(root, ".releasekit", "bin", "releasekit") - for _, test := range []struct { - name string - change func(*goCall) - }{ - {"wrong target", func(c *goCall) { c.goarch = "wrong" }}, - {"workspace enabled", func(c *goCall) { c.gowork = "auto" }}, - {"toolchain download", func(c *goCall) { c.toolchain = "auto" }}, - {"wrong directory", func(c *goCall) { c.cwd = filepath.Dir(root) }}, - {"missing readonly", func(c *goCall) { c.args = []string{"build", "-o", output, releasePackage} }}, - {"wrong output", func(c *goCall) { c.args[3] = filepath.Join(root, "wrong") }}, - {"independent package pin", func(c *goCall) { c.args[4] += "@v0.2.0" }}, - } { - t.Run(test.name, func(t *testing.T) { - call := goCall{cwd: root, goos: "linux", goarch: "amd64", gowork: "off", toolchain: "local", - args: []string{"build", "-mod=readonly", "-o", output, releasePackage}} - test.change(&call) - if err := checkNativeBootstrap([]goCall{call}, root, "linux", "amd64"); err == nil { - t.Fatal("invalid bootstrap accepted") +func TestRawAndLegacy(t *testing.T) { + for _, args := range [][]string{{"version", "--help"}, {"publication-attempts", "--json"}, {"reconcile", "--record", "/absolute/path with spaces/attempt.json"}} { + f := newFixture(t) + _, _, status := f.run(t, "releasekit", 0, args...) + if status != 0 { + t.Fatal(status) + } + want := append([]string{"tool", tool}, args...) + if got := lines(t, f.log+".args"); !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) + } + } + f := newFixture(t) + _, _, status := f.run(t, "release", 3, "ckprompt", "v1.2.3") + if status != 3 { + t.Fatal(status) + } + want := []string{"tool", tool, "publish", "--repo", f.root, "--config", filepath.Join(f.root, modules[2].profile), "--expected-module", modules[2].dir, "v1.2.3"} + if got := lines(t, f.log+".args"); !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +func TestStatusAndStreams(t *testing.T) { + for _, status := range []int{0, 1, 2, 3} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + f := newFixture(t) + out, stderr, got := f.run(t, "releasekit", status, "core", "check", "v1.2.3") + if got != status || out != "tool stdout\n" || stderr != "tool stderr\n" { + t.Fatalf("status=%d stdout=%q stderr=%q", got, out, stderr) } }) } - for _, args := range [][]string{ - {"check", "--repo", root, "--config", filepath.Join(root, "releasekit.json"), "--expected-module", ".", releaseVersion}, - {"publish", "--repo", "wrong", "--config", filepath.Join(root, "releasekit.json"), "--expected-module", ".", releaseVersion}, - {"publish", "--repo", root, "--config", "wrong", "--expected-module", ".", releaseVersion}, - {"publish", "--repo", root, "--config", filepath.Join(root, "releasekit.json"), "--expected-module", ".", "wrong-version"}, - {"publish", "--repo", root, "--config", filepath.Join(root, "releasekit.json"), releaseVersion, "--expected-module", "."}, - } { - if err := checkPublication(args, root, "core"); err == nil { - t.Errorf("invalid publication accepted: %q", args) +} + +func TestInvalidForms(t *testing.T) { + for _, tc := range []struct { + script string + args []string + }{{"releasekit", nil}, {"releasekit", []string{"unknown"}}, {"releasekit", []string{"core"}}, {"releasekit", []string{"core", "version"}}, {"release", nil}, {"release", []string{"core"}}, {"release", []string{"core", "v1", "extra"}}, {"release", []string{"unknown", "v1"}}} { + f := newFixture(t) + _, stderr, status := f.run(t, tc.script, 0, tc.args...) + if status != 2 || !strings.Contains(stderr, "usage:") { + t.Fatalf("%s %v: status=%d stderr=%q", tc.script, tc.args, status, stderr) + } + if _, err := os.Stat(f.log); !os.IsNotExist(err) { + t.Fatalf("Go ran or log inaccessible: %v", err) } } } - -func TestReleaseLauncherStopsAfterBootstrapFailure(t *testing.T) { - shell := requirePOSIXShell(t) - fixture := newFixture(t) - result := runLauncher(t, shell, fixture, []string{"core", releaseVersion}, map[string]string{ - "FAKE_GO_BUILD_EXIT": "23", - }) - - if result.status != 23 { - t.Fatalf("status = %d, want build status 23; stderr: %q", result.status, result.stderr) - } - if _, err := os.Stat(fixture.publishLog); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("publisher was invoked or its log could not be checked: %v", err) - } - if !hasGoBuildCall(t, fixture) { - t.Fatal("Go build invocation was not recorded") - } -} - -type launcherResult struct { - status int - stdout string - stderr string -} - -type fixture struct { - repoRoot string - launcher string - outside string - fakeBin string - goLog string - publishLog string - publishEnvLog string - publisher string -} - -func newFixture(t *testing.T) fixture { - t.Helper() - if runtime.GOOS == "windows" { - if _, err := exec.LookPath("sh"); err != nil { - t.Skip("POSIX shell is unavailable") - } - } - - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("could not locate test source") - } - repo := filepath.Dir(filepath.Dir(sourceFile)) - root := t.TempDir() - repoRoot := filepath.Join(root, "CommandKit fixture with spaces") - outside := filepath.Join(root, "outside working directory") - fakeBin := filepath.Join(root, "controlled tools") - for _, dir := range []string{filepath.Join(repoRoot, "scripts"), outside, fakeBin} { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - } - copyFile(t, filepath.Join(repo, "scripts", "release"), filepath.Join(repoRoot, "scripts", "release"), 0o755) - for _, profile := range []string{ - "releasekit.json", - "integrations/ckconfig/releasekit.json", - "integrations/ckprompt/releasekit.json", - } { - target := filepath.Join(repoRoot, profile) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - t.Fatal(err) - } - copyFile(t, filepath.Join(repo, profile), target, 0o644) - } - - goLog := filepath.Join(root, "go calls.log") - publishLog := filepath.Join(root, "publisher arguments.log") - publishEnvLog := filepath.Join(root, "publisher environment.log") - publisher := filepath.Join(root, "fake publisher") - writeFile(t, publisher, fakePublisher, 0o644) - writeFile(t, filepath.Join(fakeBin, "go"), fakeGo, 0o755) - - return fixture{ - repoRoot: repoRoot, - launcher: filepath.Join(repoRoot, "scripts", "release"), - outside: outside, - fakeBin: fakeBin, - goLog: goLog, - publishLog: publishLog, - publishEnvLog: publishEnvLog, - publisher: publisher, - } -} - -func runLauncher(t *testing.T, shell string, fixture fixture, args []string, overrides map[string]string) launcherResult { - t.Helper() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, shell, append([]string{fixture.launcher}, args...)...) - cmd.Dir = fixture.outside - cmd.Env = fixtureEnvironment(fixture, overrides) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - err := cmd.Run() - - result := launcherResult{stdout: stdout.String(), stderr: stderr.String()} - if err == nil { - return result - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - result.status = exitErr.ExitCode() - return result - } - t.Fatalf("run launcher: %v", err) - return launcherResult{} -} - -func fixtureEnvironment(fixture fixture, overrides map[string]string) []string { - values := map[string]string{ - "PATH": fixture.fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"), - "GO_LOG": fixture.goLog, - "PUBLISH_ARGS_LOG": fixture.publishLog, - "PUBLISH_ENV_LOG": fixture.publishEnvLog, - "FAKE_GOHOSTOS": "linux", - "FAKE_GOHOSTARCH": "amd64", - "FAKE_PUBLISHER_SOURCE": fixture.publisher, - "FAKE_GO_BUILD_EXIT": "0", - "PUBLISH_EXIT": "0", - "PUBLISH_STDOUT": "publisher stdout", - "PUBLISH_STDERR": "publisher stderr", - } - for key, value := range overrides { - values[key] = value - } - var env []string - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - if _, overridden := values[key]; !overridden { - env = append(env, entry) - } - } - for key, value := range values { - env = append(env, key+"="+value) - } - return env -} - -func assertNativeBootstrap(t *testing.T, fixture fixture) { - t.Helper() - if err := checkNativeBootstrap(readGoCalls(t, fixture.goLog), fixture.repoRoot, "linux", "amd64"); err != nil { - t.Fatal(err) - } -} - -func checkNativeBootstrap(calls []goCall, root, hostOS, hostArch string) error { - foundBuild := false - for _, call := range calls { - if call.cwd != root { - return fmt.Errorf("Go ran in %q, want repository root %q", call.cwd, root) - } - if call.gowork != "off" || call.toolchain != "local" { - return fmt.Errorf("Go settings = GOWORK=%q GOTOOLCHAIN=%q", call.gowork, call.toolchain) - } - if len(call.args) > 0 && call.args[0] == "build" { - foundBuild = true - if call.args[len(call.args)-1] != releasePackage { - return fmt.Errorf("build does not select module-owned package %q: %q", releasePackage, call.args) - } - if argumentValue(call.args[1:], "-mod") != "readonly" { - return fmt.Errorf("build does not use readonly module metadata: %q", call.args) - } - output := argumentValue(call.args[1:], "-o") - if !filepath.IsAbs(output) { - output = filepath.Join(call.cwd, output) - } - if filepath.Clean(output) != filepath.Join(root, ".releasekit", "bin", "releasekit") { - return fmt.Errorf("incorrect build output %q", output) - } - if call.goos != hostOS || call.goarch != hostArch { - return fmt.Errorf("build target = %s/%s, want %s/%s", call.goos, call.goarch, hostOS, hostArch) - } - } - } - if !foundBuild { - return errors.New("no native build observed") - } - return nil -} - -// Inspect only the selected build options, leaving other Go flags to Go itself. -func argumentValue(args []string, name string) string { - value := "" - for i := 0; i < len(args); i++ { - if args[i] == name && i+1 < len(args) { - i++ - value = args[i] - } else if supplied, ok := strings.CutPrefix(args[i], name+"="); ok { - value = supplied - } - } - return value -} - -func checkPublication(args []string, root, selector string) error { - modules := map[string]string{ - "core": ".", - "ckconfig": "integrations/ckconfig", - "ckprompt": "integrations/ckprompt", - } - module, ok := modules[selector] - if !ok { - return fmt.Errorf("unknown module selector %q", selector) - } - profile := map[string]string{ - "core": "releasekit.json", - "ckconfig": "integrations/ckconfig/releasekit.json", - "ckprompt": "integrations/ckprompt/releasekit.json", - }[selector] - want := []string{ - "publish", - "--repo", root, - "--config", filepath.Join(root, profile), - "--expected-module", module, - releaseVersion, - } - if !equalStrings(args, want) { - return fmt.Errorf("publication arguments = %q, want %q", args, want) - } - return nil -} - -type goCall struct { - cwd string - goos string - goarch string - gowork string - toolchain string - args []string -} - -func readGoCalls(t *testing.T, path string) []goCall { - t.Helper() - file, err := os.Open(path) - if err != nil { - t.Fatal(err) - } - defer file.Close() - - var calls []goCall - scanner := bufio.NewScanner(file) - for scanner.Scan() { - fields := strings.Split(scanner.Text(), "\t") - if len(fields) < 6 { - t.Fatalf("malformed Go call record: %q", scanner.Text()) - } - argc, err := strconv.Atoi(fields[5]) - if err != nil || len(fields) != 6+argc { - t.Fatalf("malformed Go arguments in record: %q", scanner.Text()) - } - calls = append(calls, goCall{ - cwd: fields[0], - goos: fields[1], - goarch: fields[2], - gowork: fields[3], - toolchain: fields[4], - args: fields[6:], - }) - } - if err := scanner.Err(); err != nil { - t.Fatal(err) - } - return calls -} - -func hasGoBuildCall(t *testing.T, fixture fixture) bool { - t.Helper() - for _, call := range readGoCalls(t, fixture.goLog) { - if len(call.args) > 0 && call.args[0] == "build" { - return true - } - } - return false -} - -func readLines(t *testing.T, path string) []string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") -} - -func copyFile(t *testing.T, source, destination string, mode os.FileMode) { - t.Helper() - data, err := os.ReadFile(source) - if err != nil { - t.Fatal(err) - } - writeFile(t, destination, string(data), mode) -} - -func writeFile(t *testing.T, path, data string, mode os.FileMode) { - t.Helper() - if err := os.WriteFile(path, []byte(data), mode); err != nil { - t.Fatal(err) - } -} - -func requirePOSIXShell(t *testing.T) string { - t.Helper() - shell, err := exec.LookPath("sh") - if err != nil { - t.Skip("POSIX shell is unavailable") - } - return shell -} - -func equalStrings(got, want []string) bool { - if len(got) != len(want) { - return false - } - for i := range got { - if got[i] != want[i] { - return false - } - } - return true -} - -const fakeGo = `#!/bin/sh -set -eu -printf '%s\t%s\t%s\t%s\t%s\t%s' "$PWD" "${GOOS-}" "${GOARCH-}" "${GOWORK-}" "${GOTOOLCHAIN-}" "$#" >> "$GO_LOG" -for arg do printf '\t%s' "$arg" >> "$GO_LOG"; done -printf '\n' >> "$GO_LOG" - -if [ "$1" = env ]; then - shift - for name do - case "$name" in - GOHOSTOS) printf '%s\n' "$FAKE_GOHOSTOS" ;; - GOHOSTARCH) printf '%s\n' "$FAKE_GOHOSTARCH" ;; - *) exit 90 ;; - esac - done - exit 0 -fi - -if [ "$1" = build ]; then - if [ "$FAKE_GO_BUILD_EXIT" -ne 0 ]; then exit "$FAKE_GO_BUILD_EXIT"; fi - output= - expect_output=0 - for arg do - if [ "$expect_output" -eq 1 ]; then - output=$arg - expect_output=0 - continue - fi - if [ "$arg" = -o ]; then expect_output=1; fi - case "$arg" in -o=*) output=${arg#-o=} ;; esac - done - [ -n "$output" ] || exit 91 - cat "$FAKE_PUBLISHER_SOURCE" > "$output" - chmod +x "$output" - exit 0 -fi - -exit 92 -` - -const fakePublisher = `#!/bin/sh -set -eu -printf '%s\n' "$@" > "$PUBLISH_ARGS_LOG" -printf 'GOWORK=%s\nGOTOOLCHAIN=%s\n' "${GOWORK-}" "${GOTOOLCHAIN-}" > "$PUBLISH_ENV_LOG" -printf '%s\n' "$PUBLISH_STDOUT" -printf '%s\n' "$PUBLISH_STDERR" >&2 -exit "$PUBLISH_EXIT" -`