Build selected targets with version injection

This commit is contained in:
2026-10-05 19:20:58 +00:00
parent c83313b3b9
commit 5d6a6d10bd
12 changed files with 927 additions and 34 deletions

View File

@@ -2,10 +2,10 @@
ReleaseKit is an opinionated Go library and executable for a shared release ReleaseKit is an opinionated Go library and executable for a shared release
process. The Go module, root `releasekit` facade, `buildinfo` runtime package, process. The Go module, root `releasekit` facade, `buildinfo` runtime package,
local candidate check, source-tag publication API, and `version`, `check`, and local candidate check, source-tag publication API, selected-target build API,
`publish` commands are implemented. ReleaseKit's source-only maintainer and `version`, `check`, and `publish` commands are implemented. ReleaseKit's
procedure is documented. Executable builds and delivery remain future work. source-only maintainer procedure is documented. The build command and delivery
The [product roadmap](docs/roadmap/releasekit.md) and remain future work. The [product roadmap](docs/roadmap/releasekit.md) and
[implementation plan](docs/roadmap/implementation.md) describe their selected [implementation plan](docs/roadmap/implementation.md) describe their selected
behavior and sequence. behavior and sequence.
@@ -48,6 +48,8 @@ failure recovery.
- [Configuration](docs/config.md): profile fields and child-command settings. - [Configuration](docs/config.md): profile fields and child-command settings.
- [Runtime version guidance](docs/consumers/runtime-version.md): how applications - [Runtime version guidance](docs/consumers/runtime-version.md): how applications
read their embedded version through `buildinfo`. 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 - [Candidate check guidance](docs/consumers/check.md): local candidate validation
through the Go API and CLI. through the Go API and CLI.
- [Maintainer release procedure](docs/release.md): ReleaseKit's source-only - [Maintainer release procedure](docs/release.md): ReleaseKit's source-only

97
build.go Normal file
View File

@@ -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 <repository>/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
}

377
build_test.go Normal file
View File

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

62
docs/consumers/build.md Normal file
View File

@@ -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
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
target pair is built once, at its first position.
The default output directory is `<repository>/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 `<binary> <version>` 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.

View File

@@ -13,4 +13,5 @@ version := buildinfo.Version()
Otherwise it returns the executable's main module version when that metadata is 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 available and is not `(devel)`. It returns `dev` when neither source provides a
release identifier. Applications should use this accessor for version output; 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.

View File

@@ -49,8 +49,8 @@ root with:
go run ./internal/apicheck go run ./internal/apicheck
``` ```
See the [candidate check guide](consumers/check.md), [internal package See the [candidate check guide](consumers/check.md), [build consumer guide](consumers/build.md),
inventory](internal/overview.md), and [implementation plan](roadmap/implementation.md) [internal package inventory](internal/overview.md), and [implementation plan](roadmap/implementation.md)
for current implementation progress. for current implementation progress.
For prose changes, review the diff and check local links, fragment destinations, For prose changes, review the diff and check local links, fragment destinations,

View File

@@ -8,12 +8,14 @@ Implemented internal packages:
- `internal/runtimeinfo` holds the injected version value and selects the - `internal/runtimeinfo` holds the injected version value and selects the
executable's main-module version when no injected value is present. executable's main-module version when no injected value is present.
- `internal/versioncontract` names the linker symbol shared with the future - `internal/versioncontract` names the linker symbol and stable version form
release builder. shared with the build and candidate operations.
- `internal/apicheck` checks compiled exports with `go/types` and checks import - `internal/apicheck` checks compiled exports with `go/types` and checks import
direction. Run it with `go run ./internal/apicheck` from the repository root. direction. Run it with `go run ./internal/apicheck` from the repository root.
- `internal/process` runs cancellable child commands with per-child environment - `internal/process` runs cancellable child commands with per-child environment
overlays. 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 - `internal/candidate` captures and validates one clean local candidate, checks
its committed release note and local tag state, runs validation once, and its committed release note and local tag state, runs validation once, and
rechecks mutable repository state. rechecks mutable repository state.

View File

@@ -1,7 +1,6 @@
# ReleaseKit staged implementation plan # ReleaseKit staged implementation plan
Status: Proposed. All stages are pending; this document introduces no implemented Status: In progress. Stages 1–10 are complete; later stages remain pending.
features or published contracts.
Date: 2026-10-05 Date: 2026-10-05
@@ -683,7 +682,7 @@ ReleaseKit release was published.
## Stage 10: Build selected targets with shared version injection ## 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 1. Implement public build values and internal build execution using the fixed
flags, version symbol, filename convention, and prepared Go environment. 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 missing main packages, build failures, incorrect version output, unsafe binary
names, and cancellation fail with useful partial results. 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 ## Stage 11: Integrate candidate builds and artifact command
Status: Pending. Depends on Stage 10. Status: Pending. Depends on Stage 10.

View File

@@ -2,8 +2,8 @@
This is a proposal based on a review of PromptKit, ConfigKit, Audita, Notarius, 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 Narratio, and Scriptorium on 2026-09-29. The implementation plan records the
current implemented boundary; builds, delivery, and broader integrations remain current implemented boundary; CLI build integration, delivery, and broader
proposed. The public facade architecture is accepted in integrations remain planned. The public facade architecture is accepted in
[ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version, [ADR 0001](../adr/0001-public-facade.md) and is implemented for runtime version,
candidate validation, and source publication. candidate validation, and source publication.
The [implementation plan](implementation.md) owns ordered stages, execution The [implementation plan](implementation.md) owns ordered stages, execution
@@ -55,8 +55,9 @@ cleanup.
The [architecture policy](../policy/architecture.md) owns the adopted The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary. responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate The root `releasekit` package provides the public facade for candidate
validation and source publication; builds and delivery remain planned. The validation, source publication, and selected-target builds; CLI build
separate public `buildinfo` package provides lightweight runtime version access. 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 Each facade owns its public named types and documented contracts. Public
operations, inputs, outcomes, errors, and callbacks express deliberately operations, inputs, outcomes, errors, and callbacks express deliberately

332
internal/build/build.go Normal file
View File

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

View File

@@ -13,6 +13,7 @@ import (
"sync" "sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
var ErrInvalidVersion = errors.New("invalid stable version") var ErrInvalidVersion = errors.New("invalid stable version")
@@ -241,24 +242,7 @@ func (w *synchronizedWriter) Write(p []byte) (int, error) {
} }
func validStableVersion(version string) bool { func validStableVersion(version string) bool {
if !strings.HasPrefix(version, "v") { return versioncontract.ValidStableVersion(version)
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
} }
func validateNote(note []byte, product, version string) error { func validateNote(note []byte, product, version string) error {

View File

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