From fdce017affa2bffe481bcce76bd024f6f75a82e6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 10 Oct 2026 02:53:38 +0000 Subject: [PATCH] Build from the selected module --- build.go | 51 +++++---- build_test.go | 157 +++++++++++++++++++++++++- docs/consumers/build.md | 23 ++-- docs/internal/overview.md | 4 +- docs/roadmap/implementation.md | 9 ++ internal/artifact/artifact.go | 17 +++ internal/artifact/artifact_test.go | 16 +++ internal/build/build.go | 92 ++++++++------- internal/build/candidate.go | 30 +++-- internal/delivery/assets.go | 7 +- internal/delivery/assets_test.go | 9 +- internal/delivery/delivery.go | 26 +++-- internal/delivery/performance_test.go | 10 +- internal/tempdir/tempdir.go | 67 +++++++++-- internal/tempdir/tempdir_test.go | 28 +++-- 15 files changed, 424 insertions(+), 122 deletions(-) diff --git a/build.go b/build.go index dbfeba5..84f5a6e 100644 --- a/build.go +++ b/build.go @@ -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 /dist. + OutputDir string // output location; relative paths use ModuleDir and empty selects /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 { diff --git a/build_test.go b/build_test.go index 203242d..4d976be 100644 --- a/build_test.go +++ b/build_test.go @@ -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(".") diff --git a/docs/consumers/build.md b/docs/consumers/build.md index 54d4d65..66f0af6 100644 --- a/docs/consumers/build.md +++ b/docs/consumers/build.md @@ -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 `---`; 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 `/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 `/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 diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 138b0d8..bd5c123 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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` | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 707bc7e..5f6fd8f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go index 92bf1bd..3cc2f61 100644 --- a/internal/artifact/artifact.go +++ b/internal/artifact/artifact.go @@ -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 { diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go index 3477dbb..aed6cdb 100644 --- a/internal/artifact/artifact_test.go +++ b/internal/artifact/artifact_test.go @@ -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{ diff --git a/internal/build/build.go b/internal/build/build.go index dbf7e8a..846c2b4 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -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 } diff --git a/internal/build/candidate.go b/internal/build/candidate.go index 4468851..6336f3e 100644 --- a/internal/build/candidate.go +++ b/internal/build/candidate.go @@ -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) diff --git a/internal/delivery/assets.go b/internal/delivery/assets.go index d5419ef..0b2289f 100644 --- a/internal/delivery/assets.go +++ b/internal/delivery/assets.go @@ -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 diff --git a/internal/delivery/assets_test.go b/internal/delivery/assets_test.go index 83634b0..7cb316c 100644 --- a/internal/delivery/assets_test.go +++ b/internal/delivery/assets_test.go @@ -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), }}, diff --git a/internal/delivery/delivery.go b/internal/delivery/delivery.go index 9234766..28048ea 100644 --- a/internal/delivery/delivery.go +++ b/internal/delivery/delivery.go @@ -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 diff --git a/internal/delivery/performance_test.go b/internal/delivery/performance_test.go index 3be26b9..44b16a2 100644 --- a/internal/delivery/performance_test.go +++ b/internal/delivery/performance_test.go @@ -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" diff --git a/internal/tempdir/tempdir.go b/internal/tempdir/tempdir.go index a737f66..5b01361 100644 --- a/internal/tempdir/tempdir.go +++ b/internal/tempdir/tempdir.go @@ -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 +} diff --git a/internal/tempdir/tempdir_test.go b/internal/tempdir/tempdir_test.go index fa52ea7..6815fab 100644 --- a/internal/tempdir/tempdir_test.go +++ b/internal/tempdir/tempdir_test.go @@ -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) } }