diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a359463..ab79e6d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -290,7 +290,7 @@ path, build, and delegation responsibilities. Commit the stage. ## Stage 4: exercise real publication against disposable Git repositories -**Status:** pending. **Prerequisite:** Stage 3 and prepared dependency caches. +**Status:** complete. **Prerequisite:** Stage 3 and prepared dependency caches. **Files:** `scripts/release_integration_test.go`, supporting fixture helpers in that tests-only package if needed, and stage evidence in this plan. @@ -348,6 +348,21 @@ Record the passed publication and preflight observations. This exercise uses no real remote, credentials, provider, binary target, or hosted page. Retain the opt-in test for future tooling changes, without placing it in `releasekit.json`. +Observed 2026-10-08: after `GOWORK=off GOTOOLCHAIN=local go mod download`, this +offline publication exercise passed: + +```sh +COMMANDKIT_RELEASE_INTEGRATION=1 GOWORK=off GOTOOLCHAIN=local GOPROXY=off GONOPROXY=none GOSUMDB=off go test -mod=readonly -count=1 ./scripts -run '^TestReleaseIntegration$' +``` + +The local publication produced an annotated +`v0.0.1` tag whose object matched the bare remote and pointed to the candidate; +remote `main` retained that commit, the candidate suite wrote one marker, the +worktree was clean, and no `dist` output appeared. A second candidate with a +mismatched expected endpoint returned status 1 with the endpoint diagnostic, +added no tag, and did not run the suite again. The exercise used only temporary +local Git repositories and the real cached module-selected publisher. + **Completion:** the actual module-selected executable and copied integration publish a source tag locally and respect the configured endpoint guard. Commit the stage. diff --git a/scripts/release_integration_test.go b/scripts/release_integration_test.go new file mode 100644 index 0000000..8857ad8 --- /dev/null +++ b/scripts/release_integration_test.go @@ -0,0 +1,339 @@ +package release_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "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, "v0.0.1") + if status != 0 { + t.Fatalf("publish 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) + assertOnlyTag(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) + } + + fixture.prepareSecondCandidate(t, ctx, git) + stdout, stderr, status = runPublisher(t, ctx, shell, fixture, env, "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) + } + assertOnlyTag(t, ctx, git, fixture, "v0.0.1") + 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 + 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) + } + 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) + 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, + 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, version string) (string, string, int) { + t.Helper() + cmd := exec.CommandContext(ctx, shell, filepath.Join(fixture.worktree, "scripts", "release"), 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 assertOnlyTag(t *testing.T, ctx context.Context, git string, fixture publicationFixture, version 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") + want := "refs/tags/" + version + 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 "# CommandKit " + 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) + } +} +`