Build from the selected module

This commit is contained in:
2026-10-10 02:53:38 +00:00
parent 8350cbf9c9
commit fdce017aff
15 changed files with 424 additions and 122 deletions

View File

@@ -4,7 +4,9 @@ import (
"context"
"io"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
)
// Target identifies one explicit Go operating-system and architecture pair.
@@ -28,7 +30,7 @@ type BuildRequest struct {
ModuleDir string
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.
OutputDir string // output location; relative paths use ModuleDir and empty selects <module>/dist.
Output io.Writer // optional build progress destination.
}
@@ -59,22 +61,27 @@ type BuildResult struct {
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 available compatible local Go toolchain, applies ReleaseKit's
// readonly offline build settings, preserves effective cache and tool settings,
// and verifies the version command on the Go host before succeeding. An empty
// target set records source identity without running the Go toolchain. Errors
// return partial build facts in the result.
// Build resolves the selected module's working-tree identity, then compiles the
// selected targets with Version injected through buildinfo. It uses the
// available compatible local Go toolchain, applies ReleaseKit's readonly
// offline build settings, preserves effective cache and tool settings, and
// verifies the version command on the Go host before succeeding. Relative
// output paths use ModuleDir; an empty path selects its dist directory. 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) {
result, err := internalbuild.Build(ctx, internalbuild.Request{
RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Version: request.Version,
Spec: *internalBuildSpec(&request.Spec),
OutputDir: request.OutputDir,
Output: request.Output,
public := BuildResult{Version: request.Version}
identity, err := releaseidentity.FromWorkingTree(ctx, request.RepositoryDir, request.ModuleDir, request.Version)
if err != nil {
return public, err
}
result, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
Identity: identity,
Spec: *internalBuildSpec(&request.Spec),
OutputDir: request.OutputDir,
Output: request.Output,
})
public := BuildResult{
public = BuildResult{
RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
@@ -82,14 +89,14 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified,
Version: result.Version,
MainPackage: result.MainPackage,
BinaryName: result.BinaryName,
MainPackage: result.Settings.MainPackage,
BinaryName: result.Settings.BinaryName,
OutputDir: result.OutputDir,
Compiler: result.Compiler,
Targets: make([]Target, 0, len(result.Targets)),
Targets: make([]Target, 0, len(result.Settings.Targets)),
Artifacts: make([]BuildArtifact, 0, len(result.Artifacts)),
}
for _, target := range result.Targets {
for _, target := range result.Settings.Targets {
public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range result.Artifacts {
@@ -115,8 +122,10 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
}
func internalBuildResult(result BuildResult) *internalbuild.Result {
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Version: result.Version, MainPackage: result.MainPackage, BinaryName: result.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
for _, target := range result.Targets {
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
for _, target := range settings.Targets {
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
}
for _, artifact := range result.Artifacts {

View File

@@ -115,7 +115,7 @@ func main() {
t.Setenv("GOARCH", "arm64")
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
Version: "v2.3.4",
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
@@ -126,7 +126,7 @@ func main() {
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" {
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v1.2.3-windows-arm64.exe" {
t.Fatalf("build artifacts = %#v", result.Artifacts)
}
}
@@ -166,7 +166,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
Version: "v2.3.4",
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
@@ -242,6 +242,145 @@ func main() {}
}
}
func TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput(t *testing.T) {
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
moduleDir := filepath.Join(repository, "integrations", "nested")
writeNestedBuildConsumer(t, repository, "integrations/nested", "example.com/nestedbuild", `package main
import (
"fmt"
"os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
)
func main() {
if len(os.Args) == 2 && os.Args[1] == "version" {
if logPath := os.Getenv("RELEASEKIT_SMOKE_LOG"); logPath != "" {
if executable, err := os.Executable(); err == nil {
_ = os.WriteFile(logPath, []byte(executable), 0o600)
}
}
fmt.Printf("nested-app %s\n", buildinfo.Version())
}
}
`)
outputDirectory := "artifacts"
crossTarget := releasekit.Target{OS: "darwin", Arch: "arm64"}
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
crossTarget = releasekit.Target{OS: "linux", Arch: "amd64"}
}
nativeTarget := releasekit.Target{OS: runtime.GOOS, Arch: runtime.GOARCH}
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
ModuleDir: "integrations/nested",
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "nested-app",
Targets: []releasekit.Target{nativeTarget, crossTarget},
},
OutputDir: outputDirectory,
})
if err != nil {
t.Fatalf("Build() error = %v; result = %#v", err, result)
}
if result.ModuleDir != moduleDir || result.ModulePath != "example.com/nestedbuild" || result.Tag != "integrations/nested/v1.2.3" {
t.Fatalf("nested build identity = %#v", result)
}
wantOutput := filepath.Join(moduleDir, outputDirectory)
if result.OutputDir != wantOutput || len(result.Artifacts) != 2 {
t.Fatalf("nested build output = %#v, want directory %q and two artifacts", result, wantOutput)
}
var native string
for _, artifact := range result.Artifacts {
if filepath.Dir(artifact.Path) != wantOutput || !strings.Contains(artifact.Filename, "nested-app-v1.2.3-") {
t.Fatalf("nested artifact = %#v", artifact)
}
if artifact.Target == nativeTarget {
native = artifact.Path
}
}
if native == "" {
t.Fatalf("native artifact missing from %#v", result.Artifacts)
}
if output, err := exec.Command(native, "version").CombinedOutput(); err != nil || string(output) != "nested-app v1.2.3\n" {
t.Fatalf("nested native version = %q, error = %v", output, err)
}
localTemporary := filepath.Join(repository, "source-temporary")
if err := os.Mkdir(localTemporary, 0o700); err != nil {
t.Fatal(err)
}
crossOutput := filepath.Join(t.TempDir(), "cross-artifacts")
smokeLog := filepath.Join(t.TempDir(), "smoke-executable")
t.Setenv("RELEASEKIT_SMOKE_LOG", smokeLog)
t.Setenv("TMPDIR", localTemporary)
smokeResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
ModuleDir: "integrations/nested",
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "nested-app",
Targets: []releasekit.Target{crossTarget},
},
OutputDir: crossOutput,
})
if err != nil || len(smokeResult.Artifacts) != 1 {
t.Fatalf("nested cross build with source-local TMPDIR = %#v, %v", smokeResult, err)
}
smokePathBytes, err := os.ReadFile(smokeLog)
if err != nil {
t.Fatalf("read native smoke executable path: %v", err)
}
smokePath := string(smokePathBytes)
relativeSmokePath, err := filepath.Rel(repository, smokePath)
if err != nil || relativeSmokePath == "." || (!filepath.IsAbs(relativeSmokePath) && relativeSmokePath != ".." && !strings.HasPrefix(relativeSmokePath, ".."+string(filepath.Separator))) {
t.Fatalf("native smoke output path %q is inside repository %q (relative %q, error %v)", smokePath, repository, relativeSmokePath, err)
}
if _, err := os.Stat(smokePath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("native smoke output %q was not removed: %v", smokePath, err)
}
remaining, err := os.ReadDir(localTemporary)
if err != nil || len(remaining) != 0 {
t.Fatalf("native smoke output remained in source-local TMPDIR: %v %v", remaining, err)
}
emptyResult, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
ModuleDir: "integrations/nested",
Version: "v1.2.3",
})
if err != nil || emptyResult.Compiler != "" || emptyResult.OutputDir != filepath.Join(moduleDir, "dist") {
t.Fatalf("nested empty build = %#v, %v", emptyResult, err)
}
if _, err := os.Stat(emptyResult.OutputDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("empty nested build created dist: %v", err)
}
}
func TestBuildResolvesDirtyWorkingTreeDeclaration(t *testing.T) {
repository := newBuildConsumer(t, "package main\nfunc main() {}\n")
declarationPath := filepath.Join(repository, "go.mod")
contents, err := os.ReadFile(declarationPath)
if err != nil {
t.Fatal(err)
}
updated := strings.Replace(string(contents), "module example.com/buildfixture", "module example.com/changedbuild", 1)
if updated == string(contents) {
t.Fatal("fixture module declaration was not replaced")
}
writeBuildFixture(t, repository, "go.mod", updated)
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
Version: "v1.2.3",
})
if err != nil {
t.Fatal(err)
}
if result.ModulePath != "example.com/changedbuild" || result.Tag != "v1.2.3" {
t.Fatalf("dirty declaration identity = %#v", result)
}
}
func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
const source = `package main
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
@@ -563,6 +702,18 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
return repository
}
func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, mainSource string) string {
t.Helper()
root, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", modulePath, filepath.ToSlash(root))
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
return filepath.Join(repository, filepath.FromSlash(moduleDir))
}
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
t.Helper()
root, err := filepath.Abs(".")

View File

@@ -36,19 +36,24 @@ func build(ctx context.Context) error {
}
```
`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
`Build` resolves `ModuleDir` and the selected module's working-tree declaration
once, records the repository's current `HEAD`, injects the selected stable
version into `buildinfo.Version`, and returns repository/module identity, the
derived tag, compiler identity, normalized targets, artifact paths, filenames,
and SHA-256 digests. It does not require a release note, unused tag, clean
`main`, or publication. Names follow
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
target pair is built once, at its first position. Local builds may use modified
source. `SourceVerified` is true only after a complete build observes clean
source and the same HEAD before and after compilation; binary delivery requires
that fact. This observation is not a source snapshot or attestation guarantee.
The default output directory is `<repository>/dist`; a relative `OutputDir` is
resolved from the repository. Selected artifact filenames are regenerated. 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`.
Go commands run from `ModuleDir`, so `MainPackage` is interpreted within that
module. The default output directory is `<module>/dist`; a relative `OutputDir`
is resolved from the module, while an absolute path is used as supplied.
Selected artifact filenames are regenerated. 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 available compatible Go toolchain selected locally; ReleaseKit
does not download a different toolchain automatically. It disables workspace
@@ -72,7 +77,9 @@ The [`build` command](../cli.md) reads the same build specification from the
profile and writes a private receipt alongside its artifacts. `check` and
`publish` run selected builds after the standard suite and any additional
checks using temporary output outside the worktree. Candidate checks do not
write a receipt or leave build files in the repository.
write a receipt or leave build files in the repository. Disposable native
version-check output also remains outside the whole repository when `TMPDIR`
points inside the source tree.
For delivery from a tagged checkout, `deliver` builds selected tagged-source
targets into temporary storage by default. Use `--assets DIR` to deliver a

View File

@@ -37,7 +37,7 @@ reports these implemented packages:
| `cmd/releasekit/internal/cli` | Command parsing, strict JSON profile loading, configured checks, and private build receipts. |
| `internal/apicheck` | Compiled-export and import-boundary checks run during development. |
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
| `internal/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. |
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
| `internal/git` | Git command boundary, committed-tree reads, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
@@ -87,7 +87,7 @@ not a separate release matrix:
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
| Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` |
| Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |

View File

@@ -418,6 +418,15 @@ identity for composites, empty targets, and partial failures. Confirm temporary
outside the repository even with a nested selection and source-local `TMPDIR`.
Run build and runtime-boundary tests.
**Completed:** Standalone builds now resolve working-tree module identity once;
candidate and managed delivery builds consume their committed identity and Go
tool owner through the resolved build path. Normalized build settings are shared
by execution and delivery evidence. Builds run from `ModuleDir`, use module-based
output paths, and keep temporary native smoke output outside the repository.
Verified nested native version output, a second cross target, dirty declarations,
new uncommitted nested modules, empty targets, temporary-output fallback and
cleanup, full `go test ./...`, `go run ./internal/apicheck`, and `git diff --check`.
## Stage 8: Deliver module-local notes and assets to full-tag pages
**Outcome:** Page-only, managed-build, and API prebuilt delivery share committed

View File

@@ -13,11 +13,28 @@ type Target struct {
Arch string
}
// Settings contains normalized inputs shared by build execution and evidence.
type Settings struct {
MainPackage string
BinaryName string
Targets []Target
}
type Declaration struct {
Target Target
Filename string
}
// NormalizeSettings copies build inputs and removes duplicate targets while
// preserving their first occurrence.
func NormalizeSettings(mainPackage, binaryName string, targets []Target) Settings {
return Settings{
MainPackage: mainPackage,
BinaryName: binaryName,
Targets: NormalizeTargets(targets),
}
}
// NormalizeTargets removes duplicate targets while preserving their first
// occurrence.
func NormalizeTargets(targets []Target) []Target {

View File

@@ -17,6 +17,22 @@ func TestNormalizeTargetsPreservesOrderAndFirstOccurrence(t *testing.T) {
}
}
func TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets(t *testing.T) {
settings := NormalizeSettings("./cmd/app", "app", []Target{
{OS: "linux", Arch: "amd64"},
{OS: "windows", Arch: "arm64"},
{OS: "linux", Arch: "amd64"},
})
want := Settings{
MainPackage: "./cmd/app",
BinaryName: "app",
Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "arm64"}},
}
if !reflect.DeepEqual(settings, want) {
t.Fatalf("NormalizeSettings() = %#v, want %#v", settings, want)
}
}
func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
want := []Declaration{

View File

@@ -16,25 +16,20 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
type Target = artifact.Target
type Spec struct {
MainPackage string
BinaryName string
Targets []Target
}
type Spec = artifact.Settings
type Request struct {
RepositoryDir string
ModuleDir string
Version string
Spec Spec
OutputDir string
Output io.Writer
ToolOwner *gotool.Owner
Identity releaseidentity.Identity
Spec Spec
OutputDir string
Output io.Writer
ToolOwner *gotool.Owner
}
type Artifact struct {
@@ -51,6 +46,7 @@ type Result struct {
Tag string
CandidateCommit string
SourceVerified bool
Settings artifact.Settings
Version string
MainPackage string
BinaryName string
@@ -60,25 +56,32 @@ type Result struct {
Compiler string
}
func Build(ctx context.Context, request Request) (Result, error) {
func BuildResolved(ctx context.Context, request Request) (Result, error) {
identity := request.Identity
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
result := Result{
Version: request.Version,
MainPackage: request.Spec.MainPackage,
BinaryName: request.Spec.BinaryName,
Targets: artifact.NormalizeTargets(request.Spec.Targets),
RepositoryDir: identity.RepositoryDir,
ModuleDir: identity.ModuleDir,
ModulePath: identity.ModulePath,
Tag: identity.Tag,
Settings: settings,
Version: identity.Version,
MainPackage: settings.MainPackage,
BinaryName: settings.BinaryName,
Targets: settings.Targets,
}
if !versioncontract.ValidStableVersion(request.Version) {
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
if !versioncontract.ValidStableVersion(identity.Version) {
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
}
if request.RepositoryDir == "" {
return result, errors.New("build requires a repository directory")
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
return result, errors.New("build requires a resolved module identity")
}
if len(result.Targets) != 0 {
if request.Spec.MainPackage == "" {
if settings.MainPackage == "" {
return result, errors.New("build requires a main package when targets are selected")
}
if !artifact.ValidComponent(request.Spec.BinaryName) {
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
if !artifact.ValidComponent(settings.BinaryName) {
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
}
for _, target := range result.Targets {
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
@@ -89,11 +92,16 @@ func Build(ctx context.Context, request Request) (Result, error) {
if err := ctx.Err(); err != nil {
return result, fmt.Errorf("build canceled: %w", err)
}
repositoryDir, err := filepath.Abs(request.RepositoryDir)
repositoryDir, err := filepath.Abs(identity.RepositoryDir)
if err != nil {
return result, fmt.Errorf("resolve repository directory: %w", err)
}
result.RepositoryDir = repositoryDir
moduleDir, err := filepath.Abs(identity.ModuleDir)
if err != nil {
return result, fmt.Errorf("resolve module directory: %w", err)
}
result.ModuleDir = moduleDir
commit, err := gitCommit(ctx, repositoryDir)
if err != nil {
return result, err
@@ -105,12 +113,12 @@ func Build(ctx context.Context, request Request) (Result, error) {
}
if len(result.Targets) == 0 {
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir)
return result, nil
}
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
declarations := artifact.ExpectedBuild(request.Spec.BinaryName, request.Version, result.Targets)
result.OutputDir = resolvedOutputDir(moduleDir, request.OutputDir)
declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.Targets)
filenames := make([]string, len(declarations))
for i, declaration := range declarations {
filenames[i] = declaration.Filename
@@ -121,13 +129,13 @@ func Build(ctx context.Context, request Request) (Result, error) {
owner := request.ToolOwner
if owner == nil {
owner = gotool.NewOwner(repositoryDir)
owner = gotool.NewOwner(moduleDir)
}
goTool, err := owner.Acquire(ctx)
if err != nil {
return result, fmt.Errorf("prepare Go toolchain: %w", err)
}
compiler, err := goTool.Output(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
compiler, err := goTool.Output(ctx, moduleDir, gotool.Native, gotool.Target{}, nil, "version")
if err != nil {
return result, fmt.Errorf("identify Go compiler: %w", err)
}
@@ -144,7 +152,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
if request.Output != nil {
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
}
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, path, identity.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}
@@ -162,26 +170,26 @@ func Build(ctx context.Context, request Request) (Result, error) {
}
}
if smokePath == "" {
temporaryDir, err := os.MkdirTemp("", "releasekit-native-smoke-")
temporaryDir, err := tempdir.Outside(repositoryDir, "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)
smokePath = filepath.Join(temporaryDir, settings.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, goTool, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, smokePath, identity.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)
fmt.Fprintf(request.Output, "checking %s version output\n", settings.BinaryName)
}
if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil {
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
return result, err
}
commitAfter, err := gitCommit(ctx, repositoryDir)
@@ -196,11 +204,11 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, nil
}
func resolvedOutputDir(repositoryDir, outputDir string) string {
func resolvedOutputDir(moduleDir, outputDir string) string {
if outputDir == "" {
outputDir = filepath.Join(repositoryDir, "dist")
outputDir = filepath.Join(moduleDir, "dist")
} else if !filepath.IsAbs(outputDir) {
outputDir = filepath.Join(repositoryDir, outputDir)
outputDir = filepath.Join(moduleDir, outputDir)
}
resolved, err := filepath.Abs(outputDir)
if err != nil {
@@ -221,9 +229,9 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
return strings.TrimSpace(string(output)), nil
}
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
return err
}

View File

@@ -5,9 +5,11 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
)
@@ -18,18 +20,32 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
selected := *spec
selected.Targets = append([]Target(nil), spec.Targets...)
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error {
moduleSubdir, err := filepath.Rel(captured.RepositoryDir, captured.ModuleDir)
if err != nil {
return fmt.Errorf("resolve captured module path: %w", err)
}
identity := releaseidentity.Identity{
Selection: releaseidentity.Selection{
RepositoryDir: captured.RepositoryDir,
ModuleDir: captured.ModuleDir,
ModuleSubdir: filepath.ToSlash(moduleSubdir),
},
ModulePath: captured.ModulePath,
Version: captured.Version,
Tag: captured.Tag,
NotePath: filepath.ToSlash(filepath.Join(moduleSubdir, "docs", "releases", captured.Version+".md")),
}
temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-")
if err != nil {
return fmt.Errorf("create temporary candidate build output: %w", err)
}
defer os.RemoveAll(temporaryDir)
_, err = Build(ctx, Request{
RepositoryDir: captured.RepositoryDir,
Version: captured.Version,
Spec: selected,
OutputDir: temporaryDir,
Output: output,
ToolOwner: owner,
_, err = BuildResolved(ctx, Request{
Identity: identity,
Spec: selected,
OutputDir: temporaryDir,
Output: output,
ToolOwner: owner,
})
if err != nil {
return fmt.Errorf("build candidate: %w", err)

View File

@@ -11,16 +11,17 @@ import (
)
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
if len(result.Targets) == 0 {
settings := result.Settings
if len(settings.Targets) == 0 {
if len(result.Artifacts) != 0 {
return nil, fmt.Errorf("source-only build result contains artifacts")
}
return nil, nil
}
if len(result.Artifacts) != len(result.Targets) {
if len(result.Artifacts) != len(settings.Targets) {
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
}
declarations := artifact.ExpectedBuild(result.BinaryName, result.Version, result.Targets)
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
wantByTarget := make(map[artifact.Target]string, len(declarations))
for _, declaration := range declarations {
target := declaration.Target

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
@@ -25,8 +26,10 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
t.Fatal(err)
}
result, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app",
Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
Version: "v1.2.3",
Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{
{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"},
}),
Artifacts: []internalbuild.Artifact{
{Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)},
{Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)},
@@ -54,7 +57,7 @@ func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
t.Fatal(err)
}
_, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
Version: "v1.2.3", Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{{OS: "linux", Arch: "amd64"}}),
Artifacts: []internalbuild.Artifact{{
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents),
}},

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
@@ -90,17 +91,24 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
build := request.Prebuilt
if request.Build != nil && len(request.Build.Targets) != 0 {
selection, err := releaseidentity.ResolveSelection(repositoryDir, request.ModuleDir)
if err != nil {
return result, fmt.Errorf("resolve managed build module: %w", err)
}
identity, err := releaseidentity.FromCommittedSelection(ctx, selection, request.Version, source.TagCommit)
if err != nil {
return result, fmt.Errorf("resolve managed build identity: %w", err)
}
temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*")
if err != nil {
return result, fmt.Errorf("create temporary delivery build directory: %w", err)
}
defer os.RemoveAll(temporaryDir)
built, err := internalbuild.Build(ctx, internalbuild.Request{
RepositoryDir: repositoryDir,
Version: request.Version,
Spec: *request.Build,
OutputDir: filepath.Join(temporaryDir, "assets"),
Output: request.Output,
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
Identity: identity,
Spec: *request.Build,
OutputDir: filepath.Join(temporaryDir, "assets"),
Output: request.Output,
})
if err != nil {
return result, fmt.Errorf("build delivery assets: %w", err)
@@ -122,7 +130,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("verify build assets: %w", err)
}
}
if build != nil && len(build.Targets) != 0 {
if build != nil && len(build.Settings.Targets) != 0 {
current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
if err != nil {
return result, fmt.Errorf("recheck tagged source after build: %w", err)
@@ -179,7 +187,7 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R
}
return nil
}
if len(build.Targets) == 0 {
if len(build.Settings.Targets) == 0 {
if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets")
}
@@ -195,7 +203,7 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R
if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version {
return errors.New("build result does not identify the selected tagged source")
}
if strings.TrimSpace(build.MainPackage) == "" || strings.TrimSpace(build.BinaryName) == "" || len(assets) != len(build.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
}
return nil

View File

@@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
@@ -60,14 +61,13 @@ func performanceBuildResult(b *testing.B) (internalbuild.Result, int64) {
}
digest := sha256.Sum256(data)
result := internalbuild.Result{
Version: "v1.2.3",
BinaryName: "releasekit",
Targets: []internalbuild.Target{
Version: "v1.2.3",
Settings: artifact.NormalizeSettings("./cmd/releasekit", "releasekit", []artifact.Target{
{OS: "linux", Arch: "amd64"},
{OS: "windows", Arch: "amd64"},
},
}),
}
for _, target := range result.Targets {
for _, target := range result.Settings.Targets {
name := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
if target.OS == "windows" {
name += ".exe"

View File

@@ -8,32 +8,77 @@ import (
"strings"
)
// Outside uses the system temporary directory and leaves cleanup to its caller.
// Outside uses system temporary storage and falls back to the repository's
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
func Outside(repositoryDir, pattern string) (string, error) {
repository, err := filepath.Abs(repositoryDir)
if err != nil {
return "", err
}
repository, err = filepath.EvalSymlinks(repository)
if err != nil {
return "", err
}
directory, err := os.MkdirTemp("", pattern)
if err != nil {
return "", err
}
absolute, err := filepath.Abs(directory)
created := directory
directory, err = filepath.Abs(created)
if err != nil {
os.RemoveAll(created)
return "", err
}
directory, err = filepath.EvalSymlinks(directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
directory = absolute
repository, err := filepath.Abs(repositoryDir)
if err == nil && !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
outside, err := isOutside(repository, directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
if outside {
return directory, nil
}
if err == nil {
var relative string
relative, err = filepath.Rel(repository, directory)
if err == nil && (relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
err = fmt.Errorf("temporary storage %q is inside repository %q; select an external temporary directory", directory, repository)
}
if err := os.RemoveAll(directory); err != nil {
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
}
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
if err != nil {
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
}
created = directory
directory, err = filepath.Abs(created)
if err != nil {
os.RemoveAll(created)
return "", err
}
directory, err = filepath.EvalSymlinks(directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
outside, err = isOutside(repository, directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
if !outside {
os.RemoveAll(directory)
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
}
return directory, nil
}
func isOutside(repository, directory string) (bool, error) {
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
return true, nil
}
relative, err := filepath.Rel(repository, directory)
if err != nil {
return false, err
}
return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)), nil
}

View File

@@ -29,16 +29,28 @@ func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
}
}
func TestOutsideRejectsAndCleansStorageInsideWorktree(t *testing.T) {
repository := t.TempDir()
setTemporaryDirectory(t, repository)
if directory, err := Outside(repository, "fixture-"); err == nil {
os.RemoveAll(directory)
t.Fatal("storage created inside worktree")
func TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree(t *testing.T) {
parent := t.TempDir()
repository := filepath.Join(parent, "worktree")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
localTemporary := filepath.Join(repository, "tmp")
if err := os.Mkdir(localTemporary, 0o700); err != nil {
t.Fatal(err)
}
setTemporaryDirectory(t, localTemporary)
directory, err := Outside(repository, "fixture-")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(directory)
if filepath.Dir(directory) != parent {
t.Fatalf("temporary storage=%q want external parent %q", directory, parent)
}
remaining, err := os.ReadDir(repository)
if err != nil || len(remaining) != 0 {
t.Fatalf("temporary storage left behind: %v %v", remaining, err)
if err != nil || len(remaining) != 1 || remaining[0].Name() != "tmp" {
t.Fatalf("temporary storage left behind in worktree: %v %v", remaining, err)
}
}