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) } } `