51 Commits
v0.3.0 ... main

Author SHA1 Message Date
7d033c5ca1 Prepare v0.4.2 release notes and retire the completed bug report 2026-10-11 02:10:48 +00:00
f076da99b6 Resolve annotated tag events in Woodpecker delivery workflows 2026-10-11 02:07:54 +00:00
3f2a1a1052 Document Audita's annotated tag delivery failure 2026-10-11 01:54:26 +00:00
2d5a8110c6 Prepare v0.4.1 release notes 2026-10-11 00:57:29 +00:00
d42727b038 Map every concurrency audit finding to its roadmap correction 2026-10-11 00:17:06 +00:00
059a79525a Synchronize delivery test fixtures and record the concurrency audit 2026-10-11 00:07:25 +00:00
6176971307 Plan synchronization of delivery test fixtures 2026-10-10 23:56:12 +00:00
8f6e72b0b8 Report source delivery test fixture races 2026-10-10 23:52:34 +00:00
b96262eb0c Remove completed module release roadmaps and review notes 2026-10-10 15:44:03 +00:00
5a0bc6f78b Write release notes and migration guidance for v0.4.0 2026-10-10 15:39:47 +00:00
f27abcb53e Read blob sizes only for selected validation inputs 2026-10-10 15:29:29 +00:00
6c7b77970b Keep committed release note reads offline 2026-10-10 15:20:03 +00:00
80d88a0352 Reuse local asset verification during delivery uploads 2026-10-10 15:17:18 +00:00
c0a3f55f12 Resolve module paths consistently through symlinks 2026-10-10 15:13:00 +00:00
7216fb5f98 Record findings from the follow-up repository audit 2026-10-10 15:06:10 +00:00
6b0b8ce6de Align module release documentation with implemented behavior 2026-10-10 14:36:56 +00:00
8112a001d2 Select module files with ancestor boundary lookups 2026-10-10 14:29:27 +00:00
8552f92ddb Separate example setup from synthetic test releases 2026-10-10 14:27:47 +00:00
c09c7a2ac7 Preserve literal characters in shared file paths 2026-10-10 14:20:54 +00:00
54cc6baf95 Resolve committed module identity from Git objects 2026-10-10 14:20:09 +00:00
7eebbbbfc3 Accept valid local main module paths 2026-10-10 14:12:50 +00:00
c8eb64adec Rediscover Go settings only when Go work needs them 2026-10-10 14:11:48 +00:00
9333573cd8 Accept equivalent checkout paths in build receipts 2026-10-10 14:09:37 +00:00
d44ff1260d Resolve prebuilt assets from the selected module 2026-10-10 14:08:11 +00:00
f059fef320 Preserve build errors and available failure evidence 2026-10-10 14:05:39 +00:00
7555d3e6c9 Resolve source operations from the Git checkout root 2026-10-10 14:03:57 +00:00
750632f80e Record findings from the module release review 2026-10-10 13:53:04 +00:00
90559fe01b Complete module release documentation 2026-10-10 04:03:42 +00:00
b49c1f379e Add mapped multi-module delivery workflow 2026-10-10 03:55:05 +00:00
cd828dc06d Add multi-module release launcher example 2026-10-10 03:46:12 +00:00
c9879cffa2 Use prepared checkouts in root workflows 2026-10-10 03:41:36 +00:00
c0cc50b127 Support full-tag prepared CI delivery 2026-10-10 03:40:10 +00:00
f837871b77 Map CLI profiles to selected modules 2026-10-10 03:30:34 +00:00
d06b62efe2 Version and bind module build receipts 2026-10-10 03:16:40 +00:00
926b74bc0b Deliver module releases to full-tag pages 2026-10-10 03:10:43 +00:00
fdce017aff Build from the selected module 2026-10-10 02:53:38 +00:00
8350cbf9c9 Run validation in the selected module 2026-10-10 02:43:23 +00:00
02896b6e12 Apply module scope to file validation checks 2026-10-10 02:32:28 +00:00
3a99536f9c Select committed validation inputs by module 2026-10-10 02:25:12 +00:00
fa34cbaff3 Publish and reconcile complete module tags 2026-10-10 02:18:02 +00:00
3e516d0ac1 Carry module identity through candidate results 2026-10-10 02:15:13 +00:00
f99fb59cd4 Derive module release identity and accept full Git tags 2026-10-10 02:10:18 +00:00
dbfae9ea81 Plan implementation of individual module releases 2026-10-10 01:34:33 +00:00
f33023f8e0 Resolve standalone build identity from working-tree declarations 2026-10-10 01:21:19 +00:00
5847f40ab5 Record module release ownership rules and the open build decision 2026-10-10 01:19:27 +00:00
f656a2d2e9 Preserve simple consumers and resolve module identity once 2026-10-10 01:05:55 +00:00
578503c1ca Plan a clean module release API and receipt cutover 2026-10-10 01:02:17 +00:00
39dca97f9b Define shared file selection for module releases 2026-10-10 00:59:23 +00:00
283f62813f Plan complete module support across release operations 2026-10-10 00:49:13 +00:00
f89bdf7edf Plan module-scoped release support 2026-10-09 16:19:05 +00:00
86c1da2cf5 Accept ADR 0005 for module-scoped releases 2026-10-09 16:15:54 +00:00
107 changed files with 7000 additions and 1160 deletions

View File

@@ -8,6 +8,13 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
maintainer procedure and prepared Woodpecker workflow for hosted note-page maintainer procedure and prepared Woodpecker workflow for hosted note-page
delivery are documented. ReleaseKit does not declare binary deliverables. delivery are documented. ReleaseKit does not declare binary deliverables.
Release operations can select one Go module within a repository. The repository
root remains the default; a selected module's declared import path and physical
directory determine its complete Git tag and module-local release note. See the
[candidate check](docs/consumers/check.md),
[publication](docs/consumers/publish.md), and
[multi-module example](examples/multi-module/).
See the [consumer adoption guide](docs/consumers/adoption.md) to add ReleaseKit See the [consumer adoption guide](docs/consumers/adoption.md) to add ReleaseKit
as a versioned Go tool or package dependency, and the as a versioned Go tool or package dependency, and the
[v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline. [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline.

View File

@@ -2,9 +2,14 @@ package releasekit
import ( import (
"context" "context"
"fmt"
"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/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
// Target identifies one explicit Go operating-system and architecture pair. // Target identifies one explicit Go operating-system and architecture pair.
@@ -22,10 +27,13 @@ type BuildSpec struct {
// BuildRequest contains the source and output settings for Build. // BuildRequest contains the source and output settings for Build.
type BuildRequest struct { type BuildRequest struct {
RepositoryDir string // source repository whose current HEAD is recorded. RepositoryDir string // directory within the source Git checkout whose HEAD is recorded.
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
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.
} }
@@ -40,6 +48,9 @@ type BuildArtifact struct {
// BuildResult describes the source and artifacts reached by Build. // BuildResult describes the source and artifacts reached by Build.
type BuildResult struct { type BuildResult struct {
RepositoryDir string // absolute source repository path. RepositoryDir string // absolute source repository path.
ModuleDir string // absolute selected module directory.
ModulePath string // selected module's declared Go path.
Tag string // complete tag derived for this version.
CandidateCommit string // Git HEAD observed when the build started. CandidateCommit string // Git HEAD observed when the build started.
// SourceVerified reports clean source and the same HEAD observed before and // SourceVerified reports clean source and the same HEAD observed before and
// after a completed build. It is required for binary delivery, not an attestation. // after a completed build. It is required for binary delivery, not an attestation.
@@ -53,33 +64,60 @@ 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{ spec := *internalBuildSpec(&request.Spec)
RepositoryDir: request.RepositoryDir, result := internalbuild.Result{Version: request.Version}
Version: request.Version, failure := func(err error) (BuildResult, error) {
Spec: *internalBuildSpec(&request.Spec), result.Settings = artifact.NormalizeSettings(spec.MainPackage, spec.BinaryName, spec.Targets)
return publicBuildResult(result), err
}
if !versioncontract.ValidStableVersion(request.Version) {
return failure(fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version))
}
repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil {
return failure(err)
}
result.RepositoryDir = repositoryDir
identity, err := releaseidentity.FromWorkingTree(ctx, repositoryDir, request.ModuleDir, request.Version)
result.ModuleDir, result.ModulePath, result.Tag = identity.ModuleDir, identity.ModulePath, identity.Tag
if err != nil {
return failure(err)
}
result, err = internalbuild.BuildResolved(ctx, internalbuild.Request{
Identity: identity,
Spec: spec,
OutputDir: request.OutputDir, OutputDir: request.OutputDir,
Output: request.Output, Output: request.Output,
}) })
return publicBuildResult(result), err
}
func publicBuildResult(result internalbuild.Result) BuildResult {
public := BuildResult{ public := BuildResult{
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
Tag: result.Tag,
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 {
@@ -90,7 +128,7 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
SHA256: artifact.SHA256, SHA256: artifact.SHA256,
}) })
} }
return public, err return public
} }
func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec { func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
@@ -105,8 +143,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, 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

@@ -18,6 +18,29 @@ import (
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
) )
func TestBuildPreservesInvalidVersionAndDeclarationFailureEvidence(t *testing.T) {
spec := releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "widget", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}}
invalid, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: filepath.Join(t.TempDir(), "absent"), Version: "bad-version", Spec: spec,
})
if !errors.Is(err, releasekit.ErrInvalidVersion) || invalid.Version != "bad-version" || invalid.MainPackage != spec.MainPackage || invalid.BinaryName != spec.BinaryName || len(invalid.Targets) != 1 {
t.Fatalf("invalid version result = %#v, error = %v", invalid, err)
}
repo := newPublishFixture(t)
writeFile(t, repo.work, "nested/go.mod", "go 1.26.0\n")
failed, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repo.work, ModuleDir: "nested", Version: "v1.2.3", Spec: spec,
})
if err == nil || failed.RepositoryDir != repo.work || failed.ModuleDir != filepath.Join(repo.work, "nested") || failed.Version != "v1.2.3" || failed.MainPackage != spec.MainPackage || failed.BinaryName != spec.BinaryName || len(failed.Targets) != 1 || failed.SourceVerified {
t.Fatalf("declaration failure = %#v, error = %v", failed, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := releasekit.Build(ctx, releasekit.BuildRequest{RepositoryDir: repo.work, Version: "v1.2.3", Spec: spec}); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled build error = %v", err)
}
}
func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) { func TestBuildCreatesSelectedTargetsAndInjectsVersion(t *testing.T) {
repository := newBuildConsumer(t, `package main repository := newBuildConsumer(t, `package main
@@ -115,7 +138,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 +149,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 +189,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 +265,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")
@@ -432,6 +594,21 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) { func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
repository := newPublishFixture(t) repository := newPublishFixture(t)
realGo, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
goShimDir := t.TempDir()
goLog := filepath.Join(goShimDir, "go-commands")
goShim := filepath.Join(goShimDir, "go")
shimSource := "#!/bin/sh\nset -eu\nprintf '%s\\n' \"$*\" >> \"$RELEASEKIT_REAL_GO_LOG\"\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n"
if err := os.WriteFile(goShim, []byte(shimSource), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_REAL_GO", realGo)
t.Setenv("RELEASEKIT_REAL_GO_LOG", goLog)
t.Setenv("GOENV", "off")
t.Setenv("PATH", goShimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n") writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n")
gitRun(t, repository.work, "add", "guide.md") gitRun(t, repository.work, "add", "guide.md")
gitRun(t, repository.work, "commit", "-m", "add Markdown guide") gitRun(t, repository.work, "commit", "-m", "add Markdown guide")
@@ -465,6 +642,26 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") { if validations != 1 || candidate.Commit != gitText(t, repository.work, "rev-parse", "HEAD") {
t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations) t.Fatalf("candidate = %#v, validation calls = %d", candidate, validations)
} }
goCommands, err := os.ReadFile(goLog)
if err != nil {
t.Fatal(err)
}
var discoveries, suites, builds, versions int
for _, command := range strings.Split(strings.TrimSpace(string(goCommands)), "\n") {
switch {
case strings.HasPrefix(command, "env "):
discoveries++
case strings.HasPrefix(command, "test "):
suites++
case strings.HasPrefix(command, "build "):
builds++
case command == "version":
versions++
}
}
if discoveries != 2 || suites != 1 || builds != 1 || versions != 1 {
t.Fatalf("Go commands = %q; discovery=%d suite=%d build=%d version=%d, want 2, 1, 1, 1", goCommands, discoveries, suites, builds, versions)
}
if got := gitText(t, repository.work, "status", "--porcelain"); got != "" { if got := gitText(t, repository.work, "status", "--porcelain"); got != "" {
t.Fatalf("candidate build dirtied worktree: %s", got) t.Fatalf("candidate build dirtied worktree: %s", got)
} }
@@ -528,6 +725,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

@@ -18,20 +18,29 @@ var ErrInvalidVersion = candidate.ErrInvalidVersion
var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL
// Candidate identifies one committed local source revision selected for // Candidate identifies one committed local source revision selected for
// validation. // validation. ModuleDir is the absolute selected module directory, ModulePath
// is its declared Go import path, and Tag is the complete derived Git tag.
type Candidate struct { type Candidate struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Commit string Commit string
Version string Version string // selected stable semantic version.
} }
// ReleaseRequest contains the inputs used to validate and optionally build one // ReleaseRequest contains the inputs used to validate and optionally build one
// local candidate. Remote is retained for publication operations; Check makes // local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim. // no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
// RepositoryDir names a directory within the source Git checkout. Operations
// resolve its root before applying repository-relative selections.
RepositoryDir string RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
Product string Product string
Version string Version string // stable semantic version, independent of the derived Git tag.
Remote string Remote string
// ExpectedPushURL optionally requires Publish's resolved push URL to match // ExpectedPushURL optionally requires Publish's resolved push URL to match
// this string exactly before validation or tag creation. Check ignores this // this string exactly before validation or tag creation. Check ignores this
@@ -46,8 +55,17 @@ type ReleaseRequest struct {
} }
// ValidationSpec selects optional shared validation checks. Its zero value // ValidationSpec selects optional shared validation checks. Its zero value
// preserves the current validation behavior. // preserves the current validation behavior. File checks inspect the selected
// module and shared-file inputs. Independent nested modules form automatic
// input boundaries; vendored files remain included. Referenced destinations
// remain available for targeted inspection without expanding the input set.
type ValidationSpec struct { type ValidationSpec struct {
// SharedFiles adds repository-relative committed files to optional file
// checks. Nil selects the optional root COPYRIGHT, LICENSE, and
// docs/oss/attribution.md defaults; an empty non-nil slice selects none.
// Explicit files replace the defaults and must exist even when file checks
// are off.
SharedFiles []string
Go GoValidationSpec Go GoValidationSpec
// GoFormat checks the formatting of committed regular Go source files. It // GoFormat checks the formatting of committed regular Go source files. It
// does not modify repository contents and is disabled when false. // does not modify repository contents and is disabled when false.
@@ -76,7 +94,7 @@ type FileSizeValidationSpec struct {
// LimitBytes is the maximum decimal byte count. Nil selects 1,000,000; // LimitBytes is the maximum decimal byte count. Nil selects 1,000,000;
// zero is a zero-byte limit, and negative values are invalid. // zero is a zero-byte limit, and negative values are invalid.
LimitBytes *int64 LimitBytes *int64
// Exclusions are literal repository-relative file or directory paths. // Exclusions are literal module-relative file or directory paths.
// A directory exclusion includes committed descendants. // A directory exclusion includes committed descendants.
Exclusions []string Exclusions []string
} }
@@ -88,7 +106,8 @@ type ModuleDistributionValidationSpec struct {
// nested paths and symlink entries. It does not select Go's test workspace. // nested paths and symlink entries. It does not select Go's test workspace.
WorkspaceFiles bool WorkspaceFiles bool
// LocalReplacements rejects filesystem replacements in committed go.mod // LocalReplacements rejects filesystem replacements in committed go.mod
// declarations, including nested modules. It does not select Go's workspace. // declarations in the selected inputs, including vendor and explicit shared
// files. It does not select Go's workspace.
LocalReplacements bool LocalReplacements bool
} }
@@ -107,9 +126,12 @@ type GoValidationSpec struct {
} }
// Check validates one local, committed release candidate. It requires a clean // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // worktree and index on main, a selected module declaration and module-local
// and an unused local tag. It always runs the offline Go test suite with the // committed note matching Product and Version, and an unused local tag. An
// available compatible local Go toolchain and Go host target, then calls // empty ModuleDir selects the repository root. The module declaration
// determines the full Git tag for the semantic Version. It always runs the
// offline Go test suite with the available compatible local Go toolchain and
// Go host target, then calls
// AdditionalChecks when supplied. Validation.Go.Race runs that one suite with // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
// the race detector, Validation.Go.FullVet runs go vet after the suite instead // the race detector, Validation.Go.FullVet runs go vet after the suite instead
// of test-time vet, and Validation.Go.CompileTargets compiles all packages for // of test-time vet, and Validation.Go.CompileTargets compiles all packages for
@@ -121,7 +143,7 @@ type GoValidationSpec struct {
// files after formatting. Its zero value disables that distribution check and // files after formatting. Its zero value disables that distribution check and
// does not change Go's workspace environment. // does not change Go's workspace environment.
// Validation.ModuleDistribution.LocalReplacements parses committed go.mod // Validation.ModuleDistribution.LocalReplacements parses committed go.mod
// declarations, including nested modules, and rejects filesystem replacements // declarations in the selected inputs and rejects filesystem replacements
// after workspace-file inspection. Its zero value disables that check. // after workspace-file inspection. Its zero value disables that check.
// Validation.FileSize checks each selected committed blob after module // Validation.FileSize checks each selected committed blob after module
// distribution checks. Its nil value disables size checks; an empty object // distribution checks. Its nil value disables size checks; an empty object
@@ -139,25 +161,34 @@ type GoValidationSpec struct {
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
checked, err := candidate.Check(ctx, candidateRequest(request)) checked, err := candidate.Check(ctx, candidateRequest(request))
if err != nil { if err != nil {
return Candidate{}, convertValidationError(err) return publicCandidate(checked), convertValidationError(err)
} }
return publicCandidate(checked), nil
}
func publicCandidate(checked candidate.Candidate) Candidate {
return Candidate{ return Candidate{
RepositoryDir: checked.RepositoryDir, RepositoryDir: checked.RepositoryDir,
ModuleDir: checked.ModuleDir,
ModulePath: checked.ModulePath,
Tag: checked.Tag,
Commit: checked.Commit, Commit: checked.Commit,
Version: checked.Version, Version: checked.Version,
}, nil }
} }
func candidateRequest(request ReleaseRequest) candidate.Request { func candidateRequest(request ReleaseRequest) candidate.Request {
fileSize := internalFileSizeSettings(request.Validation.FileSize) fileSize := internalFileSizeSettings(request.Validation.FileSize)
result := candidate.Request{ result := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: validation.Settings{ Validation: validation.Settings{
Race: request.Validation.Go.Race, Race: request.Validation.Go.Race,
FullVet: request.Validation.Go.FullVet, FullVet: request.Validation.Go.FullVet,
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)), CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
GoFormat: request.Validation.GoFormat, GoFormat: request.Validation.GoFormat,
Markdown: internalMarkdownSettings(request.Validation.Markdown), Markdown: internalMarkdownSettings(request.Validation.Markdown),
ModuleDistribution: validation.ModuleDistributionSettings{ ModuleDistribution: validation.ModuleDistributionSettings{
@@ -176,6 +207,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
return result return result
} }
func copyStringSlice(values []string) []string {
if values == nil {
return nil
}
return append(make([]string, 0, len(values)), values...)
}
func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings { func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings {
if spec == nil { if spec == nil {
return nil return nil
@@ -203,10 +241,6 @@ func candidateChecks(checks func(context.Context, Candidate) error) func(context
return nil return nil
} }
return func(ctx context.Context, captured candidate.Candidate) error { return func(ctx context.Context, captured candidate.Candidate) error {
return checks(ctx, Candidate{ return checks(ctx, publicCandidate(captured))
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
} }
} }

38
check_request_test.go Normal file
View File

@@ -0,0 +1,38 @@
package releasekit
import "testing"
func TestCandidateRequestPreservesSharedFileSelection(t *testing.T) {
for _, tt := range []struct {
name string
input []string
wantNil bool
wantValues []string
}{
{name: "omitted defaults", input: nil, wantNil: true},
{name: "explicit empty", input: []string{}, wantValues: []string{}},
{name: "explicit paths", input: []string{"LICENSE", "docs/shared.md"}, wantValues: []string{"LICENSE", "docs/shared.md"}},
} {
t.Run(tt.name, func(t *testing.T) {
request := ReleaseRequest{Validation: ValidationSpec{SharedFiles: tt.input}}
mapped := candidateRequest(request).Validation.SharedFiles
if (mapped == nil) != tt.wantNil {
t.Fatalf("mapped shared files nil=%t, want %t", mapped == nil, tt.wantNil)
}
if len(mapped) != len(tt.wantValues) {
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
}
for index, value := range tt.wantValues {
if mapped[index] != value {
t.Fatalf("mapped shared files = %#v, want %#v", mapped, tt.wantValues)
}
}
if len(tt.input) != 0 {
request.Validation.SharedFiles[0] = "changed"
if mapped[0] != tt.wantValues[0] {
t.Fatal("mapped shared-file selection aliases the caller's slice")
}
}
})
}
}

View File

@@ -0,0 +1,103 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestBuildAndPrebuiltDeliveryUseSameDirectoryBase(t *testing.T) {
for _, tt := range []struct {
name, module string
absolute bool
alias bool
}{
{name: "root relative"},
{name: "nested relative", module: "apps/widget"},
{name: "nested absolute", module: "apps/widget", absolute: true},
{name: "nested checkout alias", module: "apps/widget", absolute: true, alias: true},
} {
t.Run(tt.name, func(t *testing.T) {
repository := newDeliveryBuildRepository(t)
moduleDirectory := repository
tag := "v1.2.3"
if tt.module != "" {
moduleDirectory = filepath.Join(repository, filepath.FromSlash(tt.module))
tag = tt.module + "/v1.2.3"
for _, name := range []string{"go.mod", "main.go", "docs/releases/v1.2.3.md"} {
contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name)))
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, tt.module+"/"+name, string(contents))
}
}
writeRepositoryFile(t, repository, ".gitignore", "dist/\n")
state := &deliveryServerState{data: deliveryServerData{tag: tag}}
server := newDeliveryServer(t, state)
defer server.Close()
settings := cli.Settings{Product: "Example Product", ModuleDir: tt.module,
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}
writeDeliveryProfile(t, repository, settings)
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Prepare module source and output exclusion")
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", tag)
output, resolved := "dist", filepath.Join(moduleDirectory, "dist")
if tt.absolute {
output = filepath.Join(t.TempDir(), "assets")
resolved = output
}
var stdout, stderr bytes.Buffer
invocationRepository := repository
if tt.alias {
invocationRepository = filepath.Join(t.TempDir(), "checkout-alias")
if err := os.Symlink(repository, invocationRepository); err != nil {
t.Fatal(err)
}
}
if status := run(context.Background(), []string{"build", "--repo", invocationRepository, "--output", output, "v1.2.3"}, &stdout, &stderr); status != 0 {
t.Fatalf("build status=%d, stderr=%s", status, stderr.String())
}
receipt, err := cli.LoadBuildReceipt(filepath.Join(resolved, cli.BuildReceiptName))
if err != nil || receipt.ModuleDir != moduleDirectory || receipt.Tag != tag {
t.Fatalf("build receipt=%#v, error=%v", receipt, err)
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
args := []string{"deliver", "--repo", invocationRepository, "--assets", output, "v1.2.3"}
stdout.Reset()
stderr.Reset()
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
}
if tt.alias {
if status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", output, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().assetUploads != 2 {
t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.snapshot().assetUploads, stderr.String())
}
}
if tt.module != "" {
cliGitRun(t, repository, "tag", "v1.2.3")
wrongModule := settings
wrongModule.ModuleDir = ""
invocation := cli.Invocation{Repository: repository, AssetsDir: resolved, Version: "v1.2.3"}
requestsBefore := state.snapshot().apiRequests
if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
}
}
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
t.Fatal(err)
}
requestsBefore := state.snapshot().apiRequests
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.snapshot().apiRequests != requestsBefore {
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.snapshot().apiRequests, stderr.String())
}
})
}
}

View File

@@ -0,0 +1,71 @@
package main
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestCandidateCheckUsesCommittedDeclarationSymlink(t *testing.T) {
repository := newCLIRepository(t)
if err := os.Rename(filepath.Join(repository, "go.mod"), filepath.Join(repository, "module.txt")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("module.txt", filepath.Join(repository, "go.mod")); err != nil {
t.Fatal(err)
}
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product"})
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Use committed declaration symlink")
var stdout, stderr bytes.Buffer
if status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 {
t.Fatalf("linked declaration check status=%d stderr=%s", status, stderr.String())
}
}
func TestPageOnlyDeliveryUsesCommittedModuleInSparseCheckout(t *testing.T) {
repository := newCLIRepository(t)
const module = "apps/widget"
writeRepositoryFile(t, repository, module+"/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
writeRepositoryFile(t, repository, module+"/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Add sparse release module")
state := &deliveryServerState{data: deliveryServerData{tag: module + "/v1.2.3"}}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", ModuleDir: module,
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", state.snapshot().tag)
cliGitRun(t, repository, "checkout", "--detach", state.snapshot().tag)
cliGitRun(t, repository, "sparse-checkout", "set", "--cone", "docs")
if _, err := os.Stat(filepath.Join(repository, filepath.FromSlash(module))); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("module fixture is materialized: %v", err)
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
gitOnly := t.TempDir()
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", gitOnly)
for _, args := range [][]string{
{"deliver", "--repo", repository, "v1.2.3"},
{"ci-deliver", "--repo", repository, "--prepared", "--expected-module", module, "--commit", state.snapshot().commit, state.snapshot().tag},
} {
var stdout, stderr bytes.Buffer
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
}
}
}

View File

@@ -0,0 +1,54 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestMultiModuleExampleSetupUsesCurrentSourceTool(t *testing.T) {
root := cliRepositoryRoot(t)
repository := newCLIRepository(t)
copyExampleTree(t, filepath.Join(root, "examples", "multi-module"), repository)
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
environment := append(os.Environ(), "GOTOOLCHAIN=local", "GOWORK=off", "GOPROXY=off", "GOSUMDB=off", "GOFLAGS=")
goCommand := func(directory string, args ...string) string {
t.Helper()
command := exec.Command("go", args...)
command.Dir, command.Env = directory, environment
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("go %q: %v\n%s", args, err, output)
}
return string(output)
}
selections := []struct {
directory, profile, product, version string
}{
{".", "releasekit-root.json", "Example Consumer", "v1.2.3"},
{"integrations/ckconfig", "releasekit-ckconfig.json", "Example Consumer Integration", "v1.2.3"},
{"integrations/core/v2", "releasekit-core-v2.json", "Example Consumer Core v2", "v2.1.0"},
}
for _, selected := range selections {
directory := filepath.Join(repository, filepath.FromSlash(selected.directory))
if selected.directory != "integrations/core/v2" {
writeRepositoryFile(t, repository, selected.directory+"/source.go", "package candidate\n")
}
goCommand(directory, "mod", "edit", "-require="+module+"@v0.0.0", "-replace="+module+"="+root, "-tool="+module+"/cmd/releasekit")
goCommand(directory, "mod", "tidy")
writeRepositoryFile(t, repository, selected.directory+"/docs/releases/"+selected.version+".md", "# "+selected.product+" "+selected.version+"\n\nSource verification fixture.\n")
}
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Select current source in disposable example")
for _, selected := range selections {
output := goCommand(filepath.Join(repository, filepath.FromSlash(selected.directory)), "tool", "releasekit", "check", "--config", selected.profile, selected.version)
if !strings.Contains(output, "is valid") {
t.Fatalf("source-selected check output=%s", output)
}
}
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
t.Fatalf("source verification changed committed fixture: %s", status)
}
}

View File

@@ -14,9 +14,10 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG"
type Command string type Command string
@@ -39,6 +40,9 @@ type Invocation struct {
Remote string Remote string
Commit string Commit string
Version string Version string
ExpectedModule string
ExpectedTag string
Prepared bool
} }
// Parse accepts only commands that have been implemented by the executable. // Parse accepts only commands that have been implemented by the executable.
@@ -67,10 +71,16 @@ func Parse(args []string) (Invocation, error) {
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory") flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
} }
var remote *string var remote *string
expectedModule := ""
if action == PublishAction || action == CIDeliveryAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
}
expectedCommit := "" expectedCommit := ""
prepared := false
if action == CIDeliveryAction { if action == CIDeliveryAction {
remote = flags.String("remote", "origin", "Git remote") remote = flags.String("remote", "origin", "Git remote")
flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit") flags.StringVar(&expectedCommit, "commit", "", "expected tagged commit")
flags.BoolVar(&prepared, "prepared", false, "verify an already prepared tagged checkout without fetching or checkout")
} }
if err := flags.Parse(args[1:]); err != nil { if err := flags.Parse(args[1:]); err != nil {
return Invocation{}, err return Invocation{}, err
@@ -84,11 +94,25 @@ func Parse(args []string) (Invocation, error) {
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) { if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID") return Invocation{}, errors.New("ci-deliver --commit must be a full Git object ID")
} }
version := flags.Arg(0)
expectedTag := ""
if action == CIDeliveryAction {
expectedTag = version
if slash := strings.LastIndex(version, "/"); slash >= 0 {
version = version[slash+1:]
}
if !versioncontract.ValidStableVersion(version) {
return Invocation{}, errors.New("ci-deliver TAG must end in a stable vMAJOR.MINOR.PATCH version")
}
}
if !versioncontract.ValidStableVersion(version) {
return Invocation{}, errors.New("VERSION must be a stable vMAJOR.MINOR.PATCH version")
}
parsedRemote := "" parsedRemote := ""
if remote != nil { if remote != nil {
parsedRemote = *remote parsedRemote = *remote
} }
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0)}, nil return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: version, ExpectedModule: expectedModule, ExpectedTag: expectedTag, Prepared: prepared}, nil
default: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }
@@ -116,6 +140,7 @@ type CheckCommand struct {
// validation selections, and check commands read from a releasekit.json profile. // validation selections, and check commands read from a releasekit.json profile.
type Settings struct { type Settings struct {
Product string `json:"product"` Product string `json:"product"`
ModuleDir string `json:"module_dir,omitempty"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
ExpectedPushURL string `json:"expected_push_url,omitempty"` ExpectedPushURL string `json:"expected_push_url,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
@@ -126,6 +151,9 @@ type Settings struct {
// ValidationSettings selects shared candidate checks in a release profile. // ValidationSettings selects shared candidate checks in a release profile.
type ValidationSettings struct { type ValidationSettings struct {
// SharedFiles selects committed files relative to the repository root.
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
SharedFiles []string `json:"shared_files"`
Go GoValidationSettings `json:"go,omitempty"` Go GoValidationSettings `json:"go,omitempty"`
// GoFormat checks the formatting of committed regular Go source files. // GoFormat checks the formatting of committed regular Go source files.
// Omitted or false disables the check. // Omitted or false disables the check.
@@ -269,9 +297,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
return settings, nil return settings, nil
} }
// RunChecks runs configured argument vectors in repoDir. Each command receives // RunChecks runs configured argument vectors in dir. Each command receives
// its own environment overlay and inherits the remaining process environment. // its own environment overlay and inherits the remaining process environment.
func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error { func RunChecks(ctx context.Context, dir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error {
for i, check := range checks { for i, check := range checks {
environment := make(map[string]string, len(check.Env)+len(ownedEnv)) environment := make(map[string]string, len(check.Env)+len(ownedEnv))
for key, value := range check.Env { for key, value := range check.Env {
@@ -282,7 +310,7 @@ func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, owned
} }
if err := process.Run(ctx, process.Invocation{ if err := process.Run(ctx, process.Invocation{
Args: check.Args, Args: check.Args,
Dir: repoDir, Dir: dir,
Env: environment, Env: environment,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,

View File

@@ -33,6 +33,10 @@ func TestParse(t *testing.T) {
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish) t.Fatalf("Parse(publish) = %#v", publish)
} }
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
}
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"}) build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -47,11 +51,11 @@ func TestParse(t *testing.T) {
if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" { if deliver.Command != DeliveryAction || deliver.Repository != "candidate" || deliver.ConfigPath != "releasekit.json" || deliver.AssetsDir != "assets with spaces" || deliver.Version != "v1.2.6" {
t.Fatalf("Parse(deliver) = %#v", deliver) t.Fatalf("Parse(deliver) = %#v", deliver)
} }
ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.7"}) ciDelivery, err := Parse([]string{"ci-deliver", "--repo", "candidate", "--config", "profiles/release config.json", "--remote", "upstream", "--prepared", "--expected-module", "integrations/ckconfig", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/v1.2.7"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" { if ciDelivery.Command != CIDeliveryAction || ciDelivery.Repository != "candidate" || ciDelivery.ConfigPath != "profiles/release config.json" || ciDelivery.Remote != "upstream" || ciDelivery.Commit != "0123456789abcdef0123456789abcdef01234567" || ciDelivery.Version != "v1.2.7" || ciDelivery.ExpectedTag != "integrations/ckconfig/v1.2.7" || ciDelivery.ExpectedModule != "integrations/ckconfig" || !ciDelivery.Prepared {
t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery) t.Fatalf("Parse(ci-deliver) = %#v", ciDelivery)
} }
ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"}) ciDefault, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "v1.2.8"})
@@ -64,6 +68,9 @@ func TestParse(t *testing.T) {
if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") { if _, err := Parse([]string{"ci-deliver", "--commit", "abc123", "v1.2.3"}); err == nil || !strings.Contains(err.Error(), "full Git object ID") {
t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err) t.Fatalf("Parse(ci-deliver with abbreviated commit) error = %v, want full object ID error", err)
} }
if _, err := Parse([]string{"ci-deliver", "--commit", "0123456789abcdef0123456789abcdef01234567", "integrations/ckconfig/latest"}); err == nil || !strings.Contains(err.Error(), "stable vMAJOR.MINOR.PATCH") {
t.Fatalf("Parse(ci-deliver unstable terminal tag) error = %v", err)
}
if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil { if _, err := Parse([]string{"check", "v1.2.3", "--repo", "repository"}); err == nil {
t.Fatal("Parse accepted a flag after the version") t.Fatal("Parse accepted a flag after the version")
} }
@@ -166,6 +173,56 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
} }
} }
func TestLoadSettingsPreservesSharedFilesSelection(t *testing.T) {
repoDir := t.TempDir()
profileDir := filepath.Join(repoDir, "profiles")
if err := os.MkdirAll(profileDir, 0o700); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
profile string
wantNil bool
wantFile string
}{
{name: "omitted", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{}}`, wantNil: true},
{name: "null", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":null}}`, wantNil: true},
{name: "empty", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":[]}}`},
{name: "selected", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":["../README.md"]}}`, wantFile: "../README.md"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(profileDir, "release.json")
if err := os.WriteFile(path, []byte(test.profile), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, filepath.Join("profiles", "release.json"))
if err != nil {
t.Fatal(err)
}
if settings.ModuleDir != "integrations/ckconfig" {
t.Fatalf("module_dir = %q", settings.ModuleDir)
}
if settings.Validation == nil || (settings.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("shared_files = %#v, want nil=%t", settings.Validation, test.wantNil)
}
if test.wantFile != "" && (len(settings.Validation.SharedFiles) != 1 || settings.Validation.SharedFiles[0] != test.wantFile) {
t.Fatalf("shared_files = %#v, want %q", settings.Validation.SharedFiles, test.wantFile)
}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var roundTrip Settings
if err := json.Unmarshal(encoded, &roundTrip); err != nil {
t.Fatal(err)
}
if (roundTrip.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("serialized shared_files = %#v, want nil=%t; JSON=%s", roundTrip.Validation.SharedFiles, test.wantNil, encoded)
}
})
}
}
func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) { func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") profilePath := filepath.Join(repoDir, "releasekit.json")

View File

@@ -2,7 +2,6 @@ package cli
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -12,10 +11,12 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact" "gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
const BuildReceiptName = ".releasekit-build.json" const BuildReceiptName = ".releasekit-build.json"
const BuildReceiptSchemaVersion = 1
type ReceiptTarget struct { type ReceiptTarget struct {
OS string `json:"os"` OS string `json:"os"`
@@ -31,7 +32,11 @@ type ReceiptArtifact struct {
// BuildReceipt is private local coordination data for generated binaries. // BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct { type BuildReceipt struct {
SchemaVersion int `json:"schema_version"`
RepositoryDir string `json:"repository_dir"` RepositoryDir string `json:"repository_dir"`
ModuleDir string `json:"module_dir"`
ModulePath string `json:"module_path"`
Tag string `json:"tag"`
CandidateCommit string `json:"candidate_commit"` CandidateCommit string `json:"candidate_commit"`
SourceVerified bool `json:"source_verified"` SourceVerified bool `json:"source_verified"`
Version string `json:"version"` Version string `json:"version"`
@@ -49,7 +54,7 @@ func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
return "", fmt.Errorf("resolve build receipt directory: %w", err) return "", fmt.Errorf("resolve build receipt directory: %w", err)
} }
receipt.OutputDir = resolvedDir receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil { if err := validateBuildReceipt(receipt); err != nil {
return "", err return "", err
} }
contents, err := json.MarshalIndent(receipt, "", " ") contents, err := json.MarshalIndent(receipt, "", " ")
@@ -111,41 +116,62 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) {
} }
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err) return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
} }
if err := validateBuildReceipt(receipt, true); err != nil { if err := validateBuildReceipt(receipt); err != nil {
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
return BuildReceipt{}, fmt.Errorf("%w; regenerate it with the updated build command", err)
}
return BuildReceipt{}, err return BuildReceipt{}, err
} }
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) { if !sameDirectory(receipt.OutputDir, filepath.Dir(resolvedPath)) {
return BuildReceipt{}, errors.New("build receipt output directory does not match its location") return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
} }
return receipt, nil return receipt, nil
} }
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool { func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
resolvedRepo, err := filepath.Abs(repositoryDir) if !sameDirectory(receipt.RepositoryDir, repositoryDir) {
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
return false return false
} }
if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName { if receipt.CandidateCommit != commit || receipt.Version != version {
return false return false
} }
want := normalizeReceiptTargets(settings.Targets) want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets))
if len(want) != len(receipt.Targets) { got := artifact.NormalizeSettings(receipt.MainPackage, receipt.BinaryName, parseStoredTargets(receipt.Targets))
if want.MainPackage != got.MainPackage || want.BinaryName != got.BinaryName || len(want.Targets) != len(got.Targets) {
return false return false
} }
for i, target := range want { for i, target := range want.Targets {
if target != receipt.Targets[i] { if target != got.Targets[i] {
return false return false
} }
} }
return true return true
} }
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error { func sameDirectory(first, second string) bool {
firstInfo, err := os.Stat(first)
if err != nil {
return false
}
secondInfo, err := os.Stat(second)
return err == nil && os.SameFile(firstInfo, secondInfo)
}
func validateBuildReceipt(receipt BuildReceipt) error {
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion)
}
if !versioncontract.ValidStableVersion(receipt.Version) { if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version) return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
} }
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) { if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) {
return errors.New("build receipt repository and output directories must be absolute") return errors.New("build receipt repository, module, and output directories must be absolute")
}
if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" {
return errors.New("build receipt is missing module path or full tag")
}
if err := validateRecordedIdentity(receipt); err != nil {
return fmt.Errorf("build receipt has inconsistent module identity: %w", err)
} }
if !validCommitID(receipt.CandidateCommit) { if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit") return errors.New("build receipt has invalid candidate commit")
@@ -191,39 +217,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !validSHA256(artifact.SHA256) { if !validSHA256(artifact.SHA256) {
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename) return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
} }
if verifyFiles { }
if err := verifyReceiptArtifact(artifact); err != nil { return nil
}
func validateRecordedIdentity(receipt BuildReceipt) error {
repositoryDir := filepath.Clean(receipt.RepositoryDir)
moduleDir := filepath.Clean(receipt.ModuleDir)
relative, err := filepath.Rel(repositoryDir, moduleDir)
if err != nil {
return fmt.Errorf("relate module directory to repository: %w", err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return errors.New("module directory is outside repository")
}
if relative == "" {
relative = "."
}
identity, err := releaseidentity.Derive(releaseidentity.Selection{
RepositoryDir: repositoryDir,
ModuleDir: moduleDir,
ModuleSubdir: filepath.ToSlash(relative),
}, []byte("module "+receipt.ModulePath+"\n"), receipt.Version)
if err != nil {
return err return err
} }
} if identity.Tag != receipt.Tag {
return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag)
} }
return nil return nil
} }
func verifyReceiptArtifact(artifact ReceiptArtifact) error { func parseReceiptTargets(targets []string) []artifact.Target {
info, err := os.Lstat(artifact.Path)
if err != nil {
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
}
file, err := os.Open(artifact.Path)
if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err)
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 {
return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256)
}
return nil
}
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
parsed := make([]artifact.Target, 0, len(targets)) parsed := make([]artifact.Target, 0, len(targets))
for _, value := range targets { for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/") goos, goarch, ok := strings.Cut(value, "/")
@@ -232,12 +257,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget {
} }
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch}) parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
} }
normalized := artifact.NormalizeTargets(parsed) return parsed
result := make([]ReceiptTarget, 0, len(normalized))
for _, target := range normalized {
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
} }
return result
func parseStoredTargets(targets []ReceiptTarget) []artifact.Target {
parsed := make([]artifact.Target, 0, len(targets))
for _, target := range targets {
parsed = append(parsed, artifact.Target{OS: target.OS, Arch: target.Arch})
}
return parsed
} }
func validCommitID(value string) bool { func validCommitID(value string) bool {

View File

@@ -3,15 +3,33 @@ package cli
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
) )
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) { func TestBuildReceiptRoundTripBindsRootAndNestedModuleIdentity(t *testing.T) {
for _, test := range []struct {
name string
moduleDir string
modulePath string
tag string
}{
{name: "root", moduleDir: ".", modulePath: "example.com/receipt", tag: "v1.2.3"},
{name: "nested", moduleDir: "integrations/ckconfig", modulePath: "example.com/ckconfig", tag: "integrations/ckconfig/v1.2.3"},
} {
t.Run(test.name, func(t *testing.T) {
outputDir := t.TempDir() outputDir := t.TempDir()
repositoryDir := t.TempDir() repositoryDir := t.TempDir()
moduleDir := repositoryDir
if test.moduleDir != "." {
moduleDir = filepath.Join(repositoryDir, filepath.FromSlash(test.moduleDir))
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
}
filename := "example-v1.2.3-linux-amd64" filename := "example-v1.2.3-linux-amd64"
artifactPath := filepath.Join(outputDir, filename) artifactPath := filepath.Join(outputDir, filename)
contents := []byte("built application") contents := []byte("built application")
@@ -20,7 +38,11 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
} }
digest := sha256.Sum256(contents) digest := sha256.Sum256(contents)
receipt := BuildReceipt{ receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion,
RepositoryDir: repositoryDir, RepositoryDir: repositoryDir,
ModuleDir: moduleDir,
ModulePath: test.modulePath,
Tag: test.tag,
CandidateCommit: strings.Repeat("a", 40), CandidateCommit: strings.Repeat("a", 40),
Version: "v1.2.3", Version: "v1.2.3",
MainPackage: "./cmd/example", MainPackage: "./cmd/example",
@@ -28,10 +50,8 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
Compiler: "go version go1.25.0 linux/amd64", Compiler: "go version go1.25.0 linux/amd64",
Artifacts: []ReceiptArtifact{{ Artifacts: []ReceiptArtifact{{
Path: artifactPath, Path: artifactPath, Filename: filename,
Filename: filename, Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
Target: ReceiptTarget{OS: "linux", Arch: "amd64"},
SHA256: hex.EncodeToString(digest[:]),
}}, }},
} }
path, err := WriteBuildReceipt(outputDir, receipt) path, err := WriteBuildReceipt(outputDir, receipt)
@@ -42,14 +62,27 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
settings := BuildSettings{ settings := BuildSettings{MainPackage: "./cmd/example", BinaryName: "example", Targets: []string{"linux/amd64", "linux/amd64"}}
MainPackage: "./cmd/example",
BinaryName: "example",
Targets: []string{"linux/amd64", "linux/amd64"},
}
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatalf("receipt does not match normalized build settings: %#v", loaded) t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
} }
aliases := t.TempDir()
repositoryAlias, outputAlias := filepath.Join(aliases, "repository"), filepath.Join(aliases, "output")
if err := os.Symlink(repositoryDir, repositoryAlias); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outputDir, outputAlias); err != nil {
t.Fatal(err)
}
if !loaded.MatchesBuild(repositoryAlias, receipt.CandidateCommit, receipt.Version, settings) {
t.Fatal("receipt rejected an alias of its source repository")
}
if _, err := LoadBuildReceipt(filepath.Join(outputAlias, BuildReceiptName)); err != nil {
t.Fatalf("load receipt through output alias: %v", err)
}
if loaded.MatchesBuild(t.TempDir(), receipt.CandidateCommit, receipt.Version, settings) {
t.Fatal("receipt matched a different repository")
}
settings.BinaryName = "other" settings.BinaryName = "other"
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) { if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
t.Fatal("receipt matched changed build settings") t.Fatal("receipt matched changed build settings")
@@ -57,8 +90,16 @@ func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil { if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") { if _, err := LoadBuildReceipt(path); err != nil {
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err) t.Fatalf("receipt loading verified changed artifact bytes: %v", err)
}
if err := os.Remove(artifactPath); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err != nil {
t.Fatalf("receipt loading required artifact bytes: %v", err)
}
})
} }
} }
@@ -68,7 +109,9 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
body string body string
want string want string
}{ }{
{name: "unknown field", body: `{"unexpected":true}`, want: "unknown field"}, {name: "unknown field", body: `{"schema_version":1,"unexpected":true}`, want: "unknown field"},
{name: "unversioned receipt", body: `{}`, want: "regenerate it with the updated build command"},
{name: "unknown schema", body: `{"schema_version":2}`, want: "regenerate it with the updated build command"},
{name: "multiple values", body: `{} {}`, want: "multiple JSON values"}, {name: "multiple values", body: `{} {}`, want: "multiple JSON values"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -82,3 +125,66 @@ func TestLoadBuildReceiptRejectsUnknownFieldsAndMultipleValues(t *testing.T) {
}) })
} }
} }
func TestWriteBuildReceiptRejectsInconsistentRecordedIdentity(t *testing.T) {
repositoryDir := t.TempDir()
moduleDir := filepath.Join(repositoryDir, "integrations", "ckconfig")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
outputDir := t.TempDir()
receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion,
RepositoryDir: repositoryDir, ModuleDir: moduleDir, ModulePath: "example.com/ckconfig", Tag: "v1.2.3",
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
Artifacts: []ReceiptArtifact{{
Path: filepath.Join(outputDir, "helper-v1.2.3-linux-amd64"), Filename: "helper-v1.2.3-linux-amd64",
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: strings.Repeat("a", 64),
}},
}
if _, err := WriteBuildReceipt(outputDir, receipt); err == nil || !strings.Contains(err.Error(), "full tag") {
t.Fatalf("WriteBuildReceipt() error = %v, want inconsistent full tag", err)
}
}
func TestLoadBuildReceiptRejectsMismatchedModuleIdentity(t *testing.T) {
repositoryDir := t.TempDir()
outputDir := t.TempDir()
artifactPath := filepath.Join(outputDir, "helper-v1.2.3-linux-amd64")
contents := []byte("fixture")
if err := os.WriteFile(artifactPath, contents, 0o600); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(contents)
receipt := BuildReceipt{
SchemaVersion: BuildReceiptSchemaVersion,
RepositoryDir: repositoryDir, ModuleDir: repositoryDir, ModulePath: "example.com/receipt", Tag: "v1.2.3",
CandidateCommit: strings.Repeat("a", 40), Version: "v1.2.3", MainPackage: ".", BinaryName: "helper",
OutputDir: outputDir, Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}}, Compiler: "go version fixture",
Artifacts: []ReceiptArtifact{{
Path: artifactPath, Filename: filepath.Base(artifactPath),
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
}},
}
contents, err := json.Marshal(receipt)
if err != nil {
t.Fatal(err)
}
var fields map[string]any
if err := json.Unmarshal(contents, &fields); err != nil {
t.Fatal(err)
}
fields["module_dir"] = filepath.Join(repositoryDir, "integrations", "ckconfig")
contents, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(outputDir, BuildReceiptName)
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "inconsistent module identity") {
t.Fatalf("LoadBuildReceipt() error = %v, want inconsistent module identity", err)
}
}

View File

@@ -0,0 +1,44 @@
package main
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestMainModuleWithoutDomainSupportsCheckBuildAndPageDelivery(t *testing.T) {
repository := newDeliveryBuildRepository(t)
writeRepositoryFile(t, repository, "go.mod", "module example\n\ngo 1.26.0\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Use local main module path")
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
settings := cli.Settings{Product: "Example Product",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}
writeDeliveryProfile(t, repository, settings)
for _, args := range [][]string{
{"check", "--repo", repository, "v1.2.3"},
{"build", "--repo", repository, "--output", filepath.Join(t.TempDir(), "assets"), "v1.2.3"},
} {
var stdout, stderr bytes.Buffer
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || !strings.Contains(stdout.String(), "module example version v1.2.3 tag v1.2.3") {
t.Fatalf("%s status=%d stdout=%s stderr=%s", args[0], status, stdout.String(), stderr.String())
}
}
settings.Build = nil
writeDeliveryProfile(t, repository, settings)
state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
if status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr); status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
t.Fatalf("page delivery status=%d creates=%d uploads=%d stderr=%s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
}
}

View File

@@ -14,6 +14,9 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit" "gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
func main() { func main() {
@@ -29,6 +32,13 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage) fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage)
return 2 return 2
} }
if invocation.Command != cli.VersionCommand {
invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 1
}
}
switch invocation.Command { switch invocation.Command {
case cli.VersionCommand: case cli.VersionCommand:
@@ -42,11 +52,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
request := candidateRequest(invocation, settings, stderr) request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction { if invocation.Command == cli.PublishAction {
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2
}
request.ExpectedPushURL = settings.ExpectedPushURL request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), invocation.Version)
result, err := releasekit.Publish(ctx, request) result, err := releasekit.Publish(ctx, request)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err)) fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -55,18 +69,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "published %s at %s (tag object %s)\n", result.Version, result.CandidateCommit, result.TagObject) fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0 return 0
} }
candidate, err := releasekit.Check(ctx, request) candidate, err := releasekit.Check(ctx, request)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "check failed: %s\n", candidateFailureText(err)) fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit) fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
return 0 return 0
case cli.BuildAction: case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
@@ -80,6 +94,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
result, err := releasekit.Build(ctx, releasekit.BuildRequest{ result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version, Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build), Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir, OutputDir: invocation.OutputDir,
@@ -92,7 +107,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
} }
if err != nil { if err != nil {
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err) fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256) fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
} }
@@ -102,7 +117,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
if len(result.Targets) == 0 { if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for %s at %s\n", result.Version, result.CandidateCommit) fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
return 0 return 0
} }
receipt := receiptFromBuild(result) receipt := receiptFromBuild(result)
@@ -120,7 +135,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings") fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1 return 1
} }
fmt.Fprintf(stdout, "built %d artifacts for %s at %s with %s\n", len(result.Artifacts), result.Version, result.CandidateCommit, result.Compiler) fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256) fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
} }
@@ -137,6 +152,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest { func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
request := releasekit.ReleaseRequest{ request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
Remote: settings.Remote, Remote: settings.Remote,
@@ -147,9 +163,13 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
if len(settings.Checks) != 0 { if len(settings.Checks) != 0 {
checks := settings.Checks checks := settings.Checks
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error { request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{ return cli.RunChecks(ctx, candidate.ModuleDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version, "RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit, "RELEASEKIT_CANDIDATE": candidate.Commit,
"RELEASEKIT_REPOSITORY_DIR": candidate.RepositoryDir,
"RELEASEKIT_MODULE_DIR": candidate.ModuleDir,
"RELEASEKIT_MODULE_PATH": candidate.ModulePath,
"RELEASEKIT_TAG": candidate.Tag,
}, output, output) }, output, output)
} }
} }
@@ -161,6 +181,7 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
return releasekit.ValidationSpec{} return releasekit.ValidationSpec{}
} }
result := releasekit.ValidationSpec{ result := releasekit.ValidationSpec{
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
GoFormat: settings.GoFormat, GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{ ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles, WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
@@ -199,6 +220,42 @@ func candidateFailureText(err error) string {
return err.Error() return err.Error()
} }
func checkExpectedModule(ctx context.Context, repositoryDir, expectedModule, profileModule string) error {
if expectedModule == "" {
return nil
}
commit, err := git.CaptureHead(ctx, repositoryDir)
if err != nil {
return err
}
expected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, expectedModule, commit)
if err != nil {
return fmt.Errorf("normalize expected module %q: %w", expectedModule, err)
}
selected, err := releaseidentity.ResolveCommittedSelection(ctx, repositoryDir, profileModule, commit)
if err != nil {
return fmt.Errorf("normalize profile module %q: %w", profileModule, err)
}
if expected.ModuleSubdir != selected.ModuleSubdir {
return fmt.Errorf("expected module %q resolves to %q, but the profile selects %q", expectedModule, expected.ModuleSubdir, selected.ModuleSubdir)
}
return nil
}
func selectedModuleLabel(moduleDir string) string {
if moduleDir == "" {
return "."
}
return moduleDir
}
func copyStringsPreservingEmpty(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil { if err != nil {
@@ -211,9 +268,10 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
Version: invocation.Version, Tag: invocation.ExpectedTag,
Remote: invocation.Remote, Remote: invocation.Remote,
ExpectedCommit: invocation.Commit, ExpectedCommit: invocation.Commit,
VerifyOnly: invocation.Prepared,
}) })
if err != nil { if err != nil {
fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err) fmt.Fprintf(stderr, "prepare tagged checkout: %v\n", err)
@@ -228,12 +286,19 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
fmt.Fprintf(stderr, "load profile: %v\n", err) fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2 return 2
} }
if err := checkExpectedModule(ctx, invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2
}
destination, status := deliveryDestination(settings, stderr) destination, status := deliveryDestination(settings, stderr)
if status != 0 { if status != 0 {
return status return status
} }
if settings.Build != nil && len(settings.Build.Targets) != 0 { if settings.Build != nil && len(settings.Build.Targets) != 0 {
if err := releasekit.PrepareBuildDependencies(ctx, invocation.Repository); err != nil { if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
}); err != nil {
fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err) fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err)
return 1 return 1
} }
@@ -282,10 +347,17 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
} }
if hasTargets { if hasTargets {
if invocation.AssetsDir != "" { if invocation.AssetsDir != "" {
assetsDir := invocation.AssetsDir commit, err := git.CaptureHead(ctx, invocation.Repository)
if !filepath.IsAbs(assetsDir) { if err != nil {
assetsDir = filepath.Join(invocation.Repository, assetsDir) fmt.Fprintf(stderr, "capture asset source commit: %v\n", err)
return 1
} }
selection, err := releaseidentity.ResolveCommittedSelection(ctx, invocation.Repository, settings.ModuleDir, commit)
if err != nil {
fmt.Fprintf(stderr, "resolve asset module directory: %v\n", err)
return 1
}
assetsDir := artifact.OutputDirectory(selection.ModuleDir, invocation.AssetsDir)
receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)) receipt, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "load verified build receipt: %v\n", err) fmt.Fprintf(stderr, "load verified build receipt: %v\n", err)
@@ -301,15 +373,17 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
ExpectedTag: invocation.ExpectedTag,
Destination: destination, Destination: destination,
Build: buildSpec, Build: buildSpec,
Prebuilt: prebuilt, Prebuilt: prebuilt,
Output: stderr, Output: stderr,
}) })
if err != nil { if err != nil {
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err) fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
writeDeliveryAssets(stderr, result) writeDeliveryAssets(stderr, result)
writeDeliveryRecovery(stderr, result) writeDeliveryRecovery(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
@@ -320,7 +394,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "delivered %s at %s (state %s)\n", result.Version, result.CandidateCommit, result.State) fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
if result.ReleaseURL != "" { if result.ReleaseURL != "" {
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL) fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
} }
@@ -342,13 +416,13 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) { func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
switch result.State { switch result.State {
case releasekit.DeliveryNotAttempted: case releasekit.DeliveryNotAttempted:
fmt.Fprintln(output, "recovery: fix the local or preflight failure before retrying; no remote write was attempted") fmt.Fprintf(output, "recovery: fix the local or preflight failure for module %s version %s tag %s before retrying; no remote write was attempted\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryPartial: case releasekit.DeliveryPartial:
fmt.Fprintln(output, "recovery: fix the reported cause, then retry this version") fmt.Fprintf(output, "recovery: fix the reported cause, then retry module %s version %s tag %s\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryConflict: case releasekit.DeliveryConflict:
fmt.Fprintln(output, "recovery: investigate conflicting hosted content; use a new version if the source or published contents need correction") fmt.Fprintf(output, "recovery: investigate conflicting hosted content for module %s version %s tag %s; use a new version if the source or published contents need correction\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryUnknown: case releasekit.DeliveryUnknown:
fmt.Fprintln(output, "recovery: resolve the remote state before retrying") fmt.Fprintf(output, "recovery: resolve the remote state for module %s version %s tag %s before retrying\n", result.ModulePath, result.Version, result.Tag)
default: default:
return return
} }
@@ -359,7 +433,8 @@ func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult { func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &releasekit.BuildResult{ result := &releasekit.BuildResult{
RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified, RepositoryDir: receipt.RepositoryDir, ModuleDir: receipt.ModuleDir, ModulePath: receipt.ModulePath, Tag: receipt.Tag,
CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified,
Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName, Version: receipt.Version, MainPackage: receipt.MainPackage, BinaryName: receipt.BinaryName,
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler, OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
} }
@@ -393,7 +468,11 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt { func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
SchemaVersion: cli.BuildReceiptSchemaVersion,
RepositoryDir: result.RepositoryDir, RepositoryDir: result.RepositoryDir,
ModuleDir: result.ModuleDir,
ModulePath: result.ModulePath,
Tag: result.Tag,
CandidateCommit: result.CandidateCommit, CandidateCommit: result.CandidateCommit,
SourceVerified: result.SourceVerified, SourceVerified: result.SourceVerified,
Version: result.Version, Version: result.Version,

View File

@@ -15,6 +15,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
@@ -51,6 +52,13 @@ func TestUnknownCommand(t *testing.T) {
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable() raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) repository := newCLIRepository(t)
moduleDir := filepath.Join(repository, "integrations", "ckconfig")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/fixture_test.go", "package ckconfig\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/race_fixture_test.go", "//go:build race\n\npackage ckconfig\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestRaceBuildSelection(t *testing.T) {\n\tmarker := os.Getenv(\"RELEASEKIT_RACE_MARKER\")\n\tif marker == \"\" { return }\n\tif err := os.WriteFile(marker, []byte(\"race enabled\"), 0o600); err != nil { t.Fatal(err) }\n}\n")
cliGitRun(t, repository, "add", "integrations/ckconfig")
cliGitRun(t, repository, "commit", "-m", "add nested module candidate")
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") profilePath := filepath.Join(t.TempDir(), "profile with spaces.json")
orderPath := filepath.Join(t.TempDir(), "check-order") orderPath := filepath.Join(t.TempDir(), "check-order")
t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath) t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath)
@@ -71,6 +79,7 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
ModuleDir: "integrations/ckconfig",
Remote: "origin", Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")}, Checks: []cli.CheckCommand{check("first"), check("second")},
} }
@@ -102,7 +111,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want { if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want {
t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String()) t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String())
} }
if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want { if !strings.Contains(stderr.String(), fmt.Sprintf("%s|%s|%s|example.com/ckconfig|integrations/ckconfig/v1.2.3", moduleDir, repository, moduleDir)) {
t.Fatalf("configured checks did not receive the selected module directory and identity: %q", stderr.String())
}
if want := fmt.Sprintf("local candidate module example.com/ckconfig version v1.2.3 tag integrations/ckconfig/v1.2.3 at %s is valid\n", commit); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want) t.Fatalf("stdout = %q, want %q", stdout.String(), want)
} }
if raceSelected { if raceSelected {
@@ -154,6 +166,40 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
} }
} }
func TestCandidateRequestPreservesModuleAndSharedFileSelection(t *testing.T) {
for _, test := range []struct {
name string
shared []string
wantNil bool
wantValues []string
}{
{name: "nil", wantNil: true},
{name: "empty", shared: []string{}},
{name: "selected", shared: []string{"README.md", "docs/policy.md"}, wantValues: []string{"README.md", "docs/policy.md"}},
} {
t.Run(test.name, func(t *testing.T) {
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{
ModuleDir: "integrations/ckconfig",
Validation: &cli.ValidationSettings{SharedFiles: test.shared},
}, nil)
if request.ModuleDir != "integrations/ckconfig" {
t.Fatalf("module selection = %q", request.ModuleDir)
}
if (request.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("mapped shared_files = %#v, want nil=%t", request.Validation.SharedFiles, test.wantNil)
}
if len(request.Validation.SharedFiles) != len(test.wantValues) {
t.Fatalf("mapped shared_files = %#v, want %#v", request.Validation.SharedFiles, test.wantValues)
}
for i, path := range test.wantValues {
if request.Validation.SharedFiles[i] != path {
t.Fatalf("mapped shared_files = %#v, want %#v", request.Validation.SharedFiles, test.wantValues)
}
}
})
}
}
func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) { func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
@@ -172,6 +218,33 @@ func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) {
} }
} }
func TestPublishExpectedModuleRejectsMismatchBeforeProductCommands(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
marker := filepath.Join(t.TempDir(), "product-command-ran")
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
Checks: []cli.CheckCommand{{Args: []string{"sh", "-c", "touch " + marker}}},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", "releasekit.json", "integrations/ckconfig/go.mod")
cliGitRun(t, repository, "commit", "-m", "configure root release profile")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publish", "--repo", repository, "--expected-module", "integrations/ckconfig", "v1.2.3"}, &stdout, &stderr)
if status != 2 || !strings.Contains(stderr.String(), "profile module selection") {
t.Fatalf("publish status=%d stdout=%q stderr=%q, want module-selection error", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("product command ran despite module mismatch: stat error=%v", err)
}
if err := exec.Command("git", "-C", repository, "show-ref", "--verify", "refs/tags/v1.2.3").Run(); err == nil {
t.Fatal("module mismatch created a release tag")
}
}
func TestCandidateCommandsRunMarkdownProfile(t *testing.T) { func TestCandidateCommandsRunMarkdownProfile(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{ profile, err := json.Marshal(cli.Settings{
@@ -328,10 +401,10 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
} }
commit := cliGitOutput(t, repository, "rev-parse", "HEAD") commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
if want := fmt.Sprintf("published v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want { if want := fmt.Sprintf("published v1.2.3 as v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want) t.Fatalf("stdout = %q, want %q", stdout.String(), want)
} }
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
} }
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
@@ -351,6 +424,47 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
} }
} }
func TestBuildCommandUsesProfileModuleSelection(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
ModuleDir: "integrations/ckconfig",
Build: &cli.BuildSettings{},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 0 {
t.Fatalf("build status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "no build targets selected for module example.com/ckconfig version v1.2.3 tag integrations/ckconfig/v1.2.3") {
t.Fatalf("build did not use profile module selection: %q", stdout.String())
}
}
func TestProfileSharedFilesRequireCommittedFilesWithoutEnablingChecks(t *testing.T) {
repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{SharedFiles: []string{"docs/shared.md"}},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure an explicit shared file")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "shared-files") || !strings.Contains(stderr.String(), "docs/shared.md") {
t.Fatalf("check status=%d stdout=%q stderr=%q, want missing shared-files failure", status, stdout.String(), stderr.String())
}
}
func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) { func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) {
repository := newCLIBuildRepository(t, `package main repository := newCLIBuildRepository(t, `package main
@@ -381,7 +495,7 @@ func main() {
BinaryName: "fixture", BinaryName: "fixture",
Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}, Targets: []string{fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)},
} }
if !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 { if receipt.SchemaVersion != cli.BuildReceiptSchemaVersion || !receipt.MatchesBuild(repository, commit, "v1.2.3", settings) || len(receipt.Artifacts) != 1 {
t.Fatalf("build receipt = %#v", receipt) t.Fatalf("build receipt = %#v", receipt)
} }
if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) { if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) {
@@ -440,7 +554,12 @@ func TestCandidateCheckHelper(t *testing.T) {
os.Exit(9) os.Exit(9)
} }
} }
fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE")) workdir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(9)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s|%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE"), workdir, os.Getenv("RELEASEKIT_REPOSITORY_DIR"), os.Getenv("RELEASEKIT_MODULE_DIR"), os.Getenv("RELEASEKIT_MODULE_PATH"), os.Getenv("RELEASEKIT_TAG"))
os.Exit(0) os.Exit(0)
} }
@@ -523,7 +642,7 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs. cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs.
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
for attempt := 0; attempt < 2; attempt++ { for attempt := 0; attempt < 2; attempt++ {
@@ -536,11 +655,11 @@ func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
t.Fatalf("delivery output = %q", stdout.String()) t.Fatalf("delivery output = %q", stdout.String())
} }
} }
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 { if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 || state.snapshot().assetListReads != 0 {
t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads) t.Fatalf("page-only calls = creates:%d asset uploads:%d asset reads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads, state.snapshot().assetListReads)
} }
if state.noteBody != "# Example Product v1.2.3\n\nRelease note.\n" { if state.snapshot().noteBody != "# Example Product v1.2.3\n\nRelease note.\n" {
t.Fatalf("hosted note body = %q", state.noteBody) t.Fatalf("hosted note body = %q", state.snapshot().noteBody)
} }
} }
@@ -557,7 +676,7 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
cliGitRun(t, repository, "checkout", "--detach", "v1.2.3") cliGitRun(t, repository, "checkout", "--detach", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
@@ -571,14 +690,15 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
t.Fatalf("second delivery did not report matching assets: %q", stdout.String()) t.Fatalf("second delivery did not report matching assets: %q", stdout.String())
} }
} }
if state.releaseCreates != 1 || state.assetUploads != 2 { observed := state.snapshot()
t.Fatalf("delivery calls = creates:%d asset uploads:%d", state.releaseCreates, state.assetUploads) if observed.releaseCreates != 1 || observed.assetUploads != 2 {
t.Fatalf("delivery calls = creates:%d asset uploads:%d", observed.releaseCreates, observed.assetUploads)
} }
if _, ok := state.assets["helper-v1.2.3-linux-amd64"]; !ok { if _, ok := observed.assets["helper-v1.2.3-linux-amd64"]; !ok {
t.Fatalf("binary was not uploaded: %#v", state.assets) t.Fatalf("binary was not uploaded: %#v", observed.assets)
} }
if _, ok := state.assets["SHA256SUMS"]; !ok { if _, ok := observed.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not uploaded: %#v", state.assets) t.Fatalf("checksum manifest was not uploaded: %#v", observed.assets)
} }
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
t.Fatalf("facade-owned build output remains after delivery: entries=%v error=%v", entries, err) t.Fatalf("facade-owned build output remains after delivery: entries=%v error=%v", entries, err)
@@ -599,19 +719,21 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.commit = expectedCommit state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"}) writeDeliveryProfileAt(t, repository, "profiles/ci-delivery.json", cli.Settings{Product: "Later Branch Profile"})
cliGitRun(t, repository, "checkout", "--detach", expectedCommit)
cliGitRun(t, repository, "remote", "remove", "origin")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
t.Setenv("GOPROXY", "off") t.Setenv("GOPROXY", "off")
t.Setenv("GOSUMDB", "off") t.Setenv("GOSUMDB", "off")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
args := []string{"ci-deliver", "--repo", repository, "--config", "profiles/ci-delivery.json", "--commit", expectedCommit, "v1.2.3"} args := []string{"ci-deliver", "--repo", repository, "--config", "profiles/ci-delivery.json", "--prepared", "--commit", expectedCommit, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
} }
if state.releaseCreates != 1 || state.apiRequests == 0 { if state.snapshot().releaseCreates != 1 || state.snapshot().apiRequests == 0 {
t.Fatalf("page delivery did not reach the server: creates=%d requests=%d", state.releaseCreates, state.apiRequests) t.Fatalf("page delivery did not reach the server: creates=%d requests=%d", state.snapshot().releaseCreates, state.snapshot().apiRequests)
} }
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != expectedCommit { if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != expectedCommit {
t.Fatalf("prepared HEAD = %s, want %s", got, expectedCommit) t.Fatalf("prepared HEAD = %s, want %s", got, expectedCommit)
@@ -632,7 +754,7 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.commit = expectedCommit state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
shimDir := t.TempDir() shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked") markerPath := filepath.Join(shimDir, "go-invoked")
@@ -655,8 +777,57 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
if _, err := os.Stat(markerPath); !os.IsNotExist(err) { if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("Go was invoked before commit agreement: %v", err) t.Fatalf("Go was invoked before commit agreement: %v", err)
} }
if state.apiRequests != 0 { if state.snapshot().apiRequests != 0 {
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests) t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
}
}
func TestCIDeliverRejectsExpectedModuleMismatchBeforeDependencyPreparation(t *testing.T) {
repository := newCLIRepository(t)
if err := os.MkdirAll(filepath.Join(repository, "integrations", "other"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(repository, "integrations", "expected"), 0o700); err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "integrations/other/go.mod", "module example.com/other\n\ngo 1.25.0\n")
writeRepositoryFile(t, repository, "integrations/expected/go.mod", "module example.com/expected\n\ngo 1.25.0\n")
cliGitRun(t, repository, "add", "integrations")
cliGitRun(t, repository, "commit", "-m", "add module profiles")
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
ModuleDir: "integrations/other",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}},
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
expectedCommit := pushTaggedCLIRepository(t, repository)
state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked")
goShim := "#!/bin/sh\n: > \"$RELEASEKIT_GO_MARKER\"\nexit 99\n"
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(goShim), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_GO_MARKER", markerPath)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
args := []string{"ci-deliver", "--repo", repository, "--expected-module", "integrations/expected", "--commit", expectedCommit, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 2 {
t.Fatalf("run() status=%d stdout=%q stderr=%q, want profile-selection error", status, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "profile module selection") {
t.Fatalf("stderr=%q, want profile-selection diagnostic", stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("Go was invoked before profile agreement: %v", err)
}
if state.snapshot().apiRequests != 0 {
t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
} }
} }
@@ -671,18 +842,18 @@ func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.commit = expectedCommit state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
args := []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"} args := []string{"ci-deliver", "--repo", repository, "--commit", expectedCommit, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String()) t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
} }
if state.releaseCreates != 1 || state.assetUploads != 2 { if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
t.Fatalf("delivery counts = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) t.Fatalf("delivery counts = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
} }
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok { if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
t.Fatalf("binary not uploaded: %#v", state.assets) t.Fatalf("binary not uploaded: %#v", state.snapshot().assets)
} }
} }
@@ -697,7 +868,7 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
expectedCommit := pushTaggedCLIRepository(t, repository) expectedCommit := pushTaggedCLIRepository(t, repository)
state.commit = expectedCommit state.update(func(state *deliveryServerData) { state.commit = expectedCommit })
shimDir := t.TempDir() shimDir := t.TempDir()
markerPath := filepath.Join(shimDir, "go-invoked") markerPath := filepath.Join(shimDir, "go-invoked")
@@ -719,8 +890,8 @@ func TestCIDeliverValidatesCredentialsBeforePreparingDependencies(t *testing.T)
if _, err := os.Stat(markerPath); !os.IsNotExist(err) { if _, err := os.Stat(markerPath); !os.IsNotExist(err) {
t.Fatalf("Go was invoked before credential validation: %v", err) t.Fatalf("Go was invoked before credential validation: %v", err)
} }
if state.apiRequests != 0 { if state.snapshot().apiRequests != 0 {
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests) t.Fatalf("hosted API requests = %d, want 0", state.snapshot().apiRequests)
} }
} }
@@ -735,7 +906,7 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
} }
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "uncommitted.txt"), []byte("dirty"), 0600); err != nil { if err := os.WriteFile(filepath.Join(repository, "uncommitted.txt"), []byte("dirty"), 0600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -743,8 +914,8 @@ func TestDeliverRejectsInvalidTaggedSourceBeforeManagedBuild(t *testing.T) {
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.apiRequests != 0 || strings.Contains(stderr.String(), "building ") { if status != 1 || state.snapshot().apiRequests != 0 || strings.Contains(stderr.String(), "building ") {
t.Fatalf("invalid-source delivery status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String()) t.Fatalf("invalid-source delivery status=%d requests=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, stdout.String(), stderr.String())
} }
} }
@@ -760,7 +931,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{ {
name: "partial", name: "partial",
status: 1, status: 1,
recovery: []string{"recovery: fix the reported cause", "retry this version"}, recovery: []string{"recovery: fix the reported cause", "retry module example.com/cli-candidate version v1.2.3 tag v1.2.3"},
assetObservation: "pending helper-v1.2.3-linux-amd64", assetObservation: "pending helper-v1.2.3-linux-amd64",
}, },
{ {
@@ -773,7 +944,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{ {
name: "unknown", name: "unknown",
status: 3, status: 3,
recovery: []string{"resolve the remote state before retrying"}, recovery: []string{"recovery: resolve the remote state for module example.com/cli-candidate version v1.2.3 tag v1.2.3"},
assetObservation: "uncertain helper-v1.2.3-linux-amd64", assetObservation: "uncertain helper-v1.2.3-linux-amd64",
missingObservation: "pending SHA256SUMS", missingObservation: "pending SHA256SUMS",
}, },
@@ -789,16 +960,18 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
Build: &build, Build: &build,
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
assetsDir, _ := writeDeliveryReceipt(t, repository, build) assetsDir, _ := writeDeliveryReceipt(t, repository, build)
switch test.name { switch test.name {
case "partial": case "partial":
state.failAssetName = "helper-v1.2.3-linux-amd64" state.update(func(state *deliveryServerData) { state.failAssetName = "helper-v1.2.3-linux-amd64" })
case "conflict": case "conflict":
state.update(func(state *deliveryServerData) {
state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes") state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes")
})
case "unknown": case "unknown":
state.ambiguousAssetName = "helper-v1.2.3-linux-amd64" state.update(func(state *deliveryServerData) { state.ambiguousAssetName = "helper-v1.2.3-linux-amd64" })
} }
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -826,17 +999,17 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) { func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{remoteCommit: strings.Repeat("0", 40)} state := &deliveryServerState{data: deliveryServerData{remoteCommit: strings.Repeat("0", 40)}}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.releaseCreates != 0 || state.assetUploads != 0 { if status != 1 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 {
t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String()) t.Fatalf("wrong hosted tag status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stdout.String(), stderr.String())
} }
} }
@@ -846,13 +1019,13 @@ func TestDeliverRequiresCredentialBeforeRemoteRequests(t *testing.T) {
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_TEST_DELIVERY_TOKEN"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "") t.Setenv("RELEASEKIT_TEST_DELIVERY_TOKEN", "")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 2 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") { if status != 2 || state.snapshot().apiRequests != 0 || !strings.Contains(stderr.String(), "RELEASEKIT_TEST_DELIVERY_TOKEN") {
t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.apiRequests, stdout.String(), stderr.String()) t.Fatalf("missing credential status=%d requests=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, stdout.String(), stderr.String())
} }
} }
@@ -862,7 +1035,7 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(repository, "later.txt"), []byte("later commit"), 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -872,16 +1045,16 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(t *testing.T) {
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 { if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 {
t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.apiRequests, state.releaseCreates, stdout.String(), stderr.String()) t.Fatalf("mismatched checkout status=%d requests=%d creates=%d stdout=%q stderr=%q", status, state.snapshot().apiRequests, state.snapshot().releaseCreates, stdout.String(), stderr.String())
} }
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.commit { if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3"); got != state.snapshot().commit {
t.Fatalf("delivery moved tag from %s to %s", state.commit, got) t.Fatalf("delivery moved tag from %s to %s", state.snapshot().commit, got)
} }
} }
func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) { func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testing.T) {
for _, mode := range []string{"valid receipt", "tampered", "stale settings"} { for _, mode := range []string{"valid receipt", "tampered", "missing artifact", "stale settings"} {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}
@@ -890,7 +1063,7 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} current := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
settings := cli.Settings{Product: "Example Product", Build: &current, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} settings := cli.Settings{Product: "Example Product", Build: &current, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
receiptSettings := current receiptSettings := current
if mode == "stale settings" { if mode == "stale settings" {
@@ -902,20 +1075,35 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
t.Fatal(err) t.Fatal(err)
} }
} }
if mode == "missing artifact" {
if err := os.Remove(artifactPath); err != nil {
t.Fatal(err)
}
}
if mode == "tampered" || mode == "missing artifact" {
if _, err := cli.LoadBuildReceipt(filepath.Join(assetsDir, cli.BuildReceiptName)); err != nil {
t.Fatalf("receipt loading performed artifact verification: %v", err)
}
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
if mode == "valid receipt" { if mode == "valid receipt" {
if status != 0 || state.releaseCreates != 1 || state.assetUploads != 2 { if status != 0 || state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.releaseCreates, state.assetUploads, stdout.String(), stderr.String()) t.Fatalf("valid receipt status=%d creates=%d uploads=%d stdout=%q stderr=%q", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stdout.String(), stderr.String())
} }
if _, err := os.Stat(artifactPath); err != nil { if _, err := os.Stat(artifactPath); err != nil {
t.Fatalf("prebuilt artifact was not retained: %v", err) t.Fatalf("prebuilt artifact was not retained: %v", err)
} }
return return
} }
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 { if status != 1 || state.snapshot().apiRequests != 0 || state.snapshot().releaseCreates != 0 || state.snapshot().assetUploads != 0 {
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.apiRequests, stdout.String(), stderr.String()) t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.snapshot().apiRequests, stdout.String(), stderr.String())
}
if mode == "tampered" || mode == "missing artifact" {
if strings.Contains(stderr.String(), "load verified build receipt") {
t.Fatalf("%s artifact was rejected while loading its receipt: %q", mode, stderr.String())
}
} }
}) })
} }
@@ -937,7 +1125,9 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
digest := sha256.Sum256(content) digest := sha256.Sum256(content)
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"} target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
receipt := cli.BuildReceipt{ receipt := cli.BuildReceipt{
RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"), SchemaVersion: cli.BuildReceiptSchemaVersion,
RepositoryDir: resolvedRepository, ModuleDir: resolvedRepository, ModulePath: "example.com/cli-candidate", Tag: "v1.2.3",
CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"),
SourceVerified: true, SourceVerified: true,
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName,
OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture", OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture",
@@ -950,6 +1140,31 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
} }
type deliveryServerState struct { type deliveryServerState struct {
mu sync.Mutex
data deliveryServerData
}
func (state *deliveryServerState) update(change func(*deliveryServerData)) {
state.mu.Lock()
defer state.mu.Unlock()
change(&state.data)
}
func (state *deliveryServerState) snapshot() deliveryServerData {
state.mu.Lock()
defer state.mu.Unlock()
result := state.data
result.assets = make(map[string][]byte, len(state.data.assets))
for key, value := range state.data.assets {
result.assets[key] = append([]byte(nil), value...)
}
return result
}
type deliveryServerData struct {
tag string
malformedCreate bool malformedCreate bool
failAssetListing bool failAssetListing bool
failAssetDownloads bool failAssetDownloads bool
@@ -969,33 +1184,38 @@ type deliveryServerState struct {
func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server { func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server {
t.Helper() t.Helper()
if state.assets == nil { data := &state.data
state.assets = make(map[string][]byte) if data.tag == "" {
data.tag = "v1.2.3"
}
if data.assets == nil {
data.assets = make(map[string][]byte)
} }
serverURL := ""
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state.mu.Lock()
defer state.mu.Unlock()
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
state.apiRequests++ data.apiRequests++
if r.Header.Get("Authorization") != "token fixture-token" { if r.Header.Get("Authorization") != "token fixture-token" {
t.Errorf("authorization = %q", r.Header.Get("Authorization")) t.Errorf("authorization = %q", r.Header.Get("Authorization"))
} }
} }
switch r.URL.Path { switch r.URL.Path {
case "/api/v1/repos/owner/example/tags/v1.2.3": case "/api/v1/repos/owner/example/tags/" + data.tag:
if state.commit == "" { if data.commit == "" {
t.Error("tag source commit was not initialized") t.Error("tag source commit was not initialized")
} }
commit := state.remoteCommit commit := data.remoteCommit
if commit == "" { if commit == "" {
commit = state.commit commit = data.commit
} }
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit) _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3": case "/api/v1/repos/owner/example/releases/tags/" + data.tag:
if !state.pageExists { if !data.pageExists {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
writeDeliveryRelease(w, serverURL, state) writeDeliveryRelease(w, serverURL(r), data)
case "/api/v1/repos/owner/example/releases": case "/api/v1/repos/owner/example/releases":
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
t.Errorf("release request method = %s", r.Method) t.Errorf("release request method = %s", r.Method)
@@ -1011,26 +1231,26 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode create payload: %v", err) t.Errorf("decode create payload: %v", err)
} }
if payload.Tag != "v1.2.3" || payload.Target != state.commit { if payload.Tag != data.tag || payload.Target != data.commit {
t.Errorf("release source payload = %#v", payload) t.Errorf("release source payload = %#v", payload)
} }
state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body data.pageExists, data.releaseCreates, data.noteBody = true, data.releaseCreates+1, payload.Body
if state.malformedCreate { if data.malformedCreate {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": "v1.2.3", "name": "inconsistent response", "body": payload.Body}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "tag_name": data.tag, "name": "inconsistent response", "body": payload.Body})
return return
} }
writeDeliveryRelease(w, serverURL, state) writeDeliveryRelease(w, serverURL(r), data)
case "/api/v1/repos/owner/example/releases/10/assets": case "/api/v1/repos/owner/example/releases/10/assets":
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
state.assetListReads++ data.assetListReads++
if state.failAssetListing { if data.failAssetListing {
http.Error(w, "unavailable", 503) http.Error(w, "unavailable", 503)
return return
} }
list := make([]map[string]any, 0, len(state.assets)) list := make([]map[string]any, 0, len(data.assets))
for name, content := range state.assets { for name, content := range data.assets {
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": serverURL + "/files/" + name}) list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name})
} }
_ = json.NewEncoder(w).Encode(list) _ = json.NewEncoder(w).Encode(list)
return return
@@ -1052,28 +1272,28 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
if err != nil { if err != nil {
t.Errorf("read asset bytes: %v", err) t.Errorf("read asset bytes: %v", err)
} }
if _, exists := state.assets[name]; exists { if _, exists := data.assets[name]; exists {
t.Errorf("asset %q uploaded more than once", name) t.Errorf("asset %q uploaded more than once", name)
} }
if name == state.failAssetName { if name == data.failAssetName {
http.Error(w, "upload failed", http.StatusBadRequest) http.Error(w, "upload failed", http.StatusBadRequest)
return return
} }
if name == state.ambiguousAssetName { if name == data.ambiguousAssetName {
http.Error(w, "upload outcome ambiguous", http.StatusInternalServerError) http.Error(w, "upload outcome ambiguous", http.StatusInternalServerError)
return return
} }
state.assets[name] = content data.assets[name] = content
state.assetUploads++ data.assetUploads++
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.assetUploads, "name": name, "size": len(content)}) _ = json.NewEncoder(w).Encode(map[string]any{"id": data.assetUploads, "name": name, "size": len(content)})
default: default:
if strings.HasPrefix(r.URL.Path, "/files/") { if strings.HasPrefix(r.URL.Path, "/files/") {
if state.failAssetDownloads { if data.failAssetDownloads {
http.Error(w, "unavailable", 503) http.Error(w, "unavailable", 503)
return return
} }
name := strings.TrimPrefix(r.URL.Path, "/files/") name := strings.TrimPrefix(r.URL.Path, "/files/")
content, ok := state.assets[name] content, ok := data.assets[name]
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -1086,17 +1306,17 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
} }
}) })
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
serverURL = server.URL
return server return server
} }
func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *deliveryServerState) { // writeDeliveryRelease reads data while the request handler holds state.mu.
func writeDeliveryRelease(w http.ResponseWriter, baseURL string, data *deliveryServerData) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
product := state.product product := data.product
if product == "" { if product == "" {
product = "Example Product" product = "Example Product"
} }
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL + "/releases/10", "tag_name": "v1.2.3", "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": baseURL + "/releases/10", "tag_name": data.tag, "target_commitish": data.commit, "name": product + " v1.2.3", "body": data.noteBody, "draft": false, "prerelease": false})
} }
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) { func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
@@ -1227,7 +1447,7 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
defer server.Close() defer server.Close()
settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}} settings := cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
writeDeliveryProfile(t, repository, settings) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
mainPath := filepath.Join(repository, "main.go") mainPath := filepath.Join(repository, "main.go")
original, err := os.ReadFile(mainPath) original, err := os.ReadFile(mainPath)
@@ -1254,26 +1474,26 @@ func TestDeliverRejectsReceiptBuiltFromDirtySourceAfterCleanup(t *testing.T) {
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr)
if status != 1 || state.apiRequests != 0 || !strings.Contains(stderr.String(), "build source") { if status != 1 || state.snapshot().apiRequests != 0 || !strings.Contains(stderr.String(), "build source") {
t.Fatalf("status=%d requests=%d %s", status, state.apiRequests, stderr.String()) t.Fatalf("status=%d requests=%d %s", status, state.snapshot().apiRequests, stderr.String())
} }
} }
func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testing.T) { func TestRecoveredPageCreatePreservesPartialDeliveryOnInspectionFailure(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{malformedCreate: true, failAssetListing: true} state := &deliveryServerState{data: deliveryServerData{malformedCreate: true, failAssetListing: true}}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
assets, _ := writeDeliveryReceipt(t, repository, build) assets, _ := writeDeliveryReceipt(t, repository, build)
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr) status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assets, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "state=partial") || state.releaseCreates != 1 || state.assetListReads != 1 || state.assetUploads != 0 { if status != 1 || !strings.Contains(stderr.String(), "state=partial") || state.snapshot().releaseCreates != 1 || state.snapshot().assetListReads != 1 || state.snapshot().assetUploads != 0 {
t.Fatalf("status=%d state=%#v %s", status, state, stderr.String()) t.Fatalf("status=%d state=%#v %s", status, state.snapshot(), stderr.String())
} }
} }
@@ -1281,12 +1501,12 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
for _, existingPage := range []bool{false, true} { for _, existingPage := range []bool{false, true} {
t.Run(fmt.Sprintf("existing-page-%t", existingPage), func(t *testing.T) { t.Run(fmt.Sprintf("existing-page-%t", existingPage), func(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{pageExists: existingPage, noteBody: "# Example Product v1.2.3\n\nRelease note.\n", failAssetDownloads: true, assets: map[string][]byte{"helper-v1.2.3-linux-amd64": []byte("fixture artifact")}} state := &deliveryServerState{data: deliveryServerData{pageExists: existingPage, noteBody: "# Example Product v1.2.3\n\nRelease note.\n", failAssetDownloads: true, assets: map[string][]byte{"helper-v1.2.3-linux-amd64": []byte("fixture artifact")}}}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}} build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &build, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
assets, _ := writeDeliveryReceipt(t, repository, build) assets, _ := writeDeliveryReceipt(t, repository, build)
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token") t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
@@ -1298,8 +1518,8 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
expectedState = "not_attempted" expectedState = "not_attempted"
expectedCreates = 0 expectedCreates = 0
} }
if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.assetUploads != 0 || state.releaseCreates != expectedCreates { if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.snapshot().assetUploads != 0 || state.snapshot().releaseCreates != expectedCreates {
t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.releaseCreates, state.assetUploads, stderr.String()) t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.snapshot().releaseCreates, state.snapshot().assetUploads, stderr.String())
} }
if existingPage && !strings.Contains(stderr.String(), "recovery: fix the local or preflight failure") { if existingPage && !strings.Contains(stderr.String(), "recovery: fix the local or preflight failure") {
t.Fatalf("not-attempted recovery guidance missing: %s", stderr.String()) t.Fatalf("not-attempted recovery guidance missing: %s", stderr.String())
@@ -1342,7 +1562,7 @@ func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}) writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "v1.2.3") cliGitRun(t, repository, "tag", "v1.2.3")
parent := t.TempDir() parent := t.TempDir()
moved := filepath.Join(parent, "repo") moved := filepath.Join(parent, "repo")

View File

@@ -0,0 +1,247 @@
package main
import (
"bytes"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestMultiModuleSelectorMapsSelectorsAndTags(t *testing.T) {
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
helper := filepath.Join(example, "select-module.sh")
for _, test := range []struct {
mode string
value string
want string
}{
{mode: "selector", value: "root", want: "root\tv[01].*\t.\treleasekit-root.json"},
{mode: "selector", value: "ckconfig", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"},
{mode: "tag", value: "integrations/ckconfig/v1.2.3", want: "ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json"},
{mode: "tag", value: "integrations/core/v2.3.4", want: "core-v2\tintegrations/core/v2.*\tintegrations/core/v2\treleasekit-core-v2.json"},
{mode: "tag", value: "v1.2.3", want: "root\tv[01].*\t.\treleasekit-root.json"},
} {
t.Run(test.mode+" "+test.value, func(t *testing.T) {
got := runModuleSelector(t, helper, test.mode, test.value, "")
if strings.TrimSpace(got) != test.want {
t.Fatalf("selector output = %q, want %q", got, test.want)
}
})
}
mapPath := filepath.Join(t.TempDir(), "release modules.tsv")
if err := os.WriteFile(mapPath, []byte("one\tfeature/*\tmodules/one\tprofiles/one.json\ntwo\tfeature/v1.*\tmodules/two\tprofiles/two.json\n"), 0o600); err != nil {
t.Fatal(err)
}
if output := runModuleSelectorError(t, helper, "selector", "absent", mapPath); !strings.Contains(output, "no module map row") {
t.Fatalf("unknown selector error = %q", output)
}
if output := runModuleSelectorError(t, helper, "tag", "feature/v1.2.3", mapPath); !strings.Contains(output, "ambiguous") {
t.Fatalf("ambiguous event error = %q", output)
}
if err := os.WriteFile(mapPath, []byte("space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil {
t.Fatal(err)
}
got := runModuleSelector(t, helper, "tag", "feature/v1.2.3", mapPath)
want := "space\tfeature/*\tintegrations/ck config\tprofiles/core release.json"
if strings.TrimSpace(got) != want {
t.Fatalf("paths with spaces = %q, want %q", got, want)
}
}
func TestMultiModuleLauncherUsesSelectedModuleAndProfile(t *testing.T) {
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
repository := newCLIRepository(t)
copyExampleTree(t, example, repository)
rootModule := "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n"
selectedModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n"
writeRepositoryFile(t, repository, "go.mod", rootModule)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", selectedModule)
spaceModule := "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.299\n"
writeRepositoryFile(t, repository, "integrations/ck config/go.mod", spaceModule)
writeRepositoryFile(t, repository, "profiles/core release.json", `{"product":"Example Consumer Integration","module_dir":"integrations/ck config"}`)
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "prepare multi-module launcher fixture")
mapPath := filepath.Join(t.TempDir(), "release modules.tsv")
if err := os.WriteFile(mapPath, []byte("with space\tfeature/*\tintegrations/ck config\tprofiles/core release.json\n"), 0o600); err != nil {
t.Fatal(err)
}
shimDir := t.TempDir()
goLog := filepath.Join(t.TempDir(), "go commands")
argsLog := filepath.Join(t.TempDir(), "tool arguments")
shim := `#!/bin/sh
set -eu
printf 'cwd=%s args=%s\n' "$PWD" "$*" >> "$RELEASEKIT_TEST_GO_LOG"
modfile=
output=
previous=
for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
if [ "$previous" = -o ]; then output=$argument; fi
previous=$argument
done
if [ -n "$modfile" ]; then cat "$modfile" >> "$RELEASEKIT_TEST_GO_LOG"; fi
if [ "$1" = build ]; then
cat > "$output" <<'TOOL'
#!/bin/sh
printf '<%s>\n' "$@" > "$RELEASEKIT_TEST_TOOL_ARGS"
TOOL
chmod +x "$output"
fi
`
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("RELEASEKIT_MODULE_MAP", mapPath)
t.Setenv("RELEASEKIT_TEST_GO_LOG", goLog)
t.Setenv("RELEASEKIT_TEST_TOOL_ARGS", argsLog)
launcher := filepath.Join(repository, "release.sh")
command := exec.Command(launcher, "with space", "v1.2.4")
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
if err := command.Run(); err != nil {
t.Fatalf("launcher error: %v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
goCommands, err := os.ReadFile(goLog)
if err != nil {
t.Fatal(err)
}
wantModule := filepath.Join(repository, "integrations", "ck config")
if !strings.Contains(string(goCommands), "cwd="+wantModule) || !strings.Contains(string(goCommands), "v0.2.299") || strings.Contains(string(goCommands), "v0.2.197") {
t.Fatalf("tool bootstrap did not use selected module metadata: %s", goCommands)
}
toolArgs, err := os.ReadFile(argsLog)
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"<publish>", "<--repo>", "<" + repository + ">", "<--config>", "<profiles/core release.json>", "<--expected-module>", "<integrations/ck config>", "<v1.2.4>"} {
if !strings.Contains(string(toolArgs), expected) {
t.Errorf("publication arguments %q omit %q", toolArgs, expected)
}
}
if strings.Contains(string(goCommands), repository+"/.releasekit-bootstrap.") {
t.Fatalf("temporary tool metadata was placed inside the repository: %s", goCommands)
}
}
func TestMultiModuleLauncherBuildsToolFromSelectedDependency(t *testing.T) {
example := filepath.Join(cliRepositoryRoot(t), "examples", "multi-module")
repository := newCLIRepository(t)
copyExampleTree(t, example, repository)
writeRepositoryFile(t, repository, "go.mod", "module example.com/multi-module-consumer\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.197\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/multi-module-consumer/integrations/ckconfig\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.2.198\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "prepare selected tool module")
proxy := writeReleaseKitModuleProxy(t)
moduleCache := t.TempDir()
t.Setenv("GOMODCACHE", moduleCache)
t.Cleanup(func() {
_ = filepath.WalkDir(moduleCache, func(path string, entry fs.DirEntry, err error) error {
if err == nil && entry.IsDir() {
_ = os.Chmod(path, 0o700)
}
return nil
})
})
t.Setenv("GOPROXY", "file://"+filepath.ToSlash(proxy))
t.Setenv("GOSUMDB", "off")
t.Setenv("GOTOOLCHAIN", "local")
moduleBefore, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
if err != nil {
t.Fatal(err)
}
command := exec.Command(filepath.Join(repository, "release.sh"), "ckconfig", "v1.2.3")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("launcher error: %v output=%q", err, output)
}
for _, expected := range []string{
"tool-version=v0.2.198 helper=tool-only dependency",
`"publish"`, `"--repo"`, `"` + repository + `"`,
`"--config"`, `"releasekit-ckconfig.json"`,
`"--expected-module"`, `"integrations/ckconfig"`, `"v1.2.3"`,
} {
if !strings.Contains(string(output), expected) {
t.Errorf("launcher output %q omits %q", output, expected)
}
}
if strings.Contains(string(output), "tool-version=v0.2.197") {
t.Fatalf("launcher built the root-selected ReleaseKit version: %s", output)
}
moduleAfter, err := os.ReadFile(filepath.Join(repository, "integrations", "ckconfig", "go.mod"))
if err != nil || !bytes.Equal(moduleAfter, moduleBefore) {
t.Fatalf("selected module go.mod changed: got %q want %q error=%v", moduleAfter, moduleBefore, err)
}
}
func runModuleSelector(t *testing.T, helper, mode, value, mapPath string) string {
t.Helper()
command := exec.Command("sh", helper, mode, value)
command.Env = os.Environ()
if mapPath != "" {
command.Env = append(command.Env, "RELEASEKIT_MODULE_MAP="+mapPath)
}
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("selector %s %q: %v: %s", mode, value, err, output)
}
return string(output)
}
func runModuleSelectorError(t *testing.T, helper, mode, value, mapPath string) string {
t.Helper()
command := exec.Command("sh", helper, mode, value)
command.Env = append(os.Environ(), "RELEASEKIT_MODULE_MAP="+mapPath)
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("selector %s %q unexpectedly succeeded: %s", mode, value, output)
}
return string(output)
}
func copyExampleTree(t *testing.T, source, destination string) {
t.Helper()
err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(source, path)
if err != nil {
return err
}
if relative == "." {
return nil
}
target := filepath.Join(destination, relative)
if entry.IsDir() {
return os.MkdirAll(target, 0o700)
}
contents, err := os.ReadFile(path)
if err != nil {
return err
}
info, err := entry.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
if err := os.WriteFile(target, contents, mode); err != nil {
return err
}
return os.Chmod(target, mode)
})
if err != nil {
t.Fatal(fmt.Errorf("copy multi-module example: %w", err))
}
}

View File

@@ -0,0 +1,439 @@
package main
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
func TestMultiModuleWoodpeckerDelivery(t *testing.T) {
root := cliRepositoryRoot(t)
example := filepath.Join(root, "examples", "multi-module")
contents, err := os.ReadFile(filepath.Join(example, "woodpecker.yml"))
if err != nil {
t.Fatal(err)
}
block, err := extractWoodpeckerCommand(string(contents), "deliver")
if err != nil {
t.Fatal(err)
}
command := strings.ReplaceAll(block, "$${", "${")
for _, eventTag := range []string{"refs/tags/v[01].*", "refs/tags/integrations/ckconfig/v*", "refs/tags/integrations/core/v2.*"} {
if !strings.Contains(string(contents), eventTag) {
t.Errorf("workflow tag filters omit %q", eventTag)
}
}
toolBinary := buildWorkflowCLI(t, root)
proxy := writeWorkflowToolProxy(t)
t.Run("nested page delivery retries from tagged mapping", func(t *testing.T) {
const tag = "integrations/ckconfig/v1.2.3"
delivery := newMappedDeliveryFixture(t, tag)
repository := newWorkflowRepository(t, example)
writeMappedProfile(t, repository, "releasekit-ckconfig.json", cli.Settings{
Product: "Example Consumer Integration", ModuleDir: "integrations/ckconfig",
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
})
writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Consumer Integration v1.2.3\n\nTagged integration note.\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.197"))
commit := tagAndPushWorkflowRepository(t, repository, tag)
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
env["CI_COMMIT_SHA"] = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+tag)
if output := runWoodpeckerBlock(t, command, repository, env); !strings.Contains(output, "state complete") || !strings.Contains(output, "tool-version=v0.2.197") {
t.Fatalf("tagged page delivery output = %q", output)
}
if delivery.snapshot().creates != 1 || delivery.snapshot().requests == 0 || delivery.snapshot().tag != tag {
t.Fatalf("page delivery fixture = %#v", delivery.snapshot())
}
cliGitRun(t, repository, "checkout", "main")
writeRepositoryFile(t, repository, "release-modules.tsv", "# changed after release\nroot\tv[01].*\t.\treleasekit-root.json\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/ckconfig", "v0.2.198"))
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "change default branch release selection")
cliGitRun(t, repository, "push", "origin", "main")
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197") || strings.Contains(output, "tool-version=v0.2.198") {
t.Fatalf("tag retry did not use its committed tool version: %q", output)
}
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery.snapshot())
}
})
t.Run("binary delivery from physical major module", func(t *testing.T) {
const tag = "integrations/core/v2.1.0"
delivery := newMappedDeliveryFixture(t, tag)
repository := newWorkflowRepository(t, example)
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "core", Targets: []string{"linux/amd64"}},
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
})
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
prepareWorkflowModuleSum(t, repository, "integrations/core/v2", proxy)
commit := tagAndPushWorkflowRepository(t, repository, tag)
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
t.Fatalf("binary delivery output = %q", output)
}
observed := delivery.snapshot()
if observed.creates != 1 || observed.uploads != 2 || observed.tag != tag {
t.Fatalf("binary delivery fixture = %#v", observed)
}
if _, ok := observed.assets["core-v2.1.0-linux-amd64"]; !ok {
t.Fatalf("binary asset missing: %#v", observed.assets)
}
if _, ok := observed.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum asset missing: %#v", observed.assets)
}
})
t.Run("binary delivery from virtual major module", func(t *testing.T) {
const tag = "integrations/core/v2.1.0"
delivery := newMappedDeliveryFixture(t, tag)
repository := newWorkflowRepository(t, example)
writeRepositoryFile(t, repository, "release-modules.tsv", strings.ReplaceAll(readWorkflowFile(t, repository, "release-modules.tsv"), "integrations/core/v2\treleasekit-core-v2.json", "integrations/core\treleasekit-core-v2.json"))
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
Product: "Example Consumer Core v2", ModuleDir: "integrations/core",
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
})
writeRepositoryFile(t, repository, "integrations/core/go.mod", workflowModuleFile("example.com/multi-module-consumer/integrations/core/v2", "v0.2.197"))
writeRepositoryFile(t, repository, "integrations/core/main.go", readWorkflowFile(t, example, "integrations/core/v2/main.go"))
writeRepositoryFile(t, repository, "integrations/core/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nTagged core note.\n")
prepareWorkflowModuleSum(t, repository, "integrations/core", proxy)
commit := tagAndPushWorkflowRepository(t, repository, tag)
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "state complete") {
t.Fatalf("virtual-major delivery output = %q", output)
}
if delivery.snapshot().creates != 1 || delivery.snapshot().tag != tag {
t.Fatalf("virtual-major delivery fixture = %#v", delivery.snapshot())
}
})
t.Run("inconsistent mapping fails exact event tag check", func(t *testing.T) {
const tag = "integrations/ckconfig/v2.1.0"
delivery := newMappedDeliveryFixture(t, tag)
repository := newWorkflowRepository(t, example)
mapping := readWorkflowFile(t, repository, "release-modules.tsv")
mapping = strings.Replace(mapping,
"ckconfig\tintegrations/ckconfig/v*\tintegrations/ckconfig\treleasekit-ckconfig.json",
"ckconfig\tintegrations/ckconfig/v*\tintegrations/core/v2\treleasekit-core-v2.json", 1)
writeRepositoryFile(t, repository, "release-modules.tsv", mapping)
writeMappedProfile(t, repository, "releasekit-core-v2.json", cli.Settings{
Product: "Example Consumer Core v2", ModuleDir: "integrations/core/v2",
Delivery: workflowDeliverySettings(delivery.snapshot().URL),
})
writeRepositoryFile(t, repository, "integrations/core/v2/docs/releases/v2.1.0.md", "# Example Consumer Core v2 v2.1.0\n\nMismatched mapped note.\n")
commit := tagAndPushWorkflowRepository(t, repository, tag)
delivery.update(func(delivery *mappedDeliveryData) { delivery.commit = commit })
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("inconsistent mapping succeeded: %s", output)
} else if !strings.Contains(output, tag) || !strings.Contains(output, "integrations/core/v2.1.0") {
t.Fatalf("inconsistent mapping did not fail the exact tag comparison: %s", output)
}
if delivery.snapshot().requests != 0 {
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery.snapshot())
}
})
}
type mappedDeliveryFixture struct {
mu sync.Mutex
data mappedDeliveryData
}
func (state *mappedDeliveryFixture) update(change func(*mappedDeliveryData)) {
state.mu.Lock()
defer state.mu.Unlock()
change(&state.data)
}
func (state *mappedDeliveryFixture) snapshot() mappedDeliveryData {
state.mu.Lock()
defer state.mu.Unlock()
result := state.data
result.assets = make(map[string][]byte, len(state.data.assets))
for key, value := range state.data.assets {
result.assets[key] = append([]byte(nil), value...)
}
return result
}
type mappedDeliveryData struct {
URL string
tag string
commit string
page bool
name string
body string
creates int
uploads int
requests int
assets map[string][]byte
}
func newMappedDeliveryFixture(t *testing.T, tag string) *mappedDeliveryFixture {
t.Helper()
state := &mappedDeliveryFixture{data: mappedDeliveryData{tag: tag, assets: make(map[string][]byte)}}
data := &state.data
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
state.mu.Lock()
defer state.mu.Unlock()
data.requests++
if r.Header.Get("Authorization") != "token fixture-token" {
t.Errorf("delivery authorization = %q", r.Header.Get("Authorization"))
}
apiRoot := "/api/v1/repos/owner/example/"
escapedTag := url.PathEscape(tag)
switch r.URL.EscapedPath() {
case apiRoot + "tags/" + escapedTag:
_ = json.NewEncoder(w).Encode(map[string]any{"name": tag, "commit": map[string]string{"sha": data.commit}})
case apiRoot + "releases/tags/" + escapedTag:
if !data.page {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": data.name, "body": data.body, "draft": false, "prerelease": false})
case apiRoot + "releases":
var payload struct {
Tag string `json:"tag_name"`
Commit string `json:"target_commitish"`
Name string `json:"name"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Errorf("decode release create: %v", err)
}
if payload.Tag != tag || payload.Commit != data.commit {
t.Errorf("release payload = %#v", payload)
}
data.page = true
data.name = payload.Name
data.body = payload.Body
data.creates++
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": data.commit, "name": payload.Name, "body": payload.Body, "draft": false, "prerelease": false})
case apiRoot + "releases/10/assets":
if r.Method == http.MethodGet {
listing := make([]map[string]any, 0, len(data.assets))
for name, content := range data.assets {
listing = append(listing, map[string]any{"id": len(listing) + 1, "name": name, "size": len(content), "browser_download_url": serverURL(r) + "/files/" + name})
}
_ = json.NewEncoder(w).Encode(listing)
return
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse asset upload: %v", err)
http.Error(w, "multipart", http.StatusBadRequest)
return
}
name := r.URL.Query().Get("name")
file, _, err := r.FormFile("attachment")
if err != nil {
t.Errorf("read asset %q: %v", name, err)
http.Error(w, "attachment", http.StatusBadRequest)
return
}
content, err := io.ReadAll(file)
_ = file.Close()
if err != nil {
t.Errorf("read asset bytes: %v", err)
}
data.assets[name] = content
data.uploads++
_ = json.NewEncoder(w).Encode(map[string]any{"id": data.uploads, "name": name, "size": len(content)})
default:
if strings.HasPrefix(r.URL.Path, "/files/") {
name := strings.TrimPrefix(r.URL.Path, "/files/")
if content, ok := data.assets[name]; ok {
_, _ = w.Write(content)
return
}
http.NotFound(w, r)
return
}
t.Errorf("unexpected delivery request %s %s", r.Method, r.URL.EscapedPath())
http.NotFound(w, r)
}
}))
data.URL = "http://" + server.Listener.Addr().String()
server.Start()
t.Cleanup(server.Close)
return state
}
func serverURL(r *http.Request) string {
return "http://" + r.Host
}
func buildWorkflowCLI(t *testing.T, root string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "releasekit")
command := exec.Command("go", "build", "-o", path, "./cmd/releasekit")
command.Dir = root
command.Env = append(os.Environ(), "GOTOOLCHAIN=local", "GOPROXY=off", "GOSUMDB=off")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("build workflow CLI: %v\n%s", err, output)
}
return path
}
func writeWorkflowToolProxy(t *testing.T) string {
t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
const helper = "example.com/releasekit-helper"
proxyDir := t.TempDir()
moduleFile := "module " + module + "\n\ngo 1.25.0\n\nrequire " + helper + " v1.0.0\n"
for _, version := range []string{"v0.2.197", "v0.2.198"} {
main := fmt.Sprintf(`package main
import (
"fmt"
"os"
"os/exec"
helper "%s"
)
func main() {
fmt.Println("tool-version=%s helper=" + helper.Version)
command := exec.Command(os.Getenv("RELEASEKIT_TEST_BINARY"), os.Args[1:]...)
command.Env = os.Environ()
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok { os.Exit(exit.ExitCode()) }
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
`, helper, version)
files := map[string]string{
"cmd/releasekit/main.go": main,
"buildinfo/version.go": "package buildinfo\nimport \"gitea.maximumdirect.net/eric/ReleaseKit/internal/runtimeinfo\"\nfunc Version() string { return runtimeinfo.Version() }\n",
"internal/runtimeinfo/version.go": "package runtimeinfo\nvar injectedVersion string\nfunc Version() string { if injectedVersion != \"\" { return injectedVersion }; return \"dev\" }\n",
}
writeModuleProxyVersion(t, proxyDir, module, version, moduleFile, files)
}
writeModuleProxyVersion(t, proxyDir, helper, "v1.0.0", "module "+helper+"\n\ngo 1.25.0\n", map[string]string{"helper.go": "package helper\nconst Version = \"tagged dependency\"\n"})
return proxyDir
}
func newWorkflowRepository(t *testing.T, example string) string {
t.Helper()
repository := filepath.Join(t.TempDir(), "multi module checkout")
if err := os.Mkdir(repository, 0o700); err != nil {
t.Fatal(err)
}
copyExampleTree(t, example, repository)
for _, selection := range []struct{ directory, module string }{
{".", "example.com/multi-module-consumer"},
{"integrations/ckconfig", "example.com/multi-module-consumer/integrations/ckconfig"},
{"integrations/core/v2", "example.com/multi-module-consumer/integrations/core/v2"},
} {
writeRepositoryFile(t, repository, selection.directory+"/go.mod", workflowModuleFile(selection.module, "v0.2.197"))
}
cliGitRun(t, repository, "init", "--initial-branch=main")
cliGitRun(t, repository, "config", "user.name", "ReleaseKit Test")
cliGitRun(t, repository, "config", "user.email", "releasekit@example.test")
cliGitRun(t, repository, "config", "commit.gpgsign", "false")
return repository
}
func workflowModuleFile(module, version string) string {
return fmt.Sprintf("module %s\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit %s\n", module, version)
}
func workflowDeliverySettings(baseURL string) *cli.DeliverySettings {
return &cli.DeliverySettings{BaseURL: baseURL, Owner: "owner", Repository: "example", TokenEnv: "RELEASEKIT_GITEA_TOKEN"}
}
func writeMappedProfile(t *testing.T, repository, name string, settings cli.Settings) {
t.Helper()
contents, err := json.MarshalIndent(settings, "", " ")
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, name, string(contents)+"\n")
}
func tagAndPushWorkflowRepository(t *testing.T, repository, tag string) string {
t.Helper()
cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "prepare mapped delivery")
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "-a", tag, "-m", "mapped release")
remote := filepath.Join(filepath.Dir(repository), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+tag)
return commit
}
func multiModuleWorkflowEnvironment(t *testing.T, repository, commit, tag, binary, proxy string) map[string]string {
t.Helper()
goDir, goLog := installGoBootstrapShim(t)
environment := templateEnvironment(repository, commit, goDir, goLog)
environment["CI_COMMIT_TAG"] = tag
environment["RELEASEKIT_TEST_BINARY"] = binary
useSyntheticModuleProxy(t, environment, "file://"+filepath.ToSlash(proxy))
cache := environment["GOMODCACHE"]
t.Cleanup(func() {
_ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error {
if err == nil && entry.IsDir() {
_ = os.Chmod(path, 0o700)
}
return nil
})
})
return environment
}
func prepareWorkflowModuleSum(t *testing.T, repository, moduleDir, proxy string) {
t.Helper()
cache := t.TempDir()
t.Cleanup(func() {
_ = filepath.WalkDir(cache, func(path string, entry fs.DirEntry, err error) error {
if err == nil && entry.IsDir() {
_ = os.Chmod(path, 0o700)
}
return nil
})
})
command := exec.Command("go", "mod", "download", "gitea.maximumdirect.net/eric/ReleaseKit@v0.2.197")
command.Dir = filepath.Join(repository, filepath.FromSlash(moduleDir))
command.Env = append(os.Environ(), "GOMODCACHE="+cache, "GOPROXY=file://"+filepath.ToSlash(proxy), "GOSUMDB=off", "GOWORK=off", "GOTOOLCHAIN=local", "GOFLAGS=-modcacherw")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("prepare tagged module sums: %v\n%s", err, output)
}
}
func readWorkflowFile(t *testing.T, repository, name string) string {
t.Helper()
contents, err := os.ReadFile(filepath.Join(repository, filepath.FromSlash(name)))
if err != nil {
t.Fatal(err)
}
return string(contents)
}

View File

@@ -50,14 +50,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
cliGitRun(t, repository, "add", "docs/releases/"+version+".md") cliGitRun(t, repository, "add", "docs/releases/"+version+".md")
cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery") cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery")
state := &deliveryServerState{product: "ReleaseKit"} state := &deliveryServerState{data: deliveryServerData{product: "ReleaseKit"}}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{ writeDeliveryProfile(t, repository, cli.Settings{
Product: "ReleaseKit", Product: "ReleaseKit",
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.update(func(state *deliveryServerData) { state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") })
cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3") cliGitRun(t, repository, "tag", "-a", version, "-m", "ReleaseKit v1.2.3")
remote := filepath.Join(t.TempDir(), "origin.git") remote := filepath.Join(t.TempDir(), "origin.git")
@@ -78,7 +78,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
} }
env := map[string]string{ env := map[string]string{
"CI_WORKSPACE": repository, "CI_WORKSPACE": repository,
"CI_COMMIT_SHA": state.commit, "CI_COMMIT_SHA": cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version),
"CI_COMMIT_TAG": version, "CI_COMMIT_TAG": version,
"RELEASEKIT_GITEA_TOKEN": "fixture-token", "RELEASEKIT_GITEA_TOKEN": "fixture-token",
"GOTOOLCHAIN": "local", "GOTOOLCHAIN": "local",
@@ -87,14 +87,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
} }
command := strings.ReplaceAll(block, "$${", "${") command := strings.ReplaceAll(block, "$${", "${")
state.remoteCommit = strings.Repeat("f", 40) state.update(func(state *deliveryServerData) { state.remoteCommit = strings.Repeat("f", 40) })
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output) t.Fatalf("delivery with a mismatched hosted tag succeeded: %s", output)
} }
if state.releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.commit { if state.snapshot().releaseCreates != 0 || cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}") != state.snapshot().commit {
t.Fatal("delivery failure changed hosted release state or source tag") t.Fatal("delivery failure changed hosted release state or source tag")
} }
state.remoteCommit = "" state.update(func(state *deliveryServerData) { state.remoteCommit = "" })
for attempt := 0; attempt < 2; attempt++ { for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerBlock(t, command, repository, env) output := runWoodpeckerBlock(t, command, repository, env)
@@ -102,14 +102,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
t.Fatalf("delivery attempt %d output = %q", attempt+1, output) t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
} }
} }
if state.releaseCreates != 1 || state.assetUploads != 0 || state.assetListReads != 0 { if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 || state.snapshot().assetListReads != 0 {
t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.releaseCreates, state.assetUploads, state.assetListReads) t.Fatalf("page-only workflow calls = creates:%d uploads:%d asset reads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads, state.snapshot().assetListReads)
} }
if state.noteBody != note { if state.snapshot().noteBody != note {
t.Fatalf("hosted note body = %q, want complete note %q", state.noteBody, note) t.Fatalf("hosted note body = %q, want complete note %q", state.snapshot().noteBody, note)
} }
if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.commit { if got := cliGitOutput(t, repository, "rev-parse", "refs/tags/"+version+"^{commit}"); got != state.snapshot().commit {
t.Fatalf("source tag moved after delivery: %s, want %s", got, state.commit) t.Fatalf("source tag moved after delivery: %s, want %s", got, state.snapshot().commit)
} }
if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" { if status := cliGitOutput(t, repository, "status", "--porcelain"); status != "" {
t.Fatalf("tagged checkout became dirty after delivery: %s", status) t.Fatalf("tagged checkout became dirty after delivery: %s", status)

View File

@@ -0,0 +1,34 @@
package main
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestNativeCheckFromChildUsesRootProfileAndSuite(t *testing.T) {
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) { t.Fatal(\"root suite failure\") }\n")
writeRepositoryFile(t, repository, "releasekit.json", `{"product":"Example Product"}`)
writeRepositoryFile(t, repository, "apps/widget/go.mod", "module example.com/widget\n\ngo 1.26.0\n")
writeRepositoryFile(t, repository, "apps/widget/widget_test.go", "package widget\nimport \"testing\"\nfunc TestWidget(t *testing.T) {}\n")
cliGitRun(t, repository, "add", ".")
cliGitRun(t, repository, "commit", "-m", "Add failing root and passing child")
command := exec.Command(binary, "check", "v1.2.3")
command.Dir = filepath.Join(repository, "apps", "widget")
output, err := command.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 || !strings.Contains(string(output), "root suite failure") || !strings.Contains(string(output), "module=example.com/cli-candidate") {
t.Fatalf("child invocation error = %v, output = %s", err, output)
}
}
func TestNativeBuildClassifiesInvalidVersionBeforeSourceAccess(t *testing.T) {
binary := buildWorkflowCLI(t, cliRepositoryRoot(t))
command := exec.Command(binary, "build", "--repo", filepath.Join(t.TempDir(), "absent"), "bad-version")
output, err := command.CombinedOutput()
if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 2 || !strings.Contains(string(output), "stable vMAJOR.MINOR.PATCH") {
t.Fatalf("invalid build error = %v, output = %s", err, output)
}
}

View File

@@ -42,7 +42,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.commit = commit state.update(func(state *deliveryServerData) { state.commit = commit })
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
@@ -51,8 +51,8 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if !strings.Contains(output, "state complete") { if !strings.Contains(output, "state complete") {
t.Fatalf("delivery output=%q", output) t.Fatalf("delivery output=%q", output)
} }
if state.releaseCreates != 1 || state.assetUploads != 0 { if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) t.Fatalf("source-only calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
} }
assertModuleBootstrap(t, repository, goLog, moduleBefore) assertModuleBootstrap(t, repository, goLog, moduleBefore)
}) })
@@ -73,7 +73,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
}) })
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.commit = commit state.update(func(state *deliveryServerData) { state.commit = commit })
goDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
@@ -88,14 +88,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
t.Fatalf("second delivery did not reuse matching assets: %q", output) t.Fatalf("second delivery did not reuse matching assets: %q", output)
} }
} }
if state.releaseCreates != 1 || state.assetUploads != 2 { if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) t.Fatalf("binary calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
} }
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok { if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
t.Fatalf("binary asset was not delivered: %#v", state.assets) t.Fatalf("binary asset was not delivered: %#v", state.snapshot().assets)
} }
if _, ok := state.assets["SHA256SUMS"]; !ok { if _, ok := state.snapshot().assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not delivered: %#v", state.assets) t.Fatalf("checksum manifest was not delivered: %#v", state.snapshot().assets)
} }
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore) assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
}) })
@@ -104,10 +104,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false) selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
cliGitRun(t, repository, "commit", "--allow-empty", "-m", "unrelated event commit")
unrelated := cliGitOutput(t, repository, "rev-parse", "HEAD")
goDir, goLog := installControlledGoBootstrapShim(t) goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog) env := templateEnvironment(repository, unrelated, goDir, goLog)
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil { if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("wrong tag succeeded: %s", output) t.Fatalf("wrong tag succeeded: %s", output)
} else if !strings.Contains(output, commit) || !strings.Contains(output, unrelated) {
t.Fatalf("tag mismatch omitted commit identities: %s", output)
} }
assertNoCommands(t, goLog) assertNoCommands(t, goLog)
}) })
@@ -126,12 +130,30 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
}) })
t.Run("selected executable receives event arguments and its status", func(t *testing.T) { t.Run("selected executable receives event arguments and its status", func(t *testing.T) {
for _, event := range []struct {
name, tag string
tagObject bool
}{
{"lightweight commit", "v1.2.3", false},
{"annotated commit", "v1.2.4", false},
{"annotated tag object", "v1.2.4", true},
} {
t.Run(event.name, func(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false) selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
if event.tag == "v1.2.4" {
cliGitRun(t, repository, "tag", "-a", event.tag, "-m", "annotated release")
cliGitRun(t, repository, "push", "origin", "refs/tags/"+event.tag)
}
eventID := commit
if event.tagObject {
eventID = cliGitOutput(t, repository, "rev-parse", "refs/tags/"+event.tag)
}
goDir, goLog := installControlledGoBootstrapShim(t) goDir, goLog := installControlledGoBootstrapShim(t)
toolArgs := filepath.Join(t.TempDir(), "tool-arguments") toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog) env := templateEnvironment(repository, strings.ToUpper(eventID), goDir, goLog)
env["CI_COMMIT_TAG"] = event.tag
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
env["RELEASEKIT_TOOL_EXIT"] = "37" env["RELEASEKIT_TOOL_EXIT"] = "37"
output, err := executeWoodpeckerBlock(command, repository, env) output, err := executeWoodpeckerBlock(command, repository, env)
@@ -140,14 +162,19 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output) t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output)
} }
args := readWoodpeckerFixtureFile(t, toolArgs) args := readWoodpeckerFixtureFile(t, toolArgs)
for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--commit>", "<" + strings.ToUpper(commit) + ">", "<v1.2.3>"} { for _, expected := range []string{"<ci-deliver>", "<--repo>", "<" + repository + ">", "<--prepared>", "<" + event.tag + ">"} {
if !strings.Contains(args, expected) { if !strings.Contains(args, expected) {
t.Errorf("selected executable arguments %q omit %q", args, expected) t.Errorf("selected executable arguments %q omit %q", args, expected)
} }
} }
if !strings.Contains(strings.ToLower(args), "<--commit>\n<"+commit+">") {
t.Errorf("selected executable did not receive source commit %s: %q", commit, args)
}
assertGoSelection(t, repository, goLog) assertGoSelection(t, repository, goLog)
}) })
} }
})
}
func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) { func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
// Synthetic downloads must leave the caller's module cache untouched. // Synthetic downloads must leave the caller's module cache untouched.
@@ -284,7 +311,7 @@ func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
if proxyRequests.Load() == 0 { if proxyRequests.Load() == 0 {
t.Fatal("self-bootstrap did not prepare its dependency from the local proxy") t.Fatal("self-bootstrap did not prepare its dependency from the local proxy")
} }
if !strings.Contains(output, `"ci-deliver"`) || !strings.Contains(output, `"`+repository+`"`) || !strings.Contains(output, `"v1.2.3"`) { if !strings.Contains(output, `"ci-deliver"`) || !strings.Contains(output, `"`+repository+`"`) || !strings.Contains(output, `"--prepared"`) || !strings.Contains(output, `"v1.2.3"`) {
t.Fatalf("self-bootstrapped command arguments = %q", output) t.Fatalf("self-bootstrapped command arguments = %q", output)
} }
log := readWoodpeckerFixtureFile(t, goLog) log := readWoodpeckerFixtureFile(t, goLog)

View File

@@ -45,9 +45,15 @@ type DeliveryAsset struct {
// page-only delivery when both are nil. Build with no targets, or an empty // page-only delivery when both are nil. Build with no targets, or an empty
// Prebuilt result, also performs page-only delivery. // Prebuilt result, also performs page-only delivery.
type DeliveryRequest struct { type DeliveryRequest struct {
RepositoryDir string RepositoryDir string // directory within the tagged source Git checkout.
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
Product string Product string
Version string Version string
// ExpectedTag optionally requires the full CI event tag to match the
// selected module's derived tag before building or making hosted requests.
ExpectedTag string
Destination GiteaDestination Destination GiteaDestination
// Build selects binaries to build in temporary storage outside the checkout. // Build selects binaries to build in temporary storage outside the checkout.
Build *BuildSpec Build *BuildSpec
@@ -62,7 +68,11 @@ type DeliveryRequest struct {
// current observations: an asset appears in at most one slice, and early // current observations: an asset appears in at most one slice, and early
// failures may leave some declared assets unreported. // failures may leave some declared assets unreported.
type DeliveryResult struct { type DeliveryResult struct {
Version string RepositoryDir string
ModuleDir string
ModulePath string
Tag string // complete derived Git tag.
Version string // selected stable semantic version.
CandidateCommit string CandidateCommit string
ReleaseURL string ReleaseURL string
State DeliveryState State DeliveryState
@@ -83,6 +93,9 @@ type DeliveryResult struct {
// requests are compiled into temporary storage that Deliver removes on return. // requests are compiled into temporary storage that Deliver removes on return.
// Prebuilt artifacts remain caller-owned and are not removed. Both paths // Prebuilt artifacts remain caller-owned and are not removed. Both paths
// validate source identity and artifact contents before hosted writes. // validate source identity and artifact contents before hosted writes.
// Module selection and declaration links resolve from the captured commit.
// Page-only delivery can use a sparse checkout with available committed objects;
// managed builds require their selected source files in the working tree.
func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) { func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, error) {
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted} result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
destination := gitea.Destination{ destination := gitea.Destination{
@@ -95,13 +108,19 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
} }
internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{ internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
ExpectedTag: request.ExpectedTag,
Destination: destination, Destination: destination,
Build: internalBuildSpec(request.Build), Build: internalBuildSpec(request.Build),
Prebuilt: prebuilt, Prebuilt: prebuilt,
Output: request.Output, Output: request.Output,
}) })
result.RepositoryDir = internalResult.RepositoryDir
result.ModuleDir = internalResult.ModuleDir
result.ModulePath = internalResult.ModulePath
result.Tag = internalResult.Tag
result.CandidateCommit = internalResult.CandidateCommit result.CandidateCommit = internalResult.CandidateCommit
result.ReleaseURL = internalResult.ReleaseURL result.ReleaseURL = internalResult.ReleaseURL
result.State = DeliveryState(internalResult.State) result.State = DeliveryState(internalResult.State)

View File

@@ -0,0 +1,67 @@
package releasekit_test
import (
"encoding/binary"
"errors"
"net/http"
"net/http/httptest"
"syscall"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
func TestDeliverReadsLocalBinaryOnceForVerificationAndOnceForUpload(t *testing.T) {
repository, build, expected := newDeliveryBenchmarkFixture(t)
watcher, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC)
if err != nil {
t.Fatal(err)
}
defer syscall.Close(watcher)
// Close events separate successive opens so inotify cannot coalesce them.
if _, err := syscall.InotifyAddWatch(watcher, build.Artifacts[0].Path, syscall.IN_OPEN|syscall.IN_CLOSE_NOWRITE); err != nil {
t.Fatal(err)
}
state := &deliveryBenchmarkServer{commit: build.CandidateCommit, assets: expected}
state.reset(false)
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
state.baseURL = "http://" + server.Listener.Addr().String()
server.Start()
defer server.Close()
result, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
RepositoryDir: repository, Product: "Sample App", Version: build.Version,
Destination: facadeDeliveryDestination(server.URL), Prebuilt: &build,
})
if err != nil || result.State != releasekit.DeliveryComplete || len(result.Uploaded) != len(expected) {
t.Fatalf("delivery = %#v, error = %v", result, err)
}
state.verifyUploads(t)
opens := 0
var events [256]byte
for {
n, err := syscall.Read(watcher, events[:])
if errors.Is(err, syscall.EAGAIN) {
break
}
if errors.Is(err, syscall.EINTR) {
continue
}
if err != nil {
t.Fatal(err)
}
for offset := 0; offset < n; {
mask := binary.NativeEndian.Uint32(events[offset+4:])
if mask&syscall.IN_Q_OVERFLOW != 0 {
t.Fatal("artifact event queue overflowed")
}
if mask&syscall.IN_OPEN != 0 {
opens++
}
nameLength := binary.NativeEndian.Uint32(events[offset+12:])
offset += syscall.SizeofInotifyEvent + int(nameLength)
}
}
if opens != 2 {
t.Fatalf("local binary opened %d times; want one verification read and one upload read", opens)
}
}

View File

@@ -8,10 +8,12 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit" releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
@@ -40,11 +42,12 @@ func TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput(t *testing.
if err != nil { if err != nil {
t.Fatalf("Deliver() error = %v; result = %#v", err, result) t.Fatalf("Deliver() error = %v; result = %#v", err, result)
} }
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit { observed := remote.snapshot()
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
t.Fatalf("delivery result = %#v", result) t.Fatalf("delivery result = %#v", result)
} }
if len(result.Uploaded) != 2 || len(remote.assets) != 2 { if len(result.Uploaded) != 2 || len(observed.assets) != 2 {
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets)) t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
} }
if !strings.Contains(progress.String(), "checking fixture version output") { if !strings.Contains(progress.String(), "checking fixture version output") {
t.Fatalf("build progress = %q", progress.String()) t.Fatalf("build progress = %q", progress.String())
@@ -78,7 +81,8 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Deliver() error = %v; result = %#v", err, result) t.Fatalf("Deliver() error = %v; result = %#v", err, result)
} }
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != remote.commit { observed := remote.snapshot()
if result.State != releasekit.DeliveryComplete || result.CandidateCommit != observed.commit {
t.Fatalf("delivery result = %#v", result) t.Fatalf("delivery result = %#v", result)
} }
wantAssets := []string{ wantAssets := []string{
@@ -86,12 +90,12 @@ func TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets(t *testing.T) {
"fixture-v1.2.3-linux-arm64", "fixture-v1.2.3-linux-arm64",
"SHA256SUMS", "SHA256SUMS",
} }
if len(result.Uploaded) != len(wantAssets) || len(remote.assets) != len(wantAssets) { if len(result.Uploaded) != len(wantAssets) || len(observed.assets) != len(wantAssets) {
t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(remote.assets)) t.Fatalf("uploaded = %#v; hosted assets = %v", result.Uploaded, mapKeys(observed.assets))
} }
for _, name := range wantAssets { for _, name := range wantAssets {
if _, ok := remote.assets[name]; !ok { if _, ok := observed.assets[name]; !ok {
t.Errorf("hosted assets omit %q: %v", name, mapKeys(remote.assets)) t.Errorf("hosted assets omit %q: %v", name, mapKeys(observed.assets))
} }
} }
if !strings.Contains(progress.String(), "checking fixture version output") { if !strings.Contains(progress.String(), "checking fixture version output") {
@@ -173,8 +177,8 @@ func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T)
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted { if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err) t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none before local verification", remote.requests) t.Fatalf("hosted requests = %d, want none before local verification", remote.snapshot().requests)
} }
} }
@@ -190,6 +194,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
prebuilt := test.prebuilt
if prebuilt != nil {
built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: repository, Version: "v1.2.3"})
if err != nil {
t.Fatalf("Build() for page-only prebuilt identity = %#v, %v", built, err)
}
prebuilt = &built
}
temporaryRoot := t.TempDir() temporaryRoot := t.TempDir()
t.Setenv("TMPDIR", temporaryRoot) t.Setenv("TMPDIR", temporaryRoot)
server, remote := newFacadeDeliveryServer(t, repository) server, remote := newFacadeDeliveryServer(t, repository)
@@ -202,14 +214,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
Version: "v1.2.3", Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL), Destination: facadeDeliveryDestination(server.URL),
Build: test.build, Build: test.build,
Prebuilt: test.prebuilt, Prebuilt: prebuilt,
Output: &progress, Output: &progress,
}) })
if err != nil || result.State != releasekit.DeliveryComplete { if err != nil || result.State != releasekit.DeliveryComplete {
t.Fatalf("Deliver() = %#v, %v", result, err) t.Fatalf("Deliver() = %#v, %v", result, err)
} }
if len(remote.assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 { if len(remote.snapshot().assets) != 0 || len(result.Uploaded) != 0 || len(result.Pending) != 0 {
t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.assets)) t.Fatalf("page-only delivery included assets: result=%#v assets=%v", result, mapKeys(remote.snapshot().assets))
} }
if progress.Len() != 0 { if progress.Len() != 0 {
t.Fatalf("page-only delivery emitted build progress: %q", progress.String()) t.Fatalf("page-only delivery emitted build progress: %q", progress.String())
@@ -221,6 +233,108 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
} }
} }
func TestDeliverUsesCommittedModuleIdentityForSeparatePages(t *testing.T) {
repository := newTwoModuleDeliveryConsumer(t)
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
wrongTag, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository,
Product: "Fixture",
Version: "v1.2.3",
ExpectedTag: "integrations/wrong/v1.2.3",
Destination: facadeDeliveryDestination(server.URL),
})
if err == nil || wrongTag.Tag != "v1.2.3" || remote.snapshot().requests != 0 {
t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.snapshot().requests)
}
rootResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository,
Product: "Fixture",
Version: "v1.2.3",
ExpectedTag: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL),
})
if err != nil || rootResult.State != releasekit.DeliveryComplete || rootResult.Tag != "v1.2.3" {
t.Fatalf("root delivery = %#v, %v", rootResult, err)
}
nestedTag := "integrations/ckconfig/v1.2.3"
remote.update(func(remote *facadeDeliveryData) {
remote.tag = nestedTag
remote.noteBody = "# Fixture v1.2.3\n\nNested delivery fixture.\n"
})
nestedResult, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository,
ModuleDir: "integrations/ckconfig",
Product: "Fixture",
Version: "v1.2.3",
ExpectedTag: nestedTag,
Destination: facadeDeliveryDestination(server.URL),
})
if err != nil || nestedResult.State != releasekit.DeliveryComplete || nestedResult.Tag != nestedTag || nestedResult.ModulePath != "example.com/ckconfig" {
t.Fatalf("nested delivery = %#v, %v", nestedResult, err)
}
if len(remote.snapshot().pages) != 2 || !remote.snapshot().pages["v1.2.3"] || !remote.snapshot().pages[nestedTag] {
t.Fatalf("created hosted pages = %#v", remote.snapshot().pages)
}
}
func TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests(t *testing.T) {
repository := newTwoModuleDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
},
OutputDir: filepath.Join(t.TempDir(), "root-artifacts"),
})
if err != nil || !prebuilt.SourceVerified {
t.Fatalf("root Build() = %#v, %v", prebuilt, err)
}
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
remote.update(func(remote *facadeDeliveryData) { remote.tag = "integrations/ckconfig/v1.2.3" })
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository,
ModuleDir: "integrations/ckconfig",
Product: "Fixture",
Version: "v1.2.3",
ExpectedTag: remote.snapshot().tag,
Destination: facadeDeliveryDestination(server.URL),
Prebuilt: &prebuilt,
})
if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.snapshot().tag {
t.Fatalf("nested delivery with root artifacts = %#v, %v", result, err)
}
if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.snapshot().requests)
}
}
func newTwoModuleDeliveryConsumer(t *testing.T) string {
t.Helper()
repository := newTaggedDeliveryConsumer(t)
writeNestedBuildConsumer(t, repository, "integrations/ckconfig", "example.com/ckconfig", `package main
import (
"fmt"
"os"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
)
func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Println("fixture", buildinfo.Version()) } }
`)
writeBuildFixture(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Fixture v1.2.3\n\nNested delivery fixture.\n")
buildGit(t, repository, "add", "--all")
buildGit(t, repository, "commit", "-m", "add nested release module")
buildGit(t, repository, "tag", "-f", "v1.2.3")
buildGit(t, repository, "tag", "integrations/ckconfig/v1.2.3")
return repository
}
func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) { func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
writeBuildFixture(t, repository, "local-change.txt", "dirty source") writeBuildFixture(t, repository, "local-change.txt", "dirty source")
@@ -242,19 +356,20 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T)
}, },
Output: &progress, Output: &progress,
}) })
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err) t.Fatalf("Deliver() = %#v, %v; want preflight failure for known commit", result, err)
} }
if progress.Len() != 0 { if progress.Len() != 0 {
t.Fatalf("build started before source eligibility passed: %q", progress.String()) t.Fatalf("build started before source eligibility passed: %q", progress.String())
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
} }
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) { func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t) repository := newTaggedDeliveryConsumer(t)
tagCommit := buildGit(t, repository, "rev-parse", "HEAD")
writeBuildFixture(t, repository, "later-change.txt", "new commit") writeBuildFixture(t, repository, "later-change.txt", "new commit")
buildGit(t, repository, "add", "--all") buildGit(t, repository, "add", "--all")
buildGit(t, repository, "commit", "-m", "advance checkout after release tag") buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
@@ -276,14 +391,14 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T
}, },
Output: &progress, Output: &progress,
}) })
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == remote.commit { if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit == tagCommit {
t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err) t.Fatalf("Deliver() = %#v, %v; want tag/HEAD mismatch", result, err)
} }
if progress.Len() != 0 { if progress.Len() != 0 {
t.Fatalf("build started before tag/HEAD agreement: %q", progress.String()) t.Fatalf("build started before tag/HEAD agreement: %q", progress.String())
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
} }
@@ -307,14 +422,14 @@ func TestDeliverRejectsInvalidCommittedNoteBeforeBuildOrHostedWrites(t *testing.
}, },
Output: &progress, Output: &progress,
}) })
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err) t.Fatalf("Deliver() = %#v, %v; want committed-note preflight failure", result, err)
} }
if progress.Len() != 0 { if progress.Len() != 0 {
t.Fatalf("build started before committed-note validation: %q", progress.String()) t.Fatalf("build started before committed-note validation: %q", progress.String())
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
} }
@@ -348,8 +463,8 @@ func TestDeliverManagedBuildRejectsCheckoutMutationBeforeHostedWrites(t *testing
if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted { if err == nil || !strings.Contains(err.Error(), "build source") || result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err) t.Fatalf("Deliver() = %#v, %v; want build-source eligibility failure", result, err)
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err) t.Fatalf("ineligible managed build output remains: entries=%v error=%v", entries, err)
@@ -374,11 +489,11 @@ func TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites(t *te
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}, Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
}, },
}) })
if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.commit { if err == nil || result.State != releasekit.DeliveryNotAttempted || result.CandidateCommit != remote.snapshot().commit {
t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err) t.Fatalf("Deliver() = %#v, %v; want managed build failure before hosted writes", result, err)
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err) t.Fatalf("failed managed build output remains: entries=%v error=%v", entries, err)
@@ -410,8 +525,8 @@ func TestDeliverManagedBuildCancellationCleansTemporaryOutput(t *testing.T) {
if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted { if !errors.Is(err, context.Canceled) || result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err) t.Fatalf("Deliver() = %#v, %v; want cancellation before hosted writes", result, err)
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 { if entries, err := os.ReadDir(temporaryRoot); err != nil || len(entries) != 0 {
t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err) t.Fatalf("canceled managed build output remains: entries=%v error=%v", entries, err)
@@ -434,8 +549,8 @@ func TestDeliverRejectsBothBuildSourcesBeforeHostedWrites(t *testing.T) {
if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") { if err == nil || result.State != releasekit.DeliveryNotAttempted || !strings.Contains(err.Error(), "both") {
t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err) t.Fatalf("Deliver() = %#v, %v; want invalid build-source combination", result, err)
} }
if remote.requests != 0 { if remote.snapshot().requests != 0 {
t.Fatalf("hosted requests = %d, want none", remote.requests) t.Fatalf("hosted requests = %d, want none", remote.snapshot().requests)
} }
} }
@@ -472,9 +587,41 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
} }
type facadeDeliveryServer struct { type facadeDeliveryServer struct {
mu sync.Mutex
data facadeDeliveryData
}
func (state *facadeDeliveryServer) update(change func(*facadeDeliveryData)) {
state.mu.Lock()
defer state.mu.Unlock()
change(&state.data)
}
func (state *facadeDeliveryServer) snapshot() facadeDeliveryData {
state.mu.Lock()
defer state.mu.Unlock()
result := state.data
result.assets = make(map[string][]byte, len(state.data.assets))
for key, value := range state.data.assets {
result.assets[key] = append([]byte(nil), value...)
}
result.pages = make(map[string]bool, len(state.data.pages))
for key, value := range state.data.pages {
result.pages[key] = value
}
return result
}
type facadeDeliveryData struct {
t *testing.T t *testing.T
commit string commit string
page bool tag string
version string
noteBody string
pages map[string]bool
requests int requests int
assets map[string][]byte assets map[string][]byte
url string url string
@@ -482,24 +629,28 @@ type facadeDeliveryServer struct {
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) { func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
t.Helper() t.Helper()
state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)} state := &facadeDeliveryServer{data: facadeDeliveryData{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), tag: "v1.2.3", version: "v1.2.3", noteBody: "# Fixture v1.2.3\n\nDelivery fixture.\n", pages: make(map[string]bool), assets: make(map[string][]byte)}}
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP)) server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
state.url = server.URL state.data.url = "http://" + server.Listener.Addr().String()
server.Start()
return server, state return server, state
} }
func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) { func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
state.mu.Lock()
defer state.mu.Unlock()
data := &state.data
if strings.HasPrefix(r.URL.Path, "/api/") { if strings.HasPrefix(r.URL.Path, "/api/") {
state.requests++ data.requests++
if r.Header.Get("Authorization") != "token fixture-token" { if r.Header.Get("Authorization") != "token fixture-token" {
state.t.Errorf("authorization = %q", r.Header.Get("Authorization")) data.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
} }
} }
switch r.URL.Path { switch r.URL.EscapedPath() {
case "/api/v1/repos/owner/example/tags/v1.2.3": case "/api/v1/repos/owner/example/tags/" + url.PathEscape(data.tag):
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit) _, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, data.tag, data.commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3": case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(data.tag):
if !state.page { if !data.pages[data.tag] {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -512,20 +663,20 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
Body string `json:"body"` Body string `json:"body"`
} }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
state.t.Errorf("decode release payload: %v", err) data.t.Errorf("decode release payload: %v", err)
http.Error(w, "invalid payload", http.StatusBadRequest) http.Error(w, "invalid payload", http.StatusBadRequest)
return return
} }
if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" { if payload.Tag != data.tag || payload.Target != data.commit || payload.Name != "Fixture "+data.version {
state.t.Errorf("release payload = %#v", payload) data.t.Errorf("release payload = %#v", payload)
} }
state.page = true data.pages[data.tag] = true
state.writeRelease(w) state.writeRelease(w)
case "/api/v1/repos/owner/example/releases/10/assets": case "/api/v1/repos/owner/example/releases/10/assets":
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
list := make([]map[string]any, 0, len(state.assets)) list := make([]map[string]any, 0, len(data.assets))
for name, content := range state.assets { for name, content := range data.assets {
list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": state.url + "/files/" + name}) list = append(list, map[string]any{"id": len(list) + 1, "name": name, "size": len(content), "browser_download_url": data.url + "/files/" + name})
} }
_ = json.NewEncoder(w).Encode(list) _ = json.NewEncoder(w).Encode(list)
return return
@@ -533,22 +684,22 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
name := r.URL.Query().Get("name") name := r.URL.Query().Get("name")
file, _, err := r.FormFile("attachment") file, _, err := r.FormFile("attachment")
if err != nil { if err != nil {
state.t.Errorf("read upload %q: %v", name, err) data.t.Errorf("read upload %q: %v", name, err)
http.Error(w, "invalid multipart upload", http.StatusBadRequest) http.Error(w, "invalid multipart upload", http.StatusBadRequest)
return return
} }
content, err := io.ReadAll(file) content, err := io.ReadAll(file)
_ = file.Close() _ = file.Close()
if err != nil { if err != nil {
state.t.Errorf("read upload %q: %v", name, err) data.t.Errorf("read upload %q: %v", name, err)
} }
state.assets[name] = content data.assets[name] = content
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": len(state.assets), "name": name, "size": len(content)}) _ = json.NewEncoder(w).Encode(map[string]any{"id": len(data.assets), "name": name, "size": len(content)})
default: default:
if strings.HasPrefix(r.URL.Path, "/files/") { if strings.HasPrefix(r.URL.Path, "/files/") {
name := strings.TrimPrefix(r.URL.Path, "/files/") name := strings.TrimPrefix(r.URL.Path, "/files/")
content, ok := state.assets[name] content, ok := data.assets[name]
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -556,17 +707,19 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
_, _ = w.Write(content) _, _ = w.Write(content)
return return
} }
state.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL) data.t.Errorf("unexpected delivery request %s %s", r.Method, r.URL)
http.NotFound(w, r) http.NotFound(w, r)
} }
} }
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) { func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
// The request handler holds state.mu.
data := &state.data
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3", "id": 10, "html_url": data.url + "/releases/10", "tag_name": data.tag,
"target_commitish": state.commit, "name": "Fixture v1.2.3", "target_commitish": data.commit, "name": "Fixture " + data.version,
"body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false, "body": data.noteBody, "draft": false, "prerelease": false,
}) })
} }

View File

@@ -0,0 +1,111 @@
# ADR 0005: Select a Go module as the unit of release
## Status
Accepted.
## Date
2026-10-09
## Context
A Go repository can contain independently versioned modules. CommandKit plans
to release its core and optional integrations from one repository. Each module
needs its own validation, release note, semantic version, and Git tag.
ReleaseKit currently couples the semantic version to the Git tag and uses the
repository directory for Go validation and release-note discovery. Supporting
independent modules through consumer-owned publication code would duplicate
ReleaseKit's responsibilities.
[ADR 0002](0002-routine-releases.md) prioritizes routine releases with little
application-owned code or recurring decision-making.
[ADR 0003](0003-declarative-validation.md) establishes shared declarative
validation. Module selection should give those checks a coherent release scope.
## Decision
Each check or publication invocation selects one Go module within a Git
repository. Omitted module selection means the repository-root module. The
public Go API and CLI/profile interface express the same selection. Consumers
can use small per-module profiles through a shared launcher.
ReleaseKit distinguishes the repository directory, selected module directory,
semantic version, and complete Git tag. It derives the tag using Go's module
tag conventions and the selected module's committed declaration. Derivation
accounts for major-version directories. For example, a module in
`integrations/ckconfig` at `v0.2.0` uses the tag
`integrations/ckconfig/v0.2.0`. A module declared with a `/v2` suffix and stored
in `integrations/ckconfig/v2` uses `integrations/ckconfig/v2.0.0` for `v2.0.0`.
The semantic version identifies the module release. The complete tag identifies
its source in the shared repository and governs uniqueness, tag creation,
publication, source links, and recovery. Candidate evidence, publication
results, and CLI output identify the selected module and both version and tag,
alongside the existing commit and publication evidence. Failure outcomes retain
the resolved identity for recovery.
Release notes belong at `<module-directory>/docs/releases/<version>.md` and
identify the product and semantic version in their title. ReleaseKit reads the
selected note from the captured candidate. Root-module releases retain the
existing note layout and unprefixed tags. Modules can publish equal semantic
versions through their distinct notes and tags.
Mandatory and optional release validation share the selected module's scope.
Go tests, selected vet, and package compilation execute in that module with
the existing offline environment. File checks inspect the module's committed
contents and relevant release materials. Independently released nested modules
are excluded from their parent's validation scope. Selecting the root module
follows the same rule.
A check may inspect referenced material outside the selected module when needed
to validate an in-scope item. Resolving a Markdown link to shared repository
documentation does not add that destination's documents to the validation input
set. The checkout remains the boundary for checks of outside-checkout links.
Shared release materials follow established conventions and documented
consumer requirements.
Product-specific checks receive the selected module context and repository
context for deliberate composition checks. ReleaseKit applies module selection
consistently across its shared checks, avoiding separate scope settings for
each check.
Publication guards cover the shared repository: cleanliness, branch, candidate
commit, authoritative endpoint, and the complete selected tag. The standard
suite runs once for the selected candidate and module. Successful source-tag
publication remains the completion point, with existing failure and uncertain
publication outcomes preserved. Consumers prepare released sibling dependencies
and choose their publication order.
## Alternatives considered
- Consumer-owned module publication gives each application responsibility for
tag conventions, validation, and recovery, duplicating shared mechanics.
- Module-local Go execution with repository-wide optional validation lets
unrelated modules block a selected release and gives checks inconsistent
scopes.
- Independent scope settings for each check require consumers to coordinate
multiple declarations of the same release boundary.
- Coordinated multi-module publication adds release-set and dependency-ordering
responsibilities beyond the demonstrated need for individual releases.
## Consequences
Consumers describe the module and product being released while ReleaseKit owns
the common procedure. Existing single-module repositories retain their default
behavior. Root modules containing independently released nested modules gain
module-scoped validation; release notes and migration guidance must explain
that change.
The shared candidate and publication paths must carry module scope and separate
version and tag identity throughout validation, diagnostics, and recovery.
Verification must cover root defaults, nested-module failures, equal versions
across modules, major-version directories, validation boundaries, and exact-tag
publication outcomes.
GoDoc and consumer documentation will own exact API and scope contracts.
Configuration and CLI documentation will own profile fields, commands, and
output. Implementation planning will define interactions with artifact builds
and hosted delivery. ReleaseKit's maintainer release policy continues to govern
ReleaseKit's own releases; consumers document their adopted module conventions.

View File

@@ -21,9 +21,10 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
``` ```
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the `VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile version. The repository argument defaults to the current directory. ReleaseKit
defaults to `<repository>/releasekit.json`. A successful command prints the resolves its containing Git checkout root before selecting the module or profile;
captured local commit and version. It checks the committed release note and the profile defaults to `<repository-root>/releasekit.json`. A successful command prints the
captured module, version, full tag, and commit. It checks the committed release note and
local Git state, runs the standard offline Go test suite (with `-race` when local Git state, runs the standard offline Go test suite (with `-race` when
`validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...` `validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`
pass after successful tests when `validation.go.full_vet` is true. It then runs pass after successful tests when `validation.go.full_vet` is true. It then runs
@@ -31,17 +32,28 @@ selected package compilation and committed Go formatting, followed by
configured additional checks in profile order. It does not inspect remote configured additional checks in profile order. It does not inspect remote
availability or publish a tag. availability or publish a tag.
The profile's optional `module_dir` selects a repository-relative physical
module directory; omission selects the root module. Validation and configured
product checks use that module selection. Additional check commands run with
the selected module as their working directory and receive
`RELEASEKIT_VERSION`, `RELEASEKIT_CANDIDATE`, `RELEASEKIT_REPOSITORY_DIR`,
`RELEASEKIT_MODULE_DIR`, `RELEASEKIT_MODULE_PATH`, and `RELEASEKIT_TAG` in their
environment. These values are added to each child environment without changing
the ReleaseKit process environment.
When `validation.go.compile_targets` is selected, `check` compiles all packages When `validation.go.compile_targets` is selected, `check` compiles all packages
for each configured `GOOS/GOARCH` pair after tests and full vet, using static for each configured `GOOS/GOARCH` pair after tests and full vet, using static
mode with cgo disabled. This check produces no artifacts and does not execute mode with cgo disabled. This check produces no artifacts and does not execute
target binaries. Compilation failures use the `package-compile` identity and target binaries. Compilation failures use the `package-compile` identity and
show the selected target. When `validation.go_format` is true, `check` examines show the selected target. When `validation.go_format` is true, `check` examines
regular committed Go files with the Go formatter after tests, vet, and package regular committed Go files with the Go formatter after tests, vet, and package
compilation. It includes vendor and nested-module source, ignores the worktree compilation. It uses the selected module and shared-file inputs, including
vendored source, and ignores the worktree
and symlink blobs, and reports unformatted paths as `go-format` without and symlink blobs, and reports unformatted paths as `go-format` without
modifying files. When `validation.module_distribution.workspace_files` is true, modifying files. When `validation.module_distribution.workspace_files` is true,
`check` reports committed `go.work` and `go.work.sum` blobs, including nested `check` reports committed `go.work` and `go.work.sum` blobs in those inputs,
and symlink entries, as `workspace-files`. This inspection is independent of including symlink entries, as `workspace-files`. Automatic file inputs stop at
independent nested modules. This inspection is independent of
`GOWORK` and ignores uncommitted files. Any artifact build selected by the `GOWORK` and ignores uncommitted files. Any artifact build selected by the
separate `build` setting runs after shared validation and configured checks. separate `build` setting runs after shared validation and configured checks.
When `validation.markdown` is a non-null object, `check` checks committed local When `validation.markdown` is a non-null object, `check` checks committed local
@@ -63,7 +75,7 @@ Unsupported argument sequences print usage to standard error.
Publish a source tag: Publish a source tag:
```sh ```sh
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION
``` ```
`publish` reads the same product, remote, validation, and additional-check `publish` reads the same product, remote, validation, and additional-check
@@ -75,8 +87,8 @@ tests. Selected package compilation and committed Go formatting follow. The
selected file checks then run in order: workspace files, local replacements, selected file checks then run in order: workspace files, local replacements,
file size, and Markdown. Configured checks run afterward in profile order. It file size, and Markdown. Configured checks run afterward in profile order. It
then creates an annotated local tag then creates an annotated local tag
and pushes only its exact tag object. It prints the confirmed version, and pushes only its exact full tag object. It prints the confirmed semantic
candidate commit, and tag object version, full tag, candidate commit, and tag object
on success. Validation on success. Validation
progress and failed publication state, including the captured publication progress and failed publication state, including the captured publication
endpoint for recovery, are written to standard error. A signal endpoint for recovery, are written to standard error. A signal
@@ -95,9 +107,11 @@ go.work.sum paths as `workspace-files`. Formatting failures are reported as
`go-format` with affected paths and preserve parser or source-inspection causes. `go-format` with affected paths and preserve parser or source-inspection causes.
When `validation.module_distribution.local_replacements` is true, the command When `validation.module_distribution.local_replacements` is true, the command
parses committed `go.mod` declarations, including nested and vendor modules, parses committed `go.mod` declarations in the selected module after
after workspace-file inspection. Filesystem replacements use the workspace-file inspection. Vendored contents remain in scope, while
`local-replacements` identity and include the declaration path, source line, automatic inputs stop at independently released nested modules. Explicit shared
files join the file-check inputs without expanding Go scope. Filesystem replacements use
the `local-replacements` identity and include the declaration path, source line,
and target; versioned replacements are permitted. Committed module symlinks are and target; versioned replacements are permitted. Committed module symlinks are
resolved within the candidate, regardless of `GOWORK`. resolved within the candidate, regardless of `GOWORK`.
When `validation.file_size` is a non-null object, the command checks each When `validation.file_size` is a non-null object, the command checks each
@@ -112,6 +126,11 @@ It reports outside-checkout links to standard error unless boundary enforcement
is selected; submodule contents remain out of scope. Markdown failures stop is selected; submodule contents remain out of scope. Markdown failures stop
publication before product checks or tag creation. publication before product checks or tag creation.
`--expected-module DIR` optionally checks a launcher's selected physical module
against the profile's `module_dir` before candidate work. Both paths are
normalized relative to the repository; the profile remains the source of module
selection. The root selection is `.`.
The optional profile field `expected_push_url` applies only to `publish`. A The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git; nonempty value must exactly match the effective push URL resolved by Git;
mismatches fail during preflight before Go validation or tag creation. Git URL mismatches fail during preflight before Go validation or tag creation. Git URL
@@ -135,8 +154,8 @@ go run ./cmd/releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSIO
targets with the requested version injected, checks the native `version` targets with the requested version injected, checks the native `version`
invocation, and writes binaries plus the private `.releasekit-build.json` invocation, and writes binaries plus the private `.releasekit-build.json`
receipt into `dist` by default. `--output` selects a different directory; receipt into `dist` by default. `--output` selects a different directory;
relative paths are resolved from the repository. Selected artifact filenames relative paths are resolved from the selected module directory. Selected
are regenerated while unrelated files are preserved. A complete nonempty build artifact filenames are regenerated while unrelated files are preserved. A complete nonempty build
writes a receipt only after compilation and smoke checking succeed. On partial writes a receipt only after compilation and smoke checking succeed. On partial
failure, standard error lists completed outputs and prior receipts in that failure, standard error lists completed outputs and prior receipts in that
output directory are invalidated; no new receipt is written. An output directory are invalidated; no new receipt is written. An
@@ -169,7 +188,9 @@ the tagged source into temporary storage, removes it when the operation ends,
and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is and uploads its binaries and complete `SHA256SUMS`. When `--assets DIR` is
supplied, that directory must contain the private `.releasekit-build.json` supplied, that directory must contain the private `.releasekit-build.json`
receipt and matching unchanged artifacts; this keeps the caller's original receipt and matching unchanged artifacts; this keeps the caller's original
bytes available for retries. Profiles without targets use page-only delivery bytes available for retries. Relative `--assets` paths use the selected module
directory, matching `build --output`; absolute paths retain their supplied base.
Profiles without targets use page-only delivery
and need no receipt, binary build, or manifest. `deliver` never runs candidate and need no receipt, binary build, or manifest. `deliver` never runs candidate
checks or publishes a tag. checks or publishes a tag.
@@ -197,24 +218,34 @@ known missing assets are added. See the
Prepare a tagged CI checkout and deliver it: Prepare a tagged CI checkout and deliver it:
```sh ```sh
releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG
``` ```
`ci-deliver` fetches `VERSION` from the selected Git remote (default `origin`), `TAG` is the complete Git tag name, including any module path prefix. Its last
requires the tag's peeled commit to match the required full `--commit` object path component must be a stable semantic version; other commands continue to
ID, and checks out that commit detached. It then loads the profile from the take a semantic `VERSION`. By default, `ci-deliver` fetches the exact `TAG` from
prepared checkout, validates delivery settings and the configured token, and the selected Git remote (default `origin`), requires its peeled commit to match
downloads application dependencies only when the profile selects build targets. the required full `--commit` object ID, checks out that commit detached, then
Binary profiles are built and delivered through the same facade path as verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
`deliver`; source-only profiles skip dependency downloads and binary builds. checks read-only against the existing checkout and does not fetch or check out.
In either mode, it loads the profile from the verified source. `--expected-module`
optionally checks that the profile selects the module chosen by a launcher;
module selection itself remains profile-owned. The command validates delivery
settings and the configured token, then downloads application dependencies only
when the profile selects build targets. Binary profiles are built and delivered
through the same facade path as `deliver`; source-only profiles skip dependency
downloads and binary builds.
`--config` follows the existing profile path rules: relative paths are resolved `--config` follows the existing profile path rules: relative paths are resolved
from `--repo`, and absolute paths are used as-is. This command explicitly from `--repo`, and absolute paths are used as-is. The default mode prepares and
prepares and changes the selected checkout. Ordinary `deliver` continues to changes the selected checkout; `--prepared` reuses a verified checkout.
operate on already prepared source without fetching, checking out, or Ordinary `deliver` continues to operate on already prepared source without
downloading dependencies. fetching, checking out, or downloading dependencies.
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the In Woodpecker, pass `CI_WORKSPACE`, the source commit resolved from
repository, expected commit, and version arguments. The CLI does not read `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the repository, expected commit, and full
tag arguments. The event ID may identify an annotated tag object; see the
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for resolution
and workflow preparation. The CLI does not read
provider-specific environment variable names. Checkout and dependency provider-specific environment variable names. Checkout and dependency
preparation failures return status 1; usage, profile, credential, and invalid preparation failures return status 1; usage, profile, credential, and invalid
version errors return status 2. Final delivery uses the same statuses as version errors return status 2. Final delivery uses the same statuses as

View File

@@ -1,14 +1,16 @@
# Configuration # Configuration
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default. ReleaseKit reads a JSON profile from `<repository-root>/releasekit.json` by default.
The repository directory defaults to the current directory. A relative profile The repository argument defaults to the current directory; the CLI resolves its
path is resolved from that repository directory; an absolute path is used as-is. containing Git checkout root. A relative profile path is resolved from that root;
an absolute path is used as-is.
The current profile fields are: The current profile fields are:
| Field | Required | Behavior | | Field | Required | Behavior |
| --- | --- | --- | | --- | --- | --- |
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `module_dir` | No | Repository-relative physical Go module directory with its own `go.mod`; omission selects `.` (the repository root). |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `remote` | No | Selected Git remote; defaults to `origin`. |
| `expected_push_url` | No | Exact effective push URL required by `publish`; ignored by local `check`. | | `expected_push_url` | No | Exact effective push URL required by `publish`; ignored by local `check`. |
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
@@ -16,6 +18,13 @@ The current profile fields are:
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. | | `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
`module_dir` names the selected module's physical location in the repository;
ReleaseKit reads its declared import path from that module's own `go.mod`. The
physical directory and declared path are distinct: a declared `/v2` module can
live either at `integrations/core` or `integrations/core/v2`, while both use the
same full release tag. Root-only profiles can omit `module_dir` and do not need
a module map.
Each check requires a nonempty `args` array whose first element names the Each check requires a nonempty `args` array whose first element names the
executable. Arguments are passed directly to the child process; ReleaseKit does executable. Arguments are passed directly to the child process; ReleaseKit does
not parse a shell command string. Select a shell explicitly when a check needs not parse a shell command string. Select a shell explicitly when a check needs
@@ -28,6 +37,17 @@ target requires the package and binary name; each target is written as one
`GOOS/GOARCH` pair. An omitted or empty target list means no binary work. `GOOS/GOARCH` pair. An omitted or empty target list means no binary work.
The optional `validation` object selects shared checks implemented by ReleaseKit. The optional `validation` object selects shared checks implemented by ReleaseKit.
`validation.shared_files` is a committed file list resolved from the repository
root. Omission or `null` uses `COPYRIGHT`, `LICENSE`, and
`docs/oss/attribution.md` when present for enabled file checks; `[]` selects no
shared files. An explicit list replaces those defaults. Each explicit file must
exist in the candidate, even when no file checks are enabled. Paths are literal,
repository-relative files with no glob semantics and may select a file outside
the chosen module. ReleaseKit deduplicates them with module inputs by path. This
field does not enable any checks or expand Go test, vet, or package-compilation
scope. Automatic file inputs stop at independent nested-module boundaries;
vendored files remain included. Reference inspection can read destinations
elsewhere in the captured repository without adding them to those inputs.
`validation.go.race` enables the race detector for the single standard Go test `validation.go.race` enables the race detector for the single standard Go test
suite. `validation.go.full_vet` runs a separate native `go vet` pass after that suite. `validation.go.full_vet` runs a separate native `go vet` pass after that
suite succeeds; ReleaseKit disables test-time vet for that suite to avoid suite succeeds; ReleaseKit disables test-time vet for that suite to avoid
@@ -40,7 +60,8 @@ executable, or create an artifact manifest. Omitted or empty `compile_targets`
selects no package-compilation pass. When `full_vet` is omitted or false, the selects no package-compilation pass. When `full_vet` is omitted or false, the
test command keeps its existing vet behavior. These settings are independent. test command keeps its existing vet behavior. These settings are independent.
`validation.go_format` checks regular committed Go blobs with the Go formatter `validation.go_format` checks regular committed Go blobs with the Go formatter
used to build ReleaseKit. It includes vendor and nested-module source, ignores used to build ReleaseKit. It uses the selected file inputs, including vendor and
explicit shared files, and ignores
symlink blobs and the worktree, and reports every selected path whose bytes symlink blobs and the worktree, and reports every selected path whose bytes
change under formatting. It never writes formatted bytes back. Omitted or false change under formatting. It never writes formatted bytes back. Omitted or false
disables the check. It runs after tests, selected full vet, and selected package disables the check. It runs after tests, selected full vet, and selected package
@@ -69,12 +90,14 @@ Anchors keep Unicode letters and numbers, underscores, and hyphens; spaces
become hyphens, and repeated headings receive numeric suffixes. become hyphens, and repeated headings receive numeric suffixes.
When `validation.module_distribution.workspace_files` is true, it reports When `validation.module_distribution.workspace_files` is true, it reports
committed blob entries whose basename is `go.work` or `go.work.sum`, including committed `go.work` and `go.work.sum` files in the selected module inputs,
nested paths and symlinks. It checks candidate distribution contents regardless including nested paths and symlinks. It checks the captured candidate regardless
of `GOWORK`; it does not select or change the workspace used by Go commands, of `GOWORK`; it does not select or change the workspace used by Go commands,
and it does not alter existing vendor behavior. Omitted or false disables it. and vendored contents remain in scope. Omitted or false disables it. When
When `validation.module_distribution.local_replacements` is true, it parses all `validation.module_distribution.local_replacements` is true, it parses the
committed `go.mod` declarations, including vendor and nested modules, and reports selected module's committed `go.mod` declarations. Vendored contents remain in
scope; an explicit shared-file selection can include an individual declaration
from another module. It reports
`replace` directives whose replacement has no version. These filesystem `replace` directives whose replacement has no version. These filesystem
replacements include relative and absolute paths; versioned module replacements replacements include relative and absolute paths; versioned module replacements
are permitted. Committed `go.mod` symlinks are resolved within the candidate, are permitted. Committed `go.mod` symlinks are resolved within the candidate,
@@ -109,8 +132,8 @@ check; `{}` enables it with a default of 1,000,000 bytes. A missing or null
`limit_bytes` selects that default, zero is a valid limit, and negative limits `limit_bytes` selects that default, zero is a valid limit, and negative limits
are invalid. are invalid.
`validation.file_size.exclusions` contains literal repository-relative file or `validation.file_size.exclusions` contains literal paths relative to the
directory paths. Directory exclusions include committed descendants, using path selected module. Directory exclusions include committed descendants, using path
boundaries (`assets` does not exclude `assets-old`). Ordinary relative path boundaries (`assets` does not exclude `assets-old`). Ordinary relative path
normalization applies; metacharacters are literal and unmatched paths have no normalization applies; metacharacters are literal and unmatched paths have no
effect. The check measures regular and symlink blobs, including small binaries; effect. The check measures regular and symlink blobs, including small binaries;
@@ -173,14 +196,21 @@ retained for retries or when build and delivery credentials need separate
invocations. Ordinary `deliver` does not download application dependencies or invocations. Ordinary `deliver` does not download application dependencies or
change the checkout. change the checkout.
`ci-deliver` uses the same profile after preparing the selected tag in the `ci-deliver` takes a full Git `TAG`; its final path component is the stable
checkout. It checks out the tag only after its peeled commit matches the semantic version and the complete value is checked against the profile's
provider-supplied commit ID, then loads the profile and validates delivery derived tag. By default it fetches the selected tag, confirms its peeled commit
settings and credentials. It runs `go mod download` only when the profile has matches the provider-supplied commit ID, checks it out detached, and verifies
selected build targets; source-only delivery does not need application the tag, HEAD, and clean worktree. With `--prepared`, it performs the same
dependencies. This preparation may access the network and may update checks against an existing checkout without fetching or checking out. It then
`go.sum`. The command takes CI values as arguments, so profile settings remain loads the profile from that source and validates delivery settings and
independent of Woodpecker environment variable names. See the credentials. An optional `--expected-module` checks a launcher's module
selection against the profile. It runs `go mod download` only when the profile
has selected build targets; source-only delivery does not need application
dependencies. Default preparation may access the network and may update
`go.sum`. For binary tagged delivery, prepare and commit that module's sums
before tagging so dependency preparation leaves the tagged checkout clean. The
command takes CI values as arguments, so profile settings remain independent of
Woodpecker environment variable names. See the
[CLI contract](cli.md) for the invocation and exit statuses. [CLI contract](cli.md) for the invocation and exit statuses.
This application dependency preparation is separate from bootstrapping the This application dependency preparation is separate from bootstrapping the

View File

@@ -9,21 +9,26 @@ maintainers without adding imports to application or library code.
From the consumer module's root, select the ReleaseKit version to adopt: From the consumer module's root, select the ReleaseKit version to adopt:
```sh ```sh
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.2.0 : "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
go get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
go mod tidy go mod tidy
go tool releasekit version go tool releasekit version
``` ```
Replace `v0.2.0` with the intended release. Go records the executable package in Select a release that implements the API, profile, and workflow you are adopting.
The module-release interface requires a release containing that feature;
pre-feature versions through v0.3.0 use their corresponding historical interface.
Go records the executable package in
a `tool` directive and the module version in a `require` directive. The tool a `tool` directive and the module version in a `require` directive. The tool
declaration retains the dependency through `go mod tidy`. Commit the resulting declaration retains the dependency through `go mod tidy`. Commit the resulting
`go.mod` and `go.sum` changes. To upgrade, repeat the command with the chosen `go.mod` and `go.sum` changes. To upgrade, repeat the command with the chosen
version and review the module changes. version and review the module changes.
Go tool dependencies require Go 1.24 or later, together with a toolchain that The Go tool dependency feature requires Go 1.24 or later. The current ReleaseKit
satisfies the selected ReleaseKit version and its dependencies. Tools share the module declares a Go 1.26.0 minimum, so use Go 1.26.0 or later for the current
consumer's module graph: their requirements participate in dependency version release. Tool dependencies share the consumer's module graph: their requirements
selection and can raise the consumer module's Go minimum. See the participate in dependency version selection and can raise the consumer module's
Go minimum. See the
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool). [Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
Run `go tool releasekit` from within the consumer module to use its selected Run `go tool releasekit` from within the consumer module to use its selected
@@ -37,7 +42,8 @@ Consumers importing the root `releasekit` API or
required imports and select the module version: required imports and select the module version:
```sh ```sh
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0 : "${RELEASEKIT_TOOL_VERSION:?Set the released ReleaseKit version to adopt}"
go get "gitea.maximumdirect.net/eric/ReleaseKit@${RELEASEKIT_TOOL_VERSION}"
go mod tidy go mod tidy
``` ```
@@ -51,3 +57,10 @@ supports both tool and ordinary package dependencies. It builds ReleaseKit from
the tagged consumer's module selection, so the committed module files also the tagged consumer's module selection, so the committed module files also
govern CI delivery and retries. Registering the CLI as a tool is sufficient for govern CI delivery and retries. Registering the CLI as a tool is sufficient for
consumers that use ReleaseKit only for maintainer commands. consumers that use ReleaseKit only for maintainer commands.
Upgrade the module-selected tool and workflow together. For a tag created before
that upgrade, retry with the workflow matching the tool recorded at that tag.
Keep its immutable module files and tag intact. See the
[delivery guide](delivery.md#woodpecker-tag-workflows) for tagged-source bootstrap
and the [example's source verification](../../examples/multi-module/README.md#local-source-verification)
for testing an unreleased checkout in a disposable consumer repository.

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,10 +77,14 @@ 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
separate build output; ReleaseKit requires its receipt and verifies the source, separate build output; ReleaseKit requires the current module-bound receipt and
build settings, and artifact hashes before the first Gitea write. See the checks its source identity and normalized build settings. It verifies artifact
bytes and sizes during delivery before the first Gitea write. Older receipts
must be regenerated with the updated `build` command. See the
[delivery consumer guide](delivery.md) for page-only delivery and retry states. [delivery consumer guide](delivery.md) for page-only delivery and retry states.

View File

@@ -21,14 +21,27 @@ if err != nil {
fmt.Println(result.Version, result.Commit) fmt.Println(result.Version, result.Commit)
``` ```
The repository must have a clean index and worktree on `main`, and the selected Set `ReleaseRequest.ModuleDir` to select a nested module using a
version must be stable `vMAJOR.MINOR.PATCH`. The committed repository-relative physical directory; omission or `.` selects the root
`docs/releases/<version>.md` note must have a first Markdown H1 containing the module. ReleaseKit reads that module's committed `go.mod` and returns its
product name and exact version as complete title elements. No local tag with physical `ModuleDir`, declared `ModulePath`, semantic `Version`, and complete
that version may already exist. `Tag` in the candidate and result. A physical `/v2` directory is removed from
the tag prefix only when the declared module path also ends in `/v2`. For
example, both `integrations/core/v2` and `integrations/core` can declare
`example.com/project/integrations/core/v2` and use the tag
`integrations/core/v2.1.0`. The release note lives under the physical module at
`<module>/docs/releases/<version>.md`.
ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the The repository must have a clean index and worktree on `main`, and the selected
available compatible local Go toolchain. Set `Validation.Go.Race` to run that version must be stable `vMAJOR.MINOR.PATCH`. The module-local committed
`<module>/docs/releases/<version>.md` note must have a first Markdown H1 containing the
product name and exact version as complete title elements. No local tag with
the derived complete tag may already exist. Different modules may use the same
semantic version with distinct notes and tags.
ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite from the
selected module directory with the available compatible local Go toolchain.
Set `Validation.Go.Race` to run that
suite with `-race`; set `Validation.Go.FullVet` to run suite with `-race`; set `Validation.Go.FullVet` to run
`go vet -mod=readonly ./...` after successful tests, with test-time vet disabled `go vet -mod=readonly ./...` after successful tests, with test-time vet disabled
for the suite. Set `Validation.Go.CompileTargets` to compile packages for each for the suite. Set `Validation.Go.CompileTargets` to compile packages for each
@@ -37,28 +50,32 @@ complete target pair after tests and vet. Package compilation uses
failures use the `package-compile` identity and include the target. The zero failures use the `package-compile` identity and include the target. The zero
values retain the ordinary native test behavior and select no compilation. values retain the ordinary native test behavior and select no compilation.
Set `Validation.GoFormat` to check committed regular Go files using the Go Set `Validation.GoFormat` to check committed regular Go files using the Go
formatter built into ReleaseKit. It includes vendor and nested-module source, formatter built into ReleaseKit. It uses the selected file inputs, including
vendor and explicit shared files, and
ignores symlink blobs and worktree changes, and reports unformatted paths as ignores symlink blobs and worktree changes, and reports unformatted paths as
`go-format` without rewriting files. Its zero value disables the check; it runs `go-format` without rewriting files. Its zero value disables the check; it runs
after tests, selected vet, and package compilation, before `AdditionalChecks`. after tests, selected vet, and package compilation, before `AdditionalChecks`.
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
`go.work` and `go.work.sum` blobs, including nested and symlink entries. It `go.work` and `go.work.sum` files in the selected module inputs, including
examines the captured candidate even when `GOWORK=off`, ignores uncommitted symlink entries. It examines the captured candidate even when
files, and does not change Go test workspace selection or vendor behavior. The `GOWORK=off`, ignores uncommitted files, and does not change Go test workspace
zero value disables the check; failures use `workspace-files`. selection; vendored contents remain in scope. The zero value disables the check;
failures use `workspace-files`.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations throughout the candidate, including nested and the selected module's committed `go.mod` declarations. Vendored contents remain
vendor modules. It reports filesystem `replace` directives with their source in scope; automatic inputs stop at independently released nested modules. An
path, line, and target as `local-replacements`; versioned replacements are explicit shared-file selection can include another module's declaration. It reports
filesystem `replace` directives with their source path, line, and target as
`local-replacements`; versioned replacements are
permitted. Committed module symlinks are resolved within the candidate. Parse permitted. Committed module symlinks are resolved within the candidate. Parse
and inspection errors retain their causes, and the rule applies even when and inspection errors retain their causes, and the rule applies even when
`GOWORK=off`. Its zero value disables the check. `GOWORK=off`. Its zero value disables the check.
Set `Validation.FileSize` to check each committed blob against a byte limit. Set `Validation.FileSize` to check each committed blob against a byte limit.
Nil disables the check; an empty object enables the default of 1,000,000 Nil disables the check; an empty object enables the default of 1,000,000
decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects
negative values. `Exclusions` contains literal repository-relative files or negative values. `Exclusions` contains literal files or directories relative to
directories; excluded directories include descendants. Symlink blobs are the selected module; excluded directories include descendants. Symlink blobs are
measured by link-text bytes, and gitlinks are omitted. The check applies to the measured by link-text bytes, and gitlinks are omitted. The check applies to the
captured candidate and reports one oversized file with its measurement as captured candidate and reports one oversized file with its measurement as
`file-size`; it does not measure historical or aggregate size. `file-size`; it does not measure historical or aggregate size.
@@ -76,7 +93,25 @@ standard suite, selected vet/format/distribution checks, or Markdown navigation
checks in `AdditionalChecks` or profile `checks`. Keep release-note rules and checks in `AdditionalChecks` or profile `checks`. Keep release-note rules and
consumer-specific assertions—such as tests of maintained examples—in those consumer-specific assertions—such as tests of maintained examples—in those
product-owned checks. product-owned checks.
ReleaseKit calls `AdditionalChecks` once after shared validation when supplied. The `Candidate` supplies both `RepositoryDir` and `ModuleDir`, plus the declared
`ModulePath`, semantic `Version`, and full `Tag`; use its module directory for
module-local product checks. ReleaseKit calls `AdditionalChecks` once after
shared validation when supplied.
`ValidationSpec.SharedFiles` adds committed, repository-relative files to
ReleaseKit's optional file checks without enabling any checks. A nil slice
selects the optional defaults `COPYRIGHT`, `LICENSE`, and
`docs/oss/attribution.md`, when present; a non-nil empty slice selects none.
Explicit files replace those defaults, must exist even when file checks are
disabled, and may be in another module. Combined inputs are deduplicated by
repository-relative path.
Automatic file inputs stop at independent nested-module boundaries, including
fixture modules. Vendored files remain included. Shared selection affects file
inputs only; Go validation retains the selected module's scope. Reference reads
can inspect destinations elsewhere in the captured repository without adding
those destinations to the input set.
It does not download a different toolchain automatically. If race prerequisites It does not download a different toolchain automatically. If race prerequisites
are unavailable, the `go-test` check fails rather than being skipped; a selected are unavailable, the `go-test` check fails rather than being skipped; a selected
full-vet failure is reported as `go-vet`. The suite uses the Go host target, full-vet failure is reported as `go-vet`. The suite uses the Go host target,
@@ -97,10 +132,11 @@ commands from `releasekit.json`.
## Publishing the source tag ## Publishing the source tag
`releasekit.Publish` uses the same request and validation order: the standard `releasekit.Publish` uses the same request and validation order: the standard
suite runs first, followed by `AdditionalChecks` when supplied. It then performs suite runs first, followed by `AdditionalChecks` when supplied. It surrounds
authoritative remote checks, annotated-tag creation, and an exact tag push. The validation with authoritative remote guards before annotated-tag creation and
an exact tag push. The
selected remote must publish `main` at the captured commit and must not already selected remote must publish `main` at the captured commit and must not already
contain the selected version tag. A successful result includes the endpoint and contain the selected complete tag. A successful result includes the endpoint and
tag object ID. Publication states distinguish an unattempted push, a tag that tag object ID. Publication states distinguish an unattempted push, a tag that
was created locally but not pushed, a successful push, a guard conflict, and an was created locally but not pushed, a successful push, a guard conflict, and an
unresolved push error. See the [publication guide](publish.md) for the complete unresolved push error. See the [publication guide](publish.md) for the complete

View File

@@ -7,6 +7,13 @@ The operation accepts historical lightweight and annotated tags, including a
detached checkout, when the checked-out `HEAD` identifies the selected tag detached checkout, when the checked-out `HEAD` identifies the selected tag
commit. It does not require the old remote `main` to match. commit. It does not require the old remote `main` to match.
Delivery resolves the selected module's declaration and release note from the
captured commit. `Version` remains the semantic version; the full tag includes
the selected module's repository-relative physical path when it is nested. The
profile or API request selects the physical module directory. The optional API
`ExpectedTag` checks that a CI event's literal tag agrees with the derived tag
before any build or hosted request.
## CI preparation ## CI preparation
Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified
@@ -20,7 +27,7 @@ or hosted content.
```go ```go
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{ err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
RepositoryDir: repositoryDir, RepositoryDir: repositoryDir,
Version: version, Tag: "integrations/example/v1.2.3",
ExpectedCommit: ciCommit, ExpectedCommit: ciCommit,
}) })
if err != nil { if err != nil {
@@ -28,16 +35,25 @@ if err != nil {
} }
``` ```
For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after For binary profiles, call `PrepareBuildDependencies` with a
checkout preparation and before delivery. It runs `go mod download` with `DependencyPreparationRequest` selecting the repository and module after
workspace discovery disabled and the available compatible local Go toolchain. checkout preparation and before delivery. It runs `go mod download` in that
module with workspace discovery disabled and the available compatible local Go
toolchain.
The operation may use the network and updates the Go module cache while The operation may use the network and updates the Go module cache while
preserving the caller's proxy, private-module, credential, and cache settings. preserving the caller's proxy, private-module, credential, and cache settings.
If checksums are missing, Go may update `go.sum`; see the If checksums are missing, Go may update `go.sum`. For binary CI delivery, prepare
and commit the selected module's `go.sum` before tagging: delivery verifies the
tagged source remains clean after dependency preparation. See the
[Go Modules Reference](https://go.dev/ref/mod#go-mod-download). Cancellation [Go Modules Reference](https://go.dev/ref/mod#go-mod-download). Cancellation
and command errors are returned. Page-only profiles do not need dependency and command errors are returned. Page-only profiles do not need dependency
preparation. preparation.
Set `TaggedCheckoutRequest.VerifyOnly` when a bootstrap step already fetched and
detached the event tag. This mode checks the exact tag, expected commit, `HEAD`,
and clean worktree without fetching or changing the checkout. Both modes require
the full `Tag` and expected commit.
## Command line ## Command line
Add a Gitea destination to the profile: Add a Gitea destination to the profile:
@@ -66,9 +82,14 @@ The CLI reads the named token variable only for `deliver`; its default name is
creating or accepting a hosted release page. The full checked-in note is the creating or accepting a hosted release page. The full checked-in note is the
hosted body. A retry accepts an identical page and matching attachments, adds hosted body. A retry accepts an identical page and matching attachments, adds
only missing assets, and reports conflicts without replacing content. only missing assets, and reports conflicts without replacing content.
For nested modules, the note comes from that module's committed
`docs/releases/<version>.md`, and the hosted page is keyed by its complete tag.
With no build targets, delivery is page-only: no builder, version smoke check, With no build targets, delivery is page-only: no builder, version smoke check,
receipt, binary asset, or checksum manifest is involved. With selected targets, receipt, binary asset, or checksum manifest is involved. Committed module
directories and declaration links can be resolved from a sparse checkout;
page-only delivery needs the committed objects and note. Managed builds require
the selected source files to be materialized. With selected targets,
one `deliver` invocation builds the tagged source through the facade into one `deliver` invocation builds the tagged source through the facade into
temporary storage and delivers the result. To retain artifacts for a retry or temporary storage and delivers the result. To retain artifacts for a retry or
separate credentials between build and delivery, use two invocations: separate credentials between build and delivery, use two invocations:
@@ -80,10 +101,13 @@ RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" releasekit deliver --assets dist v1.2.3
The build command does not need delivery credentials. The `--assets DIR` The build command does not need delivery credentials. The `--assets DIR`
directory must contain the private `.releasekit-build.json` receipt created by directory must contain the private `.releasekit-build.json` receipt created by
`build`. ReleaseKit checks the receipt's repository, commit, version, resolved `build`. ReleaseKit checks its schema, repository, commit, version, resolved
build settings, and file hashes before making a remote request; caller-owned module directory and path, full tag, normalized build settings, and artifact
files remain available for retries. Ordinary `deliver` does not download declarations. Old receipts must be regenerated with the updated `build`
application dependencies or change the checkout. command. Delivery verifies the artifact bytes and sizes before making a hosted
request; caller-owned files remain available for retries.
Ordinary `deliver` does not download application dependencies or change the
checkout.
The result reports `complete`, `partial`, `conflict`, `unknown`, or The result reports `complete`, `partial`, `conflict`, `unknown`, or
`not_attempted`, along with matching, uploaded, pending, conflicting, and `not_attempted`, along with matching, uploaded, pending, conflicting, and
@@ -110,19 +134,23 @@ outside the checkout and removed when delivery returns. `Output` receives build
progress. With no build, or with an empty target set, delivery is page-only. progress. With no build, or with an empty target set, delivery is page-only.
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`. To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
ReleaseKit checks that it identifies the selected repository, tagged commit, and ReleaseKit checks its repository, module directory, declared module path, full
version, that its source was verified clean and unchanged during compilation, tag, semantic version, tagged commit, normalized settings, and artifact evidence
and that the current files still match their recorded digests. The files remain against the selected committed module. Binary results must record verified
caller-owned, so callers can retain the original bytes for retries and are source. The current files are checked against their recorded digests before any
responsible for cleanup. With neither artifact source, an empty managed target hosted request. The files remain caller-owned, so callers can retain the
set, or an empty prebuilt result, delivery is page-only. Do not set both `Build` original bytes for retries and are responsible for cleanup. With neither
and `Prebuilt` in one request. artifact source, an empty managed target set, or a matching empty prebuilt
result, delivery is page-only. Do not set both `Build` and `Prebuilt` in one
request.
```go ```go
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: "/work/example", RepositoryDir: "/work/example",
ModuleDir: "integrations/example",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
Destination: releasekit.GiteaDestination{ Destination: releasekit.GiteaDestination{
BaseURL: "https://gitea.example", BaseURL: "https://gitea.example",
Owner: "example", Owner: "example",
@@ -157,31 +185,51 @@ a different toolchain. The tag's `go.mod` must select ReleaseKit as a module
dependency. For CLI-only consumers, use the versioned Go tool dependency dependency. For CLI-only consumers, use the versioned Go tool dependency
described in the [adoption guide](adoption.md). Ordinary package dependencies described in the [adoption guide](adoption.md). Ordinary package dependencies
also work with this template. also work with this template.
Select a tool release implementing this workflow's interface before adopting it;
the [adoption guide](adoption.md#ci-adoption) owns coordinated tool/workflow
upgrades. Retries of older tags use the workflow matching their recorded tool.
Configure the Woodpecker repository secret `releasekit_gitea_token`; the Configure the Woodpecker repository secret `releasekit_gitea_token`; the
template maps it to `RELEASEKIT_GITEA_TOKEN` for delivery. The repository needs template maps it to `RELEASEKIT_GITEA_TOKEN` for delivery. The repository needs
a `delivery` profile. Page-only and binary behavior both use this template; the a `delivery` profile. Page-only and binary behavior both use this template; the
profile's build target list selects whether application binaries are built and profile's build target list selects whether application binaries are built and
delivered. delivered.
Before running Go, the template fetches the explicit event tag, compares its Before running Go, the template fetches the explicit event tag and independently
peeled commit with the provider's full commit ID without regard to hex case, resolves both the tag and `CI_COMMIT_SHA` to source commits. The event ID may
checks out that commit detached, and confirms the source tree is clean. It then identify an annotated tag object or a commit. The template requires the resolved
copies the tagged `go.mod` and any `go.sum` to a temporary directory outside commits to match, checks out that commit detached, and confirms the source tree
the checkout. `go list -m` confirms that the module selects ReleaseKit; Go uses is clean. It then copies the tagged `go.mod` and any `go.sum` to a temporary
directory outside the checkout. `go list -m` confirms that the module selects
ReleaseKit; Go uses
an alternate `-modfile` to prepare dependencies and build the selected an alternate `-modfile` to prepare dependencies and build the selected
`cmd/releasekit` package without changing the tagged module files. The template `cmd/releasekit` package without changing the tagged module files. The template
invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and invokes that executable with `CI_WORKSPACE`, the resolved event commit, and
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable. `CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
Tool bootstrap and application artifact preparation are separate. The bootstrap Tool bootstrap and application artifact preparation are separate. The bootstrap
resolves and builds ReleaseKit from the tagged consumer's module configuration. resolves and builds ReleaseKit from the tagged consumer's module configuration.
Afterward, `ci-deliver` loads the profile and downloads application dependencies After the workflow verifies and checks out the event tag, the resulting CLI uses
only when build targets are selected; it builds and delivers those targets with `ci-deliver --prepared` to repeat the tag, commit, HEAD, and cleanliness checks
the note page. Source-only profiles skip application dependency downloads and without another fetch or checkout. It loads the tagged profile and downloads
binary work. Dependency preparation for binaries may use the network and may application dependencies only when build targets are selected; it builds and
update an uncommitted checksum file in the checkout. A retry still uses the delivers those targets with the note page. Source-only profiles skip application
same immutable tag and that tag's module settings, even after the default branch dependency downloads and binary work. Dependency preparation for binaries may
selects a newer ReleaseKit version. use the network. Prepare and commit the selected module's `go.sum` before
tagging; an uncommitted change makes the later delivery source check fail. A
retry still uses the same immutable tag and that tag's module settings, even
after the default branch selects a newer ReleaseKit version.
The root-only [`tag-delivery.yml`](../../examples/woodpecker/tag-delivery.yml)
keeps the small default profile and root-module bootstrap. For consumers that
release several modules from one repository, copy the contents of the maintained
[multi-module example](../../examples/multi-module/) to the consumer repository
root and use its [`woodpecker.yml`](../../examples/multi-module/woodpecker.yml),
[`release-modules.tsv`](../../examples/multi-module/release-modules.tsv), and
shared selector. The event tag chooses one row from the tagged checkout; the
profile and physical module must agree, and ReleaseKit verifies the derived tag.
The example covers source-only integration pages and a binary core module.
If the application does not declare ReleaseKit as a module dependency, install If the application does not declare ReleaseKit as a module dependency, install
the executable separately and invoke the same `ci-deliver` command. For the executable separately and invoke the same `ci-deliver` command. For
@@ -197,10 +245,12 @@ The normal Go module and build caches are sufficient. Cache mounts may be added
as deployment configuration when they help the runner; they are not needed by as deployment configuration when they help the runner; they are not needed by
the template. A separately installed executable can be installed with the template. A separately installed executable can be installed with
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run `go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
with the same arguments: with the same arguments. With the event object available in the CI checkout,
resolve it to its source commit before invocation:
```sh ```sh
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG" expected_commit=$(git -C "$CI_WORKSPACE" rev-parse --verify "${CI_COMMIT_SHA}^{commit}")
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$expected_commit" "$CI_COMMIT_TAG"
``` ```
The module-based workflow follows the ReleaseKit version selected by the tagged The module-based workflow follows the ReleaseKit version selected by the tagged
@@ -209,6 +259,12 @@ consumer module configuration when adopting a newer ReleaseKit version and
verify it with a tag fixture. The command and profile behavior are documented verify it with a tag fixture. The command and profile behavior are documented
in the [CLI contract](../cli.md) and [configuration reference](../config.md). in the [CLI contract](../cli.md) and [configuration reference](../config.md).
Update copied workflow YAML when adopting corrected examples. A dependency
upgrade alone leaves that copy unchanged. An older tagged workflow that passes
a raw annotated tag object ID still fails on retry. Recover delivery from the
original tagged source with the resolved event commit through the command above,
preserving the published tag and existing content.
Woodpecker runs delivery after the tag event, independently from the source Woodpecker runs delivery after the tag event, independently from the source
publication result. Read its workflow status as delivery status: a delivery publication result. Read its workflow status as delivery status: a delivery
failure does not undo source publication. A failed, partial, or uncertain run failure does not undo source publication. A failed, partial, or uncertain run

View File

@@ -6,6 +6,7 @@ annotated source tag:
```go ```go
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
ModuleDir: "integrations/example",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
ExpectedPushURL: "ssh://git.example/owner/example.git", ExpectedPushURL: "ssh://git.example/owner/example.git",
@@ -13,15 +14,28 @@ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
Go: releasekit.GoValidationSpec{Race: true, FullVet: true}, Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
}, },
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir) return runProjectChecks(ctx, candidate.ModuleDir)
}, },
}) })
if err != nil { if err != nil {
return err return err
} }
fmt.Println(result.Version, result.CandidateCommit, result.TagObject) fmt.Println(result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.TagObject)
``` ```
`ModuleDir` is a repository-relative physical module directory; omit it or use
`.` for a root-module release. ReleaseKit reads the selected module's committed
declaration and derives the complete Git tag from its declared module path and
physical location. `Version` remains the stable semantic version. The note is
`<module>/docs/releases/<version>.md`; an on-disk `/v2` directory is part of
the note path but is removed from the tag prefix only when the module path ends
in `/v2`. `PublicationResult` returns the absolute `ModuleDir`, declared
`ModulePath`, complete `Tag`, and existing commit and tag-object evidence.
The optional `ValidationSpec.SharedFiles` selects additional committed files
for enabled file checks. Its nil and empty-slice behavior is described in the
[candidate check guide](check.md#local-candidate-check).
Publication captures a clean candidate on `main`, validates its committed Publication captures a clean candidate on `main`, validates its committed
release note, and checks the selected remote before running candidate checks. release note, and checks the selected remote before running candidate checks.
Set `ExpectedPushURL` to require the effective push destination to match an Set `ExpectedPushURL` to require the effective push destination to match an
@@ -49,22 +63,25 @@ does not execute target binaries. A failure is reported as `package-compile`
with the selected target and prevents tag creation. with the selected target and prevents tag creation.
Set `Validation.GoFormat` to check committed regular Go source using the Go Set `Validation.GoFormat` to check committed regular Go source using the Go
formatter built into ReleaseKit. It includes vendor and nested modules, ignores formatter built into ReleaseKit. It uses the selected file inputs, including
vendor and explicit shared files, and ignores
worktree changes and symlink blobs, and reports unformatted paths as worktree changes and symlink blobs, and reports unformatted paths as
`go-format` without changing files. The default is disabled. The check follows `go-format` without changing files. The default is disabled. The check follows
tests, selected vet, and package compilation, and a failure stops publication tests, selected vet, and package compilation, and a failure stops publication
before product checks or tag creation. before product checks or tag creation.
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
`go.work` and `go.work.sum` blobs, including nested paths and symlink entries. `go.work` and `go.work.sum` blobs in the selected inputs, including symlink entries.
The rule reads the captured candidate regardless of `GOWORK`; uncommitted files The rule reads the captured candidate regardless of `GOWORK`; uncommitted files
are ignored, and Go workspace selection and vendor behavior are unchanged. Its are ignored, and Go workspace selection and vendor behavior are unchanged. Its
zero value disables the check. Findings use `workspace-files` and stop zero value disables the check. Findings use `workspace-files` and stop
publication before product checks or tag creation. publication before product checks or tag creation.
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
committed `go.mod` declarations across the candidate, including nested and selected module's committed `go.mod` declarations. Vendored contents remain in
vendor modules. Filesystem `replace` directives are reported with their source scope; automatic inputs stop at independently released nested modules. Explicit
path, line, and target as `local-replacements`; versioned replacements are shared files can include another module's declaration. Filesystem
`replace` directives are reported with their source path, line, and target as
`local-replacements`; versioned replacements are
permitted. Committed module symlinks are resolved within the candidate, and permitted. Committed module symlinks are resolved within the candidate, and
parse or inspection errors retain their causes. This check is independent of parse or inspection errors retain their causes. This check is independent of
`GOWORK`; its zero value disables it, and a failure stops publication before `GOWORK`; its zero value disables it, and a failure stops publication before
@@ -72,9 +89,9 @@ product checks or tag creation.
Set `Validation.FileSize` to check each committed blob against a byte limit. Set `Validation.FileSize` to check each committed blob against a byte limit.
Nil disables the check; an empty object enables the default of 1,000,000 Nil disables the check; an empty object enables the default of 1,000,000
decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects decimal bytes. `LimitBytes` uses strict greater-than, accepts zero, and rejects
negative values. `Exclusions` contains literal repository-relative files or negative values. `Exclusions` contains literal files or directories relative to
directories, including committed descendants of excluded directories. Symlink the selected module, including committed descendants of excluded directories.
blob size is its link text; gitlinks are omitted. This check measures only the Symlink blob size is its link text; gitlinks are omitted. This check measures only the
captured candidate and reports one oversized file with its measurement as captured candidate and reports one oversized file with its measurement as
`file-size`; it stops publication before product checks or tag creation. `file-size`; it stops publication before product checks or tag creation.

View File

@@ -1,7 +1,7 @@
# Gitea release pages # Gitea release pages
ReleaseKit's public `Deliver` operation and `deliver` command verify tagged ReleaseKit's public `Deliver` operation and `deliver` command verify tagged
source, find or create its stable release page, inspect existing assets, and source, find or create the selected module's release page, inspect existing assets, and
upload missing assets through the internal Gitea client. Page-only and upload missing assets through the internal Gitea client. Page-only and
binary-producing profiles use the same delivery path. The binary-producing profiles use the same delivery path. The
[consumer delivery guide](../consumers/delivery.md) covers caller workflows; [consumer delivery guide](../consumers/delivery.md) covers caller workflows;
@@ -13,16 +13,30 @@ the `Authorization: token …` request header and is not included in errors or
results. The base URL must use HTTP or HTTPS and cannot contain URL credentials, results. The base URL must use HTTP or HTTPS and cannot contain URL credentials,
a query, or a fragment. a query, or a fragment.
For version `vMAJOR.MINOR.PATCH`, the client first reads The selected module derives a complete tag from its semantic version, declared
module path, and physical repository-relative directory. For example, version
`v1.2.3` in `integrations/ckconfig` uses
`integrations/ckconfig/v1.2.3`; a root module uses `v1.2.3`. A `/v2` module
stored at `integrations/core/v2` uses `integrations/core/v2.1.0`; the same
declared module stored at `integrations/core` derives the same tag. ReleaseKit
removes a final physical `/v2` directory from the tag prefix only when the
module declaration has that `/v2` suffix. A module-local note stays at
`<module>/docs/releases/<version>.md`, including a physical `/v2` directory.
The default shared validation convention uses repository-root `COPYRIGHT`,
`LICENSE`, and `docs/oss/attribution.md` when present. Consumers own their license
layout and release-note links. Use the complete derived tag as the immutable
source ref and the actual repository-relative file paths in those links.
Shared-file selection controls validation inputs; delivery preserves the
committed note and its footer links. The client first reads
`/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit `/api/v1/repos/{owner}/{repo}/tags/{tag}` and requires the hosted tag's commit
SHA to equal the captured source commit. It then reads SHA to equal the captured source commit. It then reads
`/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page `/api/v1/repos/{owner}/{repo}/releases/tags/{tag}`. A matching existing page
must have that tag, the complete committed release note as its body, and stable must have that tag, the complete committed release note as its body, and stable
non-draft state. Its existing title is retained. ReleaseKit does not edit a non-draft state. Its existing title is retained. ReleaseKit does not edit a
conflicting page. conflicting page. Slash-containing tags are escaped as one URL path segment.
If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases` If the page is absent, the client posts to `/api/v1/repos/{owner}/{repo}/releases`
with the selected tag and commit, the release note's H1 as title, the complete with the selected full tag and commit, the release note's H1 as title, the complete
note as body, and draft and prerelease disabled. A create conflict, interrupted note as body, and draft and prerelease disabled. A create conflict, interrupted
response, or malformed create response (including inconsistent decoded fields) response, or malformed create response (including inconsistent decoded fields)
triggers one lookup. An exact page found triggers one lookup. An exact page found

View File

@@ -1,7 +1,10 @@
# Go command environments # Go command environments
`internal/gotool` resolves effective Go settings once for a validation or build `internal/gotool` resolves effective Go settings when an operation first needs
operation and applies the shared environment to its Go commands. It uses a Go command and applies the shared environment to its commands. Candidate
validation and its build callback share an operation-local owner while the
inherited environment and persistent Go settings file remain unchanged.
Standalone and delivery builds create their own owners. The package uses
`internal/process` for cancellation, the working directory, output, and wrapped `internal/process` for cancellation, the working directory, output, and wrapped
errors. errors.
@@ -35,9 +38,10 @@ uses the same ownership transfer when formatting a failure.
## Configuration discovery ## Configuration discovery
The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then The owner asks the installed `go` command for `GOHOSTOS`, `GOHOSTARCH`, and the
queries effective operational settings with that host selected. Both queries `GOENV` settings-file path, then queries effective operational settings with
use `GOTOOLCHAIN=local` and controlled `GOFLAGS`. Discovery sets that host selected. Both queries use `GOTOOLCHAIN=local` and controlled
`GOFLAGS`. Discovery sets
`GOEXPERIMENT=none` and clears architecture feature variables so an invalid or `GOEXPERIMENT=none` and clears architecture feature variables so an invalid or
nonbaseline tuning value cannot prevent settings from being read. The nonbaseline tuning value cannot prevent settings from being read. The
experiment override is discovery-only. experiment override is discovery-only.
@@ -48,6 +52,13 @@ and native cgo/C-tool configuration into its settings. This captures values
supplied by either the process environment or the persistent Go environment supplied by either the process environment or the persistent Go environment
file. file.
The operation-local owner snapshots the inherited environment and selected
settings file. It reuses the resolved tool across validation and its following
build callback while those inputs match. Subsequent Go acquisition rediscovers
settings when a consumer callback changes either input. An unavailable snapshot
disables reuse while discovery follows the Go tool's accepted configuration.
Ownership is operation-local.
Configuration discovery parses JSON from stdout. Stderr is captured separately Configuration discovery parses JSON from stdout. Stderr is captured separately
and included in command-failure diagnostics, so a successful query can emit and included in command-failure diagnostics, so a successful query can emit
warnings while supplying valid configuration. warnings while supplying valid configuration.
@@ -69,9 +80,10 @@ baseline values. Effective `GOCACHE`, `GOCACHEPROG`, `GOMODCACHE`, `GOPATH`,
`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain `GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain
available to child commands. available to child commands.
Candidate validation uses native mode for the offline `go test` suite and Candidate validation runs the offline `go test` suite and selected full
selected full `go vet` pass. Package-compilation checks use static mode for each `go vet` pass from the selected module directory using native mode.
selected target and direct Go's output to the operating system null device, so Package-compilation checks use static mode for each selected target and direct
they leave no binary artifact. Artifact builds also use static mode for Go's output to the operating system null device, so they leave no binary
selected-target compilation, then build the native version-check executable; artifact. Artifact builds also compile selected targets in static mode, then
that executable runs as a normal child process. build the native version-check executable, which runs as a normal child
process.

View File

@@ -25,7 +25,7 @@ Go declarations and GoDoc remain the owners of exact exported contracts.
## Module and dependencies ## Module and dependencies
The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language
version `1.25.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal version `1.26.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
validation; public facades do not expose either dependency. `go list ./...` validation; public facades do not expose either dependency. `go list ./...`
reports these implemented packages: reports these implemented packages:
@@ -37,15 +37,16 @@ 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` | Captured-commit module identity and note selection, literal-tag checkout checks, prebuilt identity binding, 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 and shared reference traversal, checkout-root and commit capture, 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/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `internal/gitea` | Gitea API transport, full-tag hosted release pages, asset inspection, manifests, and uploads. |
| `internal/gotool` | Effective Go configuration discovery, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). | | `internal/gotool` | Operation-local Go configuration discovery and reuse, shared native-validation and static-build environments, and network-capable dependency preparation; see [Go command environments](gotool.md). |
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. | | `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. | | `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `internal/releaseidentity` | Committed-object and working-tree module selection, declaration reading, and semantic-version-to-tag derivation. |
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. | | `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. | | `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
@@ -61,15 +62,24 @@ also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
Candidate checking captures and validates one clean committed source revision Candidate checking captures and validates one clean committed source revision
in `internal/candidate`. Its validation bridge calls `internal/validation` for in `internal/candidate`. Its validation bridge calls `internal/validation` for
the default offline Go suite, then runs product-specific checks once. The the selected module's default offline Go suite, then runs product-specific
candidate package owns post-validation builds and local-state rechecks; checks once. Validation and its post-validation build callback share one local
publication owns endpoint guards, tag creation, push, and recovery around that Go settings owner. Each Go acquisition checks inherited and persistent inputs;
same candidate validation path. `internal/validation.CandidateTree` reads settings are rediscovered when subsequent Go work needs them. An unavailable
persistent-settings snapshot disables reuse. The candidate package owns builds and local-state
rechecks; publication owns endpoint guards, tag creation, push, and recovery
around that same candidate validation path. `internal/validation.CandidateTree` reads
committed blobs lazily and resolves local destinations from committed entries; committed blobs lazily and resolves local destinations from committed entries;
it does not consult neighboring files or traverse submodule checkouts. it does not consult neighboring files or traverse submodule checkouts.
See the [shared validation guide](validation.md) for checker, Go command, and See the [shared validation guide](validation.md) for checker, Go command, and
candidate-file integration mechanics. candidate-file integration mechanics.
Delivery verifies all local binary sizes and digests before hosted requests.
Its Gitea upload path reuses that verification and streams missing files once
for transport. Independent calls to the internal client's `UploadMissing`
verify missing local files before writing. Both paths share remote inspection,
conflict handling, and reconciliation.
## Capability owners and behavioral evidence ## Capability owners and behavioral evidence
The following tests are representative evidence from the current offline suite, The following tests are representative evidence from the current offline suite,
@@ -81,16 +91,17 @@ not a separate release matrix:
| Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` | | Candidate capture, shared validation, public diagnostics, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestRunUsesOneSuiteWithSelectedRaceSetting`, `TestRunReportsRacePrerequisiteFailureWithoutFallback`, `TestRunExecutesRaceEnabledSuiteOnHost`, `TestRunCompilesTargetsAfterTestAndVetInSelectionOrder`, `TestRunAttributesTargetCompileFailureAndStops`, `TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts`, `TestCandidateRequestMapsGoValidation`, `TestCandidateRequestPreservesMarkdownObjectSelection`, `TestLoadSettingsReadsGoValidation`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
| Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` | | Committed-file validation: formatting, workspace files, local replacements, and file size | `internal/validation`, `internal/candidate`, `releasekit.Check`, `releasekit.Publish`, CLI profile conversion | `TestCheckGoFormatUsesCapturedRegularGoBlobs`, `TestCheckWorkspaceFilesReportsCommittedBlobNames`, `TestCheckLocalReplacementsParsesCommittedModuleDeclarations`, `TestCheckLocalReplacementsFollowsCommittedModuleSymlinks`, `TestCheckFileSizeUsesStrictLimitsAndDefault`, `TestCheckFileSizeAppliesLiteralPathExclusions`, `TestCheckFileSizeUsesCommittedBlobSizes`, `TestCheckFileSizeUsesOnlyCandidateFilesIndividually` |
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` | | Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` | | Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestOwnerReusesSettingsUntilInheritedEnvironmentChanges`, `TestOwnerRefreshesWhenPersistentSettingsChange`, `TestCheckSharesGoDiscoveryWithAfterValidateCallback`, `TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree`, `TestRunValidatesOnlyTheSelectedModule`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
| Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` | | Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
| 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`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` |
| 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`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
| Shared module identity and tag derivation | `internal/releaseidentity`, `internal/candidate`, `internal/build`, `internal/delivery` | `TestDeriveUsesModuleDeclarationAndPhysicalLayout`, `TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources`, `TestSelectedNestedModuleDoesNotRequireRootDeclaration`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages` |
| 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` |
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/`, `examples/multi-module/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout`, `TestMultiModuleSelectorMapsSelectorsAndTags`, `TestMultiModuleLauncherUsesSelectedModuleAndProfile`, `TestMultiModuleWoodpeckerDelivery` |
Run the complete suite with `go test ./...`. The public API and import boundary Run the complete suite with `go test ./...`. The public API and import boundary
check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise

View File

@@ -8,30 +8,49 @@ post-validation build callback and local-state recheck. `internal/publish`
adds its endpoint guards, tag creation, push, and recovery around that same adds its endpoint guards, tag creation, push, and recovery around that same
candidate path. candidate path.
The executor runs one offline Go suite, optionally with the race detector when The executor runs one offline Go suite from the captured module directory,
selected. When full vet is selected, it disables test-time vet for that suite optionally with the race detector when selected. When full vet is selected, it
and runs native `go vet -mod=readonly ./...` after the suite succeeds. It then disables test-time vet for that suite and runs native
runs `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each `go vet -mod=readonly ./...` from the same directory after the suite succeeds.
selected package-compilation target, preserving target order and stopping on It then runs `go build -mod=readonly -o <os.DevNull> ./...` there in static mode
the first failure. This compiles source without producing an artifact or for each selected package-compilation target, preserving target order and
executing a target binary. Test, vet, and package-compilation failures retain stopping on the first failure. This compiles source without producing an
their `go-test`, `go-vet`, and `package-compile` identities, selected target artifact or executing a target binary. Test, vet, and package-compilation
where applicable, and underlying causes. Its failures retain their `go-test`, `go-vet`, and `package-compile` identities,
selected target where applicable, and underlying causes. Its
request carries the captured repository directory and commit, output writer, request carries the captured repository directory and commit, output writer,
and internal settings. When selected, the Go-format check loads the candidate and internal settings. When selected, the Go-format check loads the candidate
tree once, reads regular Go blobs from the captured commit, and compares them tree once, reads regular Go blobs from the captured commit, and compares them
with go/format output. It includes vendor and nested-module files, skips with go/format output. It uses the selected inputs, including vendor and explicit
shared files, and skips
symlink blobs, reports all unformatted paths, and stops with a contextual cause symlink blobs, reports all unformatted paths, and stops with a contextual cause
on parse or inspection errors. It runs before product checks and build callbacks. on parse or inspection errors. It runs before product checks and build callbacks.
`Request.ReadCandidateTree` lets selected file checks request candidate `Request.ReadCandidateTree` lets selected file checks request candidate
contents; the Go-only path does not enumerate the tree. contents; the Go-only path does not enumerate the tree.
## Selected validation inputs
`CandidateTree` keeps the full committed repository lookup separate from the
ordered file input set. The selected module's committed inputs
exclude independently released nested modules, while vendored contents stay
in scope. Selection indexes boundary directories once and checks each entry's
ancestors, so unrelated boundaries are not scanned for each file. The
[scope benchmark](../../internal/validation/scope_performance_test.go) varies
boundary count while retaining 20,000 input files.
Explicit shared-file selections are normalized from repository-root
paths, added as individual files, and deduplicated with module inputs by path.
File-size exclusions are normalized from the selected module directory. Link
resolution can still inspect a committed destination outside the selected input
set without adding that destination's content to the checks. The public
selection, nil/empty behavior, and profile path bases belong to the
[configuration reference](../config.md) and the root facade's GoDoc.
The workspace-file check uses that same candidate inventory and reports blob The workspace-file check uses that same candidate inventory and reports blob
entries named go.work or go.work.sum, including nested paths and symlink blobs. entries named go.work or go.work.sum, including nested paths and symlink blobs.
It does not read workspace contents or depend on the workspace selected for Go It does not read workspace contents or depend on the workspace selected for Go
commands. commands.
The local-replacement check parses every committed go.mod blob with The local-replacement check parses selected go.mod blobs with
`golang.org/x/mod/modfile`, including nested modules and vendor declarations. `golang.org/x/mod/modfile`, including vendor and explicitly shared declarations.
It follows committed symlink declarations through the captured tree and fails It follows committed symlink declarations through the captured tree and fails
with an inspection cause when their selected contents are unavailable. A with an inspection cause when their selected contents are unavailable. A
replacement with no parsed version is reported with its go.mod path, line, replacement with no parsed version is reported with its go.mod path, line,
@@ -40,22 +59,24 @@ workspace-file check using the same candidate inventory. Resolved module bodies
are parsed once per blob object ID; only owned filesystem-replacement targets are parsed once per blob object ID; only owned filesystem-replacement targets
and line numbers remain cached. Each declaration path receives findings from and line numbers remain cached. Each declaration path receives findings from
that summary in its own path context. that summary in its own path context.
The optional file-size check uses each blob's captured Git size, including The optional file-size check applies normalized literal file/directory
symlink link-text bytes, and skips entries without a blob size such as gitlinks. exclusions with path-boundary matching, then requests local Git sizes for the
It applies normalized literal file/directory exclusions with path-boundary remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
matching, then returns the first oversized path with `FileSizeMeasurement`. omitted. The check returns the first oversized path with `FileSizeMeasurement`.
The optional Markdown check follows file-size inspection and uses the same The optional Markdown check follows file-size inspection and uses the same
candidate tree. It parses regular Markdown blobs, resolves local destinations, candidate tree. It parses regular Markdown blobs, resolves local destinations,
and checks supported anchors; outside-checkout and submodule observations are and checks supported anchors; outside-checkout and submodule observations are
written to the shared output writer. A nil writer keeps library use quiet. written to the shared output writer. A nil writer keeps library use quiet.
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes `CandidateTree` enumerates full repository path, type, mode, and object metadata
lazily, and resolves destinations without consulting the worktree or without requiring blob contents or sizes. Checks acquire sizes and bytes for
neighboring files. Each tree owns one lazy Git blob reader; direct tree users their selected inputs and required references. Unreferenced blobs in excluded
must close it when finished. The shared validation executor closes its tree on modules remain outside those reads. Destinations resolve without consulting
every return before product checks or build callbacks run. A metadata-only the worktree or neighboring files. Each tree owns one lazy Git blob reader;
selection never starts the reader. Per-read contexts cancel an in-flight blob direct tree users must close it when finished. The shared validation executor
exchange. The context supplied when creating the tree governs the reader closes its tree on every return before product checks or build callbacks run.
lifetime, including shutdown and child reaping. A metadata-only selection never starts the reader. Per-read contexts cancel
an in-flight blob exchange. The context supplied when creating the tree governs
the reader lifetime, including shutdown and child reaping.
Regular-file reads return independent caller-owned byte slices and are not Regular-file reads return independent caller-owned byte slices and are not
retained by the tree. Symlink targets are retained as immutable owned strings retained by the tree. Symlink targets are retained as immutable owned strings
by blob object ID; explicitly reading a symlink still returns independent by blob object ID; explicitly reading a symlink still returns independent
@@ -88,6 +109,7 @@ Committed-tree metadata and blob reads disable lazy fetching and Git transports
for those child commands. Missing local objects produce inspection errors; for those child commands. Missing local objects produce inspection errors;
source preparation retains its separate network-capable operations. See Git's source preparation retains its separate network-capable operations. See Git's
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism. [lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
Candidate and delivery release-note reads use the same local object boundary.
Public validation errors convert their diagnostic fields to the internal Public validation errors convert their diagnostic fields to the internal
failure model for shared formatting. Converted failures retain their original failure model for shared formatting. Converted failures retain their original
@@ -149,7 +171,8 @@ directories, missing paths, checkout boundaries, submodule paths, and symlink
cycles; the caller selects which observations fail validation or are reported cycles; the caller selects which observations fail validation or are reported
as out of scope. as out of scope.
Resolution walks committed components and symlink targets in order. Active The shared Git path resolver walks committed components and symlink targets
in order using the tree's indexed metadata and lazy symlink reader. Active
symlink expansion detects cycles, and traversal honors cancellation. Absolute symlink expansion detects cycles, and traversal honors cancellation. Absolute
destinations and symlink targets within the repository directory map back to destinations and symlink targets within the repository directory map back to
candidate-relative paths; existence still comes from committed entries. candidate-relative paths; existence still comes from committed entries.

View File

@@ -28,16 +28,12 @@ The maintained [source-delivery image](../examples/woodpecker/releasekit-source-
and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the
chosen build toolchain; update them together when that selection changes. chosen build toolchain; update them together when that selection changes.
The current consumer minimum is declared in [go.mod](../go.mod). When preparing The current consumer minimum is Go 1.26.0, declared in [go.mod](../go.mod).
a release that raises it to Go 1.26, the normal declaration is: Keep it for a release unless release preparation deliberately selects a newer
series under the [Go support policy](policy/release.md#consumer-minimum-and-release-toolchain).
```sh When increasing it, update `go.mod` to the first stable version of the selected
go mod edit -go=1.26.0 series, or to a higher patch when a dependency or specific fix requires one.
``` Make module changes before committing the candidate. Minimum increases must fit the
Retain any higher patch minimum required by dependencies or a specific fix.
Make module changes before committing the candidate. A release preserving the
existing minimum keeps its current declaration. Minimum increases must fit the
[compatibility policy](policy/release.md#versioning-and-compatibility); before [compatibility policy](policy/release.md#versioning-and-compatibility); before
v1.0.0, an increase belongs in a minor release. v1.0.0, an increase belongs in a minor release.

141
docs/releases/v0.4.0.md Normal file
View File

@@ -0,0 +1,141 @@
# ReleaseKit v0.4.0
## Summary
ReleaseKit supports releasing individual Go modules within a repository, with
module-specific validation, builds, tags, and Gitea release pages. Root-module
consumers retain the simple default configuration. This release also improves
path resolution, offline source inspection, failure diagnostics, and efficiency.
## Changes
- `Check`, `Publish`, `Build`, and `Deliver` accept `ModuleDir` and expose the
resolved module directory, declared module path, and complete tag in their
results. Each operation resolves its module identity once. Product commands
receive repository and module context, and candidate callbacks receive module
identity.
- Release tags follow the selected module's physical location and declared major
version. For example, `v2.1.0` for
`example.com/project/integrations/core/v2` uses
`integrations/core/v2.1.0` whether its physical directory is
`integrations/core` or `integrations/core/v2`. Its note remains at that physical
module's `docs/releases/v2.1.0.md`. Binary version output and asset names retain
the semantic version; Git publication and Gitea delivery use the complete tag.
- Profiles add `module_dir`. Go checks and configured product commands run from
the selected module. Automatic committed-file inputs stop at independent
nested modules; vendored files remain included. Nested fixtures remain
available to the parent module's product tests.
- `ValidationSpec.SharedFiles` and profile `validation.shared_files` select
repository-relative literal files for optional file checks. Omission selects
the available root copyright, license, and attribution defaults; an explicit
list replaces them, and an explicit empty list selects none. Explicit files
must exist even with checks disabled. Selection preserves the enabled checks
and Go test scope; reference inspection can read destinations elsewhere in
the captured repository.
- Module releases receive separate Gitea pages, including source-only modules.
Relative build output and prebuilt delivery paths share the selected module
as their base. Prebuilt receipts bind artifacts to module identity, semantic
version, complete tag, source commit, and normalized build settings.
- `ci-deliver` accepts the complete event tag. Prepared mode verifies a checkout
already detached at that tag, and expected-module and expected-tag checks bind
delivery to the CI selection before building or contacting Gitea. The
multi-module example shares a module map between its local launcher and
Woodpecker workflow. The workflow verifies the tag before reading the mapping,
profile, or tool selection, preserving the tagged configuration for retries.
## Reliability and efficiency
- Operations resolve the containing Git checkout root when invoked from a child
directory. Working-tree and committed module paths resolve symlinks consistently,
including paths containing `..`. Committed identity comes from Git objects;
page-only delivery can use committed module metadata absent from the worktree.
Valid local main-module paths are accepted.
- Build failures preserve available identity and settings alongside completed
artifacts. Invalid versions retain the public error identity and the CLI's
usage-error status. Prebuilt delivery accepts equivalent checkout paths while
retaining source and module verification.
- Committed release-note and validation reads remain offline in partial clones.
Validation reads blob contents and sizes as needed for selected inputs and
referenced destinations. Unavailable blobs outside that inspection scope do
not prevent applicable checks.
- Module-file selection uses ancestor boundary lookups. Operations reuse Go
environment discovery while its inputs remain suitable for reuse and acquire
Go settings only when Go work needs them. Delivery reuses its local asset
verification when uploading missing assets, removing a repeated hashing pass.
These improvements depend on the repository and workload. Artifact verification,
exact remote comparisons, additive retries, and partial-delivery reporting retain
their behavior.
## Compatibility
This pre-1.0 minor release introduces a clean API and receipt cutover:
- `TaggedCheckoutRequest.Version` becomes `Tag`, containing the complete Git
tag. `VerifyOnly` adds verification of an already prepared checkout.
- `PrepareBuildDependencies` now accepts `DependencyPreparationRequest` with
repository and module directories.
- The private build receipt requires schema version 1 and module identity.
Existing receipts must be regenerated before prebuilt delivery.
- Root-module committed-file checks exclude independent nested modules. Select
those modules separately when their files need release validation.
The Go minimum remains 1.26.0. Root-module releases retain their existing note
path, unprefixed tags, profile defaults, and ordinary workflow without a module
map. The root orchestration facade and separate lightweight `buildinfo` facade
retain their roles; runtime version access requires no migration.
Source-tag publication remains the source-release completion point. Hosted page
and asset delivery remains asynchronous and can be retried from the same
immutable tag. ReleaseKit's deliverables remain its source tag and hosted note
page.
## Upgrade guidance
1. Select v0.4.0 in each consumer module that uses ReleaseKit. For API consumers:
```sh
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.4.0
go mod tidy
```
For consumers using the CLI as a Go tool dependency:
```sh
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.4.0
go mod tidy
```
Review and commit the resulting module and checksum changes together with
caller, profile, and workflow updates. Keep historical tag retries paired
with the tool and workflow recorded at that tag.
2. Update preparation API calls for `Tag` and `DependencyPreparationRequest`.
For nested modules, set `ModuleDir` or profile `module_dir` to the physical
module location, with its own `go.mod` declaration. Keep ordinary `Version`
arguments as semantic versions. Use result `ModuleDir`, `ModulePath`, and
`Tag` where module identity is needed. Product commands can distinguish their
contexts through `RELEASEKIT_REPOSITORY_DIR` and `RELEASEKIT_MODULE_DIR`.
3. Update `ci-deliver` invocations to pass the full event tag and required
`--commit OID`. Use `--prepared` after bootstrap verification and
`--expected-module DIR` for mapped launchers. Prepare and commit each binary
module's checksums before tagging so dependency preparation leaves tagged
source clean. Root-only consumers can retain the ordinary tag workflow.
4. Rebuild existing prebuilt outputs with the v0.4.0 `build` command before
`deliver --assets DIR`. Relative output and asset paths both resolve from the
selected module. Review optional shared-file selections and nested-module
validation coverage, then run the consumer's ordinary checks.
See the
[adoption guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/consumers/adoption.md),
[configuration reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/config.md),
[CLI contract](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/cli.md),
[delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/consumers/delivery.md),
and [multi-module example](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/examples/multi-module/README.md)
for supported usage.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.0/docs/oss/attribution.md)

29
docs/releases/v0.4.1.md Normal file
View File

@@ -0,0 +1,29 @@
# ReleaseKit v0.4.1
## Summary
This patch synchronizes shared state in the delivery and Gitea HTTP test
fixtures. It makes the test suite reliable under concurrent request handling
and the Go race detector. Production code and public behavior are unchanged.
## Changes
- Protect delivery fixture state with mutexes and independent snapshots; use
atomic observations for independent Gitea fixture counters and flags.
- Keep fatal test assertions in the test goroutine and ensure release test
servers close with their owning tests.
- Preserve existing delivery, conflict, retry, and source-tag assertions while
exercising the synchronized fixtures.
## Compatibility
There are no API, configuration, or runtime behavior changes, and no consumer
migration is required. The Go minimum remains 1.26.0. The published v0.4.0 tag
and its contents are unchanged.
Consumers may upgrade to v0.4.1 with their existing API, profile, and workflow
configuration.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.1/docs/oss/attribution.md)

36
docs/releases/v0.4.2.md Normal file
View File

@@ -0,0 +1,36 @@
# ReleaseKit v0.4.2
## Summary
This patch corrects Woodpecker delivery bootstrap for events whose
`CI_COMMIT_SHA` identifies an annotated tag object. The maintained workflows
resolve the event object to its source commit before verification and delivery.
## Changes
- Correct module-selected, multi-module, and ReleaseKit source delivery
workflows to resolve event and tag commits independently and pass the
resolved event commit to `ci-deliver`.
- Report both commit identities when an event and its selected tag disagree.
- Extend executable-workflow regression coverage for annotated tag-object
events, annotated and lightweight commit events, resolved delivery arguments,
and rejection of unrelated commits.
## Compatibility and upgrade guidance
Public APIs, CLI contracts, configuration formats, and the Go 1.26.0 consumer
minimum retain their v0.4 definitions. `ci-deliver --commit` continues to accept
the expected source commit ID.
Update copied workflow YAML from the maintained examples when adopting this
patch. Upgrading the ReleaseKit dependency alone leaves copied workflows
unchanged. The [delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/consumers/delivery.md#woodpecker-tag-workflows)
describes event resolution, workflow adoption, and delivery recovery.
Existing published tags and contents remain unchanged. Recover a failed older
workflow from its original tagged source using the resolved event commit through
the documented delivery command, preserving existing hosted content.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.4.2/docs/oss/attribution.md)

View File

@@ -0,0 +1,132 @@
# Multi-module release example
This example maps selectors and complete event tags to a physical Go module
directory and a repository-root profile. The library derives the release tag
from the selected module declaration and physical location; the table only
selects the corresponding profile and checkout directory.
Copy the contents of this directory into a consumer repository root, keeping
the listed module directories and profile paths at those locations, then run
the launcher from that checkout.
The sample modules declare Go 1.26.0, matching the current ReleaseKit consumer
minimum.
## Select the release tool
The module files are setup skeletons. Set `RELEASEKIT_TOOL_VERSION` to a released
ReleaseKit version containing module-release support, then register that tool in
each consumer module before running the launcher or workflow:
```sh
: "${RELEASEKIT_TOOL_VERSION:?Select a ReleaseKit release with module-release support}"
for module_dir in . integrations/ckconfig integrations/core/v2; do
go -C "$module_dir" get -tool "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_TOOL_VERSION}"
go -C "$module_dir" mod tidy
done
```
Review and commit the generated tool, dependency, and checksum declarations.
The [adoption guide](../../docs/consumers/adoption.md) owns tool registration and
coordinated tool/workflow upgrades. The
[v0.4.0 release note](../../docs/releases/v0.4.0.md) describes module-release
support and migration from earlier versions.
## Module mapping and publication
`release-modules.tsv` is literal tab-separated text with four columns:
1. Local selector.
2. Shell pattern matched against a complete event tag.
3. Repository-relative physical module directory (`.` means the root module).
4. Repository-relative profile path.
The checked-in patterns are `v[01].*`, `integrations/ckconfig/v*`, and
`integrations/core/v2.*`. The helper passes pattern text to the shell's `case`
matcher without `eval`. A tag must match exactly one row; missing and ambiguous
matches are configuration errors. Module and profile paths may contain spaces.
The `core-v2` row places the Go module physically under `integrations/core/v2`
and declares a `/v2` module path. ReleaseKit derives the versioned tag under
`integrations/core`, while the table retains the full physical path.
Before running candidate checks offline, prepare each module's dependencies in
its own directory:
```sh
go -C . mod download
go -C integrations/ckconfig mod download
go -C integrations/core/v2 mod download
```
Commit each generated `go.sum` with its module files. The binary delivery
profile prepares application dependencies in the tagged module before building;
the committed sums keep that prepared checkout clean for delivery verification.
Then select a module and semantic version with the shared launcher:
```sh
./release.sh root v1.2.3
./release.sh ckconfig v1.2.3
./release.sh core-v2 v2.1.0
```
The launcher copies the selected module's `go.mod` and `go.sum` to temporary
alternate module files outside the checkout, resolves ReleaseKit from that
module's local dependency selection, and builds the tool there. It invokes
`publish` with the repository profile and `--expected-module` so the library
can verify that the profile agrees with the selected table row. The temporary
files and tool are removed on exit. As with any `publish` invocation, commit the
selected module's release note and prepare its repository before publishing.
If an integration depends on a sibling release, publish the dependency first,
then publish the integration with the version that consumes it. For example,
publish the core release before an integration that requires that release.
This declaration does not infer dependency order or rerun the standard suite.
The maintained [Woodpecker example](woodpecker.yml) uses the same map and
selector for root, `ckconfig`, and major-version tags. It verifies and detaches
the literal event tag before selecting the module, copies that module's metadata
outside the checkout, and builds the ReleaseKit version selected there. It then
uses `ci-deliver --prepared` with the mapped profile and expected physical module.
The `core-v2` profile demonstrates binary delivery; the other profiles are
page-only. Before enabling the workflow, configure each selected profile's
`delivery` destination and add the Woodpecker repository secret
`releasekit_gitea_token`; the workflow exposes it as
`RELEASEKIT_GITEA_TOKEN` only in the delivery step.
Retries use the selected tag's module files and matching workflow. Tags whose
tool predates this interface use their corresponding historical workflow.
## Local source verification
For verification before a compatible release is published, copy this example
into a disposable consumer checkout. Set `RELEASEKIT_SOURCE` to the absolute
path of a prepared ReleaseKit checkout, then select its source in those fixture
modules:
```sh
: "${RELEASEKIT_SOURCE:?Set the absolute ReleaseKit checkout path}"
for module_dir in . integrations/ckconfig integrations/core/v2; do
go -C "$module_dir" mod edit \
-require=gitea.maximumdirect.net/eric/ReleaseKit@v0.0.0 \
-replace="gitea.maximumdirect.net/eric/ReleaseKit=$RELEASEKIT_SOURCE" \
-tool=gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
GOTOOLCHAIN=local GOWORK=off go -C "$module_dir" mod tidy
done
```
Here `v0.0.0` is a fixture placeholder resolved by the local replacement.
Keep these declarations in the disposable checkout. Add representative product
source and each selected product's module-local release note, then commit the
fixture and run the source-selected tool without publishing:
```sh
go tool releasekit check --config releasekit-root.json v1.2.3
go -C integrations/ckconfig tool releasekit check --config releasekit-ckconfig.json v1.2.3
go -C integrations/core/v2 tool releasekit check --config releasekit-core-v2.json v2.1.0
```
These commands use the repository-root profile paths even when invoked from a
child module. The core profile also verifies the native binary and injected
version. Prepared dependencies allow this verification with `GOPROXY=off` and
`GOSUMDB=off`.

View File

@@ -0,0 +1,3 @@
module example.com/multi-module-consumer
go 1.26.0

View File

@@ -0,0 +1,3 @@
module example.com/multi-module-consumer/integrations/ckconfig
go 1.26.0

View File

@@ -0,0 +1,3 @@
module example.com/multi-module-consumer/integrations/core/v2
go 1.26.0

View File

@@ -0,0 +1,11 @@
package main
import (
"fmt"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
)
func main() {
fmt.Printf("core %s\n", buildinfo.Version())
}

View File

@@ -0,0 +1,4 @@
# selector<TAB>tag pattern<TAB>module directory<TAB>profile path
root v[01].* . releasekit-root.json
ckconfig integrations/ckconfig/v* integrations/ckconfig releasekit-ckconfig.json
core-v2 integrations/core/v2.* integrations/core/v2 releasekit-core-v2.json
1 # selector<TAB>tag pattern<TAB>module directory<TAB>profile path
2 root v[01].* . releasekit-root.json
3 ckconfig integrations/ckconfig/v* integrations/ckconfig releasekit-ckconfig.json
4 core-v2 integrations/core/v2.* integrations/core/v2 releasekit-core-v2.json

View File

@@ -0,0 +1,48 @@
#!/bin/sh
set -eu
usage() {
echo "usage: release.sh SELECTOR VERSION" >&2
exit 2
}
[ "$#" -eq 2 ] || usage
selector=$1
version=$2
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
repository_dir=$(git -C "$script_dir" rev-parse --show-toplevel)
bootstrap_dir=$(mktemp -d "$(dirname "$repository_dir")/.releasekit-bootstrap.XXXXXX")
selection_file=$bootstrap_dir/module-selection.tsv
trap 'rm -rf "$bootstrap_dir"' EXIT
"$script_dir/select-module.sh" selector "$selector" > "$selection_file"
tab=$(printf '\t')
IFS="$tab" read -r selected_selector tag_pattern module_dir profile_path < "$selection_file"
module_path=$repository_dir
if [ "$module_dir" != . ]; then
module_path=$repository_dir/$module_dir
fi
cp "$module_path/go.mod" "$bootstrap_dir/releasekit.mod"
if [ -f "$module_path/go.sum" ]; then
cp "$module_path/go.sum" "$bootstrap_dir/releasekit.sum"
else
: > "$bootstrap_dir/releasekit.sum"
fi
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
(
cd "$module_path"
export GOWORK=off
export GOTOOLCHAIN=local
go list -mod=mod -modfile="$bootstrap_dir/releasekit.mod" -m "$releasekit_module"
go build -mod=mod -modfile="$bootstrap_dir/releasekit.mod" \
-o "$bootstrap_dir/releasekit" "$releasekit_module/cmd/releasekit"
)
"$bootstrap_dir/releasekit" publish \
--repo "$repository_dir" \
--config "$profile_path" \
--expected-module "$module_dir" \
"$version"

View File

@@ -0,0 +1,4 @@
{
"product": "Example Consumer Integration",
"module_dir": "integrations/ckconfig"
}

View File

@@ -0,0 +1,9 @@
{
"product": "Example Consumer Core v2",
"module_dir": "integrations/core/v2",
"build": {
"main_package": ".",
"binary_name": "core",
"targets": ["linux/amd64"]
}
}

View File

@@ -0,0 +1,4 @@
{
"product": "Example Consumer",
"module_dir": "."
}

View File

@@ -0,0 +1,70 @@
#!/bin/sh
set -eu
usage() {
echo "usage: select-module.sh selector|tag VALUE" >&2
exit 2
}
[ "$#" -eq 2 ] || usage
mode=$1
value=$2
case "$mode" in
selector|tag) ;;
*) usage ;;
esac
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
mapping=${RELEASEKIT_MODULE_MAP:-$script_dir/release-modules.tsv}
[ -f "$mapping" ] || { echo "module map not found: $mapping" >&2; exit 2; }
tab=$(printf '\t')
matches=0
selected_selector=
selected_pattern=
selected_module=
selected_profile=
while IFS="$tab" read -r selector pattern module profile || [ -n "${selector:-}${pattern:-}${module:-}${profile:-}" ]; do
case "${selector:-}" in
''|\#*) continue ;;
esac
fields=$(awk -F '\t' '{ print NF }' <<EOF
$selector$tab$pattern$tab$module$tab$profile
EOF
)
[ "$fields" -eq 4 ] || { echo "module map row must have four tab-separated fields" >&2; exit 2; }
[ -n "$pattern" ] && [ -n "$module" ] && [ -n "$profile" ] || {
echo "module map row has an empty field for selector $selector" >&2
exit 2
}
matched=false
if [ "$mode" = selector ]; then
[ "$selector" = "$value" ] && matched=true
else
# The value remains a shell pattern in this case statement; it is never eval'd.
case "$value" in
$pattern) matched=true ;;
esac
fi
if [ "$matched" = true ]; then
matches=$((matches + 1))
selected_selector=$selector
selected_pattern=$pattern
selected_module=$module
selected_profile=$profile
fi
done < "$mapping"
if [ "$matches" -ne 1 ]; then
if [ "$matches" -eq 0 ]; then
echo "no module map row matches $mode $value" >&2
else
echo "module map has ambiguous rows for $mode $value" >&2
fi
exit 2
fi
printf '%s\t%s\t%s\t%s\n' \
"$selected_selector" "$selected_pattern" "$selected_module" "$selected_profile"

View File

@@ -0,0 +1,67 @@
when:
- event: tag
ref:
include:
- refs/tags/v[01].*
- refs/tags/integrations/ckconfig/v*
- refs/tags/integrations/core/v2.*
steps:
- name: deliver
image: golang:1.26.8-bookworm
environment:
RELEASEKIT_GITEA_TOKEN:
from_secret: releasekit_gitea_token
commands:
- |
set -eu
test -n "$${CI_COMMIT_TAG}"
test -n "$${CI_COMMIT_SHA}"
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
if [ "$${tag_commit}" != "$${expected_commit}" ]; then
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
exit 1
fi
git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)"
bootstrap_dir=$(mktemp -d "$${CI_WORKSPACE%/*}/.releasekit-bootstrap.XXXXXX")
trap 'rm -rf "$${bootstrap_dir}"' EXIT
selection_file="$${bootstrap_dir}/module-selection.tsv"
"$${CI_WORKSPACE}/select-module.sh" tag "$${CI_COMMIT_TAG}" > "$${selection_file}"
tab=$(printf '\t')
IFS="$${tab}" read -r selector tag_pattern module_dir profile_path < "$${selection_file}"
module_path="$${CI_WORKSPACE}"
if [ "$${module_dir}" != . ]; then
module_path="$${CI_WORKSPACE}/$${module_dir}"
fi
cp "$${module_path}/go.mod" "$${bootstrap_dir}/go.mod"
if [ -f "$${module_path}/go.sum" ]; then
cp "$${module_path}/go.sum" "$${bootstrap_dir}/go.sum"
else
: > "$${bootstrap_dir}/go.sum"
fi
cp "$${bootstrap_dir}/go.mod" "$${bootstrap_dir}/releasekit.mod"
cp "$${bootstrap_dir}/go.sum" "$${bootstrap_dir}/releasekit.sum"
export GOWORK=off
export GOTOOLCHAIN=local
releasekit_module=gitea.maximumdirect.net/eric/ReleaseKit
(
cd "$${module_path}"
go list -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" -m "$${releasekit_module}"
go build -mod=mod -modfile="$${bootstrap_dir}/releasekit.mod" \
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
)
"$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \
--config "$${profile_path}" \
--expected-module "$${module_dir}" \
--prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}"

View File

@@ -15,9 +15,13 @@ steps:
test -n "$${CI_COMMIT_SHA}" test -n "$${CI_COMMIT_SHA}"
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}") tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" if [ "$${tag_commit}" != "$${expected_commit}" ]; then
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
exit 1
fi
git checkout --detach "$${tag_commit}" git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
@@ -29,5 +33,6 @@ steps:
go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit
"$${bootstrap_dir}/releasekit" ci-deliver \ "$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--commit "$${CI_COMMIT_SHA}" \ --prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

View File

@@ -19,9 +19,13 @@ steps:
test -n "$${CI_COMMIT_SHA}" test -n "$${CI_COMMIT_SHA}"
git check-ref-format "refs/tags/$${CI_COMMIT_TAG}" git check-ref-format "refs/tags/$${CI_COMMIT_TAG}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}" git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
tag_commit=$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}") tag_commit=$(git rev-parse --verify "refs/tags/$${CI_COMMIT_TAG}^{commit}")
expected_commit=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]') expected_commit=$(git rev-parse --verify "$${CI_COMMIT_SHA}^{commit}")
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" if [ "$${tag_commit}" != "$${expected_commit}" ]; then
printf 'Tag %s resolves to commit %s; event %s resolves to commit %s\n' \
"$${CI_COMMIT_TAG}" "$${tag_commit}" "$${CI_COMMIT_SHA}" "$${expected_commit}" >&2
exit 1
fi
git checkout --detach "$${tag_commit}" git checkout --detach "$${tag_commit}"
test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}" test "$(git rev-parse HEAD | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
test -z "$(git status --porcelain --untracked-files=all)" test -z "$(git status --porcelain --untracked-files=all)"
@@ -45,5 +49,6 @@ steps:
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit" -o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
"$${bootstrap_dir}/releasekit" ci-deliver \ "$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \ --repo "$${CI_WORKSPACE}" \
--commit "$${CI_COMMIT_SHA}" \ --prepared \
--commit "$${expected_commit}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

View File

@@ -3,21 +3,51 @@ package artifact
import ( import (
"fmt" "fmt"
"path/filepath"
"strings" "strings"
) )
const ManifestFilename = "SHA256SUMS" const ManifestFilename = "SHA256SUMS"
// OutputDirectory resolves build output and prebuilt asset directories from
// the absolute selected module directory.
func OutputDirectory(moduleDir, directory string) string {
if directory == "" {
directory = "dist"
}
if !filepath.IsAbs(directory) {
directory = filepath.Join(moduleDir, directory)
}
return filepath.Clean(directory)
}
type Target struct { type Target struct {
OS string OS string
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,23 +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
Version string
Spec Spec Spec Spec
OutputDir string OutputDir string
Output io.Writer Output io.Writer
ToolOwner *gotool.Owner
} }
type Artifact struct { type Artifact struct {
@@ -44,8 +41,12 @@ type Artifact struct {
type Result struct { type Result struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath 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
@@ -55,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) {
@@ -84,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
@@ -100,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 = artifact.OutputDirectory(moduleDir, request.OutputDir)
return result, nil return result, nil
} }
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir) result.OutputDir = artifact.OutputDirectory(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
@@ -114,11 +127,15 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err) return result, fmt.Errorf("create build output directory %q: %w", result.OutputDir, err)
} }
goTool, err := gotool.New(ctx, repositoryDir) owner := request.ToolOwner
if owner == nil {
owner = gotool.NewOwner(moduleDir)
}
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)
} }
@@ -135,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}
@@ -153,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)
@@ -187,19 +204,6 @@ func Build(ctx context.Context, request Request) (Result, error) {
return result, nil return result, nil
} }
func resolvedOutputDir(repositoryDir, outputDir string) string {
if outputDir == "" {
outputDir = filepath.Join(repositoryDir, "dist")
} else if !filepath.IsAbs(outputDir) {
outputDir = filepath.Join(repositoryDir, outputDir)
}
resolved, err := filepath.Abs(outputDir)
if err != nil {
return filepath.Clean(outputDir)
}
return resolved
}
func gitCommit(ctx context.Context, repositoryDir string) (string, error) { func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}") command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
output, err := command.Output() output, err := command.Output()
@@ -212,9 +216,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,29 +5,47 @@ 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/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir" "gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
) )
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error { func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate, *gotool.Owner) error {
if spec == nil || len(spec.Targets) == 0 { if spec == nil || len(spec.Targets) == 0 {
return nil return nil
} }
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) 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,
}) })
if err != nil { if err != nil {
return fmt.Errorf("build candidate: %w", err) return fmt.Errorf("build candidate: %w", err)

View File

@@ -8,10 +8,12 @@ import (
"fmt" "fmt"
"io" "io"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -20,6 +22,9 @@ var ErrInvalidVersion = errors.New("invalid stable version")
type Candidate struct { type Candidate struct {
RepositoryDir string RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Commit string Commit string
Version string Version string
Note string Note string
@@ -27,29 +32,33 @@ type Candidate struct {
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
Validation validation.Settings Validation validation.Settings
AdditionalChecks func(context.Context, Candidate) error AdditionalChecks func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate, *gotool.Owner) error
Output io.Writer Output io.Writer
ToolOwner *gotool.Owner
} }
func Check(ctx context.Context, request Request) (Candidate, error) { func Check(ctx context.Context, request Request) (Candidate, error) {
captured, err := Capture(ctx, request) captured, err := Capture(ctx, request)
if err != nil { if err != nil {
return Candidate{}, err return captured, err
} }
request.ToolOwner = gotool.NewOwner(captured.ModuleDir)
if err := Validate(ctx, request, captured); err != nil { if err := Validate(ctx, request, captured); err != nil {
return Candidate{}, err return captured, err
} }
if request.AfterValidate != nil { if request.AfterValidate != nil {
if err := request.AfterValidate(ctx, captured); err != nil { callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner)
return Candidate{}, fmt.Errorf("after candidate validation: %w", err) if callbackErr != nil {
return captured, fmt.Errorf("after candidate validation: %w", callbackErr)
} }
} }
if err := Recheck(ctx, captured, ""); err != nil { if err := Recheck(ctx, captured, ""); err != nil {
return Candidate{}, err return captured, err
} }
return captured, nil return captured, nil
} }
@@ -57,59 +66,76 @@ func Check(ctx context.Context, request Request) (Candidate, error) {
// Capture validates request identity and records one clean local candidate, // Capture validates request identity and records one clean local candidate,
// including the note bytes from the captured commit. It does not run checks. // including the note bytes from the captured commit. It does not run checks.
func Capture(ctx context.Context, request Request) (Candidate, error) { func Capture(ctx context.Context, request Request) (Candidate, error) {
captured := Candidate{Version: request.Version}
if !validStableVersion(request.Version) { if !validStableVersion(request.Version) {
return Candidate{}, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version) return captured, fmt.Errorf("%w: %q", ErrInvalidVersion, request.Version)
} }
if strings.TrimSpace(request.Product) == "" { if strings.TrimSpace(request.Product) == "" {
return Candidate{}, errors.New("product is required") return captured, errors.New("product is required")
} }
repositoryDir, err := filepath.Abs(request.RepositoryDir) repositoryDir, err := git.RepositoryRoot(ctx, request.RepositoryDir)
if err != nil { if err != nil {
return Candidate{}, fmt.Errorf("resolve repository directory: %w", err) return captured, fmt.Errorf("resolve repository directory: %w", err)
} }
captured.RepositoryDir = repositoryDir
state, err := inspectLocalState(ctx, repositoryDir) state, err := inspectLocalState(ctx, repositoryDir)
if err != nil { if err != nil {
return Candidate{}, err return captured, err
} }
captured.Commit = state.commit
if state.branch != "main" { if state.branch != "main" {
return Candidate{}, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch) return captured, fmt.Errorf("release candidate must be on main; current branch is %q", state.branch)
} }
if state.status != "" { if state.status != "" {
return Candidate{}, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status) return captured, fmt.Errorf("release candidate worktree and index must be clean:\n%s", state.status)
} }
path := "docs/releases/" + request.Version + ".md" identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit)
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path) captured.ModuleDir = identity.ModuleDir
captured.ModulePath = identity.ModulePath
captured.Tag = identity.Tag
if err != nil { if err != nil {
return Candidate{}, fmt.Errorf("read committed release note %q: %w", path, err) return captured, fmt.Errorf("resolve selected module release identity: %w", err)
}
note, err := git.ReadCommitFile(ctx, identity.RepositoryDir, state.commit, identity.NotePath)
if err != nil {
return captured, fmt.Errorf("read committed release note %q: %w", identity.NotePath, err)
} }
if err := validateNote(note, request.Product, request.Version); err != nil { if err := validateNote(note, request.Product, request.Version); err != nil {
return Candidate{}, fmt.Errorf("release note %q: %w", path, err) return captured, fmt.Errorf("release note %q: %w", identity.NotePath, err)
} }
if exists, err := hasLocalTag(ctx, repositoryDir, request.Version); err != nil { if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err) return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
} else if exists { } else if exists {
return Candidate{}, fmt.Errorf("local tag %q already exists", request.Version) return captured, fmt.Errorf("local tag %q already exists", identity.Tag)
} }
return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil captured.Note = string(note)
return captured, nil
} }
// Validate runs the selected offline Go suite, followed by AdditionalChecks // Validate runs the selected offline Go suite, followed by AdditionalChecks
// when supplied, for the captured candidate. // when supplied, for the captured candidate.
func Validate(ctx context.Context, request Request, captured Candidate) error { func Validate(ctx context.Context, request Request, captured Candidate) error {
owner := request.ToolOwner
if owner == nil {
owner = gotool.NewOwner(captured.ModuleDir)
}
if err := validation.Run(ctx, validation.Request{ if err := validation.Run(ctx, validation.Request{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
ModuleDir: captured.ModuleDir,
Commit: captured.Commit, Commit: captured.Commit,
Output: request.Output, Output: request.Output,
Settings: request.Validation, Settings: request.Validation,
ToolOwner: owner,
}); err != nil { }); err != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
} }
if request.AdditionalChecks != nil { if request.AdditionalChecks != nil {
if err := request.AdditionalChecks(ctx, captured); err != nil { callbackErr := request.AdditionalChecks(ctx, captured)
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err) if callbackErr != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr)
} }
} }
return nil return nil
@@ -133,23 +159,23 @@ func Recheck(ctx context.Context, captured Candidate, expectedTagObject string)
return fmt.Errorf("worktree or index changed:\n%s", current.status) return fmt.Errorf("worktree or index changed:\n%s", current.status)
} }
if expectedTagObject == "" { if expectedTagObject == "" {
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil { if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Tag); err != nil {
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err) return fmt.Errorf("recheck local tag %q: %w", captured.Tag, err)
} else if exists { } else if exists {
return fmt.Errorf("local tag %q appeared during validation", captured.Version) return fmt.Errorf("local tag %q appeared during validation", captured.Tag)
} }
return nil return nil
} }
object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Version) object, err := gitOutput(ctx, captured.RepositoryDir, "rev-parse", "--verify", "refs/tags/"+captured.Tag)
if err != nil { if err != nil {
return fmt.Errorf("read local tag %q: %w", captured.Version, err) return fmt.Errorf("read local tag %q: %w", captured.Tag, err)
} }
tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Version) tagType, err := gitOutput(ctx, captured.RepositoryDir, "cat-file", "-t", "refs/tags/"+captured.Tag)
if err != nil { if err != nil {
return fmt.Errorf("inspect local tag %q: %w", captured.Version, err) return fmt.Errorf("inspect local tag %q: %w", captured.Tag, err)
} }
if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" { if strings.TrimSpace(string(object)) != expectedTagObject || strings.TrimSpace(string(tagType)) != "tag" {
return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Version, expectedTagObject) return fmt.Errorf("local tag %q no longer identifies annotated tag object %s", captured.Tag, expectedTagObject)
} }
return nil return nil
} }
@@ -199,10 +225,10 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, error) { func hasLocalTag(ctx context.Context, repositoryDir, tag string) (bool, error) {
var stderr bytes.Buffer var stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{ err := process.Run(ctx, process.Invocation{
Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + version}, Args: []string{"git", "show-ref", "--verify", "--quiet", "refs/tags/" + tag},
Dir: repositoryDir, Dir: repositoryDir,
Stderr: &stderr, Stderr: &stderr,
}) })

View File

@@ -3,13 +3,17 @@ package candidate
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
@@ -71,7 +75,7 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
if seen != candidate { if seen != candidate {
t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate) t.Fatalf("callback candidate = %#v, returned candidate = %#v", seen, candidate)
} }
if candidate.RepositoryDir != repository || candidate.Commit != commit || candidate.Version != testVersion { if candidate.RepositoryDir != repository || candidate.ModuleDir != repository || candidate.ModulePath != "example.com/candidate" || candidate.Tag != testVersion || candidate.Commit != commit || candidate.Version != testVersion {
t.Fatalf("Check() candidate = %#v", candidate) t.Fatalf("Check() candidate = %#v", candidate)
} }
if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" { if tags := gitOutputForTest(t, repository, "tag", "--list"); tags != "" {
@@ -82,6 +86,67 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
} }
} }
func TestCaptureUsesNestedMajorModuleNoteAndTag(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRoot release.\n")
moduleDir := "integrations/ckconfig/v2"
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig/v2\n\ngo 1.25.0\n")
writeTestFile(t, repository, moduleDir+"/docs/releases/v2.3.4.md", "# Example Product v2.3.4\n\nNested release.\n")
gitRun(t, repository, "add", moduleDir)
gitRun(t, repository, "commit", "-m", "add nested major module")
captured, err := Capture(context.Background(), Request{
RepositoryDir: repository,
ModuleDir: moduleDir,
Product: "Example Product",
Version: "v2.3.4",
})
if err != nil {
t.Fatal(err)
}
if captured.RepositoryDir != repository || captured.ModuleDir != filepath.Join(repository, moduleDir) || captured.ModulePath != "example.com/ckconfig/v2" || captured.Tag != "integrations/ckconfig/v2.3.4" {
t.Fatalf("nested candidate identity = %#v", captured)
}
if !strings.Contains(captured.Note, "Nested release.") || strings.Contains(captured.Note, "Root release.") {
t.Fatalf("captured note = %q, want nested module note", captured.Note)
}
gitRun(t, repository, "tag", captured.Tag)
conflicted, err := Capture(context.Background(), Request{
RepositoryDir: repository,
ModuleDir: moduleDir,
Product: "Example Product",
Version: "v2.3.4",
})
if err == nil || !strings.Contains(err.Error(), captured.Tag) {
t.Fatalf("Capture() with existing nested tag returned %#v, %v", conflicted, err)
}
if conflicted.ModulePath != captured.ModulePath || conflicted.Tag != captured.Tag || conflicted.Commit != captured.Commit {
t.Fatalf("capture failure lost identity evidence: %#v", conflicted)
}
}
func TestCaptureKeepsRepositoryWideCleanlinessGuardForNestedSelection(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n")
moduleDir := "integrations/ckconfig"
writeTestFile(t, repository, moduleDir+"/go.mod", "module example.com/ckconfig\n")
writeTestFile(t, repository, moduleDir+"/docs/releases/"+testVersion+".md", "# Example Product v1.2.3\n")
gitRun(t, repository, "add", moduleDir)
gitRun(t, repository, "commit", "-m", "add nested module")
writeTestFile(t, repository, "sibling/untracked.txt", "dirty\n")
captured, err := Capture(context.Background(), Request{
RepositoryDir: repository,
ModuleDir: moduleDir,
Product: "Example Product",
Version: testVersion,
})
if err == nil || !strings.Contains(err.Error(), "must be clean") {
t.Fatalf("Capture() = %#v, %v; want repository-wide dirty-state rejection", captured, err)
}
if captured.Commit == "" {
t.Fatalf("dirty-state error lost captured commit: %#v", captured)
}
}
func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) { func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n") writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n")
@@ -98,7 +163,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T)
productCalls++ productCalls++
return nil return nil
}, },
AfterValidate: func(context.Context, Candidate) error { AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
buildCalls++ buildCalls++
return nil return nil
}, },
@@ -130,7 +195,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *te
productCalls++ productCalls++
return nil return nil
}, },
AfterValidate: func(context.Context, Candidate) error { AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
buildCalls++ buildCalls++
return nil return nil
}, },
@@ -204,6 +269,147 @@ func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
} }
} }
func TestCheckRefreshesGoSettingsAfterAdditionalChecks(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
t.Setenv("GOENV", "off")
t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "initial-module-cache"))
selectedCache := filepath.Join(t.TempDir(), "callback-module-cache")
var observed string
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AdditionalChecks: func(context.Context, Candidate) error {
t.Setenv("GOMODCACHE", selectedCache)
return nil
},
AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error {
tool, err := owner.Acquire(ctx)
if err != nil {
return err
}
output, err := tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "env", "GOMODCACHE")
if err != nil {
return err
}
observed = strings.TrimSpace(string(output))
return nil
},
})
if err != nil {
t.Fatal(err)
}
if observed != selectedCache {
t.Fatalf("Go settings after additional checks = %q, want %q", observed, selectedCache)
}
}
func TestCheckNeedsNoGoDiscoveryAfterFinalCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
gitOnly := t.TempDir()
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
t.Fatal(err)
}
_, err = Check(context.Background(), Request{
RepositoryDir: repository, Product: "Example Product", Version: testVersion,
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
t.Setenv("PATH", gitOnly)
return nil
},
})
if err != nil {
t.Fatalf("completed Go work was rediscovered after final callback: %v", err)
}
}
func TestCheckSharesGoDiscoveryWithAfterValidateCallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the controlled Go command fixture uses a POSIX shell")
}
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
logPath := installCandidateGoShim(t)
t.Setenv("GOENV", "off")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
AfterValidate: func(ctx context.Context, captured Candidate, owner *gotool.Owner) error {
tool, err := owner.Acquire(ctx)
if err != nil {
return err
}
_, err = tool.Output(ctx, captured.ModuleDir, gotool.Native, gotool.Target{}, nil, "version")
return err
},
})
if err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
commands := strings.Split(strings.TrimSpace(string(contents)), "\n")
var discoveries, suites, callbacks int
for _, command := range commands {
switch {
case strings.HasPrefix(command, "env "):
discoveries++
case strings.HasPrefix(command, "test "):
suites++
case command == "version":
callbacks++
}
}
if discoveries != 2 || suites != 1 || callbacks != 1 {
t.Fatalf("Go invocations = %q; discovery=%d suite=%d callback=%d, want 2, 1, 1", commands, discoveries, suites, callbacks)
}
}
func installCandidateGoShim(t *testing.T) string {
t.Helper()
directory := t.TempDir()
logPath := filepath.Join(directory, "go-commands")
settings := map[string]string{
"GOHOSTOS": runtime.GOOS, "GOHOSTARCH": runtime.GOARCH, "GOENV": "off",
"CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "", "CGO_CXXFLAGS": "",
"CGO_ENABLED": "1", "CGO_LDFLAGS": "", "GOCACHE": "", "GOCACHEPROG": "",
"GOMODCACHE": "", "GOPATH": "", "GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "",
}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
script := fmt.Sprintf(`#!/bin/sh
set -eu
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
if [ "${1-}" = "env" ]; then
printf '%%s\n' '%s'
exit 0
fi
if [ "${1-}" = "version" ]; then
echo 'go version fixture'
exit 0
fi
exit 0
`, encoded)
shimPath := filepath.Join(directory, "go")
if err := os.WriteFile(shimPath, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
path := directory
if current := os.Getenv("PATH"); current != "" {
path += string(os.PathListSeparator) + current
}
t.Setenv("PATH", path)
t.Setenv("RELEASEKIT_GO_LOG", logPath)
return logPath
}
func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) { func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n") writeTestFile(t, repository, "failure_test.go", "package candidate\nimport \"testing\"\nfunc TestExpectedFailure(t *testing.T) { t.Fatal(\"expected fixture failure\") }\n")
@@ -423,7 +629,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) { func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n") repository := newRepository(t, "# Example Product v1.2.3\n")
_, err := Check(context.Background(), Request{ captured, err := Check(context.Background(), Request{
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
@@ -435,6 +641,9 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "appeared during validation") { if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
t.Fatalf("Check() error = %v, want tag recheck error", err) t.Fatalf("Check() error = %v, want tag recheck error", err)
} }
if captured.RepositoryDir != repository || captured.ModuleDir != repository || captured.ModulePath != "example.com/candidate" || captured.Tag != testVersion || captured.Commit == "" {
t.Fatalf("tag recheck failure lost candidate evidence: %#v", captured)
}
} }
func validRequest(repository string) Request { func validRequest(repository string) Request {

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,8 @@ 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/gotool"
"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"
) )
@@ -29,8 +31,10 @@ const (
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
ExpectedTag string
Destination gitea.Destination Destination gitea.Destination
Build *internalbuild.Spec Build *internalbuild.Spec
Prebuilt *internalbuild.Result Prebuilt *internalbuild.Result
@@ -38,6 +42,10 @@ type Request struct {
} }
type Result struct { type Result struct {
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
CandidateCommit string CandidateCommit string
ReleaseURL string ReleaseURL string
State State State State
@@ -59,28 +67,49 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
if request.Build != nil && request.Prebuilt != nil { if request.Build != nil && request.Prebuilt != nil {
return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts") return result, errors.New("delivery cannot use both a managed build and prebuilt artifacts")
} }
repositoryDir, err := filepath.Abs(request.RepositoryDir) repositoryDir, err := git.RepositoryRoot(ctx, request.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)
} }
source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version) result.RepositoryDir = repositoryDir
if err != nil { commit, err := git.CaptureHead(ctx, repositoryDir)
return result, fmt.Errorf("inspect tagged source: %w", err)
}
result.CandidateCommit = source.TagCommit
if source.Status != "" {
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
}
if source.Head != source.TagCommit {
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, request.Version, source.TagCommit)
}
notePath := "docs/releases/" + request.Version + ".md"
note, err := git.ReadCommitFile(ctx, repositoryDir, source.TagCommit, notePath)
if err != nil { if err != nil {
return result, err return result, err
} }
result.CandidateCommit = commit
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, commit)
result.ModuleDir = identity.ModuleDir
result.ModulePath = identity.ModulePath
result.Tag = identity.Tag
if err != nil {
return result, fmt.Errorf("resolve committed delivery identity: %w", err)
}
result.RepositoryDir = identity.RepositoryDir
result.ModuleDir = identity.ModuleDir
result.ModulePath = identity.ModulePath
result.Tag = identity.Tag
if request.ExpectedTag != "" && request.ExpectedTag != identity.Tag {
return result, fmt.Errorf("expected delivery tag %q does not match selected module tag %q", request.ExpectedTag, identity.Tag)
}
source, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
if err != nil {
return result, fmt.Errorf("inspect tagged source: %w", err)
}
if source.Status != "" {
return result, fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
}
if source.Head != commit {
return result, fmt.Errorf("checkout HEAD changed from captured commit %s to %s", commit, source.Head)
}
if source.TagCommit != commit {
return result, fmt.Errorf("checkout HEAD %s does not match tag %s commit %s", source.Head, identity.Tag, source.TagCommit)
}
note, err := git.ReadCommitFile(ctx, repositoryDir, commit, identity.NotePath)
if err != nil {
return result, fmt.Errorf("read release note %q: %w", identity.NotePath, err)
}
if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil { if err := candidate.ValidateReleaseNote(note, request.Product, request.Version); err != nil {
return result, fmt.Errorf("release note %q: %w", notePath, err) return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
} }
build := request.Prebuilt build := request.Prebuilt
@@ -90,12 +119,12 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
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,
ToolOwner: gotool.NewOwner(identity.ModuleDir),
}) })
if err != nil { if err != nil {
return result, fmt.Errorf("build delivery assets: %w", err) return result, fmt.Errorf("build delivery assets: %w", err)
@@ -109,7 +138,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, err return result, err
} }
} }
if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil { if err := validateBuild(identity, commit, build, assets); err != nil {
return result, err return result, err
} }
if len(assets) > 0 { if len(assets) > 0 {
@@ -117,23 +146,23 @@ 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, identity.Tag)
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)
} }
if current.Status != "" { if current.Status != "" {
return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status) return result, fmt.Errorf("tagged checkout changed during build:\n%s", current.Status)
} }
if current.Head != source.TagCommit || current.TagCommit != source.TagCommit { if current.Head != commit || current.TagCommit != commit {
return result, errors.New("tagged source changed during build") return result, errors.New("tagged source changed during build")
} }
} }
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{ releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
Destination: request.Destination, Destination: request.Destination,
Version: request.Version, Tag: identity.Tag,
Commit: source.TagCommit, Commit: commit,
Note: string(note), Note: string(note),
}) })
if err != nil { if err != nil {
@@ -154,7 +183,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
result.Uncertain = assetIdentities(assets) result.Uncertain = assetIdentities(assets)
return result, err return result, err
} }
uploadResult, uploadErr := (gitea.Client{}).UploadMissing(ctx, request.Destination, releaseResult.Release.ID, inspection) uploadResult, uploadErr := (gitea.Client{}).UploadVerifiedMissing(ctx, request.Destination, releaseResult.Release.ID, inspection)
result.Matching = uploadResult.Matching result.Matching = uploadResult.Matching
result.Uploaded = uploadResult.Uploaded result.Uploaded = uploadResult.Uploaded
result.Pending = uploadResult.Pending result.Pending = uploadResult.Pending
@@ -167,14 +196,25 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
return result, uploadErr return result, uploadErr
} }
func validateBuild(repositoryDir, commit, version string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error { func validateBuild(identity releaseidentity.Identity, commit string, build *internalbuild.Result, assets []gitea.ExpectedAsset) error {
if build == nil { if build == nil {
if len(assets) != 0 { if len(assets) != 0 {
return errors.New("assets require a build identity") return errors.New("assets require a build identity")
} }
return nil return nil
} }
if len(build.Targets) == 0 { buildRepository, err := filepath.Abs(build.RepositoryDir)
if err != nil {
return fmt.Errorf("resolve build repository: %w", err)
}
buildModule, err := filepath.Abs(build.ModuleDir)
if err != nil {
return fmt.Errorf("resolve build module: %w", err)
}
if buildRepository != identity.RepositoryDir || buildModule != identity.ModuleDir || build.ModulePath != identity.ModulePath || build.Tag != identity.Tag || build.CandidateCommit != commit || build.Version != identity.Version {
return errors.New("build result does not identify the selected tagged module")
}
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")
} }
@@ -183,14 +223,7 @@ func validateBuild(repositoryDir, commit, version string, build *internalbuild.R
if !build.SourceVerified { if !build.SourceVerified {
return errors.New("build source was not verified as clean and unchanged during compilation") return errors.New("build source was not verified as clean and unchanged during compilation")
} }
buildRepository, err := filepath.Abs(build.RepositoryDir) 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 {
if err != nil {
return fmt.Errorf("resolve build repository: %w", err)
}
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 {
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"
) )
@@ -61,13 +62,12 @@ 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"

155
internal/git/path.go Normal file
View File

@@ -0,0 +1,155 @@
package git
import (
"context"
"fmt"
"path"
"path/filepath"
"strings"
)
// PathResolver traverses committed paths, sharing reference rules between
// release identity and validation. Lookups include both files and directories.
type PathResolver struct {
RepositoryDir string
Lookup func(context.Context, string) (TreeEntry, bool, error)
ReadLink func(context.Context, TreeEntry) (string, error)
}
// DestinationKind classifies a committed local destination.
type DestinationKind string
const (
DestinationEntry DestinationKind = "entry"
DestinationDirectory DestinationKind = "directory"
DestinationMissing DestinationKind = "missing"
DestinationOutsideCheckout DestinationKind = "outside-checkout"
DestinationSubmodule DestinationKind = "submodule"
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
)
// Destination records the resolved identity and its classification.
type Destination struct {
Kind DestinationKind
Path string
}
// ResolveDestination resolves local destinations using the captured Git tree.
// Relative destinations start at the source directory; absolute destinations
// within the repository directory map back to committed paths.
func (resolver PathResolver) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
if err := ctx.Err(); err != nil {
return Destination{}, err
}
directory := path.Dir(sourcePath)
if path.IsAbs(destination) {
relative, inside, err := resolver.relativeAbsoluteDestination(destination)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
}
directory, destination = ".", relative
}
return resolver.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
}
func (resolver PathResolver) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) {
for index, component := range components {
if err := ctx.Err(); err != nil {
return Destination{}, err
}
remaining := components[index+1:]
switch component {
case "", ".":
continue
case "..":
if directory == "." {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil
}
directory = path.Dir(directory)
continue
}
partial := path.Join(directory, component)
entry, exists, err := resolver.Lookup(ctx, partial)
if err != nil {
return Destination{}, err
}
if !exists {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
if entry.Type == "tree" {
directory = partial
continue
}
if entry.Mode == "160000" {
return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(partial, remaining)}, nil
}
if entry.Mode == "120000" {
if active[partial] {
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
}
linkText, err := resolver.ReadLink(ctx, entry)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := linkText
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := resolver.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := resolver.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
delete(active, partial)
if err != nil || len(remaining) == 0 {
return destination, err
}
switch destination.Kind {
case DestinationDirectory:
return resolver.resolveDestination(ctx, destination.Path, remaining, active)
case DestinationEntry:
destination.Kind = DestinationMissing
case DestinationSymlinkCycle:
return destination, nil
}
destination.Path = unresolvedPath(destination.Path, remaining)
return destination, nil
}
if len(remaining) != 0 {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
return Destination{Kind: DestinationEntry, Path: partial}, nil
}
return Destination{Kind: DestinationDirectory, Path: directory}, nil
}
func (resolver PathResolver) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(resolver.RepositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, nil
}
// unresolvedPath preserves components that could not be traversed. Cleaning
// them would hide missing entries, non-directories, or checkout boundaries.
func unresolvedPath(base string, remaining []string) string {
if len(remaining) == 0 {
return base
}
return base + "/" + strings.Join(remaining, "/")
}

View File

@@ -8,30 +8,51 @@ import (
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit // PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
// against expectedCommit, and checks out that commit detached. // against expectedCommit, and checks out that commit detached.
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error { func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
if !validVersionRef(version) || !validObjectID(expectedCommit) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version) return err
} }
ref := "refs/tags/" + version if !validObjectID(expectedCommit) {
return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag)
}
ref := "refs/tags/" + tag
if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil { if _, err := run(ctx, repositoryDir, nil, "fetch", "--no-tags", remote, ref+":"+ref); err != nil {
return fmt.Errorf("fetch tag %s from remote %s: %w", version, remote, err) return fmt.Errorf("fetch tag %s from remote %s: %w", tag, remote, err)
} }
source, err := InspectTaggedSource(ctx, repositoryDir, version) source, err := InspectTaggedSource(ctx, repositoryDir, tag)
if err != nil { if err != nil {
return err return err
} }
if !strings.EqualFold(source.TagCommit, expectedCommit) { if !strings.EqualFold(source.TagCommit, expectedCommit) {
return fmt.Errorf("tag %s resolves to commit %s, expected %s", version, source.TagCommit, expectedCommit) return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit)
} }
if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil { if _, err := run(ctx, repositoryDir, nil, "checkout", "--detach", source.TagCommit); err != nil {
return fmt.Errorf("check out tag %s at %s: %w", version, source.TagCommit, err) return fmt.Errorf("check out tag %s at %s: %w", tag, source.TagCommit, err)
} }
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") return VerifyTaggedCheckout(ctx, repositoryDir, tag, expectedCommit)
}
// VerifyTaggedCheckout confirms that the existing local tag, HEAD, and clean
// worktree all identify the expected commit. It does not fetch or check out.
func VerifyTaggedCheckout(ctx context.Context, repositoryDir, tag, expectedCommit string) error {
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return err
}
if !validObjectID(expectedCommit) {
return fmt.Errorf("invalid tagged checkout commit %q for tag %q", expectedCommit, tag)
}
source, err := InspectTaggedSource(ctx, repositoryDir, tag)
if err != nil { if err != nil {
return fmt.Errorf("verify prepared checkout HEAD: %w", err) return err
} }
if !strings.EqualFold(strings.TrimSpace(head), expectedCommit) { if source.Status != "" {
return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), expectedCommit) return fmt.Errorf("tagged checkout must be clean:\n%s", source.Status)
}
if !strings.EqualFold(source.TagCommit, expectedCommit) {
return fmt.Errorf("tag %s resolves to commit %s, expected %s", tag, source.TagCommit, expectedCommit)
}
if !strings.EqualFold(source.Head, expectedCommit) {
return fmt.Errorf("checkout HEAD is %s, expected %s", source.Head, expectedCommit)
} }
return nil return nil
} }

View File

@@ -18,7 +18,7 @@ var (
ErrEndpointChanged = errors.New("Git publication endpoint changed") ErrEndpointChanged = errors.New("Git publication endpoint changed")
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch") ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit") ErrRemoteMainConflict = errors.New("authoritative main does not identify the candidate commit")
ErrRemoteTagConflict = errors.New("version tag already exists on the authoritative remote") ErrRemoteTagConflict = errors.New("tag already exists on the authoritative remote")
ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable") ErrRemoteStateUnreadable = errors.New("authoritative remote state is unreadable")
) )
@@ -50,7 +50,7 @@ type RemoteRef struct {
Peeled string Peeled string
} }
// Snapshot is the result of querying refs/heads/main and one exact version tag. // Snapshot is the result of querying refs/heads/main and one exact tag.
type Snapshot struct { type Snapshot struct {
Main RemoteRef Main RemoteRef
Tag RemoteRef Tag RemoteRef
@@ -97,14 +97,14 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
return endpoint.verifyScopedURLs(ctx, repositoryDir) return endpoint.verifyScopedURLs(ctx, repositoryDir)
} }
// Query reads exact main and version-tag refs. A command failure is unreadable, // Query reads exact main and tag refs. A command failure is unreadable,
// never evidence that a ref is absent. // never evidence that a ref is absent.
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) { func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
if !validVersionRef(version) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return Snapshot{}, fmt.Errorf("invalid version tag name %q", version) return Snapshot{}, err
} }
mainRef := "refs/heads/main" mainRef := "refs/heads/main"
tagRef := "refs/tags/" + version tagRef := "refs/tags/" + tag
peeledRef := tagRef + "^{}" peeledRef := tagRef + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef) output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
if err != nil { if err != nil {
@@ -112,7 +112,7 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err) return snapshot, fmt.Errorf("%w: query authoritative refs at %s: %w", ErrRemoteStateUnreadable, endpoint.PushURL, err)
} }
main := RemoteRef{State: RefAbsent} main := RemoteRef{State: RefAbsent}
tag := RemoteRef{State: RefAbsent} remoteTag := RemoteRef{State: RefAbsent}
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") { for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
if line == "" { if line == "" {
continue continue
@@ -125,19 +125,19 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
case mainRef: case mainRef:
main = mergeRef(main, object, false) main = mergeRef(main, object, false)
case tagRef: case tagRef:
tag = mergeRef(tag, object, false) remoteTag = mergeRef(remoteTag, object, false)
case peeledRef: case peeledRef:
tag = mergeRef(tag, object, true) remoteTag = mergeRef(remoteTag, object, true)
} }
} }
return Snapshot{Main: main, Tag: tag}, nil return Snapshot{Main: main, Tag: remoteTag}, nil
} }
// RequireCandidate requires exact main equality and an absent remote version // RequireCandidate requires exact main equality and an absent remote
// tag. It deliberately does not accept a main branch that merely contains the // tag. It deliberately does not accept a main branch that merely contains the
// candidate as an ancestor. // candidate as an ancestor.
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) { func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, tag, commit string) (Snapshot, error) {
snapshot, err := endpoint.Query(ctx, repositoryDir, version) snapshot, err := endpoint.Query(ctx, repositoryDir, tag)
if err != nil { if err != nil {
return snapshot, err return snapshot, err
} }
@@ -294,25 +294,14 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
return ref return ref
} }
func validVersionRef(version string) bool { func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
if len(version) < 6 || !strings.HasPrefix(version, "v") { if tag == "" {
return false return fmt.Errorf("invalid Git tag name %q", tag)
} }
parts := strings.Split(version[1:], ".") if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
if len(parts) != 3 { return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
return false
} }
for _, part := range parts { return nil
if part == "" || (len(part) > 1 && part[0] == '0') {
return false
}
for _, char := range part {
if char < '0' || char > '9' {
return false
}
}
}
return true
} }
func validObjectID(object string) bool { func validObjectID(object string) bool {

View File

@@ -42,6 +42,32 @@ func TestResolveUsesPushURLAndQueriesWithoutMutation(t *testing.T) {
} }
} }
func TestRemoteTagOperationsAcceptSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t)
endpoint, err := Resolve(context.Background(), fixture.work, "origin")
if err != nil {
t.Fatal(err)
}
tag := "integrations/check/v1.2.3"
if _, err := endpoint.RequireCandidate(context.Background(), fixture.work, tag, fixture.commit); err != nil {
t.Fatalf("RequireCandidate(%q): %v", tag, err)
}
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
if err != nil {
t.Fatal(err)
}
if err := endpoint.PushTag(context.Background(), fixture.work, tag, object); err != nil {
t.Fatal(err)
}
remote, err := endpoint.QueryTag(context.Background(), fixture.work, tag)
if err != nil {
t.Fatal(err)
}
if remote.State != RefPresent || remote.Object != object || remote.Peeled != fixture.commit {
t.Fatalf("remote tag = %#v", remote)
}
}
func TestResolveAppliesGitURLRewrites(t *testing.T) { func TestResolveAppliesGitURLRewrites(t *testing.T) {
for _, kind := range []string{"insteadOf", "pushInsteadOf"} { for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
t.Run(kind, func(t *testing.T) { t.Run(kind, func(t *testing.T) {

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"path" "path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -30,11 +31,43 @@ type TaggedSource struct {
TagObjectType string TagObjectType string
} }
// RepositoryRoot resolves the containing checkout, including independent
// worktrees and directories reached through filesystem aliases.
func RepositoryRoot(ctx context.Context, directory string) (string, error) {
if directory == "" {
directory = "."
}
root, err := run(ctx, directory, nil, "rev-parse", "--show-toplevel")
if err != nil {
return "", fmt.Errorf("resolve Git repository root: %w", err)
}
root = strings.TrimSuffix(root, "\n")
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
return "", fmt.Errorf("resolve Git repository directory: %w", err)
}
return resolved, nil
}
// CaptureHead records the checkout commit before identity derived from files
// in that commit is inspected.
func CaptureHead(ctx context.Context, repositoryDir string) (string, error) {
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return "", fmt.Errorf("read checkout HEAD: %w", err)
}
head = strings.TrimSpace(head)
if !validObjectID(head) {
return "", fmt.Errorf("Git returned an invalid checkout commit %q", head)
}
return head, nil
}
// InspectTaggedSource reads the local HEAD, worktree state, and selected tag. // InspectTaggedSource reads the local HEAD, worktree state, and selected tag.
// Both lightweight historical tags and annotated tags are supported. // Both lightweight historical tags and annotated tags are supported.
func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) { func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
if !validVersionRef(version) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return TaggedSource{}, fmt.Errorf("invalid tag version %q", version) return TaggedSource{}, err
} }
var source TaggedSource var source TaggedSource
head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}") head, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", "HEAD^{commit}")
@@ -50,10 +83,10 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err) return TaggedSource{}, fmt.Errorf("inspect tagged checkout state: %w", err)
} }
source.Status = strings.TrimSpace(status) source.Status = strings.TrimSpace(status)
ref := "refs/tags/" + version ref := "refs/tags/" + tag
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil { if err != nil {
return TaggedSource{}, fmt.Errorf("read local tag %s: %w", version, err) return TaggedSource{}, fmt.Errorf("read local tag %s: %w", tag, err)
} }
source.TagObject = strings.TrimSpace(object) source.TagObject = strings.TrimSpace(object)
if !validObjectID(source.TagObject) { if !validObjectID(source.TagObject) {
@@ -61,7 +94,7 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
} }
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}") peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{commit}")
if err != nil { if err != nil {
return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", version, err) return TaggedSource{}, fmt.Errorf("resolve local tag %s to a commit: %w", tag, err)
} }
source.TagCommit = strings.TrimSpace(peeled) source.TagCommit = strings.TrimSpace(peeled)
if !validObjectID(source.TagCommit) { if !validObjectID(source.TagCommit) {
@@ -69,11 +102,11 @@ func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (Ta
} }
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil { if err != nil {
return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", version, err) return TaggedSource{}, fmt.Errorf("inspect local tag %s: %w", tag, err)
} }
source.TagObjectType = strings.TrimSpace(tagType) source.TagObjectType = strings.TrimSpace(tagType)
if source.TagObjectType != "tag" && source.TagObjectType != "commit" { if source.TagObjectType != "tag" && source.TagObjectType != "commit" {
return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", version, source.TagObjectType) return TaggedSource{}, fmt.Errorf("local tag %s has unsupported object type %q", tag, source.TagObjectType)
} }
return source, nil return source, nil
} }
@@ -83,15 +116,56 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") { if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || strings.HasPrefix(name, "../") || name == ".." || strings.Contains(name, "\\") {
return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit) return nil, fmt.Errorf("invalid committed file identity %q at %q", name, commit)
} }
content, err := run(ctx, repositoryDir, nil, "show", commit+":"+name) content, err := readLocalObjects(ctx, repositoryDir, nil, "show", commit+":"+name)
if err != nil { if err != nil {
return nil, fmt.Errorf("read committed file %q: %w", name, err) return nil, fmt.Errorf("read committed file %q: %w", name, err)
} }
return []byte(content), nil return content, nil
} }
// ReadTree enumerates all entries in a commit and records blob sizes. Paths // CommittedPaths resolves individual paths from captured objects. It requests
// are NUL-delimited by Git so whitespace in a filename is preserved. // only metadata and link contents needed for traversal.
func CommittedPaths(repositoryDir, commit string) PathResolver {
metadata := make(map[string]TreeEntry)
return PathResolver{
RepositoryDir: repositoryDir,
Lookup: func(ctx context.Context, name string) (TreeEntry, bool, error) {
if err := ctx.Err(); err != nil {
return TreeEntry{}, false, err
}
if entry, seen := metadata[name]; seen {
return entry, entry.ObjectID != "", nil
}
if !validObjectID(commit) || name == "" || path.IsAbs(name) || path.Clean(name) != name || name == ".." || strings.HasPrefix(name, "../") {
return TreeEntry{}, false, fmt.Errorf("invalid committed path identity %q at %q", name, commit)
}
output, err := readLocalObjects(ctx, repositoryDir, nil, "--literal-pathspecs", "ls-tree", "-z", "--full-tree", commit, "--", name)
if err != nil {
return TreeEntry{}, false, fmt.Errorf("inspect committed path %q: %w", name, err)
}
entries, err := parseTreeEntries(output)
if err != nil {
return TreeEntry{}, false, err
}
for _, entry := range entries {
if entry.Path == name {
metadata[name] = entry
return entry, true, nil
}
}
metadata[name] = TreeEntry{}
return TreeEntry{}, false, nil
},
ReadLink: func(ctx context.Context, entry TreeEntry) (string, error) {
contents, err := ReadBlob(ctx, repositoryDir, entry.ObjectID)
return string(contents), err
},
}
}
// ReadTree enumerates path, type, mode, and object metadata for a commit.
// Blob sizes and contents are inspected separately. Paths are NUL-delimited
// by Git so whitespace in a filename is preserved.
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) { func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
if !validObjectID(commit) { if !validObjectID(commit) {
return nil, fmt.Errorf("invalid commit object ID %q", commit) return nil, fmt.Errorf("invalid commit object ID %q", commit)
@@ -104,9 +178,6 @@ func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, e
if err != nil { if err != nil {
return nil, fmt.Errorf("parse committed tree %s: %w", commit, err) return nil, fmt.Errorf("parse committed tree %s: %w", commit, err)
} }
if err := populateBlobSizes(ctx, repositoryDir, entries); err != nil {
return nil, fmt.Errorf("read committed blob sizes for %s: %w", commit, err)
}
return entries, nil return entries, nil
} }
@@ -150,11 +221,16 @@ func parseTreeEntries(output []byte) ([]TreeEntry, error) {
return entries, nil return entries, nil
} }
func populateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error { // PopulateBlobSizes fills unknown sizes for the supplied blob entries using
// local objects. Callers select the entries whose sizes their check needs.
func PopulateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
if err := ctx.Err(); err != nil {
return err
}
var blobIndexes []int var blobIndexes []int
var input strings.Builder var input strings.Builder
for index := range entries { for index := range entries {
if entries[index].Type != "blob" { if entries[index].Type != "blob" || entries[index].HasSize {
continue continue
} }
blobIndexes = append(blobIndexes, index) blobIndexes = append(blobIndexes, index)
@@ -177,7 +253,7 @@ func populateBlobSizes(ctx context.Context, repositoryDir string, entries []Tree
fields := strings.Fields(line) fields := strings.Fields(line)
entry := &entries[blobIndexes[index]] entry := &entries[blobIndexes[index]]
if len(fields) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" { if len(fields) == 2 && fields[0] == entry.ObjectID && fields[1] == "missing" {
return fmt.Errorf("committed blob %s is unavailable locally", entry.ObjectID) return fmt.Errorf("committed blob %s for %q is unavailable locally", entry.ObjectID, entry.Path)
} }
if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" { if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" {
return fmt.Errorf("unexpected blob size record %q", line) return fmt.Errorf("unexpected blob size record %q", line)

View File

@@ -32,6 +32,37 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
} }
} }
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t)
tag := "integrations/check/v2.3.4"
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
t.Fatalf("checkTagRef(%q): %v", tag, err)
}
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
if err != nil {
t.Fatal(err)
}
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
t.Fatal(err)
}
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
if err != nil {
t.Fatal(err)
}
if source.TagCommit != fixture.commit || source.TagObject != object || source.TagObjectType != "tag" {
t.Fatalf("source for %q = %#v", tag, source)
}
}
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
fixture := newRemoteFixture(t)
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
if err := checkTagRef(context.Background(), fixture.work, tag); err == nil {
t.Errorf("checkTagRef(%q) succeeded", tag)
}
}
}
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) { func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true") gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true")
@@ -54,8 +85,23 @@ func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
t.Setenv("RELEASEKIT_GIT_ATTEMPTS", attempts) t.Setenv("RELEASEKIT_GIT_ATTEMPTS", attempts)
t.Setenv("GIT_NO_LAZY_FETCH", "0") t.Setenv("GIT_NO_LAZY_FETCH", "0")
t.Setenv("GIT_ALLOW_PROTOCOL", "ssh") t.Setenv("GIT_ALLOW_PROTOCOL", "ssh")
if _, err := ReadTree(t.Context(), repository, fixture.commit); err == nil { entries, treeErr := ReadTree(t.Context(), repository, fixture.commit)
t.Fatal("tree inspection succeeded with unavailable objects") if filter == "tree:0" {
if treeErr == nil {
t.Fatal("tree inspection succeeded with unavailable trees")
}
} else {
if treeErr != nil {
t.Fatalf("metadata inspection required unavailable blobs: %v", treeErr)
}
if err := PopulateBlobSizes(t.Context(), repository, entries); err == nil {
t.Fatal("size inspection succeeded with unavailable blobs")
}
}
_, fileErr := ReadCommitFile(t.Context(), repository, fixture.commit, "source.txt")
var fileExitError *exec.ExitError
if !errors.As(fileErr, &fileExitError) {
t.Fatalf("committed file error %v lost its Git process cause", fileErr)
} }
_, err := ReadBlob(t.Context(), repository, blob) _, err := ReadBlob(t.Context(), repository, blob)
var exitError *exec.ExitError var exitError *exec.ExitError

View File

@@ -10,9 +10,9 @@ import (
// CreateAnnotatedTag records the captured note as an unsigned annotated tag // CreateAnnotatedTag records the captured note as an unsigned annotated tag
// on the captured commit and returns the tag object's ID. // on the captured commit and returns the tag object's ID.
func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit string, note []byte) (string, error) { func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
if !validVersionRef(version) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return "", fmt.Errorf("invalid version tag name %q", version) return "", err
} }
if !validObjectID(commit) { if !validObjectID(commit) {
return "", fmt.Errorf("invalid candidate commit object ID %q", commit) return "", fmt.Errorf("invalid candidate commit object ID %q", commit)
@@ -30,64 +30,67 @@ func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit stri
if err := message.Close(); err != nil { if err := message.Close(); err != nil {
return "", fmt.Errorf("close temporary release note: %w", err) return "", fmt.Errorf("close temporary release note: %w", err)
} }
if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, version, commit); err != nil { if _, err := run(ctx, repositoryDir, []string{"tag.gpgSign=false"}, "tag", "--annotate", "--cleanup=verbatim", "--file", messagePath, tag, commit); err != nil {
return "", fmt.Errorf("create annotated tag %s: %w", version, err) return "", fmt.Errorf("create annotated tag %s: %w", tag, err)
} }
ref := "refs/tags/" + version ref := "refs/tags/" + tag
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil { if err != nil {
return "", fmt.Errorf("inspect created tag %s: %w", version, err) return "", fmt.Errorf("inspect created tag %s: %w", tag, err)
} }
if strings.TrimSpace(tagType) != "tag" { if strings.TrimSpace(tagType) != "tag" {
return "", fmt.Errorf("created tag %s has object type %q, want tag", version, strings.TrimSpace(tagType)) return "", fmt.Errorf("created tag %s has object type %q, want tag", tag, strings.TrimSpace(tagType))
} }
tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) tagObject, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil { if err != nil {
return "", fmt.Errorf("read created tag object %s: %w", version, err) return "", fmt.Errorf("read created tag object %s: %w", tag, err)
} }
tagObject = strings.TrimSpace(tagObject) tagObject = strings.TrimSpace(tagObject)
if !validObjectID(tagObject) { if !validObjectID(tagObject) {
return "", fmt.Errorf("created tag %s has invalid object ID %q", version, tagObject) return "", fmt.Errorf("created tag %s has invalid object ID %q", tag, tagObject)
} }
peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}") peeled, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref+"^{}")
if err != nil { if err != nil {
return "", fmt.Errorf("read peeled commit for tag %s: %w", version, err) return "", fmt.Errorf("read peeled commit for tag %s: %w", tag, err)
} }
if strings.TrimSpace(peeled) != commit { if strings.TrimSpace(peeled) != commit {
return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", version, strings.TrimSpace(peeled), commit) return "", fmt.Errorf("created tag %s peels to %s, want candidate %s", tag, strings.TrimSpace(peeled), commit)
} }
tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref) tagContents, err := run(ctx, repositoryDir, nil, "cat-file", "tag", ref)
if err != nil { if err != nil {
return "", fmt.Errorf("read annotated tag %s: %w", version, err) return "", fmt.Errorf("read annotated tag %s: %w", tag, err)
} }
separator := bytes.Index([]byte(tagContents), []byte("\n\n")) separator := bytes.Index([]byte(tagContents), []byte("\n\n"))
if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) { if separator < 0 || !bytes.Equal([]byte(tagContents[separator+2:]), note) {
return "", fmt.Errorf("annotated tag %s message does not match the captured release note", version) return "", fmt.Errorf("annotated tag %s message does not match the captured release note", tag)
} }
return tagObject, nil return tagObject, nil
} }
// InspectLocalTag confirms the selected tag still points to the exact // InspectLocalTag confirms the selected tag still points to the exact
// annotated tag object captured before publication. // annotated tag object captured before publication.
func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject string) error { func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
if !validVersionRef(version) || !validObjectID(expectedObject) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version) return err
} }
ref := "refs/tags/" + version if !validObjectID(expectedObject) {
return fmt.Errorf("invalid expected tag object identity %q at %q", expectedObject, tag)
}
ref := "refs/tags/" + tag
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref) tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
if err != nil { if err != nil {
return fmt.Errorf("inspect local tag %s: %w", version, err) return fmt.Errorf("inspect local tag %s: %w", tag, err)
} }
if strings.TrimSpace(tagType) != "tag" { if strings.TrimSpace(tagType) != "tag" {
return fmt.Errorf("local tag %s has type %q, want annotated tag", version, strings.TrimSpace(tagType)) return fmt.Errorf("local tag %s has type %q, want annotated tag", tag, strings.TrimSpace(tagType))
} }
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref) object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
if err != nil { if err != nil {
return fmt.Errorf("read local tag %s: %w", version, err) return fmt.Errorf("read local tag %s: %w", tag, err)
} }
if strings.TrimSpace(object) != expectedObject { if strings.TrimSpace(object) != expectedObject {
return fmt.Errorf("local tag %s identifies %s, want %s", version, strings.TrimSpace(object), expectedObject) return fmt.Errorf("local tag %s identifies %s, want %s", tag, strings.TrimSpace(object), expectedObject)
} }
return nil return nil
} }
@@ -95,18 +98,18 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
// QueryTag verifies the captured endpoint still resolves to itself, then reads // QueryTag verifies the captured endpoint still resolves to itself, then reads
// one exact authoritative version tag and its peeled commit. Failed // one exact authoritative version tag and its peeled commit. Failed
// verification or reads are unreadable and never interpreted as absence. // verification or reads are unreadable and never interpreted as absence.
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) { func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
if !validVersionRef(version) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version) return RemoteRef{}, err
} }
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil { if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err) return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: verify authoritative endpoint before tag query: %w", ErrRemoteStateUnreadable, err)
} }
ref := "refs/tags/" + version ref := "refs/tags/" + tag
peeledRef := ref + "^{}" peeledRef := ref + "^{}"
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef) output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
if err != nil { if err != nil {
return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, version, endpoint.PushURL, err) return RemoteRef{State: RefUnreadable}, fmt.Errorf("%w: query authoritative tag %s at %s: %w", ErrRemoteStateUnreadable, tag, endpoint.PushURL, err)
} }
remoteTag := RemoteRef{State: RefAbsent} remoteTag := RemoteRef{State: RefAbsent}
for _, line := range outputLines(output) { for _, line := range outputLines(output) {
@@ -125,13 +128,16 @@ func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version st
} }
// PushTag publishes only the captured tag object to its exact tag ref. // PushTag publishes only the captured tag object to its exact tag ref.
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error { func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
if !validVersionRef(version) || !validObjectID(tagObject) { if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
return fmt.Errorf("invalid tag publication identity %q at %q", tagObject, version) return err
} }
refspec := tagObject + ":refs/tags/" + version if !validObjectID(tagObject) {
return fmt.Errorf("invalid tag publication object %q at %q", tagObject, tag)
}
refspec := tagObject + ":refs/tags/" + tag
if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil { if _, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "push", "--no-follow-tags", "--porcelain", endpoint.ScopedName, refspec); err != nil {
return fmt.Errorf("push annotated tag %s to %s: %w", version, endpoint.PushURL, err) return fmt.Errorf("push annotated tag %s to %s: %w", tag, endpoint.PushURL, err)
} }
return nil return nil
} }

View File

@@ -47,7 +47,8 @@ func TestInspectAssetsAcceptsMatchingContentAndPreservesUnrelatedFiles(t *testin
case "/files/notes": case "/files/notes":
_, _ = w.Write([]byte("unrelated")) _, _ = w.Write([]byte("unrelated"))
default: default:
t.Fatalf("unexpected path %s", r.URL.Path) t.Errorf("unexpected path %s", r.URL.Path)
return
} }
})) }))
defer server.Close() defer server.Close()
@@ -107,7 +108,8 @@ func TestInspectAssetsAcceptsFullManifestWhenBinariesAreMissing(t *testing.T) {
return return
} }
if r.URL.Path != "/files/manifest" { if r.URL.Path != "/files/manifest" {
t.Fatalf("unexpected path %s", r.URL.Path) t.Errorf("unexpected path %s", r.URL.Path)
return
} }
_, _ = w.Write(manifest.Content) _, _ = w.Write(manifest.Content)
})) }))
@@ -237,7 +239,8 @@ func TestInspectAssetsFollowsSameOriginRedirectWithAuthorization(t *testing.T) {
_, _ = w.Write(content) _, _ = w.Write(content)
return return
} }
t.Fatalf("unexpected path %s", r.URL.Path) t.Errorf("unexpected path %s", r.URL.Path)
return
})) }))
defer server.Close() defer server.Close()
inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected}) inspection, err := testClient(server).InspectAssets(context.Background(), testDestination(server.URL), 10, []ExpectedAsset{expected})
@@ -253,9 +256,9 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
for _, mode := range []string{"direct", "redirect", "conflicting"} { for _, mode := range []string{"direct", "redirect", "conflicting"} {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
expected := assetFor("app.bin", []byte("matching bytes")) expected := assetFor("app.bin", []byte("matching bytes"))
externalGets := 0 var externalGets atomic.Int64
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
externalGets++ externalGets.Add(1)
if r.Header.Get("Authorization") != "" { if r.Header.Get("Authorization") != "" {
t.Error("Gitea token reached external host") t.Error("Gitea token reached external host")
} }
@@ -287,8 +290,8 @@ func TestInspectAssetsVerifiesExternalContentWithoutGiteaToken(t *testing.T) {
if mode == "conflicting" { if mode == "conflicting" {
want = AssetConflicting want = AssetConflicting
} }
if inspection.Assets[0].State != want || externalGets != 1 { if inspection.Assets[0].State != want || externalGets.Load() != 1 {
t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets) t.Fatalf("inspection=%#v downloads=%d", inspection, externalGets.Load())
} }
}) })
} }

View File

@@ -11,8 +11,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
type FailureKind string type FailureKind string
@@ -59,7 +57,7 @@ type Destination struct {
type Request struct { type Request struct {
Destination Destination Destination Destination
Version string Tag string
Commit string Commit string
Note string Note string
} }
@@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
} }
client := c.httpClient() client := c.httpClient()
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository) apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(request.Destination.Owner) + "/" + url.PathEscape(request.Destination.Repository)
tagURL := apiRoot + "/tags/" + url.PathEscape(request.Version) tagURL := apiRoot + "/tags/" + url.PathEscape(request.Tag)
var tag tagResponse var tag tagResponse
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil { if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
return Result{}, err return Result{}, err
} }
if tag.Commit.SHA != request.Commit { if tag.Commit.SHA != request.Commit {
return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Version, tag.Commit.SHA, request.Commit)) return Result{}, failure(FailureConflict, "verify hosted tag", 0, fmt.Errorf("tag %q targets %q, want %q", request.Tag, tag.Commit.SHA, request.Commit))
} }
releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Version) releaseURL := apiRoot + "/releases/tags/" + url.PathEscape(request.Tag)
existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL) existing, status, err := c.getRelease(ctx, client, request.Destination.Token, releaseURL)
if err == nil { if err == nil {
return acceptExisting(existing, request) return acceptExisting(existing, request)
@@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
title := noteTitle(request.Note) title := noteTitle(request.Note)
body, err := json.Marshal(createReleaseRequest{ body, err := json.Marshal(createReleaseRequest{
TagName: request.Version, TargetCommitish: request.Commit, TagName: request.Tag, TargetCommitish: request.Commit,
Name: title, Body: request.Note, Name: title, Body: request.Note,
}) })
if err != nil { if err != nil {
@@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client {
} }
func validateRequest(request Request) error { func validateRequest(request Request) error {
if !versioncontract.ValidStableVersion(request.Version) { if strings.TrimSpace(request.Tag) == "" {
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required")) return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
} }
if !validObjectID(request.Commit) { if !validObjectID(request.Commit) {
return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID")) return failure(FailureInvalid, "validate request", 0, errors.New("source commit must be a lowercase Git object ID"))
@@ -305,14 +303,14 @@ func parseBaseURL(raw string) (*url.URL, error) {
} }
func acceptExisting(release Release, request Request) (Result, error) { func acceptExisting(release Release, request Request) (Result, error) {
if release.TagName != request.Version || release.Body != request.Note || release.Draft || release.Prerelease { if release.TagName != request.Tag || release.Body != request.Note || release.Draft || release.Prerelease {
return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag")) return Result{}, failure(FailureConflict, "verify existing release", 0, errors.New("existing release does not match the committed note and stable tag"))
} }
return Result{Release: release}, nil return Result{Release: release}, nil
} }
func acceptCreated(release Release, request Request) (Result, error) { func acceptCreated(release Release, request Request) (Result, error) {
if release.ID <= 0 || release.TagName != request.Version || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease { if release.ID <= 0 || release.TagName != request.Tag || release.Body != request.Note || release.Title != noteTitle(request.Note) || release.Draft || release.Prerelease {
return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release")) return Result{}, failure(FailureMalformed, "verify created release", 0, errors.New("created release response does not match the requested stable release"))
} }
return Result{Release: release, Created: true}, nil return Result{Release: release, Created: true}, nil

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
) )
@@ -26,6 +27,49 @@ func TestEnsureReleaseFindsMatchingReleaseAndPreservesTitle(t *testing.T) {
} }
} }
func TestEnsureReleaseEscapesSlashContainingTag(t *testing.T) {
tag := "integrations/ckconfig/v1.2.3"
state := &fixtureState{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAuth(t, r)
switch r.URL.EscapedPath() {
case "/api/v1/repos/owner/project/tags/integrations%2Fckconfig%2Fv1.2.3":
assertRequest(t, r, http.MethodGet, r.URL.Path)
writeTag(t, w, testCommit)
case "/api/v1/repos/owner/project/releases/tags/integrations%2Fckconfig%2Fv1.2.3":
assertRequest(t, r, http.MethodGet, r.URL.Path)
http.NotFound(w, r)
case "/api/v1/repos/owner/project/releases":
assertRequest(t, r, http.MethodPost, r.URL.Path)
state.posts.Add(1)
var payload createReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
return
}
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
t.Errorf("create payload = %#v", payload)
return
}
if err := json.NewEncoder(w).Encode(releaseResponse{ID: 7, HTMLURL: "https://example.test/release", TagName: tag, TargetCommitish: testCommit, Name: payload.Name, Body: payload.Body}); err != nil {
t.Error(err)
return
}
default:
t.Errorf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
return
}
}))
defer server.Close()
request := testRequest(server.URL)
request.Tag = tag
result, err := testClient(server).EnsureRelease(context.Background(), request)
if err != nil || !result.Created || state.posts.Load() != 1 {
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts.Load(), err)
}
}
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) { func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
server, _ := newReleaseServer(t, releaseFixture{}, true) server, _ := newReleaseServer(t, releaseFixture{}, true)
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
@@ -43,7 +87,7 @@ func TestEnsureReleaseConflictDoesNotEditExistingPage(t *testing.T) {
if failureKind(t, err) != FailureConflict { if failureKind(t, err) != FailureConflict {
t.Fatalf("error = %v", err) t.Fatalf("error = %v", err)
} }
if got := state.posts; got != 0 { if got := state.posts.Load(); got != 0 {
t.Fatalf("release creates = %d, want 0", got) t.Fatalf("release creates = %d, want 0", got)
} }
} }
@@ -64,68 +108,68 @@ func TestEnsureReleaseUnauthorizedIsStructuredAndHidesToken(t *testing.T) {
} }
func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) { func TestEnsureReleaseMalformedTagResponseStopsBeforeCreate(t *testing.T) {
posts := 0 var posts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
posts++ posts.Add(1)
} }
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
_, _ = w.Write([]byte("{")) _, _ = w.Write([]byte("{"))
})) }))
defer server.Close() defer server.Close()
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
if failureKind(t, err) != FailureMalformed || posts != 0 { if failureKind(t, err) != FailureMalformed || posts.Load() != 0 {
t.Fatalf("error = %v, posts = %d", err, posts) t.Fatalf("error = %v, posts = %d", err, posts.Load())
} }
} }
func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) { func TestEnsureReleaseWrongHostedTagStopsBeforeCreate(t *testing.T) {
posts := 0 var posts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
posts++ posts.Add(1)
} }
_, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7) _, _ = fmt.Fprintf(w, `{"commit":{"sha":"%040d"}}`, 7)
})) }))
defer server.Close() defer server.Close()
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
if failureKind(t, err) != FailureConflict || posts != 0 { if failureKind(t, err) != FailureConflict || posts.Load() != 0 {
t.Fatalf("error = %v, posts = %d", err, posts) t.Fatalf("error = %v, posts = %d", err, posts.Load())
} }
} }
func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) { func TestEnsureReleaseMissingHostedTagStopsBeforeCreate(t *testing.T) {
posts := 0 var posts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3") assertRequest(t, r, http.MethodGet, "/api/v1/repos/owner/project/tags/v1.2.3")
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
posts++ posts.Add(1)
} }
http.NotFound(w, r) http.NotFound(w, r)
})) }))
defer server.Close() defer server.Close()
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
if failureKind(t, err) != FailureNotFound || posts != 0 { if failureKind(t, err) != FailureNotFound || posts.Load() != 0 {
t.Fatalf("error = %v, posts = %d", err, posts) t.Fatalf("error = %v, posts = %d", err, posts.Load())
} }
} }
func TestEnsureReleaseHonorsContextCancellation(t *testing.T) { func TestEnsureReleaseHonorsContextCancellation(t *testing.T) {
called := false var called atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called.Store(true) }))
defer server.Close() defer server.Close()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL)) _, err := testClient(server).EnsureRelease(ctx, testRequest(server.URL))
if !errors.Is(err, context.Canceled) || called { if !errors.Is(err, context.Canceled) || called.Load() {
t.Fatalf("error = %v, server called = %t", err, called) t.Fatalf("error = %v, server called = %t", err, called.Load())
} }
} }
func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) { func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T) {
created := false var created atomic.Bool
lookups := 0 var lookups atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAuth(t, r) assertAuth(t, r)
switch r.URL.Path { switch r.URL.Path {
@@ -134,8 +178,8 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
writeTag(t, w, testCommit) writeTag(t, w, testCommit)
case "/api/v1/repos/owner/project/releases/tags/v1.2.3": case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
assertRequest(t, r, http.MethodGet, r.URL.Path) assertRequest(t, r, http.MethodGet, r.URL.Path)
lookups++ lookups.Add(1)
if !created { if !created.Load() {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -144,23 +188,28 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
assertRequest(t, r, http.MethodPost, r.URL.Path) assertRequest(t, r, http.MethodPost, r.URL.Path)
var payload createReleaseRequest var payload createReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err) t.Error(err)
return
} }
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
t.Fatalf("create payload = %#v", payload) t.Errorf("create payload = %#v", payload)
return
} }
created = true created.Store(true)
hijacker, ok := w.(http.Hijacker) hijacker, ok := w.(http.Hijacker)
if !ok { if !ok {
t.Fatal("HTTP writer does not support hijacking") t.Error("HTTP writer does not support hijacking")
return
} }
connection, _, err := hijacker.Hijack() connection, _, err := hijacker.Hijack()
if err != nil { if err != nil {
t.Fatal(err) t.Error(err)
return
} }
_ = connection.Close() _ = connection.Close()
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
return
} }
})) }))
defer server.Close() defer server.Close()
@@ -168,13 +217,13 @@ func TestEnsureReleaseResolvesLostCreateAcknowledgmentWithOneLookup(t *testing.T
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Created || lookups != 2 { if result.Created || lookups.Load() != 2 {
t.Fatalf("result = %#v, release lookups = %d", result, lookups) t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
} }
} }
func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) { func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
lookups := 0 var lookups atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAuth(t, r) assertAuth(t, r)
switch r.URL.Path { switch r.URL.Path {
@@ -183,8 +232,8 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
writeTag(t, w, testCommit) writeTag(t, w, testCommit)
case "/api/v1/repos/owner/project/releases/tags/v1.2.3": case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
assertRequest(t, r, http.MethodGet, r.URL.Path) assertRequest(t, r, http.MethodGet, r.URL.Path)
lookups++ lookups.Add(1)
if lookups == 1 { if lookups.Load() == 1 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -193,7 +242,8 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
assertRequest(t, r, http.MethodPost, r.URL.Path) assertRequest(t, r, http.MethodPost, r.URL.Path)
w.WriteHeader(http.StatusConflict) w.WriteHeader(http.StatusConflict)
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
return
} }
})) }))
defer server.Close() defer server.Close()
@@ -201,13 +251,13 @@ func TestEnsureReleaseResolvesCreateConflictWithOneLookup(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Created || result.Release.Title != "Created by another request" || lookups != 2 { if result.Created || result.Release.Title != "Created by another request" || lookups.Load() != 2 {
t.Fatalf("result = %#v, release lookups = %d", result, lookups) t.Fatalf("result = %#v, release lookups = %d", result, lookups.Load())
} }
} }
func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) { func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *testing.T) {
lookups := 0 var lookups atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertAuth(t, r) assertAuth(t, r)
switch r.URL.Path { switch r.URL.Path {
@@ -216,8 +266,8 @@ func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *test
writeTag(t, w, testCommit) writeTag(t, w, testCommit)
case "/api/v1/repos/owner/project/releases/tags/v1.2.3": case "/api/v1/repos/owner/project/releases/tags/v1.2.3":
assertRequest(t, r, http.MethodGet, r.URL.Path) assertRequest(t, r, http.MethodGet, r.URL.Path)
lookups++ lookups.Add(1)
if lookups == 1 { if lookups.Load() == 1 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -226,13 +276,14 @@ func TestEnsureReleaseReportsUnknownWhenAmbiguousCreateCannotBeInspected(t *test
assertRequest(t, r, http.MethodPost, r.URL.Path) assertRequest(t, r, http.MethodPost, r.URL.Path)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
return
} }
})) }))
defer server.Close() defer server.Close()
_, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) _, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
if failureKind(t, err) != FailureUnknown || lookups != 2 { if failureKind(t, err) != FailureUnknown || lookups.Load() != 2 {
t.Fatalf("error = %v, release lookups = %d", err, lookups) t.Fatalf("error = %v, release lookups = %d", err, lookups.Load())
} }
} }
@@ -243,7 +294,7 @@ type releaseFixture struct {
pre bool pre bool
} }
type fixtureState struct{ posts int } type fixtureState struct{ posts atomic.Int64 }
func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) { func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool) (*httptest.Server, *fixtureState) {
t.Helper() t.Helper()
@@ -263,13 +314,15 @@ func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool)
writeRelease(t, w, existing) writeRelease(t, w, existing)
case "/api/v1/repos/owner/project/releases": case "/api/v1/repos/owner/project/releases":
assertRequest(t, r, http.MethodPost, r.URL.Path) assertRequest(t, r, http.MethodPost, r.URL.Path)
state.posts++ state.posts.Add(1)
var payload createReleaseRequest var payload createReleaseRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err) t.Error(err)
return
} }
if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease { if payload.TagName != "v1.2.3" || payload.TargetCommitish != testCommit || payload.Name != "Example v1.2.3" || payload.Body != testNote || payload.Draft || payload.Prerelease {
t.Fatalf("create payload = %#v", payload) t.Errorf("create payload = %#v", payload)
return
} }
if !createMissing { if !createMissing {
w.WriteHeader(http.StatusConflict) w.WriteHeader(http.StatusConflict)
@@ -277,10 +330,12 @@ func newReleaseServer(t *testing.T, existing releaseFixture, createMissing bool)
} }
writeRelease(t, w, releaseFixture{title: payload.Name}) writeRelease(t, w, releaseFixture{title: payload.Name})
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
return
} }
}) })
server := httptest.NewServer(handler) server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server, state return server, state
} }
@@ -307,7 +362,8 @@ func (fixture releaseFixture) bodyOrDefault() string {
func assertRequest(t *testing.T, request *http.Request, method, path string) { func assertRequest(t *testing.T, request *http.Request, method, path string) {
t.Helper() t.Helper()
if request.Method != method || request.URL.Path != path { if request.Method != method || request.URL.Path != path {
t.Fatalf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path) t.Errorf("request = %s %s, want %s %s", request.Method, request.URL.Path, method, path)
return
} }
assertAuth(t, request) assertAuth(t, request)
} }
@@ -315,7 +371,8 @@ func assertRequest(t *testing.T, request *http.Request, method, path string) {
func assertAuth(t *testing.T, request *http.Request) { func assertAuth(t *testing.T, request *http.Request) {
t.Helper() t.Helper()
if got := request.Header.Get("Authorization"); got != "token "+testToken { if got := request.Header.Get("Authorization"); got != "token "+testToken {
t.Fatalf("Authorization = %q", got) t.Errorf("Authorization = %q", got)
return
} }
} }
@@ -324,7 +381,7 @@ func testClient(server *httptest.Server) Client {
} }
func testRequest(baseURL string) Request { func testRequest(baseURL string) Request {
return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Version: "v1.2.3", Commit: testCommit, Note: testNote} return Request{Destination: Destination{BaseURL: baseURL, Owner: "owner", Repository: "project", Token: testToken}, Tag: "v1.2.3", Commit: testCommit, Note: testNote}
} }
func failureKind(t *testing.T, err error) FailureKind { func failureKind(t *testing.T, err error) FailureKind {
@@ -343,14 +400,14 @@ func contains(value, substring string) bool {
func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) { func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
for _, mode := range []string{"matching", "conflicting", "unavailable"} { for _, mode := range []string{"matching", "conflicting", "unavailable"} {
t.Run(mode, func(t *testing.T) { t.Run(mode, func(t *testing.T) {
posts, lookups := 0, 0 var posts, lookups atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"): case strings.Contains(r.URL.Path, "/tags/") && !strings.Contains(r.URL.Path, "/releases/"):
writeTag(t, w, testCommit) writeTag(t, w, testCommit)
case strings.Contains(r.URL.Path, "/releases/tags/"): case strings.Contains(r.URL.Path, "/releases/tags/"):
lookups++ lookups.Add(1)
if posts == 0 { if posts.Load() == 0 {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -364,7 +421,7 @@ func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
} }
writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body}) writeRelease(t, w, releaseFixture{title: "Example v1.2.3", body: body})
case r.Method == http.MethodPost: case r.Method == http.MethodPost:
posts++ posts.Add(1)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote}) json.NewEncoder(w).Encode(releaseResponse{ID: 10, TagName: "v1.2.3", Name: "inconsistent title", Body: testNote})
default: default:
@@ -373,8 +430,8 @@ func TestEnsureReleaseReconcilesInconsistentSuccessfulCreate(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL)) result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
if posts != 1 || lookups != 2 { if posts.Load() != 1 || lookups.Load() != 2 {
t.Fatalf("posts=%d lookups=%d", posts, lookups) t.Fatalf("posts=%d lookups=%d", posts.Load(), lookups.Load())
} }
switch mode { switch mode {
case "matching": case "matching":

View File

@@ -68,6 +68,17 @@ func VerifyLocalAssets(assets []ExpectedAsset) error {
// before the first write if any declared existing asset conflicts or cannot be // before the first write if any declared existing asset conflicts or cannot be
// verified, and never replaces an existing attachment. // verified, and never replaces an existing attachment.
func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) { func (c Client) UploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
return c.uploadMissing(ctx, destination, releaseID, inspection, true)
}
// UploadVerifiedMissing uploads missing assets whose local contents have already
// passed VerifyLocalAssets. It reuses that verification while retaining remote
// inspection, conflict handling, and upload reconciliation.
func (c Client) UploadVerifiedMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection) (UploadResult, error) {
return c.uploadMissing(ctx, destination, releaseID, inspection, false)
}
func (c Client) uploadMissing(ctx context.Context, destination Destination, releaseID int64, inspection Inspection, verifyLocal bool) (UploadResult, error) {
if releaseID <= 0 { if releaseID <= 0 {
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive")) return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
} }
@@ -127,6 +138,7 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
missing[observation.Expected.Filename] = observation.Expected missing[observation.Expected.Filename] = observation.Expected
} }
} }
if verifyLocal {
for _, item := range expected { for _, item := range expected {
if _, needsUpload := missing[item.Filename]; !needsUpload { if _, needsUpload := missing[item.Filename]; !needsUpload {
continue continue
@@ -135,6 +147,7 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
return result, err return result, err
} }
} }
}
baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository) baseAPI := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
client := c.httpClient() client := c.httpClient()

View File

@@ -15,6 +15,31 @@ import (
"testing" "testing"
) )
func TestUploadMissingVerifiesEveryMissingLocalFileBeforeWriting(t *testing.T) {
fixture := &uploadFixture{files: make(map[string][]byte)}
server := httptest.NewServer(fixture)
defer server.Close()
client := testClient(server)
destination := testDestination(server.URL)
assets := localAssets(t, []string{"alpha.bin", "beta.bin"})
inspection, err := client.InspectAssets(t.Context(), destination, 10, assets)
if err != nil {
t.Fatal(err)
}
contents := readExpectedFile(t, assets[1])
contents[0] ^= 0xff
if err := os.WriteFile(assets[1].Path, contents, 0o600); err != nil {
t.Fatal(err)
}
result, err := client.UploadMissing(t.Context(), destination, 10, inspection)
if err == nil || result.State != DeliveryPartial {
t.Fatalf("upload = %#v, error = %v; want local verification failure", result, err)
}
if posts := fixture.postNames(); len(posts) != 0 {
t.Fatalf("uploaded %v before rejecting local content", posts)
}
}
func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) { func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"} fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
server := httptest.NewServer(fixture) server := httptest.NewServer(fixture)

View File

@@ -36,6 +36,7 @@ type Target struct {
type Tool struct { type Tool struct {
host Target host Target
settings map[string]string settings map[string]string
settingsFile string
} }
var operationalSettings = []string{ var operationalSettings = []string{
@@ -55,8 +56,9 @@ func New(ctx context.Context, directory string) (*Tool, error) {
var host struct { var host struct {
OS string `json:"GOHOSTOS"` OS string `json:"GOHOSTOS"`
Arch string `json:"GOHOSTARCH"` Arch string `json:"GOHOSTARCH"`
SettingsFile string `json:"GOENV"`
} }
output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH") output, err := discover(ctx, directory, discoveryEnvironment(), "go", "env", "-json", "GOHOSTOS", "GOHOSTARCH", "GOENV")
if err != nil { if err != nil {
return nil, fmt.Errorf("identify Go host: %w", err) return nil, fmt.Errorf("identify Go host: %w", err)
} }
@@ -66,7 +68,7 @@ func New(ctx context.Context, directory string) (*Tool, error) {
if host.OS == "" || host.Arch == "" { if host.OS == "" || host.Arch == "" {
return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output))) return nil, fmt.Errorf("Go returned an incomplete host target: %s", strings.TrimSpace(string(output)))
} }
tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}} tool := &Tool{host: Target{OS: host.OS, Arch: host.Arch}, settingsFile: host.SettingsFile}
environment := discoveryEnvironment() environment := discoveryEnvironment()
environment["GOOS"] = tool.host.OS environment["GOOS"] = tool.host.OS

View File

@@ -301,6 +301,84 @@ func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
} }
} }
func TestOwnerReusesSettingsUntilInheritedEnvironmentChanges(t *testing.T) {
clearGoSettings(t)
t.Setenv("GOENV", "off")
owner := NewOwner(t.TempDir())
first, err := owner.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := owner.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatal("unchanged Go inputs caused settings discovery to be repeated")
}
cache := filepath.Join(t.TempDir(), "changed-cache")
t.Setenv("GOCACHE", cache)
third, err := owner.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
if third == first || third.settings["GOCACHE"] != cache {
t.Fatalf("changed inherited environment kept stale settings: tool=%p GOCACHE=%q", third, third.settings["GOCACHE"])
}
}
func TestOwnerAcceptsGoConfigurationWithoutReadableSnapshot(t *testing.T) {
clearGoSettings(t)
t.Setenv("GOENV", t.TempDir())
directory := t.TempDir()
if _, err := New(context.Background(), directory); err != nil {
t.Fatalf("underlying Go discovery rejected fixture: %v", err)
}
owner := NewOwner(directory)
first, err := owner.Acquire(context.Background())
if err != nil {
t.Fatalf("owner added a snapshot prerequisite: %v", err)
}
second, err := owner.Acquire(context.Background())
if err != nil || first == second {
t.Fatalf("unavailable snapshot should rediscover on demand: first=%p second=%p error=%v", first, second, err)
}
}
func TestOwnerRefreshesWhenPersistentSettingsChange(t *testing.T) {
clearGoSettings(t)
firstCache := filepath.Join(t.TempDir(), "first-cache")
secondCache := filepath.Join(t.TempDir(), "second-cache")
settingsFile := filepath.Join(t.TempDir(), "go-env")
writeSettings := func(cache string) {
t.Helper()
if err := os.WriteFile(settingsFile, []byte("GOCACHE="+cache+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
writeSettings(firstCache)
t.Setenv("GOENV", settingsFile)
owner := NewOwner(t.TempDir())
first, err := owner.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
if first.settings["GOCACHE"] != firstCache {
t.Fatalf("initial GOCACHE = %q, want %q", first.settings["GOCACHE"], firstCache)
}
writeSettings(secondCache)
second, err := owner.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
if second == first || second.settings["GOCACHE"] != secondCache {
t.Fatalf("changed persistent settings were not rediscovered: tool=%p GOCACHE=%q", second, second.settings["GOCACHE"])
}
}
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string { func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
t.Helper() t.Helper()
args := append([]string{"env", "-json"}, names...) args := append([]string{"env", "-json"}, names...)

122
internal/gotool/owner.go Normal file
View File

@@ -0,0 +1,122 @@
package gotool
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
)
// Owner lazily discovers and reuses one Go tool while its inherited
// environment and persistent settings file remain unchanged.
type Owner struct {
directory string
tool *Tool
env map[string]string
file string
contents []byte
fileFound bool
snapshotAvailable bool
}
// NewOwner creates an operation-local Go tool owner without running Go.
func NewOwner(directory string) *Owner {
return &Owner{directory: directory}
}
// Acquire returns the operation's Go tool, rediscovering settings when their
// process or persistent inputs have changed.
func (owner *Owner) Acquire(ctx context.Context) (*Tool, error) {
if owner.tool != nil && owner.snapshotAvailable {
changed, err := owner.inputsChanged()
if err == nil && !changed {
return owner.tool, nil
}
}
if err := owner.discover(ctx); err != nil {
return nil, err
}
return owner.tool, nil
}
func (owner *Owner) discover(ctx context.Context) error {
environment := inheritedEnvironment()
tool, err := New(ctx, owner.directory)
if err != nil {
return err
}
settingsFile, snapshotErr := resolveSettingsFile(owner.directory, tool.settingsFile)
var contents []byte
var found bool
if snapshotErr == nil {
contents, found, snapshotErr = readSettingsFile(settingsFile)
}
owner.tool = tool
owner.env = environment
owner.file = settingsFile
owner.contents = contents
owner.fileFound = found
owner.snapshotAvailable = snapshotErr == nil
return nil
}
func (owner *Owner) inputsChanged() (bool, error) {
if !sameEnvironment(owner.env, inheritedEnvironment()) {
return true, nil
}
contents, found, err := readSettingsFile(owner.file)
if err != nil {
return true, err
}
return found != owner.fileFound || !bytes.Equal(contents, owner.contents), nil
}
func inheritedEnvironment() map[string]string {
values := make(map[string]string)
for _, entry := range os.Environ() {
key, value, ok := strings.Cut(entry, "=")
if ok {
values[key] = value
}
}
return values
}
func sameEnvironment(left, right map[string]string) bool {
if len(left) != len(right) {
return false
}
for key, value := range left {
other, ok := right[key]
if !ok || other != value {
return false
}
}
return true
}
func resolveSettingsFile(directory, filename string) (string, error) {
if filename == "" || filename == "off" {
return "", nil
}
if !filepath.IsAbs(filename) {
filename = filepath.Join(directory, filename)
}
return filepath.Abs(filename)
}
func readSettingsFile(filename string) ([]byte, bool, error) {
if filename == "" {
return nil, false, nil
}
contents, err := os.ReadFile(filename)
if errors.Is(err, os.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return contents, true, nil
}

View File

@@ -16,28 +16,34 @@ import (
type TaggedCheckoutRequest struct { type TaggedCheckoutRequest struct {
RepositoryDir string RepositoryDir string
Version string Tag string
Remote string Remote string
ExpectedCommit string ExpectedCommit string
VerifyOnly bool
} }
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
if !versioncontract.ValidStableVersion(request.Version) { lastSlash := strings.LastIndex(request.Tag, "/")
return fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version) version := request.Tag[lastSlash+1:]
if !versioncontract.ValidStableVersion(version) {
return fmt.Errorf("%w: tag %q must end in a stable semantic version", candidate.ErrInvalidVersion, request.Tag)
} }
if strings.TrimSpace(request.ExpectedCommit) == "" { if strings.TrimSpace(request.ExpectedCommit) == "" {
return errors.New("expected commit is required") return errors.New("expected commit is required")
} }
if request.VerifyOnly {
return git.VerifyTaggedCheckout(ctx, request.RepositoryDir, request.Tag, request.ExpectedCommit)
}
remote := request.Remote remote := request.Remote
if remote == "" { if remote == "" {
remote = "origin" remote = "origin"
} }
return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Version, remote, request.ExpectedCommit) return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Tag, remote, request.ExpectedCommit)
} }
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error { func PrepareBuildDependencies(ctx context.Context, moduleDir string) error {
if strings.TrimSpace(repositoryDir) == "" { if strings.TrimSpace(moduleDir) == "" {
return errors.New("repository directory is required") return errors.New("module directory is required")
} }
return gotool.PrepareBuildDependencies(ctx, repositoryDir) return gotool.PrepareBuildDependencies(ctx, moduleDir)
} }

View File

@@ -10,6 +10,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/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation" "gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
@@ -42,17 +43,22 @@ var ErrUnexpectedPushURL = errors.New("unexpected publication push URL")
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Product string Product string
Version string Version string
Remote string Remote string
ExpectedPushURL string ExpectedPushURL string
Validation validation.Settings Validation validation.Settings
AdditionalChecks func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate, *gotool.Owner) error
Output io.Writer Output io.Writer
} }
type Result struct { type Result struct {
RepositoryDir string
ModuleDir string
ModulePath string
Tag string
Version string Version string
CandidateCommit string CandidateCommit string
Endpoint string Endpoint string
@@ -76,6 +82,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
} }
checks := candidate.Request{ checks := candidate.Request{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
ModuleDir: request.ModuleDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: request.Validation, Validation: request.Validation,
@@ -83,10 +90,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
Output: request.Output, Output: request.Output,
} }
captured, err := candidate.Capture(ctx, checks) captured, err := candidate.Capture(ctx, checks)
result.RepositoryDir = captured.RepositoryDir
result.ModuleDir = captured.ModuleDir
result.ModulePath = captured.ModulePath
result.Tag = captured.Tag
result.CandidateCommit = captured.Commit
if err != nil { if err != nil {
return result, fmt.Errorf("capture release candidate: %w", err) return result, fmt.Errorf("capture release candidate: %w", err)
} }
result.CandidateCommit = captured.Commit
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName) endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
if err != nil { if err != nil {
@@ -99,7 +110,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err) return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
} }
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
if isRemoteConflict(err) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
@@ -110,12 +121,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
} }
result.Phase = Validation result.Phase = Validation
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
if err := candidate.Validate(ctx, checks, captured); err != nil { if err := candidate.Validate(ctx, checks, captured); err != nil {
return result, err return result, err
} }
if request.AfterValidate != nil { if request.AfterValidate != nil {
if err := request.AfterValidate(ctx, captured); err != nil { callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner)
return result, fmt.Errorf("after candidate validation: %w", err) if callbackErr != nil {
return result, fmt.Errorf("after candidate validation: %w", callbackErr)
} }
} }
if err := candidate.Recheck(ctx, captured, ""); err != nil { if err := candidate.Recheck(ctx, captured, ""); err != nil {
@@ -124,7 +137,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-publication endpoint guard: %w", err) return result, fmt.Errorf("pre-publication endpoint guard: %w", err)
} }
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
if isRemoteConflict(err) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
@@ -133,7 +146,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = TagCreation result.Phase = TagCreation
result.State = NotPublished result.State = NotPublished
tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Version, captured.Commit, []byte(captured.Note)) tagObject, err := git.CreateAnnotatedTag(ctx, captured.RepositoryDir, captured.Tag, captured.Commit, []byte(captured.Note))
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -144,7 +157,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-push endpoint guard: %w", err) return result, fmt.Errorf("pre-push endpoint guard: %w", err)
} }
if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Version, captured.Commit); err != nil { if _, err := endpoint.RequireCandidate(ctx, captured.RepositoryDir, captured.Tag, captured.Commit); err != nil {
if isRemoteConflict(err) { if isRemoteConflict(err) {
result.State = Conflict result.State = Conflict
} }
@@ -153,7 +166,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
result.Phase = Push result.Phase = Push
result.State = Unknown result.State = Unknown
if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Version, tagObject); err != nil { if err := endpoint.PushTag(ctx, captured.RepositoryDir, captured.Tag, tagObject); err != nil {
return reconcilePush(endpoint, captured, tagObject, result, err) return reconcilePush(endpoint, captured, tagObject, result, err)
} }
result.Phase = Complete result.Phase = Complete
@@ -165,7 +178,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
result.Phase = Reconciliation result.Phase = Reconciliation
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout) recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
defer cancel() defer cancel()
remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Version) remoteTag, queryErr := endpoint.QueryTag(recoveryCtx, captured.RepositoryDir, captured.Tag)
if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict { if queryErr != nil || remoteTag.State == git.RefUnreadable || remoteTag.State == git.RefConflict {
result.State = Unknown result.State = Unknown
if queryErr == nil { if queryErr == nil {
@@ -175,11 +188,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
queryErr = git.ErrRemoteStateUnreadable queryErr = git.ErrRemoteStateUnreadable
} }
} }
return result, fmt.Errorf("push annotated tag %s failed; authoritative reconciliation failed (%w): %w", captured.Version, queryErr, pushErr) return result, fmt.Errorf("push annotated tag %s to %s failed; authoritative reconciliation failed (%w): %w", captured.Tag, endpoint.PushURL, queryErr, pushErr)
} }
if remoteTag.State == git.RefAbsent { if remoteTag.State == git.RefAbsent {
result.State = NotPublished result.State = NotPublished
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent: %w", captured.Version, pushErr) return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag is absent at %s: %w", captured.Tag, endpoint.PushURL, pushErr)
} }
if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) { if remoteTag.State == git.RefPresent && remoteTag.Object == tagObject && (remoteTag.Peeled == "" || remoteTag.Peeled == captured.Commit) {
result.Phase = Complete result.Phase = Complete
@@ -187,7 +200,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
return result, nil return result, nil
} }
result.State = Conflict result.State = Conflict
return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s: %w", captured.Version, remoteTag.Object, tagObject, pushErr) return result, fmt.Errorf("push annotated tag %s failed and the authoritative tag identifies object %s, not %s at %s: %w", captured.Tag, remoteTag.Object, tagObject, endpoint.PushURL, pushErr)
} }
func isRemoteConflict(err error) bool { func isRemoteConflict(err error) bool {

View File

@@ -0,0 +1,226 @@
// Package releaseidentity resolves and derives the identity of one Go module
// release within a repository.
package releaseidentity
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
)
// Selection identifies the normalized repository and selected module
// directories. ModuleDir is an absolute path; ModuleSubdir is its canonical
// repository-relative slash path, with "." representing the root.
type Selection struct {
RepositoryDir string
ModuleDir string
ModuleSubdir string
}
// Identity is the release identity derived from one selected module.
type Identity struct {
Selection
ModulePath string
Version string
Tag string
NotePath string
}
// ResolveSelection normalizes a repository-relative module selection and
// confirms its final filesystem location remains inside the repository.
func ResolveSelection(repositoryDir, moduleDir string) (Selection, error) {
if moduleDir == "" {
moduleDir = "."
}
if filepath.IsAbs(moduleDir) {
return Selection{}, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
}
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return Selection{}, fmt.Errorf("resolve repository directory: %w", err)
}
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
if err != nil {
return Selection{}, fmt.Errorf("resolve repository directory %q: %w", repositoryDir, err)
}
// Preserve components until symlinks are followed, so link/.. uses the
// linked directory's parent just as committed path traversal does.
moduleAbs, err := filepath.EvalSymlinks(repositoryAbs + string(filepath.Separator) + moduleDir)
if err != nil {
return Selection{}, fmt.Errorf("resolve module directory %q: %w", moduleDir, err)
}
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
if err != nil {
return Selection{}, fmt.Errorf("make module directory relative to repository: %w", err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return Selection{}, fmt.Errorf("module directory %q resolves outside repository %q", moduleDir, repositoryDir)
}
if relative == "." {
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: "."}, nil
}
return Selection{RepositoryDir: repositoryAbs, ModuleDir: moduleAbs, ModuleSubdir: filepath.ToSlash(relative)}, nil
}
// FromCommitted derives identity from the selected module declaration at a
// captured commit. The selected module must contain its own go.mod.
func FromCommitted(ctx context.Context, repositoryDir, moduleDir, version, commit string) (Identity, error) {
resolver, err := committedPaths(repositoryDir, commit)
if err != nil {
return Identity{Version: version}, err
}
selection, err := selectCommittedDirectory(ctx, resolver, moduleDir)
if err != nil {
return Identity{Selection: selection, Version: version}, err
}
return fromCommittedSelection(ctx, selection, version, commit, resolver)
}
// ResolveCommittedSelection resolves directory selection without reading the
// module declaration or requiring the directory to be materialized.
func ResolveCommittedSelection(ctx context.Context, repositoryDir, moduleDir, commit string) (Selection, error) {
resolver, err := committedPaths(repositoryDir, commit)
if err != nil {
return Selection{}, err
}
return selectCommittedDirectory(ctx, resolver, moduleDir)
}
func committedPaths(repositoryDir, commit string) (git.PathResolver, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return git.PathResolver{}, err
}
repositoryAbs, err = filepath.EvalSymlinks(repositoryAbs)
if err != nil {
return git.PathResolver{}, err
}
return git.CommittedPaths(repositoryAbs, commit), nil
}
func selectCommittedDirectory(ctx context.Context, resolver git.PathResolver, moduleDir string) (Selection, error) {
selection := Selection{RepositoryDir: resolver.RepositoryDir}
if moduleDir == "" {
moduleDir = "."
}
if filepath.IsAbs(moduleDir) {
return selection, fmt.Errorf("module directory must be repository-relative: %q", moduleDir)
}
directory, err := resolver.ResolveDestination(ctx, "go.mod", filepath.ToSlash(moduleDir))
if err != nil {
return selection, fmt.Errorf("resolve committed module directory: %w", err)
}
if directory.Kind != git.DestinationDirectory {
return selection, fmt.Errorf("selected committed module directory %q resolves to %s at %q", moduleDir, directory.Kind, directory.Path)
}
selection.ModuleDir = filepath.Join(resolver.RepositoryDir, filepath.FromSlash(directory.Path))
selection.ModuleSubdir = directory.Path
return selection, nil
}
// FromCommittedSelection derives identity from a previously resolved module
// selection and the selected module declaration at a captured commit.
func FromCommittedSelection(ctx context.Context, selection Selection, version, commit string) (Identity, error) {
return fromCommittedSelection(ctx, selection, version, commit, git.CommittedPaths(selection.RepositoryDir, commit))
}
func fromCommittedSelection(ctx context.Context, selection Selection, version, commit string, resolver git.PathResolver) (Identity, error) {
identity := Identity{Selection: selection, Version: version}
if err := ctx.Err(); err != nil {
return identity, fmt.Errorf("resolve committed release identity: %w", err)
}
declarationPath, err := resolver.ResolveDestination(ctx, "go.mod", moduleFilePath(selection.ModuleSubdir))
if err != nil {
return identity, fmt.Errorf("resolve selected module declaration at %s: %w", commit, err)
}
if declarationPath.Kind != git.DestinationEntry {
return identity, fmt.Errorf("selected module declaration resolves to %s at %q", declarationPath.Kind, declarationPath.Path)
}
entry, _, err := resolver.Lookup(ctx, declarationPath.Path)
if err != nil {
return identity, err
}
declaration, err := git.ReadBlob(ctx, selection.RepositoryDir, entry.ObjectID)
if err != nil {
return identity, fmt.Errorf("read selected module declaration at %s: %w", commit, err)
}
return Derive(selection, declaration, version)
}
// FromWorkingTree derives identity from the selected module declaration in the
// working tree. The selected module must contain its own go.mod.
func FromWorkingTree(ctx context.Context, repositoryDir, moduleDir, version string) (Identity, error) {
selection, err := ResolveSelection(repositoryDir, moduleDir)
identity := Identity{Selection: selection, Version: version}
if err != nil {
return identity, err
}
if err := ctx.Err(); err != nil {
return identity, fmt.Errorf("resolve working-tree release identity: %w", err)
}
declarationPath := filepath.Join(selection.ModuleDir, "go.mod")
declaration, err := os.ReadFile(declarationPath)
if err != nil {
return identity, fmt.Errorf("read selected module declaration %q: %w", declarationPath, err)
}
return Derive(selection, declaration, version)
}
// Derive applies Go module path-major rules and derives the standard full tag
// and module-local release-note path from a declaration and selection.
func Derive(selection Selection, declaration []byte, version string) (Identity, error) {
identity := Identity{Selection: selection, Version: version}
if selection.RepositoryDir == "" || selection.ModuleDir == "" || selection.ModuleSubdir == "" {
return identity, fmt.Errorf("incomplete module selection")
}
modulePath := modfile.ModulePath(declaration)
if modulePath == "" {
return identity, fmt.Errorf("selected module declaration has no valid module directive")
}
identity.ModulePath = modulePath
if err := module.CheckImportPath(modulePath); err != nil {
return identity, fmt.Errorf("invalid module path %q: %w", modulePath, err)
}
_, pathMajor, ok := module.SplitPathVersion(modulePath)
if !ok {
return identity, fmt.Errorf("module path %q has an invalid major-version suffix", modulePath)
}
if !versioncontract.ValidStableVersion(version) {
return identity, fmt.Errorf("invalid stable release version %q", version)
}
if err := module.CheckPathMajor(version, pathMajor); err != nil {
return identity, fmt.Errorf("version %q is incompatible with module path %q: %w", version, modulePath, err)
}
tagPrefix := selection.ModuleSubdir
if strings.HasPrefix(pathMajor, "/") && tagPrefix != "." && path.Base(tagPrefix) == strings.TrimPrefix(pathMajor, "/") {
tagPrefix = path.Dir(tagPrefix)
}
tag := version
if tagPrefix != "." {
tag = path.Join(tagPrefix, version)
}
notePath := path.Join(selection.ModuleSubdir, "docs", "releases", version+".md")
return Identity{
Selection: selection,
ModulePath: modulePath,
Version: version,
Tag: tag,
NotePath: notePath,
}, nil
}
func moduleFilePath(moduleSubdir string) string {
if moduleSubdir == "." {
return "go.mod"
}
return path.Join(moduleSubdir, "go.mod")
}

View File

@@ -0,0 +1,262 @@
package releaseidentity
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestDeriveUsesModuleDeclarationAndPhysicalLayout(t *testing.T) {
tests := []struct {
name string
subdir string
modulePath string
version string
wantTag string
wantNote string
}{
{name: "root v1", subdir: ".", modulePath: "example.com/root", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"},
{name: "local main module", subdir: ".", modulePath: "example", version: "v1.2.3", wantTag: "v1.2.3", wantNote: "docs/releases/v1.2.3.md"},
{name: "local major module", subdir: "apps/local/v2", modulePath: "example/v2", version: "v2.3.4", wantTag: "apps/local/v2.3.4", wantNote: "apps/local/v2/docs/releases/v2.3.4.md"},
{name: "nested", subdir: "integrations/check", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/check/v1.2.3", wantNote: "integrations/check/docs/releases/v1.2.3.md"},
{name: "root major path", subdir: ".", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "docs/releases/v2.3.4.md"},
{name: "root physical major directory", subdir: "v2", modulePath: "example.com/root/v2", version: "v2.3.4", wantTag: "v2.3.4", wantNote: "v2/docs/releases/v2.3.4.md"},
{name: "physical major directory", subdir: "integrations/check/v2", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/v2/docs/releases/v2.3.4.md"},
{name: "logical major without physical directory", subdir: "integrations/check", modulePath: "example.com/check/v2", version: "v2.3.4", wantTag: "integrations/check/v2.3.4", wantNote: "integrations/check/docs/releases/v2.3.4.md"},
{name: "ordinary directory named v2", subdir: "integrations/v2", modulePath: "example.com/check", version: "v1.2.3", wantTag: "integrations/v2/v1.2.3", wantNote: "integrations/v2/docs/releases/v1.2.3.md"},
{name: "gopkg special major", subdir: "integrations/example.v2", modulePath: "gopkg.in/example.v2", version: "v2.3.4", wantTag: "integrations/example.v2/v2.3.4", wantNote: "integrations/example.v2/docs/releases/v2.3.4.md"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
selection := Selection{RepositoryDir: "/repo", ModuleDir: filepath.Join("/repo", filepath.FromSlash(tt.subdir)), ModuleSubdir: tt.subdir}
if tt.subdir == "." {
selection.ModuleDir = "/repo"
}
got, err := Derive(selection, []byte("module "+tt.modulePath+"\n"), tt.version)
if err != nil {
t.Fatal(err)
}
if got.Tag != tt.wantTag || got.NotePath != tt.wantNote {
t.Fatalf("identity tag/note = %q, %q; want %q, %q", got.Tag, got.NotePath, tt.wantTag, tt.wantNote)
}
})
}
}
func TestDeriveRejectsInvalidModuleAndVersionIdentity(t *testing.T) {
selection := Selection{RepositoryDir: "/repo", ModuleDir: "/repo", ModuleSubdir: "."}
for _, tt := range []struct {
name, declaration, version string
}{
{name: "missing module directive", declaration: "go 1.26\n", version: "v1.2.3"},
{name: "malformed module path", declaration: "module example.com/../bad\n", version: "v1.2.3"},
{name: "incompatible major", declaration: "module example.com/service/v2\n", version: "v1.2.3"},
{name: "incompatible local major", declaration: "module example/v2\n", version: "v1.2.3"},
{name: "invalid local module path", declaration: "module example/../bad\n", version: "v1.2.3"},
{name: "unstable version", declaration: "module example.com/service\n", version: "v1.2.3-rc.1"},
} {
t.Run(tt.name, func(t *testing.T) {
if _, err := Derive(selection, []byte(tt.declaration), tt.version); err == nil {
t.Fatal("Derive() succeeded for invalid identity")
}
})
}
}
func TestResolveSelectionNormalizesAndRejectsEscape(t *testing.T) {
repository := t.TempDir()
moduleDir := filepath.Join(repository, "integrations", "one")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
selection, err := ResolveSelection(repository, "integrations/one/../one")
if err != nil {
t.Fatal(err)
}
if selection.ModuleSubdir != "integrations/one" || selection.ModuleDir != moduleDir {
t.Fatalf("normalized selection = %#v", selection)
}
root, err := ResolveSelection(repository, "")
if err != nil || root.ModuleSubdir != "." || root.ModuleDir != repository {
t.Fatalf("default root selection = %#v, error = %v", root, err)
}
if _, err := ResolveSelection(repository, "../outside"); err == nil {
t.Fatal("ResolveSelection() accepted a path outside the repository")
}
if _, err := ResolveSelection(repository, moduleDir); err == nil {
t.Fatal("ResolveSelection() accepted an absolute module path")
}
outside := t.TempDir()
link := filepath.Join(repository, "linked")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
if _, err := ResolveSelection(repository, "linked"); err == nil {
t.Fatal("ResolveSelection() accepted a symlink that resolves outside the repository")
}
}
func TestCommittedAndWorkingTreeDeclarationsUseTheirOwnSources(t *testing.T) {
repository := t.TempDir()
gitIdentityRun(t, repository, "init", "-q")
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
moduleDir := filepath.Join(repository, "nested", "mod")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
declaration := filepath.Join(moduleDir, "go.mod")
if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil {
t.Fatal(err)
}
gitIdentityRun(t, repository, "add", ".")
gitIdentityRun(t, repository, "commit", "-qm", "add module")
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil {
t.Fatal(err)
}
committed, err := FromCommitted(context.Background(), repository, "nested/mod", "v1.2.3", commit)
if err != nil {
t.Fatal(err)
}
working, err := FromWorkingTree(context.Background(), repository, "nested/mod", "v1.2.3")
if err != nil {
t.Fatal(err)
}
if committed.ModulePath != "example.com/committed" || committed.Tag != "nested/mod/v1.2.3" {
t.Fatalf("committed identity = %#v", committed)
}
if working.ModulePath != "example.com/working" || working.Tag != "nested/mod/v1.2.3" {
t.Fatalf("working identity = %#v", working)
}
}
func TestCommittedIdentityFollowsCapturedDeclarationAndDirectoryLinks(t *testing.T) {
repository := t.TempDir()
gitIdentityRun(t, repository, "init", "-q")
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
moduleDir := filepath.Join(repository, "nested")
if err := os.Mkdir(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
declaration := filepath.Join(repository, "module.txt")
if err := os.WriteFile(declaration, []byte("module example.com/committed\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../module.txt", filepath.Join(moduleDir, "go.mod")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("nested", filepath.Join(repository, "selected")); err != nil {
t.Fatal(err)
}
gitIdentityRun(t, repository, "add", ".")
gitIdentityRun(t, repository, "commit", "-qm", "Add linked declaration")
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
if err := os.WriteFile(declaration, []byte("module example.com/working\n"), 0o644); err != nil {
t.Fatal(err)
}
committed, err := FromCommitted(t.Context(), repository, "selected", "v1.2.3", commit)
if err != nil || committed.ModulePath != "example.com/committed" || committed.ModuleDir != moduleDir || committed.Tag != "nested/v1.2.3" {
t.Fatalf("committed linked identity = %#v, error = %v", committed, err)
}
working, err := FromWorkingTree(t.Context(), repository, "selected", "v1.2.3")
if err != nil || working.ModulePath != "example.com/working" || working.ModuleDir != moduleDir {
t.Fatalf("working linked identity = %#v, error = %v", working, err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := FromCommitted(ctx, repository, "selected", "v1.2.3", commit); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled committed identity error = %v", err)
}
}
func TestCommittedIdentityPreservesReferenceFailures(t *testing.T) {
for _, mode := range []string{"cycle", "outside", "missing", "unavailable blob"} {
t.Run(mode, func(t *testing.T) {
repository := t.TempDir()
gitIdentityRun(t, repository, "init", "-q")
gitIdentityRun(t, repository, "config", "user.name", "ReleaseKit test")
gitIdentityRun(t, repository, "config", "user.email", "releasekit@example.invalid")
target, wantCause := "go.mod", "symlink-cycle"
switch mode {
case "outside":
target, wantCause = "../module.txt", "outside-checkout"
case "missing":
target, wantCause = "missing.mod", "missing"
case "unavailable blob":
target, wantCause = "module.txt", "read selected module declaration"
if err := os.WriteFile(filepath.Join(repository, target), []byte("module example.com/linked\n"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(target, filepath.Join(repository, "go.mod")); err != nil {
t.Fatal(err)
}
gitIdentityRun(t, repository, "add", ".")
gitIdentityRun(t, repository, "commit", "-qm", "Add reference fixture")
commit := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD"))
if mode == "unavailable blob" {
object := strings.TrimSpace(gitIdentityOutput(t, repository, "rev-parse", "HEAD:module.txt"))
if err := os.Remove(filepath.Join(repository, ".git", "objects", object[:2], object[2:])); err != nil {
t.Fatal(err)
}
}
identity, err := FromCommitted(t.Context(), repository, ".", "v1.2.3", commit)
if err == nil || !strings.Contains(err.Error(), wantCause) || identity.RepositoryDir != repository || identity.ModuleDir != repository {
t.Fatalf("identity = %#v, error = %v; want %s", identity, err, wantCause)
}
})
}
}
func TestSelectedNestedModuleDoesNotRequireRootDeclaration(t *testing.T) {
repository := t.TempDir()
moduleDir := filepath.Join(repository, "nested")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte("module example.com/nested\n"), 0o644); err != nil {
t.Fatal(err)
}
identity, err := FromWorkingTree(context.Background(), repository, "nested", "v1.2.3")
if err != nil || identity.ModulePath != "example.com/nested" {
t.Fatalf("nested identity = %#v, error = %v", identity, err)
}
if _, err := FromWorkingTree(context.Background(), repository, ".", "v1.2.3"); err == nil {
t.Fatal("root selection silently fell back to a nested module declaration")
}
}
func TestReadersPreserveCancellation(t *testing.T) {
repository := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := FromWorkingTree(ctx, repository, ".", "v1.2.3"); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("working-tree error = %v, want cancellation", err)
}
}
func gitIdentityRun(t *testing.T, dir string, args ...string) {
t.Helper()
command := exec.Command("git", args...)
command.Dir = dir
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}
func gitIdentityOutput(t *testing.T, dir string, args ...string) string {
t.Helper()
command := exec.Command("git", args...)
command.Dir = dir
output, err := command.Output()
if err != nil {
t.Fatalf("git %v: %v", args, err)
}
return string(output)
}

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 && !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
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 != nil { if err != nil {
os.RemoveAll(directory) os.RemoveAll(directory)
return "", err return "", err
} }
if outside {
return directory, nil return directory, nil
} }
if err := os.RemoveAll(directory); err != nil {
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
}
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
if err != nil {
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
}
created = directory
directory, err = filepath.Abs(created)
if err != nil {
os.RemoveAll(created)
return "", err
}
directory, err = filepath.EvalSymlinks(directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
outside, err = isOutside(repository, directory)
if err != nil {
os.RemoveAll(directory)
return "", err
}
if !outside {
os.RemoveAll(directory)
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
}
return directory, nil
}
func isOutside(repository, directory string) (bool, error) {
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
return true, nil
}
relative, err := filepath.Rel(repository, directory)
if err != nil {
return false, err
}
return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)), nil
}

View File

@@ -29,16 +29,28 @@ func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
} }
} }
func TestOutsideRejectsAndCleansStorageInsideWorktree(t *testing.T) { 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)
} }
} }

View File

@@ -25,6 +25,8 @@ const (
FileSize Check = "file-size" FileSize Check = "file-size"
// MarkdownLinks identifies missing committed Markdown destinations or anchors. // MarkdownLinks identifies missing committed Markdown destinations or anchors.
MarkdownLinks Check = "markdown-links" MarkdownLinks Check = "markdown-links"
// SharedFiles identifies a requested shared file that is unavailable.
SharedFiles Check = "shared-files"
) )
// Finding describes a validation observation at an optional source location. // Finding describes a validation observation at an optional source location.

View File

@@ -1,22 +1,31 @@
package validation package validation
import ( import (
"context"
"fmt"
"path" "path"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
) )
const defaultFileSizeLimit int64 = 1_000_000 const defaultFileSizeLimit int64 = 1_000_000
func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error { func checkFileSize(ctx context.Context, tree *CandidateTree, settings *FileSizeSettings) error {
entries := make([]git.TreeEntry, 0, len(tree.inputEntries))
for _, entry := range tree.inputEntries {
if entry.Type == "blob" && !excludedByPath(entry.Path, settings.Exclusions) {
entries = append(entries, entry)
}
}
if err := git.PopulateBlobSizes(ctx, tree.repositoryDir, entries); err != nil {
return &Failure{Check: FileSize, Cause: fmt.Errorf("inspect selected committed file sizes: %w", err)}
}
limit := defaultFileSizeLimit limit := defaultFileSizeLimit
if settings.LimitBytes != nil { if settings.LimitBytes != nil {
limit = *settings.LimitBytes limit = *settings.LimitBytes
} }
exclusions := normalizedExclusions(settings.Exclusions) for _, entry := range entries {
for _, entry := range tree.Entries() {
if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, exclusions) {
continue
}
if entry.Size > limit { if entry.Size > limit {
return &Failure{ return &Failure{
Check: FileSize, Check: FileSize,
@@ -31,6 +40,23 @@ func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
return nil return nil
} }
func fileSizeSettingsForModule(settings *FileSizeSettings, modulePath string) *FileSizeSettings {
if settings == nil {
return nil
}
selected := *settings
moduleRelative := make([]string, len(settings.Exclusions))
for index, exclusion := range settings.Exclusions {
if modulePath == "." || path.IsAbs(exclusion) || exclusion == "" {
moduleRelative[index] = exclusion
continue
}
moduleRelative[index] = modulePath + "/" + exclusion
}
selected.Exclusions = normalizedExclusions(moduleRelative)
return &selected
}
func normalizedExclusions(exclusions []string) []string { func normalizedExclusions(exclusions []string) []string {
result := make([]string, 0, len(exclusions)) result := make([]string, 0, len(exclusions))
for _, exclusion := range exclusions { for _, exclusion := range exclusions {

View File

@@ -29,10 +29,11 @@ func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{{ entries := []git.TreeEntry{{
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true, Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
}}} }}
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit}) tree := &CandidateTree{entries: entries, inputEntries: entries}
err := checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: test.limit})
if !test.wantError { if !test.wantError {
if err != nil { if err != nil {
t.Fatalf("checkFileSize() error = %v, want nil", err) t.Fatalf("checkFileSize() error = %v, want nil", err)
@@ -104,10 +105,11 @@ func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{ settings := fileSizeSettingsForModule(&FileSizeSettings{
LimitBytes: int64Pointer(10), LimitBytes: int64Pointer(10),
Exclusions: test.exclusions, Exclusions: test.exclusions,
}) }, ".")
err := checkFileSize(t.Context(), &CandidateTree{entries: test.entries, inputEntries: test.entries}, settings)
failure, ok := err.(*Failure) failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath { if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath {
t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath) t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath)
@@ -137,12 +139,12 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
} }
cleanupCandidateTree(t, tree) cleanupCandidateTree(t, tree)
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}}) err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}})
if err != nil { if err != nil {
t.Fatalf("checkFileSize() error = %v, want small binary and exact-limit blob to pass", err) t.Fatalf("checkFileSize() error = %v, want small binary and exact-limit blob to pass", err)
} }
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"small.bin", "boundary.bin"}}) err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"small.bin", "boundary.bin"}})
failure, ok := err.(*Failure) failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "link" { if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "link" {
t.Fatalf("checkFileSize() error = %v, want symlink blob to exceed the limit", err) t.Fatalf("checkFileSize() error = %v, want symlink blob to exceed the limit", err)
@@ -151,7 +153,7 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
t.Fatalf("symlink measurement = %#v, want link-text size %d", failure.Measurement, len("small.bin")) t.Fatalf("symlink measurement = %#v, want link-text size %d", failure.Measurement, len("small.bin"))
} }
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}}) err = checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}})
if err != nil { if err != nil {
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err) t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
} }
@@ -172,7 +174,7 @@ func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
cleanupCandidateTree(t, tree) cleanupCandidateTree(t, tree)
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil { if err := checkFileSize(t.Context(), tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err) t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
} }
} }

View File

@@ -89,6 +89,7 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
tree := &CandidateTree{ tree := &CandidateTree{
repositoryDir: t.TempDir(), repositoryDir: t.TempDir(),
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}}, entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
inputEntries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
byPath: map[string]int{"missing.go": 0}, byPath: map[string]int{"missing.go": 0},
} }
cleanupCandidateTree(t, tree) cleanupCandidateTree(t, tree)

View File

@@ -13,7 +13,7 @@ import (
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error { func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
var findings []Finding var findings []Finding
replacementsByObjectID := make(map[string][]moduleReplacementSummary) replacementsByObjectID := make(map[string][]moduleReplacementSummary)
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err} return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
} }

View File

@@ -30,8 +30,6 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
} }
want := map[string]Finding{ want := map[string]Finding{
"go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"}, "go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"},
"nested/go.mod:4": {Path: "nested/go.mod", Line: 4, Target: "../../local", Directive: "replace"},
"nested/go.mod:5": {Path: "nested/go.mod", Line: 5, Target: absoluteTarget, Directive: "replace"},
} }
if len(failure.Findings) != len(want) { if len(failure.Findings) != len(want) {
t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want)) t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want))
@@ -46,6 +44,27 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
t.Errorf("finding = %#v, want %q", finding, key) t.Errorf("finding = %#v, want %q", finding, key)
} }
} }
nestedTree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, nestedTree)
err = checkLocalReplacements(context.Background(), nestedTree)
if !errors.As(err, &failure) || failure.Check != LocalReplacements || len(failure.Findings) != 2 {
t.Fatalf("nested replacement findings = %#v, want the selected module's two replacements", err)
}
for _, finding := range failure.Findings {
wantLine := 4
wantTarget := "../../local"
if finding.Line == 5 {
wantLine = 5
wantTarget = absoluteTarget
}
if finding.Path != "nested/go.mod" || finding.Line != wantLine || finding.Target != wantTarget || finding.Directive != "replace" {
t.Errorf("nested finding = %#v, want line %d target %q", finding, wantLine, wantTarget)
}
}
} }
func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) { func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
@@ -58,7 +77,7 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
commit := commitCandidateTree(t, repository) commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit) tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -91,7 +110,10 @@ func TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T)
} }
} }
commit := commitCandidateTree(t, repository) commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit) tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: repository,
SharedFiles: []string{"first/go.mod", "second/go.mod"},
})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -17,7 +17,7 @@ func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckou
} }
cache := newMarkdownCache(tree) cache := newMarkdownCache(tree)
var findings []Finding var findings []Finding
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err} return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
} }

View File

@@ -19,25 +19,38 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
name string name string
race bool race bool
fullVet bool fullVet bool
compile []Target
}{ }{
{name: "default"}, {name: "default"},
{name: "race", race: true}, {name: "race", race: true},
{name: "full vet", fullVet: true}, {name: "full vet", fullVet: true},
{name: "race and full vet", race: true, fullVet: true}, {name: "race and full vet", race: true, fullVet: true},
{name: "target compilation", compile: []Target{{OS: "linux", Arch: "amd64"}}},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
logPath := installGoShim(t, "", false) logPath := installGoShim(t, "", false)
repositoryDir := t.TempDir()
moduleDir := filepath.Join(repositoryDir, "nested module")
if err := os.MkdirAll(moduleDir, 0o700); err != nil {
t.Fatal(err)
}
err := Run(context.Background(), Request{ err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(), RepositoryDir: repositoryDir,
Settings: Settings{Race: test.race, FullVet: test.fullVet}, ModuleDir: moduleDir,
Settings: Settings{Race: test.race, FullVet: test.fullVet, CompileTargets: test.compile},
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
commands := readGoShimLog(t, logPath) commands := readGoShimLog(t, logPath)
var tests, vets []string for _, directory := range readGoShimDirectories(t, os.Getenv("RELEASEKIT_GO_DIR_LOG")) {
var testPosition, vetPosition int if directory != moduleDir {
t.Errorf("Go command directory = %q, want selected module %q", directory, moduleDir)
}
}
var tests, vets, builds []string
var testPosition, vetPosition, buildPosition int
for i, command := range commands { for i, command := range commands {
if strings.HasPrefix(command, "test ") { if strings.HasPrefix(command, "test ") {
tests = append(tests, command) tests = append(tests, command)
@@ -47,6 +60,10 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
vets = append(vets, command) vets = append(vets, command)
vetPosition = i vetPosition = i
} }
if strings.HasPrefix(command, "build ") {
builds = append(builds, command)
buildPosition = i
}
} }
if len(tests) != 1 { if len(tests) != 1 {
t.Fatalf("Go test invocations = %q, want exactly one", tests) t.Fatalf("Go test invocations = %q, want exactly one", tests)
@@ -81,6 +98,27 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
t.Errorf("Go vet command %q did not follow the test suite %q", vets[0], tests[0]) t.Errorf("Go vet command %q did not follow the test suite %q", vets[0], tests[0])
} }
} }
wantBuilds := 0
if len(test.compile) != 0 {
wantBuilds = len(test.compile)
}
if len(builds) != wantBuilds {
t.Fatalf("Go build invocations = %q, want %d", builds, wantBuilds)
}
if wantBuilds != 0 {
buildCommand := " " + builds[0] + " "
for _, argument := range []string{"build", "-mod=readonly", "-o", os.DevNull, "./..."} {
if !strings.Contains(buildCommand, " "+argument+" ") {
t.Errorf("Go compile command %q does not contain %q", builds[0], argument)
}
}
if buildPosition <= testPosition {
t.Errorf("Go compile command %q did not follow the test suite %q", builds[0], tests[0])
}
if environments := readGoShimLog(t, os.Getenv("RELEASEKIT_GO_ENV_LOG")); len(environments) != 1 || environments[0] != "linux/amd64|0" {
t.Errorf("Go compile baseline = %q, want linux/amd64|0", environments)
}
}
}) })
} }
} }
@@ -111,6 +149,49 @@ func TestRunReportsRacePrerequisiteFailureWithoutFallback(t *testing.T) {
} }
} }
func TestRunValidatesOnlyTheSelectedModule(t *testing.T) {
t.Setenv("GOENV", "off")
repositoryDir := t.TempDir()
nestedDir := filepath.Join(repositoryDir, "nested")
siblingDir := filepath.Join(repositoryDir, "sibling")
for _, directory := range []string{nestedDir, siblingDir} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatal(err)
}
}
writeModule := func(directory, name, testSource string) {
t.Helper()
files := map[string]string{
"go.mod": "module " + name + "\n\ngo 1.25.0\n",
"module_test.go": testSource,
}
for filename, contents := range files {
if err := os.WriteFile(filepath.Join(directory, filename), []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
}
passingTest := "package fixture\nimport \"testing\"\nfunc TestSelected(t *testing.T) {}\n"
failingTest := "package fixture\nimport \"testing\"\nfunc TestSibling(t *testing.T) { t.Fatal(\"sibling suite ran\") }\n"
writeModule(repositoryDir, "example.com/root", passingTest)
writeModule(nestedDir, "example.com/nested", passingTest)
writeModule(siblingDir, "example.com/sibling", failingTest)
if err := Run(context.Background(), Request{RepositoryDir: repositoryDir}); err != nil {
t.Fatalf("root module validation: %v", err)
}
if err := Run(context.Background(), Request{RepositoryDir: repositoryDir, ModuleDir: nestedDir}); err != nil {
t.Fatalf("nested module validation ran a sibling suite: %v", err)
}
writeModule(nestedDir, "example.com/nested", failingTest)
err := Run(context.Background(), Request{RepositoryDir: repositoryDir, ModuleDir: nestedDir})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoTest {
t.Fatalf("failing nested module result = %v, want Go test failure", err)
}
}
func TestRunStopsAfterGoTestFailure(t *testing.T) { func TestRunStopsAfterGoTestFailure(t *testing.T) {
logPath := installGoShim(t, "test", false) logPath := installGoShim(t, "test", false)
err := Run(context.Background(), Request{ err := Run(context.Background(), Request{
@@ -214,6 +295,7 @@ func installGoShim(t *testing.T, failure string, delayVet bool) string {
directory := t.TempDir() directory := t.TempDir()
logPath := filepath.Join(directory, "go-commands") logPath := filepath.Join(directory, "go-commands")
envLogPath := filepath.Join(directory, "go-environment") envLogPath := filepath.Join(directory, "go-environment")
directoryLogPath := filepath.Join(directory, "go-directories")
settings := map[string]string{ settings := map[string]string{
"GOHOSTOS": runtime.GOOS, "GOHOSTOS": runtime.GOOS,
"GOHOSTARCH": runtime.GOARCH, "GOHOSTARCH": runtime.GOARCH,
@@ -263,6 +345,7 @@ fi
script := fmt.Sprintf(`#!/bin/sh script := fmt.Sprintf(`#!/bin/sh
set -eu set -eu
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG" printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
pwd -P >> "$RELEASEKIT_GO_DIR_LOG"
if [ "${1-}" = "env" ]; then if [ "${1-}" = "env" ]; then
printf '%%s\n' '%s' printf '%%s\n' '%s'
exit 0 exit 0
@@ -281,6 +364,7 @@ exit 0
t.Setenv("PATH", path) t.Setenv("PATH", path)
t.Setenv("RELEASEKIT_GO_LOG", logPath) t.Setenv("RELEASEKIT_GO_LOG", logPath)
t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath) t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath)
t.Setenv("RELEASEKIT_GO_DIR_LOG", directoryLogPath)
return logPath return logPath
} }
@@ -293,6 +377,15 @@ func readGoShimLog(t *testing.T, path string) []string {
return strings.Split(strings.TrimSpace(string(contents)), "\n") return strings.Split(strings.TrimSpace(string(contents)), "\n")
} }
func readGoShimDirectories(t *testing.T, path string) []string {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return strings.Split(strings.TrimSpace(string(contents)), "\n")
}
func raceSupported(goos, goarch string) bool { func raceSupported(goos, goarch string) bool {
switch goos { switch goos {
case "darwin": case "darwin":

View File

@@ -0,0 +1,125 @@
package validation
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
)
func TestRunFileChecksUseSelectedInputsAndFullReferenceLookup(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n\ngo 1.25.0\nreplace example.com/rootdep => ./missing\n"))
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "outside.go", []byte("package outside\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "outside.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "nested/source.go", []byte("package nested\n\nfunc ready() {}\n"))
writeTreeFile(t, repository, "nested/ignored.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "nested/docs/guide.md", []byte("# Guide\n\n[details](../../sibling/docs/target.md#details)\n"))
writeTreeFile(t, repository, "nested/child/go.mod", []byte("module example.com/nested/child\n"))
writeTreeFile(t, repository, "nested/child/broken.go", []byte("package child\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "nested/child/go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "sibling/go.mod", []byte("module example.com/sibling\n\nreplace example.com/siblingdep => ./missing\n"))
writeTreeFile(t, repository, "sibling/go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "sibling/broken.go", []byte("package sibling\nfunc broken( ){}\n"))
writeTreeFile(t, repository, "sibling/outside.bin", []byte(strings.Repeat("x", 160)))
writeTreeFile(t, repository, "sibling/docs/target.md", []byte("# Details\n\n[broken](missing.md)\n"))
writeTreeFile(t, repository, "shared/broken.go", []byte("package shared\nfunc broken( ){}\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
moduleDir := filepath.Join(repository, "nested")
settings := Settings{
GoFormat: true,
ModuleDistribution: ModuleDistributionSettings{
WorkspaceFiles: true,
LocalReplacements: true,
},
FileSize: &FileSizeSettings{LimitBytes: int64Pointer(100), Exclusions: []string{"ignored.bin"}},
Markdown: &MarkdownSettings{EnforceCheckoutBoundary: true},
}
request := Request{RepositoryDir: repository, ModuleDir: moduleDir, Commit: commit, Settings: settings}
if err := Run(context.Background(), request); err != nil {
t.Fatalf("selected module checks = %v, want out-of-scope violations ignored and cross-module anchor resolved", err)
}
request.Settings.FileSize.Exclusions = nil
err := Run(context.Background(), request)
assertScopedFailure(t, err, FileSize, "nested/ignored.bin")
writeTreeFile(t, repository, "nested/broken.go", []byte("package nested\nfunc broken( ){}\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{GoFormat: true}
assertScopedFailure(t, Run(context.Background(), request), GoFormat, "nested/broken.go")
writeTreeFile(t, repository, "nested/go.work", []byte("go 1.25.0\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{ModuleDistribution: ModuleDistributionSettings{WorkspaceFiles: true}}
assertScopedFailure(t, Run(context.Background(), request), WorkspaceFiles, "nested/go.work")
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\ngo 1.25.0\nreplace example.com/nesteddep => ./missing\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{ModuleDistribution: ModuleDistributionSettings{LocalReplacements: true}}
assertScopedFailure(t, Run(context.Background(), request), LocalReplacements, "nested/go.mod")
writeTreeFile(t, repository, "nested/docs/guide.md", []byte("# Guide\n\n[details](../../sibling/docs/target.md#details)\n\n[missing](absent.md)\n"))
request.Commit = commitCandidateTree(t, repository)
request.Settings = Settings{Markdown: &MarkdownSettings{}}
assertScopedFailure(t, Run(context.Background(), request), MarkdownLinks, "nested/docs/guide.md")
request.Settings = Settings{GoFormat: true, SharedFiles: []string{"shared/broken.go"}}
assertScopedFailure(t, Run(context.Background(), request), GoFormat, "shared/broken.go")
}
func assertScopedFailure(t *testing.T, err error, check Check, path string) {
t.Helper()
failure, ok := err.(*Failure)
if !ok || failure.Check != check {
t.Fatalf("validation error = %v, want %s failure", err, check)
}
for _, finding := range failure.Findings {
if finding.Path == path {
return
}
}
t.Fatalf("%s findings = %#v, want path %q", check, failure.Findings, path)
}
func TestFileSizeExclusionsAreModuleRelative(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
writeTreeFile(t, repository, "nested/dist/output.bin", []byte(strings.Repeat("x", 20)))
writeTreeFile(t, repository, "nested/dist-old/output.bin", []byte(strings.Repeat("x", 20)))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: filepath.Join(repository, "nested")})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
settings := fileSizeSettingsForModule(&FileSizeSettings{LimitBytes: int64Pointer(10), Exclusions: []string{"./dist/"}}, tree.modulePath)
err = checkFileSize(t.Context(), tree, settings)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "nested/dist-old/output.bin" {
t.Fatalf("module-relative file-size exclusion error = %v, want only nested/dist-old/output.bin", err)
}
}
func TestSharedFileSelectionFailureCarriesCauseAndPresentation(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{SharedFiles: []string{"missing.txt"}},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != SharedFiles || failure.Cause == nil || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.txt" {
t.Fatalf("shared-file selection error = %#v, want path, cause, and shared-files identity", err)
}
if !strings.Contains(err.Error(), "shared-files") || !strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("shared-file error text = %q, want existing check/path presentation", err)
}
}

View File

@@ -0,0 +1,38 @@
package validation
import (
"fmt"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func BenchmarkSelectInputEntriesByModuleCount(b *testing.B) {
const retainedFiles = 20000
for _, boundaries := range []int{0, 1000, 4000} {
b.Run(fmt.Sprintf("boundaries=%d", boundaries), func(b *testing.B) {
entries := make([]git.TreeEntry, 0, retainedFiles+2*boundaries)
entries = append(entries, git.TreeEntry{Path: "go.mod", Type: "blob", Mode: "100644"})
for file := 1; file < retainedFiles; file++ {
entries = append(entries, git.TreeEntry{Path: fmt.Sprintf("source/file-%06d.txt", file), Type: "blob", Mode: "100644"})
}
for module := 0; module < boundaries; module++ {
directory := fmt.Sprintf("modules/unit-%06d", module)
entries = append(entries,
git.TreeEntry{Path: directory + "/go.mod", Type: "blob", Mode: "100644"},
git.TreeEntry{Path: directory + "/source.go", Type: "blob", Mode: "100644"})
}
byPath := make(map[string]int, len(entries))
for i, entry := range entries {
byPath[entry.Path] = i
}
b.ReportAllocs()
for b.Loop() {
selected, err := selectInputEntries(entries, byPath, ".", []string{})
if err != nil || len(selected) != retainedFiles {
b.Fatalf("selected %d files, error=%v; want %d", len(selected), err, retainedFiles)
}
}
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"path" "path"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
@@ -15,7 +16,9 @@ import (
type CandidateTree struct { type CandidateTree struct {
context context.Context context context.Context
repositoryDir string repositoryDir string
modulePath string
entries []git.TreeEntry entries []git.TreeEntry
inputEntries []git.TreeEntry
byPath map[string]int byPath map[string]int
directories map[string]struct{} directories map[string]struct{}
blobReader *git.BlobReader blobReader *git.BlobReader
@@ -25,16 +28,40 @@ type CandidateTree struct {
closeErr error closeErr error
} }
// TreeScope selects the automatic input files for one physical module and
// optional repository-shared files. ModuleDir is an absolute path. A nil
// SharedFiles slice selects the optional default files; a non-nil slice
// replaces those defaults.
type TreeScope struct {
ModuleDir string
SharedFiles []string
}
// ReadCandidateTree enumerates the captured commit without reading from the // ReadCandidateTree enumerates the captured commit without reading from the
// worktree. The supplied context also governs the scoped reader through closure. // worktree. The supplied context also governs the scoped reader through closure.
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) { func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
}
return ReadCandidateTreeForScope(ctx, repositoryDir, commit, TreeScope{ModuleDir: repositoryAbs})
}
// ReadCandidateTreeForScope retains the complete committed tree for reference
// lookup and separately constructs the selected automatic input paths.
func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string, scope TreeScope) (*CandidateTree, error) {
entries, err := git.ReadTree(ctx, repositoryDir, commit) entries, err := git.ReadTree(ctx, repositoryDir, commit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
modulePath, err := repositoryModulePath(repositoryDir, scope.ModuleDir)
if err != nil {
return nil, err
}
tree := &CandidateTree{ tree := &CandidateTree{
context: ctx, context: ctx,
repositoryDir: repositoryDir, repositoryDir: repositoryDir,
modulePath: modulePath,
entries: entries, entries: entries,
byPath: make(map[string]int, len(entries)), byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}), directories: make(map[string]struct{}),
@@ -47,6 +74,11 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
tree.directories[directory] = struct{}{} tree.directories[directory] = struct{}{}
} }
} }
tree.inputEntries, err = selectInputEntries(entries, tree.byPath, modulePath, scope.SharedFiles)
if err != nil {
_ = tree.Close()
return nil, err
}
return tree, nil return tree, nil
} }
@@ -55,6 +87,139 @@ func (tree *CandidateTree) Entries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.entries...) return append([]git.TreeEntry(nil), tree.entries...)
} }
// InputEntries returns the ordered committed files selected for automatic
// checks. It is separate from Entries so reference resolution retains the full
// repository tree.
func (tree *CandidateTree) InputEntries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.inputEntries...)
}
func selectInputEntries(entries []git.TreeEntry, byPath map[string]int, modulePath string, sharedFiles []string) ([]git.TreeEntry, error) {
boundaries := make(map[string]struct{})
for _, entry := range entries {
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
if !ok || moduleRelative == "go.mod" || path.Base(moduleRelative) != "go.mod" {
continue
}
if insideVendor(moduleRelative) {
continue
}
boundaries[path.Dir(entry.Path)] = struct{}{}
}
selected := make(map[string]git.TreeEntry)
for _, entry := range entries {
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
if !ok || moduleRelative == "" {
continue
}
excluded := false
for ancestor := entry.Path; ancestor != "." && ancestor != modulePath; ancestor = path.Dir(ancestor) {
if _, isBoundary := boundaries[ancestor]; isBoundary {
excluded = true
break
}
}
if !excluded {
selected[entry.Path] = entry
}
}
usingDefaults := sharedFiles == nil
if usingDefaults {
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
}
for _, requested := range sharedFiles {
name, err := normalizeSharedFile(requested)
if err != nil {
cause := fmt.Errorf("invalid shared file path %q: %w", requested, err)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: cause.Error()}}, Cause: cause}
}
index, exists := byPath[name]
if !exists || entries[index].Type != "blob" {
if usingDefaults {
continue
}
cause := fmt.Errorf("selected shared file %q is not present in the committed tree", name)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}, Cause: cause}
}
selected[name] = entries[index]
}
paths := make([]string, 0, len(selected))
for name := range selected {
paths = append(paths, name)
}
sort.Strings(paths)
inputs := make([]git.TreeEntry, 0, len(paths))
for _, name := range paths {
inputs = append(inputs, selected[name])
}
return inputs, nil
}
func repositoryModulePath(repositoryDir, moduleDir string) (string, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return "", fmt.Errorf("resolve candidate repository directory: %w", err)
}
if moduleDir == "" {
moduleDir = repositoryAbs
}
if !filepath.IsAbs(moduleDir) {
return "", fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
}
moduleAbs, err := filepath.Abs(moduleDir)
if err != nil {
return "", fmt.Errorf("resolve selected module directory: %w", err)
}
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
if err != nil {
return "", fmt.Errorf("make selected module relative to repository: %w", err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
}
modulePath := filepath.ToSlash(relative)
if relative == "." {
modulePath = "."
}
return modulePath, nil
}
func relativeToModule(repositoryPath, modulePath string) (string, bool) {
if modulePath == "." {
return repositoryPath, true
}
if repositoryPath == modulePath {
return "", true
}
prefix := modulePath + "/"
if !strings.HasPrefix(repositoryPath, prefix) {
return "", false
}
return strings.TrimPrefix(repositoryPath, prefix), true
}
func insideVendor(moduleRelative string) bool {
for _, component := range strings.Split(moduleRelative, "/") {
if component == "vendor" {
return true
}
}
return false
}
func normalizeSharedFile(name string) (string, error) {
if name == "" || path.IsAbs(name) {
return "", fmt.Errorf("shared file path must be a repository-relative file path")
}
clean := path.Clean(name)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("shared file path must stay within the repository")
}
return clean, nil
}
// ReadFile returns newly allocated, caller-owned bytes for a committed blob. // ReadFile returns newly allocated, caller-owned bytes for a committed blob.
// Regular-file content is not cached, so repeated reads request the object // Regular-file content is not cached, so repeated reads request the object
// again through the tree's scoped blob reader. // again through the tree's scoped blob reader.
@@ -145,137 +310,32 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
} }
// DestinationKind classifies a committed local destination. // DestinationKind classifies a committed local destination.
type DestinationKind string type DestinationKind = git.DestinationKind
const ( const (
DestinationEntry DestinationKind = "entry" DestinationEntry = git.DestinationEntry
DestinationDirectory DestinationKind = "directory" DestinationDirectory = git.DestinationDirectory
DestinationMissing DestinationKind = "missing" DestinationMissing = git.DestinationMissing
DestinationOutsideCheckout DestinationKind = "outside-checkout" DestinationOutsideCheckout = git.DestinationOutsideCheckout
DestinationSubmodule DestinationKind = "submodule" DestinationSubmodule = git.DestinationSubmodule
DestinationSymlinkCycle DestinationKind = "symlink-cycle" DestinationSymlinkCycle = git.DestinationSymlinkCycle
) )
// Destination records the resolved identity and its classification. type Destination = git.Destination
type Destination struct {
Kind DestinationKind
Path string
}
// ResolveDestination resolves local destinations using the captured Git tree. // ResolveDestination uses shared committed reference traversal with the tree's
// Relative destinations start at the source directory; absolute destinations // indexed metadata and scoped symlink reader.
// within the repository directory map back to committed paths.
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) { func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
if err := ctx.Err(); err != nil { resolver := git.PathResolver{
return Destination{}, err RepositoryDir: tree.repositoryDir,
Lookup: func(_ context.Context, name string) (git.TreeEntry, bool, error) {
if entry, exists := tree.Entry(name); exists {
return entry, true, nil
} }
directory := path.Dir(sourcePath) _, exists := tree.directories[name]
if path.IsAbs(destination) { return git.TreeEntry{Path: name, Type: "tree"}, exists, nil
relative, inside, err := tree.relativeAbsoluteDestination(destination) },
if err != nil { ReadLink: tree.readSymlinkTarget,
return Destination{}, err
} }
if !inside { return resolver.ResolveDestination(ctx, sourcePath, destination)
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
}
directory, destination = ".", relative
}
return tree.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
}
func (tree *CandidateTree) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) {
for index, component := range components {
if err := ctx.Err(); err != nil {
return Destination{}, err
}
remaining := components[index+1:]
switch component {
case "", ".":
continue
case "..":
if directory == "." {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil
}
directory = path.Dir(directory)
continue
}
partial := path.Join(directory, component)
entry, exists := tree.Entry(partial)
if !exists {
if _, isDirectory := tree.directories[partial]; !isDirectory {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
directory = partial
continue
}
if entry.Mode == "160000" {
return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(partial, remaining)}, nil
}
if entry.Mode == "120000" {
if active[partial] {
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
}
entry, _ := tree.Entry(partial)
linkText, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := linkText
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := tree.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := tree.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
delete(active, partial)
if err != nil || len(remaining) == 0 {
return destination, err
}
switch destination.Kind {
case DestinationDirectory:
return tree.resolveDestination(ctx, destination.Path, remaining, active)
case DestinationEntry:
destination.Kind = DestinationMissing
case DestinationSymlinkCycle:
return destination, nil
}
destination.Path = unresolvedPath(destination.Path, remaining)
return destination, nil
}
if len(remaining) != 0 {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
return Destination{Kind: DestinationEntry, Path: partial}, nil
}
return Destination{Kind: DestinationDirectory, Path: directory}, nil
}
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(tree.repositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, nil
}
// unresolvedPath preserves components that could not be traversed. Cleaning
// them would hide missing entries, non-directories, or checkout boundaries.
func unresolvedPath(base string, remaining []string) string {
if len(remaining) == 0 {
return base
}
return base + "/" + strings.Join(remaining, "/")
} }

View File

@@ -48,10 +48,10 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
t.Errorf("committed tree is missing %q", path) t.Errorf("committed tree is missing %q", path)
} }
} }
if got := entries["binary.dat"]; got.size != 4 || !got.hasSize { if got := entries["binary.dat"]; got.mode != "100644" || got.kind != "blob" {
t.Errorf("binary metadata = %#v, want size 4", got) t.Errorf("binary metadata = %#v", got)
} }
if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" || !got.hasSize { if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" {
t.Errorf("symlink metadata = %#v", got) t.Errorf("symlink metadata = %#v", got)
} }
if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize { if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize {
@@ -78,6 +78,239 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
} }
} }
func TestReadCandidateTreeForScopeExcludesNestedModulesAndRetainsFullLookup(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "COPYRIGHT", []byte("copyright\n"))
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
writeTreeFile(t, repository, "docs/oss/attribution.md", []byte("attribution\n"))
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
writeTreeFile(t, repository, "parent/go.mod", []byte("module example.com/parent\n"))
writeTreeFile(t, repository, "parent/main.go", []byte("package parent\n"))
writeTreeFile(t, repository, "parent/vendor/library/go.mod", []byte("not a boundary\n"))
writeTreeFile(t, repository, "parent/vendor/library/source.go", []byte("package library\n"))
writeTreeFile(t, repository, "parent/nested/go.mod", []byte("module example.com/nested\n"))
writeTreeFile(t, repository, "parent/nested/source.go", []byte("package nested\n"))
writeTreeFile(t, repository, "sibling/go.mod", []byte("module example.com/sibling\n"))
writeTreeFile(t, repository, "sibling/source.go", []byte("package sibling\n"))
commit := commitCandidateTree(t, repository)
writeTreeFile(t, repository, "parent/worktree-only.go", []byte("package parent\n"))
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: filepath.Join(repository, "parent"),
})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
var got []string
for _, entry := range tree.InputEntries() {
got = append(got, entry.Path)
}
want := []string{
"COPYRIGHT", "LICENSE", "docs/oss/attribution.md", "parent/go.mod", "parent/main.go",
"parent/vendor/library/go.mod", "parent/vendor/library/source.go",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("selected inputs = %q, want %q", got, want)
}
if _, ok := tree.Entry("parent/nested/source.go"); !ok {
t.Fatal("full tree lookup lost an excluded nested module destination")
}
destination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "../sibling/source.go")
if err != nil || destination.Kind != DestinationEntry || destination.Path != "sibling/source.go" {
t.Fatalf("excluded sibling destination = %#v, %v", destination, err)
}
nestedDestination, err := tree.ResolveDestination(context.Background(), "parent/main.go", "nested/source.go")
if err != nil || nestedDestination.Kind != DestinationEntry || nestedDestination.Path != "parent/nested/source.go" {
t.Fatalf("excluded nested-module destination = %#v, %v", nestedDestination, err)
}
if tree.blobReader != nil || tree.blobReads != 0 {
t.Fatalf("metadata-only selection read blob content: reader=%v reads=%d", tree.blobReader != nil, tree.blobReads)
}
}
func TestReadCandidateTreeForRootScopeExcludesNestedModules(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n"))
writeTreeFile(t, repository, "root.go", []byte("package root\n"))
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n"))
writeTreeFile(t, repository, "nested/source.go", []byte("package nested\n"))
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("not a boundary\n"))
writeTreeFile(t, repository, "vendor/library/source.go", []byte("package library\n"))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: repository})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
want := []string{"go.mod", "root.go", "vendor/library/go.mod", "vendor/library/source.go"}
if got := inputPaths(tree); strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("root module inputs = %q, want %q", got, want)
}
}
func TestReadCandidateTreeSharedFilesReplaceDefaultsAndDeduplicate(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "LICENSE", []byte("default\n"))
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
writeTreeFile(t, repository, "sibling/shared.txt", []byte("selected explicitly\n"))
writeTreeFile(t, repository, "literal/*.md", []byte("literal wildcard path\n"))
commit := commitCandidateTree(t, repository)
moduleDir := filepath.Join(repository, "module")
selected, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: moduleDir,
SharedFiles: []string{"sibling/shared.txt", "module/source.go", "sibling/shared.txt"},
})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, selected)
var got []string
for _, entry := range selected.InputEntries() {
got = append(got, entry.Path)
}
want := []string{"module/go.mod", "module/source.go", "sibling/shared.txt"}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("explicit shared inputs = %q, want %q", got, want)
}
empty, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: moduleDir,
SharedFiles: []string{},
})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, empty)
if got := inputPaths(empty); strings.Join(got, "\n") != "module/go.mod\nmodule/source.go" {
t.Fatalf("explicit empty input set = %q", got)
}
literal, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: moduleDir,
SharedFiles: []string{"literal/*.md"},
})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, literal)
if got := inputPaths(literal); strings.Join(got, "\n") != "literal/*.md\nmodule/go.mod\nmodule/source.go" {
t.Fatalf("literal wildcard path inputs = %q", got)
}
}
func TestReadCandidateTreeDefaultsAreOptionalAndExplicitMissingFilesFail(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
writeTreeFile(t, repository, "module/source.go", []byte("package module\n"))
writeTreeFile(t, repository, "LICENSE", []byte("license\n"))
commit := commitCandidateTree(t, repository)
moduleDir := filepath.Join(repository, "module")
defaults, err := ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{ModuleDir: moduleDir})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, defaults)
if got := inputPaths(defaults); strings.Join(got, "\n") != "LICENSE\nmodule/go.mod\nmodule/source.go" {
t.Fatalf("default shared inputs = %q", got)
}
writeTreeFile(t, repository, "missing.md", []byte("present only in the worktree\n"))
_, err = ReadCandidateTreeForScope(context.Background(), repository, commit, TreeScope{
ModuleDir: moduleDir,
SharedFiles: []string{"missing.md"},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.md" {
t.Fatalf("missing explicit shared file error = %#v, want path-specific shared-files failure", err)
}
}
func TestSharedFileSelectionPreservesLiteralBackslash(t *testing.T) {
if filepath.Separator == '\\' {
t.Skip("backslash is a filesystem separator on this platform")
}
repository := newTreeRepository(t)
writeTreeFile(t, repository, "module/go.mod", []byte("module example.com/module\n"))
const name = `docs/notice\copy.txt`
contents := []byte("literal filename\n")
writeTreeFile(t, repository, name, contents)
commit := commitCandidateTree(t, repository)
moduleDir := filepath.Join(repository, "module")
tree, err := ReadCandidateTreeForScope(t.Context(), repository, commit, TreeScope{ModuleDir: moduleDir, SharedFiles: []string{name, name}})
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
if got := inputPaths(tree); strings.Join(got, "\n") != name+"\nmodule/go.mod" {
t.Fatalf("literal selection = %q", got)
}
read, err := tree.ReadFile(t.Context(), name)
if err != nil || string(read) != string(contents) {
t.Fatalf("literal file content = %q, error = %v", read, err)
}
for _, invalid := range []string{"../outside", "/absolute", "docs"} {
if _, err := ReadCandidateTreeForScope(t.Context(), repository, commit, TreeScope{ModuleDir: moduleDir, SharedFiles: []string{invalid}}); err == nil {
t.Fatalf("shared selection accepted %q", invalid)
}
}
}
func TestRunBuildsInputsOnlyForChecksOrExplicitSharedFiles(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/validation-tree\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "main.go", []byte("package validationtree\n"))
commit := commitCandidateTree(t, repository)
gitPath, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
bin := filepath.Join(t.TempDir(), "bin")
if err := os.MkdirAll(bin, 0o755); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(t.TempDir(), "tree-reads")
wrapper := "#!/bin/sh\nREAL_GIT='" + gitPath + "'\nLOG='" + logPath + "'\nfor ARG in \"$@\"; do [ \"$ARG\" = ls-tree ] && echo read >> \"$LOG\"; done\nexec \"$REAL_GIT\" \"$@\"\n"
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
t.Fatalf("metadata-only Run(): %v", err)
}
if _, err := os.Stat(logPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("default-only request constructed file inputs: stat error=%v", err)
}
err = Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{SharedFiles: []string{"missing.txt"}},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != SharedFiles || len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.txt" {
t.Fatalf("explicit missing shared file Run() error = %#v", err)
}
if data, err := os.ReadFile(logPath); err != nil || len(data) == 0 {
t.Fatalf("explicit shared-file request did not construct committed file inputs: %q, %v", data, err)
}
}
func inputPaths(tree *CandidateTree) []string {
entries := tree.InputEntries()
paths := make([]string, 0, len(entries))
for _, entry := range entries {
paths = append(paths, entry.Path)
}
return paths
}
func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) { func TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
repository := newTreeRepository(t) repository := newTreeRepository(t)
writeTreeFile(t, repository, "source.txt", []byte("committed content")) writeTreeFile(t, repository, "source.txt", []byte("committed content"))

View File

@@ -4,6 +4,7 @@ package validation
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"go/format" "go/format"
"io" "io"
@@ -19,6 +20,7 @@ type Settings struct {
Race bool Race bool
FullVet bool FullVet bool
CompileTargets []Target CompileTargets []Target
SharedFiles []string
GoFormat bool GoFormat bool
Markdown *MarkdownSettings Markdown *MarkdownSettings
ModuleDistribution ModuleDistributionSettings ModuleDistribution ModuleDistributionSettings
@@ -51,15 +53,20 @@ type Target struct {
// Request identifies the captured source and output used by shared validation. // Request identifies the captured source and output used by shared validation.
type Request struct { type Request struct {
RepositoryDir string RepositoryDir string
ModuleDir string
Commit string Commit string
Output io.Writer Output io.Writer
Settings Settings Settings Settings
ToolOwner *gotool.Owner
} }
// ReadCandidateTree loads the committed-file inventory when a selected check // ReadCandidateTree loads the committed-file inventory when a selected check
// needs file-oriented input. // needs file-oriented input.
func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) { func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) {
return ReadCandidateTree(ctx, request.RepositoryDir, request.Commit) return ReadCandidateTreeForScope(ctx, request.RepositoryDir, request.Commit, TreeScope{
ModuleDir: request.ModuleDir,
SharedFiles: request.Settings.SharedFiles,
})
} }
// Run executes shared validation for the captured candidate. // Run executes shared validation for the captured candidate.
@@ -81,7 +88,15 @@ func Run(ctx context.Context, request Request) (result error) {
} }
} }
} }
tool, err := gotool.New(ctx, request.RepositoryDir) goDirectory := request.ModuleDir
if goDirectory == "" {
goDirectory = request.RepositoryDir
}
owner := request.ToolOwner
if owner == nil {
owner = gotool.NewOwner(goDirectory)
}
tool, err := owner.Acquire(ctx)
if err != nil { if err != nil {
return &Failure{Check: GoTest, Cause: err} return &Failure{Check: GoTest, Cause: err}
} }
@@ -93,19 +108,19 @@ func Run(ctx context.Context, request Request) (result error) {
args = append(args, "-vet=off") args = append(args, "-vet=off")
} }
args = append(args, "./...") args = append(args, "./...")
err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...) err = tool.Run(ctx, goDirectory, gotool.Native, gotool.Target{}, request.Output, args...)
if err != nil { if err != nil {
return &Failure{Check: GoTest, Cause: err} return &Failure{Check: GoTest, Cause: err}
} }
if request.Settings.FullVet { if request.Settings.FullVet {
err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...") err = tool.Run(ctx, goDirectory, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...")
if err != nil { if err != nil {
return &Failure{Check: GoVet, Cause: err} return &Failure{Check: GoVet, Cause: err}
} }
} }
for _, target := range request.Settings.CompileTargets { for _, target := range request.Settings.CompileTargets {
selected := gotool.Target{OS: target.OS, Arch: target.Arch} selected := gotool.Target{OS: target.OS, Arch: target.Arch}
err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output, err = tool.Run(ctx, goDirectory, gotool.Static, selected, request.Output,
"build", "-mod=readonly", "-o", os.DevNull, "./...") "build", "-mod=readonly", "-o", os.DevNull, "./...")
if err != nil { if err != nil {
return &Failure{ return &Failure{
@@ -118,9 +133,14 @@ func Run(ctx context.Context, request Request) (result error) {
} }
} }
} }
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil { needsSharedSelection := len(request.Settings.SharedFiles) != 0
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil || needsSharedSelection {
tree, err := request.ReadCandidateTree(ctx) tree, err := request.ReadCandidateTree(ctx)
if err != nil { if err != nil {
var failure *Failure
if errors.As(err, &failure) {
return failure
}
check := MarkdownLinks check := MarkdownLinks
if request.Settings.FileSize != nil { if request.Settings.FileSize != nil {
check = FileSize check = FileSize
@@ -137,6 +157,7 @@ func Run(ctx context.Context, request Request) (result error) {
return &Failure{Check: check, Cause: err} return &Failure{Check: check, Cause: err}
} }
lastContentCheck := Check("") lastContentCheck := Check("")
fileSizeSettings := fileSizeSettingsForModule(request.Settings.FileSize, tree.modulePath)
defer func() { defer func() {
if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" { if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" {
result = &Failure{ result = &Failure{
@@ -169,7 +190,7 @@ func Run(ctx context.Context, request Request) (result error) {
} }
} }
if request.Settings.FileSize != nil { if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil { if err := checkFileSize(ctx, tree, fileSizeSettings); err != nil {
return err return err
} }
} }
@@ -188,7 +209,7 @@ func Run(ctx context.Context, request Request) (result error) {
func checkWorkspaceFiles(tree *CandidateTree) error { func checkWorkspaceFiles(tree *CandidateTree) error {
var findings []Finding var findings []Finding
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if entry.Type != "blob" { if entry.Type != "blob" {
continue continue
} }
@@ -209,7 +230,7 @@ func checkWorkspaceFiles(tree *CandidateTree) error {
func checkGoFormat(ctx context.Context, tree *CandidateTree) error { func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
var findings []Finding var findings []Finding
formattedByObjectID := make(map[string]bool) formattedByObjectID := make(map[string]bool)
for _, entry := range tree.Entries() { for _, entry := range tree.inputEntries {
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") { if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
continue continue
} }

View File

@@ -9,13 +9,14 @@ import (
) )
func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) { func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{ entries := []git.TreeEntry{
{Path: "go.work", Type: "blob", Mode: "100644"}, {Path: "go.work", Type: "blob", Mode: "100644"},
{Path: "nested/go.work.sum", Type: "blob", Mode: "100644"}, {Path: "nested/go.work.sum", Type: "blob", Mode: "100644"},
{Path: "vendor/nested/link/go.work", Type: "blob", Mode: "120000"}, {Path: "vendor/nested/link/go.work", Type: "blob", Mode: "120000"},
{Path: "nested/not-go.work.backup", Type: "blob", Mode: "100644"}, {Path: "nested/not-go.work.backup", Type: "blob", Mode: "100644"},
{Path: "submodule/go.work", Type: "commit", Mode: "160000"}, {Path: "submodule/go.work", Type: "commit", Mode: "160000"},
}} }
tree := &CandidateTree{entries: entries, inputEntries: entries}
err := checkWorkspaceFiles(tree) err := checkWorkspaceFiles(tree)
failure, ok := err.(*Failure) failure, ok := err.(*Failure)
if !ok || failure.Check != WorkspaceFiles { if !ok || failure.Check != WorkspaceFiles {
@@ -43,10 +44,11 @@ func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
} }
func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) { func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{ entries := []git.TreeEntry{
{Path: "go.mod", Type: "blob", Mode: "100644"}, {Path: "go.mod", Type: "blob", Mode: "100644"},
{Path: "examples/go.work.example", Type: "blob", Mode: "100644"}, {Path: "examples/go.work.example", Type: "blob", Mode: "100644"},
}} }
tree := &CandidateTree{entries: entries, inputEntries: entries}
if err := checkWorkspaceFiles(tree); err != nil { if err := checkWorkspaceFiles(tree); err != nil {
t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err) t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err)
} }

View File

@@ -47,8 +47,9 @@ type deliveryBenchmarkIdentity struct {
func BenchmarkPerformanceFacadeDeliver(b *testing.B) { func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
repository, build, expected := newDeliveryBenchmarkFixture(b) repository, build, expected := newDeliveryBenchmarkFixture(b)
state := &deliveryBenchmarkServer{commit: gitText(b, repository, "rev-parse", "HEAD"), assets: expected} state := &deliveryBenchmarkServer{commit: gitText(b, repository, "rev-parse", "HEAD"), assets: expected}
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP)) server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
state.baseURL = server.URL state.baseURL = "http://" + server.Listener.Addr().String()
server.Start()
b.Cleanup(server.Close) b.Cleanup(server.Close)
request := releasekit.DeliveryRequest{ request := releasekit.DeliveryRequest{
RepositoryDir: repository, RepositoryDir: repository,
@@ -102,7 +103,7 @@ func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
}) })
} }
func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) { func newDeliveryBenchmarkFixture(b testing.TB) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) {
b.Helper() b.Helper()
repository := filepath.Join(b.TempDir(), "repository") repository := filepath.Join(b.TempDir(), "repository")
if err := os.MkdirAll(repository, 0o755); err != nil { if err := os.MkdirAll(repository, 0o755); err != nil {
@@ -111,6 +112,7 @@ func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult,
gitRun(b, repository, "init", "-b", "main") gitRun(b, repository, "init", "-b", "main")
gitRun(b, repository, "config", "user.name", "ReleaseKit Benchmark") gitRun(b, repository, "config", "user.name", "ReleaseKit Benchmark")
gitRun(b, repository, "config", "user.email", "benchmark@example.invalid") gitRun(b, repository, "config", "user.email", "benchmark@example.invalid")
writeFile(b, repository, "go.mod", "module example.com/deliveryfixture\n\ngo 1.26.0\n")
writeFile(b, repository, "docs/releases/v1.2.3.md", "# Sample App v1.2.3\n\nDelivery fixture.\n") writeFile(b, repository, "docs/releases/v1.2.3.md", "# Sample App v1.2.3\n\nDelivery fixture.\n")
gitRun(b, repository, "add", ".") gitRun(b, repository, "add", ".")
gitRun(b, repository, "commit", "-m", "prepare delivery benchmark") gitRun(b, repository, "commit", "-m", "prepare delivery benchmark")
@@ -129,6 +131,7 @@ func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult,
targets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "amd64"}} targets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "amd64"}}
build := releasekit.BuildResult{ build := releasekit.BuildResult{
RepositoryDir: repository, CandidateCommit: gitText(b, repository, "rev-parse", "HEAD"), RepositoryDir: repository, CandidateCommit: gitText(b, repository, "rev-parse", "HEAD"),
ModuleDir: repository, ModulePath: "example.com/deliveryfixture", Tag: "v1.2.3",
SourceVerified: true, Version: "v1.2.3", MainPackage: "./cmd/app", SourceVerified: true, Version: "v1.2.3", MainPackage: "./cmd/app",
BinaryName: "releasekit", Targets: targets, OutputDir: outputDir, BinaryName: "releasekit", Targets: targets, OutputDir: outputDir,
} }
@@ -173,7 +176,7 @@ func (state *deliveryBenchmarkServer) reset(populated bool) {
} }
} }
func (state *deliveryBenchmarkServer) verifyUploads(b *testing.B) { func (state *deliveryBenchmarkServer) verifyUploads(b testing.TB) {
b.Helper() b.Helper()
state.mu.Lock() state.mu.Lock()
defer state.mu.Unlock() defer state.mu.Unlock()

View File

@@ -2,39 +2,62 @@ package releasekit
import ( import (
"context" "context"
"errors"
"fmt"
"strings"
internalpreparation "gitea.maximumdirect.net/eric/ReleaseKit/internal/preparation" internalpreparation "gitea.maximumdirect.net/eric/ReleaseKit/internal/preparation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
// TaggedCheckoutRequest selects a stable tag and the full Git object ID // TaggedCheckoutRequest selects a full Git tag and the Git object ID expected
// expected by an external build or CI event. An empty Remote selects origin. // by an external build or CI event. An empty Remote selects origin.
type TaggedCheckoutRequest struct { type TaggedCheckoutRequest struct {
RepositoryDir string RepositoryDir string
Version string Tag string
Remote string Remote string
ExpectedCommit string ExpectedCommit string
// VerifyOnly checks an already prepared checkout without fetching or checking out.
VerifyOnly bool
} }
// PrepareTaggedCheckout fetches Version from Remote, verifies that its peeled // PrepareTaggedCheckout fetches Tag from Remote, verifies that its peeled
// commit matches ExpectedCommit, and checks out that commit detached. This // commit matches ExpectedCommit, and checks out that commit detached. When
// operation mutates the selected local repository; use it for disposable // VerifyOnly is true, it performs the same tag, HEAD, and cleanliness checks
// checkouts where tag-fetch and checkout conflicts can be resolved by the // without fetching or changing the checkout. It does not publish a tag or
// caller. It does not publish a tag or hosted content. // hosted content.
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error { func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{ return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{
RepositoryDir: request.RepositoryDir, RepositoryDir: request.RepositoryDir,
Version: request.Version, Tag: request.Tag,
Remote: request.Remote, Remote: request.Remote,
ExpectedCommit: request.ExpectedCommit, ExpectedCommit: request.ExpectedCommit,
VerifyOnly: request.VerifyOnly,
}) })
} }
// PrepareBuildDependencies runs `go mod download` in repositoryDir with // DependencyPreparationRequest selects the repository module whose dependencies
// should be prepared.
type DependencyPreparationRequest struct {
RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
}
// PrepareBuildDependencies runs `go mod download` in the selected module with
// workspace discovery disabled and the locally available compatible Go // workspace discovery disabled and the locally available compatible Go
// toolchain selected. It may access module networks and updates the Go module // toolchain selected. It may access module networks and updates the Go module
// cache using the caller's Go proxy, private-module, credential, and cache // cache using the caller's Go proxy, private-module, credential, and cache
// settings. If required checksums are missing, the Go command may update // settings. If required checksums are missing, the Go command may update
// go.sum. Cancellation and Go command failures are returned to the caller. // go.sum. Cancellation and Go command failures are returned to the caller.
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error { func PrepareBuildDependencies(ctx context.Context, request DependencyPreparationRequest) error {
return internalpreparation.PrepareBuildDependencies(ctx, repositoryDir) if strings.TrimSpace(request.RepositoryDir) == "" {
return errors.New("repository directory is required")
}
selection, err := releaseidentity.ResolveSelection(request.RepositoryDir, request.ModuleDir)
if err != nil {
return fmt.Errorf("resolve dependency preparation module: %w", err)
}
return internalpreparation.PrepareBuildDependencies(ctx, selection.ModuleDir)
} }

Some files were not shown because too many files have changed in this diff Show More