diff --git a/README.md b/README.md index b4fc2b0..e9d9e54 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,10 @@ ReleaseKit is an opinionated Go library and executable for a shared release process. The Go module, root `releasekit` facade, `buildinfo` runtime package, -local candidate check, source-tag publication API, and `version`, `check`, and -`publish` commands are implemented. ReleaseKit's source-only maintainer -procedure is documented. Executable builds and delivery remain future work. -The [product roadmap](docs/roadmap/releasekit.md) and +local candidate check, source-tag publication API, selected-target build API, +and `version`, `check`, and `publish` commands are implemented. ReleaseKit's +source-only maintainer procedure is documented. The build command and delivery +remain future work. The [product roadmap](docs/roadmap/releasekit.md) and [implementation plan](docs/roadmap/implementation.md) describe their selected behavior and sequence. @@ -48,6 +48,8 @@ failure recovery. - [Configuration](docs/config.md): profile fields and child-command settings. - [Runtime version guidance](docs/consumers/runtime-version.md): how applications read their embedded version through `buildinfo`. +- [Build guidance](docs/consumers/build.md): compile selected Go targets with an + injected application version. - [Candidate check guidance](docs/consumers/check.md): local candidate validation through the Go API and CLI. - [Maintainer release procedure](docs/release.md): ReleaseKit's source-only diff --git a/build.go b/build.go new file mode 100644 index 0000000..7a6027e --- /dev/null +++ b/build.go @@ -0,0 +1,97 @@ +package releasekit + +import ( + "context" + "io" + + internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" +) + +// Target identifies one explicit Go operating-system and architecture pair. +type Target struct { + OS string // Go operating system name, such as "linux". + Arch string // Go architecture name, such as "amd64". +} + +// BuildSpec selects the application package, output name, and target pairs. +type BuildSpec struct { + MainPackage string // Go package path to build, such as "./cmd/app". + BinaryName string // filename component used in output artifacts. + Targets []Target // selected target pairs; duplicates are removed in order. +} + +// BuildRequest contains the source and output settings for Build. +type BuildRequest struct { + RepositoryDir string // source repository whose current HEAD is recorded. + Version string // stable vMAJOR.MINOR.PATCH value injected at link time. + Spec BuildSpec // application package, artifact name, and targets. + OutputDir string // output location; empty selects /dist. + Output io.Writer // optional build progress destination. +} + +// BuildArtifact identifies one generated binary and its SHA-256 digest. +type BuildArtifact struct { + Path string // absolute path to the generated binary. + Filename string // generated filename. + Target Target // Go target used for this artifact. + SHA256 string // lowercase hexadecimal digest of the complete file. +} + +// BuildResult describes the source and artifacts reached by Build. +type BuildResult struct { + RepositoryDir string // absolute source repository path. + CandidateCommit string // Git HEAD observed when the build started. + Version string // requested injected version. + MainPackage string // Go package path built. + BinaryName string // artifact name prefix. + OutputDir string // resolved output directory. + Targets []Target // normalized target pairs. + Artifacts []BuildArtifact // completed output files, including partial success. + Compiler string // output of the selected local Go toolchain's version command. +} + +// Build compiles the selected targets with Version injected through buildinfo. +// It uses the prepared local Go environment, preserves existing output files, +// and verifies the version command on the native host before succeeding. An +// empty target set records source identity without running the Go toolchain. +// Errors return partial build facts in the result. +func Build(ctx context.Context, request BuildRequest) (BuildResult, error) { + targets := make([]internalbuild.Target, 0, len(request.Spec.Targets)) + for _, target := range request.Spec.Targets { + targets = append(targets, internalbuild.Target{OS: target.OS, Arch: target.Arch}) + } + result, err := internalbuild.Build(ctx, internalbuild.Request{ + RepositoryDir: request.RepositoryDir, + Version: request.Version, + Spec: internalbuild.Spec{ + MainPackage: request.Spec.MainPackage, + BinaryName: request.Spec.BinaryName, + Targets: targets, + }, + OutputDir: request.OutputDir, + Output: request.Output, + }) + public := BuildResult{ + RepositoryDir: result.RepositoryDir, + CandidateCommit: result.CandidateCommit, + Version: result.Version, + MainPackage: result.MainPackage, + BinaryName: result.BinaryName, + OutputDir: result.OutputDir, + Compiler: result.Compiler, + Targets: make([]Target, 0, len(result.Targets)), + Artifacts: make([]BuildArtifact, 0, len(result.Artifacts)), + } + for _, target := range result.Targets { + public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch}) + } + for _, artifact := range result.Artifacts { + public.Artifacts = append(public.Artifacts, BuildArtifact{ + Path: artifact.Path, + Filename: artifact.Filename, + Target: Target{OS: artifact.Target.OS, Arch: artifact.Target.Arch}, + SHA256: artifact.SHA256, + }) + } + return public, err +} diff --git a/build_test.go b/build_test.go new file mode 100644 index 0000000..29bbf00 --- /dev/null +++ b/build_test.go @@ -0,0 +1,377 @@ +package releasekit_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + releasekit "gitea.maximumdirect.net/eric/ReleaseKit" +) + +func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) { + repository := newBuildConsumer(t, `package main + +import ( + "fmt" + "os" + + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == "version" { + fmt.Printf("fixture %s\n", buildinfo.Version()) + } +} +`) + outputDir := filepath.Join(t.TempDir(), "artifacts") + t.Setenv("GOOS", "windows") + t.Setenv("GOARCH", "arm64") + t.Setenv("GOFLAGS", "-race") + var progress bytes.Buffer + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + {OS: "darwin", Arch: "arm64"}, + {OS: "linux", Arch: "amd64"}, + }, + }, + OutputDir: outputDir, + Output: &progress, + }) + if err != nil { + t.Fatalf("Build() error = %v; partial result = %#v", err, result) + } + if result.CandidateCommit == "" || result.CandidateCommit != buildGit(t, repository, "rev-parse", "HEAD") { + t.Fatalf("candidate commit = %q", result.CandidateCommit) + } + if result.Compiler == "" || len(result.Targets) != 3 || len(result.Artifacts) != 3 { + t.Fatalf("build result = %#v", result) + } + wantNames := []string{ + "fixture-v1.2.3-linux-amd64", + "fixture-v1.2.3-linux-arm64", + "fixture-v1.2.3-darwin-arm64", + } + for index, artifact := range result.Artifacts { + if artifact.Filename != wantNames[index] || artifact.Target != result.Targets[index] { + t.Fatalf("artifact %d = %#v", index, artifact) + } + contents, err := os.ReadFile(artifact.Path) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(contents) + if artifact.SHA256 != hex.EncodeToString(digest[:]) { + t.Fatalf("artifact digest = %q, want %x", artifact.SHA256, digest) + } + } + if !strings.Contains(progress.String(), "checking fixture version output") { + t.Fatalf("build progress = %q", progress.String()) + } + for _, artifact := range result.Artifacts { + if artifact.Target != (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { + continue + } + command := exec.Command(artifact.Path, "version") + output, err := command.CombinedOutput() + if err != nil || string(output) != "fixture v1.2.3\n" { + t.Fatalf("native version command output = %q, error = %v", output, err) + } + return + } +} + +func TestBuildUsesGoHostDespiteAmbientTargetEnvironment(t *testing.T) { + repository := newBuildConsumer(t, `package main + +import ( + "fmt" + "os" + "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" +) + +func main() { + if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixture %s\n", buildinfo.Version()) } +} +`) + t.Setenv("GOOS", "windows") + t.Setenv("GOARCH", "arm64") + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v2.3.4", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: "windows", Arch: "arm64"}}, + }, + OutputDir: filepath.Join(t.TempDir(), "artifacts"), + }) + if err != nil { + t.Fatalf("Build() error = %v; partial result = %#v", err, result) + } + if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v2.3.4-windows-arm64.exe" { + t.Fatalf("build artifacts = %#v", result.Artifacts) + } +} + +func TestBuildWithNoTargetsDoesNotInvokeCompiler(t *testing.T) { + repository := newBuildConsumer(t, `package main +func main() {} +`) + outputDir := filepath.Join(t.TempDir(), "must-not-be-created") + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + OutputDir: outputDir, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if result.CandidateCommit == "" || result.Compiler != "" || len(result.Targets) != 0 || len(result.Artifacts) != 0 { + t.Fatalf("empty build result = %#v", result) + } + if _, err := os.Stat(outputDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("empty build created output directory: %v", err) + } +} + +func TestBuildRejectsUnsafeNameAndPreservesExistingOutput(t *testing.T) { + repository := newBuildConsumer(t, "package main\nfunc main() {}\n") + outputDir := filepath.Join(t.TempDir(), "artifacts") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + ownedPath := filepath.Join(outputDir, "fixture-v1.2.3-linux-amd64") + if err := os.WriteFile(ownedPath, []byte("keep this file"), 0o600); err != nil { + t.Fatal(err) + } + request := releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}, + }, + OutputDir: outputDir, + } + unsafeRequest := request + unsafeRequest.Spec.BinaryName = "../unsafe" + if result, err := releasekit.Build(context.Background(), unsafeRequest); err == nil || !strings.Contains(err.Error(), "unsafe binary name") { + t.Fatalf("Build() result = %#v, error = %v; expected unsafe-name failure", result, err) + } + request.Spec.BinaryName = "fixture" + if _, err := releasekit.Build(context.Background(), request); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("Build() error = %v, want existing-output failure", err) + } + contents, err := os.ReadFile(ownedPath) + if err != nil || string(contents) != "keep this file" { + t.Fatalf("caller output = %q, error = %v", contents, err) + } +} + +func TestBuildReturnsPartialArtifactsAfterTargetFailure(t *testing.T) { + repository := newBuildConsumer(t, "package main\nfunc main() {}\n") + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{ + {OS: runtime.GOOS, Arch: runtime.GOARCH}, + {OS: "invalid-os", Arch: "invalid-arch"}, + }, + }, + OutputDir: filepath.Join(t.TempDir(), "artifacts"), + }) + if err == nil || len(result.Artifacts) != 1 { + t.Fatalf("Build() result = %#v, error = %v; want one retained artifact", result, err) + } + if _, err := os.Stat(result.Artifacts[0].Path); err != nil { + t.Fatalf("partial artifact was not retained: %v", err) + } +} + +func TestBuildReportsMissingMainPackage(t *testing.T) { + repository := newBuildConsumer(t, "package main\nfunc main() {}\n") + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/missing", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + OutputDir: filepath.Join(t.TempDir(), "artifacts"), + }) + if err == nil || !strings.Contains(err.Error(), "cmd/missing") || len(result.Artifacts) != 0 { + t.Fatalf("Build() result = %#v, error = %v; want missing-package failure", result, err) + } +} + +func TestBuildReportsIncorrectVersionOutputAndRetainsBinary(t *testing.T) { + repository := newBuildConsumer(t, `package main +import ( + "fmt" + "os" +) +func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("wrong") } } +`) + outputDir := filepath.Join(t.TempDir(), "artifacts") + result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, + }, + OutputDir: outputDir, + }) + if err == nil || !strings.Contains(err.Error(), "native version output") || len(result.Artifacts) != 1 { + t.Fatalf("Build() result = %#v, error = %v; want retained-binary smoke failure", result, err) + } + if _, err := os.Stat(result.Artifacts[0].Path); err != nil { + t.Fatalf("built artifact was not retained after smoke failure: %v", err) + } +} + +func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) { + repository := newBuildConsumer(t, "package main\nfunc main() {}\n") + realGo, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + wrapperDir := t.TempDir() + wrapper := filepath.Join(wrapperDir, "go") + callCount := filepath.Join(wrapperDir, "build-count") + blocked := filepath.Join(wrapperDir, "second-build-started") + contents := "#!/bin/sh\n" + + "for argument do\n" + + " if [ \"$argument\" = build ]; then\n" + + " count=0\n" + + " if [ -f \"$RELEASEKIT_BUILD_COUNT\" ]; then count=$(cat \"$RELEASEKIT_BUILD_COUNT\"); fi\n" + + " count=$((count + 1))\n" + + " printf '%s\\n' \"$count\" > \"$RELEASEKIT_BUILD_COUNT\"\n" + + " if [ \"$count\" -eq 2 ]; then : > \"$RELEASEKIT_BUILD_BLOCKED\"; exec sleep 30; fi\n" + + " fi\n" + + "done\n" + + "exec \"$RELEASEKIT_BUILD_REAL_GO\" \"$@\"\n" + if err := os.WriteFile(wrapper, []byte(contents), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", wrapperDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("RELEASEKIT_BUILD_COUNT", callCount) + t.Setenv("RELEASEKIT_BUILD_BLOCKED", blocked) + t.Setenv("RELEASEKIT_BUILD_REAL_GO", realGo) + secondTarget := releasekit.Target{OS: "darwin", Arch: "arm64"} + if secondTarget == (releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}) { + secondTarget = releasekit.Target{OS: "linux", Arch: "amd64"} + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct { + result releasekit.BuildResult + err error + }, 1) + go func() { + result, err := releasekit.Build(ctx, releasekit.BuildRequest{ + RepositoryDir: repository, + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/app", + BinaryName: "fixture", + Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}, secondTarget}, + }, + OutputDir: filepath.Join(t.TempDir(), "artifacts"), + }) + done <- struct { + result releasekit.BuildResult + err error + }{result: result, err: err} + }() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + deadline := time.NewTimer(30 * time.Second) + defer deadline.Stop() + for { + if _, err := os.Stat(blocked); err == nil { + cancel() + break + } + select { + case outcome := <-done: + cancel() + t.Fatalf("Build() ended before cancellation: result=%#v error=%v", outcome.result, outcome.err) + case <-deadline.C: + cancel() + outcome := <-done + t.Fatalf("second build did not start: result=%#v error=%v", outcome.result, outcome.err) + case <-ticker.C: + } + } + outcome := <-done + if !errors.Is(outcome.err, context.Canceled) || len(outcome.result.Artifacts) != 1 { + t.Fatalf("canceled build result=%#v error=%v; want one retained artifact", outcome.result, outcome.err) + } +} + +func newBuildConsumer(t *testing.T, mainSource string) string { + t.Helper() + repository, err := os.MkdirTemp("", "releasekit-build-consumer-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(repository) }) + root, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) + writeBuildFixture(t, repository, "go.mod", module) + writeBuildFixture(t, repository, "cmd/app/main.go", mainSource) + buildGit(t, repository, "init", "--initial-branch=main") + buildGit(t, repository, "config", "user.name", "ReleaseKit Build Test") + buildGit(t, repository, "config", "user.email", "releasekit-build@example.test") + buildGit(t, repository, "add", "--all") + buildGit(t, repository, "commit", "-m", "build fixture") + return repository +} + +func writeBuildFixture(t *testing.T, directory, name, contents string) { + t.Helper() + path := filepath.Join(directory, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + +func buildGit(t *testing.T, directory string, args ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", directory}, args...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) +} diff --git a/docs/consumers/build.md b/docs/consumers/build.md new file mode 100644 index 0000000..8d7a560 --- /dev/null +++ b/docs/consumers/build.md @@ -0,0 +1,62 @@ +# Build versioned application binaries + +Use `releasekit.Build` when an application needs binaries for explicit Go +operating-system and architecture pairs: + +```go +import ( + "context" + "fmt" + + releasekit "gitea.maximumdirect.net/eric/ReleaseKit" +) + +func build(ctx context.Context) error { + result, err := releasekit.Build(ctx, releasekit.BuildRequest{ + RepositoryDir: "/work/project", + Version: "v1.2.3", + Spec: releasekit.BuildSpec{ + MainPackage: "./cmd/project", + BinaryName: "project", + Targets: []releasekit.Target{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + {OS: "darwin", Arch: "arm64"}, + }, + }, + OutputDir: "dist", + }) + if err != nil { + return err // result retains completed artifacts when a later step fails. + } + for _, artifact := range result.Artifacts { + fmt.Println(artifact.Filename, artifact.SHA256) + } + return nil +} +``` + +`Build` records the repository's current `HEAD`, injects the selected stable +version into `buildinfo.Version`, and returns the compiler identity, normalized +targets, artifact paths, filenames, and SHA-256 digests. Names follow +`---`; Windows targets add `.exe`. A repeated +target pair is built once, at its first position. + +The default output directory is `/dist`; a relative `OutputDir` is +resolved from the repository. Existing files with a selected artifact name are +preserved and cause an error. Other files in the output directory are left in +place. A failed later build or native version check returns the binaries already +generated in `BuildResult.Artifacts`. + +Builds use the prepared local Go toolchain, readonly module mode, disabled +module network access, `CGO_ENABLED=0`, `-trimpath`, and `-buildvcs=false`. Each +selected target is compiled explicitly. ReleaseKit then runs the `version` +command on a native binary and requires ` ` followed by a +newline. If no selected target matches the Go host, it builds a temporary +native binary for this check and removes it afterward. Cross-compilation does +not promise that a binary runs on another platform. + +An empty target list records the source commit and returns without invoking Go, +creating the output directory, or running a version check. It does not require a +main package or binary name. This supports source-only operations without +silently adding a binary check. diff --git a/docs/consumers/runtime-version.md b/docs/consumers/runtime-version.md index cde4bf7..e2ddecd 100644 --- a/docs/consumers/runtime-version.md +++ b/docs/consumers/runtime-version.md @@ -13,4 +13,5 @@ version := buildinfo.Version() Otherwise it returns the executable's main module version when that metadata is available and is not `(devel)`. It returns `dev` when neither source provides a release identifier. Applications should use this accessor for version output; -the runtime implementation and ReleaseKit's builder own the injection detail. +the runtime implementation and [ReleaseKit's build API](build.md) own the +injection detail. diff --git a/docs/development.md b/docs/development.md index ae13597..90caeb7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,8 +49,8 @@ root with: go run ./internal/apicheck ``` -See the [candidate check guide](consumers/check.md), [internal package -inventory](internal/overview.md), and [implementation plan](roadmap/implementation.md) +See the [candidate check guide](consumers/check.md), [build consumer guide](consumers/build.md), +[internal package inventory](internal/overview.md), and [implementation plan](roadmap/implementation.md) for current implementation progress. For prose changes, review the diff and check local links, fragment destinations, diff --git a/docs/internal/overview.md b/docs/internal/overview.md index da6c851..74aedc6 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -8,12 +8,14 @@ Implemented internal packages: - `internal/runtimeinfo` holds the injected version value and selects the executable's main-module version when no injected value is present. -- `internal/versioncontract` names the linker symbol shared with the future - release builder. +- `internal/versioncontract` names the linker symbol and stable version form + shared with the build and candidate operations. - `internal/apicheck` checks compiled exports with `go/types` and checks import direction. Run it with `go run ./internal/apicheck` from the repository root. - `internal/process` runs cancellable child commands with per-child environment overlays. +- `internal/build` compiles explicit Go targets with shared version injection, + verifies native version output, and returns generated file digests. - `internal/candidate` captures and validates one clean local candidate, checks its committed release note and local tag state, runs validation once, and rechecks mutable repository state. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index af07427..6a00734 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,7 +1,6 @@ # ReleaseKit staged implementation plan -Status: Proposed. All stages are pending; this document introduces no implemented -features or published contracts. +Status: In progress. Stages 1–10 are complete; later stages remain pending. Date: 2026-10-05 @@ -683,7 +682,7 @@ ReleaseKit release was published. ## Stage 10: Build selected targets with shared version injection -Status: Pending. Depends on Stage 9. +Status: Complete. Depends on Stage 9. 1. Implement public build values and internal build execution using the fixed flags, version symbol, filename convention, and prepared Go environment. @@ -702,6 +701,17 @@ does not select the smoke host. Empty targets perform no build or smoke test; missing main packages, build failures, incorrect version output, unsafe binary names, and cancellation fail with useful partial results. +Evidence: `releasekit.Build` and `internal/build` compile Linux amd64, Linux +arm64, and macOS arm64 artifacts using the shared linker symbol and prepared +local Go environment. The consumer fixture imports `buildinfo` and reports the +injected version through its `version` invocation. Results retain the observed +source commit, compiler identity, normalized targets, absolute paths, filenames, +and SHA-256 digests. Tests cover host selection despite ambient cross-target +variables, empty target sets, existing output preservation, partial target +failure, missing packages, incorrect version output, unsafe names, and +cancellation with completed artifacts retained. No byte-reproducibility or +cross-platform execution guarantee is added. + ## Stage 11: Integrate candidate builds and artifact command Status: Pending. Depends on Stage 10. diff --git a/docs/roadmap/releasekit.md b/docs/roadmap/releasekit.md index 74d7cd7..2eb0f34 100644 --- a/docs/roadmap/releasekit.md +++ b/docs/roadmap/releasekit.md @@ -2,8 +2,8 @@ This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, Narratio, and Scriptorium on 2026-09-29. The implementation plan records the -current implemented boundary; builds, delivery, and broader integrations remain -proposed. The public facade architecture is accepted in +current implemented boundary; CLI build integration, delivery, and broader +integrations remain planned. The public facade architecture is accepted in [ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, candidate validation, and source publication. The [implementation plan](implementation.md) owns ordered stages, execution @@ -55,8 +55,9 @@ cleanup. The [architecture policy](../policy/architecture.md) owns the adopted responsibility and dependency rules, including the runtime version boundary. The root `releasekit` package provides the public facade for candidate -validation and source publication; builds and delivery remain planned. The -separate public `buildinfo` package provides lightweight runtime version access. +validation, source publication, and selected-target builds; CLI build +integration and delivery remain planned. The separate public `buildinfo` +package provides lightweight runtime version access. Each facade owns its public named types and documented contracts. Public operations, inputs, outcomes, errors, and callbacks express deliberately diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..50d29ab --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,332 @@ +// Package build compiles versioned application binaries for explicit targets. +package build + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" +) + +type Target struct { + OS string + Arch string +} + +type Spec struct { + MainPackage string + BinaryName string + Targets []Target +} + +type Request struct { + RepositoryDir string + Version string + Spec Spec + OutputDir string + Output io.Writer +} + +type Artifact struct { + Path string + Filename string + Target Target + SHA256 string +} + +type Result struct { + RepositoryDir string + CandidateCommit string + Version string + MainPackage string + BinaryName string + OutputDir string + Targets []Target + Artifacts []Artifact + Compiler string +} + +func Build(ctx context.Context, request Request) (Result, error) { + result := Result{ + Version: request.Version, + MainPackage: request.Spec.MainPackage, + BinaryName: request.Spec.BinaryName, + Targets: normalizeTargets(request.Spec.Targets), + } + if !versioncontract.ValidStableVersion(request.Version) { + return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version) + } + if request.RepositoryDir == "" { + return result, errors.New("build requires a repository directory") + } + if len(result.Targets) != 0 { + if request.Spec.MainPackage == "" { + return result, errors.New("build requires a main package when targets are selected") + } + if !safeComponent(request.Spec.BinaryName) { + return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName) + } + for _, target := range result.Targets { + if !safeComponent(target.OS) || !safeComponent(target.Arch) { + return result, fmt.Errorf("unsafe target %q/%q", target.OS, target.Arch) + } + } + } + if err := ctx.Err(); err != nil { + return result, fmt.Errorf("build canceled: %w", err) + } + repositoryDir, err := filepath.Abs(request.RepositoryDir) + if err != nil { + return result, fmt.Errorf("resolve repository directory: %w", err) + } + result.RepositoryDir = repositoryDir + commit, err := gitCommit(ctx, repositoryDir) + if err != nil { + return result, err + } + result.CandidateCommit = commit + + if len(result.Targets) == 0 { + result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) + return result, nil + } + + result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) + filenames := make([]string, 0, len(result.Targets)) + for _, target := range result.Targets { + filenames = append(filenames, artifactFilename(request.Spec.BinaryName, request.Version, target)) + } + if err := os.MkdirAll(result.OutputDir, 0o755); err != nil { + return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) + } + for _, filename := range filenames { + path := filepath.Join(result.OutputDir, filename) + if _, err := os.Lstat(path); err == nil { + return result, fmt.Errorf("build output %q already exists; preserving caller-owned file", path) + } else if !errors.Is(err, os.ErrNotExist) { + return result, fmt.Errorf("inspect build output %q: %w", path, err) + } + } + + compiler, err := runGo(ctx, repositoryDir, nil, "version") + if err != nil { + return result, fmt.Errorf("identify Go compiler: %w", err) + } + result.Compiler = strings.TrimSpace(string(compiler)) + host, err := hostTarget(ctx, repositoryDir) + if err != nil { + return result, fmt.Errorf("identify Go host: %w", err) + } + + for index, target := range result.Targets { + if err := ctx.Err(); err != nil { + return result, fmt.Errorf("build canceled: %w", err) + } + filename := filenames[index] + path := filepath.Join(result.OutputDir, filename) + if request.Output != nil { + fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename) + } + if err := buildTarget(ctx, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil { + return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err) + } + artifact := Artifact{Path: path, Filename: filename, Target: target} + result.Artifacts = append(result.Artifacts, artifact) + if err := hashArtifact(&result.Artifacts[len(result.Artifacts)-1]); err != nil { + return result, fmt.Errorf("hash build output %q: %w", path, err) + } + } + + var smokePath string + for _, artifact := range result.Artifacts { + if artifact.Target == host { + smokePath = artifact.Path + break + } + } + if smokePath == "" { + temporaryDir, err := os.MkdirTemp("", "releasekit-native-smoke-") + if err != nil { + return result, fmt.Errorf("create native smoke directory: %w", err) + } + defer os.RemoveAll(temporaryDir) + smokePath = filepath.Join(temporaryDir, request.Spec.BinaryName) + if host.OS == "windows" { + smokePath += ".exe" + } + if request.Output != nil { + fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch) + } + if err := buildTarget(ctx, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil { + return result, fmt.Errorf("build native version check: %w", err) + } + } + if request.Output != nil { + fmt.Fprintf(request.Output, "checking %s version output\n", request.Spec.BinaryName) + } + if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil { + return result, err + } + return result, nil +} + +func normalizeTargets(targets []Target) []Target { + seen := make(map[Target]struct{}, len(targets)) + result := make([]Target, 0, len(targets)) + for _, target := range targets { + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + result = append(result, target) + } + return result +} + +func safeComponent(value string) bool { + return value != "" && !strings.ContainsAny(value, "/\\\r\n") +} + +func resolvedOutputDir(repositoryDir, outputDir string) string { + if outputDir == "" { + outputDir = filepath.Join(repositoryDir, "dist") + } else if !filepath.IsAbs(outputDir) { + outputDir = filepath.Join(repositoryDir, outputDir) + } + resolved, err := filepath.Abs(outputDir) + if err != nil { + return filepath.Clean(outputDir) + } + return resolved +} + +func artifactFilename(binaryName, version string, target Target) string { + filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch) + if target.OS == "windows" { + filename += ".exe" + } + return filename +} + +func gitCommit(ctx context.Context, repositoryDir string) (string, error) { + command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") + output, err := command.Output() + if err != nil { + if ctx.Err() != nil { + return "", fmt.Errorf("read source commit: %w", ctx.Err()) + } + return "", fmt.Errorf("read source commit: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +func hostTarget(ctx context.Context, repositoryDir string) (Target, error) { + output, err := runGo(ctx, repositoryDir, nil, "env", "GOHOSTOS", "GOHOSTARCH") + if err != nil { + return Target{}, err + } + parts := strings.Fields(string(output)) + if len(parts) != 2 { + return Target{}, fmt.Errorf("unexpected Go host response %q", output) + } + return Target{OS: parts[0], Arch: parts[1]}, nil +} + +func buildTarget(ctx context.Context, repositoryDir, mainPackage, outputPath, version string, target Target) error { + ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version + _, err := runGo(ctx, repositoryDir, map[string]string{"GOOS": target.OS, "GOARCH": target.Arch}, + "build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage) + return err +} + +func runGo(ctx context.Context, directory string, target map[string]string, args ...string) ([]byte, error) { + commandArgs := append([]string(nil), args...) + command := exec.CommandContext(ctx, "go", commandArgs...) + command.Dir = directory + command.Env = goEnvironment(target) + output, err := command.CombinedOutput() + if err == nil { + return output, nil + } + if ctx.Err() != nil { + err = fmt.Errorf("%w: %v", ctx.Err(), err) + } + if len(output) != 0 { + return output, fmt.Errorf("go %s: %w: %s", strings.Join(commandArgs, " "), err, strings.TrimSpace(string(output))) + } + return output, fmt.Errorf("go %s: %w", strings.Join(commandArgs, " "), err) +} + +func goEnvironment(target map[string]string) []string { + controlled := map[string]bool{ + "GOWORK": true, "GOPROXY": true, "GONOPROXY": true, "GOSUMDB": true, + "GOTOOLCHAIN": true, "GOFLAGS": true, "CGO_ENABLED": true, "GOOS": true, "GOARCH": true, + } + for key := range target { + controlled[key] = true + } + environment := make([]string, 0, len(os.Environ())+len(controlled)) + for _, entry := range os.Environ() { + key, _, ok := strings.Cut(entry, "=") + if !ok || !controlled[key] { + environment = append(environment, entry) + } + } + values := map[string]string{ + "GOWORK": "off", "GOPROXY": "off", "GONOPROXY": "none", "GOSUMDB": "off", + "GOTOOLCHAIN": "local", "GOFLAGS": "-mod=readonly", "CGO_ENABLED": "0", + } + for key, value := range target { + values[key] = value + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + environment = append(environment, key+"="+values[key]) + } + return environment +} + +func hashArtifact(artifact *Artifact) error { + file, err := os.Open(artifact.Path) + if err != nil { + return err + } + defer file.Close() + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return err + } + artifact.SHA256 = hex.EncodeToString(hash.Sum(nil)) + return nil +} + +func checkVersion(ctx context.Context, binaryPath, binaryName, version string) error { + command := exec.CommandContext(ctx, binaryPath, "version") + command.Env = goEnvironment(nil) + output, err := command.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return fmt.Errorf("run native version check: %w", ctx.Err()) + } + return fmt.Errorf("run native version check: %w: %s", err, strings.TrimSpace(string(output))) + } + want := binaryName + " " + version + "\n" + if string(output) != want { + return fmt.Errorf("native version output = %q, want %q", output, want) + } + return nil +} diff --git a/internal/candidate/check.go b/internal/candidate/check.go index 73455c2..8ccf7fa 100644 --- a/internal/candidate/check.go +++ b/internal/candidate/check.go @@ -13,6 +13,7 @@ import ( "sync" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process" + "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" ) var ErrInvalidVersion = errors.New("invalid stable version") @@ -241,24 +242,7 @@ func (w *synchronizedWriter) Write(p []byte) (int, error) { } func validStableVersion(version string) bool { - if !strings.HasPrefix(version, "v") { - return false - } - components := strings.Split(version[1:], ".") - if len(components) != 3 { - return false - } - for _, component := range components { - if component == "" || len(component) > 1 && component[0] == '0' { - return false - } - for i := 0; i < len(component); i++ { - if component[i] < '0' || component[i] > '9' { - return false - } - } - } - return true + return versioncontract.ValidStableVersion(version) } func validateNote(note []byte, product, version string) error { diff --git a/internal/versioncontract/version.go b/internal/versioncontract/version.go new file mode 100644 index 0000000..03ab3e0 --- /dev/null +++ b/internal/versioncontract/version.go @@ -0,0 +1,25 @@ +package versioncontract + +import "strings" + +// ValidStableVersion reports whether version uses the selected vMAJOR.MINOR.PATCH form. +func ValidStableVersion(version string) bool { + if !strings.HasPrefix(version, "v") { + return false + } + components := strings.Split(version[1:], ".") + if len(components) != 3 { + return false + } + for _, component := range components { + if component == "" || len(component) > 1 && component[0] == '0' { + return false + } + for i := 0; i < len(component); i++ { + if component[i] < '0' || component[i] > '9' { + return false + } + } + } + return true +}