52 Commits

Author SHA1 Message Date
e030794969 Remove the remaining product roadmap and repair references 2026-10-08 12:45:16 +00:00
10964bebd4 Retire completed roadmaps and retain pending adoption work 2026-10-08 12:41:11 +00:00
e84d28d249 Write the v0.2.0 release note 2026-10-08 12:37:27 +00:00
8314e0e4d8 Prepare dependencies before manual delivery retries 2026-10-08 12:31:56 +00:00
b6a490cefc Prepare self-bootstrap dependencies through normal Go access 2026-10-08 01:37:48 +00:00
2e0d302178 Treat image labels as Markdown alternative text 2026-10-08 01:32:29 +00:00
95599e96f6 Include autolink text in Markdown heading anchors 2026-10-08 01:31:18 +00:00
1eb1cc6491 Record the v0.2.0 implementation review findings 2026-10-08 01:00:42 +00:00
edd670e62f Complete the v0.2.0 implementation audit 2026-10-07 23:52:55 +00:00
c8fa8ef85f Record Audita delivery migration 2026-10-07 23:45:46 +00:00
eee979db7b Verify tag-selected tool versions 2026-10-07 23:41:25 +00:00
b80d4ee071 Build the CI tool from tagged module settings 2026-10-07 23:33:00 +00:00
40068af1bf Expose Markdown validation selection 2026-10-07 23:26:48 +00:00
6397d41517 Check committed Markdown destinations 2026-10-07 23:17:10 +00:00
b3b959ce77 Add internal Markdown parsing 2026-10-07 23:07:50 +00:00
f80970a9d4 Check committed file sizes 2026-10-07 22:58:38 +00:00
eed95896f4 Inspect committed module replacements 2026-10-07 22:51:55 +00:00
45dd827dda Inspect committed workspace files 2026-10-07 22:45:47 +00:00
c5f925c949 Check committed Go formatting 2026-10-07 22:39:15 +00:00
368043984e Check expected publication push URL 2026-10-07 22:31:58 +00:00
cabc56b134 Compile packages for selected targets 2026-10-07 22:27:31 +00:00
1c41f7a0d1 Coordinate separate full vet pass 2026-10-07 22:18:47 +00:00
30fe4fe206 Enable race testing in validation 2026-10-07 22:10:34 +00:00
89981abbe5 Clarify verification ownership in the implementation plan 2026-10-07 21:34:28 +00:00
9086306498 Finalize the v0.2.0 implementation plan 2026-10-07 21:25:58 +00:00
3974a4686a Keep future validation behavior in the roadmap 2026-10-07 20:29:53 +00:00
1dfa774164 Resolve absolute destinations inside the candidate checkout 2026-10-07 20:29:16 +00:00
9d2ca66a79 Preserve structured diagnostics and candidate error context 2026-10-07 20:27:39 +00:00
02050016c1 Keep candidate object inspection offline 2026-10-07 20:18:13 +00:00
ff83af1e34 Resolve candidate paths in traversal order 2026-10-07 20:13:45 +00:00
56e652aae3 Stop recursive symlink expansion and honor cancellation 2026-10-07 20:10:14 +00:00
1498ec7498 Record validation foundation review findings 2026-10-07 20:05:31 +00:00
f53354be5f Complete shared validation foundation 2026-10-07 18:41:29 +00:00
89c5b2c1fd Document shared validation integration 2026-10-07 18:39:15 +00:00
1ebd45c975 Present validation errors consistently in the CLI 2026-10-07 18:36:37 +00:00
8bac90a9a8 Expose validation errors through the root API 2026-10-07 18:34:34 +00:00
5848307948 Resolve candidate file destinations 2026-10-07 18:31:18 +00:00
1cad13649e Read files from captured Git commits 2026-10-07 18:29:34 +00:00
136a97fb6c Add structured validation failures 2026-10-07 18:27:36 +00:00
1de7555b3e Move shared checks into validation package 2026-10-07 18:26:36 +00:00
0c7129ab75 Settle the incremental validation API rollout 2026-10-07 18:24:25 +00:00
8c742cd11d Plan the shared validation foundation 2026-10-07 17:14:26 +00:00
7afc0de092 Accept the module-selected tooling ADR 2026-10-07 17:02:05 +00:00
2a246990fe Accept the declarative validation ADR 2026-10-07 16:57:21 +00:00
f9f0349eca Record the agreed v0.2.0 validation decisions 2026-10-07 16:54:12 +00:00
f4a32dae73 Expand optional candidate checks for v0.2.0 2026-10-07 16:46:18 +00:00
a4b1b6d5d2 Outline the shared validation model for v0.2.0 2026-10-07 16:37:34 +00:00
f55f6331cb Add agreed PromptKit features to the v0.2.0 roadmap 2026-10-07 16:29:39 +00:00
8ff8efa4a2 Add agreed ConfigKit features to the v0.2.0 roadmap 2026-10-07 16:22:49 +00:00
6de0428675 Request shared release validation for PromptKit 2026-10-07 16:17:09 +00:00
9e480f0b4f Start the v0.2.0 roadmap 2026-10-07 16:11:11 +00:00
60175d6690 Request adaptable release validation for ConfigKit 2026-10-07 16:07:16 +00:00
64 changed files with 6249 additions and 562 deletions

View File

@@ -6,9 +6,7 @@ local candidate check, source-tag publication API, selected-target build API,
tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
`deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only
maintainer procedure and prepared Woodpecker workflow for hosted note-page
delivery are documented. ReleaseKit does not declare binary deliverables. The
[product roadmap](docs/roadmap/releasekit.md) records remaining adoption and
deployment work.
delivery are documented. ReleaseKit does not declare binary deliverables.
See the [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline
and installation commands.
@@ -26,7 +24,8 @@ go run ./cmd/releasekit version
```
The local candidate check validates a committed release note, runs the standard
offline Go test suite, and then runs any configured additional checks:
offline Go suite with any selected shared validation checks, and then runs
configured product checks:
```sh
go run ./cmd/releasekit check v1.2.3
@@ -43,8 +42,6 @@ failure recovery.
## Documentation
- [Product roadmap](docs/roadmap/releasekit.md): consumer adoption goals and
remaining release and deployment work.
- [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading.
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`,

View File

@@ -432,6 +432,9 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
repository := newPublishFixture(t)
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")
prepareCandidateBuild(t, repository.work, `package main
import (
"fmt"
@@ -445,6 +448,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++
return nil

195
check.go
View File

@@ -6,11 +6,17 @@ import (
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
// ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
var ErrInvalidVersion = candidate.ErrInvalidVersion
// ErrUnexpectedPushURL reports that the effective publication push URL does
// not exactly match ReleaseRequest.ExpectedPushURL.
var ErrUnexpectedPushURL = publish.ErrUnexpectedPushURL
// Candidate identifies one committed local source revision selected for
// validation.
type Candidate struct {
@@ -23,45 +29,117 @@ type Candidate struct {
// local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim.
type ReleaseRequest struct {
RepositoryDir string
Product string
Version string
Remote string
// AdditionalChecks optionally runs product checks after the standard suite.
RepositoryDir string
Product string
Version string
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.
ExpectedPushURL string
// Validation selects optional checks run against the captured candidate.
Validation ValidationSpec
// AdditionalChecks optionally runs product checks after shared validation.
AdditionalChecks func(context.Context, Candidate) error
Build *BuildSpec // optional selected-target build after validation.
Build *BuildSpec // optional selected-target build after validation.
Output io.Writer
}
// ValidationSpec selects optional shared validation checks. Its zero value
// preserves the current validation behavior.
type ValidationSpec struct {
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
// Markdown selects committed Markdown destination and fragment checking.
// Nil disables the check; an empty object enables it with the default
// behavior, reporting outside-checkout destinations as out of scope.
Markdown *MarkdownValidationSpec
// ModuleDistribution selects checks for files that affect module-source
// distribution. Its zero value disables those checks.
ModuleDistribution ModuleDistributionValidationSpec
// FileSize selects a committed-file size check. Nil disables it; an empty
// object selects the 1,000,000-byte default.
FileSize *FileSizeValidationSpec
}
// MarkdownValidationSpec selects behavior for committed Markdown links.
type MarkdownValidationSpec struct {
// EnforceCheckoutBoundary fails when a local destination resolves outside
// the candidate checkout. Submodule contents remain out of scope.
EnforceCheckoutBoundary bool
}
// FileSizeValidationSpec selects a maximum size for each committed blob.
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.
// A directory exclusion includes committed descendants.
Exclusions []string
}
// ModuleDistributionValidationSpec selects independent module-source
// distribution checks.
type ModuleDistributionValidationSpec struct {
// WorkspaceFiles rejects committed go.work and go.work.sum blobs, including
// 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.
LocalReplacements bool
}
// GoValidationSpec selects checks performed by the Go toolchain.
type GoValidationSpec struct {
// Race runs the one standard Go test suite with the race detector enabled.
Race bool
// FullVet runs go vet after the suite instead of the test-time vet pass.
// Failures use the go-vet validation check identity.
FullVet bool
// CompileTargets checks that every package builds for each complete target
// pair after testing and vet. Compilation uses CGO_ENABLED=0 and does not
// produce artifacts or execute target binaries. Failures use the
// package-compile validation check identity.
CompileTargets []Target
}
// 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
// AdditionalChecks when supplied. If Build selects targets, Check builds them
// after validation in temporary storage outside the worktree, then rechecks
// 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
// each selected GOOS/GOARCH pair after tests and vet with cgo disabled. Package
// compilation produces no artifacts and does not execute target binaries.
// Validation.GoFormat checks regular committed Go source after those selected
// Go checks and before AdditionalChecks. Its zero value disables formatting.
// Validation.ModuleDistribution.WorkspaceFiles rejects committed workspace
// 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
// 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
// enables the default limit.
// Validation.Markdown checks committed local Markdown destinations and
// supported fragments after file-size inspection and before AdditionalChecks.
// Nil disables the check; an empty object enables it. Outside-checkout
// destinations are reported as out of scope unless the selected specification
// enforces the checkout boundary. Submodule contents remain out of scope.
// The zero values of all selections preserve the ordinary suite. If
// Build selects targets, Check builds them after validation in temporary
// storage outside the worktree, then rechecks
// branch, HEAD, worktree, index, and local tag state. Success establishes local
// candidate evidence only; it does not establish remote availability.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
var validate func(context.Context, candidate.Candidate) error
if request.AdditionalChecks != nil {
validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.AdditionalChecks(ctx, Candidate{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
}
}
checked, err := candidate.Check(ctx, candidate.Request{
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
AdditionalChecks: validate,
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output,
})
checked, err := candidate.Check(ctx, candidateRequest(request))
if err != nil {
return Candidate{}, err
return Candidate{}, convertValidationError(err)
}
return Candidate{
RepositoryDir: checked.RepositoryDir,
@@ -69,3 +147,66 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
Version: checked.Version,
}, nil
}
func candidateRequest(request ReleaseRequest) candidate.Request {
fileSize := internalFileSizeSettings(request.Validation.FileSize)
result := candidate.Request{
RepositoryDir: request.RepositoryDir,
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)),
GoFormat: request.Validation.GoFormat,
Markdown: internalMarkdownSettings(request.Validation.Markdown),
ModuleDistribution: validation.ModuleDistributionSettings{
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
LocalReplacements: request.Validation.ModuleDistribution.LocalReplacements,
},
FileSize: fileSize,
},
AdditionalChecks: candidateChecks(request.AdditionalChecks),
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output,
}
for index, target := range request.Validation.Go.CompileTargets {
result.Validation.CompileTargets[index] = validation.Target{OS: target.OS, Arch: target.Arch}
}
return result
}
func internalMarkdownSettings(spec *MarkdownValidationSpec) *validation.MarkdownSettings {
if spec == nil {
return nil
}
return &validation.MarkdownSettings{EnforceCheckoutBoundary: spec.EnforceCheckoutBoundary}
}
func internalFileSizeSettings(spec *FileSizeValidationSpec) *validation.FileSizeSettings {
if spec == nil {
return nil
}
var limitBytes *int64
if spec.LimitBytes != nil {
limit := *spec.LimitBytes
limitBytes = &limit
}
return &validation.FileSizeSettings{
LimitBytes: limitBytes,
Exclusions: append([]string(nil), spec.Exclusions...),
}
}
func candidateChecks(checks func(context.Context, Candidate) error) func(context.Context, candidate.Candidate) error {
if checks == nil {
return nil
}
return func(ctx context.Context, captured candidate.Candidate) error {
return checks(ctx, Candidate{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
}
}

View File

@@ -112,14 +112,59 @@ type CheckCommand struct {
Env map[string]string `json:"env,omitempty"`
}
// Settings contains the product, remote, and check commands read from a
// releasekit.json profile.
// Settings contains the product, remote, publication expectation, shared
// validation selections, and check commands read from a releasekit.json profile.
type Settings struct {
Product string `json:"product"`
Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
Delivery *DeliverySettings `json:"delivery,omitempty"`
Product string `json:"product"`
Remote string `json:"remote,omitempty"`
ExpectedPushURL string `json:"expected_push_url,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
Validation *ValidationSettings `json:"validation,omitempty"`
Build *BuildSettings `json:"build,omitempty"`
Delivery *DeliverySettings `json:"delivery,omitempty"`
}
// ValidationSettings selects shared candidate checks in a release profile.
type ValidationSettings struct {
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"`
Markdown *MarkdownSettings `json:"markdown,omitempty"`
ModuleDistribution ModuleDistributionSettings `json:"module_distribution,omitempty"`
// FileSize checks the size of each committed blob. Null or omission disables
// the check; an empty object enables the default limit.
FileSize *FileSizeSettings `json:"file_size,omitempty"`
}
// MarkdownSettings selects committed Markdown destination and fragment checks.
type MarkdownSettings struct {
// EnforceCheckoutBoundary fails local destinations that resolve outside
// the candidate checkout. Submodule contents remain out of scope.
EnforceCheckoutBoundary bool `json:"enforce_checkout_boundary,omitempty"`
}
// FileSizeSettings selects the maximum committed-file size and literal exclusions.
type FileSizeSettings struct {
LimitBytes *int64 `json:"limit_bytes,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
}
// ModuleDistributionSettings selects committed module-source distribution checks.
type ModuleDistributionSettings struct {
// WorkspaceFiles rejects committed go.work and go.work.sum blobs.
// Omitted or false disables the check.
WorkspaceFiles bool `json:"workspace_files,omitempty"`
// LocalReplacements rejects filesystem replacements in committed go.mod files.
// Omitted or false disables the check.
LocalReplacements bool `json:"local_replacements,omitempty"`
}
// GoValidationSettings selects checks performed by the Go toolchain.
type GoValidationSettings struct {
Race bool `json:"race,omitempty"`
FullVet bool `json:"full_vet,omitempty"`
CompileTargets []string `json:"compile_targets,omitempty"`
}
// DeliverySettings selects the hosted Gitea release destination.
@@ -207,6 +252,20 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
}
}
}
if settings.Validation != nil {
if fileSize := settings.Validation.FileSize; fileSize != nil && fileSize.LimitBytes != nil && *fileSize.LimitBytes < 0 {
return Settings{}, fmt.Errorf("profile %q: validation.file_size.limit_bytes must not be negative", configPath)
}
for i, target := range settings.Validation.Go.CompileTargets {
if strings.Count(target, "/") != 1 {
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
}
goos, goarch, _ := strings.Cut(target, "/")
if strings.TrimSpace(goos) == "" || strings.TrimSpace(goarch) == "" {
return Settings{}, fmt.Errorf("profile %q: validation.go.compile_targets[%d] must be GOOS/GOARCH", configPath, i)
}
}
}
return settings, nil
}

View File

@@ -141,6 +141,101 @@ func TestLoadSettingsDefaultsRemoteAndConfigPath(t *testing.T) {
}
}
func TestLoadSettingsReadsGoValidation(t *testing.T) {
repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json")
if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"markdown":{"enforce_checkout_boundary":true},"module_distribution":{"workspace_files":true,"local_replacements":true},"file_size":{"limit_bytes":0,"exclusions":["assets"]},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, "")
if err != nil {
t.Fatal(err)
}
if settings.Validation == nil || !settings.Validation.GoFormat || settings.Validation.Markdown == nil || !settings.Validation.Markdown.EnforceCheckoutBoundary || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
t.Fatalf("validation settings = %#v, want Go formatting, workspace inspection, race, and full vet enabled", settings.Validation)
}
if settings.Validation.FileSize == nil || settings.Validation.FileSize.LimitBytes == nil || *settings.Validation.FileSize.LimitBytes != 0 || len(settings.Validation.FileSize.Exclusions) != 1 || settings.Validation.FileSize.Exclusions[0] != "assets" {
t.Fatalf("file size settings = %#v, want explicit zero limit and exclusions", settings.Validation.FileSize)
}
if got, want := strings.Join(settings.Validation.Go.CompileTargets, ","), "linux/amd64,linux/arm64,darwin/arm64"; got != want {
t.Fatalf("compile targets = %q, want %q", got, want)
}
if settings.ExpectedPushURL != "ssh://git.example/owner/repo.git" {
t.Fatalf("expected push URL = %q", settings.ExpectedPushURL)
}
}
func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) {
repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json")
for _, test := range []struct {
name string
profile string
want bool
}{
{name: "omitted", profile: `{"product":"Example","validation":{}}`},
{name: "null", profile: `{"product":"Example","validation":{"markdown":null}}`},
{name: "empty object", profile: `{"product":"Example","validation":{"markdown":{}}}`, want: true},
{name: "boundary enabled", profile: `{"product":"Example","validation":{"markdown":{"enforce_checkout_boundary":true}}}`, want: true},
} {
t.Run(test.name, func(t *testing.T) {
if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, "")
if err != nil {
t.Fatal(err)
}
got := settings.Validation != nil && settings.Validation.Markdown != nil
if got != test.want {
t.Fatalf("Markdown selected = %t, want %t", got, test.want)
}
})
}
}
func TestLoadSettingsPreservesEmptyAndNullFileSize(t *testing.T) {
for _, test := range []struct {
name string
profile string
enabled bool
}{
{name: "empty object", profile: `{"product":"Example","validation":{"file_size":{}}}`, enabled: true},
{name: "default limit", profile: `{"product":"Example","validation":{"file_size":{"limit_bytes":null}}}`, enabled: true},
{name: "null object", profile: `{"product":"Example","validation":{"file_size":null}}`},
{name: "omitted object", profile: `{"product":"Example","validation":{}}`},
} {
t.Run(test.name, func(t *testing.T) {
repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json")
if err := os.WriteFile(profilePath, []byte(test.profile), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, profilePath)
if err != nil {
t.Fatal(err)
}
got := settings.Validation != nil && settings.Validation.FileSize != nil
if got != test.enabled {
t.Fatalf("FileSize enabled = %t, want %t", got, test.enabled)
}
})
}
}
func TestLoadSettingsRejectsNegativeFileSizeLimit(t *testing.T) {
repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json")
profile := `{"product":"Example","validation":{"file_size":{"limit_bytes":-1}}}`
if err := os.WriteFile(profilePath, []byte(profile), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadSettings(repoDir, profilePath); err == nil || !strings.Contains(err.Error(), "validation.file_size.limit_bytes") {
t.Fatalf("LoadSettings() error = %v, want invalid negative file-size limit", err)
}
}
func TestExampleProfileLoads(t *testing.T) {
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil {
@@ -191,6 +286,10 @@ func TestLoadSettingsRejectsInvalidProfiles(t *testing.T) {
{name: "unknown top-level field", content: `{"product":"Example","future":{}}`, want: "unknown field"},
{name: "build package required", content: `{"product":"Example","build":{"binary_name":"example","targets":["linux/amd64"]}}`, want: "build.main_package is required"},
{name: "build target format", content: `{"product":"Example","build":{"main_package":".","binary_name":"example","targets":["linux"]}}`, want: "must be GOOS/GOARCH"},
{name: "compile target format", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target missing architecture", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target missing operating system", content: `{"product":"Example","validation":{"go":{"compile_targets":["/amd64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "compile target repeated separator", content: `{"product":"Example","validation":{"go":{"compile_targets":["linux/amd64/arm64"]}}}`, want: "validation.go.compile_targets[0] must be GOOS/GOARCH"},
{name: "unsafe binary name", content: `{"product":"Example","build":{"main_package":".","binary_name":"../example","targets":["linux/amd64"]}}`, want: "filename component"},
{name: "unknown command field", content: `{"product":"Example","checks":[{"args":["true"],"shell":true}]}`, want: "unknown field"},
{name: "command is required", content: `{"product":"Example","checks":[{"args":[]}]}`, want: "must include a command"},

View File

@@ -40,28 +40,13 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2
}
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Build: configuredBuildSpec(settings.Build),
Output: stderr,
}
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,
}, stderr, stderr)
}
}
request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction {
request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing %s\n", 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: %v\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, err)
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))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
@@ -75,7 +60,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
candidate, err := releasekit.Check(ctx, request)
if err != nil {
fmt.Fprintf(stderr, "check failed: %v\n", err)
fmt.Fprintf(stderr, "check failed: %s\n", candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
@@ -149,6 +134,71 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 2
}
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
Validation: validationSpec(settings.Validation),
Build: configuredBuildSpec(settings.Build),
Output: output,
}
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,
}, output, output)
}
}
return request
}
func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec {
if settings == nil {
return releasekit.ValidationSpec{}
}
result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
},
Go: releasekit.GoValidationSpec{
Race: settings.Go.Race,
FullVet: settings.Go.FullVet,
CompileTargets: make([]releasekit.Target, len(settings.Go.CompileTargets)),
},
}
if settings.Markdown != nil {
result.Markdown = &releasekit.MarkdownValidationSpec{
EnforceCheckoutBoundary: settings.Markdown.EnforceCheckoutBoundary,
}
}
if settings.FileSize != nil {
var limitBytes *int64
if settings.FileSize.LimitBytes != nil {
limit := *settings.FileSize.LimitBytes
limitBytes = &limit
}
result.FileSize = &releasekit.FileSizeValidationSpec{
LimitBytes: limitBytes,
Exclusions: append([]string(nil), settings.FileSize.Exclusions...),
}
}
for index, target := range settings.Go.CompileTargets {
goos, goarch, _ := strings.Cut(target, "/")
result.Go.CompileTargets[index] = releasekit.Target{OS: goos, Arch: goarch}
}
return result
}
func candidateFailureText(err error) string {
return err.Error()
}
func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {

View File

@@ -17,6 +17,7 @@ import (
"strings"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
)
@@ -48,6 +49,7 @@ func TestUnknownCommand(t *testing.T) {
}
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t)
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json")
orderPath := filepath.Join(t.TempDir(), "check-order")
@@ -72,6 +74,9 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")},
}
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
@@ -81,6 +86,11 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
}
var stdout, stderr bytes.Buffer
raceMarker := ""
if raceSelected {
raceMarker = filepath.Join(t.TempDir(), "race-enabled")
t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker)
}
args := []string{"check", "--repo", repository, "--config", profilePath, "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())
@@ -95,9 +105,175 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
}
if raceSelected {
if _, err := os.Stat(raceMarker); err != nil {
t.Fatalf("race-enabled profile did not run: %v", err)
}
}
}
func TestCandidateRequestMapsGoValidation(t *testing.T) {
limit := int64(2048)
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{
Product: "Example Product",
ExpectedPushURL: "publication-only value",
Validation: &cli.ValidationSettings{
GoFormat: true,
Markdown: &cli.MarkdownSettings{EnforceCheckoutBoundary: true},
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true, LocalReplacements: true},
FileSize: &cli.FileSizeSettings{LimitBytes: &limit, Exclusions: []string{"cache"}},
},
}, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
t.Fatalf("candidate request validation = %#v, want Go race and full vet enabled", request.Validation)
}
if !request.Validation.GoFormat {
t.Fatalf("candidate request GoFormat = false, want true")
}
if request.Validation.Markdown == nil || !request.Validation.Markdown.EnforceCheckoutBoundary {
t.Fatalf("candidate request Markdown = %#v, want boundary enforcement enabled", request.Validation.Markdown)
}
if !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements {
t.Fatalf("candidate request module distribution = %#v, want both checks enabled", request.Validation.ModuleDistribution)
}
if request.Validation.FileSize == nil || request.Validation.FileSize.LimitBytes == nil || *request.Validation.FileSize.LimitBytes != limit || len(request.Validation.FileSize.Exclusions) != 1 || request.Validation.FileSize.Exclusions[0] != "cache" {
t.Fatalf("candidate request file size = %#v, want configured limit and exclusions", request.Validation.FileSize)
}
if request.ExpectedPushURL != "" {
t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL)
}
wantTargets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.Go.CompileTargets) != len(wantTargets) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets)
}
for index := range wantTargets {
if request.Validation.Go.CompileTargets[index] != wantTargets[index] {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.Go.CompileTargets, wantTargets)
}
}
}
func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) {
for _, test := range []struct {
name string
settings *cli.MarkdownSettings
want bool
}{
{name: "omitted"},
{name: "empty object", settings: &cli.MarkdownSettings{}, want: true},
} {
t.Run(test.name, func(t *testing.T) {
got := validationSpec(&cli.ValidationSettings{Markdown: test.settings}).Markdown != nil
if got != test.want {
t.Fatalf("Markdown selected = %t, want %t", got, test.want)
}
})
}
}
func TestCandidateCommandsRunMarkdownProfile(t *testing.T) {
repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{
Markdown: &cli.MarkdownSettings{},
},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
writeRepositoryFile(t, repository, "guide.md", "[missing](missing.md#top)\n")
cliGitRun(t, repository, "add", "releasekit.json", "guide.md")
cliGitRun(t, repository, "commit", "-m", "configure Markdown candidate validation")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main")
for _, command := range []string{"check", "publish"} {
t.Run(command, func(t *testing.T) {
var stdout, stderr bytes.Buffer
args := []string{command, "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 1 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q, want validation failure", status, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "markdown-links") || !strings.Contains(stderr.String(), "guide.md:1:") || !strings.Contains(stderr.String(), "missing.md#top") {
t.Fatalf("stderr = %q, want Markdown identity, source location, and target", stderr.String())
}
})
}
}
func TestCandidateCommandsPresentValidationDiagnosticsOnce(t *testing.T) {
repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{Product: "Example Product"})
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 candidate commands")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main")
goPath, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
goDirectory := t.TempDir()
shim := filepath.Join(goDirectory, "go")
if err := os.WriteFile(shim, []byte("#!/bin/sh\nif [ \"$1\" = test ]; then echo 'fixture Go suite failure' >&2; exit 1; fi\nexec \"$RELEASEKIT_REAL_GO\" \"$@\"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_REAL_GO", goPath)
t.Setenv("PATH", goDirectory+string(os.PathListSeparator)+os.Getenv("PATH"))
for _, command := range []struct {
name string
wantStatus int
args []string
}{
{name: "check", wantStatus: 1, args: []string{"check", "--repo", repository, "v1.2.3"}},
{name: "publish", wantStatus: 1, args: []string{"publish", "--repo", repository, "v1.2.3"}},
} {
t.Run(command.name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if status := run(context.Background(), command.args, &stdout, &stderr); status != command.wantStatus {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
if got := strings.Count(stderr.String(), "go-test validation failed"); got != 1 {
t.Fatalf("validation diagnostic appeared %d times: %q", got, stderr.String())
}
if !strings.Contains(stderr.String(), "fixture Go suite failure") {
t.Fatalf("stderr omitted the validation cause: %q", stderr.String())
}
if command.name == "publish" && !strings.Contains(stderr.String(), "state=not_attempted phase=validation") {
t.Fatalf("publication validation error lost its state: %q", stderr.String())
}
})
}
}
func TestCandidateFailurePresentationRetainsCallbackContext(t *testing.T) {
diagnostic := &releasekit.ValidationError{
Check: "examples",
Findings: []releasekit.ValidationFinding{{Path: "examples/run/main.go", Message: "unexpected output"}},
}
err := fmt.Errorf("validate candidate captured-commit: %w", diagnostic)
text := candidateFailureText(err)
for _, part := range []string{"captured-commit", "examples", "examples/run/main.go", "unexpected output"} {
if !strings.Contains(text, part) {
t.Errorf("CLI presentation %q omits %q", text, part)
}
}
}
func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\n## Compatibility\n\nNo action required.\n")
writeRepositoryFile(t, repository, "scripts/check-release-note.sh", `#!/bin/sh
@@ -114,14 +290,19 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
cliGitRun(t, repository, "add", "docs/releases/v1.2.3.md", "scripts/check-release-note.sh")
cliGitRun(t, repository, "commit", "-m", "add compatibility note and check")
checkLog := filepath.Join(t.TempDir(), "compatibility-check.log")
remote := filepath.Join(t.TempDir(), "origin.git")
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Product: "Example Product",
Remote: "origin",
ExpectedPushURL: remote,
Checks: []cli.CheckCommand{{
Args: []string{"sh", "scripts/check-release-note.sh"},
Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog},
}},
}
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
@@ -131,12 +312,16 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
}
cliGitRun(t, repository, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
var stdout, stderr bytes.Buffer
raceMarker := ""
if raceSelected {
raceMarker = filepath.Join(t.TempDir(), "race-enabled")
t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker)
}
args := []string{"publish", "--repo", repository, "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())
@@ -159,6 +344,11 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
if got, want := strings.Count(string(checkResult), "v1.2.3 "+commit), 1; got != want {
t.Fatalf("product compatibility check ran %d times, want %d", got, want)
}
if raceSelected {
if _, err := os.Stat(raceMarker); err != nil {
t.Fatalf("race-enabled profile did not run: %v", err)
}
}
}
func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) {
@@ -276,18 +466,62 @@ func newCLIRepository(t *testing.T) string {
}
write("go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n")
write("candidate_test.go", "package candidate\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
write("race_candidate_test.go", `//go:build race
package candidate
import (
"os"
"testing"
)
func TestRaceBuildSelection(t *testing.T) {
marker := os.Getenv("RELEASEKIT_RACE_MARKER")
if marker == "" {
return
}
if err := os.WriteFile(marker, []byte("race enabled"), 0o600); err != nil {
t.Fatal(err)
}
}
`)
write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "candidate")
return repository
}
func racePrerequisitesAvailable() bool {
supported := runtime.GOOS == "darwin" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") ||
(runtime.GOOS == "freebsd" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "ppc64le") ||
runtime.GOOS == "windows" && runtime.GOARCH == "amd64"
if !supported {
return false
}
cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output()
if err != nil || strings.TrimSpace(string(cgo)) != "1" {
return false
}
cc, err := exec.Command("go", "env", "CC").Output()
if err != nil || len(strings.Fields(string(cc))) == 0 {
return false
}
if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil {
return false
}
return true
}
func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
settings := cli.Settings{Product: "Example Product", Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}}
settings := cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{Go: cli.GoValidationSettings{CompileTargets: []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")
cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs.

View File

@@ -47,17 +47,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
if err := os.WriteFile(notePath, []byte(note), 0o600); err != nil {
t.Fatal(err)
}
ignore, err := os.OpenFile(filepath.Join(repository, ".gitignore"), os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
if _, err := ignore.WriteString("\n/.releasekit/\n"); err != nil {
t.Fatal(err)
}
if err := ignore.Close(); err != nil {
t.Fatal(err)
}
cliGitRun(t, repository, "add", "docs/releases/"+version+".md", ".gitignore")
cliGitRun(t, repository, "add", "docs/releases/"+version+".md")
cliGitRun(t, repository, "commit", "-m", "prepare source-only delivery")
state := &deliveryServerState{product: "ReleaseKit"}
@@ -78,16 +68,11 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
cliGitRun(t, repository, "remote", "set-url", "origin", remote)
cliGitRun(t, repository, "push", "origin", "HEAD:refs/heads/main", "refs/tags/"+version)
tool := filepath.Join(repository, ".releasekit", "bin", "releasekit")
template, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
if err != nil {
t.Fatal(err)
}
buildBlock, err := extractWoodpeckerCommand(string(template), "build-tool")
if err != nil {
t.Fatal(err)
}
deliveryBlock, err := extractWoodpeckerCommand(string(template), "deliver-page")
block, err := extractWoodpeckerCommand(string(template), "deliver")
if err != nil {
t.Fatal(err)
}
@@ -96,19 +81,14 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
"CI_COMMIT_SHA": state.commit,
"CI_COMMIT_TAG": version,
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
"GOTOOLCHAIN": "local",
"GOPROXY": "off",
"GOSUMDB": "off",
}
runWoodpeckerBlock(t, strings.ReplaceAll(buildBlock, "$${", "${"), repository, env)
if got := cliGitOutput(t, repository, "rev-parse", "HEAD"); got != state.commit {
t.Fatalf("template checkout = %s, want %s", got, state.commit)
}
command := strings.ReplaceAll(block, "$${", "${")
state.remoteCommit = strings.Repeat("f", 40)
failure := exec.Command(tool, "deliver", "--repo", repository, version)
failure.Dir = repository
failure.Env = append(os.Environ(), "RELEASEKIT_GITEA_TOKEN=fixture-token")
if output, err := failure.CombinedOutput(); err == nil {
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 {
@@ -117,7 +97,7 @@ func TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout(t *testing.T) {
state.remoteCommit = ""
for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerBlock(t, strings.ReplaceAll(deliveryBlock, "$${", "${"), repository, env)
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") {
t.Fatalf("delivery attempt %d output = %q", attempt+1, output)
}

View File

@@ -1,12 +1,18 @@
package main
import (
"archive/zip"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync/atomic"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
@@ -19,30 +25,15 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
pin, err := woodpeckerMatrixValue(string(contents), "RELEASEKIT_VERSION")
if err != nil {
t.Fatal(err)
}
block, err := extractWoodpeckerCommand(string(contents), "deliver")
if err != nil {
t.Fatal(err)
}
command := strings.ReplaceAll(block, "${RELEASEKIT_VERSION}", pin)
command = strings.ReplaceAll(command, "$${", "${")
command := strings.ReplaceAll(block, "$${", "${")
executable := filepath.Join(t.TempDir(), "releasekit")
build := exec.Command("go", "build", "-o", executable, "./cmd/releasekit")
build.Dir = root
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build local ReleaseKit executable: %v\n%s", err, output)
}
module := "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@" + pin
t.Run("source-only", func(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire example.com/unavailable/module v1.2.3\n")
cliGitRun(t, repository, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency")
selectReleaseKitModule(t, repository, root, false)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
@@ -53,24 +44,22 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
shimDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog)
for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") {
t.Fatalf("delivery attempt %d output=%q", attempt+1, output)
}
goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
output := runWoodpeckerBlock(t, command, repository, env)
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 log, err := os.ReadFile(goLog); err != nil || strings.Contains(string(log), "mod download") {
t.Fatalf("source-only command downloaded application dependencies: log=%q error=%v", log, err)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore)
})
t.Run("binary", func(t *testing.T) {
repository := newDeliveryBuildRepository(t)
selectReleaseKitModule(t, repository, root, true)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
@@ -86,8 +75,10 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository)
state.commit = commit
shimDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog)
goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
sumBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.sum"))
for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") {
@@ -106,10 +97,221 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if _, ok := state.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not delivered: %#v", state.assets)
}
if log, err := os.ReadFile(goLog); err != nil || !strings.Contains(string(log), "mod download") {
t.Fatalf("binary command did not prepare application dependencies: log=%q error=%v", log, err)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
})
t.Run("wrong tag stops before Go", func(t *testing.T) {
repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository)
goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, strings.Repeat("f", len(commit)), goDir, goLog)
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("wrong tag succeeded: %s", output)
}
assertNoCommands(t, goLog)
})
t.Run("dirty module stops before Go", func(t *testing.T) {
repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository)
writeRepositoryFile(t, repository, "go.mod", "module example.com/different-tool-selection\n\ngo 1.25.0\n")
goDir, goLog := installControlledGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("dirty module succeeded: %s", output)
}
assertNoCommands(t, goLog)
})
t.Run("selected executable receives event arguments and its status", func(t *testing.T) {
repository := newCLIRepository(t)
selectReleaseKitModule(t, repository, root, false)
commit := pushTaggedCLIRepository(t, repository)
goDir, goLog := installControlledGoBootstrapShim(t)
toolArgs := filepath.Join(t.TempDir(), "tool-arguments")
env := templateEnvironment(repository, strings.ToUpper(commit), goDir, goLog)
env["RELEASEKIT_TOOL_ARGS_LOG"] = toolArgs
env["RELEASEKIT_TOOL_EXIT"] = "37"
output, err := executeWoodpeckerBlock(command, repository, env)
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 37 {
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>"} {
if !strings.Contains(args, expected) {
t.Errorf("selected executable arguments %q omit %q", args, expected)
}
}
assertGoSelection(t, repository, goLog)
})
}
func TestWoodpeckerTagDeliveryUsesTaggedModuleVersion(t *testing.T) {
root := cliRepositoryRoot(t)
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml"))
if err != nil {
t.Fatal(err)
}
block, err := extractWoodpeckerCommand(string(contents), "deliver")
if err != nil {
t.Fatal(err)
}
command := strings.ReplaceAll(block, "$${", "${")
proxy := writeReleaseKitModuleProxy(t)
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t)
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n")
cliGitRun(t, repository, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
oldCommit := cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.198\n")
cliGitRun(t, repository, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "upgrade the default branch tool")
cliGitRun(t, repository, "init", "--bare", filepath.Join(filepath.Dir(repository), "origin.git"))
remote := filepath.Join(filepath.Dir(repository), "origin.git")
cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "origin", "refs/heads/main", "refs/tags/v1.2.3")
cliGitRun(t, repository, "checkout", "--detach", oldCommit)
if mainModule := cliGitOutput(t, repository, "show", "main:go.mod"); !strings.Contains(mainModule, module+" v0.2.198") {
t.Fatalf("default branch module = %q, want upgraded tool selection", mainModule)
}
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
commit := oldCommit
env := templateEnvironment(repository, commit, goDir, goLog)
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
env["RELEASEKIT_SUM_LOG"] = sumLog
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "helper=tool-only dependency") || strings.Contains(output, "tool-version=v0.2.198") {
t.Fatalf("retry selected the wrong tool version: %q", output)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore)
assertToolOnlyChecksums(t, sumLog)
})
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
goDir, goLog := installGoBootstrapShim(t)
repository := newCLIRepository(t)
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n"
writeRepositoryFile(t, repository, "go.mod", goMod)
cliGitRun(t, repository, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "select a versioned tool replacement")
commit := pushTaggedCLIRepository(t, repository)
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
env := templateEnvironment(repository, commit, goDir, goLog)
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
env["RELEASEKIT_SUM_LOG"] = sumLog
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "tool-version=v0.2.198") || !strings.Contains(output, "helper=tool-only dependency") {
t.Fatalf("versioned replacement was not selected: %q", output)
}
assertModuleBootstrap(t, repository, goLog, moduleBefore)
assertToolOnlyChecksums(t, sumLog)
})
}
func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
root := cliRepositoryRoot(t)
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
if err != nil {
t.Fatal(err)
}
block, err := extractWoodpeckerCommand(string(contents), "deliver")
if err != nil {
t.Fatal(err)
}
command := strings.ReplaceAll(block, "$${", "${")
repository := newCLIRepository(t)
const dependency = "example.com/releasekit-helper"
proxyDir := t.TempDir()
writeModuleProxyVersion(t, proxyDir, dependency, "v1.0.0", "module "+dependency+"\n\ngo 1.25.0\n", map[string]string{
"helper.go": "package helper\n\nconst Message = \"prepared tool dependency\"\n",
})
var proxyRequests atomic.Int64
files := http.FileServer(http.Dir(proxyDir))
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyRequests.Add(1)
files.ServeHTTP(w, r)
}))
defer proxy.Close()
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+dependency+" v1.0.0\n")
writeRepositoryFile(t, repository, "cmd/releasekit/main.go", "package main\n\nimport (\"fmt\"; \"os\"; helper \""+dependency+"\")\n\nfunc main() {\n\tfmt.Println(\"self-bootstrap \" + helper.Message)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n")
preparationEnv := map[string]string{
"GOPROXY": proxy.URL, "GOSUMDB": "off", "GONOPROXY": "none", "GOPRIVATE": "",
"GOWORK": "off", "GOTOOLCHAIN": "local", "GOENV": "off", "GOFLAGS": "-modcacherw",
"GOMODCACHE": t.TempDir(),
}
runWoodpeckerBlock(t, "go mod download "+dependency, repository, preparationEnv)
cliGitRun(t, repository, "add", "cmd/releasekit/main.go", "go.mod", "go.sum")
cliGitRun(t, repository, "commit", "-m", "add the self-bootstrapped command")
commit := pushTaggedCLIRepository(t, repository)
goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, goDir, goLog)
for key, value := range preparationEnv {
env[key] = value
}
env["GOMODCACHE"] = t.TempDir()
proxyRequests.Store(0)
env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit))
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
t.Fatalf("wrong commit unexpectedly bootstrapped the tool: %s", output)
}
assertNoCommands(t, goLog)
if proxyRequests.Load() != 0 {
t.Fatal("wrong commit reached tool dependency preparation")
}
env["CI_COMMIT_SHA"] = commit
output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "self-bootstrap prepared tool dependency") || !strings.Contains(output, `"`+commit+`"`) {
t.Fatalf("self-bootstrap output = %q", output)
}
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"`) {
t.Fatalf("self-bootstrapped command arguments = %q", output)
}
log := readWoodpeckerFixtureFile(t, goLog)
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") {
t.Fatalf("self-bootstrap Go command = %q", log)
}
var executable string
for index, field := range strings.Fields(log) {
if field == "-o" && index+1 < len(strings.Fields(log)) {
executable = strings.Fields(log)[index+1]
break
}
}
if executable == "" {
t.Fatalf("self-bootstrap output path missing from Go command: %q", log)
}
if relative, err := filepath.Rel(repository, executable); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
t.Fatalf("self-bootstrap executable %q is not outside source %q: relative=%q error=%v", executable, repository, relative, err)
}
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("self-bootstrap changed the tagged source: %q", status)
}
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
t.Fatalf("self-bootstrap checkout branch = %q, want detached HEAD", branch)
}
entries, err := os.ReadDir(filepath.Dir(repository))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-source.") {
t.Fatalf("self-bootstrap temporary directory remains: %s", entry.Name())
}
}
}
func cliRepositoryRoot(t *testing.T) string {
@@ -156,34 +358,22 @@ func extractWoodpeckerCommand(contents, stepName string) (string, error) {
return "", fmt.Errorf("step %q was not found", stepName)
}
func woodpeckerMatrixValue(contents, key string) (string, error) {
for _, line := range strings.Split(contents, "\n") {
field, value, ok := strings.Cut(strings.TrimSpace(line), ":")
if ok && field == key {
return strings.TrimSpace(value), nil
}
}
return "", fmt.Errorf("matrix value %q was not found", key)
}
func installGoBootstrapShim(t *testing.T) (string, string) {
t.Helper()
shimDir := t.TempDir()
goLog := filepath.Join(shimDir, "go-commands")
shim := `#!/bin/sh
if [ "$1" = "run" ]; then
shift
[ "$1" = "$RELEASEKIT_EXPECTED_MODULE" ] || { echo "unexpected module: $1" >&2; exit 90; }
shift
[ "$1" = "ci-deliver" ] || { echo "unexpected command: $1" >&2; exit 91; }
[ "$2" = "--repo" ] && [ "$3" = "$CI_WORKSPACE" ] || { echo "unexpected repository arguments" >&2; exit 92; }
[ "$4" = "--commit" ] && [ "$5" = "$CI_COMMIT_SHA" ] || { echo "unexpected commit arguments" >&2; exit 93; }
[ "$6" = "$CI_COMMIT_TAG" ] && [ "$#" = 6 ] || { echo "unexpected version argument" >&2; exit 94; }
printf '%s\n' "run $RELEASEKIT_EXPECTED_MODULE" >> "$RELEASEKIT_GO_LOG"
exec "$RELEASEKIT_LOCAL_EXECUTABLE" "$@"
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
"$RELEASEKIT_REAL_GO" "$@"
status=$?
if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
modfile=
for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
done
cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG"
fi
printf '%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
exec "$RELEASEKIT_REAL_GO" "$@"
exit "$status"
`
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
t.Fatal(err)
@@ -191,27 +381,79 @@ exec "$RELEASEKIT_REAL_GO" "$@"
return shimDir, goLog
}
func templateEnvironment(repository, commit, executable, module, shimDir, goLog string) map[string]string {
func installControlledGoBootstrapShim(t *testing.T) (string, string) {
t.Helper()
shimDir := t.TempDir()
goLog := filepath.Join(shimDir, "go-commands")
shim := `#!/bin/sh
set -eu
printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
modfile=
for argument in "$@"; do
case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
done
[ -n "$modfile" ] && [ -f "$modfile" ] || { echo 'alternate module file is missing' >&2; exit 89; }
[ -f "${modfile%.mod}.sum" ] || { echo 'alternate checksum file is missing' >&2; exit 88; }
grep -F "$RELEASEKIT_MODULE v0.2.0" "$modfile" >/dev/null || { echo 'module selection was not copied' >&2; exit 87; }
case "$1" in
list)
printf '%s v0.2.0\n' "$RELEASEKIT_MODULE"
;;
build)
output=
previous=
for argument in "$@"; do
if [ "$previous" = "-o" ]; then output=$argument; break; fi
previous=$argument
done
[ -n "$output" ] || { echo 'go build omitted output path' >&2; exit 90; }
cat > "$output" <<'TOOL'
#!/bin/sh
printf '<%s>\n' "$@" > "$RELEASEKIT_TOOL_ARGS_LOG"
exit "${RELEASEKIT_TOOL_EXIT:-0}"
TOOL
chmod +x "$output"
;;
*)
echo "unexpected Go command: $1" >&2
exit 91
;;
esac
`
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
t.Fatal(err)
}
return shimDir, goLog
}
func templateEnvironment(repository, commit, shimDir, goLog string) map[string]string {
realGo, _ := exec.LookPath("go")
return map[string]string{
"CI_WORKSPACE": repository,
"CI_COMMIT_SHA": commit,
"CI_COMMIT_TAG": "v1.2.3",
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
"RELEASEKIT_EXPECTED_MODULE": module,
"RELEASEKIT_LOCAL_EXECUTABLE": executable,
"RELEASEKIT_REAL_GO": realGo,
"RELEASEKIT_GO_LOG": goLog,
"GOTOOLCHAIN": "local",
"GOPROXY": "off",
"GOSUMDB": "off",
"GOFLAGS": "",
"PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"CI_WORKSPACE": repository,
"CI_COMMIT_SHA": commit,
"CI_COMMIT_TAG": "v1.2.3",
"RELEASEKIT_GITEA_TOKEN": "fixture-token",
"RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit",
"RELEASEKIT_REAL_GO": realGo,
"RELEASEKIT_GO_LOG": goLog,
"GOTOOLCHAIN": "local",
"GOPROXY": "off",
"GOSUMDB": "off",
"GOFLAGS": "",
"PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"),
}
}
func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string {
t.Helper()
output, err := executeWoodpeckerBlock(block, repository, environment)
if err != nil {
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output)
}
return output
}
func executeWoodpeckerBlock(block, repository string, environment map[string]string) (string, error) {
command := exec.Command("sh", "-e", "-c", block)
command.Dir = repository
command.Env = os.Environ()
@@ -219,10 +461,183 @@ func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[
command.Env = setEnvironment(command.Env, key, value)
}
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output)
return string(output), err
}
func selectReleaseKitModule(t *testing.T, repository, releaseKitSource string, includeSum bool) {
t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
goMod := fmt.Sprintf("module example.com/tagged-app\n\ngo 1.25.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
writeRepositoryFile(t, repository, "go.mod", goMod)
cliGitRun(t, repository, "add", "go.mod")
if includeSum {
writeRepositoryFile(t, repository, "go.sum", "")
cliGitRun(t, repository, "add", "go.sum")
}
return string(output)
cliGitRun(t, repository, "commit", "-m", "select ReleaseKit from module configuration")
}
func writeReleaseKitModuleProxy(t *testing.T) string {
t.Helper()
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
const toolDependency = "example.com/releasekit-helper"
proxyDir := t.TempDir()
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
for _, version := range []string{"v0.2.197", "v0.2.198"} {
files := map[string]string{
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n",
}
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
}
writeModuleProxyVersion(t, proxyDir, toolDependency, "v1.0.0", "module "+toolDependency+"\n\ngo 1.25.0\n", map[string]string{"tooldep.go": "package tooldep\n\nconst Version = \"tool-only dependency\"\n"})
return proxyDir
}
func writeModuleProxyVersion(t *testing.T, proxyDir, module, version, moduleFile string, files map[string]string) {
t.Helper()
var escaped strings.Builder
for _, character := range module {
if character >= 'A' && character <= 'Z' {
escaped.WriteByte('!')
character += 'a' - 'A'
}
escaped.WriteRune(character)
}
moduleVersions := filepath.Join(proxyDir, filepath.FromSlash(escaped.String()), "@v")
if err := os.MkdirAll(moduleVersions, 0o700); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(moduleVersions, "list")); os.IsNotExist(err) {
if err := os.WriteFile(filepath.Join(moduleVersions, "list"), []byte(version+"\n"), 0o600); err != nil {
t.Fatal(err)
}
} else {
listPath := filepath.Join(moduleVersions, "list")
list := readWoodpeckerFixtureFile(t, listPath)
if !strings.Contains(list, version+"\n") {
if err := os.WriteFile(listPath, []byte(list+version+"\n"), 0o600); err != nil {
t.Fatal(err)
}
}
}
if err := os.WriteFile(filepath.Join(moduleVersions, version+".mod"), []byte(moduleFile), 0o600); err != nil {
t.Fatal(err)
}
info := fmt.Sprintf("{\"Version\":%q,\"Time\":\"2026-10-01T00:00:00Z\"}\n", version)
if err := os.WriteFile(filepath.Join(moduleVersions, version+".info"), []byte(info), 0o600); err != nil {
t.Fatal(err)
}
archive, err := os.Create(filepath.Join(moduleVersions, version+".zip"))
if err != nil {
t.Fatal(err)
}
writer := zip.NewWriter(archive)
prefix := module + "@" + version + "/"
files["go.mod"] = moduleFile
for name, contents := range files {
file, err := writer.Create(prefix + name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(contents)); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
}
func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string, sumBefore ...string) {
t.Helper()
assertGoSelection(t, repository, goLog)
if got := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")); got != moduleBefore {
t.Fatalf("source go.mod changed during bootstrap: got %q, want %q", got, moduleBefore)
}
sumPath := filepath.Join(repository, "go.sum")
if len(sumBefore) == 0 {
if _, err := os.Stat(sumPath); !os.IsNotExist(err) {
t.Fatalf("source go.sum state changed: stat error = %v", err)
}
} else if got := readWoodpeckerFixtureFile(t, sumPath); got != sumBefore[0] {
t.Fatalf("source go.sum changed during bootstrap: got %q, want %q", got, sumBefore[0])
}
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("bootstrap changed tagged source: %q", status)
}
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
t.Fatalf("checkout branch = %q, want detached HEAD", branch)
}
entries, err := os.ReadDir(filepath.Dir(repository))
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
}
}
}
func assertGoSelection(t *testing.T, repository, goLog string) {
t.Helper()
log := readWoodpeckerFixtureFile(t, goLog)
list := "list -mod=mod -modfile="
build := "build -mod=mod -modfile="
module := "gitea.maximumdirect.net/eric/ReleaseKit"
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
t.Fatalf("Go module selection command is missing required settings: %q", log)
}
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") {
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log)
}
if strings.Index(log, list) > strings.Index(log, build) {
t.Fatalf("Go build ran before module selection: %q", log)
}
var modfile string
for _, field := range strings.Fields(log) {
if strings.HasPrefix(field, "-modfile=") {
modfile = strings.TrimPrefix(field, "-modfile=")
break
}
}
if modfile == "" {
t.Fatalf("Go commands did not receive an alternate module file: %q", log)
}
if relative, err := filepath.Rel(repository, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
t.Fatalf("alternate module file %q is not outside source %q: relative=%q error=%v", modfile, repository, relative, err)
}
}
func assertNoCommands(t *testing.T, goLog string) {
t.Helper()
contents, err := os.ReadFile(goLog)
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if len(contents) != 0 {
t.Fatalf("Go ran before tag/source verification: %q", contents)
}
}
func assertToolOnlyChecksums(t *testing.T, path string) {
t.Helper()
sums := readWoodpeckerFixtureFile(t, path)
if !strings.Contains(sums, "example.com/releasekit-helper v1.0.0 h1:") {
t.Fatalf("temporary module sum file omitted the tool-only dependency checksum: %q", sums)
}
}
func readWoodpeckerFixtureFile(t *testing.T, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(contents)
}
func setEnvironment(environment []string, key, value string) []string {

View File

@@ -0,0 +1,76 @@
# ADR 0003: Declare optional validation through a shared specification
## Status
Accepted.
## Date
2026-10-07
## Context
Consumers have different release-validation needs. Their additional checks
include reusable mechanics such as formatting inspection and Go toolchain
invocations, alongside assertions about product behavior. Maintaining the
reusable mechanics in each application duplicates code and coordination work.
[ADR 0001](0001-public-facade.md) establishes facade ownership of public
contracts. [ADR 0002](0002-routine-releases.md) prioritizes routine releases
with little application-owned code or recurring developer effort. Optional
validation needs a common selection and execution model that supports these
decisions as shared capabilities grow.
## Decision
Consumers select shared validation through a typed, facade-owned specification
and equivalent release-profile settings. Related settings belong together.
Omitted optional settings preserve existing default validation behavior.
`Check` and `Publish` use one internal validation path bound to the captured
candidate. ReleaseKit owns execution, ordering, coordination between selected
checks, and propagation of failures and cancellation. The CLI maps profile
settings to the facade and owns presentation.
Shared validation exposes consistent diagnostics through facade-owned errors.
Findings identify the check and provide actionable detail where applicable.
Error wrapping preserves underlying causes and cancellation semantics.
Validation failures and publication outcomes retain their distinct meanings.
The existing `AdditionalChecks` callback and profile `checks` support
product-specific assertions. Publication guards, artifact builds, and delivery
retain their separate configuration and responsibilities. Validation execution
and coordination remain internal implementation responsibilities.
Evaluate optional capabilities by the application maintenance they remove,
their clarity of scope, configuration burden, and maintenance cost. A
capability can serve a small subset of consumers when those benefits justify
shared support. Consumers select the checks appropriate to their products.
GoDoc owns exact public API contracts, and the configuration reference owns
profile settings. Roadmaps own feature scope and implementation sequencing.
## Alternatives considered
- Consumer-owned commands preserve local control and leave applications
maintaining reusable inspection, invocation, and coordination code.
- Independently configured check and publication paths allow separate
validation choices and require consumers to keep those choices aligned.
- A public validator-registration framework supports custom composition and
expands the public lifecycle and ordering contracts. The existing
additional-check extension point supports current product-specific needs.
## Consequences
Applications can replace repeated release mechanics with small declarations.
Go callers and CLI users receive equivalent selected validation through the
same release operations. Existing consumers retain their default behavior and
additional-check integration as optional capabilities are introduced.
ReleaseKit assumes responsibility for maintaining shared checks, coordinating
their interactions, and presenting useful diagnostics. Public selections and
error contracts carry compatibility obligations. Verification must cover
default behavior, equivalent API and profile selections, and failure propagation.
Internal execution can evolve within the facade boundaries. Each new capability
requires a clear scope and evidence that shared ownership reduces consumer work.

View File

@@ -0,0 +1,68 @@
# ADR 0004: Select release tooling from the released application's module configuration
## Status
Accepted.
## Date
2026-10-07
## Context
Applications can declare ReleaseKit as a Go module dependency and use that
selection to build their local release tool. A separate CI version pin creates
a second declaration that maintainers must coordinate during upgrades. The two
paths can then use different ReleaseKit versions for the same release.
Hosted delivery runs after source publication and may be retried for an older
release. It needs a stable source for tool-version selection associated with
that release. [ADR 0002](0002-routine-releases.md) prioritizes reducing repeated
configuration and routine release decisions.
## Decision
For applications that declare ReleaseKit as a module dependency, the standard
integration selects the tool through the application's module configuration.
Local publication uses the candidate's module configuration. CI delivery uses
the released source's module configuration. Both paths follow the same
module-based selection approach.
CI verifies the triggering tag and commit and checks out that source before
resolving and building the release tool. Delivery retries use the same released
source for tool selection. The application maintains its ReleaseKit dependency
as the shared version declaration for these operations.
Tool bootstrap and dependency preparation remain separate from offline
validation and builds. The CI integration supplies the event, environment, and
credentials; the application profile supplies product and delivery settings.
Source publication and hosted delivery retain their separate outcomes.
Consumers using a separately installed tool and ReleaseKit's own source
bootstrap remain supported use cases. Adoption guidance describes the
appropriate bootstrap for each integration.
## Alternatives considered
- An independent CI version pin makes tool selection explicit in the workflow
and requires maintainers to coordinate it with the application's dependency.
- Selecting from the current default branch follows ongoing dependency
updates and allows delivery retries for an older release to select a newer
tool.
## Consequences
Applications using the standard module-based integration update one dependency
declaration for local publication and CI delivery. Tool selection follows the
released source when delivery is retried, preserving the release's dependency
choice as the application evolves.
CI must establish the released checkout before tool bootstrap. Its environment
must provide a compatible Go toolchain and access to the dependencies needed
to build the selected tool. Retrying delivery for an older release retains
those preparation requirements.
Maintained templates and adoption guidance must preserve this selection order.
Verification should exercise a dependency-version change and selection from
the triggering release's source. Exact commands and bootstrap mechanics belong
in the maintained integration guidance and examples.

View File

@@ -23,15 +23,42 @@ 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, local
Git state, runs the standard offline Go test suite, and then runs any configured
additional checks in profile order. It does not inspect remote availability or
publish a tag.
captured local commit and version. 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
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.
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
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
`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
Markdown destinations and supported heading fragments after file-size
inspection. An empty object enables the check; omission or `null` disables it.
Outside-checkout links are written as out-of-scope observations to standard
error by default. Setting `validation.markdown.enforce_checkout_boundary` to
true makes those links fail; submodule-content observations remain
nonfailures. Failures use the `markdown-links` identity with the source location
and target.
`check` writes errors and diagnostics to standard error. It returns status 0
after local validation, 1 after a candidate validation failure, and 2 for usage,
profile, or invalid-version errors. Unsupported argument sequences print usage
to standard error.
profile, or invalid-version errors. Shared validation failures identify the
failed check and retain useful findings and cause context in one diagnostic;
separate full-vet failures use the `go-vet` identity.
Unsupported argument sequences print usage to standard error.
Publish a source tag:
@@ -39,16 +66,57 @@ Publish a source tag:
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION
```
`publish` reads the same product, remote, and additional-check settings as
`check`. It checks the authoritative remote and validates the candidate: the
standard offline Go test suite runs first, followed by configured checks 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
`publish` reads the same product, remote, validation, and additional-check
settings as `check`. It checks the authoritative remote and validates the
candidate: the single standard offline Go test suite runs first, with `-race`
when `validation.go.race` is true. When `validation.go.full_vet` is true,
test-time vet is disabled and `go vet -mod=readonly ./...` runs after successful
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
on success. Validation
progress and failed publication state, including the captured publication
endpoint for recovery, are written to standard error. A signal
cancels the active operation; if it arrives during a push, the library's
bounded reconciliation determines whether the exact tag was accepted.
Shared validation failures use the same check and finding presentation as
`check`, alongside publication state and phase.
When `validation.go.compile_targets` is selected, package compilation runs
after tests and full vet and before configured checks. Each failure is reported
as `package-compile` with its selected target; this does not create build
artifacts or execute target binaries. When `validation.go_format` is true,
committed Go formatting runs after package compilation and before configured
checks. The selected workspace-file check then reports committed go.work and
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,
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
committed blob after module-distribution checks. An omitted or null object
disables the check; an empty object enables the 1,000,000 decimal-byte default.
Oversized files use the `file-size` identity and report their path and measured
size against the limit. See the [configuration reference](config.md) for
literal exclusions and the current-candidate scope.
When `validation.markdown` is a non-null object, `publish` checks committed
Markdown destinations after file-size inspection and before configured checks.
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.
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
rewrites are resolved first, and the push URL is used when fetch and push URLs
differ. `check` ignores this field.
The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version
@@ -152,3 +220,13 @@ preparation failures return status 1; usage, profile, credential, and invalid
version errors return status 2. Final delivery uses the same statuses as
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
and 3 for uncertain remote state.
The maintained module-based tag workflow verifies and checks out the event tag
before resolving the consumer module. It builds the ReleaseKit version already
selected by that tagged `go.mod`, using temporary alternate module files outside
the source checkout. This tool bootstrap is separate from `ci-deliver`'s
application dependency preparation for binary profiles. ReleaseKit's own
source workflow self-bootstraps `./cmd/releasekit`; consumers without a declared
ReleaseKit module dependency can use the separately installed-tool command.
See the [delivery guide](consumers/delivery.md#woodpecker-tag-workflows) for
compatible Go environments, retries, and preparation details.

View File

@@ -10,7 +10,9 @@ The current profile fields are:
| --- | --- | --- |
| `product` | Yes | Product name used by release operations. |
| `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. |
| `validation` | No | Optional shared checks selected by ReleaseKit. |
| `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. |
@@ -25,6 +27,97 @@ Unknown fields are rejected so a setting cannot be silently ignored. The
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.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
running vet twice. `validation.go.compile_targets` is an ordered array of
`GOOS/GOARCH` pairs. After tests and selected full vet pass, ReleaseKit compiles
all packages for each target with cgo disabled. Each pair must include both
components; the selected Go toolchain reports unsupported complete targets.
This source compatibility check does not name or produce binaries, run a target
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
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
compilation, before configured product checks.
When `validation.markdown` is an object, ReleaseKit checks local destinations
and supported heading fragments in committed regular Markdown files. Omission
or `null` disables the check; `{}` enables it. Schemed and protocol-relative
links are skipped, URI query components do not affect path checks, and other
existing files or directories receive existence checks only. Missing local
destinations, symlink cycles, and missing Markdown headings fail with the
`markdown-links` identity. Outside-checkout destinations are reported as out
of scope by default. Set
`validation.markdown.enforce_checkout_boundary` to `true` to fail those links;
submodule contents remain out of scope under either setting.
The parser recognizes inline links, images, reference links, and CommonMark
autolinks; link-like text in code is ignored, and raw HTML destinations are
outside this check. For linked images, the image source and enclosing link
target are checked. Image labels supply alternative text.
Relative paths resolve from the source document in the
captured commit. Query components are ignored for file existence, and percent
escapes in local paths and fragments are decoded once. Fragments are compared
in lowercase with anchors derived from visible ATX or Setext heading text.
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
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
`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,
and unavailable selected contents or parse failures are reported as inspection
errors. This selection is independent of `workspace_files` and Go's `GOWORK`
setting. Omitted or false disables it.
For example:
```json
{
"product": "Example",
"validation": {
"go": {
"race": true,
"full_vet": true,
"compile_targets": ["linux/amd64", "linux/arm64"]
},
"go_format": true,
"markdown": {},
"module_distribution": {
"workspace_files": true,
"local_replacements": true
}
}
}
```
When `validation.file_size` is an object, ReleaseKit compares each committed
blob's size with `limit_bytes`. The limit uses decimal bytes and rejects only
sizes strictly greater than the limit. An omitted or null object disables the
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
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;
symlink size is the committed link text. Gitlinks have no measured size. Only
the captured candidate is checked, one offending file is reported at a time,
and historical or aggregate repository size is not measured.
The `delivery` object contains `base_url`, `owner`, `repository`, and optional
`token_env`. Its values are required when running `deliver`, but credentials are
not required for `check`, `publish`, or `build`. `token_env` names the
@@ -32,12 +125,44 @@ environment variable containing the Gitea token; it defaults to
`RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
passes its value explicitly to the library operation.
The `check` and `publish` commands always run
`go test -mod=readonly -count=1 ./...` with Go module and toolchain network
access disabled. They then run each configured `checks` command once, in order,
as an additional product check. Selected-target builds run after both. Do not
repeat the standard suite in `checks`; doing so runs it twice. The `build`
command uses the selected build settings and writes binaries to `dist` unless
`expected_push_url`, when nonempty, is compared exactly with Git's resolved
effective push URL during `publish`, before candidate validation. It applies
to the push destination when fetch and push URLs differ, and Git URL rewrites
such as `insteadOf` are resolved before comparison. A mismatch fails preflight;
omitting the field keeps the selected remote's existing behavior. `check` is
local-only and ignores this publication expectation.
The `check` and `publish` commands run one
`go test -mod=readonly -count=1 ./...` suite with Go module and toolchain network
access disabled, adding `-race` when `validation.go.race` is true. When
`validation.go.full_vet` is true, they add `-vet=off` to that test command and
run native `go vet -mod=readonly ./...` after successful tests. They then run
`go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
`validation.go.compile_targets` entry, in profile order. Compile failures use
the `package-compile` identity and include the selected target. When selected,
`validation.go_format` checks regular committed Go source with go/format after
package compilation; unformatted paths use the `go-format` identity. When
`validation.module_distribution.workspace_files` is true, shared validation
also reports committed go.work and go.work.sum blobs after Go formatting, using
the workspace-files identity. Uncommitted workspace files are not inspected.
When `validation.module_distribution.local_replacements` is true, shared
validation parses committed `go.mod` files after workspace inspection and reports
filesystem replacements as `local-replacements`, with path, directive line, and
target. Versioned replacements are permitted; parse and inspection failures
preserve their causes.
When `validation.file_size` is selected, shared validation checks committed
blob sizes after module-distribution checks and reports the first oversized
path with the `file-size` identity and measured and limit byte counts.
When `validation.markdown` is selected, Markdown checking follows file-size
inspection and runs before configured product checks. Out-of-scope observations
are written to the command's standard error and do not fail validation unless
the checkout boundary is enforced. Submodule observations remain nonfailures.
Configured `checks` commands run afterward, once each in order, as additional
product checks. Artifact builds selected by the separate `build` setting run
after shared validation and product checks.
Do not repeat the standard suite or vet in `checks`; doing so repeats work. The
`build` command uses the selected build settings and writes binaries to `dist` unless
`--output` selects another path.
The `deliver` command uses the delivery destination and token. By default it
passes selected targets to the delivery facade, which builds tagged-source
@@ -58,6 +183,14 @@ dependencies. This preparation may access the network and may update
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
ReleaseKit executable. The maintained module-based tag workflow verifies the
tag first, then uses temporary copies of that tag's module files to build the
ReleaseKit version selected by the consumer's `go.mod`. The
[delivery guide](consumers/delivery.md#woodpecker-tag-workflows) describes that
bootstrap, the self-bootstrapped source workflow, and the separately installed
tool option.
ReleaseKit's source-only profile is
[releasekit.json](../releasekit.json).
See the [source-only profile](../examples/releasekit.json) and

View File

@@ -8,6 +8,9 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project",
Product: "Example",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
},
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir)
},
@@ -24,14 +27,64 @@ version must be stable `vMAJOR.MINOR.PATCH`. The committed
product name and exact version as complete title elements. No local tag with
that version may already exist.
ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available
compatible local Go toolchain, then calls `AdditionalChecks` once when supplied.
It does not download a different toolchain automatically. The standard suite
uses the Go host target, disables workspace and module network access, and
ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective
cache and native cgo/C-tool settings are preserved, including values from Go's
persistent environment file. The check revalidates the branch, commit, and
cleanliness after validation.
ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite 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
complete target pair after tests and vet. Package compilation uses
`CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its
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,
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`.
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
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
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.
Set `Validation.Markdown` to check committed local Markdown destinations and
supported heading fragments. Nil disables the check; an empty object enables
it with outside-checkout destinations reported as out of scope. Set
`EnforceCheckoutBoundary` to fail outside-checkout links. Submodule contents
remain out of scope under either setting. The checker runs after file-size
inspection and before `AdditionalChecks`; its nonfailure observations use the
request's output writer. Markdown failures use the `markdown-links` identity
and include the source location and destination.
Use these selections for ReleaseKit-owned validation rather than repeating the
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.
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,
disables workspace and module network access, and ignores ambient `GOFLAGS`,
experiment, and CPU feature overrides. Effective cache and native cgo/C-tool
settings are preserved, including values from Go's persistent environment
file. The check revalidates the branch, commit, and cleanliness after
validation.
A successful check is evidence about the local candidate only. It does not
check whether a remote branch or tag is available and does not create a tag.

View File

@@ -150,28 +150,48 @@ exit codes, and transport behavior.
Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml)
in the consumer repository's `.woodpecker/` directory. Set `GO_IMAGE` to an
image with Go 1.25 or newer, Git, and a Go version compatible with the consumer
module. Replace `RELEASEKIT_VERSION` with an immutable ReleaseKit revision or
published version. The example selects the initial `v0.1.0` baseline; update
that pin when adopting a later release. Configure the Woodpecker repository secret
`releasekit_gitea_token`; the template maps it to `RELEASEKIT_GITEA_TOKEN` only
for the delivery step. The repository needs a `delivery` profile. Page-only and
binary behavior both use this template; the profile's build target list selects
whether binaries are built and delivered.
image with Go 1.25 or newer, Git, and a local Go version compatible with the
consumer module. It uses `GOTOOLCHAIN=local`; Go does not download a different
toolchain. The tag's `go.mod` must select ReleaseKit as a module dependency.
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
profile's build target list selects whether application binaries are built and
delivered.
The command runs versioned source directly with `go run`, passing
`CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as command arguments.
`ci-deliver` fetches and verifies the event tag against its commit before
checking out the commit detached. It then loads the profile from that source,
validates the destination and token, and downloads application dependencies
only for profiles with build targets. The facade builds and delivers those
targets with the note page. Source-only profiles skip dependency downloads and
binary work. The workflow does not repeat checkout guards or maintain a
separate build/receipt/delivery sequence.
Before running Go, the template fetches the explicit event tag, compares its
peeled commit with the provider's full commit ID without regard to hex case,
checks out that commit detached, and confirms the source tree is clean. It then
copies the tagged `go.mod` and any `go.sum` to a temporary directory outside
the checkout. `go list -m` confirms that the module selects ReleaseKit; Go uses
an alternate `-modfile` to prepare dependencies and build the selected
`cmd/releasekit` package without changing the tagged module files. The template
invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and
`CI_COMMIT_TAG`. A cleanup trap removes the temporary metadata and executable.
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.
If the application does not declare ReleaseKit as a module dependency, install
the executable separately and invoke the same `ci-deliver` command. For
ReleaseKit's own source workflow, the maintained
[source-delivery template](../../examples/woodpecker/releasekit-source-delivery.yml)
checks out the tag and self-bootstraps `./cmd/releasekit` into temporary storage.
Its readonly tool build uses committed module metadata and can fetch missing
dependencies through normal Go proxy, private-module, checksum, and cache
settings. Tool bootstrap follows tag verification; offline candidate validation
and application artifact compilation retain their separate environments.
The normal Go module and build caches are sufficient. Cache mounts may be added
as deployment configuration when they help the runner; they are not needed by
the template. A locally retained executable can be installed with
the template. A separately installed executable can be installed with
`go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
with the same arguments:
@@ -179,9 +199,10 @@ with the same arguments:
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG"
```
Upgrade by changing the selected immutable
`RELEASEKIT_VERSION` (or the `@PIN` used by `go install`) and validating the
workflow against a tag fixture. The command and profile behavior are documented
The module-based workflow follows the ReleaseKit version selected by the tagged
consumer's `go.mod`, so retries use that source's original selection. Update the
consumer module configuration when adopting a newer ReleaseKit version and
verify it with a tag fixture. The command and profile behavior are documented
in the [CLI contract](../cli.md) and [configuration reference](../config.md).
Woodpecker runs delivery after the tag event, independently from the source

View File

@@ -5,9 +5,13 @@ annotated source tag:
```go
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project",
Product: "Example",
Version: "v1.2.3",
RepositoryDir: "/work/project",
Product: "Example",
Version: "v1.2.3",
ExpectedPushURL: "ssh://git.example/owner/example.git",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
},
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir)
},
@@ -20,12 +24,72 @@ fmt.Println(result.Version, result.CandidateCommit, result.TagObject)
Publication captures a clean candidate on `main`, validates its committed
release note, and checks the selected remote before running candidate checks.
It always runs `go test -mod=readonly -count=1 ./...` with the available
compatible local Go toolchain. It then calls `AdditionalChecks` once when
supplied. The standard suite cannot be replaced; move product-specific behavior
into `AdditionalChecks` and remove repeated standard-suite commands from custom
checks. A failed suite or additional check prevents tag creation, and wrapped
errors preserve their causes.
Set `ExpectedPushURL` to require the effective push destination to match an
exact string. Git URL rewrites are resolved first, and the push URL is compared
when fetch and push URLs differ. A mismatch fails preflight before Go validation,
callbacks, or tag creation; detect it with
`errors.Is(err, releasekit.ErrUnexpectedPushURL)`. `Check` ignores this
publication-only field.
It runs one `go test -mod=readonly -count=1 ./...` suite 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 that suite. The zero values
keep ordinary test-time vet behavior. It then calls `AdditionalChecks` once
when supplied. The standard suite cannot be replaced;
move product-specific behavior into `AdditionalChecks` and remove repeated
standard-suite commands from custom checks. A failed suite or additional check
prevents tag creation, and wrapped errors preserve their causes.
If race prerequisites are unavailable, the selected suite fails as `go-test`
instead of silently running without the race detector. Full-vet failures are
reported as `go-vet` and stop publication before tag creation.
Set `Validation.Go.CompileTargets` to compile packages for each complete target
pair after tests and vet. This uses `CGO_ENABLED=0`, produces no artifact, and
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
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.
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
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
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
captured candidate and reports one oversized file with its measurement as
`file-size`; it stops publication before product checks or tag creation.
Set `Validation.Markdown` to check committed local Markdown destinations and
supported heading fragments during publication. Nil disables the check; an
empty object enables it with outside-checkout links reported as out of scope.
Set `EnforceCheckoutBoundary` to fail those links. Submodule contents remain
out of scope under either setting. Observations use the request's output writer;
failures use `markdown-links` and stop publication before product checks or tag
creation.
ReleaseKit-owned selections replace duplicate generic suite, vet, formatting,
distribution, and Markdown checks in configured product checks. Keep
release-note rules and consumer-specific assertions, such as maintained-example
tests, in `AdditionalChecks` or profile `checks`.
If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the

View File

@@ -2,8 +2,7 @@
ReleaseKit contains a Go module with the documented root `releasekit` facade,
adopted policies, and design documents. See the [README](../README.md) for
orientation and the [product roadmap](roadmap/releasekit.md) for proposed
capabilities and implementation sequencing.
orientation.
Read the [agent policy](policy/agents.md) and inspect the worktree before editing.
Preserve unrelated changes and follow explicit instructions about commits.
@@ -55,8 +54,7 @@ go run ./internal/apicheck
See the [candidate check guide](consumers/check.md), [source publication guide](consumers/publish.md),
[build consumer guide](consumers/build.md),
and [internal package inventory](internal/overview.md) for implemented behavior
and capability ownership. The [product roadmap](roadmap/releasekit.md) records
remaining adoption and deployment work.
and capability ownership.
For prose changes, review the diff and check local links, fragment destinations,
provenance, template residue, and consistency with current repository contents.

View File

@@ -41,6 +41,9 @@ 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. Builds
use static mode for selected-target compilation and the native version-check
build. The native version executable itself runs as a normal child process.
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.

View File

@@ -25,9 +25,9 @@ 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` has no required third-party modules; packages use the
standard library and first-party ReleaseKit packages. `go list ./...` reports
these implemented packages:
version `1.25.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:
| Package | Responsibility |
| --- | --- |
@@ -40,12 +40,13 @@ these implemented packages:
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, 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, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. |
| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
| `internal/git` | Git command boundary, committed-tree and 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/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. |
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `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` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
@@ -58,6 +59,17 @@ dependency direction with `go run ./internal/apicheck`. These constraints are
also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
[architecture policy](../policy/architecture.md).
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
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.
## Capability owners and behavioral evidence
The following tests are representative evidence from the current offline suite,
@@ -66,15 +78,17 @@ not a separate release matrix:
| Capability | Implementation owner | Existing evidence |
| --- | --- | --- |
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
| Candidate capture, standard suite, additional checks, and rechecks | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `cmd/releasekit/internal/cli` | `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate` |
| 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` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
| 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`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |
| CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |
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
@@ -86,7 +100,3 @@ The [Gitea integration guide](../integrations/gitea.md) owns the HTTP contract
and hosted response handling. The [Woodpecker examples](../../examples/woodpecker/)
are prepared integrations; their YAML is not active in this repository, and no
live Woodpecker runner, configured secret, or hosted delivery has been used.
A read-only PromptKit and Narratio migration review and projected deletion
estimates are recorded in the [product roadmap](../roadmap/releasekit.md);
actual consumer migrations and measurement of realized savings remain future
adoption work.

143
docs/internal/validation.md Normal file
View File

@@ -0,0 +1,143 @@
# Shared validation mechanics
`internal/validation` owns reusable checks for one captured candidate.
`internal/candidate` captures the clean source revision and keeps the
validation sequence shared by local checking and publication. It calls the
shared executor, runs product-specific `AdditionalChecks` once, and owns the
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
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
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.
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.
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,
directive, and target; versioned replacements are allowed. It runs after the
workspace-file check using the same candidate inventory.
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 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.
The internal Markdown parser uses Goldmark to record inline, reference, and
autolinks, images, titles, and their source use sites, and to build heading
anchors from visible AST text. It supports ATX and Setext headings, decodes
Markdown text through the parser, and assigns collision-free numeric suffixes
to repeated anchors. Link locations use one-based line and byte-column values.
A per-path cache retains parsed records and anchors for candidate documents.
`checkMarkdownLinks` examines regular `.md` blobs from the captured tree. It
skips schemed and protocol-relative URIs, strips query components, and decodes
percent escapes once in local paths and fragments. It resolves destinations
through `CandidateTree.ResolveDestination`; decoded fragments are compared in
lowercase against anchors from the resolved regular Markdown document. Missing
paths, symlink cycles, and missing anchors produce `markdown-links` findings at
the source use site. Outside-checkout links produce out-of-scope observations by
default and findings when boundary enforcement is selected. Submodule-content
observations remain out of scope under either setting. Observations go to the
provided writer; a nil writer keeps library use quiet. Public selection is
defined in the [configuration reference](../config.md) and GoDoc.
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.
Public validation errors convert their diagnostic fields to the internal
failure model for shared formatting. Converted failures retain their original
contextual cause and reuse its rendering when it already contains the same
diagnostics. The CLI presents the complete error chain, including candidate
context around callback-supplied diagnostics.
## Adding a shared check
When an optional check is accepted for implementation:
1. Define its typed Go setting in the root facade and its equivalent CLI-owned
JSON field. Specify zero-value and omitted-profile behavior in GoDoc and the
configuration reference. Keep future settings in the roadmap until their
check is implemented.
2. Add the corresponding internal setting and explicit conversions. Use the
same root request conversion for `Check` and `Publish`, and the shared CLI
request conversion for profile-based candidate commands.
3. Implement a plain check function owned by `internal/validation`. Keep its
rule semantics in the capability owner and pass only the captured candidate
inputs it needs. A file-oriented check can call `Request.ReadCandidateTree`
and read only the blobs it inspects.
4. Return an internal `*Failure` with the check identity, repository-relative
findings, useful locations where available, and the underlying cause. Let
the root facade convert those records to its own diagnostic types. Preserve
cancellation and process errors through `Unwrap`.
5. Add the check to the shared executor at the ordering point selected by its
capability. Stop on failure. Keep product assertions in `AdditionalChecks`
and profile `checks`; keep artifact building in `internal/build`.
6. Cover observable behavior through both the Go facade and profile path when
both are supported. Check default behavior, useful findings, cause
preservation, cancellation where relevant, and failure stopping. Reuse the
existing local Git and CLI fixtures.
7. Update only the canonical owners for behavior that has landed: GoDoc for the
API, configuration and CLI references for profile behavior, and this
inventory for internal ownership. Record proposed checks under
`docs/roadmap/`.
Do not add a public validator registration interface. The public specification
selects ReleaseKit-owned checks; `AdditionalChecks` remains the extension point
for product behavior.
## Go command environment reuse
The current executor uses the [shared Go command environment](gotool.md) for
its offline suite, vet pass, and selected-target package compilation.
Reuse `internal/gotool` for native and static environments and target handling.
Keep toolchain and module-cache discovery in `gotool`; do not reproduce its
environment logic in a checker. Artifact output names, version injection,
native smoke checks, and artifact digests remain owned by `internal/build`.
## File-check coordination
File checks consume the captured tree and return findings to the shared error
model. They do not read sibling worktrees or follow live symlink targets.
`CandidateTree.ResolveDestination` returns data describing entries,
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
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.
The public selection is owned by the facade and profile;
the internal checker uses the committed-file and destination-resolution
primitives described here.

View File

@@ -1,5 +1,30 @@
# Open Source Attribution Notices
## Go Authors
Selected module: `golang.org/x/mod` at `v0.40.0`.
Copyright 2009 The Go Authors.
SPDX-License-Identifier: BSD-3-Clause
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) | [LICENSE](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f/LICENSE).
[Source](https://go.googlesource.com/mod/+/d3398d06de5fa5c71083d3d1c26f2cda73508e0f).
## Goldmark
Selected module: `github.com/yuin/goldmark/v2` at `v2.1.6`.
Copyright (c) 2019 Yusuke Inuzuka.
SPDX-License-Identifier: MIT
License and notices: [MIT](licenses/MIT.txt) |
[LICENSE](https://github.com/yuin/goldmark/blob/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c/LICENSE).
[Source](https://github.com/yuin/goldmark/tree/a8d4ee5a0bd7d5493fdd1e339b4e75df92f8684c).
## Standard Repository Policies
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,

View File

@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

21
docs/oss/licenses/MIT.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Yusuke Inuzuka
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -11,8 +11,8 @@
ReleaseKit is an opinionated Go library and release executable for common
publication, builds, version injection, and delivery. This policy owns
responsibility boundaries, dependency direction, and adopted constraints for
development. The [product roadmap](../roadmap/releasekit.md) owns proposed
capabilities, implementation status, and sequencing.
development. Roadmaps own proposed capabilities, implementation status, and
sequencing.
The [internal documentation](../internal/overview.md) owns concrete package
inventory and implementation mechanics. Canonical public contracts own exact
@@ -106,7 +106,7 @@ Source publication and asynchronous delivery have distinct outcomes. The
declarations and GoDoc define supported consumer API contracts;
[consumer guides](../consumers/) explain their use, and
[integration guides](../integrations/) define external formats and protocols.
The product roadmap records planned capabilities and implementation status.
Roadmaps record planned capabilities and implementation status.
## Trust Boundaries And Threat Scope

View File

@@ -17,8 +17,7 @@ summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements.
This policy governs releases of ReleaseKit itself. Go declarations and GoDoc
own supported consumer API contracts; the [product roadmap](../roadmap/releasekit.md)
records planned adoption and deployment work. The maintainer runbook describes
own supported consumer API contracts. The maintainer runbook describes
ReleaseKit's implemented source publication and hosted-page delivery procedure.
## Release Model And Completion

View File

@@ -121,6 +121,7 @@ immutable version from its tag:
```sh
git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z"
git checkout --detach "vX.Y.Z"
GOTOOLCHAIN=local go mod download
mkdir -p .releasekit/bin
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit
RELEASEKIT_GITEA_TOKEN="$GITEA_TOKEN" .releasekit/bin/releasekit deliver "vX.Y.Z"

106
docs/releases/v0.2.0.md Normal file
View File

@@ -0,0 +1,106 @@
# ReleaseKit v0.2.0
## Summary
ReleaseKit adds declarative candidate validation and module-selected release
tooling for Woodpecker. Consumers can replace repeated release checks with typed
Go selections or equivalent profile settings, while retaining product-specific
assertions through `AdditionalChecks` and profile `checks`.
## New features
- A facade-owned `ValidationSpec` on `ReleaseRequest` and corresponding profile
`validation` object select shared checks for both `Check` and `Publish`.
Structured `ValidationError` diagnostics identify the failed check, relevant
paths and locations, compilation targets, directives, and size measurements.
Wrapping preserves underlying causes and cancellation.
- Race testing selects the race detector for the one standard Go suite. Separate
full vet automatically disables test-time vet and runs after successful tests.
Selected package-compilation targets run afterward with `CGO_ENABLED=0`,
independently of executable artifacts. Source-only libraries can check target
compatibility and retain page-only delivery.
- Go formatting checks inspect committed regular Go source and report formatting
differences while preserving files.
- Offline Markdown validation checks local destinations and supported heading
fragments, including inline/reference links, images, and autolinks. Heading
anchors support visible text, duplicate headings, and Unicode. Outside-checkout
destinations are reported as out of scope by default; consumers can select
boundary enforcement. Submodule contents remain out of scope.
- Independent module-distribution selections inspect committed workspace files
and filesystem module replacements. Versioned replacements are permitted.
- Optional file-size validation checks individual committed blobs, including
symlink text, with a configurable limit and literal file/directory exclusions.
Enabling the check selects a default limit of 1,000,000 bytes; only sizes
strictly greater than the selected limit fail.
- `ExpectedPushURL` and profile `expected_push_url` require an exact match with
Git's effective push destination during publication preflight. A mismatch
returns `ErrUnexpectedPushURL` before candidate validation or tag creation.
Local `Check` ignores the publication expectation.
- The maintained Woodpecker consumer template selects ReleaseKit from the
released application's module configuration. It verifies the event tag and
commit before building the tool, prepares tool dependencies in temporary module
metadata, and preserves tagged module files. Retries use the triggering tag's
dependency selection. ReleaseKit's self-bootstrap template also supports
fetching missing tool dependencies after tag verification.
See the [configuration reference](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/config.md),
[candidate check guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/check.md),
[publication guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/publish.md),
and [delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/delivery.md)
for supported behavior and adoption examples.
## Compatibility
The minimum Go version remains 1.25.0. The root orchestration facade and separate
lightweight `buildinfo` runtime facade keep their existing responsibilities.
Goldmark and `golang.org/x/mod` are new internal dependencies; prepare the module
cache before offline validation or builds.
Omitted validation selections preserve v0.1.0 defaults. Existing profiles and
product-specific checks continue to work. Race testing requires the native
toolchain's race prerequisites and reports an error when they are unavailable.
Markdown and file-size checks are enabled by non-nil Go specifications or profile
objects, including empty objects; omission or `null` disables them.
`ReleaseRequest` gains fields, so callers using positional struct literals must
convert them to keyed literals. Validation diagnostics now include structured
detail and additional context. Go callers can inspect `ValidationError` with
`errors.As` and underlying causes with `errors.Is`; CLI integrations should use
exit classifications rather than complete diagnostic strings.
Source publication still completes at confirmed publication of the intended
annotated tag. Hosted Gitea delivery retains its separate status. ReleaseKit's
deliverables remain the source tag and hosted note page.
## Upgrade guidance
1. Update the module dependency:
```sh
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
```
Consumers using a separately installed executable can update it with:
```sh
go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.2.0
```
2. Use v0.2.0 tooling before adding new profile fields. Select the shared checks
appropriate to the product and remove the corresponding duplicate commands
from additional checks. Retain assertions about product behavior. Package
compilation belongs under validation; executable deliverables use `build`.
3. For module-based CI, adopt the updated
[tag-delivery template](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/examples/woodpecker/tag-delivery.yml)
and remove the independent `RELEASEKIT_VERSION` workflow pin. Ensure the
application module declares ReleaseKit, align local tool bootstrap with that
selection, and retain the product's Go image, delivery profile, and secret
mapping. The
[Woodpecker guidance](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/consumers/delivery.md#woodpecker-tag-workflows)
describes temporary module metadata, older-tag retries, self-bootstrap, and
separately installed tools.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.2.0/docs/oss/attribution.md)

View File

@@ -1,261 +0,0 @@
# ReleaseKit: shared release process
Status: Initial implementation, policy adoption, and architecture remediation
are complete. Consumer adoption and live CI integration work remain.
This roadmap records the shared release-process opportunity identified in
PromptKit, ConfigKit, Audita, Notarius, Narratio, and Scriptorium on 2026-09-29,
and the remaining adoption goals. The [internal package inventory](../internal/overview.md)
records implemented capability owners and behavioral evidence. The
[architecture policy](../policy/architecture.md) and
[ADR 0001](../adr/0001-public-facade.md) own the facade boundaries and rationale.
The completed implementation, policy adoption, and remediation plans are
preserved in Git history.
## Opportunity
All six repositories follow the same base release policy. Their release scripts
repeat stable-version parsing, candidate and release-note checks, local and
remote tag checks, validation of one captured commit, explicit Git publication,
and recovery guidance for uncertain pushes. They differ in publication approach,
tag type, validation commands, and artifact delivery. The mechanics of embedding
a release version and cross-building an application are further candidates for
standardization.
If the projects deliberately standardize publication approach and tag type,
ReleaseKit can own most of the repeated release-process code. The current six
repositories contain 942 lines of release shell scripts and 742 lines of
dedicated release tests, or 1,684 lines total. A directional estimate is that
roughly 1,000–1,250 lines could leave those repositories: about 450–500 lines
of publication scripts, 150–250 lines of checks and builders, and 400–500 lines
of repeated publication tests. ReleaseKit would gain its own implementation and
tests, so this is a reduction in local code, not a net line-count saving.
## Selected publication standard
1. Prepare and commit a version-matched note at `docs/releases/<version>.md`.
2. Publish the candidate commit to authoritative `main` before invoking the
release command.
3. Run one command that checks the candidate and unused stable version, runs
that repository's offline candidate validation once, and rechecks mutable
state.
4. Create an annotated tag at the validated commit with a message derived from
the checked-in note. Push only that exact tag object to the authoritative
endpoint. A successful tag push completes the source release.
5. Treat hosted release pages and binary assets as asynchronous delivery with
their own status. Do not poll CI or require artifact availability to declare
the source release complete.
This would replace Audita's atomic branch-and-tag push, ConfigKit's and
Notarius's branch push during the release command, and Audita's, Notarius's,
and Narratio's lightweight tags. Those changes require updates to each
repository's adopted release policy and procedure; they are not merely code
cleanup.
## Architecture
[ADR 0001](../adr/0001-public-facade.md) owns the accepted facade decision.
The [architecture policy](../policy/architecture.md) owns the adopted
responsibility and dependency rules, including the runtime version boundary.
The root `releasekit` package provides the public facade for candidate
validation, source publication, selected-target builds, and tagged-source
delivery. The Gitea page and asset-delivery client is integrated through that
facade and the CLI. The separate public `buildinfo` package provides lightweight
runtime version access.
Each facade owns its public named types and documented contracts. Public
operations, inputs, outcomes, errors, and callbacks express deliberately
selected consumer needs. Handles retain private implementation state; explicit
forwarding and conversions connect the public contracts to internal
representations.
Git operations, Go toolchain execution, Gitea integration, and orchestration
bookkeeping belong under Go's `internal/` boundary. Dependency direction runs
from the facades into internal implementation, with internal collaborators using
contracts defined inside that boundary. Implementation packages can evolve
while the supported public contracts retain their compatibility obligations.
The `buildinfo` facade keeps runtime version access lightweight. Applications
read their embedded version through its supported accessor. ReleaseKit's builder
and runtime implementation own the linker symbol and injection mechanism
together.
The `buildinfo` facade follows three constraints:
- Its public API exposes only runtime information justified by consumer needs.
- Its implementation uses the standard library and a small internal dependency
graph independent of release orchestration.
- Dependency checks protect that boundary, and build-and-run integration tests
verify that the builder injects the selected version and the public accessor
reports it correctly.
The release executable calls the orchestration facade and handles command
parsing, output, and exit codes. Library errors and outcomes report meaningful
publication state, including partial progress and uncertainty. Go declarations
and GoDoc own exact public API contracts; additional public packages and
extension interfaces require a deliberate decision grounded in durable consumer
needs.
Compiled-export and dependency checks protect facade ownership.
External-package behavioral tests protect error identity, value ownership,
callback lifetime, and observable outcomes across forwarding and conversions.
## Shared tool boundary
Provide a versioned ReleaseKit executable backed by a Go library. Existing
shell entry points can become short invocations of that executable. A library
alone would still require a caller in every shell-driven repository.
ReleaseKit should own:
- stable-version parsing and committed-note identity checks;
- authoritative Git endpoint resolution, candidate capture, clean `main` and
remote-branch agreement checks, and unused local/remote tag checks;
- one validation invocation bound to the captured candidate, followed by cheap
rechecks before publication;
- annotated-tag creation and verification, explicit tag-object push, and one
bounded authoritative-ref check after a failed or ambiguous push;
- a standard version accessor in the public `buildinfo` facade, backed by
release-build injection of the selected tag;
- a cross-build API that accepts the application's main package and selected
targets, initially Linux amd64, Linux arm64, and macOS arm64 (Go's
`darwin/arm64`), including an empty set for source-only projects;
- consistent release build flags and asset naming, plus a host-runnable build
that checks the executable's reported version; and
- Git publication tests using temporary local remotes, including remote
advancement, rejected pushes, lost acknowledgments, and unavailable remotes.
Each repository should retain its product name, compatibility decisions,
candidate-specific checks, main package, selected build targets, delivery
declaration, and a small test of its ReleaseKit wiring. For example, PromptKit
exercises its consumer examples. Applications would adapt their existing
version output to use the `buildinfo` accessor; one agreed version command
or flag would let ReleaseKit smoke-test a host-runnable build the same way.
Cross-built binaries would use the same injection mechanism, but compilation
alone would not establish that they run correctly on another platform.
ReleaseKit should run the selected checks and builds without silently adding
binary gates to source-only projects.
The build API should own the mechanics rather than require each application to
provide a custom linker symbol or shell loop. The initial target list reflects
the current projects, not a permanent limit on future platforms. It should
support both candidate cross-build checks and artifact builds from the tagged
source, using the same version and target selection.
## Woodpecker and Gitea delivery
A common tag-triggered Woodpecker workflow can run ReleaseKit against the
tagged source. Repository-local YAML should declare the pinned tool image and
credentials; each repository's ReleaseKit configuration should declare its
delivery destination and any binary main package and target set. ReleaseKit can
own version injection, cross-builds, checksum generation, and delivery. All
projects should receive hosted Gitea
release pages. Source-only repositories declare zero binary assets and deliver
the committed release note without a binary build or checksum manifest.
The shared delivery command should create or find the release for the tag,
use the checked-in note, and add declared binary assets and their checksum
manifest where applicable. A retry should inspect existing assets, verify
matching content, add only missing items, and stop on a conflict.
An existing checksum manifest must remain consistent with any later addition.
The release and its attachments are separate remote operations, so partial
delivery must be expected and reported clearly.
Audita, Narratio, and Scriptorium currently use the Woodpecker release plugin
with `file-exists: skip` and `overwrite: false`. Those settings avoid deliberate
replacement by filename, but they do not prove that a rebuilt file has the
same bytes as a previously published file. Content comparison would be a
reason to implement a shared Gitea delivery client rather than rely solely on
the plugin's skip behavior.
Hosted pages are selected for source-only projects, including PromptKit,
ConfigKit, Notarius, and ReleaseKit. Adoption adds a page deliverable and delivery
credentials to their procedures while preserving successful tag publication as
source completion. The initial implementation should support both page-only and
binary delivery through the same facade and command.
## Failure states
ReleaseKit should report source publication and artifact delivery separately:
| Event | Result |
| --- | --- |
| Candidate validation or a pre-push guard fails | No release was published; correct and validate the candidate again. |
| Tag push reports success | Source release is complete; delivery may still be pending. |
| Tag push fails or its acknowledgment is lost | Query the authoritative ref once. Confirm success only if the exact intended tag object is present on the intended commit; otherwise report failed or unknown status and retain the local tag. |
| Woodpecker or Gitea delivery fails after tag publication | Keep the source release successful. Retry missing delivery from the immutable tag after checking existing content. |
| Published source or content needs correction | Publish a new version; never replace content under the published version. |
## Consumer adoption target
All six projects should use the same selected publication, runtime version,
build, and delivery conventions where those responsibilities apply. Repository
entry points should contain only tool invocation and product-specific settings
or checks. Shared mechanics and their repeated tests belong in ReleaseKit.
Application runtime code should obtain version information through `buildinfo`.
Source-only libraries need no runtime import or automatic binary deliverable.
Projects declaring binaries should use the common target selection and version
injection; hosted delivery should use content-aware retries for both page-only
and binary releases.
Existing published tags, notes, pages, and assets retain their historical contents.
Consumer policy and procedure changes should describe the deliberately adopted
standard and its compatibility impact. Adoption should demonstrate reduced local
infrastructure and one coherent release model.
## Representative migration review
Read-only reviews of the available PromptKit and Narratio checkouts were
verified on 2026-10-06 at `4b8169d` and `8afb49c`; both worktrees were clean.
These are migration assessments, not changes to either consumer.
| Consumer | Retain in its profile and product checks | Adaptation and procedure changes |
| --- | --- | --- |
| PromptKit, source-only | Product identity; its `Summary`, `Compatibility`, `Upgrade`, and `Changes` release-note sections; checks that exercise the three maintained Go examples and their product outputs. No runtime `buildinfo` import or binary targets are needed. | Its current release scripts total 209 lines. The projected deletion is about 120–160 lines of shared publication and generic validation machinery; retain the note-content rule and product-example checks as a configured additional check plus a small wiring test. PromptKit already pushes `main` before publication and creates annotated tags. Its release policy and procedure still need to add the hosted note page, delivery credentials, versioned CI invocation, and separate delivery status and recovery. |
| Narratio, binary | Product/module identity, the `./cmd/narratio` main package, `narratio` executable name, Linux amd64, Linux arm64, and macOS arm64 targets, and the `narratio <version>` command. Keep the module-graph check (`go mod tidy -diff`) if it remains useful as a product-specific candidate check. | Its release, check, asset-builder, and helper scripts total 304 lines; two dedicated release test files total 741 lines. Projected deletions are about 220–280 shell lines and 450–650 repeated test lines, leaving a small consumer wiring test and any retained module/note checks. Replace the private linker-version variable with `buildinfo.Version()` while preserving the current command output. Its policy and procedure need to change lightweight tags to annotated tags, state that a successful tag push completes source publication, and move plugin-based page/assets delivery to the shared versioned CI command with content-aware retries and separately provisioned delivery credentials. |
The deletion figures are estimates from the reviewed files, not measured
migration results or a promise that each line will disappear. Product checks,
version output, target choices, and at least one ReleaseKit integration check
remain with each consumer. The existing cross-project estimate of 1,000–1,250
potentially removable lines remains directional until migrations are completed.
The self-contained ReleaseKit fixtures exercise a meaningful source-only
compatibility check through local Git publication, and a `buildinfo` binary
consumer with selected targets through local HTTP delivery. These are separate
publication and delivery operations.
## Remaining work
- Use the [v0.1.0 baseline](../releases/v0.1.0.md) for initial consumer integration
under the [maintainer procedure](../release.md).
- Choose the broader consumer integration test plan and migration sequence.
Evaluate API clarity, product-specific checks, and the infrastructure removed
from each consuming repository.
- Provision delivery credentials and activate consumer Woodpecker workflows
using the maintained example's `v0.1.0` pin or a later selected immutable
version as part of deployment.
- Verify live Gitea delivery and Woodpecker execution. Existing verification uses
local Git and HTTP fixtures; no live deployment has been verified.
- Keep ReleaseKit executable binary distribution deferred until deliverables
are deliberately selected. Selecting binaries would require delivery
declarations, target checks, automation responsibilities, and corresponding
release policy and procedure updates.
- Evaluate a maintained ReleaseKit CI image or specialized validation/build
settings when a demonstrated consumer need justifies their maintenance cost
under [ADR 0002](../adr/0002-routine-releases.md).
## Reviewed implementations
- [PromptKit release script](../../../promptkit/scripts/release.sh),
[checker](../../../promptkit/scripts/release-check.sh)
- [ConfigKit release script](../../../ConfigKit/scripts/release.sh),
[checker](../../../ConfigKit/scripts/release-check.sh)
- [Audita release script](../../../audita/scripts/release.sh),
[Woodpecker job](../../../audita/.woodpecker/release.yml)
- [Notarius release script](../../../notarius/scripts/release.sh),
[checker](../../../notarius/scripts/check-release-source.sh)
- [Narratio release script](../../../narratio/scripts/release.sh),
[Woodpecker job](../../../narratio/.woodpecker/release.yml)
- [Scriptorium release script](../../../scriptorium/scripts/release.sh),
[Woodpecker job](../../../scriptorium/.woodpecker/release.yml)

View File

@@ -3,27 +3,31 @@ when:
ref: refs/tags/v*
steps:
- name: build-tool
image: golang:1.25.0-bookworm
commands:
- |
test -n "$${CI_COMMIT_TAG}"
test -n "$${CI_COMMIT_SHA}"
git fetch origin "refs/tags/$${CI_COMMIT_TAG}:refs/tags/$${CI_COMMIT_TAG}"
test "$(git rev-parse "refs/tags/$${CI_COMMIT_TAG}^{commit}")" = "$${CI_COMMIT_SHA}"
git checkout --detach "$${CI_COMMIT_SHA}"
test "$(git rev-parse HEAD)" = "$${CI_COMMIT_SHA}"
mkdir -p "$${CI_WORKSPACE}/.releasekit/bin"
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o "$${CI_WORKSPACE}/.releasekit/bin/releasekit" ./cmd/releasekit
- name: deliver-page
- name: deliver
image: golang:1.25.0-bookworm
environment:
RELEASEKIT_GITEA_TOKEN:
from_secret: releasekit_gitea_token
commands:
- |
"$${CI_WORKSPACE}/.releasekit/bin/releasekit" ci-deliver \
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 "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-source.XXXXXX")
trap 'rm -rf "$${bootstrap_dir}"' EXIT
export GOWORK=off
export GOTOOLCHAIN=local
go build -mod=readonly -o "$${bootstrap_dir}/releasekit" ./cmd/releasekit
"$${bootstrap_dir}/releasekit" ci-deliver \
--repo "$${CI_WORKSPACE}" \
--commit "$${CI_COMMIT_SHA}" \
"$${CI_COMMIT_TAG}"

View File

@@ -1,7 +1,6 @@
matrix:
include:
- GO_IMAGE: golang:1.25.0-bookworm
RELEASEKIT_VERSION: v0.1.0
when:
- event: tag
@@ -15,7 +14,36 @@ steps:
from_secret: releasekit_gitea_token
commands:
- |
go run gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@${RELEASEKIT_VERSION} ci-deliver \
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 "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
cp go.mod "$${bootstrap_dir}/go.mod"
if [ -f go.sum ]; then
cp 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
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}" \
--commit "$${CI_COMMIT_SHA}" \
"$${CI_COMMIT_TAG}"

5
go.mod
View File

@@ -1,3 +1,8 @@
module gitea.maximumdirect.net/eric/ReleaseKit
go 1.25.0
require (
github.com/yuin/goldmark/v2 v2.1.6
golang.org/x/mod v0.40.0
)

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=

View File

@@ -1,4 +1,6 @@
# ReleaseKit ideas
The [product roadmap](docs/roadmap/releasekit.md) records shared release-process
adoption goals and remaining release and deployment work.
The [architecture policy](docs/policy/architecture.md) and
[ADRs](docs/adr/) record the adopted design decisions. The
[v0.2.0 release note](docs/releases/v0.2.0.md) summarizes the implemented
capabilities.

View File

@@ -70,10 +70,91 @@ func checkRepository(ctx context.Context, dir string) error {
if violations := checkExports(pkg); len(violations) != 0 {
return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n "))
}
if path == modulePath {
if violations := checkValidationTypes(pkg); len(violations) != 0 {
return fmt.Errorf("facade %s has invalid validation diagnostics:\n %s", path, strings.Join(violations, "\n "))
}
}
}
return nil
}
func checkValidationTypes(pkg *types.Package) []string {
var violations []string
for _, name := range []string{"ValidationCheck", "ValidationFinding", "FileSizeMeasurement", "ValidationError"} {
object, ok := pkg.Scope().Lookup(name).(*types.TypeName)
if !ok {
violations = append(violations, fmt.Sprintf("missing facade-owned type %s", name))
continue
}
named, ok := types.Unalias(object.Type()).(*types.Named)
if !ok || named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != pkg.Path() {
violations = append(violations, fmt.Sprintf("%s is not a facade-owned named type", name))
}
}
validationError, ok := pkg.Scope().Lookup("ValidationError").(*types.TypeName)
if !ok {
return violations
}
named, ok := types.Unalias(validationError.Type()).(*types.Named)
if !ok {
return violations
}
structure, ok := named.Underlying().(*types.Struct)
if !ok {
return append(violations, "ValidationError is not a struct")
}
fields := map[string]string{
"Check": "ValidationCheck",
"Findings": "[]ValidationFinding",
"Measurement": "*FileSizeMeasurement",
}
for fieldName, expected := range fields {
fieldIndex := -1
for index := 0; index < structure.NumFields(); index++ {
if structure.Field(index).Name() == fieldName && structure.Field(index).Exported() {
fieldIndex = index
break
}
}
if fieldIndex < 0 || !validationFieldMatches(structure.Field(fieldIndex).Type(), expected, pkg.Path()) {
violations = append(violations, fmt.Sprintf("ValidationError.%s must expose %s", fieldName, expected))
}
}
if cause := fieldByName(structure, "Cause"); cause == nil || !cause.Exported() || !types.Identical(cause.Type(), types.Universe.Lookup("error").Type()) {
violations = append(violations, "ValidationError.Cause must expose error")
}
return violations
}
func fieldByName(structure *types.Struct, name string) *types.Var {
for index := 0; index < structure.NumFields(); index++ {
if structure.Field(index).Name() == name {
return structure.Field(index)
}
}
return nil
}
func validationFieldMatches(fieldType types.Type, expected, packagePath string) bool {
switch expected {
case "[]ValidationFinding":
slice, ok := types.Unalias(fieldType).(*types.Slice)
if !ok {
return false
}
fieldType = slice.Elem()
case "*FileSizeMeasurement":
pointer, ok := types.Unalias(fieldType).(*types.Pointer)
if !ok {
return false
}
fieldType = pointer.Elem()
}
named, ok := types.Unalias(fieldType).(*types.Named)
return ok && named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == packagePath && named.Obj().Name() == strings.TrimLeft(expected, "*[]")
}
func listPackages(ctx context.Context, dir string) ([]listedPackage, error) {
cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...")
cmd.Dir = dir

View File

@@ -101,6 +101,35 @@ func Open(context.Context) *Handle { return nil }
}
}
func TestValidationDiagnosticTypesBelongToFacade(t *testing.T) {
valid := `package facade
type ValidationCheck string
type ValidationFinding struct{}
type FileSizeMeasurement struct{}
type ValidationError struct {
Check ValidationCheck
Findings []ValidationFinding
Measurement *FileSizeMeasurement
Cause error
}
`
pkg := typecheck(t, token.NewFileSet(), modulePath, valid)
if violations := checkValidationTypes(pkg); len(violations) != 0 {
t.Fatalf("checkValidationTypes() = %v, want facade-owned diagnostics", violations)
}
implementation := typecheck(t, token.NewFileSet(), "example.com/implementation", `package implementation
type State string
`)
fset := token.NewFileSet()
fixtureImporter := singlePackageImporter{path: "example.com/implementation", pkg: implementation}
invalid := strings.Replace(valid, "type ValidationCheck string", "import \"example.com/implementation\"\ntype ValidationCheck = implementation.State", 1)
pkg = typecheckWithImporter(t, fset, modulePath, invalid, fixtureImporter)
if violations := checkValidationTypes(pkg); !strings.Contains(strings.Join(violations, "\n"), "ValidationCheck is not a facade-owned named type") {
t.Fatalf("checkValidationTypes() = %v, want ownership violation", violations)
}
}
func TestActualRepositoryBoundaries(t *testing.T) {
if err := checkRepository(t.Context(), "../.."); err != nil {
t.Fatal(err)

View File

@@ -11,8 +11,8 @@ import (
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
@@ -29,6 +29,7 @@ type Request struct {
RepositoryDir string
Product string
Version string
Validation validation.Settings
AdditionalChecks func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error
Output io.Writer
@@ -95,10 +96,15 @@ func Capture(ctx context.Context, request Request) (Candidate, error) {
return Candidate{RepositoryDir: repositoryDir, Commit: state.commit, Version: request.Version, Note: string(note)}, nil
}
// Validate runs the standard offline Go suite, followed by AdditionalChecks
// 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 {
if err := runDefaultValidation(ctx, captured.RepositoryDir, request.Output); err != nil {
if err := validation.Run(ctx, validation.Request{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Output: request.Output,
Settings: request.Validation,
}); err != nil {
return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
}
if request.AdditionalChecks != nil {
@@ -214,16 +220,6 @@ func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, erro
return false, err
}
func runDefaultValidation(ctx context.Context, repositoryDir string, output io.Writer) error {
tool, err := gotool.New(ctx, repositoryDir)
if err != nil {
return fmt.Errorf("prepare Go toolchain: %w", err)
}
_, err = tool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, output,
"test", "-mod=readonly", "-count=1", "./...")
return err
}
func validStableVersion(version string) bool {
return versioncontract.ValidStableVersion(version)
}

View File

@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
const testVersion = "v1.2.3"
@@ -43,12 +45,16 @@ func TestValidStableVersion(t *testing.T) {
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n")
gitRun(t, repository, "add", "guide.md")
gitRun(t, repository, "commit", "-m", "add Markdown guide")
calls := 0
var seen Candidate
candidate, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}},
AdditionalChecks: func(_ context.Context, captured Candidate) error {
calls++
seen = captured
@@ -76,6 +82,114 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
}
}
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")
gitRun(t, repository, "add", "unformatted.go")
gitRun(t, repository, "commit", "-m", "add unformatted Go source")
productCalls := 0
buildCalls := 0
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{GoFormat: true},
AdditionalChecks: func(context.Context, Candidate) error {
productCalls++
return nil
},
AfterValidate: func(context.Context, Candidate) error {
buildCalls++
return nil
},
})
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.GoFormat {
t.Fatalf("Check error = %v, want go-format failure", err)
}
if productCalls != 0 || buildCalls != 0 {
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls)
}
}
func TestCheckStopsBeforeProductAndBuildCallbacksWhenMarkdownBoundaryFails(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "guide.md", "[outside](../../outside.md)\n")
gitRun(t, repository, "add", "guide.md")
gitRun(t, repository, "commit", "-m", "add outside Markdown destination")
productCalls := 0
buildCalls := 0
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{
Markdown: &validation.MarkdownSettings{EnforceCheckoutBoundary: true},
},
AdditionalChecks: func(context.Context, Candidate) error {
productCalls++
return nil
},
AfterValidate: func(context.Context, Candidate) error {
buildCalls++
return nil
},
})
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.MarkdownLinks {
t.Fatalf("Check() error = %v, want markdown-links failure", err)
}
if productCalls != 0 || buildCalls != 0 {
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after Markdown failure", productCalls, buildCalls)
}
}
func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) {
t.Setenv("GOWORK", "off")
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "go.work", "go 1.25.0\n")
writeTestFile(t, repository, "nested/go.work.sum", "workspace-sum\n")
if err := os.MkdirAll(filepath.Join(repository, "linked"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../go.work", filepath.Join(repository, "linked", "go.work")); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "add", "go.work", "nested/go.work.sum", "linked/go.work")
gitRun(t, repository, "commit", "-m", "add workspace files")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{ModuleDistribution: validation.ModuleDistributionSettings{
WorkspaceFiles: true,
}},
})
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.WorkspaceFiles {
t.Fatalf("Check error = %v, want workspace-files failure", err)
}
want := map[string]bool{
"go.work": false,
"linked/go.work": false,
"nested/go.work.sum": false,
}
if len(failure.Findings) != len(want) {
t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want))
}
for _, finding := range failure.Findings {
if _, ok := want[finding.Path]; !ok {
t.Errorf("unexpected workspace path %q", finding.Path)
}
want[finding.Path] = true
}
for path, found := range want {
if !found {
t.Errorf("workspace path %q was not reported", path)
}
}
}
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer
@@ -123,6 +237,14 @@ func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") {
t.Fatalf("Check() error = %v, output = %q; wanted the failing standard test to run", err, output.String())
}
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.GoTest {
t.Fatalf("Check() error = %v; want a go-test validation failure", err)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Check() error = %v; want the Go process exit error preserved", err)
}
})
}
}

View File

@@ -1,12 +1,27 @@
package git
import (
"bytes"
"context"
"fmt"
"io"
"path"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
// TreeEntry records one committed path and its Git object metadata.
type TreeEntry struct {
Path string
ObjectID string
Mode string
Type string
Size int64
HasSize bool
}
type TaggedSource struct {
Head string
Status string
@@ -74,3 +89,126 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
}
return []byte(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.
func ReadTree(ctx context.Context, repositoryDir, commit string) ([]TreeEntry, error) {
if !validObjectID(commit) {
return nil, fmt.Errorf("invalid commit object ID %q", commit)
}
output, err := readLocalObjects(ctx, repositoryDir, nil, "ls-tree", "-r", "-z", "--full-tree", commit)
if err != nil {
return nil, fmt.Errorf("enumerate committed tree %s: %w", commit, err)
}
entries, err := parseTreeEntries(output)
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
}
// ReadBlob reads the bytes for a Git blob object ID without consulting the
// worktree.
func ReadBlob(ctx context.Context, repositoryDir, objectID string) ([]byte, error) {
if !validObjectID(objectID) {
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
}
content, err := readLocalObjects(ctx, repositoryDir, nil, "cat-file", "blob", objectID)
if err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
return content, nil
}
func parseTreeEntries(output []byte) ([]TreeEntry, error) {
if len(output) == 0 {
return nil, nil
}
var entries []TreeEntry
for _, record := range bytes.Split(output, []byte{0}) {
if len(record) == 0 {
continue
}
tab := bytes.IndexByte(record, '\t')
if tab < 0 {
return nil, fmt.Errorf("tree record has no path separator")
}
fields := strings.Fields(string(record[:tab]))
if len(fields) != 3 || !validObjectID(fields[2]) {
return nil, fmt.Errorf("invalid tree record metadata %q", record[:tab])
}
entries = append(entries, TreeEntry{
Mode: fields[0],
Type: fields[1],
ObjectID: fields[2],
Path: string(record[tab+1:]),
})
}
return entries, nil
}
func populateBlobSizes(ctx context.Context, repositoryDir string, entries []TreeEntry) error {
var blobIndexes []int
var input strings.Builder
for index := range entries {
if entries[index].Type != "blob" {
continue
}
blobIndexes = append(blobIndexes, index)
input.WriteString(entries[index].ObjectID)
input.WriteByte('\n')
}
if len(blobIndexes) == 0 {
return nil
}
output, err := readLocalObjects(ctx, repositoryDir, strings.NewReader(input.String()),
"cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)")
if err != nil {
return err
}
lines := strings.Split(strings.TrimSuffix(string(output), "\n"), "\n")
if len(lines) != len(blobIndexes) {
return fmt.Errorf("git returned %d blob sizes for %d blobs", len(lines), len(blobIndexes))
}
for index, line := range lines {
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)
}
if len(fields) != 3 || fields[0] != entry.ObjectID || fields[1] != "blob" {
return fmt.Errorf("unexpected blob size record %q", line)
}
size, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil || size < 0 {
return fmt.Errorf("invalid blob size in record %q", line)
}
entry.Size = size
entry.HasSize = true
}
return nil
}
// readLocalObjects keeps source inspection separate from network preparation.
func readLocalObjects(ctx context.Context, repositoryDir string, input io.Reader, args ...string) ([]byte, error) {
var stdout, stderr bytes.Buffer
err := process.Run(ctx, process.Invocation{
Args: append([]string{"git"}, args...),
Dir: repositoryDir,
// Disable transports as well for Git versions predating lazy-fetch control.
Env: map[string]string{"GIT_NO_LAZY_FETCH": "1", "GIT_ALLOW_PROTOCOL": ""},
Stdin: input,
Stdout: &stdout,
Stderr: &stderr,
})
if err != nil {
if message := strings.TrimSpace(stderr.String()); message != "" {
return nil, fmt.Errorf("read local Git objects: %s: %w", message, err)
}
return nil, err
}
return stdout.Bytes(), nil
}

View File

@@ -2,6 +2,10 @@ package git
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
@@ -28,6 +32,47 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
}
}
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true")
blob := strings.TrimSpace(gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"))
for _, filter := range []string{"blob:none", "tree:0"} {
t.Run(filter, func(t *testing.T) {
repository := filepath.Join(t.TempDir(), "partial")
gitRun(t, fixture.work, "clone", "--filter="+filter, "--no-checkout", "file://"+filepath.ToSlash(fixture.work), repository)
before := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
if strings.Contains(before, blob) {
t.Fatal("fixture contains the filtered blob")
}
gitRun(t, repository, "config", "remote.origin.url", "ssh://releasekit-test.invalid/source")
attempts := filepath.Join(t.TempDir(), "attempts")
transport := filepath.Join(t.TempDir(), "ssh")
if err := os.WriteFile(transport, []byte("#!/bin/sh\nprintf 'remote accessed\\n' >> \"$RELEASEKIT_GIT_ATTEMPTS\"\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("GIT_SSH_COMMAND", transport)
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")
}
_, err := ReadBlob(t.Context(), repository, blob)
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("blob error %v lost its Git process cause", err)
}
if data, err := os.ReadFile(attempts); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("object inspection invoked remote transport: %q, %v", data, err)
}
after := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
if before != after {
t.Fatal("object inspection populated the object database")
}
})
}
}
func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "tag", "v1.2.3")

View File

@@ -19,6 +19,7 @@ type Invocation struct {
Dir string
Env map[string]string
UnsetEnv []string
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
}
@@ -32,6 +33,7 @@ func Run(ctx context.Context, invocation Invocation) error {
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
command.Stdin = invocation.Stdin
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}

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/validation"
)
type State string
@@ -35,11 +36,17 @@ const (
const recoveryTimeout = 30 * time.Second
// ErrUnexpectedPushURL reports that the resolved push URL differs from the
// caller's expected publication destination.
var ErrUnexpectedPushURL = errors.New("unexpected publication push URL")
type Request struct {
RepositoryDir 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
Output io.Writer
@@ -54,8 +61,10 @@ type Result struct {
State State
}
// Publish validates one captured candidate and pushes only its annotated tag
// object. It runs the standard Go suite, then AdditionalChecks when supplied.
// Publish captures a candidate, resolves and checks the optional expected push
// URL, then validates the candidate and pushes only its annotated tag object.
// It runs one standard Go suite, with the selected settings, then
// AdditionalChecks when supplied.
// A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error.
@@ -69,6 +78,7 @@ func Publish(ctx context.Context, request Request) (Result, error) {
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
Validation: request.Validation,
AdditionalChecks: request.AdditionalChecks,
Output: request.Output,
}
@@ -83,6 +93,9 @@ func Publish(ctx context.Context, request Request) (Result, error) {
return result, fmt.Errorf("resolve publication endpoint: %w", err)
}
result.Endpoint = endpoint.PushURL
if request.ExpectedPushURL != "" && request.ExpectedPushURL != endpoint.PushURL {
return result, fmt.Errorf("%w: expected %q, resolved %q", ErrUnexpectedPushURL, request.ExpectedPushURL, endpoint.PushURL)
}
if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
}

View File

@@ -0,0 +1,204 @@
package validation
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRunCompilesTargetsAfterTestAndVetInSelectionOrder(t *testing.T) {
logPath := installGoShim(t, "", false)
targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}}
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true, CompileTargets: targets},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var commands []string
for _, command := range readGoShimLog(t, logPath) {
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
commands = append(commands, command)
}
}
if len(commands) != len(targets)+2 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") {
t.Fatalf("validation commands = %q, want test, vet, then one build per target", commands)
}
for index, target := range targets {
command := commands[index+2]
if !strings.HasPrefix(command, "build ") {
t.Fatalf("command %d = %q, want package build", index+2, command)
}
for _, argument := range []string{"build", "-mod=readonly", "-o " + os.DevNull, "./..."} {
if !strings.Contains(" "+command+" ", " "+argument+" ") {
t.Errorf("build command %q does not contain %q", command, argument)
}
}
if got, want := strings.Fields(command)[0], "build"; got != want {
t.Errorf("command = %q, want %q", got, want)
}
if target.OS == "" || target.Arch == "" {
t.Fatalf("test target is incomplete: %#v", target)
}
}
environments, err := os.ReadFile(os.Getenv("RELEASEKIT_GO_ENV_LOG"))
if err != nil {
t.Fatal(err)
}
got := strings.Split(strings.TrimSpace(string(environments)), "\n")
if len(got) != len(targets) {
t.Fatalf("static build environments = %q, want %d entries", got, len(targets))
}
for index, target := range targets {
if want := fmt.Sprintf("%s/%s|0", target.OS, target.Arch); got[index] != want {
t.Errorf("static build environment %d = %q, want %q", index, got[index], want)
}
}
}
func TestRunAttributesTargetCompileFailureAndStops(t *testing.T) {
logPath := installGoShim(t, "darwin/arm64", false)
targets := []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}, {OS: "windows", Arch: "amd64"}}
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true, CompileTargets: targets},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != PackageCompile {
t.Fatalf("Run() error = %v, want a package-compile failure", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Target != "darwin/arm64" {
t.Fatalf("package compilation findings = %#v, want target darwin/arm64", failure.Findings)
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("Run() error = %v, want the Go command failure preserved", err)
}
var commands []string
for _, command := range readGoShimLog(t, logPath) {
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
commands = append(commands, command)
}
}
if len(commands) != 4 || !strings.HasPrefix(commands[0], "test ") || !strings.HasPrefix(commands[1], "vet ") || !strings.HasPrefix(commands[2], "build ") || !strings.HasPrefix(commands[3], "build ") {
t.Fatalf("validation commands after target failure = %q, want test, vet, two builds", commands)
}
}
func TestRunRejectsIncompleteCompileTargets(t *testing.T) {
installGoShim(t, "", false)
for _, target := range []Target{{OS: "linux"}, {Arch: "amd64"}, {OS: " ", Arch: "amd64"}, {OS: "linux/other", Arch: "amd64"}} {
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{CompileTargets: []Target{target}},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != PackageCompile {
t.Errorf("Run(%#v) error = %v, want a package-compile validation failure", target, err)
continue
}
if len(failure.Findings) != 1 || failure.Findings[0].Target != target.OS+"/"+target.Arch {
t.Errorf("Run(%#v) findings = %#v, want the incomplete target", target, failure.Findings)
}
}
}
func TestRunCompilesSourceOnlyAndExecutablePackagesWithoutArtifacts(t *testing.T) {
fixtures := []struct {
name string
files map[string]string
}{
{
name: "source library",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
},
},
{
name: "single main",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
"cmd/app/main.go": "package main\n\nfunc main() {}\n",
},
},
{
name: "multiple mains with repeated basenames",
files: map[string]string{
"library.go": "package fixture\n\nconst Value = 1\n",
"cmd/one/tool/main.go": "package main\n\nfunc main() {}\n",
"cmd/two/tool/main.go": "package main\n\nfunc main() {}\n",
},
},
}
for _, fixture := range fixtures {
t.Run(fixture.name, func(t *testing.T) {
module := writeCompileFixture(t, fixture.files)
before := readCompileFixture(t, module, fixture.files)
var targets []Target
if fixture.name == "source library" {
targets = []Target{{OS: "linux", Arch: "amd64"}, {OS: "linux", Arch: "arm64"}, {OS: "darwin", Arch: "arm64"}}
} else {
targets = []Target{{OS: "linux", Arch: "amd64"}}
}
if err := Run(context.Background(), Request{
RepositoryDir: module,
Settings: Settings{CompileTargets: targets},
}); err != nil {
t.Fatalf("Run() error = %v", err)
}
after := readCompileFixture(t, module, fixture.files)
for path, contents := range before {
if string(after[path]) != string(contents) {
t.Errorf("candidate file %q changed during compilation", path)
}
}
})
}
}
func writeCompileFixture(t *testing.T, files map[string]string) string {
t.Helper()
module := t.TempDir()
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/compilefixture\n\ngo 1.25.0\n"), 0o600); err != nil {
t.Fatal(err)
}
for path, contents := range files {
filename := filepath.Join(module, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
return module
}
func readCompileFixture(t *testing.T, module string, files map[string]string) map[string][]byte {
t.Helper()
contents := make(map[string][]byte, len(files)+1)
for _, path := range append([]string{"go.mod"}, fixturePaths(files)...) {
data, err := os.ReadFile(filepath.Join(module, filepath.FromSlash(path)))
if err != nil {
t.Fatal(err)
}
contents[path] = data
}
return contents
}
func fixturePaths(files map[string]string) []string {
paths := make([]string, 0, len(files))
for path := range files {
paths = append(paths, path)
}
return paths
}

View File

@@ -0,0 +1,296 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "README.md", []byte("root\n"))
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
writeTreeFile(t, repository, "docs/target.md", []byte("target\n"))
writeTreeFile(t, repository, "docs/nested/item.txt", []byte("nested\n"))
writeTreeFile(t, repository, "docs/dir/item.txt", []byte("directory\n"))
for name, target := range map[string]string{
"docs/inside": "target.md",
"docs/through-dir": "nested",
"docs/outside": "../../neighbor/file",
"docs/absolute": "/outside/file",
"docs/dangling": "missing-target",
"docs/cycle-a": "cycle-b",
"docs/cycle-b": "cycle-a",
} {
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
t.Fatal(err)
}
}
submoduleSource := newTreeRepository(t)
writeTreeFile(t, submoduleSource, "sub.txt", []byte("submodule\n"))
gitTree(t, submoduleSource, "add", "--all")
gitTree(t, submoduleSource, "commit", "-m", "create submodule source")
submoduleOID := strings.TrimSpace(gitTree(t, submoduleSource, "rev-parse", "HEAD"))
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",docs/submodule")
gitTree(t, repository, "commit", "-m", "capture destination fixtures")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(repository, "docs", "inside")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "inside")); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
source string
destination string
kind DestinationKind
path string
}{
{name: "relative file", source: "docs/page.md", destination: "target.md", kind: DestinationEntry, path: "docs/target.md"},
{name: "parent traversal inside root", source: "docs/page.md", destination: "../README.md", kind: DestinationEntry, path: "README.md"},
{name: "outside root", source: "docs/page.md", destination: "../../neighbor/file", kind: DestinationOutsideCheckout, path: "../neighbor/file"},
{name: "repository root directory", source: "docs/page.md", destination: "..", kind: DestinationDirectory, path: "."},
{name: "directory", source: "docs/page.md", destination: "dir", kind: DestinationDirectory, path: "docs/dir"},
{name: "symlink to file", source: "docs/page.md", destination: "inside", kind: DestinationEntry, path: "docs/target.md"},
{name: "symlinked directory", source: "docs/page.md", destination: "through-dir/item.txt", kind: DestinationEntry, path: "docs/nested/item.txt"},
{name: "absolute symlink", source: "docs/page.md", destination: "absolute", kind: DestinationOutsideCheckout, path: "/outside/file"},
{name: "missing entry", source: "docs/page.md", destination: "missing", kind: DestinationMissing, path: "docs/missing"},
{name: "dangling symlink", source: "docs/page.md", destination: "dangling", kind: DestinationMissing, path: "docs/missing-target"},
{name: "symlink cycle", source: "docs/page.md", destination: "cycle-a", kind: DestinationSymlinkCycle, path: "docs/cycle-a"},
{name: "submodule contents", source: "docs/page.md", destination: "submodule/child", kind: DestinationSubmodule, path: "docs/submodule/child"},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
got, err := tree.ResolveDestination(context.Background(), test.source, test.destination)
if err != nil {
t.Fatalf("ResolveDestination() error = %v", err)
}
if got.Kind != test.kind || got.Path != test.path {
t.Fatalf("ResolveDestination() = %#v, want kind %q path %q", got, test.kind, test.path)
}
})
}
}
func TestResolveDestinationHandlesExpandingCyclesAndRepeatedLinks(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "README.md", []byte("source\n"))
for name, target := range map[string]string{
"direct": "direct/child",
"mutual-a": "mutual-b/child",
"mutual-b": "mutual-a/child",
"alias": ".",
} {
if err := os.Symlink(target, filepath.Join(repository, name)); err != nil {
t.Fatal(err)
}
}
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "commit", "-m", "capture recursive links")
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
destination string
kind DestinationKind
}{
{"direct", DestinationSymlinkCycle},
{"mutual-a", DestinationSymlinkCycle},
{"alias/alias/README.md", DestinationEntry},
} {
t.Run(test.destination, func(t *testing.T) {
type result struct {
destination Destination
err error
}
done := make(chan result, 1)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
go func() {
destination, err := tree.ResolveDestination(ctx, "README.md", test.destination)
done <- result{destination, err}
}()
select {
case got := <-done:
if got.err != nil || got.destination.Kind != test.kind {
t.Fatalf("resolution = %#v, %v; want %s", got.destination, got.err, test.kind)
}
if test.kind == DestinationEntry && got.destination.Path != "README.md" {
t.Fatalf("finite repeated link resolved to %q", got.destination.Path)
}
case <-ctx.Done():
t.Fatal("symlink resolution did not finish")
}
})
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := tree.ResolveDestination(ctx, "README.md", "alias/README.md"); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled resolution error = %v", err)
}
}
func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "outside")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "commit", "-m", "capture outside link")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
gotWithoutNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
if err != nil {
t.Fatal(err)
}
neighbor := filepath.Join(filepath.Dir(repository), "neighbor", "file")
if err := os.MkdirAll(filepath.Dir(neighbor), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(neighbor, []byte("outside source\n"), 0o644); err != nil {
t.Fatal(err)
}
gotWithNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
if err != nil {
t.Fatal(err)
}
if gotWithNeighbor != gotWithoutNeighbor || gotWithNeighbor.Kind != DestinationOutsideCheckout {
t.Fatalf("neighbor changed resolution: absent=%#v present=%#v", gotWithoutNeighbor, gotWithNeighbor)
}
}
func TestResolveDestinationPreservesParentTraversalOrder(t *testing.T) {
repository := newTreeRepository(t)
for _, name := range []string{"README.md", "docs/file.md", "nested/file.md", "nested/inner/item.txt"} {
writeTreeFile(t, repository, name, []byte("captured\n"))
}
for name, target := range map[string]string{
"docs/link": "../nested/inner",
"docs/parent-link": "link/../file.md",
"escape": "../neighbor",
} {
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
t.Fatal(err)
}
}
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "commit", "-m", "capture parent traversal")
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
destination string
kind DestinationKind
resolved string
}{
{"docs/link/../file.md", DestinationEntry, "nested/file.md"},
{"docs/parent-link", DestinationEntry, "nested/file.md"},
{"escape/../README.md", DestinationOutsideCheckout, ""},
{"missing/../README.md", DestinationMissing, ""},
{"README.md/../README.md", DestinationMissing, ""},
{"README.md/.", DestinationMissing, ""},
} {
t.Run(test.destination, func(t *testing.T) {
got, err := tree.ResolveDestination(t.Context(), "README.md", test.destination)
if err != nil || got.Kind != test.kind || (test.resolved != "" && got.Path != test.resolved) {
t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved)
}
})
}
before, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
if err != nil {
t.Fatal(err)
}
neighbor := filepath.Join(filepath.Dir(repository), "neighbor")
if err := os.MkdirAll(neighbor, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(neighbor, "file.md"), []byte("neighbor"), 0o644); err != nil {
t.Fatal(err)
}
after, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
if err != nil || before != after {
t.Fatalf("neighbor changed resolution: %#v -> %#v, %v", before, after, err)
}
}
func TestResolveDestinationMapsAbsolutePathsIntoCandidate(t *testing.T) {
repository := newTreeRepository(t)
for _, name := range []string{"README.md", "docs/file.md", "nested/file.md", "nested/inner/item.txt"} {
writeTreeFile(t, repository, name, []byte("captured\n"))
}
for name, target := range map[string]string{
"absolute-file": filepath.ToSlash(filepath.Join(repository, "README.md")),
"absolute-directory": filepath.ToSlash(filepath.Join(repository, "nested", "inner")),
"absolute-outside": filepath.ToSlash(repository + "-neighbor/README.md"),
"docs/link": "../nested/inner",
"absolute-parent": filepath.ToSlash(repository) + "/docs/link/../file.md",
} {
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
t.Fatal(err)
}
}
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "commit", "-m", "capture absolute links")
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
if err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(repository, "README.md")); err != nil {
t.Fatal(err)
}
writeTreeFile(t, repository, "live-only.md", []byte("not committed"))
neighbor := repository + "-neighbor"
if err := os.MkdirAll(neighbor, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(neighbor, "README.md"), []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.RemoveAll(neighbor); err != nil {
t.Error(err)
}
})
root := filepath.ToSlash(repository)
for _, test := range []struct {
destination string
kind DestinationKind
resolved string
}{
{root + "/README.md", DestinationEntry, "README.md"},
{root, DestinationDirectory, "."},
{root + "/nested/inner", DestinationDirectory, "nested/inner"},
{"absolute-file", DestinationEntry, "README.md"},
{"absolute-directory/item.txt", DestinationEntry, "nested/inner/item.txt"},
{root + "/docs/link/../file.md", DestinationEntry, "nested/file.md"},
{"absolute-parent", DestinationEntry, "nested/file.md"},
{root + "/live-only.md", DestinationMissing, "live-only.md"},
{root + "-neighbor/README.md", DestinationOutsideCheckout, root + "-neighbor/README.md"},
{"absolute-outside", DestinationOutsideCheckout, root + "-neighbor/README.md"},
} {
t.Run(test.destination, func(t *testing.T) {
got, err := tree.ResolveDestination(t.Context(), "README.md", test.destination)
if err != nil || got.Kind != test.kind || got.Path != test.resolved {
t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved)
}
})
}
}

View File

@@ -0,0 +1,112 @@
package validation
import (
"fmt"
"strings"
)
// Check identifies one shared validation check.
type Check string
const (
// GoTest identifies the base Go test suite.
GoTest Check = "go-test"
// GoVet identifies the separate full vet pass.
GoVet Check = "go-vet"
// PackageCompile identifies selected-target package compilation.
PackageCompile Check = "package-compile"
// GoFormat identifies committed Go source formatting.
GoFormat Check = "go-format"
// WorkspaceFiles identifies committed workspace files in module source.
WorkspaceFiles Check = "workspace-files"
// LocalReplacements identifies filesystem replacements in committed modules.
LocalReplacements Check = "local-replacements"
// FileSize identifies committed blobs that exceed a selected size limit.
FileSize Check = "file-size"
// MarkdownLinks identifies missing committed Markdown destinations or anchors.
MarkdownLinks Check = "markdown-links"
)
// Finding describes a validation observation at an optional source location.
type Finding struct {
Path string
Line int
Column int
Message string
Target string
Directive string
}
// FileSizeMeasurement describes a measured file size and its selected limit.
type FileSizeMeasurement struct {
Bytes int64
LimitBytes int64
}
// Failure describes a failed shared validation check and preserves its cause.
type Failure struct {
Check Check
Findings []Finding
Measurement *FileSizeMeasurement
Cause error
}
// Error returns a useful summary of the failed check.
func (failure *Failure) Error() string {
return formatFailure(failure)
}
// Unwrap returns the underlying validation error.
func (failure *Failure) Unwrap() error {
if failure == nil {
return nil
}
return failure.Cause
}
func formatFailure(failure *Failure) string {
if failure == nil {
return "<nil>"
}
var summary strings.Builder
if failure.Check == "" {
summary.WriteString("validation failed")
} else {
fmt.Fprintf(&summary, "%s validation failed", failure.Check)
}
for _, finding := range failure.Findings {
summary.WriteString(": ")
if finding.Path != "" {
summary.WriteString(finding.Path)
if finding.Line > 0 {
fmt.Fprintf(&summary, ":%d", finding.Line)
if finding.Column > 0 {
fmt.Fprintf(&summary, ":%d", finding.Column)
}
}
summary.WriteString(": ")
}
summary.WriteString(finding.Message)
if finding.Target != "" {
fmt.Fprintf(&summary, " (target %q)", finding.Target)
}
if finding.Directive != "" {
fmt.Fprintf(&summary, " (directive %q)", finding.Directive)
}
}
if failure.Measurement != nil {
fmt.Fprintf(&summary, ": measured %d bytes against a %d byte limit", failure.Measurement.Bytes, failure.Measurement.LimitBytes)
}
if failure.Cause != nil {
if len(failure.Findings) == 0 && failure.Measurement == nil {
summary.WriteString(": ")
} else {
summary.WriteString("; ")
}
summary.WriteString(failure.Cause.Error())
}
return summary.String()
}
var _ error = (*Failure)(nil)
var _ interface{ Unwrap() error } = (*Failure)(nil)

View File

@@ -0,0 +1,49 @@
package validation
import (
"context"
"errors"
"strings"
"testing"
)
func TestRunPreservesCanceledGoSetup(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := Run(ctx, Request{RepositoryDir: t.TempDir()})
var failure *Failure
if !errors.As(err, &failure) {
t.Fatalf("Run() error = %v, want a validation failure", err)
}
if failure.Check != GoTest {
t.Fatalf("failure check = %q, want %q", failure.Check, GoTest)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context cancellation preserved", err)
}
}
func TestFailureFormatsDiagnosticsAndUnwrapsCause(t *testing.T) {
wantCause := errors.New("underlying check error")
failure := &Failure{
Check: GoTest,
Findings: []Finding{{
Path: "README.md",
Line: 4,
Column: 2,
Message: "broken destination",
}},
Measurement: &FileSizeMeasurement{Bytes: 12, LimitBytes: 10},
Cause: wantCause,
}
if !errors.Is(failure, wantCause) {
t.Fatalf("Failure does not unwrap its cause")
}
for _, part := range []string{"go-test", "README.md:4:2", "broken destination", "12 bytes", "10 byte limit", wantCause.Error()} {
if !strings.Contains(failure.Error(), part) {
t.Errorf("Failure.Error() = %q; want it to include %q", failure.Error(), part)
}
}
}

View File

@@ -0,0 +1,52 @@
package validation
import (
"path"
"strings"
)
const defaultFileSizeLimit int64 = 1_000_000
func checkFileSize(tree *CandidateTree, settings *FileSizeSettings) error {
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
}
if entry.Size > limit {
return &Failure{
Check: FileSize,
Findings: []Finding{{
Path: entry.Path,
Message: "committed file exceeds the selected size limit",
}},
Measurement: &FileSizeMeasurement{Bytes: entry.Size, LimitBytes: limit},
}
}
}
return nil
}
func normalizedExclusions(exclusions []string) []string {
result := make([]string, 0, len(exclusions))
for _, exclusion := range exclusions {
if exclusion == "" {
continue
}
result = append(result, path.Clean(exclusion))
}
return result
}
func excludedByPath(filePath string, exclusions []string) bool {
for _, exclusion := range exclusions {
if exclusion == "." || filePath == exclusion || strings.HasPrefix(filePath, exclusion+"/") {
return true
}
}
return false
}

View File

@@ -0,0 +1,192 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
tests := []struct {
name string
limit *int64
size int64
wantLimit int64
wantError bool
}{
{name: "below", limit: int64Pointer(10), size: 9, wantLimit: 10},
{name: "at", limit: int64Pointer(10), size: 10, wantLimit: 10},
{name: "above", limit: int64Pointer(10), size: 11, wantLimit: 10, wantError: true},
{name: "default at", size: 1_000_000, wantLimit: 1_000_000},
{name: "default above", size: 1_000_001, wantLimit: 1_000_000, wantError: true},
{name: "zero empty", limit: int64Pointer(0), size: 0},
{name: "zero nonempty", limit: int64Pointer(0), size: 1, wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{{
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
}}}
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit})
if !test.wantError {
if err != nil {
t.Fatalf("checkFileSize() error = %v, want nil", err)
}
return
}
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "file.bin" {
t.Fatalf("checkFileSize() error = %v, want file-size finding", err)
}
if failure.Measurement == nil || failure.Measurement.Bytes != test.size || failure.Measurement.LimitBytes != test.wantLimit {
t.Fatalf("measurement = %#v, want %d bytes against %d", failure.Measurement, test.size, test.wantLimit)
}
})
}
}
func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
tests := []struct {
name string
entries []git.TreeEntry
exclusions []string
wantPath string
}{
{
name: "directory boundary and normalized paths",
entries: []git.TreeEntry{
{Path: "assets/icon.png", Type: "blob", Size: 20, HasSize: true},
{Path: "assets-other/icon.png", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"./assets/"},
wantPath: "assets-other/icon.png",
},
{
name: "glob metacharacters are literal",
entries: []git.TreeEntry{
{Path: "[ab].dat", Type: "blob", Size: 20, HasSize: true},
{Path: "a.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"[ab].dat", "literal[set]"},
wantPath: "a.dat",
},
{
name: "directory metacharacters are literal",
entries: []git.TreeEntry{
{Path: "literal[set]/file.dat", Type: "blob", Size: 20, HasSize: true},
{Path: "literalx/file.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"literal[set]"},
wantPath: "literalx/file.dat",
},
{
name: "unmatched exclusion",
entries: []git.TreeEntry{
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"not-present"},
wantPath: "selected.dat",
},
{
name: "exact file exclusion",
entries: []git.TreeEntry{
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
{Path: "other.dat", Type: "blob", Size: 20, HasSize: true},
},
exclusions: []string{"selected.dat"},
wantPath: "other.dat",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{
LimitBytes: int64Pointer(10),
Exclusions: test.exclusions,
})
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)
}
})
}
}
func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "small.bin", []byte{0, 1})
writeTreeFile(t, repository, "boundary.bin", []byte{0, 1, 2})
if err := os.Symlink("small.bin", filepath.Join(repository, "link")); err != nil {
t.Fatal(err)
}
submodule := newTreeRepository(t)
writeTreeFile(t, submodule, "submodule.txt", []byte("submodule"))
gitTree(t, submodule, "add", "--all")
gitTree(t, submodule, "commit", "-m", "create nested source")
submoduleCommit := strings.TrimSpace(gitTree(t, submodule, "rev-parse", "HEAD"))
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleCommit+",submodule")
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkFileSize(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"}})
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)
}
if failure.Measurement == nil || failure.Measurement.Bytes != int64(len("small.bin")) {
t.Fatalf("symlink measurement = %#v, want link-text size %d", failure.Measurement, len("small.bin"))
}
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}})
if err != nil {
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
}
}
func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "historical-large.dat", []byte(strings.Repeat("h", 20)))
commitCandidateTree(t, repository)
if err := os.Remove(filepath.Join(repository, "historical-large.dat")); err != nil {
t.Fatal(err)
}
writeTreeFile(t, repository, "one.dat", []byte(strings.Repeat("a", 6)))
writeTreeFile(t, repository, "two.dat", []byte(strings.Repeat("b", 6)))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
}
}
func TestRunRejectsNegativeFileSizeLimitBeforeGoChecks(t *testing.T) {
negative := int64(-1)
err := Run(context.Background(), Request{Settings: Settings{
FileSize: &FileSizeSettings{LimitBytes: &negative},
}})
failure, ok := err.(*Failure)
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || !strings.Contains(failure.Findings[0].Message, "negative") {
t.Fatalf("Run() error = %v, want invalid file-size limit failure", err)
}
if errors.Unwrap(failure) != nil {
t.Fatalf("invalid limit failure cause = %v, want none", failure.Cause)
}
}
func int64Pointer(value int64) *int64 { return &value }

View File

@@ -0,0 +1,133 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "main.go", []byte("package main\n\nfunc main() {}\n"))
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\nfunc Value( ){return 1}\n"))
writeTreeFile(t, repository, "nested/module/odd name.go", []byte("package nested\nfunc Value( ){return 2}\n"))
writeTreeFile(t, repository, "notes.txt", []byte("not Go source\n"))
if err := os.Symlink("main.go", filepath.Join(repository, "link.go")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture Go files")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
// Worktree edits must not affect the formatting result for the captured commit.
writeTreeFile(t, repository, "main.go", []byte("package main\nfunc main( ){}\n"))
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\n\nfunc Value() { return 1 }\n"))
worktreeMain, err := os.ReadFile(filepath.Join(repository, "main.go"))
if err != nil {
t.Fatal(err)
}
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("checkGoFormat error = %v, want go-format failure", err)
}
wantPaths := []string{"nested/module/odd name.go", "vendor/example/format.go"}
if len(failure.Findings) != len(wantPaths) {
t.Fatalf("format findings = %#v, want paths %q", failure.Findings, wantPaths)
}
for index, path := range wantPaths {
if failure.Findings[index].Path != path {
t.Errorf("format finding %d path = %q, want %q", index, failure.Findings[index].Path, path)
}
}
worktreeMainAfter, err := os.ReadFile(filepath.Join(repository, "main.go"))
if err != nil {
t.Fatal(err)
}
if string(worktreeMainAfter) != string(worktreeMain) {
t.Fatalf("format check changed worktree source: got %q, want %q", worktreeMainAfter, worktreeMain)
}
}
func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
t.Run("parser", func(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "broken.go", []byte("package broken\nfunc ( {\n"))
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture invalid Go source")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
t.Fatalf("parser failure = %#v, want go-format failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" {
t.Fatalf("parser findings = %#v, want broken.go", failure.Findings)
}
if !strings.Contains(failure.Cause.Error(), "broken.go") {
t.Fatalf("parser cause = %v, want committed filename context", failure.Cause)
}
})
t.Run("inspection", func(t *testing.T) {
tree := &CandidateTree{
repositoryDir: t.TempDir(),
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
byPath: map[string]int{"missing.go": 0},
blobs: make(map[string][]byte),
}
err := checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
t.Fatalf("inspection failure = %#v, want go-format failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.go" {
t.Fatalf("inspection findings = %#v, want missing.go", failure.Findings)
}
})
}
func TestRunGoFormatIsOptionalAndStopsAfterFinding(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "unformatted.go", []byte("package fixture\nfunc Value( ){return 1}\n"))
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture unformatted Go source")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
logPath := installGoShim(t, "", false)
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
t.Fatalf("default validation = %v, want success with formatting disabled", err)
}
if got := strings.Join(readGoShimLog(t, logPath), "\n"); !strings.Contains(got, "test -mod=readonly -count=1 ./...") {
t.Fatalf("default Go commands = %q, want standard suite", got)
}
logPath = installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{GoFormat: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("selected formatting error = %v, want go-format failure", err)
}
commands := strings.Join(readGoShimLog(t, logPath), "\n")
if strings.Contains(commands, "build ") || strings.Contains(commands, "vet ") {
t.Fatalf("later Go commands ran after formatting failure: %q", commands)
}
}

View File

@@ -0,0 +1,92 @@
package validation
import (
"context"
"fmt"
"path"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"golang.org/x/mod/modfile"
)
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
var findings []Finding
for _, entry := range tree.Entries() {
if path.Base(entry.Path) != "go.mod" {
continue
}
contents, err := readModuleDeclaration(ctx, tree, entry)
if err != nil {
return &Failure{
Check: LocalReplacements,
Findings: []Finding{{Path: entry.Path, Message: "could not inspect committed module declaration"}},
Cause: fmt.Errorf("inspect committed %s: %w", entry.Path, err),
}
}
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
if err != nil {
return &Failure{
Check: LocalReplacements,
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
}
}
for _, replacement := range moduleFile.Replace {
if replacement.New.Version != "" {
continue
}
finding := Finding{
Path: entry.Path,
Message: "filesystem module replacement is committed",
Target: replacement.New.Path,
Directive: "replace",
}
if replacement.Syntax != nil {
finding.Line = replacement.Syntax.Start.Line
}
findings = append(findings, finding)
}
}
if len(findings) != 0 {
return &Failure{Check: LocalReplacements, Findings: findings}
}
return nil
}
func readModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) ([]byte, error) {
if entry.Type != "blob" {
return nil, fmt.Errorf("entry has type %q, not a blob", entry.Type)
}
if entry.Mode != "120000" {
if entry.Mode != "100644" && entry.Mode != "100755" {
return nil, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
}
contents, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return nil, fmt.Errorf("read module declaration: %w", err)
}
return contents, nil
}
target, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return nil, fmt.Errorf("read symlink declaration: %w", err)
}
destination, err := tree.ResolveDestination(ctx, entry.Path, string(target))
if err != nil {
return nil, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
}
if destination.Kind != DestinationEntry {
return nil, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
}
resolved, ok := tree.Entry(destination.Path)
if !ok || resolved.Type != "blob" || (resolved.Mode != "100644" && resolved.Mode != "100755") {
return nil, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
}
contents, err := tree.ReadFile(ctx, destination.Path)
if err != nil {
return nil, fmt.Errorf("read resolved module declaration %q: %w", destination.Path, err)
}
return contents, nil
}

View File

@@ -0,0 +1,175 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
repository := newTreeRepository(t)
absoluteTarget := filepath.Join(repository, "outside module")
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n\nreplace example.com/one => \"../local module\" // retained comment\n"))
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\nreplace (\n\texample.com/two => ../../local\n\texample.com/three => \""+absoluteTarget+"\"\n)\n"))
writeTreeFile(t, repository, "vendor/example/go.mod", []byte("module example.com/vendor\n\nreplace example.com/versioned v1.0.0 => example.com/fork v1.2.0\n"))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements {
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"},
}
if len(failure.Findings) != len(want) {
t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want))
}
for _, finding := range failure.Findings {
key := finding.Path
if finding.Path != "go.mod" {
key += ":" + strconv.Itoa(finding.Line)
}
expected, ok := want[key]
if !ok || finding.Line != expected.Line || finding.Target != expected.Target || finding.Directive != expected.Directive {
t.Errorf("finding = %#v, want %q", finding, key)
}
}
}
func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "modules/real.mod", []byte("module example.com/real\n\nreplace example.com/local => ../checkout\n"))
if err := os.MkdirAll(filepath.Join(repository, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../modules/real.mod", filepath.Join(repository, "nested/go.mod")); err != nil {
t.Fatal(err)
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || len(failure.Findings) != 1 {
t.Fatalf("checkLocalReplacements error = %v, want one symlink declaration finding", err)
}
finding := failure.Findings[0]
if finding.Path != "nested/go.mod" || finding.Line != 3 || finding.Target != "../checkout" {
t.Fatalf("symlink finding = %#v, want source path and resolved declaration", finding)
}
}
func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
repository := newTreeRepository(t)
if err := os.Symlink("missing.mod", filepath.Join(repository, "go.mod")); err != nil {
t.Fatal(err)
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
t.Fatalf("checkLocalReplacements error = %v, want local-replacements inspection failure", err)
}
if !strings.Contains(failure.Cause.Error(), "missing") || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
t.Fatalf("failure = %#v, want path and unavailable target cause", failure)
}
}
func TestCheckLocalReplacementsPreservesParseCause(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/bad\nreplace =>\n"))
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkLocalReplacements(context.Background(), tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil || !strings.Contains(failure.Cause.Error(), "parse committed go.mod") {
t.Fatalf("checkLocalReplacements error = %v, want parse failure with cause", err)
}
if errors.Unwrap(failure) == nil || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
t.Fatalf("failure = %#v, want preserved parse cause and path", failure)
}
}
func TestRunSelectsDistributionChecksIndependentlyWithGoWorkspaceDisabled(t *testing.T) {
t.Setenv("GOWORK", "off")
for _, test := range []struct {
name string
workspaceFiles bool
localReplacements bool
wantCheck Check
}{
{name: "neither"},
{name: "workspace", workspaceFiles: true, wantCheck: WorkspaceFiles},
{name: "replacements", localReplacements: true, wantCheck: LocalReplacements},
{name: "both", workspaceFiles: true, localReplacements: true, wantCheck: WorkspaceFiles},
} {
t.Run(test.name, func(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\nreplace example.com/local => ../local\n"))
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
commit := commitCandidateTree(t, repository)
installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
WorkspaceFiles: test.workspaceFiles,
LocalReplacements: test.localReplacements,
}},
})
if test.wantCheck == "" {
if err != nil {
t.Fatalf("Run error = %v, want disabled checks", err)
}
return
}
failure, ok := err.(*Failure)
if !ok || failure.Check != test.wantCheck {
t.Fatalf("Run error = %v, want %s failure", err, test.wantCheck)
}
})
}
}
func TestRunAttributesCandidateTreeFailureToLocalReplacements(t *testing.T) {
repository := newTreeRepository(t)
installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: "missing-commit",
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
LocalReplacements: true,
}},
})
failure, ok := err.(*Failure)
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
t.Fatalf("Run error = %v, want local-replacements candidate inspection failure", err)
}
}
func commitCandidateTree(t *testing.T, repository string) string {
t.Helper()
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture module declarations")
return strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
}

View File

@@ -0,0 +1,176 @@
package validation
import (
"context"
"sort"
"strconv"
"strings"
"unicode"
"github.com/yuin/goldmark/v2/ast"
"github.com/yuin/goldmark/v2/parser"
)
type markdownLocation struct {
Line int
Column int
}
type markdownLink struct {
Destination string
Title string
Image bool
Reference bool
Location markdownLocation
}
type markdownDocument struct {
links []markdownLink
anchors map[string]struct{}
}
type markdownCache struct {
tree *CandidateTree
documents map[string]*markdownDocument
}
func newMarkdownCache(tree *CandidateTree) *markdownCache {
return &markdownCache{
tree: tree,
documents: make(map[string]*markdownDocument),
}
}
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
if document, ok := cache.documents[path]; ok {
return document, nil
}
source, err := cache.tree.ReadFile(ctx, path)
if err != nil {
return nil, err
}
document := parseMarkdown(source)
cache.documents[path] = document
return document, nil
}
func parseMarkdown(source []byte) *markdownDocument {
root := parser.New().Parse(source)
document := &markdownDocument{
anchors: make(map[string]struct{}),
}
lineStarts := markdownLineStarts(source)
usedAnchors := make(map[string]struct{})
nextSuffix := make(map[string]int)
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := node.(type) {
case *ast.Link:
document.links = append(document.links, markdownLink{
Destination: node.Destination.Value(source),
Title: node.Title.Value(source),
Reference: node.Reference != nil,
Location: markdownLocationAt(lineStarts, node.Pos()),
})
case *ast.Image:
document.links = append(document.links, markdownLink{
Destination: node.Destination.Value(source),
Title: node.Title.Value(source),
Image: true,
Reference: node.Reference != nil,
Location: markdownLocationAt(lineStarts, node.Pos()),
})
// Image labels contribute alternative text, not navigable destinations.
return ast.WalkSkipChildren, nil
case *ast.AutoLink:
document.links = append(document.links, markdownLink{
Destination: node.Destination.Value(source),
Location: markdownLocationAt(lineStarts, node.Pos()),
})
case *ast.Heading:
base := markdownAnchor(markdownHeadingText(node, source))
anchor := base
if _, exists := usedAnchors[anchor]; exists {
suffix, ok := nextSuffix[base]
if !ok {
suffix = 1
}
for {
anchor = base + "-" + strconv.Itoa(suffix)
suffix++
if _, exists := usedAnchors[anchor]; !exists {
break
}
}
nextSuffix[base] = suffix
}
usedAnchors[anchor] = struct{}{}
document.anchors[anchor] = struct{}{}
}
return ast.WalkContinue, nil
})
return document
}
func markdownHeadingText(heading *ast.Heading, source []byte) string {
var result strings.Builder
var appendNode func(ast.Node)
appendNode = func(node ast.Node) {
switch node := node.(type) {
case *ast.RawHTML:
return
case *ast.Text:
result.WriteString(node.Value.Value(source))
if node.SoftLineBreak() || node.HardLineBreak() {
result.WriteByte(' ')
}
return
case *ast.CodeSpan:
result.WriteString(node.Value.Value(source))
return
case *ast.AutoLink:
result.WriteString(node.Label.Value(source))
return
}
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
appendNode(child)
}
}
for child := heading.FirstChild(); child != nil; child = child.NextSibling() {
appendNode(child)
}
return result.String()
}
func markdownAnchor(heading string) string {
heading = strings.ToLower(heading)
var result strings.Builder
for _, r := range heading {
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' || r == ' ' {
result.WriteRune(r)
}
}
return strings.ReplaceAll(result.String(), " ", "-")
}
func markdownLineStarts(source []byte) []int {
starts := []int{0}
for offset, value := range source {
if value == '\n' {
starts = append(starts, offset+1)
}
}
return starts
}
func markdownLocationAt(lineStarts []int, offset int) markdownLocation {
if offset < 0 {
return markdownLocation{}
}
lineIndex := sort.Search(len(lineStarts), func(index int) bool {
return lineStarts[index] > offset
}) - 1
return markdownLocation{Line: lineIndex + 1, Column: offset - lineStarts[lineIndex] + 1}
}

View File

@@ -0,0 +1,184 @@
package validation
import (
"context"
"fmt"
"io"
"net/url"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func checkMarkdownLinks(ctx context.Context, tree *CandidateTree, enforceCheckoutBoundary bool, output io.Writer) error {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Cause: err}
}
cache := newMarkdownCache(tree)
var findings []Finding
for _, entry := range tree.Entries() {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
if !isRegularFile(entry) || !strings.EqualFold(path.Ext(entry.Path), ".md") {
continue
}
document, err := cache.document(ctx, entry.Path)
if err != nil {
return &Failure{
Check: MarkdownLinks,
Findings: []Finding{{Path: entry.Path, Message: "could not inspect Markdown document"}},
Cause: fmt.Errorf("read committed Markdown document %q: %w", entry.Path, err),
}
}
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
for _, link := range document.links {
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
linkFindings, err := checkMarkdownLink(ctx, tree, cache, entry.Path, link, enforceCheckoutBoundary, output)
findings = append(findings, linkFindings...)
if err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
}
}
if err := ctx.Err(); err != nil {
return &Failure{Check: MarkdownLinks, Findings: findings, Cause: err}
}
if len(findings) != 0 {
return &Failure{Check: MarkdownLinks, Findings: findings}
}
return nil
}
func checkMarkdownLink(ctx context.Context, tree *CandidateTree, cache *markdownCache, sourcePath string, link markdownLink, enforceCheckoutBoundary bool, output io.Writer) ([]Finding, error) {
uriPath, fragment, local, err := parseMarkdownDestination(link.Destination)
if err != nil {
return []Finding{markdownFinding(sourcePath, link, "destination contains an invalid percent escape")}, nil
}
if !local {
return nil, nil
}
destination := Destination{Kind: DestinationEntry, Path: sourcePath}
if uriPath != "" {
destination, err = tree.ResolveDestination(ctx, sourcePath, uriPath)
if err != nil {
finding := markdownFinding(sourcePath, link, "could not inspect destination")
return []Finding{finding}, fmt.Errorf("resolve Markdown destination %q from %q: %w", link.Destination, sourcePath, err)
}
}
switch destination.Kind {
case DestinationMissing:
return []Finding{markdownFinding(sourcePath, link, "destination does not exist")}, nil
case DestinationSymlinkCycle:
return []Finding{markdownFinding(sourcePath, link, "destination contains a symlink cycle")}, nil
case DestinationOutsideCheckout:
if enforceCheckoutBoundary {
return []Finding{markdownFinding(sourcePath, link, "destination is outside the checkout")}, nil
}
return nil, writeMarkdownObservation(output, sourcePath, link, "destination is outside the checkout")
case DestinationSubmodule:
return nil, writeMarkdownObservation(output, sourcePath, link, "destination enters uninspected submodule content")
case DestinationEntry:
if fragment == "" || !strings.EqualFold(path.Ext(destination.Path), ".md") {
return nil, nil
}
targetEntry, ok := tree.Entry(destination.Path)
if !ok || !isRegularFile(targetEntry) {
return nil, nil
}
targetDocument, err := cache.document(ctx, destination.Path)
if err != nil {
finding := markdownFinding(sourcePath, link, "could not inspect destination anchor")
return []Finding{finding}, fmt.Errorf("read committed Markdown destination %q: %w", destination.Path, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
if _, ok := targetDocument.anchors[fragment]; !ok {
return []Finding{markdownFinding(sourcePath, link, "heading anchor does not exist")}, nil
}
}
return nil, nil
}
func parseMarkdownDestination(destination string) (path, fragment string, local bool, err error) {
pathPart := destination
if index := strings.IndexByte(pathPart, '#'); index >= 0 {
fragment = pathPart[index+1:]
pathPart = pathPart[:index]
}
if index := strings.IndexByte(pathPart, '?'); index >= 0 {
pathPart = pathPart[:index]
}
if strings.HasPrefix(pathPart, "//") || hasURLScheme(pathPart) {
return "", "", false, nil
}
path, err = url.PathUnescape(pathPart)
if err != nil {
return "", "", true, err
}
fragment, err = url.PathUnescape(fragment)
if err != nil {
return "", "", true, err
}
return path, strings.ToLower(fragment), true, nil
}
func hasURLScheme(value string) bool {
colon := strings.IndexByte(value, ':')
if colon <= 0 {
return false
}
if slash := strings.IndexByte(value, '/'); slash >= 0 && slash < colon {
return false
}
for index, r := range value[:colon] {
if index == 0 {
if !isASCIIAlpha(r) {
return false
}
continue
}
if !isASCIIAlpha(r) && (r < '0' || r > '9') && r != '+' && r != '-' && r != '.' {
return false
}
}
return true
}
func isASCIIAlpha(r rune) bool {
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
}
func isRegularFile(entry git.TreeEntry) bool {
return entry.Type == "blob" && (entry.Mode == "100644" || entry.Mode == "100755")
}
func markdownFinding(sourcePath string, link markdownLink, message string) Finding {
return Finding{
Path: sourcePath,
Line: link.Location.Line,
Column: link.Location.Column,
Message: message,
Target: link.Destination,
}
}
func writeMarkdownObservation(output io.Writer, sourcePath string, link markdownLink, message string) error {
if output == nil {
return nil
}
_, err := fmt.Fprintf(output, "markdown-links: %s:%d:%d: %s (%q; out of scope)\n",
sourcePath, link.Location.Line, link.Location.Column, message, link.Destination)
if err != nil {
return fmt.Errorf("write Markdown destination observation: %w", err)
}
return nil
}

View File

@@ -0,0 +1,302 @@
package validation
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments(t *testing.T) {
guide := []byte("" +
"# Start\n" +
"[target](target.md?view=1#TARGET)\n" +
"[same](#START)\n" +
"[url heading](autolinks.md#httpsexampleinvalid)\n" +
"[email heading](autolinks.md#maintainerexampleinvalid)\n" +
"[top](#)\n" +
"[directory](assets/?view=1)\n" +
"[binary](image.bin#not-an-anchor)\n" +
"[encoded](docs/a%20b.md#CAF%C3%89)\n" +
"[query](target.md?view=%ZZ)\n" +
"[external](https://example.invalid/%ZZ#also%ZZ)\n" +
"[protocol-relative](//example.invalid/file.md)\n")
repository, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": guide,
"target.md": []byte("# Target\n"),
"autolinks.md": []byte("# <https://example.invalid>\n# <maintainer@example.invalid>\n"),
"docs/a b.md": []byte("# Café\n"),
"assets/file.md": []byte("# Asset\n"),
"nested/guide.md": []byte("# Nested\n[same](#NESTED)\n[query](?view=1#nested)\n"),
"image.bin": []byte{0, 1, 2},
})
if err := checkMarkdownLinks(context.Background(), tree, false, nil); err != nil {
t.Fatalf("checkMarkdownLinks() error = %v", err)
}
if got := readCommitted(t, tree, "guide.md"); !bytes.Equal(got, guide) {
t.Fatal("checker changed committed Markdown bytes")
}
if got, err := os.ReadFile(filepath.Join(repository, "guide.md")); err != nil || !bytes.Equal(got, guide) {
t.Fatalf("worktree Markdown changed: got %q, err %v", got, err)
}
}
func TestCheckMarkdownLinksTreatsImageLabelsAsAlternativeText(t *testing.T) {
_, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("" +
"![![inner](missing.png)](image.png)\n" +
"![[label](missing.md)](image.png)\n" +
"![[anchor](target.md#absent)](image.png)\n" +
"![[outside](../outside.md)](image.png)\n" +
"[![logo](image.png)](target.md#present)\n" +
"[![missing](absent.png)](absent.md)\n"),
"image.png": []byte("image"),
"target.md": []byte("# Present\n"),
})
for _, enforceBoundary := range []bool{false, true} {
t.Run(fmt.Sprintf("boundary=%t", enforceBoundary), func(t *testing.T) {
err := checkMarkdownLinks(context.Background(), tree, enforceBoundary, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 2 {
t.Fatalf("error = %v, want only the missing linked-image source and link target", err)
}
want := map[string]bool{"absent.png": true, "absent.md": true}
for _, finding := range failure.Findings {
if !want[finding.Target] {
t.Errorf("unexpected destination finding: %#v", finding)
}
delete(want, finding.Target)
}
if len(want) != 0 {
t.Errorf("missing findings for real destinations: %v", want)
}
})
}
}
func TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles(t *testing.T) {
_, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("" +
"[missing](absent.md)\n" +
"[anchor](target.md#missing)\n" +
"[cycle](cycle-a)\n"),
"target.md": []byte("# Present\n"),
})
repository := tree.repositoryDir
if err := os.Symlink("cycle-b", filepath.Join(repository, "cycle-a")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("cycle-a", filepath.Join(repository, "cycle-b")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "cycle-a", "cycle-b")
gitTree(t, repository, "commit", "-m", "add markdown link cycle")
tree = candidateTreeAtHead(t, repository)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) {
t.Fatalf("checkMarkdownLinks() error = %v, want validation Failure", err)
}
if failure.Check != MarkdownLinks {
t.Fatalf("failure check = %q, want %q", failure.Check, MarkdownLinks)
}
if len(failure.Findings) != 3 {
t.Fatalf("findings = %#v, want missing path, anchor, and cycle", failure.Findings)
}
want := map[string]string{
"absent.md": "destination does not exist",
"target.md#missing": "heading anchor does not exist",
"cycle-a": "destination contains a symlink cycle",
}
for _, finding := range failure.Findings {
if finding.Path != "guide.md" || finding.Line < 1 || finding.Column < 1 {
t.Errorf("finding lacks source location: %#v", finding)
}
if want[finding.Target] != finding.Message {
t.Errorf("finding = %#v", finding)
}
delete(want, finding.Target)
}
if len(want) != 0 {
t.Fatalf("missing expected findings for %#v", want)
}
}
func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
repository, _ := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[valid](alias.md#present)\n[missing](alias.md#missing)\n"),
"target.md": []byte("# Present\n"),
})
if err := os.Symlink("target.md", filepath.Join(repository, "alias.md")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "alias.md")
gitTree(t, repository, "commit", "-m", "add Markdown alias")
tree := candidateTreeAtHead(t, repository)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
t.Fatalf("resolved alias error = %v, want one missing-anchor finding", err)
}
if finding := failure.Findings[0]; finding.Path != "guide.md" || finding.Target != "alias.md#missing" || finding.Message != "heading anchor does not exist" {
t.Fatalf("resolved alias finding = %#v", finding)
}
}
func TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations(t *testing.T) {
repository, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[outside](../../outside.md)\n[submodule](deps/module/README.md)\n"),
})
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
gitTree(t, repository, "update-index", "--add", "--cacheinfo", fmt.Sprintf("160000,%s,deps/module", commit))
gitTree(t, repository, "commit", "-m", "add submodule entry")
tree = candidateTreeAtHead(t, repository)
var observed bytes.Buffer
if err := checkMarkdownLinks(context.Background(), tree, false, &observed); err != nil {
t.Fatalf("checkMarkdownLinks() error = %v", err)
}
if got := observed.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "uninspected submodule content") || strings.Count(got, "out of scope") != 2 {
t.Fatalf("out-of-scope observations = %q", got)
}
observed.Reset()
err := checkMarkdownLinks(context.Background(), tree, true, &observed)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
t.Fatalf("enforced boundary error = %v, want one Markdown finding", err)
}
if finding := failure.Findings[0]; finding.Target != "../../outside.md" || finding.Message != "destination is outside the checkout" {
t.Fatalf("boundary finding = %#v", finding)
}
if got := observed.String(); !strings.Contains(got, "uninspected submodule content") || strings.Contains(got, "outside the checkout") {
t.Fatalf("enforced observations = %q", got)
}
}
func TestCheckMarkdownLinksPreservesResolverCauseAndCancellation(t *testing.T) {
repository, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[target](broken.md)\n"),
})
if err := os.Symlink("target.md", filepath.Join(repository, "broken.md")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", "broken.md")
gitTree(t, repository, "commit", "-m", "add Markdown symlink")
tree = candidateTreeAtHead(t, repository)
index := tree.byPath["broken.md"]
tree.entries[index].ObjectID = strings.Repeat("0", 40)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
t.Fatalf("resolver inspection error = %v, want Markdown failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Line != 1 || failure.Findings[0].Target != "broken.md" {
t.Fatalf("resolver inspection finding = %#v", failure.Findings)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = checkMarkdownLinks(ctx, tree, false, nil)
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Markdown check error = %v, want preserved cancellation", err)
}
}
func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) {
_, tree := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[target](target.md#present)\n"),
"target.md": []byte("# Present\n"),
})
tree.entries[tree.byPath["target.md"]].ObjectID = strings.Repeat("0", 40)
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
t.Fatalf("anchor inspection error = %v, want Markdown failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "target.md#present" || failure.Findings[0].Message != "could not inspect destination anchor" {
t.Fatalf("anchor inspection finding = %#v", failure.Findings)
}
}
func TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks(t *testing.T) {
repository, _ := committedMarkdownTree(t, map[string][]byte{
"guide.md": []byte("[outside](../../outside.md)\n"),
})
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
goLog := installGoShim(t, "", false)
var output bytes.Buffer
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Output: &output,
Settings: Settings{
Race: true,
FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}},
Markdown: &MarkdownSettings{},
},
})
if err != nil {
t.Fatalf("Run() error = %v, want out-of-scope observation to succeed", err)
}
if got := output.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "out of scope") {
t.Fatalf("Run() output = %q, want outside-checkout observation", got)
}
commands := readGoShimLog(t, goLog)
var testCommands []string
var validationCommands []string
for _, command := range commands {
if strings.HasPrefix(command, "test ") {
testCommands = append(testCommands, command)
}
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
validationCommands = append(validationCommands, command)
}
}
if len(testCommands) != 1 || !strings.Contains(testCommands[0], "-race") || !strings.Contains(testCommands[0], "-vet=off") {
t.Fatalf("Go test commands = %#v, want one race-enabled suite with test-time vet disabled", testCommands)
}
if len(validationCommands) != 3 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") || !strings.HasPrefix(validationCommands[2], "build ") {
t.Fatalf("Go validation commands = %#v, want test, full vet, and target compilation before Markdown", validationCommands)
}
}
func committedMarkdownTree(t *testing.T, files map[string][]byte) (string, *CandidateTree) {
t.Helper()
repository := newTreeRepository(t)
for name, content := range files {
writeTreeFile(t, repository, name, content)
}
gitTree(t, repository, "add", "--", ".")
gitTree(t, repository, "commit", "-m", "add Markdown fixture")
return repository, candidateTreeAtHead(t, repository)
}
func candidateTreeAtHead(t *testing.T, repository string) *CandidateTree {
t.Helper()
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
return tree
}
func readCommitted(t *testing.T, tree *CandidateTree, name string) []byte {
t.Helper()
contents, err := tree.ReadFile(context.Background(), name)
if err != nil {
t.Fatal(err)
}
return contents
}

View File

@@ -0,0 +1,124 @@
package validation
import (
"context"
"strings"
"testing"
)
func TestParseMarkdownLinksAndSourceLocations(t *testing.T) {
source := []byte("" +
"é [inline](docs/a\\(b\\).md \"inline title\").\n" +
"![chart](<images/chart one.svg> 'image title')\n" +
"[ref][target] then [ref][target].\n" +
"[collapsed][] and [shortcut].\n" +
"![logo][badge]\n" +
"\n" +
"[target]: docs/target.md \"reference title\"\n" +
"[collapsed]: child.md\n" +
"[shortcut]: short.md\n" +
"[badge]: badge.png\n" +
"Inline example: `[fake](fake.md)`.\n" +
"```md\n[also fake](fenced.md)\n```\n" +
"[undefined]\n" +
"Autolink <https://example.invalid>.\n" +
"<a href=\"raw.html\">HTML</a>\n")
document := parseMarkdown(source)
want := []markdownLink{
{Destination: "docs/a(b).md", Title: "inline title", Location: markdownLocation{Line: 1, Column: 4}},
{Destination: "images/chart one.svg", Title: "image title", Image: true, Location: markdownLocation{Line: 2, Column: 1}},
{Destination: "docs/target.md", Title: "reference title", Reference: true, Location: markdownLocation{Line: 3, Column: 1}},
{Destination: "docs/target.md", Title: "reference title", Reference: true, Location: markdownLocation{Line: 3, Column: 20}},
{Destination: "child.md", Reference: true, Location: markdownLocation{Line: 4, Column: 1}},
{Destination: "short.md", Reference: true, Location: markdownLocation{Line: 4, Column: 19}},
{Destination: "badge.png", Image: true, Reference: true, Location: markdownLocation{Line: 5, Column: 1}},
{Destination: "https://example.invalid", Location: markdownLocation{Line: 16, Column: 10}},
}
if len(document.links) != len(want) {
t.Fatalf("found %d links, want %d: %#v", len(document.links), len(want), document.links)
}
for index := range want {
if got := document.links[index]; got != want[index] {
t.Errorf("link %d = %#v, want %#v", index, got, want[index])
}
}
}
func TestParseMarkdownDecodesEntitiesInDestinations(t *testing.T) {
document := parseMarkdown([]byte("[entity](<a&amp;b.md>)\n"))
if len(document.links) != 1 || document.links[0].Destination != "a&b.md" {
t.Fatalf("parsed links = %#v, want decoded destination a&b.md", document.links)
}
}
func TestParseMarkdownHeadingAnchors(t *testing.T) {
source := []byte("" +
"# Caf&eacute; &amp; 東京 `Code Here` <em>Bold</em>!\n" +
"Setext **Heading**\n" +
"---\n" +
"# Foo\n" +
"# Foo\n" +
"# Foo-1\n" +
"# Foo\n" +
"# Foo-1\n" +
"# <https://example.invalid>\n" +
"# <maintainer@example.invalid>\n" +
"# Contact <maintainer@example.invalid> Today\n")
document := parseMarkdown(source)
for _, anchor := range []string{
"café--東京-code-here-bold",
"setext-heading",
"foo",
"foo-1",
"foo-1-1",
"foo-2",
"foo-1-2",
"httpsexampleinvalid",
"maintainerexampleinvalid",
"contact-maintainerexampleinvalid-today",
} {
if _, ok := document.anchors[anchor]; !ok {
t.Errorf("missing anchor %q in %#v", anchor, document.anchors)
}
}
if len(document.anchors) != 10 {
t.Fatalf("found %d anchors, want 10: %#v", len(document.anchors), document.anchors)
}
}
func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "guide.md", []byte("# Guide\n[other](other.md)\n"))
writeTreeFile(t, repository, "other.md", []byte("# Other\n"))
gitTree(t, repository, "add", "guide.md", "other.md")
gitTree(t, repository, "commit", "-m", "add markdown files")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
cache := newMarkdownCache(tree)
first, err := cache.document(context.Background(), "guide.md")
if err != nil {
t.Fatal(err)
}
second, err := cache.document(context.Background(), "guide.md")
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatal("repeated document lookup returned a different parsed document")
}
other, err := cache.document(context.Background(), "other.md")
if err != nil {
t.Fatal(err)
}
if other == first {
t.Fatal("different candidate paths shared a parsed document")
}
if _, ok := other.anchors["other"]; !ok {
t.Fatalf("other document anchors = %#v", other.anchors)
}
}

View File

@@ -0,0 +1,307 @@
package validation
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
for _, test := range []struct {
name string
race bool
fullVet bool
}{
{name: "default"},
{name: "race", race: true},
{name: "full vet", fullVet: true},
{name: "race and full vet", race: true, fullVet: true},
} {
t.Run(test.name, func(t *testing.T) {
logPath := installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{Race: test.race, FullVet: test.fullVet},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
commands := readGoShimLog(t, logPath)
var tests, vets []string
var testPosition, vetPosition int
for i, command := range commands {
if strings.HasPrefix(command, "test ") {
tests = append(tests, command)
testPosition = i
}
if strings.HasPrefix(command, "vet ") {
vets = append(vets, command)
vetPosition = i
}
}
if len(tests) != 1 {
t.Fatalf("Go test invocations = %q, want exactly one", tests)
}
command := " " + tests[0] + " "
for _, argument := range []string{"test", "-mod=readonly", "-count=1", "./..."} {
if !strings.Contains(command, " "+argument+" ") {
t.Errorf("Go test command %q does not contain %q", tests[0], argument)
}
}
if got := strings.Contains(command, " -race "); got != test.race {
t.Errorf("Go test command %q has race=%t, want %t", tests[0], got, test.race)
}
if got := strings.Contains(command, " -vet=off "); got != test.fullVet {
t.Errorf("Go test command %q disables test-time vet=%t, want %t", tests[0], got, test.fullVet)
}
wantVets := 0
if test.fullVet {
wantVets = 1
}
if len(vets) != wantVets {
t.Fatalf("Go vet invocations = %q, want %d", vets, wantVets)
}
if test.fullVet {
vetCommand := " " + vets[0] + " "
for _, argument := range []string{"vet", "-mod=readonly", "./..."} {
if !strings.Contains(vetCommand, " "+argument+" ") {
t.Errorf("Go vet command %q does not contain %q", vets[0], argument)
}
}
if vetPosition <= testPosition {
t.Errorf("Go vet command %q did not follow the test suite %q", vets[0], tests[0])
}
}
})
}
}
func TestRunReportsRacePrerequisiteFailureWithoutFallback(t *testing.T) {
logPath := installGoShim(t, "race", false)
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{Race: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoTest {
t.Fatalf("Run() error = %v, want a go-test validation failure", err)
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("Run() error = %v, want the command failure preserved", err)
}
commands := readGoShimLog(t, logPath)
var tests []string
for _, command := range commands {
if strings.HasPrefix(command, "test ") {
tests = append(tests, command)
}
}
if len(tests) != 1 || !strings.Contains(" "+tests[0]+" ", " -race ") {
t.Fatalf("Go test invocations = %q, want one race-enabled invocation and no fallback", tests)
}
}
func TestRunStopsAfterGoTestFailure(t *testing.T) {
logPath := installGoShim(t, "test", false)
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoTest {
t.Fatalf("Run() error = %v, want a go-test validation failure", err)
}
for _, command := range readGoShimLog(t, logPath) {
if strings.HasPrefix(command, "vet ") {
t.Fatalf("Go vet ran after Go test failed: %q", command)
}
}
}
func TestRunAttributesFullVetFailure(t *testing.T) {
logPath := installGoShim(t, "vet", false)
err := Run(context.Background(), Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoVet {
t.Fatalf("Run() error = %v, want a go-vet validation failure", err)
}
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
t.Fatalf("Run() error = %v, want the command failure preserved", err)
}
commands := readGoShimLog(t, logPath)
var validationCommands []string
for _, command := range commands {
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") {
validationCommands = append(validationCommands, command)
}
}
if len(validationCommands) != 2 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") {
t.Fatalf("validation command sequence = %q, want test followed by vet", validationCommands)
}
}
func TestRunPreservesFullVetCancellation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the controlled Go command fixture uses a POSIX shell")
}
installGoShim(t, "", true)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := Run(ctx, Request{
RepositoryDir: t.TempDir(),
Settings: Settings{FullVet: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoVet {
t.Fatalf("Run() error = %v, want a go-vet validation failure", err)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Run() error = %v, want deadline cause preserved", err)
}
}
func TestRunExecutesRaceEnabledSuiteOnHost(t *testing.T) {
if !raceSupported(runtime.GOOS, runtime.GOARCH) {
t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH)
}
cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output()
if err != nil || strings.TrimSpace(string(cgo)) != "1" {
t.Skip("Go race detector requires CGO_ENABLED=1")
}
cc, err := exec.Command("go", "env", "CC").Output()
if err != nil || len(strings.Fields(string(cc))) == 0 {
t.Skip("Go race detector requires a C compiler")
}
if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil {
t.Skip("Go race detector C compiler is unavailable")
}
module := t.TempDir()
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/racefixture\n\ngo 1.25.0\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(module, "race_test.go"), []byte(`package racefixture
import "testing"
func TestRaceFixture(t *testing.T) {}
`), 0o600); err != nil {
t.Fatal(err)
}
if err := Run(context.Background(), Request{RepositoryDir: module, Settings: Settings{Race: true}}); err != nil {
t.Fatalf("race-enabled Go suite failed: %v", err)
}
}
func installGoShim(t *testing.T, failure string, delayVet bool) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the controlled Go command fixture uses a POSIX shell")
}
directory := t.TempDir()
logPath := filepath.Join(directory, "go-commands")
envLogPath := filepath.Join(directory, "go-environment")
settings := map[string]string{
"GOHOSTOS": runtime.GOOS,
"GOHOSTARCH": runtime.GOARCH,
"CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "",
"CGO_CXXFLAGS": "", "CGO_ENABLED": "1", "CGO_LDFLAGS": "",
"GOCACHE": "", "GOCACHEPROG": "", "GOMODCACHE": "", "GOPATH": "",
"GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "",
}
encodedSettings, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
vetDelay := ""
if delayVet {
vetDelay = `
if [ "${1-}" = "vet" ]; then
exec sleep 5
fi
`
}
failBlock := fmt.Sprintf(`
if [ "${1-}" = "test" ]; then
for argument in "$@"; do
if [ "%s" = "race" ] && [ "$argument" = "-race" ]; then
echo "race detector prerequisite unavailable" >&2
exit 2
fi
done
if [ "%s" = "test" ]; then
echo "Go test failed" >&2
exit 2
fi
fi
if [ "${1-}" = "vet" ] && [ "%s" = "vet" ]; then
echo "Go vet failed" >&2
exit 2
fi
if [ "${1-}" = "build" ]; then
printf '%%s/%%s|%%s\n' "${GOOS-}" "${GOARCH-}" "${CGO_ENABLED-}" >> "$RELEASEKIT_GO_ENV_LOG"
if [ "%s" = "build" ] || [ "%s" = "${GOOS-}/${GOARCH-}" ]; then
echo "Go build failed" >&2
exit 2
fi
fi
%s
`, failure, failure, failure, failure, failure, vetDelay)
script := fmt.Sprintf(`#!/bin/sh
set -eu
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
if [ "${1-}" = "env" ]; then
printf '%%s\n' '%s'
exit 0
fi
%s
exit 0
`, encodedSettings, failBlock)
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)
t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath)
return logPath
}
func readGoShimLog(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":
return goarch == "amd64" || goarch == "arm64"
case "freebsd", "linux":
return goarch == "amd64" || goarch == "arm64" || goarch == "ppc64le"
case "windows":
return goarch == "amd64"
default:
return false
}
}

216
internal/validation/tree.go Normal file
View File

@@ -0,0 +1,216 @@
package validation
import (
"context"
"fmt"
"path"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
// CandidateTree is an inventory of one captured commit with blob contents
// loaded lazily by object ID.
type CandidateTree struct {
repositoryDir string
entries []git.TreeEntry
byPath map[string]int
directories map[string]struct{}
blobs map[string][]byte
}
// ReadCandidateTree enumerates the captured commit without reading from the
// worktree.
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
entries, err := git.ReadTree(ctx, repositoryDir, commit)
if err != nil {
return nil, err
}
tree := &CandidateTree{
repositoryDir: repositoryDir,
entries: entries,
byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}),
blobs: make(map[string][]byte),
}
tree.directories["."] = struct{}{}
for index, entry := range entries {
tree.byPath[entry.Path] = index
for directory := path.Dir(entry.Path); directory != "."; directory = path.Dir(directory) {
tree.directories[directory] = struct{}{}
}
}
return tree, nil
}
// Entries returns the committed tree metadata in Git's tree order.
func (tree *CandidateTree) Entries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.entries...)
}
// ReadFile returns the committed blob bytes for path. Contents are cached by
// object ID for later reads from this tree.
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
index, ok := tree.byPath[path]
if !ok {
return nil, fmt.Errorf("committed path %q does not exist", path)
}
entry := tree.entries[index]
if entry.Type != "blob" {
return nil, fmt.Errorf("committed path %q is a %s entry, not a blob", path, entry.Type)
}
if contents, ok := tree.blobs[entry.ObjectID]; ok {
return append([]byte(nil), contents...), nil
}
contents, err := git.ReadBlob(ctx, tree.repositoryDir, entry.ObjectID)
if err != nil {
return nil, fmt.Errorf("read committed path %q: %w", path, err)
}
tree.blobs[entry.ObjectID] = append([]byte(nil), contents...)
return contents, nil
}
// Entry returns the committed metadata for path when present.
func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
index, ok := tree.byPath[path]
if !ok {
return git.TreeEntry{}, false
}
return tree.entries[index], true
}
// 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 (tree *CandidateTree) 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 := 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
}
linkText, err := tree.ReadFile(ctx, partial)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := string(linkText)
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := tree.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := tree.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
delete(active, partial)
if err != nil || len(remaining) == 0 {
return destination, err
}
switch destination.Kind {
case DestinationDirectory:
return tree.resolveDestination(ctx, destination.Path, remaining, active)
case DestinationEntry:
destination.Kind = DestinationMissing
case DestinationSymlinkCycle:
return destination, nil
}
destination.Path = unresolvedPath(destination.Path, remaining)
return destination, nil
}
if len(remaining) != 0 {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
return Destination{Kind: DestinationEntry, Path: partial}, nil
}
return Destination{Kind: DestinationDirectory, Path: directory}, nil
}
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(tree.repositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, nil
}
// unresolvedPath preserves components that could not be traversed. Cleaning
// them would hide missing entries, non-directories, or checkout boundaries.
func unresolvedPath(base string, remaining []string) string {
if len(remaining) == 0 {
return base
}
return base + "/" + strings.Join(remaining, "/")
}

View File

@@ -0,0 +1,125 @@
package validation
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "README.md", []byte("captured\n"))
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("module vendor.example\n"))
writeTreeFile(t, repository, "nested/module/go.mod", []byte("module nested.example\n"))
writeTreeFile(t, repository, "binary.dat", []byte{0, 1, 255, 2})
unusualPath := "space tab\tline\nname.txt"
writeTreeFile(t, repository, unusualPath, []byte("unusual path\n"))
if err := os.Symlink("README.md", filepath.Join(repository, "readme-link")); err != nil {
t.Fatal(err)
}
submoduleCommit := newTreeRepository(t)
writeTreeFile(t, submoduleCommit, "sub.txt", []byte("submodule\n"))
gitTree(t, submoduleCommit, "add", "--all")
gitTree(t, submoduleCommit, "commit", "-m", "create submodule source")
submoduleOID := strings.TrimSpace(gitTree(t, submoduleCommit, "rev-parse", "HEAD"))
gitTree(t, repository, "add", "--all")
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",submodule")
gitTree(t, repository, "commit", "-m", "capture candidate files")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
writeTreeFile(t, repository, "README.md", []byte("worktree changed\n"))
statusBefore := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatalf("ReadCandidateTree() error = %v", err)
}
entries := make(map[string]gitTreeEntry, len(tree.Entries()))
for _, entry := range tree.Entries() {
entries[entry.Path] = gitTreeEntry{mode: entry.Mode, kind: entry.Type, size: entry.Size, hasSize: entry.HasSize}
}
for _, path := range []string{"vendor/library/go.mod", "nested/module/go.mod", unusualPath} {
if _, ok := entries[path]; !ok {
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["readme-link"]; got.mode != "120000" || got.kind != "blob" || !got.hasSize {
t.Errorf("symlink metadata = %#v", got)
}
if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize {
t.Errorf("gitlink metadata = %#v", got)
}
contents, err := tree.ReadFile(context.Background(), "README.md")
if err != nil || string(contents) != "captured\n" {
t.Fatalf("captured README = %q, error = %v", contents, err)
}
binary, err := tree.ReadFile(context.Background(), "binary.dat")
if err != nil || !bytes.Equal(binary, []byte{0, 1, 255, 2}) {
t.Fatalf("captured binary = %v, error = %v", binary, err)
}
unusual, err := tree.ReadFile(context.Background(), unusualPath)
if err != nil || string(unusual) != "unusual path\n" {
t.Fatalf("unusual path contents = %q, error = %v", unusual, err)
}
if _, err := tree.ReadFile(context.Background(), "submodule"); err == nil {
t.Fatal("ReadFile() returned submodule commit contents")
}
statusAfter := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
if statusAfter != statusBefore {
t.Fatalf("tree inspection changed worktree state: before %q, after %q", statusBefore, statusAfter)
}
}
func TestReadCandidateTreePreservesGitProcessError(t *testing.T) {
repository := newTreeRepository(t)
_, err := ReadCandidateTree(context.Background(), repository, strings.Repeat("f", 40))
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("ReadCandidateTree() error = %v, want Git process error preserved", err)
}
}
type gitTreeEntry struct {
mode string
kind string
size int64
hasSize bool
}
func newTreeRepository(t *testing.T) string {
t.Helper()
repository := t.TempDir()
gitTree(t, repository, "init", "-b", "main")
gitTree(t, repository, "config", "user.name", "ReleaseKit Test")
gitTree(t, repository, "config", "user.email", "releasekit@example.invalid")
return repository
}
func writeTreeFile(t *testing.T, repository, name string, content []byte) {
t.Helper()
path := filepath.Join(repository, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
}
func gitTree(t *testing.T, repository string, args ...string) string {
t.Helper()
command := exec.Command("git", args...)
command.Dir = repository
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return string(output)
}

View File

@@ -0,0 +1,227 @@
// Package validation owns the shared checks run against a captured candidate.
package validation
import (
"bytes"
"context"
"fmt"
"go/format"
"io"
"os"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
)
// Settings selects shared validation behavior.
type Settings struct {
Race bool
FullVet bool
CompileTargets []Target
GoFormat bool
Markdown *MarkdownSettings
ModuleDistribution ModuleDistributionSettings
FileSize *FileSizeSettings
}
// MarkdownSettings selects committed Markdown destination and fragment checks.
type MarkdownSettings struct {
EnforceCheckoutBoundary bool
}
// FileSizeSettings selects a per-blob limit and literal path exclusions.
type FileSizeSettings struct {
LimitBytes *int64
Exclusions []string
}
// ModuleDistributionSettings selects candidate distribution checks.
type ModuleDistributionSettings struct {
WorkspaceFiles bool
LocalReplacements bool
}
// Target selects one package-compilation target.
type Target struct {
OS string
Arch string
}
// Request identifies the captured source and output used by shared validation.
type Request struct {
RepositoryDir string
Commit string
Output io.Writer
Settings Settings
}
// 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)
}
// Run executes shared validation for the captured candidate.
func Run(ctx context.Context, request Request) error {
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
return &Failure{
Check: FileSize,
Findings: []Finding{{Message: "file size limit must not be negative"}},
}
}
for _, target := range request.Settings.CompileTargets {
if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") {
return &Failure{
Check: PackageCompile,
Findings: []Finding{{
Target: target.OS + "/" + target.Arch,
Message: "target must be one complete GOOS/GOARCH pair",
}},
}
}
}
tool, err := gotool.New(ctx, request.RepositoryDir)
if err != nil {
return &Failure{Check: GoTest, Cause: err}
}
args := []string{"test", "-mod=readonly", "-count=1"}
if request.Settings.Race {
args = append(args, "-race")
}
if request.Settings.FullVet {
args = append(args, "-vet=off")
}
args = append(args, "./...")
_, err = tool.Run(ctx, request.RepositoryDir, 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", "./...")
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,
"build", "-mod=readonly", "-o", os.DevNull, "./...")
if err != nil {
return &Failure{
Check: PackageCompile,
Findings: []Finding{{
Target: fmt.Sprintf("%s/%s", target.OS, target.Arch),
Message: "package compilation failed",
}},
Cause: err,
}
}
}
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil {
tree, err := request.ReadCandidateTree(ctx)
if err != nil {
check := MarkdownLinks
if request.Settings.FileSize != nil {
check = FileSize
}
if request.Settings.ModuleDistribution.LocalReplacements {
check = LocalReplacements
}
if request.Settings.ModuleDistribution.WorkspaceFiles {
check = WorkspaceFiles
}
if request.Settings.GoFormat {
check = GoFormat
}
return &Failure{Check: check, Cause: err}
}
if request.Settings.GoFormat {
if err := checkGoFormat(ctx, tree); err != nil {
return err
}
}
if request.Settings.ModuleDistribution.WorkspaceFiles {
if err := checkWorkspaceFiles(tree); err != nil {
return err
}
}
if request.Settings.ModuleDistribution.LocalReplacements {
if err := checkLocalReplacements(ctx, tree); err != nil {
return err
}
}
if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
return err
}
}
if request.Settings.Markdown != nil {
if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil {
return err
}
}
}
return nil
}
func checkWorkspaceFiles(tree *CandidateTree) error {
var findings []Finding
for _, entry := range tree.Entries() {
if entry.Type != "blob" {
continue
}
switch path.Base(entry.Path) {
case "go.work", "go.work.sum":
findings = append(findings, Finding{
Path: entry.Path,
Message: "workspace file is committed in the candidate",
})
}
}
if len(findings) != 0 {
return &Failure{Check: WorkspaceFiles, Findings: findings}
}
return nil
}
func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
var findings []Finding
for _, entry := range tree.Entries() {
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
continue
}
if err := ctx.Err(); err != nil {
return &Failure{Check: GoFormat, Cause: err}
}
contents, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return &Failure{
Check: GoFormat,
Findings: []Finding{{
Path: entry.Path,
Message: "unable to inspect committed Go source",
}},
Cause: fmt.Errorf("inspect committed Go file %q: %w", entry.Path, err),
}
}
formatted, err := format.Source(contents)
if err != nil {
return &Failure{
Check: GoFormat,
Findings: []Finding{{
Path: entry.Path,
Message: "committed Go source cannot be formatted",
}},
Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err),
}
}
if !bytes.Equal(contents, formatted) {
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
}
}
if len(findings) != 0 {
return &Failure{Check: GoFormat, Findings: findings}
}
return nil
}

View File

@@ -0,0 +1,100 @@
package validation
import (
"context"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func TestCheckWorkspaceFilesReportsCommittedBlobNames(t *testing.T) {
tree := &CandidateTree{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"},
}}
err := checkWorkspaceFiles(tree)
failure, ok := err.(*Failure)
if !ok || failure.Check != WorkspaceFiles {
t.Fatalf("checkWorkspaceFiles error = %v, want workspace-files failure", err)
}
want := map[string]bool{
"go.work": false,
"nested/go.work.sum": false,
"vendor/nested/link/go.work": false,
}
if len(failure.Findings) != len(want) {
t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want))
}
for _, finding := range failure.Findings {
if _, ok := want[finding.Path]; !ok {
t.Errorf("unexpected workspace path %q", finding.Path)
}
want[finding.Path] = true
}
for path, found := range want {
if !found {
t.Errorf("workspace path %q was not reported", path)
}
}
}
func TestCheckWorkspaceFilesAllowsOtherNames(t *testing.T) {
tree := &CandidateTree{entries: []git.TreeEntry{
{Path: "go.mod", Type: "blob", Mode: "100644"},
{Path: "examples/go.work.example", Type: "blob", Mode: "100644"},
}}
if err := checkWorkspaceFiles(tree); err != nil {
t.Fatalf("checkWorkspaceFiles error = %v, want no findings", err)
}
}
func TestRunWorkspaceFilesUsesCommittedTreeWithGoWorkspaceDisabled(t *testing.T) {
t.Setenv("GOWORK", "off")
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
writeTreeFile(t, repository, "nested/go.work.sum", []byte("committed workspace metadata\n"))
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture workspace files")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
writeTreeFile(t, repository, "untracked/go.work.sum", []byte("uncommitted workspace metadata\n"))
logPath := installGoShim(t, "", false)
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
t.Fatalf("default validation = %v, want workspace check disabled", err)
}
logPath = installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
WorkspaceFiles: true,
}},
})
failure, ok := err.(*Failure)
if !ok || failure.Check != WorkspaceFiles {
t.Fatalf("selected workspace error = %v, want workspace-files failure", err)
}
want := map[string]bool{"go.work": false, "nested/go.work.sum": false}
if len(failure.Findings) != len(want) {
t.Fatalf("workspace findings = %#v, want committed paths only", failure.Findings)
}
for _, finding := range failure.Findings {
if _, ok := want[finding.Path]; !ok {
t.Errorf("unexpected workspace path %q", finding.Path)
}
want[finding.Path] = true
}
for path, found := range want {
if !found {
t.Errorf("committed workspace path %q was not reported", path)
}
}
if got := strings.Join(readGoShimLog(t, logPath), "\n"); !strings.Contains(got, "test -mod=readonly -count=1 ./...") {
t.Fatalf("Go commands = %q, want ordinary test suite with GOWORK=off", got)
}
}

View File

@@ -3,8 +3,6 @@ package releasekit
import (
"context"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/publish"
)
@@ -50,35 +48,41 @@ type PublicationResult struct {
State PublicationState // publication outcome for this invocation
}
// Publish always runs the offline Go test suite for the captured candidate,
// then calls AdditionalChecks when supplied. It optionally builds selected
// targets in temporary storage outside the worktree and pushes the exact
// annotated tag object to the selected remote. It returns a result with errors: preflight
// failures are not_attempted, failed authoritative guards are conflict, and
// errors after local tag creation but before push are not_published. A failed
// push gets one bounded authoritative tag query: an exact object match confirms
// publication, absence is not_published, a different object is conflict, and
// an unreadable result is unknown. A nil error means publication is confirmed
// by the successful push or exact remote object match.
// After publication preflight succeeds, Publish runs one offline Go test suite
// for the captured candidate.
// Validation.Go.Race enables the race detector for that suite,
// Validation.Go.FullVet runs go vet afterward instead of test-time vet, and
// Validation.Go.CompileTargets compiles all packages for each selected target
// with cgo disabled after tests and vet. Compilation produces no artifacts and
// does not execute target binaries. The selected formatting, distribution,
// file-size, and Markdown checks then run before AdditionalChecks. Markdown is
// disabled when nil and enabled by a non-nil specification; outside-checkout
// destinations are reported as out of scope unless boundary enforcement is
// selected. Submodule contents remain out of scope. ExpectedPushURL, when
// nonempty, is compared exactly with the resolved effective push URL before
// validation; a mismatch returns ErrUnexpectedPushURL. It then calls
// AdditionalChecks when supplied.
// It optionally builds selected targets in temporary storage outside the
// worktree and pushes the exact annotated tag object to the selected remote.
// It returns a result with errors: preflight failures are not_attempted, failed
// authoritative guards are conflict, and errors after local tag creation but
// before push are not_published. A failed push gets one bounded authoritative
// tag query: an exact object match confirms publication, absence is
// not_published, a different object is conflict, and an unreadable result is
// unknown. A nil error means publication is confirmed by the successful push
// or exact remote object match.
func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, error) {
var validate func(context.Context, candidate.Candidate) error
if request.AdditionalChecks != nil {
validate = func(ctx context.Context, captured candidate.Candidate) error {
return request.AdditionalChecks(ctx, Candidate{
RepositoryDir: captured.RepositoryDir,
Commit: captured.Commit,
Version: captured.Version,
})
}
}
candidateInput := candidateRequest(request)
result, err := publish.Publish(ctx, publish.Request{
RepositoryDir: request.RepositoryDir,
Product: request.Product,
Version: request.Version,
RepositoryDir: candidateInput.RepositoryDir,
Product: candidateInput.Product,
Version: candidateInput.Version,
Remote: request.Remote,
AdditionalChecks: validate,
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),
Output: request.Output,
ExpectedPushURL: request.ExpectedPushURL,
Validation: candidateInput.Validation,
AdditionalChecks: candidateInput.AdditionalChecks,
AfterValidate: candidateInput.AfterValidate,
Output: candidateInput.Output,
})
return PublicationResult{
Version: result.Version,
@@ -87,5 +91,5 @@ func Publish(ctx context.Context, request ReleaseRequest) (PublicationResult, er
Phase: PublicationPhase(result.Phase),
TagObject: result.TagObject,
State: PublicationState(result.State),
}, err
}, convertValidationError(err)
}

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -93,6 +94,132 @@ func TestPublishPushesOnlyTheCapturedAnnotatedTag(t *testing.T) {
}
}
func TestPublishRejectsUnexpectedPushURLBeforeValidation(t *testing.T) {
repo := newPublishFixture(t)
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
t.Setenv("RELEASEKIT_FIXTURE_SUITE_RUNS", suiteRuns)
expected := repo.bare + string(os.PathSeparator)
var callbacks int
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
ExpectedPushURL: expected,
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
callbacks++
return nil
},
})
if !errors.Is(err, releasekit.ErrUnexpectedPushURL) {
t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err)
}
if !strings.Contains(err.Error(), fmt.Sprintf("expected %q", expected)) || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) {
t.Fatalf("Publish() error = %v, want expected and resolved URLs", err)
}
if result.State != releasekit.PublicationNotAttempted || result.Phase != releasekit.PublicationPreflight || result.Endpoint != repo.bare || result.TagObject != "" {
t.Fatalf("publication result = %#v, want preflight failure at resolved endpoint", result)
}
if callbacks != 0 {
t.Fatalf("additional checks ran %d times, want none", callbacks)
}
if _, statErr := os.Stat(suiteRuns); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("standard-suite marker stat error = %v, want no suite run", statErr)
}
if got := gitText(t, repo.work, "tag", "--list", "v1.2.3"); got != "" {
t.Fatalf("local tag exists after URL mismatch: %q", got)
}
}
func TestPublishComparesExpectedURLWithResolvedInsteadOfDestination(t *testing.T) {
repo := newPublishFixture(t)
alias := "releasekit-expected-push:"
gitRun(t, repo.work, "config", "url."+repo.bare+".insteadOf", alias)
gitRun(t, repo.work, "remote", "set-url", "origin", alias)
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
ExpectedPushURL: repo.bare,
})
if err != nil {
t.Fatalf("Publish() with the resolved insteadOf destination failed: %v", err)
}
if result.Endpoint != repo.bare || result.State != releasekit.PublicationPublished {
t.Fatalf("publication result = %#v, want resolved push URL and successful publication", result)
}
}
func TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer(t *testing.T) {
repo := newPublishFixture(t)
fetchURL := filepath.Join(t.TempDir(), "fetch-only.git")
gitRun(t, repo.work, "remote", "set-url", "origin", fetchURL)
gitRun(t, repo.work, "config", "remote.origin.pushurl", repo.bare)
result, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
ExpectedPushURL: fetchURL,
})
if !errors.Is(err, releasekit.ErrUnexpectedPushURL) {
t.Fatalf("Publish() error = %v, want ErrUnexpectedPushURL", err)
}
if result.Endpoint != repo.bare || !strings.Contains(err.Error(), fmt.Sprintf("resolved %q", repo.bare)) {
t.Fatalf("result = %#v, error = %v; want the effective push URL %q", result, err, repo.bare)
}
}
func TestCheckIgnoresExpectedPushURL(t *testing.T) {
repo := newPublishFixture(t)
var callbacks int
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
ExpectedPushURL: "unrelated destination",
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
callbacks++
return nil
},
})
if err != nil {
t.Fatalf("Check() considered publication URL expectation: %v", err)
}
if callbacks != 1 {
t.Fatalf("additional checks ran %d times, want one", callbacks)
}
}
func TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks(t *testing.T) {
requireRacePrerequisites(t)
repo := newPublishFixture(t)
raceMarker := filepath.Join(t.TempDir(), "race-enabled")
t.Setenv("RELEASEKIT_RACE_MARKER", raceMarker)
checks := 0
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true},
},
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
checks++
return nil
},
})
if err != nil {
t.Fatalf("Check() error = %v", err)
}
if checks != 1 {
t.Fatalf("additional checks ran %d times, want once", checks)
}
if _, err := os.Stat(raceMarker); err != nil {
t.Fatalf("race-enabled fixture did not run: %v", err)
}
}
func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) {
repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
@@ -109,6 +236,17 @@ func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testin
if err == nil || !strings.Contains(err.Error(), "fixture standard suite failure") {
t.Fatalf("Publish() error = %v, want fixture standard suite failure", err)
}
var validationError *releasekit.ValidationError
if !errors.As(err, &validationError) || validationError.Check != "go-test" {
t.Fatalf("Publish() error = %v, want a public go-test validation error", err)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Publish() error = %v, want the Go process error preserved", err)
}
if !errors.Is(err, exitErr) {
t.Fatal("Publish() error does not preserve the Go process error identity")
}
if checks != 0 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted {
t.Fatalf("failed standard suite result=%#v additional checks=%d", result, checks)
}
@@ -122,6 +260,104 @@ func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testin
}
}
func requireRacePrerequisites(t *testing.T) {
t.Helper()
supported := runtime.GOOS == "darwin" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64") ||
(runtime.GOOS == "freebsd" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "ppc64le") ||
runtime.GOOS == "windows" && runtime.GOARCH == "amd64"
if !supported {
t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH)
}
cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output()
if err != nil || strings.TrimSpace(string(cgo)) != "1" {
t.Skip("Go race detector requires CGO_ENABLED=1")
}
cc, err := exec.Command("go", "env", "CC").Output()
if err != nil || len(strings.Fields(string(cc))) == 0 {
t.Skip("Go race detector requires a C compiler")
}
if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil {
t.Skip("Go race detector C compiler is unavailable")
}
}
func TestCheckReturnsPublicValidationError(t *testing.T) {
repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1")
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
})
var validationError *releasekit.ValidationError
if !errors.As(err, &validationError) || validationError.Check != "go-test" {
t.Fatalf("Check() error = %v, want a public go-test validation error", err)
}
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Check() error = %v, want the Go process error preserved", err)
}
if !errors.Is(err, exitErr) {
t.Fatal("Check() error does not preserve the Go process error identity")
}
}
func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) {
addBrokenMarkdownDestination := func(t *testing.T, repo publishFixture) {
t.Helper()
writeFile(t, repo.work, "guide.md", "[missing](missing.md#top)\n")
gitRun(t, repo.work, "add", "guide.md")
gitRun(t, repo.work, "commit", "-m", "add broken Markdown destination")
}
assertMarkdownFailure := func(t *testing.T, err error) {
t.Helper()
var failure *releasekit.ValidationError
if !errors.As(err, &failure) || failure.Check != "markdown-links" {
t.Fatalf("operation error = %v, want public markdown-links failure", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "missing.md#top" || failure.Findings[0].Line != 1 {
t.Fatalf("Markdown findings = %#v, want source location and target", failure.Findings)
}
}
t.Run("check", func(t *testing.T) {
repo := newPublishFixture(t)
addBrokenMarkdownDestination(t, repo)
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
})
assertMarkdownFailure(t, err)
})
t.Run("publish", func(t *testing.T) {
repo := newPublishFixture(t)
addBrokenMarkdownDestination(t, repo)
gitRun(t, repo.work, "config", "--unset", "remote.origin.mirror")
gitRun(t, repo.work, "push", "origin", "HEAD:refs/heads/main")
callbackCalls := 0
_, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
callbackCalls++
return nil
},
})
assertMarkdownFailure(t, err)
if callbackCalls != 0 {
t.Fatalf("additional checks called %d times after Markdown failure", callbackCalls)
}
if tag := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tag != "" {
t.Fatalf("failed Markdown validation created tag %q", tag)
}
})
}
func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) {
repo := newPublishFixture(t)
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
@@ -140,6 +376,10 @@ func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *te
if !errors.Is(err, wantErr) {
t.Fatalf("Publish() error = %v, want wrapped additional-check error", err)
}
var validationError *releasekit.ValidationError
if errors.As(err, &validationError) {
t.Fatalf("Publish() callback error was reclassified as validation failure: %v", err)
}
if runs, readErr := os.ReadFile(suiteRuns); readErr != nil || string(runs) != "ran\n" {
t.Fatalf("standard suite runs = %q, error = %v; want one successful run", runs, readErr)
}
@@ -591,6 +831,25 @@ func TestFixture(t *testing.T) {
t.Fatal("fixture standard suite failure")
}
}
`)
writeFile(t, work, "race_fixture_test.go", `//go:build race
package fixture
import (
"os"
"testing"
)
func TestRaceBuildSelection(t *testing.T) {
marker := os.Getenv("RELEASEKIT_RACE_MARKER")
if marker == "" {
return
}
if err := os.WriteFile(marker, []byte("race enabled"), 0o600); err != nil {
t.Fatal(err)
}
}
`)
writeFile(t, work, "docs/releases/v1.2.3.md", releaseNote)
writeFile(t, work, "source.txt", "candidate")

127
validation_error.go Normal file
View File

@@ -0,0 +1,127 @@
package releasekit
import (
"errors"
"slices"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
// ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
// "workspace-files", "local-replacements", "file-size", and "markdown-links".
type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional
// repository-relative source location.
type ValidationFinding struct {
Path string // repository-relative path when available
Line int // one-based source line, or zero when unavailable
Column int // one-based source column, or zero when unavailable
Message string // useful detail that is not promised to have exact wording
Target string // applicable target for a file check
Directive string // applicable directive for a file check
}
// FileSizeMeasurement reports a measured file size and its selected limit.
type FileSizeMeasurement struct {
Bytes int64
LimitBytes int64
}
// ValidationError describes a failed shared validation check returned by
// [Check] or [Publish]. Cause preserves the contextual and underlying error chain.
type ValidationError struct {
Check ValidationCheck // failed shared check
Findings []ValidationFinding // actionable observations, when available
Measurement *FileSizeMeasurement // measured file size, when applicable
Cause error // original contextual and underlying error chain
}
// Error returns check details alongside the contextual and underlying causes.
func (failure *ValidationError) Error() string {
if failure == nil {
return "<nil>"
}
diagnostic := internalValidationFailure(failure)
var original *validation.Failure
if errors.As(failure.Cause, &original) && sameValidationDiagnostics(diagnostic, original) {
// Converted causes already render these diagnostics and candidate context.
return failure.Cause.Error()
}
return diagnostic.Error()
}
// Unwrap returns the original validation error chain.
func (failure *ValidationError) Unwrap() error {
if failure == nil {
return nil
}
return failure.Cause
}
func convertValidationError(err error) error {
if err == nil {
return nil
}
var publicFailure *ValidationError
if errors.As(err, &publicFailure) {
return err
}
var failure *validation.Failure
if !errors.As(err, &failure) {
return err
}
result := &ValidationError{
Check: ValidationCheck(failure.Check),
Cause: err,
}
if len(failure.Findings) != 0 {
result.Findings = make([]ValidationFinding, len(failure.Findings))
for index, finding := range failure.Findings {
result.Findings[index] = ValidationFinding{
Path: finding.Path,
Line: finding.Line,
Column: finding.Column,
Message: finding.Message,
Target: finding.Target,
Directive: finding.Directive,
}
}
}
if failure.Measurement != nil {
result.Measurement = &FileSizeMeasurement{
Bytes: failure.Measurement.Bytes,
LimitBytes: failure.Measurement.LimitBytes,
}
}
return result
}
func internalValidationFailure(failure *ValidationError) *validation.Failure {
result := &validation.Failure{Check: validation.Check(failure.Check), Cause: failure.Cause}
for _, finding := range failure.Findings {
result.Findings = append(result.Findings, validation.Finding{
Path: finding.Path, Line: finding.Line, Column: finding.Column,
Message: finding.Message, Target: finding.Target, Directive: finding.Directive,
})
}
if failure.Measurement != nil {
result.Measurement = &validation.FileSizeMeasurement{
Bytes: failure.Measurement.Bytes, LimitBytes: failure.Measurement.LimitBytes,
}
}
return result
}
func sameValidationDiagnostics(a, b *validation.Failure) bool {
if b == nil || a.Check != b.Check || !slices.Equal(a.Findings, b.Findings) {
return false
}
if a.Measurement == nil || b.Measurement == nil {
return a.Measurement == b.Measurement
}
return *a.Measurement == *b.Measurement
}
var _ error = (*ValidationError)(nil)

62
validation_error_test.go Normal file
View File

@@ -0,0 +1,62 @@
package releasekit_test
import (
"context"
"errors"
"strings"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
)
func TestValidationErrorPresentsFindingsAlongsideCause(t *testing.T) {
cause := errors.New("inspection failed")
for _, withCause := range []bool{false, true} {
diagnostic := &releasekit.ValidationError{
Check: "file-size",
Findings: []releasekit.ValidationFinding{{Path: "main.go", Line: 3, Column: 2, Message: "oversized source", Target: "linked.go", Directive: "example directive"}},
Measurement: &releasekit.FileSizeMeasurement{Bytes: 12, LimitBytes: 10},
}
if withCause {
diagnostic.Cause = cause
}
for _, part := range []string{"file-size", "main.go:3:2", "oversized source", "linked.go", "example directive", "12 bytes", "10 byte limit"} {
if !strings.Contains(diagnostic.Error(), part) {
t.Errorf("withCause=%t: %q omits %q", withCause, diagnostic.Error(), part)
}
}
if withCause && (!errors.Is(diagnostic, cause) || !strings.Contains(diagnostic.Error(), cause.Error())) {
t.Fatalf("diagnostic lost its cause: %v", diagnostic)
}
}
}
func TestCandidateOperationsPreserveCallbackValidationDiagnostics(t *testing.T) {
repo := newPublishFixture(t)
cause := errors.New("example execution failed")
diagnostic := &releasekit.ValidationError{
Check: "examples",
Findings: []releasekit.ValidationFinding{{Path: "examples/run/main.go", Message: "unexpected output"}},
Cause: cause,
}
request := releasekit.ReleaseRequest{
RepositoryDir: repo.work, Product: "Sample App", Version: "v1.2.3",
AdditionalChecks: func(context.Context, releasekit.Candidate) error { return diagnostic },
}
_, checkError := releasekit.Check(t.Context(), request)
result, publishError := releasekit.Publish(t.Context(), request)
for _, err := range []error{checkError, publishError} {
var got *releasekit.ValidationError
if !errors.As(err, &got) || got != diagnostic || !errors.Is(err, cause) {
t.Fatalf("callback diagnostic identity or cause lost: %v", err)
}
for _, part := range []string{"validate candidate", "examples/run/main.go", "unexpected output", cause.Error()} {
if !strings.Contains(err.Error(), part) {
t.Errorf("callback error %q omits %q", err, part)
}
}
}
if result.State != releasekit.PublicationNotAttempted || result.TagObject != "" {
t.Fatalf("callback failure publication result = %#v", result)
}
}

156
validation_test.go Normal file
View File

@@ -0,0 +1,156 @@
package releasekit
import (
"errors"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
)
func TestCandidateRequestMapsGoValidation(t *testing.T) {
limit := int64(4096)
request := candidateRequest(ReleaseRequest{
Validation: ValidationSpec{Go: GoValidationSpec{
Race: true, FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
}, GoFormat: true, Markdown: &MarkdownValidationSpec{EnforceCheckoutBoundary: true}, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &FileSizeValidationSpec{LimitBytes: &limit, Exclusions: []string{"assets"}}},
})
if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat || !request.Validation.ModuleDistribution.WorkspaceFiles || !request.Validation.ModuleDistribution.LocalReplacements {
t.Fatalf("candidate request validation = %#v, want race, full vet, formatting, and distribution checks enabled", request.Validation)
}
if request.Validation.Markdown == nil || !request.Validation.Markdown.EnforceCheckoutBoundary {
t.Fatalf("candidate request Markdown = %#v, want boundary enforcement enabled", request.Validation.Markdown)
}
if request.Validation.FileSize == nil || request.Validation.FileSize.LimitBytes == nil || *request.Validation.FileSize.LimitBytes != limit || len(request.Validation.FileSize.Exclusions) != 1 || request.Validation.FileSize.Exclusions[0] != "assets" {
t.Fatalf("candidate request file size = %#v, want configured limit and exclusions", request.Validation.FileSize)
}
want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want)
}
for index := range want {
if request.Validation.CompileTargets[index] != want[index] {
t.Fatalf("candidate request compile targets = %#v, want %#v", request.Validation.CompileTargets, want)
}
}
}
func TestCandidateRequestPreservesMarkdownObjectSelection(t *testing.T) {
for _, test := range []struct {
name string
spec *MarkdownValidationSpec
want bool
}{
{name: "omitted"},
{name: "empty object", spec: &MarkdownValidationSpec{}, want: true},
} {
t.Run(test.name, func(t *testing.T) {
request := candidateRequest(ReleaseRequest{Validation: ValidationSpec{Markdown: test.spec}})
if got := request.Validation.Markdown != nil; got != test.want {
t.Fatalf("Markdown selected = %t, want %t", got, test.want)
}
})
}
}
func TestCandidateRequestPreservesFileSizeObjectSelection(t *testing.T) {
disabled := candidateRequest(ReleaseRequest{})
enabled := candidateRequest(ReleaseRequest{Validation: ValidationSpec{FileSize: &FileSizeValidationSpec{}}})
if disabled.Validation.FileSize != nil {
t.Fatalf("omitted FileSize = %#v, want disabled", disabled.Validation.FileSize)
}
if enabled.Validation.FileSize == nil || enabled.Validation.FileSize.LimitBytes != nil {
t.Fatalf("empty FileSize = %#v, want enabled default selection", enabled.Validation.FileSize)
}
}
func TestValidationErrorPreservesLocalReplacementsIdentityAndFinding(t *testing.T) {
cause := errors.New("module declaration could not be read")
got := convertValidationError(&validation.Failure{
Check: validation.LocalReplacements,
Findings: []validation.Finding{{
Path: "nested/go.mod",
Line: 4,
Target: "../local",
Directive: "replace",
Message: "filesystem module replacement is committed",
}},
Cause: cause,
})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "local-replacements" {
t.Fatalf("converted error = %#v, want local-replacements identity", got)
}
if len(validationError.Findings) != 1 || validationError.Findings[0].Path != "nested/go.mod" || validationError.Findings[0].Line != 4 || validationError.Findings[0].Target != "../local" || validationError.Findings[0].Directive != "replace" {
t.Fatalf("converted findings = %#v, want path, line, target, and directive", validationError.Findings)
}
if !errors.Is(got, cause) {
t.Fatalf("converted error = %v, want cause preserved", got)
}
}
func TestValidationErrorPreservesFileSizeMeasurement(t *testing.T) {
got := convertValidationError(&validation.Failure{
Check: validation.FileSize,
Findings: []validation.Finding{{Path: "assets/bundle.bin", Message: "committed file exceeds the selected size limit"}},
Measurement: &validation.FileSizeMeasurement{Bytes: 12, LimitBytes: 10},
})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "file-size" {
t.Fatalf("converted error = %#v, want file-size identity", got)
}
if validationError.Measurement == nil || validationError.Measurement.Bytes != 12 || validationError.Measurement.LimitBytes != 10 {
t.Fatalf("converted measurement = %#v, want 12 bytes against 10", validationError.Measurement)
}
}
func TestValidationErrorPreservesGoVetIdentity(t *testing.T) {
cause := errors.New("vet failed")
got := convertValidationError(&validation.Failure{Check: validation.GoVet, Cause: cause})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "go-vet" {
t.Fatalf("converted error = %#v, want go-vet validation identity", got)
}
if !errors.Is(got, cause) {
t.Fatalf("converted error = %v, want cause preserved", got)
}
}
func TestValidationErrorPreservesGoFormatIdentityAndFindings(t *testing.T) {
cause := errors.New("format parser failed")
got := convertValidationError(&validation.Failure{
Check: validation.GoFormat,
Findings: []validation.Finding{{
Path: "vendor/example/bad.go",
Message: "committed Go source cannot be formatted",
}},
Cause: cause,
})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "go-format" {
t.Fatalf("converted error = %#v, want go-format validation identity", got)
}
if len(validationError.Findings) != 1 || validationError.Findings[0].Path != "vendor/example/bad.go" {
t.Fatalf("converted findings = %#v, want vendor path", validationError.Findings)
}
if !errors.Is(got, cause) {
t.Fatalf("converted error = %v, want parser cause preserved", got)
}
}
func TestValidationErrorPreservesWorkspaceFilesIdentity(t *testing.T) {
got := convertValidationError(&validation.Failure{
Check: validation.WorkspaceFiles,
Findings: []validation.Finding{{
Path: "nested/go.work.sum",
Message: "workspace file is committed in the candidate",
}},
})
var validationError *ValidationError
if !errors.As(got, &validationError) || validationError.Check != "workspace-files" {
t.Fatalf("converted error = %#v, want workspace-files validation identity", got)
}
if len(validationError.Findings) != 1 || validationError.Findings[0].Path != "nested/go.work.sum" {
t.Fatalf("converted findings = %#v, want nested workspace path", validationError.Findings)
}
}