48 Commits

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

View File

@@ -8,6 +8,13 @@ tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
maintainer procedure and prepared Woodpecker workflow for hosted note-page
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.

View File

@@ -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 {

View File

@@ -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(".")

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,10 @@ import (
"strings"
"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,

View File

@@ -33,6 +33,10 @@ func TestParse(t *testing.T) {
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
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")

View File

@@ -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 {

View File

@@ -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)
}
}

View File

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

View File

@@ -14,6 +14,9 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
state.update(func(state *deliveryServerData) { state.commit = commit })
goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
@@ -51,8 +51,8 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if !strings.Contains(output, "state complete") {
t.Fatalf("delivery output=%q", output)
}
if state.releaseCreates != 1 || state.assetUploads != 0 {
t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 0 {
t.Fatalf("source-only calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore)
})
@@ -73,7 +73,7 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
state.update(func(state *deliveryServerData) { state.commit = commit })
goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
@@ -88,14 +88,14 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
t.Fatalf("second delivery did not reuse matching assets: %q", output)
}
}
if state.releaseCreates != 1 || state.assetUploads != 2 {
t.Fatalf("binary calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
if state.snapshot().releaseCreates != 1 || state.snapshot().assetUploads != 2 {
t.Fatalf("binary calls = creates:%d uploads:%d", state.snapshot().releaseCreates, state.snapshot().assetUploads)
}
if _, ok := state.assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
t.Fatalf("binary asset was not delivered: %#v", state.assets)
if _, ok := state.snapshot().assets["helper-v1.2.3-"+runtime.GOOS+"-"+runtime.GOARCH]; !ok {
t.Fatalf("binary asset was not delivered: %#v", state.snapshot().assets)
}
if _, ok := state.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not delivered: %#v", state.assets)
if _, ok := state.snapshot().assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not delivered: %#v", state.snapshot().assets)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
})
@@ -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)

View File

@@ -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)

View File

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

View File

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

View File

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

View File

@@ -21,9 +21,10 @@ go run ./cmd/releasekit check [--repo DIR] [--config FILE] VERSION
```
`VERSION` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. 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

View File

@@ -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

View File

@@ -9,21 +9,26 @@ maintainers without adding imports to application or library code.
From the consumer module's root, select the ReleaseKit version to adopt:
```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.

View File

@@ -36,19 +36,24 @@ func build(ctx context.Context) error {
}
```
`Build` records the repository's current `HEAD`, injects the selected stable
version into `buildinfo.Version`, and returns the compiler identity, normalized
targets, artifact paths, filenames, and SHA-256 digests. Names follow
`Build` resolves `ModuleDir` and the selected module's working-tree declaration
once, records the repository's current `HEAD`, injects the selected stable
version into `buildinfo.Version`, and returns repository/module identity, the
derived tag, compiler identity, normalized targets, artifact paths, filenames,
and SHA-256 digests. It does not require a release note, unused tag, clean
`main`, or publication. Names follow
`<binary>-<version>-<goos>-<goarch>`; Windows targets add `.exe`. A repeated
target pair is built once, at its first position. Local builds may use modified
source. `SourceVerified` is true only after a complete build observes clean
source and the same HEAD before and after compilation; binary delivery requires
that fact. This observation is not a source snapshot or attestation guarantee.
The default output directory is `<repository>/dist`; a relative `OutputDir` is
resolved from the repository. Selected artifact filenames are regenerated. Other files in the output
directory are left in place. A failed later build or native version check returns the binaries already
generated in `BuildResult.Artifacts`.
Go commands run from `ModuleDir`, so `MainPackage` is interpreted within that
module. The default output directory is `<module>/dist`; a relative `OutputDir`
is resolved from the module, while an absolute path is used as supplied.
Selected artifact filenames are regenerated. Other files in the output
directory are left in place. A failed later build or native version check
returns the binaries already generated in `BuildResult.Artifacts`.
Builds use the available compatible Go toolchain selected locally; ReleaseKit
does not download a different toolchain automatically. It disables workspace
@@ -72,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.

View File

@@ -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

View File

@@ -7,6 +7,13 @@ The operation accepts historical lightweight and annotated tags, including a
detached checkout, when the checked-out `HEAD` identifies the selected tag
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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -8,30 +8,49 @@ post-validation build callback and local-state recheck. `internal/publish`
adds its endpoint guards, tag creation, push, and recovery around that same
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.

View File

@@ -28,16 +28,12 @@ The maintained [source-delivery image](../examples/woodpecker/releasekit-source-
and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the
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
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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}"

View File

@@ -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}"

View File

@@ -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}"

View File

@@ -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 {

View File

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

View File

@@ -16,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
}

View File

@@ -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)

View File

@@ -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,
})

View File

@@ -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 {

View File

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

View File

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

View File

@@ -13,6 +13,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

View File

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

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

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

View File

@@ -8,30 +8,51 @@ import (
// PrepareTaggedCheckout fetches one explicit tag ref, checks its peeled commit
// 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
}

View File

@@ -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 {

View File

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

View File

@@ -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)

View File

@@ -32,6 +32,37 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
}
}
func TestTagOperationsAcceptFullSlashContainingRefs(t *testing.T) {
fixture := newRemoteFixture(t)
tag := "integrations/check/v2.3.4"
if err := checkTagRef(context.Background(), fixture.work, tag); err != nil {
t.Fatalf("checkTagRef(%q): %v", tag, err)
}
object, err := CreateAnnotatedTag(context.Background(), fixture.work, tag, fixture.commit, []byte("release note\n"))
if err != nil {
t.Fatal(err)
}
if err := InspectLocalTag(context.Background(), fixture.work, tag, object); err != nil {
t.Fatal(err)
}
source, err := InspectTaggedSource(context.Background(), fixture.work, tag)
if err != nil {
t.Fatal(err)
}
if source.TagCommit != fixture.commit || source.TagObject != object || source.TagObjectType != "tag" {
t.Fatalf("source for %q = %#v", tag, source)
}
}
func TestTagReferenceValidationUsesGitRules(t *testing.T) {
fixture := newRemoteFixture(t)
for _, tag := range []string{"", "v1..2.3", "v1.2.3.lock", "v1.2.3/", "v1.2.3\\bad"} {
if err := checkTagRef(context.Background(), fixture.work, tag); err == nil {
t.Errorf("checkTagRef(%q) succeeded", tag)
}
}
}
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
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

View File

@@ -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
}

View File

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

View File

@@ -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

View File

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

View File

@@ -68,6 +68,17 @@ func VerifyLocalAssets(assets []ExpectedAsset) error {
// before the first write if any declared existing asset conflicts or cannot be
// 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
}
}
}

View File

@@ -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)

View File

@@ -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

View File

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

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

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

View File

@@ -16,28 +16,34 @@ import (
type TaggedCheckoutRequest struct {
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)
}

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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}
}

View File

@@ -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)
}

View File

@@ -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}
}

View File

@@ -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":

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"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)
}

View File

@@ -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"))

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -47,8 +47,9 @@ type deliveryBenchmarkIdentity struct {
func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
repository, build, expected := newDeliveryBenchmarkFixture(b)
state := &deliveryBenchmarkServer{commit: gitText(b, repository, "rev-parse", "HEAD"), assets: expected}
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
state.baseURL = server.URL
server := httptest.NewUnstartedServer(http.HandlerFunc(state.serveHTTP))
state.baseURL = "http://" + server.Listener.Addr().String()
server.Start()
b.Cleanup(server.Close)
request := releasekit.DeliveryRequest{
RepositoryDir: repository,
@@ -102,7 +103,7 @@ func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
})
}
func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) {
func newDeliveryBenchmarkFixture(b testing.TB) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) {
b.Helper()
repository := filepath.Join(b.TempDir(), "repository")
if err := os.MkdirAll(repository, 0o755); err != nil {
@@ -111,6 +112,7 @@ func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult,
gitRun(b, repository, "init", "-b", "main")
gitRun(b, repository, "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 +131,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 +176,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()

View File

@@ -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)
}

View File

@@ -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
}

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