Build selected targets with version injection
This commit is contained in:
10
README.md
10
README.md
@@ -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
97
build.go
Normal 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
377
build_test.go
Normal 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
62
docs/consumers/build.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
332
internal/build/build.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
25
internal/versioncontract/version.go
Normal file
25
internal/versioncontract/version.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user