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" "context"
"io" "io"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" 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. // Target identifies one explicit Go operating-system and architecture pair.
@@ -28,7 +30,7 @@ type BuildRequest struct {
ModuleDir string ModuleDir string
Version string // stable vMAJOR.MINOR.PATCH value injected at link time. Version string // stable vMAJOR.MINOR.PATCH value injected at link time.
Spec BuildSpec // application package, artifact name, and targets. 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. 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. Compiler string // output of the selected local Go toolchain's version command.
} }
// Build compiles the selected targets with Version injected through buildinfo. // Build resolves the selected module's working-tree identity, then compiles the
// It uses the available compatible local Go toolchain, applies ReleaseKit's // selected targets with Version injected through buildinfo. It uses the
// readonly offline build settings, preserves effective cache and tool settings, // available compatible local Go toolchain, applies ReleaseKit's readonly
// and verifies the version command on the Go host before succeeding. An empty // offline build settings, preserves effective cache and tool settings, and
// target set records source identity without running the Go toolchain. Errors // verifies the version command on the Go host before succeeding. Relative
// return partial build facts in the result. // 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) { func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
result, err := internalbuild.Build(ctx, internalbuild.Request{ public := BuildResult{Version: request.Version}
RepositoryDir: request.RepositoryDir, identity, err := releaseidentity.FromWorkingTree(ctx, request.RepositoryDir, request.ModuleDir, request.Version)
ModuleDir: request.ModuleDir, if err != nil {
Version: request.Version, return public, err
Spec: *internalBuildSpec(&request.Spec), }
OutputDir: request.OutputDir, result, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
Output: request.Output, Identity: identity,
Spec: *internalBuildSpec(&request.Spec),
OutputDir: request.OutputDir,
Output: request.Output,
}) })
public := BuildResult{ public = BuildResult{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir, ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath, ModulePath: result.ModulePath,
@@ -82,14 +89,14 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified, SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,
MainPackage: result.MainPackage, MainPackage: result.Settings.MainPackage,
BinaryName: result.BinaryName, BinaryName: result.Settings.BinaryName,
OutputDir: result.OutputDir, OutputDir: result.OutputDir,
Compiler: result.Compiler, 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)), 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}) public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch})
} }
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
@@ -115,8 +122,10 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
} }
func internalBuildResult(result BuildResult) *internalbuild.Result { 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} input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
for _, target := range 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}) converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
} }
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {

View File

@@ -115,7 +115,7 @@ func main() {
t.Setenv("GOARCH", "arm64") t.Setenv("GOARCH", "arm64")
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{ result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v2.3.4", Version: "v1.2.3",
Spec: releasekit.BuildSpec{ Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app", MainPackage: "./cmd/app",
BinaryName: "fixture", BinaryName: "fixture",
@@ -126,7 +126,7 @@ func main() {
if err != nil { if err != nil {
t.Fatalf("Build() error = %v; partial result = %#v", err, result) 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) 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{ result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository, RepositoryDir: repository,
Version: "v2.3.4", Version: "v1.2.3",
Spec: releasekit.BuildSpec{ Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app", MainPackage: "./cmd/app",
BinaryName: "fixture", 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) { func TestBuildRegeneratesOwnedOutputsAndPreservesUnrelatedFiles(t *testing.T) {
const source = `package main const source = `package main
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo") import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
@@ -563,6 +702,18 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
return repository 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) { func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
t.Helper() t.Helper()
root, err := filepath.Abs(".") 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 `Build` resolves `ModuleDir` and the selected module's working-tree declaration
version into `buildinfo.Version`, and returns the compiler identity, normalized once, records the repository's current `HEAD`, injects the selected stable
targets, artifact paths, filenames, and SHA-256 digests. Names follow 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 `<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
target pair is built once, at its first position. Local builds may use modified 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. `SourceVerified` is true only after a complete build observes clean
source and the same HEAD before and after compilation; binary delivery requires source and the same HEAD before and after compilation; binary delivery requires
that fact. This observation is not a source snapshot or attestation guarantee. that fact. This observation is not a source snapshot or attestation guarantee.
The default output directory is `<repository>/dist`; a relative `OutputDir` is Go commands run from `ModuleDir`, so `MainPackage` is interpreted within that
resolved from the repository. Selected artifact filenames are regenerated. Other files in the output module. The default output directory is `<module>/dist`; a relative `OutputDir`
directory are left in place. A failed later build or native version check returns the binaries already is resolved from the module, while an absolute path is used as supplied.
generated in `BuildResult.Artifacts`. 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 Builds use the available compatible Go toolchain selected locally; ReleaseKit
does not download a different toolchain automatically. It disables workspace 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 profile and writes a private receipt alongside its artifacts. `check` and
`publish` run selected builds after the standard suite and any additional `publish` run selected builds after the standard suite and any additional
checks using temporary output outside the worktree. Candidate checks do not 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 For delivery from a tagged checkout, `deliver` builds selected tagged-source
targets into temporary storage by default. Use `--assets DIR` to deliver a 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. | | `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/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/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/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/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. | | `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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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`. outside the repository even with a nested selection and source-local `TMPDIR`.
Run build and runtime-boundary tests. 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 ## Stage 8: Deliver module-local notes and assets to full-tag pages
**Outcome:** Page-only, managed-build, and API prebuilt delivery share committed **Outcome:** Page-only, managed-build, and API prebuilt delivery share committed

View File

@@ -13,11 +13,28 @@ type Target struct {
Arch string Arch string
} }
// Settings contains normalized inputs shared by build execution and evidence.
type Settings struct {
MainPackage string
BinaryName string
Targets []Target
}
type Declaration struct { type Declaration struct {
Target Target Target Target
Filename string 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 // NormalizeTargets removes duplicate targets while preserving their first
// occurrence. // occurrence.
func NormalizeTargets(targets []Target) []Target { 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) { func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}} targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
want := []Declaration{ want := []Declaration{

View File

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

View File

@@ -5,9 +5,11 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" "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/tempdir"
) )
@@ -18,18 +20,32 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
selected := *spec selected := *spec
selected.Targets = append([]Target(nil), spec.Targets...) selected.Targets = append([]Target(nil), spec.Targets...)
return func(ctx context.Context, captured candidate.Candidate, owner *gotool.Owner) error { 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-") temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-")
if err != nil { if err != nil {
return fmt.Errorf("create temporary candidate build output: %w", err) return fmt.Errorf("create temporary candidate build output: %w", err)
} }
defer os.RemoveAll(temporaryDir) defer os.RemoveAll(temporaryDir)
_, err = Build(ctx, Request{ _, err = BuildResolved(ctx, Request{
RepositoryDir: captured.RepositoryDir, Identity: identity,
Version: captured.Version, Spec: selected,
Spec: selected, OutputDir: temporaryDir,
OutputDir: temporaryDir, Output: output,
Output: output, ToolOwner: owner,
ToolOwner: owner,
}) })
if err != nil { if err != nil {
return fmt.Errorf("build candidate: %w", err) return fmt.Errorf("build candidate: %w", err)

View File

@@ -11,16 +11,17 @@ import (
) )
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) { 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 { if len(result.Artifacts) != 0 {
return nil, fmt.Errorf("source-only build result contains artifacts") return nil, fmt.Errorf("source-only build result contains artifacts")
} }
return nil, nil 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") 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)) wantByTarget := make(map[artifact.Target]string, len(declarations))
for _, declaration := range declarations { for _, declaration := range declarations {
target := declaration.Target target := declaration.Target

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
) )
@@ -25,8 +26,10 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
result, err := declaredBuildAssets(internalbuild.Result{ result, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Version: "v1.2.3",
Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}, Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{
{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"},
}),
Artifacts: []internalbuild.Artifact{ Artifacts: []internalbuild.Artifact{
{Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)}, {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)}, {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) t.Fatal(err)
} }
_, err := declaredBuildAssets(internalbuild.Result{ _, 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{{ Artifacts: []internalbuild.Artifact{{
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents), 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/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea" "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/tempdir"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -90,17 +91,24 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
build := request.Prebuilt build := request.Prebuilt
if request.Build != nil && len(request.Build.Targets) != 0 { 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-*") temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-delivery-build-*")
if err != nil { if err != nil {
return result, fmt.Errorf("create temporary delivery build directory: %w", err) return result, fmt.Errorf("create temporary delivery build directory: %w", err)
} }
defer os.RemoveAll(temporaryDir) defer os.RemoveAll(temporaryDir)
built, err := internalbuild.Build(ctx, internalbuild.Request{ built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
RepositoryDir: repositoryDir, Identity: identity,
Version: request.Version, Spec: *request.Build,
Spec: *request.Build, OutputDir: filepath.Join(temporaryDir, "assets"),
OutputDir: filepath.Join(temporaryDir, "assets"), Output: request.Output,
Output: request.Output,
}) })
if err != nil { if err != nil {
return result, fmt.Errorf("build delivery assets: %w", err) 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) 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) current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
if err != nil { if err != nil {
return result, fmt.Errorf("recheck tagged source after build: %w", err) 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 return nil
} }
if len(build.Targets) == 0 { if len(build.Settings.Targets) == 0 {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("page-only delivery cannot include binary assets") 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 { if buildRepository != repositoryDir || build.CandidateCommit != commit || build.Version != version {
return errors.New("build result does not identify the selected tagged source") 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 errors.New("build result does not contain the selected artifacts and complete checksum manifest")
} }
return nil return nil

View File

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

View File

@@ -8,32 +8,77 @@ import (
"strings" "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) { 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) directory, err := os.MkdirTemp("", pattern)
if err != nil { if err != nil {
return "", err 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 { if err != nil {
os.RemoveAll(directory) os.RemoveAll(directory)
return "", err return "", err
} }
directory = absolute outside, err := isOutside(repository, directory)
repository, err := filepath.Abs(repositoryDir) if err != nil {
if err == nil && !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) { os.RemoveAll(directory)
return "", err
}
if outside {
return directory, nil return directory, nil
} }
if err == nil { if err := os.RemoveAll(directory); err != nil {
var relative string return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
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)
}
} }
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 { if err != nil {
os.RemoveAll(directory) os.RemoveAll(directory)
return "", err 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 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) { func TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree(t *testing.T) {
repository := t.TempDir() parent := t.TempDir()
setTemporaryDirectory(t, repository) repository := filepath.Join(parent, "worktree")
if directory, err := Outside(repository, "fixture-"); err == nil { if err := os.Mkdir(repository, 0o700); err != nil {
os.RemoveAll(directory) t.Fatal(err)
t.Fatal("storage created inside worktree") }
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) remaining, err := os.ReadDir(repository)
if err != nil || len(remaining) != 0 { if err != nil || len(remaining) != 1 || remaining[0].Name() != "tmp" {
t.Fatalf("temporary storage left behind: %v %v", remaining, err) t.Fatalf("temporary storage left behind in worktree: %v %v", remaining, err)
} }
} }