Compare commits
43 Commits
v0.3.0
...
b96262eb0c
| Author | SHA1 | Date | |
|---|---|---|---|
| b96262eb0c | |||
| 5a0bc6f78b | |||
| f27abcb53e | |||
| 6c7b77970b | |||
| 80d88a0352 | |||
| c0a3f55f12 | |||
| 7216fb5f98 | |||
| 6b0b8ce6de | |||
| 8112a001d2 | |||
| 8552f92ddb | |||
| c09c7a2ac7 | |||
| 54cc6baf95 | |||
| 7eebbbbfc3 | |||
| c8eb64adec | |||
| 9333573cd8 | |||
| d44ff1260d | |||
| f059fef320 | |||
| 7555d3e6c9 | |||
| 750632f80e | |||
| 90559fe01b | |||
| b49c1f379e | |||
| cd828dc06d | |||
| c9879cffa2 | |||
| c0cc50b127 | |||
| f837871b77 | |||
| d06b62efe2 | |||
| 926b74bc0b | |||
| fdce017aff | |||
| 8350cbf9c9 | |||
| 02896b6e12 | |||
| 3a99536f9c | |||
| fa34cbaff3 | |||
| 3e516d0ac1 | |||
| f99fb59cd4 | |||
| dbfae9ea81 | |||
| f33023f8e0 | |||
| 5847f40ab5 | |||
| f656a2d2e9 | |||
| 578503c1ca | |||
| 39dca97f9b | |||
| 283f62813f | |||
| f89bdf7edf | |||
| 86c1da2cf5 |
@@ -8,6 +8,13 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
||||
maintainer procedure and prepared Woodpecker workflow for hosted note-page
|
||||
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
|
||||
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.
|
||||
|
||||
88
build.go
88
build.go
@@ -2,9 +2,14 @@ package releasekit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
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.
|
||||
@@ -22,11 +27,14 @@ type BuildSpec struct {
|
||||
|
||||
// BuildRequest contains the source and output settings for Build.
|
||||
type BuildRequest struct {
|
||||
RepositoryDir string // source repository whose current HEAD is recorded.
|
||||
Version string // stable vMAJOR.MINOR.PATCH value injected at link time.
|
||||
Spec BuildSpec // application package, artifact name, and targets.
|
||||
OutputDir string // output location; empty selects <repository>/dist.
|
||||
Output io.Writer // optional build progress destination.
|
||||
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.
|
||||
Spec BuildSpec // application package, artifact name, and targets.
|
||||
OutputDir string // output location; relative paths use ModuleDir and empty selects <module>/dist.
|
||||
Output io.Writer // optional build progress destination.
|
||||
}
|
||||
|
||||
// BuildArtifact identifies one generated binary and its SHA-256 digest.
|
||||
@@ -40,6 +48,9 @@ type BuildArtifact struct {
|
||||
// BuildResult describes the source and artifacts reached by Build.
|
||||
type BuildResult struct {
|
||||
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.
|
||||
// SourceVerified reports clean source and the same HEAD observed before and
|
||||
// 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.
|
||||
}
|
||||
|
||||
// Build compiles the selected targets with Version injected through buildinfo.
|
||||
// It uses the available compatible local Go toolchain, applies ReleaseKit's
|
||||
// readonly offline build settings, preserves effective cache and tool settings,
|
||||
// and verifies the version command on the Go host before succeeding. An empty
|
||||
// target set records source identity without running the Go toolchain. Errors
|
||||
// return partial build facts in the result.
|
||||
// Build resolves the selected module's working-tree identity, then compiles the
|
||||
// selected targets with Version injected through buildinfo. It uses the
|
||||
// available compatible local Go toolchain, applies ReleaseKit's readonly
|
||||
// offline build settings, preserves effective cache and tool settings, and
|
||||
// verifies the version command on the Go host before succeeding. Relative
|
||||
// output paths use ModuleDir; an empty path selects its dist directory. An
|
||||
// empty target set records source identity without running the Go toolchain.
|
||||
// Errors return partial build facts in the result.
|
||||
func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
|
||||
result, err := internalbuild.Build(ctx, internalbuild.Request{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
Version: request.Version,
|
||||
Spec: *internalBuildSpec(&request.Spec),
|
||||
OutputDir: request.OutputDir,
|
||||
Output: request.Output,
|
||||
spec := *internalBuildSpec(&request.Spec)
|
||||
result := internalbuild.Result{Version: request.Version}
|
||||
failure := func(err error) (BuildResult, error) {
|
||||
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,
|
||||
Output: request.Output,
|
||||
})
|
||||
return publicBuildResult(result), err
|
||||
}
|
||||
|
||||
func publicBuildResult(result internalbuild.Result) BuildResult {
|
||||
public := BuildResult{
|
||||
RepositoryDir: result.RepositoryDir,
|
||||
ModuleDir: result.ModuleDir,
|
||||
ModulePath: result.ModulePath,
|
||||
Tag: result.Tag,
|
||||
CandidateCommit: result.CandidateCommit,
|
||||
SourceVerified: result.SourceVerified,
|
||||
Version: result.Version,
|
||||
MainPackage: result.MainPackage,
|
||||
BinaryName: result.BinaryName,
|
||||
MainPackage: result.Settings.MainPackage,
|
||||
BinaryName: result.Settings.BinaryName,
|
||||
OutputDir: result.OutputDir,
|
||||
Compiler: result.Compiler,
|
||||
Targets: make([]Target, 0, len(result.Targets)),
|
||||
Targets: make([]Target, 0, len(result.Settings.Targets)),
|
||||
Artifacts: make([]BuildArtifact, 0, len(result.Artifacts)),
|
||||
}
|
||||
for _, target := range result.Targets {
|
||||
for _, target := range result.Settings.Targets {
|
||||
public.Targets = append(public.Targets, Target{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, artifact := range result.Artifacts {
|
||||
@@ -90,7 +128,7 @@ func Build(ctx context.Context, request BuildRequest) (BuildResult, error) {
|
||||
SHA256: artifact.SHA256,
|
||||
})
|
||||
}
|
||||
return public, err
|
||||
return public
|
||||
}
|
||||
|
||||
func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
||||
@@ -105,8 +143,10 @@ func internalBuildSpec(spec *BuildSpec) *internalbuild.Spec {
|
||||
}
|
||||
|
||||
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}
|
||||
for _, target := range result.Targets {
|
||||
input := internalBuildSpec(&BuildSpec{MainPackage: result.MainPackage, BinaryName: result.BinaryName, Targets: result.Targets})
|
||||
settings := artifact.NormalizeSettings(input.MainPackage, input.BinaryName, input.Targets)
|
||||
converted := &internalbuild.Result{RepositoryDir: result.RepositoryDir, ModuleDir: result.ModuleDir, ModulePath: result.ModulePath, Tag: result.Tag, CandidateCommit: result.CandidateCommit, SourceVerified: result.SourceVerified, Settings: settings, Version: result.Version, MainPackage: settings.MainPackage, BinaryName: settings.BinaryName, OutputDir: result.OutputDir, Compiler: result.Compiler}
|
||||
for _, target := range settings.Targets {
|
||||
converted.Targets = append(converted.Targets, internalbuild.Target{OS: target.OS, Arch: target.Arch})
|
||||
}
|
||||
for _, artifact := range result.Artifacts {
|
||||
|
||||
215
build_test.go
215
build_test.go
@@ -18,6 +18,29 @@ import (
|
||||
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) {
|
||||
repository := newBuildConsumer(t, `package main
|
||||
|
||||
@@ -115,7 +138,7 @@ func main() {
|
||||
t.Setenv("GOARCH", "arm64")
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v2.3.4",
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
@@ -126,7 +149,7 @@ func main() {
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v; partial result = %#v", err, result)
|
||||
}
|
||||
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v2.3.4-windows-arm64.exe" {
|
||||
if len(result.Artifacts) != 1 || result.Artifacts[0].Filename != "fixture-v1.2.3-windows-arm64.exe" {
|
||||
t.Fatalf("build artifacts = %#v", result.Artifacts)
|
||||
}
|
||||
}
|
||||
@@ -166,7 +189,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
|
||||
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v2.3.4",
|
||||
Version: "v1.2.3",
|
||||
Spec: releasekit.BuildSpec{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "fixture",
|
||||
@@ -242,6 +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) {
|
||||
const source = `package main
|
||||
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
|
||||
@@ -432,6 +594,21 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
|
||||
|
||||
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.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")
|
||||
gitRun(t, repository.work, "add", "guide.md")
|
||||
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") {
|
||||
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 != "" {
|
||||
t.Fatalf("candidate build dirtied worktree: %s", got)
|
||||
}
|
||||
@@ -528,6 +725,18 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
|
||||
return repository
|
||||
}
|
||||
|
||||
func writeNestedBuildConsumer(t *testing.T, repository, moduleDir, modulePath, mainSource string) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
module := fmt.Sprintf("module %s\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", modulePath, filepath.ToSlash(root))
|
||||
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "go.mod")), module)
|
||||
writeBuildFixture(t, repository, filepath.ToSlash(filepath.Join(moduleDir, "cmd/app/main.go")), mainSource)
|
||||
return filepath.Join(repository, filepath.FromSlash(moduleDir))
|
||||
}
|
||||
|
||||
func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(".")
|
||||
|
||||
74
check.go
74
check.go
@@ -18,21 +18,30 @@ var ErrInvalidVersion = candidate.ErrInvalidVersion
|
||||
var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL
|
||||
|
||||
// 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 {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
Commit string
|
||||
Version string
|
||||
Version string // selected stable semantic version.
|
||||
}
|
||||
|
||||
// ReleaseRequest contains the inputs used to validate and optionally build one
|
||||
// local candidate. Remote is retained for publication operations; Check makes
|
||||
// no remote availability claim.
|
||||
type ReleaseRequest struct {
|
||||
// RepositoryDir names a directory within the source Git checkout. Operations
|
||||
// resolve its root before applying repository-relative selections.
|
||||
RepositoryDir string
|
||||
Product string
|
||||
Version string
|
||||
Remote string
|
||||
// ModuleDir selects a repository-relative module directory; empty selects
|
||||
// the repository root.
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string // stable semantic version, independent of the derived Git tag.
|
||||
Remote string
|
||||
// ExpectedPushURL optionally requires Publish's resolved push URL to match
|
||||
// this string exactly before validation or tag creation. Check ignores this
|
||||
// publication-only expectation. A mismatch returns ErrUnexpectedPushURL.
|
||||
@@ -46,9 +55,18 @@ type ReleaseRequest struct {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Go GoValidationSpec
|
||||
// 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
|
||||
// GoFormat checks the formatting of committed regular Go source files. It
|
||||
// does not modify repository contents and is disabled when false.
|
||||
GoFormat bool
|
||||
@@ -76,7 +94,7 @@ type FileSizeValidationSpec struct {
|
||||
// LimitBytes is the maximum decimal byte count. Nil selects 1,000,000;
|
||||
// zero is a zero-byte limit, and negative values are invalid.
|
||||
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.
|
||||
Exclusions []string
|
||||
}
|
||||
@@ -88,7 +106,8 @@ type ModuleDistributionValidationSpec struct {
|
||||
// nested paths and symlink entries. It does not select Go's test workspace.
|
||||
WorkspaceFiles bool
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -107,9 +126,12 @@ type GoValidationSpec struct {
|
||||
}
|
||||
|
||||
// Check validates one local, committed release candidate. It requires a clean
|
||||
// worktree and index on main, a committed note matching Product and Version,
|
||||
// and an unused local tag. It always runs the offline Go test suite with the
|
||||
// available compatible local Go toolchain and Go host target, then calls
|
||||
// worktree and index on main, a selected module declaration and module-local
|
||||
// committed note matching Product and Version, and an unused local tag. An
|
||||
// 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
|
||||
// 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
|
||||
@@ -121,7 +143,7 @@ type GoValidationSpec struct {
|
||||
// files after formatting. Its zero value disables that distribution check and
|
||||
// does not change Go's workspace environment.
|
||||
// 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.
|
||||
// Validation.FileSize checks each selected committed blob after module
|
||||
// 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) {
|
||||
checked, err := candidate.Check(ctx, candidateRequest(request))
|
||||
if err != nil {
|
||||
return Candidate{}, convertValidationError(err)
|
||||
return publicCandidate(checked), convertValidationError(err)
|
||||
}
|
||||
return publicCandidate(checked), nil
|
||||
}
|
||||
|
||||
func publicCandidate(checked candidate.Candidate) Candidate {
|
||||
return Candidate{
|
||||
RepositoryDir: checked.RepositoryDir,
|
||||
ModuleDir: checked.ModuleDir,
|
||||
ModulePath: checked.ModulePath,
|
||||
Tag: checked.Tag,
|
||||
Commit: checked.Commit,
|
||||
Version: checked.Version,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||
fileSize := internalFileSizeSettings(request.Validation.FileSize)
|
||||
result := candidate.Request{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
ModuleDir: request.ModuleDir,
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
Validation: validation.Settings{
|
||||
Race: request.Validation.Go.Race,
|
||||
FullVet: request.Validation.Go.FullVet,
|
||||
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
|
||||
SharedFiles: copyStringSlice(request.Validation.SharedFiles),
|
||||
GoFormat: request.Validation.GoFormat,
|
||||
Markdown: internalMarkdownSettings(request.Validation.Markdown),
|
||||
ModuleDistribution: validation.ModuleDistributionSettings{
|
||||
@@ -176,6 +207,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
|
||||
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 {
|
||||
if spec == nil {
|
||||
return nil
|
||||
@@ -203,10 +241,6 @@ func candidateChecks(checks func(context.Context, Candidate) error) func(context
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, captured candidate.Candidate) error {
|
||||
return checks(ctx, Candidate{
|
||||
RepositoryDir: captured.RepositoryDir,
|
||||
Commit: captured.Commit,
|
||||
Version: captured.Version,
|
||||
})
|
||||
return checks(ctx, publicCandidate(captured))
|
||||
}
|
||||
}
|
||||
|
||||
38
check_request_test.go
Normal file
38
check_request_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
103
cmd/releasekit/assets_test.go
Normal file
103
cmd/releasekit/assets_test.go
Normal 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{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.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.releaseCreates != 1 || state.assetUploads != 2 {
|
||||
t.Fatalf("delivery status=%d, creates=%d, uploads=%d, stderr=%s", status, state.releaseCreates, state.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.assetUploads != 2 {
|
||||
t.Fatalf("physical-path retry status=%d, uploads=%d, stderr=%s", status, state.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.apiRequests
|
||||
if status := deliverWithSettings(context.Background(), invocation, wrongModule, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore {
|
||||
t.Fatalf("wrong module status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String())
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(receipt.Artifacts[0].Path, []byte("changed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestsBefore := state.apiRequests
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 1 || state.apiRequests != requestsBefore {
|
||||
t.Fatalf("changed content status=%d, requests=%d->%d, stderr=%s", status, requestsBefore, state.apiRequests, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
71
cmd/releasekit/committed_identity_test.go
Normal file
71
cmd/releasekit/committed_identity_test.go
Normal 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{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.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
cliGitRun(t, repository, "tag", state.tag)
|
||||
cliGitRun(t, repository, "checkout", "--detach", state.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.commit, state.tag},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if status := run(context.Background(), args, &stdout, &stderr); status != 0 || state.releaseCreates != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("%s sparse delivery status=%d creates=%d uploads=%d stderr=%s", args[0], status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
54
cmd/releasekit/example_source_test.go
Normal file
54
cmd/releasekit/example_source_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"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
|
||||
|
||||
@@ -31,14 +32,17 @@ const (
|
||||
|
||||
// Invocation contains parsed command settings owned by the CLI adapter.
|
||||
type Invocation struct {
|
||||
Command Command
|
||||
Repository string
|
||||
ConfigPath string
|
||||
OutputDir string
|
||||
AssetsDir string
|
||||
Remote string
|
||||
Commit string
|
||||
Version string
|
||||
Command Command
|
||||
Repository string
|
||||
ConfigPath string
|
||||
OutputDir string
|
||||
AssetsDir string
|
||||
Remote string
|
||||
Commit string
|
||||
Version string
|
||||
ExpectedModule string
|
||||
ExpectedTag string
|
||||
Prepared bool
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
var remote *string
|
||||
expectedModule := ""
|
||||
if action == PublishAction || action == CIDeliveryAction {
|
||||
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
|
||||
}
|
||||
expectedCommit := ""
|
||||
prepared := false
|
||||
if action == CIDeliveryAction {
|
||||
remote = flags.String("remote", "origin", "Git remote")
|
||||
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 {
|
||||
return Invocation{}, err
|
||||
@@ -84,11 +94,25 @@ func Parse(args []string) (Invocation, error) {
|
||||
if action == CIDeliveryAction && !validGitObjectID(expectedCommit) {
|
||||
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 := ""
|
||||
if remote != nil {
|
||||
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:
|
||||
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.
|
||||
type Settings struct {
|
||||
Product string `json:"product"`
|
||||
ModuleDir string `json:"module_dir,omitempty"`
|
||||
Remote string `json:"remote,omitempty"`
|
||||
ExpectedPushURL string `json:"expected_push_url,omitempty"`
|
||||
Checks []CheckCommand `json:"checks,omitempty"`
|
||||
@@ -126,7 +151,10 @@ type Settings struct {
|
||||
|
||||
// ValidationSettings selects shared candidate checks in a release profile.
|
||||
type ValidationSettings struct {
|
||||
Go GoValidationSettings `json:"go,omitempty"`
|
||||
// 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"`
|
||||
// GoFormat checks the formatting of committed regular Go source files.
|
||||
// Omitted or false disables the check.
|
||||
GoFormat bool `json:"go_format,omitempty"`
|
||||
@@ -269,9 +297,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
|
||||
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.
|
||||
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 {
|
||||
environment := make(map[string]string, len(check.Env)+len(ownedEnv))
|
||||
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{
|
||||
Args: check.Args,
|
||||
Dir: repoDir,
|
||||
Dir: dir,
|
||||
Env: environment,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
|
||||
@@ -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" {
|
||||
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"})
|
||||
if err != nil {
|
||||
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" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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") {
|
||||
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 {
|
||||
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) {
|
||||
repoDir := t.TempDir()
|
||||
profilePath := filepath.Join(repoDir, "releasekit.json")
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -12,10 +11,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
|
||||
const BuildReceiptName = ".releasekit-build.json"
|
||||
const BuildReceiptSchemaVersion = 1
|
||||
|
||||
type ReceiptTarget struct {
|
||||
OS string `json:"os"`
|
||||
@@ -31,7 +32,11 @@ type ReceiptArtifact struct {
|
||||
|
||||
// BuildReceipt is private local coordination data for generated binaries.
|
||||
type BuildReceipt struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
RepositoryDir string `json:"repository_dir"`
|
||||
ModuleDir string `json:"module_dir"`
|
||||
ModulePath string `json:"module_path"`
|
||||
Tag string `json:"tag"`
|
||||
CandidateCommit string `json:"candidate_commit"`
|
||||
SourceVerified bool `json:"source_verified"`
|
||||
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)
|
||||
}
|
||||
receipt.OutputDir = resolvedDir
|
||||
if err := validateBuildReceipt(receipt, true); err != nil {
|
||||
if err := validateBuildReceipt(receipt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
contents, err := json.MarshalIndent(receipt, "", " ")
|
||||
@@ -111,41 +116,62 @@ func LoadBuildReceipt(path string) (BuildReceipt, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
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 receipt, nil
|
||||
}
|
||||
|
||||
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
|
||||
resolvedRepo, err := filepath.Abs(repositoryDir)
|
||||
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
|
||||
if !sameDirectory(receipt.RepositoryDir, repositoryDir) {
|
||||
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
|
||||
}
|
||||
want := normalizeReceiptTargets(settings.Targets)
|
||||
if len(want) != len(receipt.Targets) {
|
||||
want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.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
|
||||
}
|
||||
for i, target := range want {
|
||||
if target != receipt.Targets[i] {
|
||||
for i, target := range want.Targets {
|
||||
if target != got.Targets[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
|
||||
}
|
||||
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) {
|
||||
return errors.New("build receipt repository and output directories must be absolute")
|
||||
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) {
|
||||
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) {
|
||||
return errors.New("build receipt has invalid candidate commit")
|
||||
@@ -191,39 +217,38 @@ func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
|
||||
if !validSHA256(artifact.SHA256) {
|
||||
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
|
||||
}
|
||||
if verifyFiles {
|
||||
if err := verifyReceiptArtifact(artifact); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyReceiptArtifact(artifact ReceiptArtifact) error {
|
||||
info, err := os.Lstat(artifact.Path)
|
||||
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("inspect build artifact %q: %w", artifact.Path, err)
|
||||
return fmt.Errorf("relate module directory to repository: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
|
||||
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||
return errors.New("module directory is outside repository")
|
||||
}
|
||||
file, err := os.Open(artifact.Path)
|
||||
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 fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
|
||||
return 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)
|
||||
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
|
||||
}
|
||||
|
||||
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
|
||||
func parseReceiptTargets(targets []string) []artifact.Target {
|
||||
parsed := make([]artifact.Target, 0, len(targets))
|
||||
for _, value := range targets {
|
||||
goos, goarch, ok := strings.Cut(value, "/")
|
||||
@@ -232,12 +257,15 @@ func normalizeReceiptTargets(targets []string) []ReceiptTarget {
|
||||
}
|
||||
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
|
||||
}
|
||||
normalized := artifact.NormalizeTargets(parsed)
|
||||
result := make([]ReceiptTarget, 0, len(normalized))
|
||||
for _, target := range normalized {
|
||||
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
|
||||
return parsed
|
||||
}
|
||||
|
||||
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 result
|
||||
return parsed
|
||||
}
|
||||
|
||||
func validCommitID(value string) bool {
|
||||
|
||||
@@ -3,62 +3,103 @@ package cli
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildReceiptRoundTripBindsFilesAndBuildInputs(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
repositoryDir := t.TempDir()
|
||||
filename := "example-v1.2.3-linux-amd64"
|
||||
artifactPath := filepath.Join(outputDir, filename)
|
||||
contents := []byte("built application")
|
||||
if err := os.WriteFile(artifactPath, contents, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
receipt := BuildReceipt{
|
||||
RepositoryDir: repositoryDir,
|
||||
CandidateCommit: strings.Repeat("a", 40),
|
||||
Version: "v1.2.3",
|
||||
MainPackage: "./cmd/example",
|
||||
BinaryName: "example",
|
||||
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
||||
Compiler: "go version go1.25.0 linux/amd64",
|
||||
Artifacts: []ReceiptArtifact{{
|
||||
Path: artifactPath,
|
||||
Filename: filename,
|
||||
Target: ReceiptTarget{OS: "linux", Arch: "amd64"},
|
||||
SHA256: hex.EncodeToString(digest[:]),
|
||||
}},
|
||||
}
|
||||
path, err := WriteBuildReceipt(outputDir, receipt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadBuildReceipt(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := BuildSettings{
|
||||
MainPackage: "./cmd/example",
|
||||
BinaryName: "example",
|
||||
Targets: []string{"linux/amd64", "linux/amd64"},
|
||||
}
|
||||
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||
t.Fatalf("receipt does not match normalized build settings: %#v", loaded)
|
||||
}
|
||||
settings.BinaryName = "other"
|
||||
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||
t.Fatal("receipt matched changed build settings")
|
||||
}
|
||||
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadBuildReceipt(path); err == nil || !strings.Contains(err.Error(), "hash changed") {
|
||||
t.Fatalf("LoadBuildReceipt() error = %v, want changed-file hash error", err)
|
||||
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()
|
||||
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"
|
||||
artifactPath := filepath.Join(outputDir, filename)
|
||||
contents := []byte("built application")
|
||||
if err := os.WriteFile(artifactPath, contents, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
receipt := BuildReceipt{
|
||||
SchemaVersion: BuildReceiptSchemaVersion,
|
||||
RepositoryDir: repositoryDir,
|
||||
ModuleDir: moduleDir,
|
||||
ModulePath: test.modulePath,
|
||||
Tag: test.tag,
|
||||
CandidateCommit: strings.Repeat("a", 40),
|
||||
Version: "v1.2.3",
|
||||
MainPackage: "./cmd/example",
|
||||
BinaryName: "example",
|
||||
Targets: []ReceiptTarget{{OS: "linux", Arch: "amd64"}},
|
||||
Compiler: "go version go1.25.0 linux/amd64",
|
||||
Artifacts: []ReceiptArtifact{{
|
||||
Path: artifactPath, Filename: filename,
|
||||
Target: ReceiptTarget{OS: "linux", Arch: "amd64"}, SHA256: hex.EncodeToString(digest[:]),
|
||||
}},
|
||||
}
|
||||
path, err := WriteBuildReceipt(outputDir, receipt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := LoadBuildReceipt(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := BuildSettings{MainPackage: "./cmd/example", BinaryName: "example", Targets: []string{"linux/amd64", "linux/amd64"}}
|
||||
if !loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||
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"
|
||||
if loaded.MatchesBuild(repositoryDir, strings.Repeat("a", 40), "v1.2.3", settings) {
|
||||
t.Fatal("receipt matched changed build settings")
|
||||
}
|
||||
if err := os.WriteFile(artifactPath, []byte("changed artifact"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadBuildReceipt(path); err != nil {
|
||||
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
|
||||
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"},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
44
cmd/releasekit/local_module_test.go
Normal file
44
cmd/releasekit/local_module_test.go
Normal 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.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.releaseCreates != 1 || state.assetUploads != 0 {
|
||||
t.Fatalf("page delivery status=%d creates=%d uploads=%d stderr=%s", status, state.releaseCreates, state.assetUploads, stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
"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() {
|
||||
@@ -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)
|
||||
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 {
|
||||
case cli.VersionCommand:
|
||||
@@ -42,11 +52,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
request := candidateRequest(invocation, settings, stderr)
|
||||
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
|
||||
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)
|
||||
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) {
|
||||
return 2
|
||||
}
|
||||
@@ -55,18 +69,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
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
|
||||
}
|
||||
candidate, err := releasekit.Check(ctx, request)
|
||||
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) {
|
||||
return 2
|
||||
}
|
||||
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
|
||||
case cli.BuildAction:
|
||||
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{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
Version: invocation.Version,
|
||||
Spec: *configuredBuildSpec(settings.Build),
|
||||
OutputDir: invocation.OutputDir,
|
||||
@@ -92,7 +107,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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")
|
||||
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 {
|
||||
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 {
|
||||
request := releasekit.ReleaseRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
Product: settings.Product,
|
||||
Version: invocation.Version,
|
||||
Remote: settings.Remote,
|
||||
@@ -147,9 +163,13 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
|
||||
if len(settings.Checks) != 0 {
|
||||
checks := settings.Checks
|
||||
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
|
||||
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
|
||||
"RELEASEKIT_VERSION": candidate.Version,
|
||||
"RELEASEKIT_CANDIDATE": candidate.Commit,
|
||||
return cli.RunChecks(ctx, candidate.ModuleDir, checks, map[string]string{
|
||||
"RELEASEKIT_VERSION": candidate.Version,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -161,7 +181,8 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
|
||||
return releasekit.ValidationSpec{}
|
||||
}
|
||||
result := releasekit.ValidationSpec{
|
||||
GoFormat: settings.GoFormat,
|
||||
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
|
||||
GoFormat: settings.GoFormat,
|
||||
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
|
||||
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
|
||||
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
|
||||
@@ -199,6 +220,42 @@ func candidateFailureText(err error) string {
|
||||
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 {
|
||||
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
|
||||
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 {
|
||||
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: invocation.Repository,
|
||||
Version: invocation.Version,
|
||||
Tag: invocation.ExpectedTag,
|
||||
Remote: invocation.Remote,
|
||||
ExpectedCommit: invocation.Commit,
|
||||
VerifyOnly: invocation.Prepared,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
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)
|
||||
if status != 0 {
|
||||
return status
|
||||
}
|
||||
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)
|
||||
return 1
|
||||
}
|
||||
@@ -282,10 +347,17 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
}
|
||||
if hasTargets {
|
||||
if invocation.AssetsDir != "" {
|
||||
assetsDir := invocation.AssetsDir
|
||||
if !filepath.IsAbs(assetsDir) {
|
||||
assetsDir = filepath.Join(invocation.Repository, assetsDir)
|
||||
commit, err := git.CaptureHead(ctx, invocation.Repository)
|
||||
if err != nil {
|
||||
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))
|
||||
if err != nil {
|
||||
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{
|
||||
RepositoryDir: invocation.Repository,
|
||||
ModuleDir: settings.ModuleDir,
|
||||
Product: settings.Product,
|
||||
Version: invocation.Version,
|
||||
ExpectedTag: invocation.ExpectedTag,
|
||||
Destination: destination,
|
||||
Build: buildSpec,
|
||||
Prebuilt: prebuilt,
|
||||
Output: stderr,
|
||||
})
|
||||
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)
|
||||
writeDeliveryRecovery(stderr, result)
|
||||
if errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
@@ -320,7 +394,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
|
||||
}
|
||||
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 != "" {
|
||||
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) {
|
||||
switch result.State {
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
return
|
||||
}
|
||||
@@ -359,7 +433,8 @@ func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
|
||||
|
||||
func buildResultFromReceipt(receipt cli.BuildReceipt) *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,
|
||||
OutputDir: receipt.OutputDir, Compiler: receipt.Compiler,
|
||||
}
|
||||
@@ -393,7 +468,11 @@ func configuredBuildSpec(settings *cli.BuildSettings) *releasekit.BuildSpec {
|
||||
|
||||
func receiptFromBuild(result releasekit.BuildResult) cli.BuildReceipt {
|
||||
receipt := cli.BuildReceipt{
|
||||
SchemaVersion: cli.BuildReceiptSchemaVersion,
|
||||
RepositoryDir: result.RepositoryDir,
|
||||
ModuleDir: result.ModuleDir,
|
||||
ModulePath: result.ModulePath,
|
||||
Tag: result.Tag,
|
||||
CandidateCommit: result.CandidateCommit,
|
||||
SourceVerified: result.SourceVerified,
|
||||
Version: result.Version,
|
||||
|
||||
@@ -51,6 +51,13 @@ func TestUnknownCommand(t *testing.T) {
|
||||
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
|
||||
raceSelected := racePrerequisitesAvailable()
|
||||
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")
|
||||
orderPath := filepath.Join(t.TempDir(), "check-order")
|
||||
t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath)
|
||||
@@ -70,9 +77,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
settings := cli.Settings{
|
||||
Product: "Example Product",
|
||||
Remote: "origin",
|
||||
Checks: []cli.CheckCommand{check("first"), check("second")},
|
||||
Product: "Example Product",
|
||||
ModuleDir: "integrations/ckconfig",
|
||||
Remote: "origin",
|
||||
Checks: []cli.CheckCommand{check("first"), check("second")},
|
||||
}
|
||||
if raceSelected {
|
||||
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
|
||||
@@ -102,7 +110,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
if raceSelected {
|
||||
@@ -154,6 +165,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) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -172,6 +217,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) {
|
||||
repository := newCLIRepository(t)
|
||||
profile, err := json.Marshal(cli.Settings{
|
||||
@@ -328,10 +400,10 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
|
||||
}
|
||||
commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||
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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
|
||||
@@ -351,6 +423,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) {
|
||||
repository := newCLIBuildRepository(t, `package main
|
||||
|
||||
@@ -381,7 +494,7 @@ func main() {
|
||||
BinaryName: "fixture",
|
||||
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)
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].Filename)) || !bytes.Contains(stdout.Bytes(), []byte(receipt.Artifacts[0].SHA256)) {
|
||||
@@ -440,7 +553,12 @@ func TestCandidateCheckHelper(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -601,12 +719,14 @@ func TestCIDeliverLoadsTaggedProfileAndSkipsUnavailablePageOnlyDependencies(t *t
|
||||
expectedCommit := pushTaggedCLIRepository(t, repository)
|
||||
state.commit = expectedCommit
|
||||
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("GOPROXY", "off")
|
||||
t.Setenv("GOSUMDB", "off")
|
||||
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 {
|
||||
t.Fatalf("run() status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
|
||||
}
|
||||
@@ -660,6 +780,55 @@ func TestCIDeliverRejectsWrongCommitBeforeGoOrHostedRequests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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.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.apiRequests != 0 {
|
||||
t.Fatalf("hosted API requests = %d, want 0", state.apiRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIDeliverPreparesDependenciesAndBuildsConfiguredTargets(t *testing.T) {
|
||||
repository := newDeliveryBuildRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
@@ -760,7 +929,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
||||
{
|
||||
name: "partial",
|
||||
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",
|
||||
},
|
||||
{
|
||||
@@ -773,7 +942,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
|
||||
{
|
||||
name: "unknown",
|
||||
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",
|
||||
missingObservation: "pending SHA256SUMS",
|
||||
},
|
||||
@@ -881,7 +1050,7 @@ func TestDeliverRejectsCheckoutThatDiffersFromLocalTag(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) {
|
||||
repository := newCLIRepository(t)
|
||||
state := &deliveryServerState{}
|
||||
@@ -902,6 +1071,16 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
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")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
|
||||
@@ -917,6 +1096,11 @@ func TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites(t *testin
|
||||
if status != 1 || state.apiRequests != 0 || state.releaseCreates != 0 || state.assetUploads != 0 {
|
||||
t.Fatalf("%s receipt status=%d requests=%d stdout=%q stderr=%q", mode, status, state.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,9 +1121,11 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
|
||||
digest := sha256.Sum256(content)
|
||||
target := cli.ReceiptTarget{OS: "linux", Arch: "amd64"}
|
||||
receipt := cli.BuildReceipt{
|
||||
RepositoryDir: resolvedRepository, CandidateCommit: cliGitOutput(t, repository, "rev-parse", "HEAD"),
|
||||
SourceVerified: true,
|
||||
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName,
|
||||
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,
|
||||
Version: "v1.2.3", MainPackage: settings.MainPackage, BinaryName: settings.BinaryName,
|
||||
OutputDir: assetsDir, Targets: []cli.ReceiptTarget{target}, Compiler: "go version fixture",
|
||||
Artifacts: []cli.ReceiptArtifact{{Path: artifactPath, Filename: filename, Target: target, SHA256: hex.EncodeToString(digest[:])}},
|
||||
}
|
||||
@@ -950,6 +1136,7 @@ func writeDeliveryReceipt(t *testing.T, repository string, settings cli.BuildSet
|
||||
}
|
||||
|
||||
type deliveryServerState struct {
|
||||
tag string
|
||||
malformedCreate bool
|
||||
failAssetListing bool
|
||||
failAssetDownloads bool
|
||||
@@ -969,6 +1156,9 @@ type deliveryServerState struct {
|
||||
|
||||
func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Server {
|
||||
t.Helper()
|
||||
if state.tag == "" {
|
||||
state.tag = "v1.2.3"
|
||||
}
|
||||
if state.assets == nil {
|
||||
state.assets = make(map[string][]byte)
|
||||
}
|
||||
@@ -981,7 +1171,7 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
case "/api/v1/repos/owner/example/tags/" + state.tag:
|
||||
if state.commit == "" {
|
||||
t.Error("tag source commit was not initialized")
|
||||
}
|
||||
@@ -989,8 +1179,8 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
if commit == "" {
|
||||
commit = state.commit
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + state.tag:
|
||||
if !state.pageExists {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -1011,13 +1201,13 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Errorf("decode create payload: %v", err)
|
||||
}
|
||||
if payload.Tag != "v1.2.3" || payload.Target != state.commit {
|
||||
if payload.Tag != state.tag || payload.Target != state.commit {
|
||||
t.Errorf("release source payload = %#v", payload)
|
||||
}
|
||||
state.pageExists, state.releaseCreates, state.noteBody = true, state.releaseCreates+1, payload.Body
|
||||
if state.malformedCreate {
|
||||
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": state.tag, "name": "inconsistent response", "body": payload.Body})
|
||||
return
|
||||
}
|
||||
writeDeliveryRelease(w, serverURL, state)
|
||||
@@ -1096,7 +1286,7 @@ func writeDeliveryRelease(w http.ResponseWriter, serverURL string, state *delive
|
||||
if 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": serverURL + "/releases/10", "tag_name": state.tag, "target_commitish": state.commit, "name": product + " v1.2.3", "body": state.noteBody, "draft": false, "prerelease": false})
|
||||
}
|
||||
|
||||
func writeDeliveryProfile(t *testing.T, repository string, settings cli.Settings) {
|
||||
|
||||
247
cmd/releasekit/multi_module_example_test.go
Normal file
247
cmd/releasekit/multi_module_example_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
408
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
408
cmd/releasekit/multi_module_woodpecker_test.go
Normal file
@@ -0,0 +1,408 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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.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.commit = commit
|
||||
env := multiModuleWorkflowEnvironment(t, repository, commit, tag, toolBinary, proxy)
|
||||
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.creates != 1 || delivery.requests == 0 || delivery.tag != tag {
|
||||
t.Fatalf("page delivery fixture = %#v", delivery)
|
||||
}
|
||||
|
||||
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.creates != 1 || delivery.tag != tag {
|
||||
t.Fatalf("tag retry did not reuse the tagged mapping: %#v", delivery)
|
||||
}
|
||||
})
|
||||
|
||||
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.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.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)
|
||||
}
|
||||
if delivery.creates != 1 || delivery.uploads != 2 || delivery.tag != tag {
|
||||
t.Fatalf("binary delivery fixture = %#v", delivery)
|
||||
}
|
||||
if _, ok := delivery.assets["core-v2.1.0-linux-amd64"]; !ok {
|
||||
t.Fatalf("binary asset missing: %#v", delivery.assets)
|
||||
}
|
||||
if _, ok := delivery.assets["SHA256SUMS"]; !ok {
|
||||
t.Fatalf("checksum asset missing: %#v", delivery.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.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.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.creates != 1 || delivery.tag != tag {
|
||||
t.Fatalf("virtual-major delivery fixture = %#v", delivery)
|
||||
}
|
||||
})
|
||||
|
||||
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.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.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.requests != 0 {
|
||||
t.Fatalf("exact event tag mismatch reached hosted delivery: %#v", delivery)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type mappedDeliveryFixture 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{tag: tag, assets: make(map[string][]byte)}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state.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": state.commit}})
|
||||
case apiRoot + "releases/tags/" + escapedTag:
|
||||
if !state.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": state.commit, "name": state.name, "body": state.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 != state.commit {
|
||||
t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
state.page = true
|
||||
state.name = payload.Name
|
||||
state.body = payload.Body
|
||||
state.creates++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": 10, "html_url": serverURL(r) + "/releases/10", "tag_name": tag, "target_commitish": state.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(state.assets))
|
||||
for name, content := range state.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)
|
||||
}
|
||||
state.assets[name] = content
|
||||
state.uploads++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.uploads, "name": name, "size": len(content)})
|
||||
default:
|
||||
if strings.HasPrefix(r.URL.Path, "/files/") {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
if content, ok := state.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)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
state.URL = server.URL
|
||||
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", tag)
|
||||
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)
|
||||
}
|
||||
34
cmd/releasekit/repository_test.go
Normal file
34
cmd/releasekit/repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
|
||||
t.Fatalf("template error = %v, want propagated exit 37; output=%q", err, output)
|
||||
}
|
||||
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>", "<--commit>", "<" + strings.ToUpper(commit) + ">", "<v1.2.3>"} {
|
||||
if !strings.Contains(args, expected) {
|
||||
t.Errorf("selected executable arguments %q omit %q", args, expected)
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
|
||||
if proxyRequests.Load() == 0 {
|
||||
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)
|
||||
}
|
||||
log := readWoodpeckerFixtureFile(t, goLog)
|
||||
|
||||
29
delivery.go
29
delivery.go
@@ -45,10 +45,16 @@ type DeliveryAsset struct {
|
||||
// page-only delivery when both are nil. Build with no targets, or an empty
|
||||
// Prebuilt result, also performs page-only delivery.
|
||||
type DeliveryRequest struct {
|
||||
RepositoryDir string
|
||||
Product string
|
||||
Version string
|
||||
Destination GiteaDestination
|
||||
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
|
||||
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
|
||||
// Build selects binaries to build in temporary storage outside the checkout.
|
||||
Build *BuildSpec
|
||||
// Prebuilt supplies an existing build whose files remain caller-owned.
|
||||
@@ -62,7 +68,11 @@ type DeliveryRequest struct {
|
||||
// current observations: an asset appears in at most one slice, and early
|
||||
// failures may leave some declared assets unreported.
|
||||
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
|
||||
ReleaseURL string
|
||||
State DeliveryState
|
||||
@@ -83,6 +93,9 @@ type DeliveryResult struct {
|
||||
// requests are compiled into temporary storage that Deliver removes on return.
|
||||
// Prebuilt artifacts remain caller-owned and are not removed. Both paths
|
||||
// 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) {
|
||||
result := DeliveryResult{Version: request.Version, State: DeliveryNotAttempted}
|
||||
destination := gitea.Destination{
|
||||
@@ -95,13 +108,19 @@ func Deliver(ctx context.Context, request DeliveryRequest) (DeliveryResult, erro
|
||||
}
|
||||
internalResult, err := internaldelivery.Deliver(ctx, internaldelivery.Request{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
ModuleDir: request.ModuleDir,
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
ExpectedTag: request.ExpectedTag,
|
||||
Destination: destination,
|
||||
Build: internalBuildSpec(request.Build),
|
||||
Prebuilt: prebuilt,
|
||||
Output: request.Output,
|
||||
})
|
||||
result.RepositoryDir = internalResult.RepositoryDir
|
||||
result.ModuleDir = internalResult.ModuleDir
|
||||
result.ModulePath = internalResult.ModulePath
|
||||
result.Tag = internalResult.Tag
|
||||
result.CandidateCommit = internalResult.CandidateCommit
|
||||
result.ReleaseURL = internalResult.ReleaseURL
|
||||
result.State = DeliveryState(internalResult.State)
|
||||
|
||||
66
delivery_reads_linux_test.go
Normal file
66
delivery_reads_linux_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
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.NewServer(http.HandlerFunc(state.serveHTTP))
|
||||
defer server.Close()
|
||||
state.baseURL = server.URL
|
||||
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)
|
||||
}
|
||||
}
|
||||
141
delivery_test.go
141
delivery_test.go
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -190,6 +191,14 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.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()
|
||||
t.Setenv("TMPDIR", temporaryRoot)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
@@ -202,7 +211,7 @@ func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
|
||||
Version: "v1.2.3",
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Build: test.build,
|
||||
Prebuilt: test.prebuilt,
|
||||
Prebuilt: prebuilt,
|
||||
Output: &progress,
|
||||
})
|
||||
if err != nil || result.State != releasekit.DeliveryComplete {
|
||||
@@ -221,6 +230,106 @@ 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.requests != 0 {
|
||||
t.Fatalf("delivery with inconsistent expected tag = %#v, %v; hosted requests %d", wrongTag, err, remote.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.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.pages) != 2 || !remote.pages["v1.2.3"] || !remote.pages[nestedTag] {
|
||||
t.Fatalf("created hosted pages = %#v", remote.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.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.tag,
|
||||
Destination: facadeDeliveryDestination(server.URL),
|
||||
Prebuilt: &prebuilt,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "selected tagged module") || result.Tag != remote.tag {
|
||||
t.Fatalf("nested delivery with root artifacts = %#v, %v", result, err)
|
||||
}
|
||||
if remote.requests != 0 {
|
||||
t.Fatalf("hosted requests before prebuilt identity rejection = %d", remote.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) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
writeBuildFixture(t, repository, "local-change.txt", "dirty source")
|
||||
@@ -255,6 +364,7 @@ func TestDeliverRejectsDirtyTaggedSourceBeforeBuildOrHostedWrites(t *testing.T)
|
||||
|
||||
func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
tagCommit := buildGit(t, repository, "rev-parse", "HEAD")
|
||||
writeBuildFixture(t, repository, "later-change.txt", "new commit")
|
||||
buildGit(t, repository, "add", "--all")
|
||||
buildGit(t, repository, "commit", "-m", "advance checkout after release tag")
|
||||
@@ -276,7 +386,7 @@ func TestDeliverRejectsCheckoutAwayFromTagBeforeBuildOrHostedWrites(t *testing.T
|
||||
},
|
||||
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)
|
||||
}
|
||||
if progress.Len() != 0 {
|
||||
@@ -474,7 +584,10 @@ func (writer deliveryCancelWriter) Write(content []byte) (int, error) {
|
||||
type facadeDeliveryServer struct {
|
||||
t *testing.T
|
||||
commit string
|
||||
page bool
|
||||
tag string
|
||||
version string
|
||||
noteBody string
|
||||
pages map[string]bool
|
||||
requests int
|
||||
assets map[string][]byte
|
||||
url string
|
||||
@@ -482,7 +595,7 @@ type facadeDeliveryServer struct {
|
||||
|
||||
func newFacadeDeliveryServer(t *testing.T, repository string) (*httptest.Server, *facadeDeliveryServer) {
|
||||
t.Helper()
|
||||
state := &facadeDeliveryServer{t: t, commit: buildGit(t, repository, "rev-parse", "HEAD"), assets: make(map[string][]byte)}
|
||||
state := &facadeDeliveryServer{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))
|
||||
state.url = server.URL
|
||||
return server, state
|
||||
@@ -495,11 +608,11 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
state.t.Errorf("authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/owner/example/tags/v1.2.3":
|
||||
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
|
||||
if !state.page {
|
||||
switch r.URL.EscapedPath() {
|
||||
case "/api/v1/repos/owner/example/tags/" + url.PathEscape(state.tag):
|
||||
_, _ = fmt.Fprintf(w, `{"name":%q,"commit":{"sha":%q}}`, state.tag, state.commit)
|
||||
case "/api/v1/repos/owner/example/releases/tags/" + url.PathEscape(state.tag):
|
||||
if !state.pages[state.tag] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -516,10 +629,10 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Tag != "v1.2.3" || payload.Target != state.commit || payload.Name != "Fixture v1.2.3" {
|
||||
if payload.Tag != state.tag || payload.Target != state.commit || payload.Name != "Fixture "+state.version {
|
||||
state.t.Errorf("release payload = %#v", payload)
|
||||
}
|
||||
state.page = true
|
||||
state.pages[state.tag] = true
|
||||
state.writeRelease(w)
|
||||
case "/api/v1/repos/owner/example/releases/10/assets":
|
||||
if r.Method == http.MethodGet {
|
||||
@@ -564,9 +677,9 @@ func (state *facadeDeliveryServer) serveHTTP(w http.ResponseWriter, r *http.Requ
|
||||
func (state *facadeDeliveryServer) writeRelease(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 10, "html_url": state.url + "/releases/10", "tag_name": "v1.2.3",
|
||||
"target_commitish": state.commit, "name": "Fixture v1.2.3",
|
||||
"body": "# Fixture v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false,
|
||||
"id": 10, "html_url": state.url + "/releases/10", "tag_name": state.tag,
|
||||
"target_commitish": state.commit, "name": "Fixture " + state.version,
|
||||
"body": state.noteBody, "draft": false, "prerelease": false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
111
docs/adr/0005-module-release-scope.md
Normal file
111
docs/adr/0005-module-release-scope.md
Normal 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.
|
||||
84
docs/cli.md
84
docs/cli.md
@@ -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. The repository defaults to the current directory, and the profile
|
||||
defaults to `<repository>/releasekit.json`. A successful command prints the
|
||||
captured local commit and version. It checks the committed release note and
|
||||
version. The repository argument defaults to the current directory. ReleaseKit
|
||||
resolves its containing Git checkout root before selecting the module or profile;
|
||||
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
|
||||
`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
|
||||
@@ -31,17 +32,28 @@ selected package compilation and committed Go formatting, followed by
|
||||
configured additional checks in profile order. It does not inspect remote
|
||||
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
|
||||
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
|
||||
target binaries. Compilation failures use the `package-compile` identity and
|
||||
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
|
||||
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
|
||||
modifying files. When `validation.module_distribution.workspace_files` is true,
|
||||
`check` reports committed `go.work` and `go.work.sum` blobs, including nested
|
||||
and symlink entries, as `workspace-files`. This inspection is independent of
|
||||
`check` reports committed `go.work` and `go.work.sum` blobs in those inputs,
|
||||
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
|
||||
separate `build` setting runs after shared validation and configured checks.
|
||||
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:
|
||||
|
||||
```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
|
||||
@@ -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,
|
||||
file size, and Markdown. Configured checks run afterward in profile order. It
|
||||
then creates an annotated local tag
|
||||
and pushes only its exact tag object. It prints the confirmed version,
|
||||
candidate commit, and tag object
|
||||
and pushes only its exact full tag object. It prints the confirmed semantic
|
||||
version, full tag, candidate commit, and tag object
|
||||
on success. Validation
|
||||
progress and failed publication state, including the captured publication
|
||||
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.
|
||||
|
||||
When `validation.module_distribution.local_replacements` is true, the command
|
||||
parses committed `go.mod` declarations, including nested and vendor modules,
|
||||
after workspace-file inspection. Filesystem replacements use the
|
||||
`local-replacements` identity and include the declaration path, source line,
|
||||
parses committed `go.mod` declarations in the selected module after
|
||||
workspace-file inspection. Vendored contents remain in scope, while
|
||||
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
|
||||
resolved within the candidate, regardless of `GOWORK`.
|
||||
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
|
||||
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
|
||||
nonempty value must exactly match the effective push URL resolved by Git;
|
||||
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`
|
||||
invocation, and writes binaries plus the private `.releasekit-build.json`
|
||||
receipt into `dist` by default. `--output` selects a different directory;
|
||||
relative paths are resolved from the repository. Selected artifact filenames
|
||||
are regenerated while unrelated files are preserved. A complete nonempty build
|
||||
relative paths are resolved from the selected module directory. Selected
|
||||
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
|
||||
failure, standard error lists completed outputs and prior receipts in that
|
||||
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
|
||||
supplied, that directory must contain the private `.releasekit-build.json`
|
||||
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
|
||||
checks or publishes a tag.
|
||||
|
||||
@@ -197,24 +218,31 @@ known missing assets are added. See the
|
||||
Prepare a tagged CI checkout and deliver it:
|
||||
|
||||
```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`),
|
||||
requires the tag's peeled commit to match the required full `--commit` object
|
||||
ID, and checks out that commit detached. It then loads the profile from the
|
||||
prepared checkout, validates delivery settings and the configured token, and
|
||||
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.
|
||||
`TAG` is the complete Git tag name, including any module path prefix. Its last
|
||||
path component must be a stable semantic version; other commands continue to
|
||||
take a semantic `VERSION`. By default, `ci-deliver` fetches the exact `TAG` from
|
||||
the selected Git remote (default `origin`), requires its peeled commit to match
|
||||
the required full `--commit` object ID, checks out that commit detached, then
|
||||
verifies the tag, HEAD, and clean worktree. With `--prepared`, it performs those
|
||||
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
|
||||
from `--repo`, and absolute paths are used as-is. This command explicitly
|
||||
prepares and changes the selected checkout. Ordinary `deliver` continues to
|
||||
operate on already prepared source without fetching, checking out, or
|
||||
downloading dependencies.
|
||||
from `--repo`, and absolute paths are used as-is. The default mode prepares and
|
||||
changes the selected checkout; `--prepared` reuses a verified checkout.
|
||||
Ordinary `deliver` continues to operate on already prepared source without
|
||||
fetching, checking out, or downloading dependencies.
|
||||
|
||||
In Woodpecker, pass `CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as the
|
||||
repository, expected commit, and version arguments. The CLI does not read
|
||||
repository, expected commit, and full tag arguments. The CLI does not read
|
||||
provider-specific environment variable names. Checkout and dependency
|
||||
preparation failures return status 1; usage, profile, credential, and invalid
|
||||
version errors return status 2. Final delivery uses the same statuses as
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Configuration
|
||||
|
||||
ReleaseKit reads a JSON profile from `<repository>/releasekit.json` by default.
|
||||
The repository directory defaults to the current directory. A relative profile
|
||||
path is resolved from that repository directory; an absolute path is used as-is.
|
||||
ReleaseKit reads a JSON profile from `<repository-root>/releasekit.json` by default.
|
||||
The repository argument defaults to the current directory; the CLI resolves its
|
||||
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:
|
||||
|
||||
| Field | Required | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
@@ -16,6 +18,13 @@ The current profile fields are:
|
||||
| `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. |
|
||||
|
||||
`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
|
||||
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
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
test command keeps its existing vet behavior. These settings are independent.
|
||||
`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
|
||||
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
|
||||
@@ -69,12 +90,14 @@ Anchors keep Unicode letters and numbers, underscores, and hyphens; spaces
|
||||
become hyphens, and repeated headings receive numeric suffixes.
|
||||
|
||||
When `validation.module_distribution.workspace_files` is true, it reports
|
||||
committed blob entries whose basename is `go.work` or `go.work.sum`, including
|
||||
nested paths and symlinks. It checks candidate distribution contents regardless
|
||||
committed `go.work` and `go.work.sum` files in the selected module inputs,
|
||||
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,
|
||||
and it does not alter existing vendor behavior. Omitted or false disables it.
|
||||
When `validation.module_distribution.local_replacements` is true, it parses all
|
||||
committed `go.mod` declarations, including vendor and nested modules, and reports
|
||||
and vendored contents remain in scope. Omitted or false disables it. When
|
||||
`validation.module_distribution.local_replacements` is true, it parses the
|
||||
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
|
||||
replacements include relative and absolute paths; versioned module replacements
|
||||
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
|
||||
are invalid.
|
||||
|
||||
`validation.file_size.exclusions` contains literal repository-relative file or
|
||||
directory paths. Directory exclusions include committed descendants, using path
|
||||
`validation.file_size.exclusions` contains literal paths relative to the
|
||||
selected module. Directory exclusions include committed descendants, using path
|
||||
boundaries (`assets` does not exclude `assets-old`). Ordinary relative path
|
||||
normalization applies; metacharacters are literal and unmatched paths have no
|
||||
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
|
||||
change the checkout.
|
||||
|
||||
`ci-deliver` uses the same profile after preparing the selected tag in the
|
||||
checkout. It checks out the tag only after its peeled commit matches the
|
||||
provider-supplied commit ID, then loads the profile and validates delivery
|
||||
settings and credentials. It runs `go mod download` only when the profile has
|
||||
selected build targets; source-only delivery does not need application
|
||||
dependencies. This preparation may access the network and may update
|
||||
`go.sum`. The command takes CI values as arguments, so profile settings remain
|
||||
independent of Woodpecker environment variable names. See the
|
||||
`ci-deliver` takes a full Git `TAG`; its final path component is the stable
|
||||
semantic version and the complete value is checked against the profile's
|
||||
derived tag. By default it fetches the selected tag, confirms its peeled commit
|
||||
matches the provider-supplied commit ID, checks it out detached, and verifies
|
||||
the tag, HEAD, and clean worktree. With `--prepared`, it performs the same
|
||||
checks against an existing checkout without fetching or checking out. It then
|
||||
loads the profile from that source and validates delivery settings and
|
||||
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.
|
||||
|
||||
This application dependency preparation is separate from bootstrapping the
|
||||
|
||||
@@ -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:
|
||||
|
||||
```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 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
|
||||
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
|
||||
version and review the module changes.
|
||||
|
||||
Go tool dependencies require Go 1.24 or later, together with a toolchain that
|
||||
satisfies the selected ReleaseKit version and its dependencies. Tools share the
|
||||
consumer's module graph: their requirements participate in dependency version
|
||||
selection and can raise the consumer module's Go minimum. See the
|
||||
The Go tool dependency feature requires Go 1.24 or later. The current ReleaseKit
|
||||
module declares a Go 1.26.0 minimum, so use Go 1.26.0 or later for the current
|
||||
release. Tool dependencies share the consumer's module graph: their requirements
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
govern CI delivery and retries. Registering the CLI as a tool is sufficient for
|
||||
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.
|
||||
|
||||
@@ -36,19 +36,24 @@ func build(ctx context.Context) error {
|
||||
}
|
||||
```
|
||||
|
||||
`Build` records the repository's current `HEAD`, injects the selected stable
|
||||
version into `buildinfo.Version`, and returns the compiler identity, normalized
|
||||
targets, artifact paths, filenames, and SHA-256 digests. Names follow
|
||||
`Build` resolves `ModuleDir` and the selected module's working-tree declaration
|
||||
once, records the repository's current `HEAD`, injects the selected stable
|
||||
version into `buildinfo.Version`, and returns repository/module identity, the
|
||||
derived tag, compiler identity, normalized targets, artifact paths, filenames,
|
||||
and SHA-256 digests. It does not require a release note, unused tag, clean
|
||||
`main`, or publication. Names follow
|
||||
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
|
||||
target pair is built once, at its first position. Local builds may use modified
|
||||
source. `SourceVerified` is true only after a complete build observes clean
|
||||
source and the same HEAD before and after compilation; binary delivery requires
|
||||
that fact. This observation is not a source snapshot or attestation guarantee.
|
||||
|
||||
The default output directory is `<repository>/dist`; a relative `OutputDir` is
|
||||
resolved from the repository. Selected artifact filenames are regenerated. Other files in the output
|
||||
directory are left in place. A failed later build or native version check returns the binaries already
|
||||
generated in `BuildResult.Artifacts`.
|
||||
Go commands run from `ModuleDir`, so `MainPackage` is interpreted within that
|
||||
module. The default output directory is `<module>/dist`; a relative `OutputDir`
|
||||
is resolved from the module, while an absolute path is used as supplied.
|
||||
Selected artifact filenames are regenerated. Other files in the output
|
||||
directory are left in place. A failed later build or native version check
|
||||
returns the binaries already generated in `BuildResult.Artifacts`.
|
||||
|
||||
Builds use the available compatible Go toolchain selected locally; ReleaseKit
|
||||
does not download a different toolchain automatically. It disables workspace
|
||||
@@ -72,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
|
||||
`publish` run selected builds after the standard suite and any additional
|
||||
checks using temporary output outside the worktree. Candidate checks do not
|
||||
write a receipt or leave build files in the repository.
|
||||
write a receipt or leave build files in the repository. Disposable native
|
||||
version-check output also remains outside the whole repository when `TMPDIR`
|
||||
points inside the source tree.
|
||||
|
||||
For delivery from a tagged checkout, `deliver` builds selected tagged-source
|
||||
targets into temporary storage by default. Use `--assets DIR` to deliver a
|
||||
separate build output; ReleaseKit requires its receipt and verifies the source,
|
||||
build settings, and artifact hashes before the first Gitea write. See the
|
||||
separate build output; ReleaseKit requires the current module-bound receipt and
|
||||
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.
|
||||
|
||||
@@ -21,14 +21,27 @@ if err != nil {
|
||||
fmt.Println(result.Version, result.Commit)
|
||||
```
|
||||
|
||||
The repository must have a clean index and worktree on `main`, and the selected
|
||||
version must be stable `vMAJOR.MINOR.PATCH`. The committed
|
||||
`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
|
||||
that version may already exist.
|
||||
Set `ReleaseRequest.ModuleDir` to select a nested module using a
|
||||
repository-relative physical directory; omission or `.` selects the root
|
||||
module. ReleaseKit reads that module's committed `go.mod` and returns its
|
||||
physical `ModuleDir`, declared `ModulePath`, semantic `Version`, and complete
|
||||
`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
|
||||
available compatible local Go toolchain. Set `Validation.Go.Race` to run that
|
||||
The repository must have a clean index and worktree on `main`, and the selected
|
||||
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
|
||||
`go vet -mod=readonly ./...` after successful tests, with test-time vet disabled
|
||||
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
|
||||
values retain the ordinary native test behavior and select no compilation.
|
||||
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
|
||||
`go-format` without rewriting files. Its zero value disables the check; it runs
|
||||
after tests, selected vet, and package compilation, before `AdditionalChecks`.
|
||||
|
||||
Set `Validation.ModuleDistribution.WorkspaceFiles` to reject committed
|
||||
`go.work` and `go.work.sum` blobs, including nested and symlink entries. It
|
||||
examines the captured candidate even when `GOWORK=off`, ignores uncommitted
|
||||
files, and does not change Go test workspace selection or vendor behavior. The
|
||||
zero value disables the check; failures use `workspace-files`.
|
||||
`go.work` and `go.work.sum` files in the selected module inputs, including
|
||||
symlink entries. It examines the captured candidate even when
|
||||
`GOWORK=off`, ignores uncommitted files, and does not change Go test workspace
|
||||
selection; vendored contents remain in scope. The zero value disables the check;
|
||||
failures use `workspace-files`.
|
||||
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
|
||||
committed `go.mod` declarations throughout the candidate, including nested and
|
||||
vendor modules. It reports filesystem `replace` directives with their source
|
||||
path, line, and target as `local-replacements`; versioned replacements are
|
||||
the selected module's committed `go.mod` declarations. Vendored contents remain
|
||||
in scope; automatic inputs stop at independently released nested modules. An
|
||||
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
|
||||
and inspection errors retain their causes, and the rule applies even when
|
||||
`GOWORK=off`. Its zero value disables the check.
|
||||
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
|
||||
decimal bytes. `LimitBytes` is strict greater-than, accepts zero, and rejects
|
||||
negative values. `Exclusions` contains literal repository-relative files or
|
||||
directories; excluded directories include descendants. Symlink blobs are
|
||||
negative values. `Exclusions` contains literal files or directories relative to
|
||||
the selected module; excluded directories include descendants. Symlink blobs are
|
||||
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
|
||||
`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
|
||||
consumer-specific assertions—such as tests of maintained examples—in those
|
||||
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
|
||||
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,
|
||||
@@ -97,10 +132,11 @@ commands from `releasekit.json`.
|
||||
## Publishing the source tag
|
||||
|
||||
`releasekit.Publish` uses the same request and validation order: the standard
|
||||
suite runs first, followed by `AdditionalChecks` when supplied. It then performs
|
||||
authoritative remote checks, annotated-tag creation, and an exact tag push. The
|
||||
suite runs first, followed by `AdditionalChecks` when supplied. It surrounds
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Use `PrepareTaggedCheckout` when a disposable CI checkout must be verified
|
||||
@@ -20,7 +27,7 @@ or hosted content.
|
||||
```go
|
||||
err := releasekit.PrepareTaggedCheckout(ctx, releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repositoryDir,
|
||||
Version: version,
|
||||
Tag: "integrations/example/v1.2.3",
|
||||
ExpectedCommit: ciCommit,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -28,16 +35,25 @@ if err != nil {
|
||||
}
|
||||
```
|
||||
|
||||
For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after
|
||||
checkout preparation and before delivery. It runs `go mod download` with
|
||||
workspace discovery disabled and the available compatible local Go toolchain.
|
||||
For binary profiles, call `PrepareBuildDependencies` with a
|
||||
`DependencyPreparationRequest` selecting the repository and module after
|
||||
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
|
||||
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
|
||||
and command errors are returned. Page-only profiles do not need dependency
|
||||
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
|
||||
|
||||
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
|
||||
hosted body. A retry accepts an identical page and matching attachments, adds
|
||||
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,
|
||||
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
|
||||
temporary storage and delivers the result. To retain artifacts for a retry or
|
||||
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`
|
||||
directory must contain the private `.releasekit-build.json` receipt created by
|
||||
`build`. ReleaseKit checks the receipt's repository, commit, version, resolved
|
||||
build settings, and file hashes before making a remote request; caller-owned
|
||||
files remain available for retries. Ordinary `deliver` does not download
|
||||
application dependencies or change the checkout.
|
||||
`build`. ReleaseKit checks its schema, repository, commit, version, resolved
|
||||
module directory and path, full tag, normalized build settings, and artifact
|
||||
declarations. Old receipts must be regenerated with the updated `build`
|
||||
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
|
||||
`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.
|
||||
|
||||
To deliver binaries built separately, set `Prebuilt` to the `BuildResult`.
|
||||
ReleaseKit checks that it identifies the selected repository, tagged commit, and
|
||||
version, that its source was verified clean and unchanged during compilation,
|
||||
and that the current files still match their recorded digests. The files remain
|
||||
caller-owned, so callers can retain the original bytes for retries and are
|
||||
responsible for cleanup. With neither artifact source, an empty managed target
|
||||
set, or an empty prebuilt result, delivery is page-only. Do not set both `Build`
|
||||
and `Prebuilt` in one request.
|
||||
ReleaseKit checks its repository, module directory, declared module path, full
|
||||
tag, semantic version, tagged commit, normalized settings, and artifact evidence
|
||||
against the selected committed module. Binary results must record verified
|
||||
source. The current files are checked against their recorded digests before any
|
||||
hosted request. The files remain caller-owned, so callers can retain the
|
||||
original bytes for retries and are responsible for cleanup. With neither
|
||||
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
|
||||
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
|
||||
RepositoryDir: "/work/example",
|
||||
ModuleDir: "integrations/example",
|
||||
Product: "Example",
|
||||
Version: "v1.2.3",
|
||||
ExpectedTag: "integrations/example/v1.2.3", // optional CI event check
|
||||
Destination: releasekit.GiteaDestination{
|
||||
BaseURL: "https://gitea.example",
|
||||
Owner: "example",
|
||||
@@ -157,6 +185,11 @@ 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
|
||||
described in the [adoption guide](adoption.md). Ordinary package dependencies
|
||||
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
|
||||
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
|
||||
@@ -175,13 +208,26 @@ invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and
|
||||
|
||||
Tool bootstrap and application artifact preparation are separate. The bootstrap
|
||||
resolves and builds ReleaseKit from the tagged consumer's module configuration.
|
||||
Afterward, `ci-deliver` loads the profile and downloads application dependencies
|
||||
only when build targets are selected; it builds and delivers those targets with
|
||||
the note page. Source-only profiles skip application dependency downloads and
|
||||
binary work. Dependency preparation for binaries may use the network and may
|
||||
update an uncommitted checksum file in the checkout. A retry still uses the
|
||||
same immutable tag and that tag's module settings, even after the default branch
|
||||
selects a newer ReleaseKit version.
|
||||
After the workflow verifies and checks out the event tag, the resulting CLI uses
|
||||
`ci-deliver --prepared` to repeat the tag, commit, HEAD, and cleanliness checks
|
||||
without another fetch or checkout. It loads the tagged profile and downloads
|
||||
application dependencies only when build targets are selected; it builds and
|
||||
delivers those targets with the note page. Source-only profiles skip application
|
||||
dependency downloads and binary work. Dependency preparation for binaries may
|
||||
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
|
||||
the executable separately and invoke the same `ci-deliver` command. For
|
||||
|
||||
@@ -6,6 +6,7 @@ annotated source tag:
|
||||
```go
|
||||
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
|
||||
RepositoryDir: "/work/project",
|
||||
ModuleDir: "integrations/example",
|
||||
Product: "Example",
|
||||
Version: "v1.2.3",
|
||||
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},
|
||||
},
|
||||
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
|
||||
return runProjectChecks(ctx, candidate.RepositoryDir)
|
||||
return runProjectChecks(ctx, candidate.ModuleDir)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
release note, and checks the selected remote before running candidate checks.
|
||||
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.
|
||||
|
||||
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
|
||||
`go-format` without changing files. The default is disabled. The check follows
|
||||
tests, selected vet, and package compilation, and a failure stops publication
|
||||
before product checks or tag creation.
|
||||
|
||||
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
|
||||
are ignored, and Go workspace selection and vendor behavior are unchanged. Its
|
||||
zero value disables the check. Findings use `workspace-files` and stop
|
||||
publication before product checks or tag creation.
|
||||
Set `Validation.ModuleDistribution.LocalReplacements` independently to parse
|
||||
committed `go.mod` declarations across the candidate, including nested and
|
||||
vendor modules. Filesystem `replace` directives are reported with their source
|
||||
path, line, and target as `local-replacements`; versioned replacements are
|
||||
selected module's committed `go.mod` declarations. Vendored contents remain in
|
||||
scope; automatic inputs stop at independently released nested modules. Explicit
|
||||
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
|
||||
parse or inspection errors retain their causes. This check is independent of
|
||||
`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.
|
||||
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
|
||||
negative values. `Exclusions` contains literal repository-relative files or
|
||||
directories, including committed descendants of excluded directories. Symlink
|
||||
blob size is its link text; gitlinks are omitted. This check measures only the
|
||||
negative values. `Exclusions` contains literal files or directories relative to
|
||||
the selected module, including committed descendants of excluded directories.
|
||||
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
|
||||
`file-size`; it stops publication before product checks or tag creation.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Gitea release pages
|
||||
|
||||
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
|
||||
binary-producing profiles use the same delivery path. The
|
||||
[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,
|
||||
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
|
||||
SHA to equal the captured source commit. It then reads
|
||||
`/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
|
||||
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`
|
||||
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
|
||||
response, or malformed create response (including inconsistent decoded fields)
|
||||
triggers one lookup. An exact page found
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Go command environments
|
||||
|
||||
`internal/gotool` resolves effective Go settings once for a validation or build
|
||||
operation and applies the shared environment to its Go commands. It uses
|
||||
`internal/gotool` resolves effective Go settings when an operation first needs
|
||||
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
|
||||
errors.
|
||||
|
||||
@@ -35,9 +38,10 @@ uses the same ownership transfer when formatting a failure.
|
||||
|
||||
## Configuration discovery
|
||||
|
||||
The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then
|
||||
queries effective operational settings with that host selected. Both queries
|
||||
use `GOTOOLCHAIN=local` and controlled `GOFLAGS`. Discovery sets
|
||||
The owner asks the installed `go` command for `GOHOSTOS`, `GOHOSTARCH`, and the
|
||||
`GOENV` settings-file path, then queries effective operational settings with
|
||||
that host selected. Both queries use `GOTOOLCHAIN=local` and controlled
|
||||
`GOFLAGS`. Discovery sets
|
||||
`GOEXPERIMENT=none` and clears architecture feature variables so an invalid or
|
||||
nonbaseline tuning value cannot prevent settings from being read. The
|
||||
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
|
||||
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
|
||||
and included in command-failure diagnostics, so a successful query can emit
|
||||
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
|
||||
available to child commands.
|
||||
|
||||
Candidate validation uses native mode for the offline `go test` suite and
|
||||
selected full `go vet` pass. Package-compilation checks use static mode for each
|
||||
selected target and direct Go's output to the operating system null device, so
|
||||
they leave no binary artifact. Artifact builds also use static mode for
|
||||
selected-target compilation, then build the native version-check executable;
|
||||
that executable runs as a normal child process.
|
||||
Candidate validation runs the offline `go test` suite and selected full
|
||||
`go vet` pass from the selected module directory using native mode.
|
||||
Package-compilation checks use static mode for each selected target and direct
|
||||
Go's output to the operating system null device, so they leave no binary
|
||||
artifact. Artifact builds also compile selected targets in static mode, then
|
||||
build the native version-check executable, which runs as a normal child
|
||||
process.
|
||||
|
||||
@@ -25,7 +25,7 @@ Go declarations and GoDoc remain the owners of exact exported contracts.
|
||||
## Module and dependencies
|
||||
|
||||
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 ./...`
|
||||
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. |
|
||||
| `internal/apicheck` | Compiled-export and import-boundary checks run during development. |
|
||||
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
|
||||
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
|
||||
| `internal/build` | Resolved-identity builds, normalized settings, selected-module compilation, version injection, external native smoke output, and artifact digests. |
|
||||
| `internal/candidate` | Stable-version, note, repository-state, candidate capture, and post-validation checks. |
|
||||
| `internal/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
|
||||
| `internal/git` | Git command boundary, committed-tree reads, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||
| `internal/gitea` | Gitea API transport, 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/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 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, full-tag hosted release pages, asset inspection, manifests, and uploads. |
|
||||
| `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/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/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/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. |
|
||||
@@ -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
|
||||
in `internal/candidate`. Its validation bridge calls `internal/validation` for
|
||||
the default offline Go suite, then runs product-specific checks once. The
|
||||
candidate package owns post-validation builds and local-state rechecks;
|
||||
publication owns endpoint guards, tag creation, push, and recovery around that
|
||||
same candidate validation path. `internal/validation.CandidateTree` reads
|
||||
the selected module's default offline Go suite, then runs product-specific
|
||||
checks once. Validation and its post-validation build callback share one local
|
||||
Go settings owner. Each Go acquisition checks inherited and persistent inputs;
|
||||
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;
|
||||
it does not consult neighboring files or traverse submodule checkouts.
|
||||
See the [shared validation guide](validation.md) for checker, Go command, and
|
||||
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
|
||||
|
||||
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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
|
||||
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
|
||||
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
|
||||
| Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` |
|
||||
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
|
||||
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
|
||||
| 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`, `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`, `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` |
|
||||
| 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
|
||||
check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise
|
||||
|
||||
@@ -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
|
||||
candidate path.
|
||||
|
||||
The executor runs one offline Go suite, optionally with the race detector when
|
||||
selected. When full vet is selected, it disables test-time vet for that suite
|
||||
and runs native `go vet -mod=readonly ./...` after the suite succeeds. It then
|
||||
runs `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
|
||||
selected package-compilation target, preserving target order and stopping on
|
||||
the first failure. This compiles source without producing an artifact or
|
||||
executing a target binary. Test, vet, and package-compilation failures retain
|
||||
their `go-test`, `go-vet`, and `package-compile` identities, selected target
|
||||
where applicable, and underlying causes. Its
|
||||
The executor runs one offline Go suite from the captured module directory,
|
||||
optionally with the race detector when selected. When full vet is selected, it
|
||||
disables test-time vet for that suite and runs native
|
||||
`go vet -mod=readonly ./...` from the same directory after the suite succeeds.
|
||||
It then runs `go build -mod=readonly -o <os.DevNull> ./...` there in static mode
|
||||
for each selected package-compilation target, preserving target order and
|
||||
stopping on the first failure. This compiles source without producing an
|
||||
artifact or executing a target binary. Test, vet, and package-compilation
|
||||
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,
|
||||
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
|
||||
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
|
||||
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||
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
|
||||
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
|
||||
commands.
|
||||
The local-replacement check parses every committed go.mod blob with
|
||||
`golang.org/x/mod/modfile`, including nested modules and vendor declarations.
|
||||
The local-replacement check parses selected go.mod blobs with
|
||||
`golang.org/x/mod/modfile`, including vendor and explicitly shared declarations.
|
||||
It follows committed symlink declarations through the captured tree and fails
|
||||
with an inspection cause when their selected contents are unavailable. A
|
||||
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
|
||||
and line numbers remain cached. Each declaration path receives findings from
|
||||
that summary in its own path context.
|
||||
The optional file-size check uses each blob's captured Git size, including
|
||||
symlink link-text bytes, and skips entries without a blob size such as gitlinks.
|
||||
It applies normalized literal file/directory exclusions with path-boundary
|
||||
matching, then returns the first oversized path with `FileSizeMeasurement`.
|
||||
The optional file-size check applies normalized literal file/directory
|
||||
exclusions with path-boundary matching, then requests local Git sizes for the
|
||||
remaining selected blobs. Sizes include symlink link-text bytes; gitlinks are
|
||||
omitted. The check returns the first oversized path with `FileSizeMeasurement`.
|
||||
The optional Markdown check follows file-size inspection and uses the same
|
||||
candidate tree. It parses regular Markdown blobs, resolves local destinations,
|
||||
and checks supported anchors; outside-checkout and submodule observations are
|
||||
written to the shared output writer. A nil writer keeps library use quiet.
|
||||
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
|
||||
lazily, and resolves destinations without consulting the worktree or
|
||||
neighboring files. Each tree owns one lazy Git blob reader; direct tree users
|
||||
must close it when finished. The shared validation executor closes its tree on
|
||||
every return before product checks or build callbacks run. 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.
|
||||
`CandidateTree` enumerates full repository path, type, mode, and object metadata
|
||||
without requiring blob contents or sizes. Checks acquire sizes and bytes for
|
||||
their selected inputs and required references. Unreferenced blobs in excluded
|
||||
modules remain outside those reads. Destinations resolve without consulting
|
||||
the worktree or neighboring files. Each tree owns one lazy Git blob reader;
|
||||
direct tree users must close it when finished. The shared validation executor
|
||||
closes its tree on every return before product checks or build callbacks run.
|
||||
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
|
||||
retained by the tree. Symlink targets are retained as immutable owned strings
|
||||
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;
|
||||
source preparation retains its separate network-capable operations. See Git's
|
||||
[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
|
||||
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
|
||||
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
|
||||
destinations and symlink targets within the repository directory map back to
|
||||
candidate-relative paths; existence still comes from committed entries.
|
||||
|
||||
@@ -28,16 +28,12 @@ The maintained [source-delivery image](../examples/woodpecker/releasekit-source-
|
||||
and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the
|
||||
chosen build toolchain; update them together when that selection changes.
|
||||
|
||||
The current consumer minimum is declared in [go.mod](../go.mod). When preparing
|
||||
a release that raises it to Go 1.26, the normal declaration is:
|
||||
|
||||
```sh
|
||||
go mod edit -go=1.26.0
|
||||
```
|
||||
|
||||
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
|
||||
The current consumer minimum is Go 1.26.0, declared in [go.mod](../go.mod).
|
||||
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).
|
||||
When increasing it, update `go.mod` to the first stable version of the selected
|
||||
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
|
||||
[compatibility policy](policy/release.md#versioning-and-compatibility); before
|
||||
v1.0.0, an increase belongs in a minor release.
|
||||
|
||||
|
||||
141
docs/releases/v0.4.0.md
Normal file
141
docs/releases/v0.4.0.md
Normal 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)
|
||||
132
examples/multi-module/README.md
Normal file
132
examples/multi-module/README.md
Normal 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`.
|
||||
3
examples/multi-module/go.mod
Normal file
3
examples/multi-module/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module example.com/multi-module-consumer
|
||||
|
||||
go 1.26.0
|
||||
3
examples/multi-module/integrations/ckconfig/go.mod
Normal file
3
examples/multi-module/integrations/ckconfig/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module example.com/multi-module-consumer/integrations/ckconfig
|
||||
|
||||
go 1.26.0
|
||||
3
examples/multi-module/integrations/core/v2/go.mod
Normal file
3
examples/multi-module/integrations/core/v2/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module example.com/multi-module-consumer/integrations/core/v2
|
||||
|
||||
go 1.26.0
|
||||
11
examples/multi-module/integrations/core/v2/main.go
Normal file
11
examples/multi-module/integrations/core/v2/main.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("core %s\n", buildinfo.Version())
|
||||
}
|
||||
4
examples/multi-module/release-modules.tsv
Normal file
4
examples/multi-module/release-modules.tsv
Normal 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
|
||||
|
48
examples/multi-module/release.sh
Executable file
48
examples/multi-module/release.sh
Executable 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"
|
||||
4
examples/multi-module/releasekit-ckconfig.json
Normal file
4
examples/multi-module/releasekit-ckconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"product": "Example Consumer Integration",
|
||||
"module_dir": "integrations/ckconfig"
|
||||
}
|
||||
9
examples/multi-module/releasekit-core-v2.json
Normal file
9
examples/multi-module/releasekit-core-v2.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"product": "Example Consumer Core v2",
|
||||
"module_dir": "integrations/core/v2",
|
||||
"build": {
|
||||
"main_package": ".",
|
||||
"binary_name": "core",
|
||||
"targets": ["linux/amd64"]
|
||||
}
|
||||
}
|
||||
4
examples/multi-module/releasekit-root.json
Normal file
4
examples/multi-module/releasekit-root.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"product": "Example Consumer",
|
||||
"module_dir": "."
|
||||
}
|
||||
70
examples/multi-module/select-module.sh
Executable file
70
examples/multi-module/select-module.sh
Executable 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"
|
||||
63
examples/multi-module/woodpecker.yml
Normal file
63
examples/multi-module/woodpecker.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
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=$(printf '%s' "$${CI_COMMIT_SHA}" | tr '[:upper:]' '[:lower:]')
|
||||
test "$(printf '%s' "$${tag_commit}" | tr '[:upper:]' '[:lower:]')" = "$${expected_commit}"
|
||||
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 "$${CI_COMMIT_SHA}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
@@ -29,5 +29,6 @@ steps:
|
||||
go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit
|
||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||
--repo "$${CI_WORKSPACE}" \
|
||||
--prepared \
|
||||
--commit "$${CI_COMMIT_SHA}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
|
||||
@@ -45,5 +45,6 @@ steps:
|
||||
-o "$${bootstrap_dir}/releasekit" "$${releasekit_module}/cmd/releasekit"
|
||||
"$${bootstrap_dir}/releasekit" ci-deliver \
|
||||
--repo "$${CI_WORKSPACE}" \
|
||||
--prepared \
|
||||
--commit "$${CI_COMMIT_SHA}" \
|
||||
"$${CI_COMMIT_TAG}"
|
||||
|
||||
@@ -3,21 +3,51 @@ package artifact
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 {
|
||||
OS 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 {
|
||||
Target Target
|
||||
Filename string
|
||||
}
|
||||
|
||||
// NormalizeSettings copies build inputs and removes duplicate targets while
|
||||
// preserving their first occurrence.
|
||||
func NormalizeSettings(mainPackage, binaryName string, targets []Target) Settings {
|
||||
return Settings{
|
||||
MainPackage: mainPackage,
|
||||
BinaryName: binaryName,
|
||||
Targets: NormalizeTargets(targets),
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeTargets removes duplicate targets while preserving their first
|
||||
// occurrence.
|
||||
func NormalizeTargets(targets []Target) []Target {
|
||||
|
||||
@@ -17,6 +17,22 @@ func TestNormalizeTargetsPreservesOrderAndFirstOccurrence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets(t *testing.T) {
|
||||
settings := NormalizeSettings("./cmd/app", "app", []Target{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "windows", Arch: "arm64"},
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
})
|
||||
want := Settings{
|
||||
MainPackage: "./cmd/app",
|
||||
BinaryName: "app",
|
||||
Targets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "arm64"}},
|
||||
}
|
||||
if !reflect.DeepEqual(settings, want) {
|
||||
t.Fatalf("NormalizeSettings() = %#v, want %#v", settings, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpectedBuildUsesSharedNamesInTargetOrder(t *testing.T) {
|
||||
targets := []Target{{OS: "windows", Arch: "arm64"}, {OS: "linux", Arch: "amd64"}}
|
||||
want := []Declaration{
|
||||
|
||||
@@ -16,23 +16,20 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type Target = artifact.Target
|
||||
|
||||
type Spec struct {
|
||||
MainPackage string
|
||||
BinaryName string
|
||||
Targets []Target
|
||||
}
|
||||
type Spec = artifact.Settings
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
Version string
|
||||
Spec Spec
|
||||
OutputDir string
|
||||
Output io.Writer
|
||||
Identity releaseidentity.Identity
|
||||
Spec Spec
|
||||
OutputDir string
|
||||
Output io.Writer
|
||||
ToolOwner *gotool.Owner
|
||||
}
|
||||
|
||||
type Artifact struct {
|
||||
@@ -44,8 +41,12 @@ type Artifact struct {
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
CandidateCommit string
|
||||
SourceVerified bool
|
||||
Settings artifact.Settings
|
||||
Version string
|
||||
MainPackage string
|
||||
BinaryName string
|
||||
@@ -55,25 +56,32 @@ type Result struct {
|
||||
Compiler string
|
||||
}
|
||||
|
||||
func Build(ctx context.Context, request Request) (Result, error) {
|
||||
func BuildResolved(ctx context.Context, request Request) (Result, error) {
|
||||
identity := request.Identity
|
||||
settings := artifact.NormalizeSettings(request.Spec.MainPackage, request.Spec.BinaryName, request.Spec.Targets)
|
||||
result := Result{
|
||||
Version: request.Version,
|
||||
MainPackage: request.Spec.MainPackage,
|
||||
BinaryName: request.Spec.BinaryName,
|
||||
Targets: artifact.NormalizeTargets(request.Spec.Targets),
|
||||
RepositoryDir: identity.RepositoryDir,
|
||||
ModuleDir: identity.ModuleDir,
|
||||
ModulePath: identity.ModulePath,
|
||||
Tag: identity.Tag,
|
||||
Settings: settings,
|
||||
Version: identity.Version,
|
||||
MainPackage: settings.MainPackage,
|
||||
BinaryName: settings.BinaryName,
|
||||
Targets: settings.Targets,
|
||||
}
|
||||
if !versioncontract.ValidStableVersion(request.Version) {
|
||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
|
||||
if !versioncontract.ValidStableVersion(identity.Version) {
|
||||
return result, fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, identity.Version)
|
||||
}
|
||||
if request.RepositoryDir == "" {
|
||||
return result, errors.New("build requires a repository directory")
|
||||
if identity.RepositoryDir == "" || identity.ModuleDir == "" || identity.ModulePath == "" {
|
||||
return result, errors.New("build requires a resolved module identity")
|
||||
}
|
||||
if len(result.Targets) != 0 {
|
||||
if request.Spec.MainPackage == "" {
|
||||
if settings.MainPackage == "" {
|
||||
return result, errors.New("build requires a main package when targets are selected")
|
||||
}
|
||||
if !artifact.ValidComponent(request.Spec.BinaryName) {
|
||||
return result, fmt.Errorf("unsafe binary name %q", request.Spec.BinaryName)
|
||||
if !artifact.ValidComponent(settings.BinaryName) {
|
||||
return result, fmt.Errorf("unsafe binary name %q", settings.BinaryName)
|
||||
}
|
||||
for _, target := range result.Targets {
|
||||
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(target.Arch) {
|
||||
@@ -84,11 +92,16 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, fmt.Errorf("build canceled: %w", err)
|
||||
}
|
||||
repositoryDir, err := filepath.Abs(request.RepositoryDir)
|
||||
repositoryDir, err := filepath.Abs(identity.RepositoryDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
result.RepositoryDir = repositoryDir
|
||||
moduleDir, err := filepath.Abs(identity.ModuleDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("resolve module directory: %w", err)
|
||||
}
|
||||
result.ModuleDir = moduleDir
|
||||
commit, err := gitCommit(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -100,12 +113,12 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
|
||||
if len(result.Targets) == 0 {
|
||||
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
|
||||
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.OutputDir = resolvedOutputDir(repositoryDir, request.OutputDir)
|
||||
declarations := artifact.ExpectedBuild(request.Spec.BinaryName, request.Version, result.Targets)
|
||||
result.OutputDir = artifact.OutputDirectory(moduleDir, request.OutputDir)
|
||||
declarations := artifact.ExpectedBuild(settings.BinaryName, identity.Version, settings.Targets)
|
||||
filenames := make([]string, len(declarations))
|
||||
for i, declaration := range declarations {
|
||||
filenames[i] = declaration.Filename
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
goTool, err := gotool.New(ctx, repositoryDir)
|
||||
owner := request.ToolOwner
|
||||
if owner == nil {
|
||||
owner = gotool.NewOwner(moduleDir)
|
||||
}
|
||||
goTool, err := owner.Acquire(ctx)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("prepare Go toolchain: %w", err)
|
||||
}
|
||||
compiler, err := goTool.Output(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||
compiler, err := goTool.Output(ctx, moduleDir, gotool.Native, gotool.Target{}, nil, "version")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("identify Go compiler: %w", err)
|
||||
}
|
||||
@@ -135,7 +152,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
if request.Output != nil {
|
||||
fmt.Fprintf(request.Output, "building %s/%s as %s\n", target.OS, target.Arch, filename)
|
||||
}
|
||||
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, path, request.Version, target); err != nil {
|
||||
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, path, identity.Version, target); err != nil {
|
||||
return result, fmt.Errorf("build target %s/%s: %w", target.OS, target.Arch, err)
|
||||
}
|
||||
artifact := Artifact{Path: path, Filename: filename, Target: target}
|
||||
@@ -153,26 +170,26 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
}
|
||||
if smokePath == "" {
|
||||
temporaryDir, err := os.MkdirTemp("", "releasekit-native-smoke-")
|
||||
temporaryDir, err := tempdir.Outside(repositoryDir, "releasekit-native-smoke-")
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("create native smoke directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
smokePath = filepath.Join(temporaryDir, request.Spec.BinaryName)
|
||||
smokePath = filepath.Join(temporaryDir, settings.BinaryName)
|
||||
if host.OS == "windows" {
|
||||
smokePath += ".exe"
|
||||
}
|
||||
if request.Output != nil {
|
||||
fmt.Fprintf(request.Output, "building temporary version check for %s/%s\n", host.OS, host.Arch)
|
||||
}
|
||||
if err := buildTarget(ctx, goTool, repositoryDir, request.Spec.MainPackage, smokePath, request.Version, host); err != nil {
|
||||
if err := buildTarget(ctx, goTool, moduleDir, settings.MainPackage, smokePath, identity.Version, host); err != nil {
|
||||
return result, fmt.Errorf("build native version check: %w", err)
|
||||
}
|
||||
}
|
||||
if request.Output != nil {
|
||||
fmt.Fprintf(request.Output, "checking %s version output\n", request.Spec.BinaryName)
|
||||
fmt.Fprintf(request.Output, "checking %s version output\n", settings.BinaryName)
|
||||
}
|
||||
if err := checkVersion(ctx, smokePath, request.Spec.BinaryName, request.Version); err != nil {
|
||||
if err := checkVersion(ctx, smokePath, settings.BinaryName, identity.Version); err != nil {
|
||||
return result, err
|
||||
}
|
||||
commitAfter, err := gitCommit(ctx, repositoryDir)
|
||||
@@ -187,19 +204,6 @@ func Build(ctx context.Context, request Request) (Result, error) {
|
||||
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) {
|
||||
command := exec.CommandContext(ctx, "git", "-C", repositoryDir, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
output, err := command.Output()
|
||||
@@ -212,9 +216,9 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
|
||||
func buildTarget(ctx context.Context, goTool *gotool.Tool, moduleDir, mainPackage, outputPath, version string, target Target) error {
|
||||
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
|
||||
err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||
err := goTool.Run(ctx, moduleDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
|
||||
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,29 +5,47 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
)
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
selected := *spec
|
||||
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-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary candidate build output: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
_, err = Build(ctx, Request{
|
||||
RepositoryDir: captured.RepositoryDir,
|
||||
Version: captured.Version,
|
||||
Spec: selected,
|
||||
OutputDir: temporaryDir,
|
||||
Output: output,
|
||||
_, err = BuildResolved(ctx, Request{
|
||||
Identity: identity,
|
||||
Spec: selected,
|
||||
OutputDir: temporaryDir,
|
||||
Output: output,
|
||||
ToolOwner: owner,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("build candidate: %w", err)
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
@@ -20,6 +22,9 @@ var ErrInvalidVersion = errors.New("invalid stable version")
|
||||
|
||||
type Candidate struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
Commit string
|
||||
Version string
|
||||
Note string
|
||||
@@ -27,29 +32,33 @@ type Candidate struct {
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
Validation validation.Settings
|
||||
AdditionalChecks func(context.Context, Candidate) error
|
||||
AfterValidate func(context.Context, Candidate) error
|
||||
AfterValidate func(context.Context, Candidate, *gotool.Owner) error
|
||||
Output io.Writer
|
||||
ToolOwner *gotool.Owner
|
||||
}
|
||||
|
||||
func Check(ctx context.Context, request Request) (Candidate, error) {
|
||||
captured, err := Capture(ctx, request)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
request.ToolOwner = gotool.NewOwner(captured.ModuleDir)
|
||||
if err := Validate(ctx, request, captured); err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
if request.AfterValidate != nil {
|
||||
if err := request.AfterValidate(ctx, captured); err != nil {
|
||||
return Candidate{}, fmt.Errorf("after candidate validation: %w", err)
|
||||
callbackErr := request.AfterValidate(ctx, captured, request.ToolOwner)
|
||||
if callbackErr != nil {
|
||||
return captured, fmt.Errorf("after candidate validation: %w", callbackErr)
|
||||
}
|
||||
}
|
||||
if err := Recheck(ctx, captured, ""); err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
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,
|
||||
// including the note bytes from the captured commit. It does not run checks.
|
||||
func Capture(ctx context.Context, request Request) (Candidate, error) {
|
||||
captured := Candidate{Version: 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) == "" {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return Candidate{}, err
|
||||
return captured, err
|
||||
}
|
||||
captured.Commit = state.commit
|
||||
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 != "" {
|
||||
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"
|
||||
note, err := gitOutput(ctx, repositoryDir, "show", state.commit+":"+path)
|
||||
identity, err := releaseidentity.FromCommitted(ctx, repositoryDir, request.ModuleDir, request.Version, state.commit)
|
||||
captured.ModuleDir = identity.ModuleDir
|
||||
captured.ModulePath = identity.ModulePath
|
||||
captured.Tag = identity.Tag
|
||||
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 {
|
||||
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 {
|
||||
return Candidate{}, fmt.Errorf("inspect local tag %q: %w", request.Version, err)
|
||||
if exists, err := hasLocalTag(ctx, repositoryDir, identity.Tag); err != nil {
|
||||
return captured, fmt.Errorf("inspect local tag %q: %w", identity.Tag, err)
|
||||
} 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
|
||||
// when supplied, for the captured candidate.
|
||||
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{
|
||||
RepositoryDir: captured.RepositoryDir,
|
||||
ModuleDir: captured.ModuleDir,
|
||||
Commit: captured.Commit,
|
||||
Output: request.Output,
|
||||
Settings: request.Validation,
|
||||
ToolOwner: owner,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
||||
}
|
||||
if request.AdditionalChecks != nil {
|
||||
if err := request.AdditionalChecks(ctx, captured); err != nil {
|
||||
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
|
||||
callbackErr := request.AdditionalChecks(ctx, captured)
|
||||
if callbackErr != nil {
|
||||
return fmt.Errorf("validate candidate %s: %w", captured.Commit, callbackErr)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
if expectedTagObject == "" {
|
||||
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Version); err != nil {
|
||||
return fmt.Errorf("recheck local tag %q: %w", captured.Version, err)
|
||||
if exists, err := hasLocalTag(ctx, captured.RepositoryDir, captured.Tag); err != nil {
|
||||
return fmt.Errorf("recheck local tag %q: %w", captured.Tag, err)
|
||||
} 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
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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" {
|
||||
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
|
||||
}
|
||||
@@ -199,10 +225,10 @@ func gitOutput(ctx context.Context, repositoryDir string, args ...string) ([]byt
|
||||
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
|
||||
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,
|
||||
Stderr: &stderr,
|
||||
})
|
||||
|
||||
@@ -3,13 +3,17 @@ package candidate
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
)
|
||||
|
||||
@@ -71,7 +75,7 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
|
||||
if 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)
|
||||
}
|
||||
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) {
|
||||
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")
|
||||
@@ -98,7 +163,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T)
|
||||
productCalls++
|
||||
return nil
|
||||
},
|
||||
AfterValidate: func(context.Context, Candidate) error {
|
||||
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
|
||||
buildCalls++
|
||||
return nil
|
||||
},
|
||||
@@ -130,7 +195,7 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *te
|
||||
productCalls++
|
||||
return nil
|
||||
},
|
||||
AfterValidate: func(context.Context, Candidate) error {
|
||||
AfterValidate: func(context.Context, Candidate, *gotool.Owner) error {
|
||||
buildCalls++
|
||||
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) {
|
||||
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")
|
||||
@@ -423,7 +629,7 @@ func TestCheckRechecksHeadAndWorktreeAfterCallback(t *testing.T) {
|
||||
|
||||
func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||
repository := newRepository(t, "# Example Product v1.2.3\n")
|
||||
_, err := Check(context.Background(), Request{
|
||||
captured, err := Check(context.Background(), Request{
|
||||
RepositoryDir: repository,
|
||||
Product: "Example Product",
|
||||
Version: testVersion,
|
||||
@@ -435,6 +641,9 @@ func TestCheckRechecksLocalTagAfterCallback(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "appeared during validation") {
|
||||
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 {
|
||||
|
||||
@@ -11,16 +11,17 @@ import (
|
||||
)
|
||||
|
||||
func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, error) {
|
||||
if len(result.Targets) == 0 {
|
||||
settings := result.Settings
|
||||
if len(settings.Targets) == 0 {
|
||||
if len(result.Artifacts) != 0 {
|
||||
return nil, fmt.Errorf("source-only build result contains artifacts")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if len(result.Artifacts) != len(result.Targets) {
|
||||
if len(result.Artifacts) != len(settings.Targets) {
|
||||
return nil, fmt.Errorf("build result must contain one artifact for each selected target")
|
||||
}
|
||||
declarations := artifact.ExpectedBuild(result.BinaryName, result.Version, result.Targets)
|
||||
declarations := artifact.ExpectedBuild(settings.BinaryName, result.Version, settings.Targets)
|
||||
wantByTarget := make(map[artifact.Target]string, len(declarations))
|
||||
for _, declaration := range declarations {
|
||||
target := declaration.Target
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
@@ -25,8 +26,10 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := declaredBuildAssets(internalbuild.Result{
|
||||
Version: "v1.2.3", BinaryName: "app",
|
||||
Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
|
||||
Version: "v1.2.3",
|
||||
Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{
|
||||
{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"},
|
||||
}),
|
||||
Artifacts: []internalbuild.Artifact{
|
||||
{Path: firstPath, Filename: filepath.Base(firstPath), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(first)},
|
||||
{Path: secondPath, Filename: filepath.Base(secondPath), Target: internalbuild.Target{OS: "darwin", Arch: "arm64"}, SHA256: assetDigest(second)},
|
||||
@@ -54,7 +57,7 @@ func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := declaredBuildAssets(internalbuild.Result{
|
||||
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
|
||||
Version: "v1.2.3", Settings: artifact.NormalizeSettings("./cmd/app", "app", []artifact.Target{{OS: "linux", Arch: "amd64"}}),
|
||||
Artifacts: []internalbuild.Artifact{{
|
||||
Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest(contents),
|
||||
}},
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
@@ -29,8 +31,10 @@ const (
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
ExpectedTag string
|
||||
Destination gitea.Destination
|
||||
Build *internalbuild.Spec
|
||||
Prebuilt *internalbuild.Result
|
||||
@@ -38,6 +42,10 @@ type Request struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
CandidateCommit string
|
||||
ReleaseURL string
|
||||
State State
|
||||
@@ -59,28 +67,49 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
if request.Build != nil && request.Prebuilt != nil {
|
||||
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 {
|
||||
return result, fmt.Errorf("resolve repository directory: %w", err)
|
||||
}
|
||||
source, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
||||
if err != nil {
|
||||
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)
|
||||
result.RepositoryDir = repositoryDir
|
||||
commit, err := git.CaptureHead(ctx, repositoryDir)
|
||||
if err != nil {
|
||||
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 {
|
||||
return result, fmt.Errorf("release note %q: %w", notePath, err)
|
||||
return result, fmt.Errorf("release note %q: %w", identity.NotePath, err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
defer os.RemoveAll(temporaryDir)
|
||||
built, err := internalbuild.Build(ctx, internalbuild.Request{
|
||||
RepositoryDir: repositoryDir,
|
||||
Version: request.Version,
|
||||
Spec: *request.Build,
|
||||
OutputDir: filepath.Join(temporaryDir, "assets"),
|
||||
Output: request.Output,
|
||||
built, err := internalbuild.BuildResolved(ctx, internalbuild.Request{
|
||||
Identity: identity,
|
||||
Spec: *request.Build,
|
||||
OutputDir: filepath.Join(temporaryDir, "assets"),
|
||||
Output: request.Output,
|
||||
ToolOwner: gotool.NewOwner(identity.ModuleDir),
|
||||
})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := validateBuild(repositoryDir, source.TagCommit, request.Version, build, assets); err != nil {
|
||||
if err := validateBuild(identity, commit, build, assets); err != nil {
|
||||
return result, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if build != nil && len(build.Targets) != 0 {
|
||||
current, err := git.InspectTaggedSource(ctx, repositoryDir, request.Version)
|
||||
if build != nil && len(build.Settings.Targets) != 0 {
|
||||
current, err := git.InspectTaggedSource(ctx, repositoryDir, identity.Tag)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("recheck tagged source after build: %w", err)
|
||||
}
|
||||
if 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")
|
||||
}
|
||||
}
|
||||
|
||||
releaseResult, err := (gitea.Client{}).EnsureRelease(ctx, gitea.Request{
|
||||
Destination: request.Destination,
|
||||
Version: request.Version,
|
||||
Commit: source.TagCommit,
|
||||
Tag: identity.Tag,
|
||||
Commit: commit,
|
||||
Note: string(note),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -154,7 +183,7 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
result.Uncertain = assetIdentities(assets)
|
||||
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.Uploaded = uploadResult.Uploaded
|
||||
result.Pending = uploadResult.Pending
|
||||
@@ -167,14 +196,25 @@ func Deliver(ctx context.Context, request Request) (Result, error) {
|
||||
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 len(assets) != 0 {
|
||||
return errors.New("assets require a build identity")
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return errors.New("build source was not verified as clean and unchanged during compilation")
|
||||
}
|
||||
buildRepository, err := filepath.Abs(build.RepositoryDir)
|
||||
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 {
|
||||
if strings.TrimSpace(build.Settings.MainPackage) == "" || strings.TrimSpace(build.Settings.BinaryName) == "" || len(assets) != len(build.Settings.Targets)+1 || assets[len(assets)-1].Filename != gitea.ManifestFilename {
|
||||
return errors.New("build result does not contain the selected artifacts and complete checksum manifest")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
||||
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
|
||||
)
|
||||
@@ -60,14 +61,13 @@ func performanceBuildResult(b *testing.B) (internalbuild.Result, int64) {
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
result := internalbuild.Result{
|
||||
Version: "v1.2.3",
|
||||
BinaryName: "releasekit",
|
||||
Targets: []internalbuild.Target{
|
||||
Version: "v1.2.3",
|
||||
Settings: artifact.NormalizeSettings("./cmd/releasekit", "releasekit", []artifact.Target{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
},
|
||||
}),
|
||||
}
|
||||
for _, target := range result.Targets {
|
||||
for _, target := range result.Settings.Targets {
|
||||
name := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
|
||||
if target.OS == "windows" {
|
||||
name += ".exe"
|
||||
|
||||
155
internal/git/path.go
Normal file
155
internal/git/path.go
Normal 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, "/")
|
||||
}
|
||||
@@ -8,30 +8,51 @@ import (
|
||||
|
||||
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
|
||||
// against expectedCommit, and checks out that commit detached.
|
||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, version, remote, expectedCommit string) error {
|
||||
if !validVersionRef(version) || !validObjectID(expectedCommit) {
|
||||
return fmt.Errorf("invalid tagged checkout identity %q at %q", expectedCommit, version)
|
||||
func PrepareTaggedCheckout(ctx context.Context, repositoryDir, tag, remote, expectedCommit string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return fmt.Errorf("verify prepared checkout HEAD: %w", err)
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(head), expectedCommit) {
|
||||
return fmt.Errorf("prepared checkout HEAD is %s, expected %s", strings.TrimSpace(head), expectedCommit)
|
||||
if source.Status != "" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ var (
|
||||
ErrEndpointChanged = errors.New("Git publication endpoint changed")
|
||||
ErrRemoteMainMissing = errors.New("authoritative remote has no main branch")
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ type RemoteRef struct {
|
||||
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 {
|
||||
Main RemoteRef
|
||||
Tag RemoteRef
|
||||
@@ -97,14 +97,14 @@ func (endpoint Endpoint) Recheck(ctx context.Context, repositoryDir string) erro
|
||||
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.
|
||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version string) (Snapshot, error) {
|
||||
if !validVersionRef(version) {
|
||||
return Snapshot{}, fmt.Errorf("invalid version tag name %q", version)
|
||||
func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, tag string) (Snapshot, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
mainRef := "refs/heads/main"
|
||||
tagRef := "refs/tags/" + version
|
||||
tagRef := "refs/tags/" + tag
|
||||
peeledRef := tagRef + "^{}"
|
||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, mainRef, tagRef, peeledRef)
|
||||
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)
|
||||
}
|
||||
main := RemoteRef{State: RefAbsent}
|
||||
tag := RemoteRef{State: RefAbsent}
|
||||
remoteTag := RemoteRef{State: RefAbsent}
|
||||
for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
@@ -125,19 +125,19 @@ func (endpoint Endpoint) Query(ctx context.Context, repositoryDir, version strin
|
||||
case mainRef:
|
||||
main = mergeRef(main, object, false)
|
||||
case tagRef:
|
||||
tag = mergeRef(tag, object, false)
|
||||
remoteTag = mergeRef(remoteTag, object, false)
|
||||
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
|
||||
// candidate as an ancestor.
|
||||
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, version, commit string) (Snapshot, error) {
|
||||
snapshot, err := endpoint.Query(ctx, repositoryDir, version)
|
||||
func (endpoint Endpoint) RequireCandidate(ctx context.Context, repositoryDir, tag, commit string) (Snapshot, error) {
|
||||
snapshot, err := endpoint.Query(ctx, repositoryDir, tag)
|
||||
if err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
@@ -294,25 +294,14 @@ func mergeRef(ref RemoteRef, object string, peeled bool) RemoteRef {
|
||||
return ref
|
||||
}
|
||||
|
||||
func validVersionRef(version string) bool {
|
||||
if len(version) < 6 || !strings.HasPrefix(version, "v") {
|
||||
return false
|
||||
func checkTagRef(ctx context.Context, repositoryDir, tag string) error {
|
||||
if tag == "" {
|
||||
return fmt.Errorf("invalid Git tag name %q", tag)
|
||||
}
|
||||
parts := strings.Split(version[1:], ".")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
if _, err := run(ctx, repositoryDir, nil, "check-ref-format", "refs/tags/"+tag); err != nil {
|
||||
return fmt.Errorf("invalid Git tag name %q: %w", tag, err)
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part == "" || (len(part) > 1 && part[0] == '0') {
|
||||
return false
|
||||
}
|
||||
for _, char := range part {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return nil
|
||||
}
|
||||
|
||||
func validObjectID(object string) bool {
|
||||
|
||||
@@ -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) {
|
||||
for _, kind := range []string{"insteadOf", "pushInsteadOf"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -30,11 +31,43 @@ type TaggedSource struct {
|
||||
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.
|
||||
// Both lightweight historical tags and annotated tags are supported.
|
||||
func InspectTaggedSource(ctx context.Context, repositoryDir, version string) (TaggedSource, error) {
|
||||
if !validVersionRef(version) {
|
||||
return TaggedSource{}, fmt.Errorf("invalid tag version %q", version)
|
||||
func InspectTaggedSource(ctx context.Context, repositoryDir, tag string) (TaggedSource, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return TaggedSource{}, err
|
||||
}
|
||||
var source TaggedSource
|
||||
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)
|
||||
}
|
||||
source.Status = strings.TrimSpace(status)
|
||||
ref := "refs/tags/" + version
|
||||
ref := "refs/tags/" + tag
|
||||
object, err := run(ctx, repositoryDir, nil, "rev-parse", "--verify", ref)
|
||||
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)
|
||||
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}")
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -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, "\\") {
|
||||
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 {
|
||||
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
|
||||
// are NUL-delimited by Git so whitespace in a filename is preserved.
|
||||
// CommittedPaths resolves individual paths from captured objects. It requests
|
||||
// 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) {
|
||||
if !validObjectID(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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -150,11 +221,16 @@ func parseTreeEntries(output []byte) ([]TreeEntry, error) {
|
||||
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 input strings.Builder
|
||||
for index := range entries {
|
||||
if entries[index].Type != "blob" {
|
||||
if entries[index].Type != "blob" || entries[index].HasSize {
|
||||
continue
|
||||
}
|
||||
blobIndexes = append(blobIndexes, index)
|
||||
@@ -177,7 +253,7 @@ func populateBlobSizes(ctx context.Context, repositoryDir string, entries []Tree
|
||||
fields := strings.Fields(line)
|
||||
entry := &entries[blobIndexes[index]]
|
||||
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" {
|
||||
return fmt.Errorf("unexpected blob size record %q", line)
|
||||
|
||||
@@ -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) {
|
||||
fixture := newRemoteFixture(t)
|
||||
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("GIT_NO_LAZY_FETCH", "0")
|
||||
t.Setenv("GIT_ALLOW_PROTOCOL", "ssh")
|
||||
if _, err := ReadTree(t.Context(), repository, fixture.commit); err == nil {
|
||||
t.Fatal("tree inspection succeeded with unavailable objects")
|
||||
entries, treeErr := ReadTree(t.Context(), repository, fixture.commit)
|
||||
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)
|
||||
var exitError *exec.ExitError
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
// CreateAnnotatedTag records the captured note as an unsigned annotated tag
|
||||
// on the captured commit and returns the tag object's ID.
|
||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, version, commit string, note []byte) (string, error) {
|
||||
if !validVersionRef(version) {
|
||||
return "", fmt.Errorf("invalid version tag name %q", version)
|
||||
func CreateAnnotatedTag(ctx context.Context, repositoryDir, tag, commit string, note []byte) (string, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !validObjectID(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 {
|
||||
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 {
|
||||
return "", fmt.Errorf("create annotated tag %s: %w", version, err)
|
||||
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", tag, err)
|
||||
}
|
||||
|
||||
ref := "refs/tags/" + version
|
||||
ref := "refs/tags/" + tag
|
||||
tagType, err := run(ctx, repositoryDir, nil, "cat-file", "-t", ref)
|
||||
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" {
|
||||
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)
|
||||
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)
|
||||
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+"^{}")
|
||||
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 {
|
||||
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)
|
||||
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"))
|
||||
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
|
||||
}
|
||||
|
||||
// InspectLocalTag confirms the selected tag still points to the exact
|
||||
// annotated tag object captured before publication.
|
||||
func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject string) error {
|
||||
if !validVersionRef(version) || !validObjectID(expectedObject) {
|
||||
return fmt.Errorf("invalid expected tag identity %q at %q", expectedObject, version)
|
||||
func InspectLocalTag(ctx context.Context, repositoryDir, tag, expectedObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
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)
|
||||
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" {
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -95,18 +98,18 @@ func InspectLocalTag(ctx context.Context, repositoryDir, version, expectedObject
|
||||
// QueryTag verifies the captured endpoint still resolves to itself, then reads
|
||||
// one exact authoritative version tag and its peeled commit. Failed
|
||||
// verification or reads are unreadable and never interpreted as absence.
|
||||
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, version string) (RemoteRef, error) {
|
||||
if !validVersionRef(version) {
|
||||
return RemoteRef{}, fmt.Errorf("invalid version tag name %q", version)
|
||||
func (endpoint Endpoint) QueryTag(ctx context.Context, repositoryDir, tag string) (RemoteRef, error) {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
return RemoteRef{}, err
|
||||
}
|
||||
if err := endpoint.verifyScopedURLs(ctx, repositoryDir); err != nil {
|
||||
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 + "^{}"
|
||||
output, err := run(ctx, repositoryDir, endpoint.scopedConfig(), "ls-remote", endpoint.ScopedName, ref, peeledRef)
|
||||
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}
|
||||
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.
|
||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, version, tagObject string) error {
|
||||
if !validVersionRef(version) || !validObjectID(tagObject) {
|
||||
return fmt.Errorf("invalid tag publication identity %q at %q", tagObject, version)
|
||||
func (endpoint Endpoint) PushTag(ctx context.Context, repositoryDir, tag, tagObject string) error {
|
||||
if err := checkTagRef(ctx, repositoryDir, tag); err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
||||
)
|
||||
|
||||
type FailureKind string
|
||||
@@ -59,7 +57,7 @@ type Destination struct {
|
||||
|
||||
type Request struct {
|
||||
Destination Destination
|
||||
Version string
|
||||
Tag string
|
||||
Commit string
|
||||
Note string
|
||||
}
|
||||
@@ -122,17 +120,17 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
||||
}
|
||||
client := c.httpClient()
|
||||
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
|
||||
if err := c.getJSON(ctx, client, request.Destination.Token, "get hosted tag", tagURL, &tag); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
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)
|
||||
if err == nil {
|
||||
return acceptExisting(existing, request)
|
||||
@@ -143,7 +141,7 @@ func (c Client) EnsureRelease(ctx context.Context, request Request) (Result, err
|
||||
|
||||
title := noteTitle(request.Note)
|
||||
body, err := json.Marshal(createReleaseRequest{
|
||||
TagName: request.Version, TargetCommitish: request.Commit,
|
||||
TagName: request.Tag, TargetCommitish: request.Commit,
|
||||
Name: title, Body: request.Note,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -272,8 +270,8 @@ func (c Client) httpClient() *http.Client {
|
||||
}
|
||||
|
||||
func validateRequest(request Request) error {
|
||||
if !versioncontract.ValidStableVersion(request.Version) {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("stable version is required"))
|
||||
if strings.TrimSpace(request.Tag) == "" {
|
||||
return failure(FailureInvalid, "validate request", 0, errors.New("full release tag is required"))
|
||||
}
|
||||
if !validObjectID(request.Commit) {
|
||||
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) {
|
||||
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{Release: release}, nil
|
||||
}
|
||||
|
||||
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{Release: release, Created: true}, nil
|
||||
|
||||
@@ -26,6 +26,45 @@ 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++
|
||||
var payload createReleaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.TagName != tag || payload.Name != "Example v1.2.3" || payload.Body != testNote {
|
||||
t.Fatalf("create payload = %#v", payload)
|
||||
}
|
||||
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.Fatal(err)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected escaped request %s %s", r.Method, r.URL.EscapedPath())
|
||||
}
|
||||
}))
|
||||
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 != 1 {
|
||||
t.Fatalf("nested tag release = %#v, posts %d, error %v", result, state.posts, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureReleaseCreatesMissingPageFromCommittedNote(t *testing.T) {
|
||||
server, _ := newReleaseServer(t, releaseFixture{}, true)
|
||||
result, err := testClient(server).EnsureRelease(context.Background(), testRequest(server.URL))
|
||||
@@ -324,7 +363,7 @@ func testClient(server *httptest.Server) Client {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -68,6 +68,17 @@ func VerifyLocalAssets(assets []ExpectedAsset) error {
|
||||
// before the first write if any declared existing asset conflicts or cannot be
|
||||
// verified, and never replaces an existing attachment.
|
||||
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 {
|
||||
return UploadResult{State: DeliveryPartial}, failure(FailureInvalid, "upload release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
@@ -127,12 +138,14 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
|
||||
missing[observation.Expected.Filename] = observation.Expected
|
||||
}
|
||||
}
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
if err := verifyLocalAsset(item); err != nil {
|
||||
return result, err
|
||||
if verifyLocal {
|
||||
for _, item := range expected {
|
||||
if _, needsUpload := missing[item.Filename]; !needsUpload {
|
||||
continue
|
||||
}
|
||||
if err := verifyLocalAsset(item); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,31 @@ import (
|
||||
"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) {
|
||||
fixture := &uploadFixture{files: make(map[string][]byte), failName: "beta.bin"}
|
||||
server := httptest.NewServer(fixture)
|
||||
|
||||
@@ -34,8 +34,9 @@ type Target struct {
|
||||
// Tool holds the host target and effective operational settings resolved from
|
||||
// the process environment and Go's persistent environment file.
|
||||
type Tool struct {
|
||||
host Target
|
||||
settings map[string]string
|
||||
host Target
|
||||
settings map[string]string
|
||||
settingsFile string
|
||||
}
|
||||
|
||||
var operationalSettings = []string{
|
||||
@@ -53,10 +54,11 @@ var architectureFeatures = []string{
|
||||
// configuration from being read.
|
||||
func New(ctx context.Context, directory string) (*Tool, error) {
|
||||
var host struct {
|
||||
OS string `json:"GOHOSTOS"`
|
||||
Arch string `json:"GOHOSTARCH"`
|
||||
OS string `json:"GOHOSTOS"`
|
||||
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 {
|
||||
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 == "" {
|
||||
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["GOOS"] = tool.host.OS
|
||||
|
||||
@@ -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 {
|
||||
t.Helper()
|
||||
args := append([]string{"env", "-json"}, names...)
|
||||
|
||||
122
internal/gotool/owner.go
Normal file
122
internal/gotool/owner.go
Normal 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
|
||||
}
|
||||
@@ -16,28 +16,34 @@ import (
|
||||
|
||||
type TaggedCheckoutRequest struct {
|
||||
RepositoryDir string
|
||||
Version string
|
||||
Tag string
|
||||
Remote string
|
||||
ExpectedCommit string
|
||||
VerifyOnly bool
|
||||
}
|
||||
|
||||
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
|
||||
if !versioncontract.ValidStableVersion(request.Version) {
|
||||
return fmt.Errorf("%w: %q", candidate.ErrInvalidVersion, request.Version)
|
||||
lastSlash := strings.LastIndex(request.Tag, "/")
|
||||
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) == "" {
|
||||
return errors.New("expected commit is required")
|
||||
}
|
||||
if request.VerifyOnly {
|
||||
return git.VerifyTaggedCheckout(ctx, request.RepositoryDir, request.Tag, request.ExpectedCommit)
|
||||
}
|
||||
remote := request.Remote
|
||||
if remote == "" {
|
||||
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 {
|
||||
if strings.TrimSpace(repositoryDir) == "" {
|
||||
return errors.New("repository directory is required")
|
||||
func PrepareBuildDependencies(ctx context.Context, moduleDir string) error {
|
||||
if strings.TrimSpace(moduleDir) == "" {
|
||||
return errors.New("module directory is required")
|
||||
}
|
||||
return gotool.PrepareBuildDependencies(ctx, repositoryDir)
|
||||
return gotool.PrepareBuildDependencies(ctx, moduleDir)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
|
||||
)
|
||||
|
||||
@@ -42,17 +43,22 @@ var ErrUnexpectedPushURL = errors.New("unexpected publication push URL")
|
||||
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Product string
|
||||
Version string
|
||||
Remote string
|
||||
ExpectedPushURL string
|
||||
Validation validation.Settings
|
||||
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
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
ModulePath string
|
||||
Tag string
|
||||
Version string
|
||||
CandidateCommit string
|
||||
Endpoint string
|
||||
@@ -76,6 +82,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
checks := candidate.Request{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
ModuleDir: request.ModuleDir,
|
||||
Product: request.Product,
|
||||
Version: request.Version,
|
||||
Validation: request.Validation,
|
||||
@@ -83,10 +90,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
Output: request.Output,
|
||||
}
|
||||
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 {
|
||||
return result, fmt.Errorf("capture release candidate: %w", err)
|
||||
}
|
||||
result.CandidateCommit = captured.Commit
|
||||
|
||||
endpoint, err := git.Resolve(ctx, captured.RepositoryDir, remoteName)
|
||||
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 {
|
||||
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) {
|
||||
result.State = Conflict
|
||||
}
|
||||
@@ -110,12 +121,14 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
}
|
||||
|
||||
result.Phase = Validation
|
||||
checks.ToolOwner = gotool.NewOwner(captured.ModuleDir)
|
||||
if err := candidate.Validate(ctx, checks, captured); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if request.AfterValidate != nil {
|
||||
if err := request.AfterValidate(ctx, captured); err != nil {
|
||||
return result, fmt.Errorf("after candidate validation: %w", err)
|
||||
callbackErr := request.AfterValidate(ctx, captured, checks.ToolOwner)
|
||||
if callbackErr != nil {
|
||||
return result, fmt.Errorf("after candidate validation: %w", callbackErr)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
result.State = Conflict
|
||||
}
|
||||
@@ -133,7 +146,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
|
||||
result.Phase = TagCreation
|
||||
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 {
|
||||
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 {
|
||||
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) {
|
||||
result.State = Conflict
|
||||
}
|
||||
@@ -153,7 +166,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
|
||||
|
||||
result.Phase = Push
|
||||
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)
|
||||
}
|
||||
result.Phase = Complete
|
||||
@@ -165,7 +178,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
||||
result.Phase = Reconciliation
|
||||
recoveryCtx, cancel := context.WithTimeout(context.Background(), recoveryTimeout)
|
||||
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 {
|
||||
result.State = Unknown
|
||||
if queryErr == nil {
|
||||
@@ -175,11 +188,11 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
||||
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 {
|
||||
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) {
|
||||
result.Phase = Complete
|
||||
@@ -187,7 +200,7 @@ func reconcilePush(endpoint git.Endpoint, captured candidate.Candidate, tagObjec
|
||||
return result, nil
|
||||
}
|
||||
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 {
|
||||
|
||||
226
internal/releaseidentity/identity.go
Normal file
226
internal/releaseidentity/identity.go
Normal 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")
|
||||
}
|
||||
262
internal/releaseidentity/identity_test.go
Normal file
262
internal/releaseidentity/identity_test.go
Normal 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)
|
||||
}
|
||||
@@ -8,32 +8,77 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Outside uses the system temporary directory and leaves cleanup to its caller.
|
||||
// Outside uses system temporary storage and falls back to the repository's
|
||||
// parent when TMPDIR resolves inside the worktree. Cleanup belongs to the caller.
|
||||
func Outside(repositoryDir, pattern string) (string, error) {
|
||||
repository, err := filepath.Abs(repositoryDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
repository, err = filepath.EvalSymlinks(repository)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
directory, err := os.MkdirTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absolute, err := filepath.Abs(directory)
|
||||
created := directory
|
||||
directory, err = filepath.Abs(created)
|
||||
if err != nil {
|
||||
os.RemoveAll(created)
|
||||
return "", err
|
||||
}
|
||||
directory, err = filepath.EvalSymlinks(directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
}
|
||||
directory = absolute
|
||||
repository, err := filepath.Abs(repositoryDir)
|
||||
if err == nil && !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
||||
outside, err := isOutside(repository, directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
}
|
||||
if outside {
|
||||
return directory, nil
|
||||
}
|
||||
if err == nil {
|
||||
var relative string
|
||||
relative, err = filepath.Rel(repository, directory)
|
||||
if err == nil && (relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
|
||||
err = fmt.Errorf("temporary storage %q is inside repository %q; select an external temporary directory", directory, repository)
|
||||
}
|
||||
if err := os.RemoveAll(directory); err != nil {
|
||||
return "", fmt.Errorf("remove temporary storage inside repository %q: %w", repository, err)
|
||||
}
|
||||
directory, err = os.MkdirTemp(filepath.Dir(repository), pattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temporary storage outside repository %q: %w", repository, err)
|
||||
}
|
||||
created = directory
|
||||
directory, err = filepath.Abs(created)
|
||||
if err != nil {
|
||||
os.RemoveAll(created)
|
||||
return "", err
|
||||
}
|
||||
directory, err = filepath.EvalSymlinks(directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
}
|
||||
outside, err = isOutside(repository, directory)
|
||||
if err != nil {
|
||||
os.RemoveAll(directory)
|
||||
return "", err
|
||||
}
|
||||
if !outside {
|
||||
os.RemoveAll(directory)
|
||||
return "", fmt.Errorf("temporary storage %q is inside repository %q", directory, repository)
|
||||
}
|
||||
return directory, nil
|
||||
}
|
||||
|
||||
func isOutside(repository, directory string) (bool, error) {
|
||||
if !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
|
||||
return true, nil
|
||||
}
|
||||
relative, err := filepath.Rel(repository, directory)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)), nil
|
||||
}
|
||||
|
||||
@@ -29,16 +29,28 @@ func TestOutsideHonorsRelativeTemporaryDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutsideRejectsAndCleansStorageInsideWorktree(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
setTemporaryDirectory(t, repository)
|
||||
if directory, err := Outside(repository, "fixture-"); err == nil {
|
||||
os.RemoveAll(directory)
|
||||
t.Fatal("storage created inside worktree")
|
||||
func TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
repository := filepath.Join(parent, "worktree")
|
||||
if err := os.Mkdir(repository, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
localTemporary := filepath.Join(repository, "tmp")
|
||||
if err := os.Mkdir(localTemporary, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setTemporaryDirectory(t, localTemporary)
|
||||
directory, err := Outside(repository, "fixture-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
if filepath.Dir(directory) != parent {
|
||||
t.Fatalf("temporary storage=%q want external parent %q", directory, parent)
|
||||
}
|
||||
remaining, err := os.ReadDir(repository)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("temporary storage left behind: %v %v", remaining, err)
|
||||
if err != nil || len(remaining) != 1 || remaining[0].Name() != "tmp" {
|
||||
t.Fatalf("temporary storage left behind in worktree: %v %v", remaining, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
FileSize Check = "file-size"
|
||||
// MarkdownLinks identifies missing committed Markdown destinations or anchors.
|
||||
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.
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
)
|
||||
|
||||
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
|
||||
if settings.LimitBytes != nil {
|
||||
limit = *settings.LimitBytes
|
||||
}
|
||||
exclusions := normalizedExclusions(settings.Exclusions)
|
||||
for _, entry := range tree.Entries() {
|
||||
if entry.Type != "blob" || !entry.HasSize || excludedByPath(entry.Path, exclusions) {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Size > limit {
|
||||
return &Failure{
|
||||
Check: FileSize,
|
||||
@@ -31,6 +40,23 @@ func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
|
||||
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 {
|
||||
result := make([]string, 0, len(exclusions))
|
||||
for _, exclusion := range exclusions {
|
||||
|
||||
@@ -29,10 +29,11 @@ func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
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,
|
||||
}}}
|
||||
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 err != nil {
|
||||
t.Fatalf("checkFileSize() error = %v, want nil", err)
|
||||
@@ -104,10 +105,11 @@ func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{
|
||||
settings := fileSizeSettingsForModule(&FileSizeSettings{
|
||||
LimitBytes: int64Pointer(10),
|
||||
Exclusions: test.exclusions,
|
||||
})
|
||||
}, ".")
|
||||
err := checkFileSize(t.Context(), &CandidateTree{entries: test.entries, inputEntries: test.entries}, settings)
|
||||
failure, ok := err.(*Failure)
|
||||
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)
|
||||
@@ -137,12 +139,12 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
@@ -151,7 +153,7 @@ func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
|
||||
}
|
||||
@@ -172,7 +174,7 @@ func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
|
||||
tree := &CandidateTree{
|
||||
repositoryDir: t.TempDir(),
|
||||
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},
|
||||
}
|
||||
cleanupCandidateTree(t, tree)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
replacementsByObjectID := make(map[string][]moduleReplacementSummary)
|
||||
for _, entry := range tree.Entries() {
|
||||
for _, entry := range tree.inputEntries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,7 @@ func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
|
||||
t.Fatalf("checkLocalReplacements error = %v, want local-replacements failure", err)
|
||||
}
|
||||
want := map[string]Finding{
|
||||
"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"},
|
||||
"go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"},
|
||||
}
|
||||
if len(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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -58,7 +77,7 @@ func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -91,7 +110,10 @@ func TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckou
|
||||
}
|
||||
cache := newMarkdownCache(tree)
|
||||
var findings []Finding
|
||||
for _, entry := range tree.Entries() {
|
||||
for _, entry := range tree.inputEntries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
|
||||
}
|
||||
|
||||
@@ -19,25 +19,38 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
|
||||
name string
|
||||
race bool
|
||||
fullVet bool
|
||||
compile []Target
|
||||
}{
|
||||
{name: "default"},
|
||||
{name: "race", race: true},
|
||||
{name: "full vet", 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) {
|
||||
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{
|
||||
RepositoryDir: t.TempDir(),
|
||||
Settings: Settings{Race: test.race, FullVet: test.fullVet},
|
||||
RepositoryDir: repositoryDir,
|
||||
ModuleDir: moduleDir,
|
||||
Settings: Settings{Race: test.race, FullVet: test.fullVet, CompileTargets: test.compile},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
commands := readGoShimLog(t, logPath)
|
||||
var tests, vets []string
|
||||
var testPosition, vetPosition int
|
||||
for _, directory := range readGoShimDirectories(t, os.Getenv("RELEASEKIT_GO_DIR_LOG")) {
|
||||
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 {
|
||||
if strings.HasPrefix(command, "test ") {
|
||||
tests = append(tests, command)
|
||||
@@ -47,6 +60,10 @@ func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
|
||||
vets = append(vets, command)
|
||||
vetPosition = i
|
||||
}
|
||||
if strings.HasPrefix(command, "build ") {
|
||||
builds = append(builds, command)
|
||||
buildPosition = i
|
||||
}
|
||||
}
|
||||
if len(tests) != 1 {
|
||||
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])
|
||||
}
|
||||
}
|
||||
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) {
|
||||
logPath := installGoShim(t, "test", false)
|
||||
err := Run(context.Background(), Request{
|
||||
@@ -214,6 +295,7 @@ func installGoShim(t *testing.T, failure string, delayVet bool) string {
|
||||
directory := t.TempDir()
|
||||
logPath := filepath.Join(directory, "go-commands")
|
||||
envLogPath := filepath.Join(directory, "go-environment")
|
||||
directoryLogPath := filepath.Join(directory, "go-directories")
|
||||
settings := map[string]string{
|
||||
"GOHOSTOS": runtime.GOOS,
|
||||
"GOHOSTARCH": runtime.GOARCH,
|
||||
@@ -263,6 +345,7 @@ fi
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
set -eu
|
||||
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
|
||||
pwd -P >> "$RELEASEKIT_GO_DIR_LOG"
|
||||
if [ "${1-}" = "env" ]; then
|
||||
printf '%%s\n' '%s'
|
||||
exit 0
|
||||
@@ -281,6 +364,7 @@ exit 0
|
||||
t.Setenv("PATH", path)
|
||||
t.Setenv("RELEASEKIT_GO_LOG", logPath)
|
||||
t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath)
|
||||
t.Setenv("RELEASEKIT_GO_DIR_LOG", directoryLogPath)
|
||||
return logPath
|
||||
}
|
||||
|
||||
@@ -293,6 +377,15 @@ func readGoShimLog(t *testing.T, path string) []string {
|
||||
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 {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
|
||||
125
internal/validation/scope_checks_test.go
Normal file
125
internal/validation/scope_checks_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
internal/validation/scope_performance_test.go
Normal file
38
internal/validation/scope_performance_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
type CandidateTree struct {
|
||||
context context.Context
|
||||
repositoryDir string
|
||||
modulePath string
|
||||
entries []git.TreeEntry
|
||||
inputEntries []git.TreeEntry
|
||||
byPath map[string]int
|
||||
directories map[string]struct{}
|
||||
blobReader *git.BlobReader
|
||||
@@ -25,16 +28,40 @@ type CandidateTree struct {
|
||||
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
|
||||
// worktree. The supplied context also governs the scoped reader through closure.
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modulePath, err := repositoryModulePath(repositoryDir, scope.ModuleDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tree := &CandidateTree{
|
||||
context: ctx,
|
||||
repositoryDir: repositoryDir,
|
||||
modulePath: modulePath,
|
||||
entries: entries,
|
||||
byPath: make(map[string]int, len(entries)),
|
||||
directories: make(map[string]struct{}),
|
||||
@@ -47,6 +74,11 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
|
||||
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
|
||||
}
|
||||
|
||||
@@ -55,6 +87,139 @@ func (tree *CandidateTree) Entries() []git.TreeEntry {
|
||||
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.
|
||||
// Regular-file content is not cached, so repeated reads request the object
|
||||
// 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.
|
||||
type DestinationKind string
|
||||
type DestinationKind = git.DestinationKind
|
||||
|
||||
const (
|
||||
DestinationEntry DestinationKind = "entry"
|
||||
DestinationDirectory DestinationKind = "directory"
|
||||
DestinationMissing DestinationKind = "missing"
|
||||
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
||||
DestinationSubmodule DestinationKind = "submodule"
|
||||
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
||||
DestinationEntry = git.DestinationEntry
|
||||
DestinationDirectory = git.DestinationDirectory
|
||||
DestinationMissing = git.DestinationMissing
|
||||
DestinationOutsideCheckout = git.DestinationOutsideCheckout
|
||||
DestinationSubmodule = git.DestinationSubmodule
|
||||
DestinationSymlinkCycle = git.DestinationSymlinkCycle
|
||||
)
|
||||
|
||||
// Destination records the resolved identity and its classification.
|
||||
type Destination struct {
|
||||
Kind DestinationKind
|
||||
Path string
|
||||
}
|
||||
type Destination = git.Destination
|
||||
|
||||
// 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.
|
||||
// ResolveDestination uses shared committed reference traversal with the tree's
|
||||
// indexed metadata and scoped symlink reader.
|
||||
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Destination{}, err
|
||||
resolver := git.PathResolver{
|
||||
RepositoryDir: tree.repositoryDir,
|
||||
Lookup: func(_ context.Context, name string) (git.TreeEntry, bool, error) {
|
||||
if entry, exists := tree.Entry(name); exists {
|
||||
return entry, true, nil
|
||||
}
|
||||
_, exists := tree.directories[name]
|
||||
return git.TreeEntry{Path: name, Type: "tree"}, exists, nil
|
||||
},
|
||||
ReadLink: tree.readSymlinkTarget,
|
||||
}
|
||||
directory := path.Dir(sourcePath)
|
||||
if path.IsAbs(destination) {
|
||||
relative, inside, err := tree.relativeAbsoluteDestination(destination)
|
||||
if err != nil {
|
||||
return Destination{}, err
|
||||
}
|
||||
if !inside {
|
||||
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, "/")
|
||||
return resolver.ResolveDestination(ctx, sourcePath, destination)
|
||||
}
|
||||
|
||||
@@ -48,10 +48,10 @@ func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T
|
||||
t.Errorf("committed tree is missing %q", path)
|
||||
}
|
||||
}
|
||||
if got := entries["binary.dat"]; got.size != 4 || !got.hasSize {
|
||||
t.Errorf("binary metadata = %#v, want size 4", got)
|
||||
if got := entries["binary.dat"]; got.mode != "100644" || got.kind != "blob" {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
repository := newTreeRepository(t)
|
||||
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
|
||||
|
||||
@@ -4,6 +4,7 @@ package validation
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"io"
|
||||
@@ -19,6 +20,7 @@ type Settings struct {
|
||||
Race bool
|
||||
FullVet bool
|
||||
CompileTargets []Target
|
||||
SharedFiles []string
|
||||
GoFormat bool
|
||||
Markdown *MarkdownSettings
|
||||
ModuleDistribution ModuleDistributionSettings
|
||||
@@ -51,15 +53,20 @@ type Target struct {
|
||||
// Request identifies the captured source and output used by shared validation.
|
||||
type Request struct {
|
||||
RepositoryDir string
|
||||
ModuleDir string
|
||||
Commit string
|
||||
Output io.Writer
|
||||
Settings Settings
|
||||
ToolOwner *gotool.Owner
|
||||
}
|
||||
|
||||
// ReadCandidateTree loads the committed-file inventory when a selected check
|
||||
// needs file-oriented input.
|
||||
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.
|
||||
@@ -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 {
|
||||
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, "./...")
|
||||
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 {
|
||||
return &Failure{Check: GoTest, Cause: err}
|
||||
}
|
||||
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 {
|
||||
return &Failure{Check: GoVet, Cause: err}
|
||||
}
|
||||
}
|
||||
for _, target := range request.Settings.CompileTargets {
|
||||
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, "./...")
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
var failure *Failure
|
||||
if errors.As(err, &failure) {
|
||||
return failure
|
||||
}
|
||||
check := MarkdownLinks
|
||||
if request.Settings.FileSize != nil {
|
||||
check = FileSize
|
||||
@@ -137,6 +157,7 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
return &Failure{Check: check, Cause: err}
|
||||
}
|
||||
lastContentCheck := Check("")
|
||||
fileSizeSettings := fileSizeSettingsForModule(request.Settings.FileSize, tree.modulePath)
|
||||
defer func() {
|
||||
if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" {
|
||||
result = &Failure{
|
||||
@@ -169,7 +190,7 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
}
|
||||
}
|
||||
if request.Settings.FileSize != nil {
|
||||
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
|
||||
if err := checkFileSize(ctx, tree, fileSizeSettings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -188,7 +209,7 @@ func Run(ctx context.Context, request Request) (result error) {
|
||||
|
||||
func checkWorkspaceFiles(tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
for _, entry := range tree.Entries() {
|
||||
for _, entry := range tree.inputEntries {
|
||||
if entry.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
@@ -209,7 +230,7 @@ func checkWorkspaceFiles(tree *CandidateTree) error {
|
||||
func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
|
||||
var findings []Finding
|
||||
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") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
)
|
||||
|
||||
func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
|
||||
tree := &CandidateTree{entries: []git.TreeEntry{
|
||||
entries := []git.TreeEntry{
|
||||
{Path: "go.work", Type: "blob", Mode: "100644"},
|
||||
{Path: "nested/go.work.sum", Type: "blob", Mode: "100644"},
|
||||
{Path: "vendor/nested/link/go.work", Type: "blob", Mode: "120000"},
|
||||
{Path: "nested/not-go.work.backup", Type: "blob", Mode: "100644"},
|
||||
{Path: "submodule/go.work", Type: "commit", Mode: "160000"},
|
||||
}}
|
||||
}
|
||||
tree := &CandidateTree{entries: entries, inputEntries: entries}
|
||||
err := checkWorkspaceFiles(tree)
|
||||
failure, ok := err.(*Failure)
|
||||
if !ok || failure.Check != WorkspaceFiles {
|
||||
@@ -43,10 +44,11 @@ func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) {
|
||||
tree := &CandidateTree{entries: []git.TreeEntry{
|
||||
entries := []git.TreeEntry{
|
||||
{Path: "go.mod", 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 {
|
||||
t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,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()
|
||||
repository := filepath.Join(b.TempDir(), "repository")
|
||||
if err := os.MkdirAll(repository, 0o755); err != nil {
|
||||
@@ -111,6 +111,7 @@ func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult,
|
||||
gitRun(b, repository, "init", "-b", "main")
|
||||
gitRun(b, repository, "config", "user.name", "ReleaseKit Benchmark")
|
||||
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")
|
||||
gitRun(b, repository, "add", ".")
|
||||
gitRun(b, repository, "commit", "-m", "prepare delivery benchmark")
|
||||
@@ -129,6 +130,7 @@ func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult,
|
||||
targets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "amd64"}}
|
||||
build := releasekit.BuildResult{
|
||||
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",
|
||||
BinaryName: "releasekit", Targets: targets, OutputDir: outputDir,
|
||||
}
|
||||
@@ -173,7 +175,7 @@ func (state *deliveryBenchmarkServer) reset(populated bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (state *deliveryBenchmarkServer) verifyUploads(b *testing.B) {
|
||||
func (state *deliveryBenchmarkServer) verifyUploads(b testing.TB) {
|
||||
b.Helper()
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
@@ -2,39 +2,62 @@ package releasekit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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
|
||||
// expected by an external build or CI event. An empty Remote selects origin.
|
||||
// TaggedCheckoutRequest selects a full Git tag and the Git object ID expected
|
||||
// by an external build or CI event. An empty Remote selects origin.
|
||||
type TaggedCheckoutRequest struct {
|
||||
RepositoryDir string
|
||||
Version string
|
||||
Tag string
|
||||
Remote 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
|
||||
// commit matches ExpectedCommit, and checks out that commit detached. This
|
||||
// operation mutates the selected local repository; use it for disposable
|
||||
// checkouts where tag-fetch and checkout conflicts can be resolved by the
|
||||
// caller. It does not publish a tag or hosted content.
|
||||
// PrepareTaggedCheckout fetches Tag from Remote, verifies that its peeled
|
||||
// commit matches ExpectedCommit, and checks out that commit detached. When
|
||||
// VerifyOnly is true, it performs the same tag, HEAD, and cleanliness checks
|
||||
// without fetching or changing the checkout. It does not publish a tag or
|
||||
// hosted content.
|
||||
func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) error {
|
||||
return internalpreparation.PrepareTaggedCheckout(ctx, internalpreparation.TaggedCheckoutRequest{
|
||||
RepositoryDir: request.RepositoryDir,
|
||||
Version: request.Version,
|
||||
Tag: request.Tag,
|
||||
Remote: request.Remote,
|
||||
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
|
||||
// toolchain selected. It may access module networks and updates the Go module
|
||||
// cache using the caller's Go proxy, private-module, credential, and cache
|
||||
// settings. If required checksums are missing, the Go command may update
|
||||
// go.sum. Cancellation and Go command failures are returned to the caller.
|
||||
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error {
|
||||
return internalpreparation.PrepareBuildDependencies(ctx, repositoryDir)
|
||||
func PrepareBuildDependencies(ctx context.Context, request DependencyPreparationRequest) error {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
@@ -18,7 +19,7 @@ func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
||||
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Tag: "v1.2.3",
|
||||
ExpectedCommit: tagCommit,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -31,6 +32,22 @@ func TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTaggedCheckoutAcceptsNestedFullTag(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
tag := "integrations/ckconfig/v1.2.3"
|
||||
tagCommit := publishPreparationTagNamed(t, repository, tag)
|
||||
advancePreparationCheckout(t, repository, "later local commit")
|
||||
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Tag: tag, ExpectedCommit: tagCommit,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit {
|
||||
t.Fatalf("prepared HEAD = %s, want %s", head, tagCommit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
tagCommit := publishPreparationTag(t, repository)
|
||||
@@ -40,7 +57,7 @@ func TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout(t *testing.T) {
|
||||
|
||||
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Tag: "v1.2.3",
|
||||
Remote: "candidate",
|
||||
ExpectedCommit: currentCommit,
|
||||
})
|
||||
@@ -67,7 +84,7 @@ func TestPrepareTaggedCheckoutReportsLocalCheckoutConflict(t *testing.T) {
|
||||
|
||||
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Tag: "v1.2.3",
|
||||
ExpectedCommit: tagCommit,
|
||||
})
|
||||
if err == nil {
|
||||
@@ -90,7 +107,7 @@ func TestPrepareTaggedCheckoutReportsConflictingLocalTag(t *testing.T) {
|
||||
|
||||
err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository,
|
||||
Version: "v1.2.3",
|
||||
Tag: "v1.2.3",
|
||||
ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD~1"),
|
||||
})
|
||||
if err == nil {
|
||||
@@ -108,9 +125,9 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
publishPreparationTag(t, repository)
|
||||
for _, request := range []releasekit.TaggedCheckoutRequest{
|
||||
{RepositoryDir: repository, Version: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")},
|
||||
{RepositoryDir: repository, Version: "v1.2.3"},
|
||||
{RepositoryDir: repository, Version: "v1.2.3", ExpectedCommit: "not-an-object-id"},
|
||||
{RepositoryDir: repository, Tag: "not-a-version", ExpectedCommit: buildGit(t, repository, "rev-parse", "HEAD")},
|
||||
{RepositoryDir: repository, Tag: "v1.2.3"},
|
||||
{RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: "not-an-object-id"},
|
||||
} {
|
||||
err := releasekit.PrepareTaggedCheckout(context.Background(), request)
|
||||
if err == nil {
|
||||
@@ -121,27 +138,135 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Version: "invalid", ExpectedCommit: "bad",
|
||||
RepositoryDir: repository, Tag: "invalid", ExpectedCommit: "bad",
|
||||
}); !errors.Is(err, releasekit.ErrInvalidVersion) {
|
||||
t.Fatalf("invalid-version error = %v, want ErrInvalidVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTaggedCheckoutVerifyOnlyIsReadOnly(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
wrong bool
|
||||
mutate func(t *testing.T, repository string)
|
||||
}{
|
||||
{name: "wrong expected commit", wrong: true},
|
||||
{name: "dirty checkout", mutate: func(t *testing.T, repository string) {
|
||||
if err := os.WriteFile(filepath.Join(repository, "dirty.txt"), []byte("dirty"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "missing tag", mutate: func(t *testing.T, repository string) {
|
||||
buildGit(t, repository, "tag", "-d", "v1.2.3")
|
||||
}},
|
||||
{name: "moved tag", mutate: func(t *testing.T, repository string) {
|
||||
advancePreparationCheckout(t, repository, "moved tagged commit")
|
||||
buildGit(t, repository, "tag", "--force", "v1.2.3")
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
tagCommit := publishPreparationTag(t, repository)
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.mutate != nil {
|
||||
test.mutate(t, repository)
|
||||
}
|
||||
commit := tagCommit
|
||||
if test.wrong {
|
||||
commit = strings.Repeat("0", len(tagCommit))
|
||||
}
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote",
|
||||
ExpectedCommit: commit, VerifyOnly: true,
|
||||
}); err == nil {
|
||||
t.Fatal("VerifyOnly accepted an inconsistent prepared checkout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
tagCommit := publishPreparationTag(t, repository)
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Tag: "v1.2.3", ExpectedCommit: tagCommit,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := releasekit.PrepareTaggedCheckout(context.Background(), releasekit.TaggedCheckoutRequest{
|
||||
RepositoryDir: repository, Tag: "v1.2.3", Remote: "missing-remote",
|
||||
ExpectedCommit: tagCommit, VerifyOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("VerifyOnly rejected a prepared checkout: %v", err)
|
||||
}
|
||||
if head := buildGit(t, repository, "rev-parse", "HEAD"); head != tagCommit {
|
||||
t.Fatalf("VerifyOnly changed HEAD to %s, want %s", head, tagCommit)
|
||||
}
|
||||
if branch := buildGit(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
|
||||
t.Fatalf("VerifyOnly changed checkout branch to %q", branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) {
|
||||
if err := releasekit.PrepareBuildDependencies(context.Background(), " "); err == nil {
|
||||
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil {
|
||||
t.Fatal("PrepareBuildDependencies() accepted an empty repository directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareBuildDependenciesUsesSelectedModuleDirectory(t *testing.T) {
|
||||
repository := t.TempDir()
|
||||
moduleDir := filepath.Join(repository, "integrations", "ckconfig")
|
||||
if err := os.MkdirAll(moduleDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(moduleDir, "deep"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("integrations/ckconfig/deep", filepath.Join(repository, "linked")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolDir := t.TempDir()
|
||||
marker := filepath.Join(t.TempDir(), "go-directory")
|
||||
tool := filepath.Join(toolDir, "go")
|
||||
if err := os.WriteFile(tool, []byte("#!/bin/sh\nprintf '%s' \"$PWD\" > \"$PREPARE_DIR_MARKER\"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("PREPARE_DIR_MARKER", marker)
|
||||
for _, selected := range []string{"integrations/ckconfig", "linked/.."} {
|
||||
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{
|
||||
RepositoryDir: repository,
|
||||
ModuleDir: selected,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(marker)
|
||||
if err != nil || string(got) != moduleDir {
|
||||
t.Fatalf("go mod download directory for %q = %q, error=%v, want %q", selected, got, err, moduleDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishPreparationTag(t *testing.T, repository string) string {
|
||||
t.Helper()
|
||||
return publishPreparationTagNamed(t, repository, "v1.2.3")
|
||||
}
|
||||
|
||||
func publishPreparationTagNamed(t *testing.T, repository, tag string) string {
|
||||
t.Helper()
|
||||
ref := "refs/tags/" + tag
|
||||
if _, err := buildGitOutput(repository, "show-ref", "--verify", "--quiet", ref); err != nil {
|
||||
buildGit(t, repository, "tag", tag)
|
||||
}
|
||||
remoteDir := t.TempDir()
|
||||
remote := filepath.Join(remoteDir, "origin.git")
|
||||
buildGit(t, remoteDir, "init", "--bare", remote)
|
||||
buildGit(t, repository, "remote", "add", "origin", remote)
|
||||
commit := buildGit(t, repository, "rev-parse", "refs/tags/v1.2.3^{commit}")
|
||||
buildGit(t, repository, "push", "origin", "refs/tags/v1.2.3")
|
||||
buildGit(t, repository, "tag", "-d", "v1.2.3")
|
||||
commit := buildGit(t, repository, "rev-parse", ref+"^{commit}")
|
||||
buildGit(t, repository, "push", "origin", ref)
|
||||
buildGit(t, repository, "tag", "-d", tag)
|
||||
return commit
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ const (
|
||||
|
||||
// PublicationResult describes the publication state reached by this call.
|
||||
type PublicationResult struct {
|
||||
RepositoryDir string // absolute source repository path.
|
||||
ModuleDir string // absolute selected module directory.
|
||||
ModulePath string // selected module's declared Go path.
|
||||
Tag string // complete selected Git tag.
|
||||
Version string // selected stable version
|
||||
CandidateCommit string // captured main commit
|
||||
Endpoint string // captured effective push URL
|
||||
@@ -75,6 +79,7 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
|
||||
candidateInput := candidateRequest(request)
|
||||
result, err := publish.Publish(ctx, publish.Request{
|
||||
RepositoryDir: candidateInput.RepositoryDir,
|
||||
ModuleDir: candidateInput.ModuleDir,
|
||||
Product: candidateInput.Product,
|
||||
Version: candidateInput.Version,
|
||||
Remote: request.Remote,
|
||||
@@ -85,6 +90,10 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
|
||||
Output: candidateInput.Output,
|
||||
})
|
||||
return PublicationResult{
|
||||
RepositoryDir: result.RepositoryDir,
|
||||
ModuleDir: result.ModuleDir,
|
||||
ModulePath: result.ModulePath,
|
||||
Tag: result.Tag,
|
||||
Version: result.Version,
|
||||
CandidateCommit: result.CandidateCommit,
|
||||
Endpoint: result.Endpoint,
|
||||
|
||||
196
publish_test.go
196
publish_test.go
@@ -18,6 +18,44 @@ import (
|
||||
|
||||
const releaseNote = "# Sample App v1.2.3\n\nA source release.\n\nUpgrade notes stay intact.\n"
|
||||
|
||||
func TestCandidateOperationsDiscoverGoOnlyWhenLaterWorkNeedsIt(t *testing.T) {
|
||||
gitPath := mustLookPath(t, "git")
|
||||
gitOnly := t.TempDir()
|
||||
if err := os.Symlink(gitPath, filepath.Join(gitOnly, "git")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, operation := range []string{"check", "publish"} {
|
||||
for _, withBuild := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/build=%t", operation, withBuild), func(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
request := releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
|
||||
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
|
||||
t.Setenv("PATH", gitOnly)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if withBuild {
|
||||
request.Build = &releasekit.BuildSpec{MainPackage: ".", BinaryName: "fixture", Targets: []releasekit.Target{{OS: "linux", Arch: "amd64"}}}
|
||||
}
|
||||
var err error
|
||||
if operation == "check" {
|
||||
_, err = releasekit.Check(context.Background(), request)
|
||||
} else {
|
||||
_, err = releasekit.Publish(context.Background(), request)
|
||||
}
|
||||
if withBuild {
|
||||
if !errors.Is(err, exec.ErrNotFound) {
|
||||
t.Fatalf("later build did not rediscover changed Go inputs: %v", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("finished Go work added a discovery gate: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
wrapper := installGitWrapper(t, repo, "pass")
|
||||
@@ -37,7 +75,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||
if readErr != nil || string(runs) != "ran\n" {
|
||||
return fmt.Errorf("standard suite runs before additional checks: contents=%q error=%v", runs, readErr)
|
||||
}
|
||||
if candidate.RepositoryDir != repo.work || candidate.Commit != commit || candidate.Version != "v1.2.3" {
|
||||
if candidate.RepositoryDir != repo.work || candidate.ModuleDir != repo.work || candidate.ModulePath != "example.com/releasekit-publish-fixture" || candidate.Tag != "v1.2.3" || candidate.Commit != commit || candidate.Version != "v1.2.3" {
|
||||
t.Fatalf("callback candidate = %#v", candidate)
|
||||
}
|
||||
return nil
|
||||
@@ -58,7 +96,7 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||
if result.State != releasekit.PublicationPublished || result.Phase != releasekit.PublicationComplete {
|
||||
t.Fatalf("publication result = %#v", result)
|
||||
}
|
||||
if result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
|
||||
if result.RepositoryDir != repo.work || result.ModuleDir != repo.work || result.ModulePath != "example.com/releasekit-publish-fixture" || result.Tag != "v1.2.3" || result.CandidateCommit != commit || result.Endpoint != repo.bare || result.TagObject == "" {
|
||||
t.Fatalf("publication identity = %#v", result)
|
||||
}
|
||||
|
||||
@@ -94,6 +132,109 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishUsesSeparateFullTagsForModulesAtOneCommit(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
firstDir, secondDir := "integrations/first", "integrations/second"
|
||||
addNestedReleaseModule(t, repo.work, firstDir, "example.com/first", "v1.2.3")
|
||||
addNestedReleaseModule(t, repo.work, secondDir, "example.com/second", "v1.2.3")
|
||||
gitRun(t, repo.work, "add", firstDir, secondDir)
|
||||
gitRun(t, repo.work, "commit", "-m", "add two release modules")
|
||||
gitRun(t, repo.work, "push", "origin")
|
||||
commit := gitText(t, repo.work, "rev-parse", "HEAD")
|
||||
|
||||
for _, moduleDir := range []string{firstDir, secondDir} {
|
||||
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Publish(%q): %v", moduleDir, err)
|
||||
}
|
||||
wantTag := moduleDir + "/v1.2.3"
|
||||
if result.CandidateCommit != commit || result.Tag != wantTag || result.Version != "v1.2.3" {
|
||||
t.Fatalf("publication for %q = %#v", moduleDir, result)
|
||||
}
|
||||
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+wantTag); got != result.TagObject {
|
||||
t.Fatalf("remote tag %s object = %s, want %s", wantTag, got, result.TagObject)
|
||||
}
|
||||
}
|
||||
refs := gitText(t, repo.bare, "for-each-ref", "--format=%(refname)", "refs/tags")
|
||||
if strings.Contains(refs, "refs/tags/v1.2.3\n") || !strings.Contains(refs, "refs/tags/"+firstDir+"/v1.2.3") || !strings.Contains(refs, "refs/tags/"+secondDir+"/v1.2.3") {
|
||||
t.Fatalf("published tag refs = %q", refs)
|
||||
}
|
||||
|
||||
firstTag := firstDir + "/v1.2.3"
|
||||
gitRun(t, repo.work, "tag", "-d", firstTag)
|
||||
conflict, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work,
|
||||
ModuleDir: firstDir,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
})
|
||||
if !errors.Is(err, git.ErrRemoteTagConflict) || conflict.State != releasekit.PublicationConflict || conflict.Tag != firstTag {
|
||||
t.Fatalf("reuse of remote tag result=%#v error=%v", conflict, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishNestedRejectedPushReportsFullTag(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
moduleDir := "integrations/ckconfig"
|
||||
addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3")
|
||||
gitRun(t, repo.work, "add", moduleDir)
|
||||
gitRun(t, repo.work, "commit", "-m", "add nested module")
|
||||
gitRun(t, repo.work, "push", "origin")
|
||||
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
|
||||
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTag := moduleDir + "/v1.2.3"
|
||||
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
})
|
||||
if err == nil || result.State != releasekit.PublicationNotPublished || result.Tag != wantTag {
|
||||
t.Fatalf("nested rejected push result=%#v error=%v", result, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "authoritative tag is absent") {
|
||||
t.Fatalf("nested rejected-push error = %v", err)
|
||||
}
|
||||
if _, err := exec.Command("git", "--git-dir", repo.bare, "show-ref", "--verify", "--quiet", "refs/tags/"+wantTag).CombinedOutput(); err == nil {
|
||||
t.Fatal("rejected nested tag unexpectedly appeared on the remote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishNestedTagKeepsUnknownReconciliationEvidence(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
moduleDir := "integrations/ckconfig"
|
||||
addNestedReleaseModule(t, repo.work, moduleDir, "example.com/ckconfig", "v1.2.3")
|
||||
gitRun(t, repo.work, "add", moduleDir)
|
||||
gitRun(t, repo.work, "commit", "-m", "add nested module")
|
||||
gitRun(t, repo.work, "push", "origin")
|
||||
hook := filepath.Join(repo.bare, "hooks", "pre-receive")
|
||||
if err := os.WriteFile(hook, []byte("#!/bin/sh\ncat >/dev/null\nexit 1\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTag := moduleDir + "/v1.2.3"
|
||||
wrapper := installTagQueryFailureWrapper(t, wantTag)
|
||||
t.Setenv("PATH", wrapper+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work,
|
||||
ModuleDir: moduleDir,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
})
|
||||
if err == nil || result.State != releasekit.PublicationUnknown || result.Tag != wantTag || result.TagObject == "" {
|
||||
t.Fatalf("nested unknown result=%#v error=%v", result, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), wantTag) || !strings.Contains(err.Error(), repo.bare) || !strings.Contains(err.Error(), "reconciliation unavailable") || !strings.Contains(err.Error(), "failed to push some refs") {
|
||||
t.Fatalf("nested reconciliation error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
|
||||
@@ -284,7 +425,7 @@ func requireRacePrerequisites(t *testing.T) {
|
||||
func TestCheckReturnsPublicValidationError(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
|
||||
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
|
||||
captured, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repo.work,
|
||||
Product: "Sample App",
|
||||
Version: "v1.2.3",
|
||||
@@ -300,6 +441,9 @@ func TestCheckReturnsPublicValidationError(t *testing.T) {
|
||||
if !errors.Is(err, exitErr) {
|
||||
t.Fatal("Check() error does not preserve the Go process error identity")
|
||||
}
|
||||
if captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != "v1.2.3" || captured.Commit != repo.commit || captured.Version != "v1.2.3" {
|
||||
t.Fatalf("Check() failure candidate = %#v, want captured identity alongside validation error", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) {
|
||||
@@ -696,6 +840,52 @@ func installGitWrapper(t *testing.T, repo publishFixture, mode string) gitWrappe
|
||||
return installGitWrapperWithMutationMode(t, repo, mode, "")
|
||||
}
|
||||
|
||||
func addNestedReleaseModule(t *testing.T, repository, moduleDir, modulePath, version string) {
|
||||
t.Helper()
|
||||
writeFile(t, repository, moduleDir+"/go.mod", "module "+modulePath+"\n\ngo 1.25.0\n")
|
||||
writeFile(t, repository, moduleDir+"/fixture_test.go", "package fixture\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
|
||||
writeFile(t, repository, moduleDir+"/docs/releases/"+version+".md", "# Sample App "+version+"\n\nNested release.\n")
|
||||
}
|
||||
|
||||
func installTagQueryFailureWrapper(t *testing.T, tag string) string {
|
||||
t.Helper()
|
||||
realGit, 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)
|
||||
}
|
||||
marker := filepath.Join(t.TempDir(), "push-attempted")
|
||||
contents := fmt.Sprintf(`#!/bin/sh
|
||||
REAL_GIT=%s
|
||||
MARKER=%s
|
||||
TAG_REF=%s
|
||||
ARGS=" $* "
|
||||
IS_PUSH=0
|
||||
IS_LS_REMOTE=0
|
||||
for ARG in "$@"; do
|
||||
[ "$ARG" = "push" ] && IS_PUSH=1
|
||||
[ "$ARG" = "ls-remote" ] && IS_LS_REMOTE=1
|
||||
done
|
||||
if [ "$IS_LS_REMOTE" = "1" ] && [ -f "$MARKER" ]; then
|
||||
case "$ARGS" in *"$TAG_REF"*) echo "reconciliation unavailable" >&2; exit 77;; esac
|
||||
fi
|
||||
if [ "$IS_PUSH" = "1" ]; then
|
||||
"$REAL_GIT" "$@"
|
||||
STATUS=$?
|
||||
: > "$MARKER"
|
||||
exit "$STATUS"
|
||||
fi
|
||||
exec "$REAL_GIT" "$@"
|
||||
`, shellQuote(realGit), shellQuote(marker), shellQuote("refs/tags/"+tag))
|
||||
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(contents), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func installGitWrapperWithMutation(t *testing.T, repo publishFixture, object string) gitWrapper {
|
||||
t.Helper()
|
||||
return installGitWrapperWithMutationMode(t, repo, "mutate-tag", object)
|
||||
|
||||
124
repository_test.go
Normal file
124
repository_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package releasekit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||
)
|
||||
|
||||
func TestModuleSelectionFollowsSymlinkBeforeParentAcrossOperations(t *testing.T) {
|
||||
repository := newTaggedDeliveryConsumer(t)
|
||||
const version = "v1.2.3"
|
||||
const modulePath = "example.com/nested"
|
||||
const note = "# Fixture v1.2.3\n\nNested release.\n"
|
||||
addNestedReleaseModule(t, repository, "nested", modulePath, version)
|
||||
writeFile(t, repository, "nested/docs/releases/"+version+".md", note)
|
||||
writeFile(t, repository, "nested/deep/placeholder", "tracked directory\n")
|
||||
if err := os.Symlink("nested/deep", filepath.Join(repository, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buildGit(t, repository, "add", ".")
|
||||
buildGit(t, repository, "commit", "-m", "Add linked nested module")
|
||||
wantDir := filepath.Join(repository, "nested")
|
||||
wantTag := "nested/" + version
|
||||
built, err := releasekit.Build(t.Context(), releasekit.BuildRequest{
|
||||
RepositoryDir: repository, ModuleDir: "link/..", Version: version,
|
||||
})
|
||||
if err != nil || built.ModuleDir != wantDir || built.ModulePath != modulePath || built.Tag != wantTag {
|
||||
t.Fatalf("build = %#v, error = %v", built, err)
|
||||
}
|
||||
checked, err := releasekit.Check(t.Context(), releasekit.ReleaseRequest{
|
||||
RepositoryDir: repository, ModuleDir: "link/..", Product: "Fixture", Version: version,
|
||||
})
|
||||
if err != nil || checked.ModuleDir != wantDir || checked.ModulePath != modulePath || checked.Tag != built.Tag {
|
||||
t.Fatalf("check = %#v, error = %v", checked, err)
|
||||
}
|
||||
buildGit(t, repository, "tag", wantTag)
|
||||
server, remote := newFacadeDeliveryServer(t, repository)
|
||||
defer server.Close()
|
||||
remote.tag, remote.noteBody = wantTag, note
|
||||
delivered, err := releasekit.Deliver(t.Context(), releasekit.DeliveryRequest{
|
||||
RepositoryDir: repository, ModuleDir: "link/..", Product: "Fixture", Version: version,
|
||||
ExpectedTag: wantTag, Destination: facadeDeliveryDestination(server.URL),
|
||||
})
|
||||
if err != nil || delivered.State != releasekit.DeliveryComplete || delivered.ModuleDir != wantDir || delivered.ModulePath != modulePath || delivered.Tag != built.Tag {
|
||||
t.Fatalf("delivery = %#v, error = %v", delivered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceOperationsResolveContainingRepository(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
const module = "apps/widget"
|
||||
addNestedReleaseModule(t, repo.work, module, "example.com/widget", "v1.2.3")
|
||||
gitRun(t, repo.work, "add", ".")
|
||||
gitRun(t, repo.work, "commit", "-m", "Add nested module")
|
||||
gitRun(t, repo.work, "-c", "remote.origin.mirror=false", "push", "origin", "HEAD:refs/heads/main")
|
||||
child := filepath.Join(repo.work, filepath.FromSlash(module))
|
||||
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
|
||||
request := releasekit.ReleaseRequest{RepositoryDir: child, Product: "Sample App", Version: "v1.2.3"}
|
||||
captured, err := releasekit.Check(context.Background(), request)
|
||||
if err == nil || captured.RepositoryDir != repo.work || captured.ModuleDir != repo.work || captured.ModulePath != "example.com/releasekit-publish-fixture" || captured.Tag != request.Version {
|
||||
t.Fatalf("default check = %#v, error = %v; want failing root module", captured, err)
|
||||
}
|
||||
published, err := releasekit.Publish(context.Background(), request)
|
||||
if err == nil || published.State != releasekit.PublicationNotAttempted || published.TagObject != "" || published.Tag != request.Version {
|
||||
t.Fatalf("default publication = %#v, error = %v", published, err)
|
||||
}
|
||||
if refs := gitText(t, repo.bare, "tag", "--list", request.Version); refs != "" {
|
||||
t.Fatalf("failing root tag was published: %s", refs)
|
||||
}
|
||||
for _, selected := range []string{"", module} {
|
||||
wantModule, wantPath, wantTag := repo.work, "example.com/releasekit-publish-fixture", request.Version
|
||||
if selected != "" {
|
||||
wantModule, wantPath, wantTag = child, "example.com/widget", module+"/"+request.Version
|
||||
}
|
||||
built, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: child, ModuleDir: selected, Version: request.Version})
|
||||
if err != nil || built.RepositoryDir != repo.work || built.ModuleDir != wantModule || built.ModulePath != wantPath || built.Tag != wantTag {
|
||||
t.Fatalf("build %q = %#v, error = %v", selected, built, err)
|
||||
}
|
||||
delivered, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{RepositoryDir: child, ModuleDir: selected, Product: request.Product, Version: request.Version, ExpectedTag: "mismatch"})
|
||||
if err == nil || !strings.Contains(err.Error(), "expected delivery tag") || delivered.RepositoryDir != repo.work || delivered.ModuleDir != wantModule || delivered.ModulePath != wantPath || delivered.Tag != wantTag {
|
||||
t.Fatalf("delivery %q = %#v, error = %v", selected, delivered, err)
|
||||
}
|
||||
}
|
||||
request.ModuleDir = module
|
||||
captured, err = releasekit.Check(context.Background(), request)
|
||||
if err != nil || captured.ModuleDir != child || captured.ModulePath != "example.com/widget" || captured.Tag != module+"/"+request.Version {
|
||||
t.Fatalf("nested check = %#v, error = %v", captured, err)
|
||||
}
|
||||
published, err = releasekit.Publish(context.Background(), request)
|
||||
if err != nil || published.State != releasekit.PublicationPublished || published.Tag != captured.Tag {
|
||||
t.Fatalf("nested publication = %#v, error = %v", published, err)
|
||||
}
|
||||
if got := gitText(t, repo.bare, "rev-parse", "refs/tags/"+captured.Tag+"^{commit}"); got != captured.Commit {
|
||||
t.Fatalf("published commit = %s; want %s", got, captured.Commit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryResolutionSupportsAliasesAndIndependentWorktrees(t *testing.T) {
|
||||
repo := newPublishFixture(t)
|
||||
alias := filepath.Join(t.TempDir(), "alias")
|
||||
if err := os.Symlink(repo.work, alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worktree := filepath.Join(t.TempDir(), "worktree")
|
||||
gitRun(t, repo.work, "worktree", "add", "--detach", worktree, "HEAD")
|
||||
for _, tt := range []struct{ directory, wantRoot string }{{alias, repo.work}, {filepath.Join(worktree, "docs"), worktree}} {
|
||||
result, err := releasekit.Build(context.Background(), releasekit.BuildRequest{RepositoryDir: tt.directory, Version: "v1.2.3"})
|
||||
if err != nil || result.RepositoryDir != tt.wantRoot || result.ModuleDir != tt.wantRoot || result.CandidateCommit != repo.commit {
|
||||
t.Fatalf("build through %q = %#v, error = %v", tt.directory, result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyPreparationDoesNotRequireGitCheckout(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeFile(t, directory, "go.mod", "module example.com/preparation\n\ngo 1.26.0\n")
|
||||
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: directory}); err != nil {
|
||||
t.Fatalf("prepare module outside Git checkout: %v", err)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user