Exercise local source publication

This commit is contained in:
2026-10-08 16:44:08 +00:00
parent f9ec143130
commit d3f0891225
2 changed files with 355 additions and 1 deletions

View File

@@ -290,7 +290,7 @@ path, build, and delegation responsibilities. Commit the stage.
## Stage 4: exercise real publication against disposable Git repositories ## 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 **Files:** `scripts/release_integration_test.go`, supporting fixture helpers in
that tests-only package if needed, and stage evidence in this plan. 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 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`. 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 **Completion:** the actual module-selected executable and copied integration
publish a source tag locally and respect the configured endpoint guard. Commit publish a source tag locally and respect the configured endpoint guard. Commit
the stage. the stage.

View File

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