82 Commits

Author SHA1 Message Date
e43df4416e Remove completed roadmaps and repair the benchmark link 2026-10-09 03:34:31 +00:00
9b51447acd Align COPYRIGHT with the upstream library template 2026-10-09 03:32:52 +00:00
80ec9e3fb4 Adopt release policy 1.6 and preserve local release conventions 2026-10-09 03:29:37 +00:00
86b73db71f Raise the Go minimum to 1.26.0 2026-10-09 03:20:21 +00:00
52e3a389bb Add release notes and upgrade guidance for v0.3.0 2026-10-09 03:17:16 +00:00
cf9e451ecd Reconcile completed performance and remediation roadmaps 2026-10-09 03:09:21 +00:00
6376ef23f1 Measure output capture allocations for chunked writes 2026-10-09 03:08:51 +00:00
7d92c477c2 Attribute reader shutdown failures to checks that read content 2026-10-09 03:07:13 +00:00
6eec022bb0 Preserve validation cancellation during reader shutdown 2026-10-09 03:06:06 +00:00
15dd58f5ac Record performance implementation review findings 2026-10-09 02:15:56 +00:00
df064bd1d3 Record final performance implementation results 2026-10-09 01:55:56 +00:00
42569b4af2 Cache runtime version metadata fallback 2026-10-09 01:46:07 +00:00
50d3573257 Transfer completed Go output without copies 2026-10-09 01:44:33 +00:00
39196a525c Use one delivery asset verification pass 2026-10-09 01:40:43 +00:00
cfd8ba7cb2 Reuse parsed validation content by blob 2026-10-09 01:37:33 +00:00
182da397e3 Release validation source bytes after checks 2026-10-09 01:33:21 +00:00
a3e993d95a Scope candidate blob readers to validation 2026-10-09 01:30:30 +00:00
33e10635b6 Add local batched Git blob reader 2026-10-09 01:23:00 +00:00
618a7c9dd3 Add piped child process support 2026-10-09 01:17:58 +00:00
41e58ef894 Measure delivery output and version costs 2026-10-09 01:11:07 +00:00
29e1bab228 Record validation performance baseline 2026-10-09 01:02:05 +00:00
ca571aca4f Plan the computational efficiency implementation 2026-10-09 00:42:08 +00:00
ff90510c38 Record the computational efficiency roadmap 2026-10-09 00:10:13 +00:00
db9e24f7dc Document versioned Go tool adoption for CLI consumers 2026-10-08 16:42:18 +00:00
4bb480f990 Separate the Go minimum from the release build toolchain 2026-10-08 16:29:48 +00:00
d51a0ada5e Record successful PromptKit cache recovery 2026-10-08 16:10:23 +00:00
f2124b3b7b Isolate synthetic module downloads in workflow tests 2026-10-08 14:30:08 +00:00
ae0e4a0263 Document checksum recovery and synthetic module cache isolation 2026-10-08 14:25:46 +00:00
c7942210d5 Record PromptKit checksum investigation findings 2026-10-08 14:23:24 +00:00
c339872bfe Report PromptKit's v0.2.0 checksum mismatch 2026-10-08 14:15:00 +00:00
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
89 changed files with 9116 additions and 661 deletions

View File

@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details. GNU Lesser General Public License for more details.
Third-party code and assets are subject to their respective license terms. Third-party code and assets are subject to their respective license terms.
Policy documents adapted from Standard Repository Policies, the root AGENTS.md
orientation, and the Policy Reading Guide in docs/development.md remain licensed
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
upstream credit, and license links.

View File

@@ -6,12 +6,11 @@ local candidate check, source-tag publication API, selected-target build API,
tagged-source delivery API, and the `version`, `check`, `publish`, `build`, tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
`deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only `deliver`, and `ci-deliver` commands are implemented. ReleaseKit's source-only
maintainer procedure and prepared Woodpecker workflow for hosted note-page maintainer procedure and prepared Woodpecker workflow for hosted note-page
delivery are documented. ReleaseKit does not declare binary deliverables. The delivery are documented. ReleaseKit does not declare binary deliverables.
[product roadmap](docs/roadmap/releasekit.md) records remaining adoption and
deployment work.
See the [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline See the [consumer adoption guide](docs/consumers/adoption.md) to add ReleaseKit
and installation commands. as a versioned Go tool or package dependency, and the
[v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline.
Run the offline Go test suite from the repository root with: Run the offline Go test suite from the repository root with:
@@ -26,7 +25,8 @@ go run ./cmd/releasekit version
``` ```
The local candidate check validates a committed release note, runs the standard 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 ```sh
go run ./cmd/releasekit check v1.2.3 go run ./cmd/releasekit check v1.2.3
@@ -43,8 +43,8 @@ failure recovery.
## Documentation ## Documentation
- [Product roadmap](docs/roadmap/releasekit.md): consumer adoption goals and - [Consumer adoption](docs/consumers/adoption.md): versioned CLI tool and Go API
remaining release and deployment work. dependencies, upgrades, and CI adoption.
- [Contributor guide](docs/development.md): orientation, validation, and selective - [Contributor guide](docs/development.md): orientation, validation, and selective
policy reading. policy reading.
- [CLI contract](docs/cli.md): the `version`, `check`, `publish`, `build`, - [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) { func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
repository := newPublishFixture(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 prepareCandidateBuild(t, repository.work, `package main
import ( import (
"fmt" "fmt"
@@ -445,6 +448,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
RepositoryDir: repository.work, RepositoryDir: repository.work,
Product: "Sample App", Product: "Sample App",
Version: "v1.2.3", Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
AdditionalChecks: func(context.Context, releasekit.Candidate) error { AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++ validations++
return nil return nil
@@ -512,7 +516,7 @@ func newBuildConsumer(t *testing.T, mainSource string) string {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) module := fmt.Sprintf("module example.com/buildfixture\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
writeBuildFixture(t, repository, "go.mod", module) writeBuildFixture(t, repository, "go.mod", module)
writeBuildFixture(t, repository, "cmd/app/main.go", mainSource) writeBuildFixture(t, repository, "cmd/app/main.go", mainSource)
writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n") writeBuildFixture(t, repository, "cmd/app/main_test.go", "package main\nimport \"testing\"\nfunc TestCandidateFixture(t *testing.T) {}\n")
@@ -530,7 +534,7 @@ func prepareCandidateBuild(t *testing.T, repository, mainSource string) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) module := fmt.Sprintf("module example.com/candidatebuild\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
writeFile(t, repository, "go.mod", module) writeFile(t, repository, "go.mod", module)
writeFile(t, repository, ".gitignore", "dist/\n") writeFile(t, repository, ".gitignore", "dist/\n")
writeFile(t, repository, "cmd/app/main.go", mainSource) writeFile(t, repository, "cmd/app/main.go", mainSource)

View File

@@ -19,7 +19,7 @@ func TestVersionInConsumerExecutable(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
module := fmt.Sprintf(`module example.com/versionfixture module := fmt.Sprintf(`module example.com/versionfixture
go 1.25.0 go 1.26.0
require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0 require gitea.maximumdirect.net/eric/ReleaseKit v0.0.0

195
check.go
View File

@@ -6,11 +6,17 @@ import (
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build" internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "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. // ErrInvalidVersion reports a version that is not stable vMAJOR.MINOR.PATCH.
var ErrInvalidVersion = candidate.ErrInvalidVersion 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 // Candidate identifies one committed local source revision selected for
// validation. // validation.
type Candidate struct { type Candidate struct {
@@ -23,45 +29,117 @@ type Candidate struct {
// local candidate. Remote is retained for publication operations; Check makes // local candidate. Remote is retained for publication operations; Check makes
// no remote availability claim. // no remote availability claim.
type ReleaseRequest struct { type ReleaseRequest struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
// AdditionalChecks optionally runs product checks after the standard suite. // 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 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 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 // Check validates one local, committed release candidate. It requires a clean
// worktree and index on main, a committed note matching Product and Version, // worktree and index on main, a committed note matching Product and Version,
// and an unused local tag. It always runs the offline Go test suite with the // 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 // available compatible local Go toolchain and Go host target, then calls
// AdditionalChecks when supplied. If Build selects targets, Check builds them // AdditionalChecks when supplied. Validation.Go.Race runs that one suite with
// after validation in temporary storage outside the worktree, then rechecks // 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 // branch, HEAD, worktree, index, and local tag state. Success establishes local
// candidate evidence only; it does not establish remote availability. // candidate evidence only; it does not establish remote availability.
func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) { func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
var validate func(context.Context, candidate.Candidate) error checked, err := candidate.Check(ctx, candidateRequest(request))
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,
})
if err != nil { if err != nil {
return Candidate{}, err return Candidate{}, convertValidationError(err)
} }
return Candidate{ return Candidate{
RepositoryDir: checked.RepositoryDir, RepositoryDir: checked.RepositoryDir,
@@ -69,3 +147,66 @@ func Check(ctx context.Context, request ReleaseRequest) (Candidate, error) {
Version: checked.Version, Version: checked.Version,
}, nil }, 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"` Env map[string]string `json:"env,omitempty"`
} }
// Settings contains the product, remote, and check commands read from a // Settings contains the product, remote, publication expectation, shared
// releasekit.json profile. // validation selections, and check commands read from a releasekit.json profile.
type Settings struct { type Settings struct {
Product string `json:"product"` Product string `json:"product"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` ExpectedPushURL string `json:"expected_push_url,omitempty"`
Build *BuildSettings `json:"build,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
Delivery *DeliverySettings `json:"delivery,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. // 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 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) { func TestExampleProfileLoads(t *testing.T) {
repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", "..")) repoDir, err := filepath.Abs(filepath.Join("..", "..", "..", ".."))
if err != nil { 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: "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 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: "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: "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: "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"}, {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) fmt.Fprintf(stderr, "load profile: %v\n", err)
return 2 return 2
} }
request := releasekit.ReleaseRequest{ request := candidateRequest(invocation, settings, stderr)
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)
}
}
if invocation.Command == cli.PublishAction { if invocation.Command == cli.PublishAction {
request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version)
result, err := releasekit.Publish(ctx, request) result, err := releasekit.Publish(ctx, request)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "publication state=%s phase=%s version=%s commit=%s tag-object=%s endpoint=%q: %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) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -75,7 +60,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
candidate, err := releasekit.Check(ctx, request) candidate, err := releasekit.Check(ctx, request)
if err != nil { 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) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -149,6 +134,71 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 2 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 { func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil { if err != nil {

View File

@@ -17,6 +17,7 @@ import (
"strings" "strings"
"testing" "testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo" "gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
) )
@@ -48,6 +49,7 @@ func TestUnknownCommand(t *testing.T) {
} }
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) repository := newCLIRepository(t)
profilePath := filepath.Join(t.TempDir(), "profile with spaces.json") profilePath := filepath.Join(t.TempDir(), "profile with spaces.json")
orderPath := filepath.Join(t.TempDir(), "check-order") orderPath := filepath.Join(t.TempDir(), "check-order")
@@ -72,6 +74,9 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
Remote: "origin", Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")}, Checks: []cli.CheckCommand{check("first"), check("second")},
} }
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings) contents, err := json.Marshal(settings)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -81,6 +86,11 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
var stdout, stderr bytes.Buffer 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"} args := []string{"check", "--repo", repository, "--config", profilePath, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
@@ -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 { 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) 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) { func TestPublishCommandPublishesConfiguredCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) 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, "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 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, "add", "docs/releases/v1.2.3.md", "scripts/check-release-note.sh")
cliGitRun(t, repository, "commit", "-m", "add compatibility note and check") cliGitRun(t, repository, "commit", "-m", "add compatibility note and check")
checkLog := filepath.Join(t.TempDir(), "compatibility-check.log") checkLog := filepath.Join(t.TempDir(), "compatibility-check.log")
remote := filepath.Join(t.TempDir(), "origin.git")
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Remote: "origin", Remote: "origin",
ExpectedPushURL: remote,
Checks: []cli.CheckCommand{{ Checks: []cli.CheckCommand{{
Args: []string{"sh", "scripts/check-release-note.sh"}, Args: []string{"sh", "scripts/check-release-note.sh"},
Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog}, Env: map[string]string{"RELEASEKIT_COMPATIBILITY_LOG": checkLog},
}}, }},
} }
if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
}
contents, err := json.Marshal(settings) contents, err := json.Marshal(settings)
if err != nil { if err != nil {
t.Fatal(err) 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, "add", "releasekit.json")
cliGitRun(t, repository, "commit", "-m", "configure source release") cliGitRun(t, repository, "commit", "-m", "configure source release")
remote := filepath.Join(t.TempDir(), "origin.git")
cliGitRun(t, repository, "init", "--bare", remote) cliGitRun(t, repository, "init", "--bare", remote)
cliGitRun(t, repository, "remote", "add", "origin", remote) cliGitRun(t, repository, "remote", "add", "origin", remote)
cliGitRun(t, repository, "push", "--set-upstream", "origin", "main") cliGitRun(t, repository, "push", "--set-upstream", "origin", "main")
var stdout, stderr bytes.Buffer 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"} args := []string{"publish", "--repo", repository, "v1.2.3"}
if status := run(context.Background(), args, &stdout, &stderr); status != 0 { if status := run(context.Background(), args, &stdout, &stderr); status != 0 {
t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String()) t.Fatalf("run() status = %d; stdout=%q stderr=%q", status, stdout.String(), stderr.String())
@@ -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 { 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) 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) { 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("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("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") write("docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nRelease note.\n")
cliGitRun(t, repository, "add", "--all") cliGitRun(t, repository, "add", "--all")
cliGitRun(t, repository, "commit", "-m", "candidate") cliGitRun(t, repository, "commit", "-m", "candidate")
return repository 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) { func TestDeliverPageOnlyCreatesAndReusesRelease(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() 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) writeDeliveryProfile(t, repository, settings)
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD") state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs. cliGitRun(t, repository, "tag", "v1.2.3") // Historical lightweight tags are valid delivery inputs.
@@ -921,7 +1155,7 @@ func newCLIBuildRepository(t *testing.T, mainSource string) string {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root)) module := fmt.Sprintf("module example.com/cli-candidate\n\ngo 1.26.0\n\nrequire gitea.maximumdirect.net/eric/ReleaseKit v0.0.0\n\nreplace gitea.maximumdirect.net/eric/ReleaseKit => %s\n", filepath.ToSlash(root))
write := func(name, contents string) { write := func(name, contents string) {
t.Helper() t.Helper()
path := filepath.Join(repository, filepath.FromSlash(name)) path := filepath.Join(repository, filepath.FromSlash(name))

View File

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

View File

@@ -1,12 +1,18 @@
package main package main
import ( import (
"archive/zip"
"errors"
"fmt" "fmt"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli" "gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
@@ -19,30 +25,15 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
pin, err := woodpeckerMatrixValue(string(contents), "RELEASEKIT_VERSION")
if err != nil {
t.Fatal(err)
}
block, err := extractWoodpeckerCommand(string(contents), "deliver") block, err := extractWoodpeckerCommand(string(contents), "deliver")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
command := strings.ReplaceAll(block, "${RELEASEKIT_VERSION}", pin) command := strings.ReplaceAll(block, "$${", "${")
command = strings.ReplaceAll(command, "$${", "${")
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) { t.Run("source-only", func(t *testing.T) {
repository := newCLIRepository(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") selectReleaseKitModule(t, repository, root, false)
cliGitRun(t, repository, "add", "go.mod")
cliGitRun(t, repository, "commit", "-m", "declare unavailable dependency")
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
@@ -53,24 +44,22 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.commit = commit state.commit = commit
shimDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog) env := templateEnvironment(repository, commit, goDir, goLog)
for attempt := 0; attempt < 2; attempt++ { moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
output := runWoodpeckerBlock(t, command, repository, env) output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") { if !strings.Contains(output, "state complete") {
t.Fatalf("delivery attempt %d output=%q", attempt+1, output) t.Fatalf("delivery output=%q", output)
}
} }
if state.releaseCreates != 1 || state.assetUploads != 0 { if state.releaseCreates != 1 || state.assetUploads != 0 {
t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads) t.Fatalf("source-only calls = creates:%d uploads:%d", state.releaseCreates, state.assetUploads)
} }
if log, err := os.ReadFile(goLog); err != nil || strings.Contains(string(log), "mod download") { assertModuleBootstrap(t, repository, goLog, moduleBefore)
t.Fatalf("source-only command downloaded application dependencies: log=%q error=%v", log, err)
}
}) })
t.Run("binary", func(t *testing.T) { t.Run("binary", func(t *testing.T) {
repository := newDeliveryBuildRepository(t) repository := newDeliveryBuildRepository(t)
selectReleaseKitModule(t, repository, root, true)
state := &deliveryServerState{} state := &deliveryServerState{}
server := newDeliveryServer(t, state) server := newDeliveryServer(t, state)
defer server.Close() defer server.Close()
@@ -86,8 +75,10 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
commit := pushTaggedCLIRepository(t, repository) commit := pushTaggedCLIRepository(t, repository)
state.commit = commit state.commit = commit
shimDir, goLog := installGoBootstrapShim(t) goDir, goLog := installGoBootstrapShim(t)
env := templateEnvironment(repository, commit, executable, module, shimDir, goLog) 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++ { for attempt := 0; attempt < 2; attempt++ {
output := runWoodpeckerBlock(t, command, repository, env) output := runWoodpeckerBlock(t, command, repository, env)
if !strings.Contains(output, "state complete") { if !strings.Contains(output, "state complete") {
@@ -106,10 +97,228 @@ func TestWoodpeckerTagDeliveryTemplate(t *testing.T) {
if _, ok := state.assets["SHA256SUMS"]; !ok { if _, ok := state.assets["SHA256SUMS"]; !ok {
t.Fatalf("checksum manifest was not delivered: %#v", state.assets) t.Fatalf("checksum manifest was not delivered: %#v", state.assets)
} }
if log, err := os.ReadFile(goLog); err != nil || !strings.Contains(string(log), "mod download") { assertModuleBootstrap(t, repository, goLog, moduleBefore, sumBefore)
t.Fatalf("binary command did not prepare application dependencies: log=%q error=%v", log, err) })
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) {
// Synthetic downloads must leave the caller's module cache untouched.
callerCache := t.TempDir()
t.Setenv("GOMODCACHE", callerCache)
t.Cleanup(func() {
entries, err := os.ReadDir(callerCache)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Errorf("synthetic module downloads modified the caller's cache: %v", entries)
} }
}) })
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)
useSyntheticModuleProxy(t, env, "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)
useSyntheticModuleProxy(t, env, "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{}
useSyntheticModuleProxy(t, preparationEnv, proxy.URL)
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)
useSyntheticModuleProxy(t, env, proxy.URL)
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 { func cliRepositoryRoot(t *testing.T) string {
@@ -156,34 +365,22 @@ func extractWoodpeckerCommand(contents, stepName string) (string, error) {
return "", fmt.Errorf("step %q was not found", stepName) 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) { func installGoBootstrapShim(t *testing.T) (string, string) {
t.Helper() t.Helper()
shimDir := t.TempDir() shimDir := t.TempDir()
goLog := filepath.Join(shimDir, "go-commands") goLog := filepath.Join(shimDir, "go-commands")
shim := `#!/bin/sh shim := `#!/bin/sh
if [ "$1" = "run" ]; then printf 'cwd=%s GOWORK=%s GOTOOLCHAIN=%s %s\n' "$PWD" "$GOWORK" "$GOTOOLCHAIN" "$*" >> "$RELEASEKIT_GO_LOG"
shift "$RELEASEKIT_REAL_GO" "$@"
[ "$1" = "$RELEASEKIT_EXPECTED_MODULE" ] || { echo "unexpected module: $1" >&2; exit 90; } status=$?
shift if [ "$status" -eq 0 ] && [ "$1" = "build" ] && [ -n "${RELEASEKIT_SUM_LOG:-}" ]; then
[ "$1" = "ci-deliver" ] || { echo "unexpected command: $1" >&2; exit 91; } modfile=
[ "$2" = "--repo" ] && [ "$3" = "$CI_WORKSPACE" ] || { echo "unexpected repository arguments" >&2; exit 92; } for argument in "$@"; do
[ "$4" = "--commit" ] && [ "$5" = "$CI_COMMIT_SHA" ] || { echo "unexpected commit arguments" >&2; exit 93; } case "$argument" in -modfile=*) modfile=${argument#-modfile=} ;; esac
[ "$6" = "$CI_COMMIT_TAG" ] && [ "$#" = 6 ] || { echo "unexpected version argument" >&2; exit 94; } done
printf '%s\n' "run $RELEASEKIT_EXPECTED_MODULE" >> "$RELEASEKIT_GO_LOG" cp "${modfile%.mod}.sum" "$RELEASEKIT_SUM_LOG"
exec "$RELEASEKIT_LOCAL_EXECUTABLE" "$@"
fi fi
printf '%s\n' "$*" >> "$RELEASEKIT_GO_LOG" exit "$status"
exec "$RELEASEKIT_REAL_GO" "$@"
` `
if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil { if err := os.WriteFile(filepath.Join(shimDir, "go"), []byte(shim), 0o700); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -191,27 +388,92 @@ exec "$RELEASEKIT_REAL_GO" "$@"
return shimDir, goLog 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 useSyntheticModuleProxy(t *testing.T, environment map[string]string, proxyURL string) {
t.Helper()
environment["GOMODCACHE"] = t.TempDir()
environment["GOPROXY"] = proxyURL
environment["GOSUMDB"] = "off"
environment["GONOPROXY"] = "none"
environment["GOPRIVATE"] = ""
environment["GOWORK"] = "off"
environment["GOTOOLCHAIN"] = "local"
environment["GOENV"] = "off"
environment["GOFLAGS"] = "-modcacherw"
}
func templateEnvironment(repository, commit, shimDir, goLog string) map[string]string {
realGo, _ := exec.LookPath("go") realGo, _ := exec.LookPath("go")
return map[string]string{ return map[string]string{
"CI_WORKSPACE": repository, "CI_WORKSPACE": repository,
"CI_COMMIT_SHA": commit, "CI_COMMIT_SHA": commit,
"CI_COMMIT_TAG": "v1.2.3", "CI_COMMIT_TAG": "v1.2.3",
"RELEASEKIT_GITEA_TOKEN": "fixture-token", "RELEASEKIT_GITEA_TOKEN": "fixture-token",
"RELEASEKIT_EXPECTED_MODULE": module, "RELEASEKIT_MODULE": "gitea.maximumdirect.net/eric/ReleaseKit",
"RELEASEKIT_LOCAL_EXECUTABLE": executable, "RELEASEKIT_REAL_GO": realGo,
"RELEASEKIT_REAL_GO": realGo, "RELEASEKIT_GO_LOG": goLog,
"RELEASEKIT_GO_LOG": goLog, "GOTOOLCHAIN": "local",
"GOTOOLCHAIN": "local", "GOPROXY": "off",
"GOPROXY": "off", "GOSUMDB": "off",
"GOSUMDB": "off", "GOFLAGS": "",
"GOFLAGS": "", "PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"PATH": shimDir + string(os.PathListSeparator) + os.Getenv("PATH"),
} }
} }
func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string { func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[string]string) string {
t.Helper() 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 := exec.Command("sh", "-e", "-c", block)
command.Dir = repository command.Dir = repository
command.Env = os.Environ() command.Env = os.Environ()
@@ -219,10 +481,183 @@ func runWoodpeckerBlock(t *testing.T, block, repository string, environment map[
command.Env = setEnvironment(command.Env, key, value) command.Env = setEnvironment(command.Env, key, value)
} }
output, err := command.CombinedOutput() output, err := command.CombinedOutput()
if err != nil { return string(output), err
t.Fatalf("Woodpecker command failed: %v\ncommand: %s\noutput: %s", err, block, output) }
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.26.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 { func setEnvironment(environment []string, key, value string) []string {

View File

@@ -134,6 +134,50 @@ func TestDeliverPrebuiltArtifactsRemainCallerOwned(t *testing.T) {
} }
} }
func TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites(t *testing.T) {
repository := newTaggedDeliveryConsumer(t)
prebuilt, err := releasekit.Build(context.Background(), releasekit.BuildRequest{
RepositoryDir: repository,
Version: "v1.2.3",
Spec: releasekit.BuildSpec{
MainPackage: "./cmd/app",
BinaryName: "fixture",
Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}},
},
OutputDir: filepath.Join(t.TempDir(), "caller-owned-assets"),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
contents, err := os.ReadFile(prebuilt.Artifacts[0].Path)
if err != nil {
t.Fatal(err)
}
if len(contents) == 0 {
t.Fatal("built artifact is empty")
}
contents[0] ^= 0xff
if err := os.WriteFile(prebuilt.Artifacts[0].Path, contents, 0o600); err != nil {
t.Fatal(err)
}
server, remote := newFacadeDeliveryServer(t, repository)
defer server.Close()
result, err := releasekit.Deliver(context.Background(), releasekit.DeliveryRequest{
RepositoryDir: repository,
Product: "Fixture",
Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL),
Prebuilt: &prebuilt,
})
if err == nil || !strings.Contains(err.Error(), "verify build assets") || result.State != releasekit.DeliveryNotAttempted {
t.Fatalf("Deliver() = %#v, %v; want local asset verification failure", result, err)
}
if remote.requests != 0 {
t.Fatalf("hosted requests = %d, want none before local verification", remote.requests)
}
}
func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) { func TestDeliverPageOnlySkipsBuildAndHostedAssets(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name 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` must use stable `vMAJOR.MINOR.PATCH` syntax. Options must precede the
version. The repository defaults to the current directory, and the profile version. The repository defaults to the current directory, and the profile
defaults to `<repository>/releasekit.json`. A successful command prints the defaults to `<repository>/releasekit.json`. A successful command prints the
captured local commit and version. It checks the committed release note, local captured local commit and version. It checks the committed release note and
Git state, runs the standard offline Go test suite, and then runs any configured local Git state, runs the standard offline Go test suite (with `-race` when
additional checks in profile order. It does not inspect remote availability or `validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`
publish a tag. 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 `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, after local validation, 1 after a candidate validation failure, and 2 for usage,
profile, or invalid-version errors. Unsupported argument sequences print usage profile, or invalid-version errors. Shared validation failures identify the
to standard error. 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: Publish a source tag:
@@ -39,16 +66,57 @@ Publish a source tag:
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION
``` ```
`publish` reads the same product, remote, and additional-check settings as `publish` reads the same product, remote, validation, and additional-check
`check`. It checks the authoritative remote and validates the candidate: the settings as `check`. It checks the authoritative remote and validates the
standard offline Go test suite runs first, followed by configured checks in candidate: the single standard offline Go test suite runs first, with `-race`
profile order. It then creates an annotated local tag and pushes only its exact when `validation.go.race` is true. When `validation.go.full_vet` is true,
tag object. It prints the confirmed version, candidate commit, and tag object 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 on success. Validation
progress and failed publication state, including the captured publication progress and failed publication state, including the captured publication
endpoint for recovery, are written to standard error. A signal endpoint for recovery, are written to standard error. A signal
cancels the active operation; if it arrives during a push, the library's cancels the active operation; if it arrives during a push, the library's
bounded reconciliation determines whether the exact tag was accepted. 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 The command returns status 0 for confirmed publication, 1 for a known
publication failure or conflict, 2 for usage, profile, or invalid-version 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 version errors return status 2. Final delivery uses the same statuses as
`deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery, `deliver`: 0 for complete, 1 for known failure, conflict, or partial delivery,
and 3 for uncertain remote state. 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. | | `product` | Yes | Product name used by release operations. |
| `remote` | No | Selected Git remote; defaults to `origin`. | | `remote` | No | Selected Git remote; defaults to `origin`. |
| `expected_push_url` | No | Exact effective push URL required by `publish`; ignored by local `check`. |
| `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. | | `checks` | No | Ordered additional child commands, each with an argument vector and optional environment map. |
| `validation` | No | Optional shared checks selected by ReleaseKit. |
| `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. | | `build` | No | Optional main package, binary name, and selected `GOOS/GOARCH` target strings. |
| `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. | | `delivery` | No | Optional Gitea `base_url`, `owner`, `repository`, and `token_env` settings for tagged-source delivery. |
@@ -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 target requires the package and binary name; each target is written as one
`GOOS/GOARCH` pair. An omitted or empty target list means no binary work. `GOOS/GOARCH` pair. An omitted or empty target list means no binary work.
The optional `validation` object selects shared checks implemented by ReleaseKit.
`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 The `delivery` object contains `base_url`, `owner`, `repository`, and optional
`token_env`. Its values are required when running `deliver`, but credentials are `token_env`. Its values are required when running `deliver`, but credentials are
not required for `check`, `publish`, or `build`. `token_env` names the 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 `RELEASEKIT_GITEA_TOKEN`. The CLI reads this variable only for `deliver` and
passes its value explicitly to the library operation. passes its value explicitly to the library operation.
The `check` and `publish` commands always run `expected_push_url`, when nonempty, is compared exactly with Git's resolved
`go test -mod=readonly -count=1 ./...` with Go module and toolchain network effective push URL during `publish`, before candidate validation. It applies
access disabled. They then run each configured `checks` command once, in order, to the push destination when fetch and push URLs differ, and Git URL rewrites
as an additional product check. Selected-target builds run after both. Do not such as `insteadOf` are resolved before comparison. A mismatch fails preflight;
repeat the standard suite in `checks`; doing so runs it twice. The `build` omitting the field keeps the selected remote's existing behavior. `check` is
command uses the selected build settings and writes binaries to `dist` unless 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. `--output` selects another path.
The `deliver` command uses the delivery destination and token. By default it The `deliver` command uses the delivery destination and token. By default it
passes selected targets to the delivery facade, which builds tagged-source 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 independent of Woodpecker environment variable names. See the
[CLI contract](cli.md) for the invocation and exit statuses. [CLI contract](cli.md) for the invocation and exit statuses.
This application dependency preparation is separate from bootstrapping the
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's source-only profile is
[releasekit.json](../releasekit.json). [releasekit.json](../releasekit.json).
See the [source-only profile](../examples/releasekit.json) and See the [source-only profile](../examples/releasekit.json) and

View File

@@ -0,0 +1,53 @@
# Adopt ReleaseKit
Use a versioned Go tool dependency when ReleaseKit provides the repository's
release commands. This records the CLI in `go.mod` and keeps it available to
maintainers without adding imports to application or library code.
## CLI tool dependency
From the consumer module's root, select the ReleaseKit version to adopt:
```sh
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.2.0
go mod tidy
go tool releasekit version
```
Replace `v0.2.0` with the intended release. Go records the executable package in
a `tool` directive and the module version in a `require` directive. The tool
declaration retains the dependency through `go mod tidy`. Commit the resulting
`go.mod` and `go.sum` changes. To upgrade, repeat the command with the chosen
version and review the module changes.
Go tool dependencies require Go 1.24 or later, together with a toolchain that
satisfies the selected ReleaseKit version and its dependencies. Tools share the
consumer's module graph: their requirements participate in dependency version
selection and can raise the consumer module's Go minimum. See the
[Go tool dependency reference](https://go.dev/ref/mod#go-mod-file-tool).
Run `go tool releasekit` from within the consumer module to use its selected
version. Follow the [CLI contract](../cli.md) for commands and exit statuses,
and the [configuration reference](../config.md) for the release profile.
## Go API consumers
Consumers importing the root `releasekit` API or
[`buildinfo`](runtime-version.md) use ordinary package dependencies. Add the
required imports and select the module version:
```sh
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
go mod tidy
```
API consumers can also register the CLI with `go get -tool` as above. Both uses
share the module-selected ReleaseKit version.
## CI adoption
The maintained [consumer tag-delivery workflow](delivery.md#woodpecker-tag-workflows)
supports both tool and ordinary package dependencies. It builds ReleaseKit from
the tagged consumer's module selection, so the committed module files also
govern CI delivery and retries. Registering the CLI as a tool is sufficient for
consumers that use ReleaseKit only for maintainer commands.

View File

@@ -8,6 +8,9 @@ result, err := releasekit.Check(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", Version: "v1.2.3",
Validation: releasekit.ValidationSpec{
Go: releasekit.GoValidationSpec{Race: true, FullVet: true},
},
AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error { AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir) return runProjectChecks(ctx, candidate.RepositoryDir)
}, },
@@ -24,14 +27,67 @@ version must be stable `vMAJOR.MINOR.PATCH`. The committed
product name and exact version as complete title elements. No local tag with product name and exact version as complete title elements. No local tag with
that version may already exist. that version may already exist.
ReleaseKit always runs `go test -mod=readonly -count=1 ./...` with the available ReleaseKit runs one `go test -mod=readonly -count=1 ./...` suite with the
compatible local Go toolchain, then calls `AdditionalChecks` once when supplied. available compatible local Go toolchain. Set `Validation.Go.Race` to run that
It does not download a different toolchain automatically. The standard suite suite with `-race`; set `Validation.Go.FullVet` to run
uses the Go host target, disables workspace and module network access, and `go vet -mod=readonly ./...` after successful tests, with test-time vet disabled
ignores ambient `GOFLAGS`, experiment, and CPU feature overrides. Effective for the suite. Set `Validation.Go.CompileTargets` to compile packages for each
cache and native cgo/C-tool settings are preserved, including values from Go's complete target pair after tests and vet. Package compilation uses
persistent environment file. The check revalidates the branch, commit, and `CGO_ENABLED=0`, writes no artifact, and does not execute a target binary; its
cleanliness after validation. 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.
For checksum failures during dependency preparation, use the
[module checksum investigation guide](delivery.md#investigating-module-checksum-mismatches).
A successful check is evidence about the local candidate only. It does not 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. check whether a remote branch or tag is available and does not create a tag.

View File

@@ -150,28 +150,52 @@ exit codes, and transport behavior.
Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml) Use the maintained [tag-delivery workflow](../../examples/woodpecker/tag-delivery.yml)
in the consumer repository's `.woodpecker/` directory. Set `GO_IMAGE` to an 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 image with Git and a Go toolchain compatible with the consumer module and its
module. Replace `RELEASEKIT_VERSION` with an immutable ReleaseKit revision or selected ReleaseKit version. Choose the patch used for the consumer's release
published version. The example selects the initial `v0.1.0` baseline; update build and validation. The template uses `GOTOOLCHAIN=local`; Go does not download
that pin when adopting a later release. Configure the Woodpecker repository secret a different toolchain. The tag's `go.mod` must select ReleaseKit as a module
`releasekit_gitea_token`; the template maps it to `RELEASEKIT_GITEA_TOKEN` only dependency. For CLI-only consumers, use the versioned Go tool dependency
for the delivery step. The repository needs a `delivery` profile. Page-only and described in the [adoption guide](adoption.md). Ordinary package dependencies
binary behavior both use this template; the profile's build target list selects also work with this template.
whether binaries are built and delivered. 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 Before running Go, the template fetches the explicit event tag, compares its
`CI_WORKSPACE`, `CI_COMMIT_SHA`, and `CI_COMMIT_TAG` as command arguments. peeled commit with the provider's full commit ID without regard to hex case,
`ci-deliver` fetches and verifies the event tag against its commit before checks out that commit detached, and confirms the source tree is clean. It then
checking out the commit detached. It then loads the profile from that source, copies the tagged `go.mod` and any `go.sum` to a temporary directory outside
validates the destination and token, and downloads application dependencies the checkout. `go list -m` confirms that the module selects ReleaseKit; Go uses
only for profiles with build targets. The facade builds and delivers those an alternate `-modfile` to prepare dependencies and build the selected
targets with the note page. Source-only profiles skip dependency downloads and `cmd/releasekit` package without changing the tagged module files. The template
binary work. The workflow does not repeat checkout guards or maintain a invokes that executable with `CI_WORKSPACE`, `CI_COMMIT_SHA`, and
separate build/receipt/delivery sequence. `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 The normal Go module and build caches are sufficient. Cache mounts may be added
as deployment configuration when they help the runner; they are not needed by as deployment configuration when they help the runner; they are not needed by
the template. A 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 `go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@PIN`, then run
with the same arguments: with the same arguments:
@@ -179,9 +203,10 @@ with the same arguments:
releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG" releasekit ci-deliver --repo "$CI_WORKSPACE" --commit "$CI_COMMIT_SHA" "$CI_COMMIT_TAG"
``` ```
Upgrade by changing the selected immutable The module-based workflow follows the ReleaseKit version selected by the tagged
`RELEASEKIT_VERSION` (or the `@PIN` used by `go install`) and validating the consumer's `go.mod`, so retries use that source's original selection. Update the
workflow against a tag fixture. The command and profile behavior are documented 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). in the [CLI contract](../cli.md) and [configuration reference](../config.md).
Woodpecker runs delivery after the tag event, independently from the source Woodpecker runs delivery after the tag event, independently from the source
@@ -193,3 +218,48 @@ move the tag to make a retry pass. See
for runner-provided tag and commit variables and for runner-provided tag and commit variables and
[workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) for tag [workflow syntax](https://woodpecker-ci.org/docs/usage/workflow-syntax) for tag
events, sequential steps, and the shared workspace. events, sequential steps, and the shared workspace.
## Investigating module checksum mismatches
During tool installation or dependency preparation, a checksum mismatch means
the bytes Go read differ from a recorded checksum. The message alone does not
identify whether those bytes came from the local cache or a remote source.
Keep checksum verification enabled and retain the error, selected version,
and relevant `go.sum` entries. Go's [module authentication reference](https://go.dev/ref/mod#authenticating)
describes these checks.
To isolate a public ReleaseKit download from application settings and the
existing module cache, run the following with a compatible local Go toolchain.
Replace `v0.2.0` with the affected version. This diagnostic accesses the public
Go proxy and checksum database and retains its temporary directory for inspection.
```sh
(
checksum_dir=$(mktemp -d) || exit 1
printf 'Checksum investigation directory: %s\n' "$checksum_dir"
cd "$checksum_dir" || exit 1
GOENV=off GOFLAGS= GOWORK=off GOTOOLCHAIN=local \
GOPRIVATE= GONOPROXY= GONOSUMDB= \
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
GOMODCACHE="$checksum_dir/modules" \
go mod download -json gitea.maximumdirect.net/eric/ReleaseKit@v0.2.0
)
```
A successful isolated download establishes that this version verifies through
the selected proxy. Compare its `GoMod`, `GoModSum`, and `Sum` with the affected
cache and the application's recorded checksums. Locate the original cache with
`go env GOMODCACHE`; Go stores downloaded module metadata separately from the
extracted source. See the [module cache reference](https://go.dev/ref/mod#module-cache).
Once a stale cache entry is confirmed, preserve a copy, quarantine that entry
while no Go process is using it, and retry normal dependency preparation. For
a confirmed stale `.mod`, quarantine that file alone. Preserve application
checksums and published tags. Routine downloads can continue to use the normal
module cache.
If the isolated download also fails, investigate the selected proxy, tagged
source, and recorded checksums with the maintainer before changing local state.
Record any difference between proxy and direct fetching. A confirmed cache
problem can be repaired locally; changes to published source follow the
maintainer's immutable-release procedure.

View File

@@ -5,9 +5,13 @@ annotated source tag:
```go ```go
result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{ result, err := releasekit.Publish(ctx, releasekit.ReleaseRequest{
RepositoryDir: "/work/project", RepositoryDir: "/work/project",
Product: "Example", Product: "Example",
Version: "v1.2.3", 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 { AdditionalChecks: func(ctx context.Context, candidate releasekit.Candidate) error {
return runProjectChecks(ctx, candidate.RepositoryDir) 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 Publication captures a clean candidate on `main`, validates its committed
release note, and checks the selected remote before running candidate checks. release note, and checks the selected remote before running candidate checks.
It always runs `go test -mod=readonly -count=1 ./...` with the available Set `ExpectedPushURL` to require the effective push destination to match an
compatible local Go toolchain. It then calls `AdditionalChecks` once when exact string. Git URL rewrites are resolved first, and the push URL is compared
supplied. The standard suite cannot be replaced; move product-specific behavior when fetch and push URLs differ. A mismatch fails preflight before Go validation,
into `AdditionalChecks` and remove repeated standard-suite commands from custom callbacks, or tag creation; detect it with
checks. A failed suite or additional check prevents tag creation, and wrapped `errors.Is(err, releasekit.ErrUnexpectedPushURL)`. `Check` ignores this
errors preserve their causes. 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 If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the 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, ReleaseKit contains a Go module with the documented root `releasekit` facade,
adopted policies, and design documents. See the [README](../README.md) for adopted policies, and design documents. See the [README](../README.md) for
orientation and the [product roadmap](roadmap/releasekit.md) for proposed orientation.
capabilities and implementation sequencing.
Read the [agent policy](policy/agents.md) and inspect the worktree before editing. Read the [agent policy](policy/agents.md) and inspect the worktree before editing.
Preserve unrelated changes and follow explicit instructions about commits. 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), See the [candidate check guide](consumers/check.md), [source publication guide](consumers/publish.md),
[build consumer guide](consumers/build.md), [build consumer guide](consumers/build.md),
and [internal package inventory](internal/overview.md) for implemented behavior and [internal package inventory](internal/overview.md) for implemented behavior
and capability ownership. The [product roadmap](roadmap/releasekit.md) records and capability ownership.
remaining adoption and deployment work.
For prose changes, review the diff and check local links, fragment destinations, For prose changes, review the diff and check local links, fragment destinations,
provenance, template residue, and consistency with current repository contents. provenance, template residue, and consistency with current repository contents.
@@ -77,3 +75,17 @@ validation. The [maintainer release procedure](release.md) owns preparation,
publication, and recovery. Hosted note-page delivery is a separate prepared publication, and recovery. Hosted note-page delivery is a separate prepared
workflow and does not change the successful-tag-push completion point. ReleaseKit workflow and does not change the successful-tag-push completion point. ReleaseKit
does not declare binary deliverables. does not declare binary deliverables.
## Testing synthetic modules
Tests that serve synthetic module versions through local proxies should pass a
test-owned temporary `GOMODCACHE` to every Go subprocess that consumes those
versions. Scope fixture-specific proxy and checksum settings to those processes.
This applies the [testing policy's isolation requirements](policy/testing.md#test-execution-requirements)
to module downloads and keeps fixture metadata out of developer and CI caches.
Distinct fixture version numbers still occupy the real module's cache namespace.
Use temporary caches even when the selected versions have never been published.
Verify isolation at the shared fixture boundary; ordinary builds can continue
to use their prepared dependency cache. Consumer recovery guidance lives in the
[module checksum investigation guide](consumers/delivery.md#investigating-module-checksum-mismatches).

View File

@@ -5,6 +5,34 @@ operation and applies the shared environment to its Go commands. It uses
`internal/process` for cancellation, the working directory, output, and wrapped `internal/process` for cancellation, the working directory, output, and wrapped
errors. errors.
## Command output ownership
`Tool.Run` executes a status-only command. It forwards combined stdout and
stderr to the optional writer while the child runs, then releases its capture
on success. Failures include the captured diagnostic text and preserve the
underlying process or forwarding error.
`Tool.Output` returns combined stdout and stderr for commands whose output is
needed by the caller, such as compiler identification. After the child stops,
the capture's backing bytes transfer to the caller without a second copy. A
failed output command returns both those bytes and an error containing the
diagnostic text. The capture remains in memory for the duration of the child;
the component benchmark compares one 8 MiB write with the same output in
32 KiB writes, forwarding to `io.Discard`. Smaller writes incur buffer-growth
allocations. The [output benchmark](../../internal/gotool/performance_test.go)
measures total allocated bytes per operation, excluding subprocess overhead and
diagnostic strings. These totals count all allocations, including superseded
buffers. The returned slice retains the final buffer holding the complete output.
Peak live memory depends on buffer growth, garbage collection, and other command
work and is not measured by allocation totals. Failed output also formats
diagnostics into the error, so the returned bytes and diagnostic string can
coexist. Disk-backed diagnostics could reduce large failure peaks, but add file
lifecycle and cleanup work and remain a follow-up option.
Dependency preparation uses a separate command environment so proxy, private
module, checksum, and cache settings remain available. Its completed capture
uses the same ownership transfer when formatting a failure.
## Configuration discovery ## Configuration discovery
The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then The owner asks the installed `go` command for `GOHOSTOS` and `GOHOSTARCH`, then
@@ -41,6 +69,9 @@ baseline values. Effective `GOCACHE`, `GOCACHEPROG`, `GOMODCACHE`, `GOPATH`,
`GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain `GOROOT`, and `GOTMPDIR` values are retained. Other process variables remain
available to child commands. available to child commands.
Candidate validation uses native mode for the offline `go test` suite. Builds Candidate validation uses native mode for the offline `go test` suite and
use static mode for selected-target compilation and the native version-check selected full `go vet` pass. Package-compilation checks use static mode for each
build. The native version executable itself runs as a normal child process. 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 ## Module and dependencies
The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language The module is `gitea.maximumdirect.net/eric/ReleaseKit`, with Go language
version `1.25.0`. `go.mod` has no required third-party modules; packages use the version `1.25.0`. `go.mod` requires `golang.org/x/mod` and Goldmark for internal
standard library and first-party ReleaseKit packages. `go list ./...` reports validation; public facades do not expose either dependency. `go list ./...`
these implemented packages: reports these implemented packages:
| Package | Responsibility | | Package | Responsibility |
| --- | --- | | --- | --- |
@@ -39,15 +39,16 @@ these implemented packages:
| `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. | | `internal/artifact` | Shared build-target normalization, artifact declarations, filename rules, and checksum-manifest naming. |
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. | | `internal/build` | 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/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/delivery` | Tagged-source and release-note checks, managed builds in disposable storage outside the checkout, structural artifact declaration, manifest assembly, one local digest-and-size preflight before hosted requests, and delivery coordination. |
| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. | | `internal/git` | Git command boundary, committed-tree reads, lazy local-only batched blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
| `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `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/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/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. | | `internal/process` | Cancellable child-process execution, including owned stdin/stdout pipes for serialized exchanges, with per-command environment overlays and removals. |
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. | | `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Injected version selection and cached fallback metadata lookup for the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. | | `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
The `buildinfo` facade depends only on its small runtime implementation. The The `buildinfo` facade depends only on its small runtime implementation. The
@@ -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 also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
[architecture policy](../policy/architecture.md). [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 ## Capability owners and behavioral evidence
The following tests are representative evidence from the current offline suite, The following tests are representative evidence from the current offline suite,
@@ -66,15 +78,19 @@ not a separate release matrix:
| Capability | Implementation owner | Existing evidence | | Capability | Implementation owner | Existing evidence |
| --- | --- | --- | | --- | --- | --- |
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` | | 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` | | 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` | | Batched local Git blob reads | `internal/git`, `internal/process` | `TestBlobReaderReadsLooseAndPackedObjects`, `TestBlobReaderMissingObjectKeepsBatchSessionUsable`, `TestCandidateObjectReadsStayLocalInPartialClones` |
| Piped child-process exchanges and cancellation cleanup | `internal/process` | `TestStartPipedExchangesBeforeInputEOFAndPreservesSettings`, `TestStartPipedCancellationAfterReadinessReapsChild`, `TestStartPipedPreservesNonzeroExit` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` | | Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |
| Cross-builds, version smoke checks, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure` | | Cross-builds, version smoke checks, 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` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites` |
| Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` | | Tagged checkout and build dependency preparation | `releasekit.PrepareTaggedCheckout`, `releasekit.PrepareBuildDependencies`, `internal/preparation`, `internal/git`, `internal/gotool` | `TestPrepareTaggedCheckoutFetchesExpectedCommitAndDetaches`, `TestPrepareTaggedCheckoutRejectsWrongCommitBeforeCheckout`, `TestPrepareTaggedCheckoutReportsLocalCheckoutConflict`, `TestPrepareTaggedCheckoutReportsConflictingLocalTag`, `TestPrepareBuildDependenciesPreservesPreparationEnvironment`, `TestPrepareBuildDependenciesPropagatesCommandFailure`, `TestPrepareBuildDependenciesPropagatesCancellation` |
| Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` | | Page, manifest, asset comparison, and additive retry outcomes | `internal/gitea` | `TestEnsureReleaseCreatesMissingPageFromCommittedNote`, `TestInspectAssetsDetectsSameNameAndSizeWithDifferentBytes`, `TestUploadMissingPartialFailureThenRerunOnlyMissingAssets` |
| 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 Run the complete suite with `go test ./...`. The public API and import boundary
check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise check is `go run ./internal/apicheck`. Local HTTP and Git fixtures exercise
@@ -86,7 +102,3 @@ The [Gitea integration guide](../integrations/gitea.md) owns the HTTP contract
and hosted response handling. The [Woodpecker examples](../../examples/woodpecker/) and hosted response handling. The [Woodpecker examples](../../examples/woodpecker/)
are prepared integrations; their YAML is not active in this repository, and no are prepared integrations; their YAML is not active in this repository, and no
live Woodpecker runner, configured secret, or hosted delivery has been used. 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.

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

@@ -0,0 +1,159 @@
# 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. Resolved module bodies
are parsed once per blob object ID; only owned filesystem-replacement targets
and line numbers remain cached. Each declaration path receives findings from
that summary in its own path context.
The optional file-size check uses each blob's captured Git size, including
symlink link-text bytes, and skips entries without a blob size such as gitlinks.
It applies normalized literal file/directory exclusions with path-boundary
matching, then returns the first oversized path with `FileSizeMeasurement`.
The optional Markdown check follows file-size inspection and uses the same
candidate tree. It parses regular Markdown blobs, resolves local destinations,
and checks supported anchors; outside-checkout and submodule observations are
written to the shared output writer. A nil writer keeps library use quiet.
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or
neighboring files. Each tree owns one lazy Git blob reader; direct tree users
must close it when finished. The shared validation executor closes its tree on
every return before product checks or build callbacks run. A metadata-only
selection never starts the reader. Per-read contexts cancel an in-flight blob
exchange. The context supplied when creating the tree governs the reader
lifetime, including shutdown and child reaping.
Regular-file reads return independent caller-owned byte slices and are not
retained by the tree. Symlink targets are retained as immutable owned strings
by blob object ID; explicitly reading a symlink still returns independent
bytes. The Go-format check caches only whether each successfully inspected
object is formatted, then reports every unformatted path that refers to it.
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.
The Markdown cache shares parsed records and anchors by regular blob object ID,
with a path lookup for callers. Retained destinations and titles own their
string storage, and parsed records contain content-relative locations only.
`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` relative to the current source path;
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,9 +1,37 @@
# Open Source Attribution Notices # 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 ## Standard Repository Policies
Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation, Material: policy documents under `docs/policy/`, the root `AGENTS.md` orientation,
and the Policy Reading Guide in `docs/development.md`, adapted for this repository. and the Policy Reading Guide in `docs/development.md`, adapted for this repository.
The release policy incorporates revision `e833faddf120c789a9b45348edaf75720de16348`;
the remaining material incorporates revision
`d84a38ebab26f048cafbe59a2246700c35d29843`.
Copyright (C) 2026 Eric Rakestraw Copyright (C) 2026 Eric Rakestraw
@@ -13,4 +41,9 @@ License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843). Release-policy notices:
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
[Release-policy source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md) |
[Other policy material source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).

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

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.8 **Base Policy Version:** 1.8
**Last Updated**: 2026-10-07 **Last Updated**: 2026-10-08
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md) **Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
@@ -55,7 +55,7 @@ provides texts to copy for applicable licenses and their companions.
ReleaseKit's selected grant is LGPL version 3 or later, recorded in ReleaseKit's selected grant is LGPL version 3 or later, recorded in
[COPYRIGHT](../../COPYRIGHT). It applies to the library and release [COPYRIGHT](../../COPYRIGHT). It applies to the library and release
executable. Adapted policy documents, agent orientation, and the policy reading executable. Adapted policy documents, agent orientation, and the policy reading
guide retain their CC-BY-SA-4.0 scope as identified there and in the guide retain their CC-BY-SA-4.0 scope as identified in the
[attribution document](../oss/attribution.md). [attribution document](../oss/attribution.md).
Unless the author specifies otherwise, use **GPL version 3 or later** Unless the author specifies otherwise, use **GPL version 3 or later**
@@ -90,9 +90,9 @@ fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
grant. This convention introduces no blanket requirement for per-file headers; grant. This convention introduces no blanket requirement for per-file headers;
preserve notices or add them where the applicable terms require them. preserve notices or add them where the applicable terms require them.
Identify separately licensed documentation or assets in the project grant, with Identify separately licensed documentation or assets in the attribution
links to their attribution and license information. An application's default document, with links to their license information. The project grant uses the
code license does not override the terms of incorporated documentation. upstream template's third-party statement.
## Upstream Attribution House Format ## Upstream Attribution House Format

View File

@@ -1,10 +1,10 @@
# Release Policy # Release Policy
**Base Policy Version:** 1.4 **Base Policy Version:** 1.6
**Last Updated**: 2026-10-07 **Last Updated**: 2026-10-08
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md) **Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
## Purpose ## Purpose
@@ -12,13 +12,15 @@ This policy defines a proportionate workflow for validating and publishing an
intended source revision under a fresh version. Releases should require little intended source revision under a fresh version. Releases should require little
routine intervention, avoid repeated work, and preserve published contents. routine intervention, avoid repeated work, and preserve published contents.
Adopt stronger verification or reproducibility requirements through an explicit
project-specific decision justified by the intended audience and support needs.
The repository-specific commands belong in `docs/release.md`. Historical release The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements. policy and link to it rather than introduce additional release requirements.
This policy governs releases of ReleaseKit itself. Go declarations and GoDoc This policy governs releases of ReleaseKit itself. Go declarations and GoDoc
own supported consumer API contracts; the [product roadmap](../roadmap/releasekit.md) own supported consumer API contracts. The maintainer runbook describes
records planned adoption and deployment work. The maintainer runbook describes
ReleaseKit's implemented source publication and hosted-page delivery procedure. ReleaseKit's implemented source publication and hosted-page delivery procedure.
## Release Model And Completion ## Release Model And Completion
@@ -70,7 +72,8 @@ requires a deliberate update to the policy and procedure where needed.
For releases at or after `v1.0.0`: For releases at or after `v1.0.0`:
- a major release may make intentional incompatible changes; - a major release may make intentional incompatible changes;
- a minor release may add backward-compatible behavior; and - a minor release may add backward-compatible behavior or raise the Go consumer
minimum as described below; and
- a patch release may make backward-compatible fixes. - a patch release may make backward-compatible fixes.
Before `v1.0.0`, a minor release may change a documented public, operational, Before `v1.0.0`, a minor release may change a documented public, operational,
@@ -82,6 +85,43 @@ Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. Release notes explain adopted policy or canonical contract documentation. Release notes explain
changes; they do not redefine current compatibility contracts. changes; they do not redefine current compatibility contracts.
## Consumer Minimum And Release Toolchain
ReleaseKit provides reasonable support for routine build and release needs,
following [ADR 0002](../adr/0002-routine-releases.md). Distinguish the minimum Go
toolchain supported for consumers from the toolchain maintainers use to build
and validate a release. The consumer minimum belongs in [go.mod](../../go.mod);
the [maintainer runbook](../release.md) defines the selected release toolchain
and its setup commands.
Select the Go series during release preparation. When advancing the consumer
minimum, use the latest locally available stable series and record its initial
stable version in `go.mod`, such as `go 1.26.0`. Use a higher patch minimum when
a dependency requirement or identified correctness fix requires it, and document
the reason.
Build the maintainer release executable and validate the candidate with the
same latest locally available patch of the selected series. Keep that toolchain
active throughout preparation, tool bootstrap, and validation, using local
toolchain selection. CI jobs performing release builds and validation use the
selected toolchain; maintained CI images follow that choice. A newer validation
patch version alone leaves the consumer minimum unchanged.
For release-policy purposes, successful build and release validation on that
patch are sufficient to presume support across the selected Go series, subject
to the declared consumer minimum and concrete evidence of incompatibility.
This relies on Go's [release policy](https://go.dev/doc/devel/release#policy) and
[compatibility commitments](https://go.dev/doc/go1compat). Investigate specific
compatibility concerns as they arise. Routine releases require no patch-version
matrix. Record the actual validation toolchain in the release evidence.
A minor release may raise the Go consumer minimum, including from one Go series
to the next, both before and after `v1.0.0`. This is an explicit exception to the
backward-compatibility requirement for minor releases. Patch releases preserve
the consumer minimum. Explain an increase's impact and required consumer action
in the release note. Other incompatible changes remain subject to the versioning
rules above.
## Release Documentation And Preparation ## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged Every new release must include `docs/releases/<version>.md` in the tagged
@@ -133,8 +173,8 @@ checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement. check must not silently become a permanent release requirement.
Release tests must not require real credentials, mutable application services, Release tests must not require real credentials, mutable application services,
paid APIs, or live service probes. The procedure may describe dependency paid APIs, live service probes, or inference calls. The procedure may describe
preparation separately from offline validation. dependency preparation separately from offline validation.
Avoid duplicate validation across release phases. Successful checks for the Avoid duplicate validation across release phases. Successful checks for the
same final candidate need not be repeated solely to satisfy another phase; same final candidate need not be repeated solely to satisfy another phase;
@@ -201,7 +241,8 @@ release without replacing its contents or reusing its version.
Create and maintain `docs/release.md` with the first working publication command Create and maintain `docs/release.md` with the first working publication command
as a short, executable maintainer runbook. It must identify: as a short, executable maintainer runbook. It must identify:
1. prerequisites, primary branch, and authoritative remote; 1. prerequisites, selected release toolchain, primary branch, and authoritative
remote;
2. version and release-note preparation; 2. version and release-note preparation;
3. one publication command with its selected local checks and automated guards; 3. one publication command with its selected local checks and automated guards;
4. declared deliverables, required build targets, and automation responsibilities; 4. declared deliverables, required build targets, and automation responsibilities;

View File

@@ -12,8 +12,36 @@ succeeded. CI and consumer installation are not completion checks.
## Prepare a candidate ## Prepare a candidate
Use a local Go 1.25 toolchain and a clean clone. Start on `main`, choose an Use a clean clone on `main`. The chosen build toolchain is Go 1.26.8. Make that
unused stable `vMAJOR.MINOR.PATCH` version, and add local installation the active `go` command for preparation, building the release
executable, and candidate validation:
```sh
export GOTOOLCHAIN=local
go version
```
Record the reported version with the validation results. The
[Go support policy](policy/release.md#consumer-minimum-and-release-toolchain) owns
series selection, consumer minima, patch exceptions, and validation scope.
The maintained [source-delivery image](../examples/woodpecker/releasekit-source-delivery.yml)
and [consumer template image](../examples/woodpecker/tag-delivery.yml) follow the
chosen build toolchain; update them together when that selection changes.
The current consumer minimum is declared in [go.mod](../go.mod). When preparing
a release that raises it to Go 1.26, the normal declaration is:
```sh
go mod edit -go=1.26.0
```
Retain any higher patch minimum required by dependencies or a specific fix.
Make module changes before committing the candidate. A release preserving the
existing minimum keeps its current declaration. Minimum increases must fit the
[compatibility policy](policy/release.md#versioning-and-compatibility); before
v1.0.0, an increase belongs in a minor release.
Choose an unused stable `vMAJOR.MINOR.PATCH` version, and add
`docs/releases/<version>.md` with a first heading containing `ReleaseKit` and `docs/releases/<version>.md` with a first heading containing `ReleaseKit` and
the exact version. Summarize the change and any compatibility or upgrade impact. the exact version. Summarize the change and any compatibility or upgrade impact.
End the note with the three license links at the immutable source tag: End the note with the three license links at the immutable source tag:
@@ -121,6 +149,7 @@ immutable version from its tag:
```sh ```sh
git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z" git fetch origin "refs/tags/vX.Y.Z:refs/tags/vX.Y.Z"
git checkout --detach "vX.Y.Z" git checkout --detach "vX.Y.Z"
GOTOOLCHAIN=local go mod download
mkdir -p .releasekit/bin mkdir -p .releasekit/bin
GOTOOLCHAIN=local GOPROXY=off GOSUMDB=off go build -o .releasekit/bin/releasekit ./cmd/releasekit 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" 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)

117
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,117 @@
# ReleaseKit v0.3.0
## Summary
ReleaseKit reduces repeated Git processes, validation memory retention, artifact
hashing, output copying, and runtime metadata parsing. It also improves
cancellation during validation shutdown and documents module-selected CLI
adoption and checksum recovery.
## Changes
- Committed-file checks share one lazy Git batch reader per validation operation.
Metadata-only checks start no content reader. Cancellation remains active
through reader shutdown, and cleanup reaps the child before validation returns.
- Validation releases regular-file source bytes after inspection and reuses
compact or parsed results by blob identity for Go formatting, module
replacements, Markdown documents, and symlink targets. Findings retain each
source path's locations and destination context.
- Managed and prebuilt delivery use one local asset verification pass before
hosted requests, removing the adjacent declaration-time hashing pass. Changed
assets still fail verification; exact remote comparisons, additive retries,
and partial-delivery reporting retain their behavior.
- Go commands transfer completed output buffers without an additional full
copy. Status-only successes release their captures, while failures retain
complete diagnostics and underlying causes. Capture remains in memory;
allocation costs depend on output size and write chunking.
- The runtime version accessor caches its build-metadata fallback safely across
calls. Linker-injected versions retain precedence, and the development fallback
remains unchanged.
- Shutdown errors are attributed to the last validation check that actually read
content. Selecting a later check with no applicable files preserves that
attribution, and an earlier check failure remains the primary error.
- CLI-only consumers have documented adoption through versioned Go tool
dependencies. The Woodpecker template supports both tool dependencies and
ordinary package dependencies selected by the tagged consumer module.
- Synthetic-module workflow tests use isolated module caches. New checksum
investigation guidance explains how to distinguish stale local metadata from
a download failure while preserving checksum verification and published tags.
Performance gains depend on the consumer's workload. The changes preserve
complete in-memory diagnostics and existing validation selections.
## Compatibility
The declared minimum Go version increases from 1.25.0 to 1.26.0. Consumers
require Go 1.26.0 or newer; dependency versions are unchanged from v0.2.0.
ReleaseKit's maintainer build toolchain and maintained Woodpecker template images
use Go 1.26.8; consumers select a local toolchain compatible with their module
and the selected ReleaseKit version.
The root orchestration facade, separate lightweight `buildinfo` facade, public
signatures, CLI commands, and profile schema are unchanged. Existing v0.2.0
callers and profiles require no source or configuration migration.
Validation cleanup may now return the caller's cancellation cause where shutdown
previously remained blocked. Cleanup failures identify the check that used the
reader. Inspect structured validation errors with `errors.As` and underlying
causes with `errors.Is`.
Source publication and hosted delivery retain their separate completion states.
ReleaseKit's deliverables remain the source tag and hosted Gitea note page.
## Upgrade guidance
1. Use Go 1.26.0 or newer for local tooling and CI before selecting v0.3.0.
Updating ReleaseKit in a shared module graph can raise the consumer's declared
Go minimum. Select the dependency form appropriate to the consumer.
Go API consumers:
```sh
go get gitea.maximumdirect.net/eric/ReleaseKit@v0.3.0
go mod tidy
```
Consumers using ReleaseKit for repository release commands can register or
upgrade the CLI as a Go tool dependency:
```sh
go get -tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
go mod tidy
go tool releasekit version
```
Review and commit the resulting module changes. API and tool dependencies
share the consumer's module graph and selected ReleaseKit version.
A separately installed executable can be updated with:
```sh
go install gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit@v0.3.0
```
2. Retain existing validation and delivery selections. The performance changes
apply automatically. Prepare dependencies before offline checks or builds,
and run the consumer's ordinary tests after upgrading.
3. For module-selected Woodpecker delivery, commit the dependency upgrade before
tagging the consumer. Choose a compatible local Go image; delivery and retries
continue to use the triggering tag's module selection. CLI-only consumers can
use the tool declaration to retain ReleaseKit through `go mod tidy`.
4. If dependency preparation reports a checksum mismatch, follow the
[checksum investigation guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md#investigating-module-checksum-mismatches).
Repair a confirmed stale cache entry while retaining checksum verification
and the application's recorded checksums.
See the
[adoption guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/adoption.md),
[candidate check guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/check.md),
[delivery guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/delivery.md),
and [runtime version guide](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/docs/consumers/runtime-version.md)
for supported behavior and usage.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.0/LICENSE) |
[ATTRIBUTION](https://gitea.maximumdirect.net/eric/ReleaseKit/src/tag/v0.3.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* ref: refs/tags/v*
steps: steps:
- name: build-tool - name: deliver
image: golang:1.25.0-bookworm image: golang:1.26.8-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
image: golang:1.25.0-bookworm
environment: environment:
RELEASEKIT_GITEA_TOKEN: RELEASEKIT_GITEA_TOKEN:
from_secret: releasekit_gitea_token from_secret: releasekit_gitea_token
commands: 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}" \ --repo "$${CI_WORKSPACE}" \
--commit "$${CI_COMMIT_SHA}" \ --commit "$${CI_COMMIT_SHA}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

View File

@@ -1,7 +1,6 @@
matrix: matrix:
include: include:
- GO_IMAGE: golang:1.25.0-bookworm - GO_IMAGE: golang:1.26.8-bookworm
RELEASEKIT_VERSION: v0.1.0
when: when:
- event: tag - event: tag
@@ -15,7 +14,36 @@ steps:
from_secret: releasekit_gitea_token from_secret: releasekit_gitea_token
commands: 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}" \ --repo "$${CI_WORKSPACE}" \
--commit "$${CI_COMMIT_SHA}" \ --commit "$${CI_COMMIT_SHA}" \
"$${CI_COMMIT_TAG}" "$${CI_COMMIT_TAG}"

7
go.mod
View File

@@ -1,3 +1,8 @@
module gitea.maximumdirect.net/eric/ReleaseKit module gitea.maximumdirect.net/eric/ReleaseKit
go 1.25.0 go 1.26.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 # ReleaseKit ideas
The [product roadmap](docs/roadmap/releasekit.md) records shared release-process The [architecture policy](docs/policy/architecture.md) and
adoption goals and remaining release and deployment work. [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 { if violations := checkExports(pkg); len(violations) != 0 {
return fmt.Errorf("facade %s has invalid public contracts:\n %s", path, strings.Join(violations, "\n ")) 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 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) { func listPackages(ctx context.Context, dir string) ([]listedPackage, error) {
cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...") cmd := exec.CommandContext(ctx, "go", "list", "-deps", "-export", "-json", "./...")
cmd.Dir = dir 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) { func TestActualRepositoryBoundaries(t *testing.T) {
if err := checkRepository(t.Context(), "../.."); err != nil { if err := checkRepository(t.Context(), "../.."); err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -118,7 +118,7 @@ func Build(ctx context.Context, request Request) (Result, error) {
if err != nil { if err != nil {
return result, fmt.Errorf("prepare Go toolchain: %w", err) return result, fmt.Errorf("prepare Go toolchain: %w", err)
} }
compiler, err := goTool.Run(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version") compiler, err := goTool.Output(ctx, repositoryDir, gotool.Native, gotool.Target{}, nil, "version")
if err != nil { if err != nil {
return result, fmt.Errorf("identify Go compiler: %w", err) return result, fmt.Errorf("identify Go compiler: %w", err)
} }
@@ -214,7 +214,7 @@ func gitCommit(ctx context.Context, repositoryDir string) (string, error) {
func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error { func buildTarget(ctx context.Context, goTool *gotool.Tool, repositoryDir, mainPackage, outputPath, version string, target Target) error {
ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version ldflags := "-s -w -X " + versioncontract.InjectedVersionLinkerSymbol + "=" + version
_, err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil, err := goTool.Run(ctx, repositoryDir, gotool.Static, gotool.Target{OS: target.OS, Arch: target.Arch}, nil,
"build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage) "build", "-o", outputPath, "-mod=readonly", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, mainPackage)
return err return err
} }

View File

@@ -11,8 +11,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract" "gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
) )
@@ -29,6 +29,7 @@ type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Validation validation.Settings
AdditionalChecks func(context.Context, Candidate) error AdditionalChecks func(context.Context, Candidate) error
AfterValidate func(context.Context, Candidate) error AfterValidate func(context.Context, Candidate) error
Output io.Writer 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 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. // when supplied, for the captured candidate.
func Validate(ctx context.Context, request Request, captured Candidate) error { func Validate(ctx context.Context, request Request, captured Candidate) error {
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) return fmt.Errorf("validate candidate %s: %w", captured.Commit, err)
} }
if request.AdditionalChecks != nil { if request.AdditionalChecks != nil {
@@ -214,16 +220,6 @@ func hasLocalTag(ctx context.Context, repositoryDir, version string) (bool, erro
return false, err 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 { func validStableVersion(version string) bool {
return versioncontract.ValidStableVersion(version) return versioncontract.ValidStableVersion(version)
} }

View File

@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
const testVersion = "v1.2.3" const testVersion = "v1.2.3"
@@ -43,12 +45,16 @@ func TestValidStableVersion(t *testing.T) {
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) { func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n")
gitRun(t, repository, "add", "guide.md")
gitRun(t, repository, "commit", "-m", "add Markdown guide")
calls := 0 calls := 0
var seen Candidate var seen Candidate
candidate, err := Check(context.Background(), Request{ candidate, err := Check(context.Background(), Request{
RepositoryDir: repository, RepositoryDir: repository,
Product: "Example Product", Product: "Example Product",
Version: testVersion, Version: testVersion,
Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}},
AdditionalChecks: func(_ context.Context, captured Candidate) error { AdditionalChecks: func(_ context.Context, captured Candidate) error {
calls++ calls++
seen = captured 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) { func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n") repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer var output bytes.Buffer
@@ -123,6 +237,14 @@ func TestDefaultValidationDoesNotInheritGOFLAGSRunFilter(t *testing.T) {
if err == nil || !strings.Contains(output.String(), "TestExpectedFailure") { 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()) 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,10 +1,7 @@
package delivery package delivery
import ( import (
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
@@ -53,22 +50,6 @@ func declaredBuildAssets(result internalbuild.Result) ([]gitea.ExpectedAsset, er
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename) return nil, fmt.Errorf("build artifact %q is not a regular file", artifact.Filename)
} }
file, err := os.Open(artifact.Path)
if err != nil {
return nil, fmt.Errorf("open build artifact %q: %w", artifact.Filename, err)
}
hash := sha256.New()
_, copyErr := io.Copy(hash, file)
closeErr := file.Close()
if copyErr != nil {
return nil, fmt.Errorf("hash build artifact %q: %w", artifact.Filename, copyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("close build artifact %q: %w", artifact.Filename, closeErr)
}
if artifact.SHA256 != hex.EncodeToString(hash.Sum(nil)) {
return nil, fmt.Errorf("build artifact %q does not match its declared SHA-256 digest", artifact.Filename)
}
assets = append(assets, gitea.ExpectedAsset{ assets = append(assets, gitea.ExpectedAsset{
Filename: artifact.Filename, Filename: artifact.Filename,
SHA256: artifact.SHA256, SHA256: artifact.SHA256,

View File

@@ -47,20 +47,6 @@ func TestDeclaredBuildAssetsGenerateManifestFromBuildResult(t *testing.T) {
} }
} }
func TestDeclaredBuildAssetsRejectsTamperedArtifact(t *testing.T) {
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-amd64")
if err := os.WriteFile(path, []byte("changed bytes"), 0o600); err != nil {
t.Fatal(err)
}
_, err := declaredBuildAssets(internalbuild.Result{
Version: "v1.2.3", BinaryName: "app", Targets: []internalbuild.Target{{OS: "linux", Arch: "amd64"}},
Artifacts: []internalbuild.Artifact{{Path: path, Filename: filepath.Base(path), Target: internalbuild.Target{OS: "linux", Arch: "amd64"}, SHA256: assetDigest([]byte("original bytes"))}},
})
if err == nil || !strings.Contains(err.Error(), "SHA-256") {
t.Fatalf("declaredBuildAssets() error = %v, want digest mismatch", err)
}
}
func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) { func TestDeclaredBuildAssetsRejectsArtifactDeclarationMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64") path := filepath.Join(t.TempDir(), "app-v1.2.3-linux-arm64")
contents := []byte("built application") contents := []byte("built application")

View File

@@ -0,0 +1,85 @@
package delivery
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"testing"
internalbuild "gitea.maximumdirect.net/eric/ReleaseKit/internal/build"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
const performanceArtifactSize = 16 << 20
func BenchmarkPerformanceAssetPreflight(b *testing.B) {
result, totalBytes := performanceBuildResult(b)
declarations, err := declaredBuildAssets(result)
if err != nil {
b.Fatal(err)
}
b.Run("Declarations", func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(totalBytes)
b.ResetTimer()
b.ReportMetric(float64(totalBytes), "bytes/op")
for i := 0; i < b.N; i++ {
assets, err := declaredBuildAssets(result)
if err != nil || len(assets) != len(declarations) {
b.Fatalf("declaredBuildAssets() returned %d assets, err %v", len(assets), err)
}
}
})
b.Run("DeclarationsAndLocalVerification", func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(totalBytes)
b.ResetTimer()
b.ReportMetric(float64(totalBytes), "bytes/op")
for i := 0; i < b.N; i++ {
assets, err := declaredBuildAssets(result)
if err != nil {
b.Fatal(err)
}
if err := gitea.VerifyLocalAssets(assets); err != nil {
b.Fatal(err)
}
}
})
}
func performanceBuildResult(b *testing.B) (internalbuild.Result, int64) {
b.Helper()
outputDir := b.TempDir()
data := make([]byte, performanceArtifactSize)
for i := range data {
data[i] = byte((i*31 + i/251 + 17) % 251)
}
digest := sha256.Sum256(data)
result := internalbuild.Result{
Version: "v1.2.3",
BinaryName: "releasekit",
Targets: []internalbuild.Target{
{OS: "linux", Arch: "amd64"},
{OS: "windows", Arch: "amd64"},
},
}
for _, target := range result.Targets {
name := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
if target.OS == "windows" {
name += ".exe"
}
path := filepath.Join(outputDir, name)
if err := os.WriteFile(path, data, 0o600); err != nil {
b.Fatal(err)
}
result.Artifacts = append(result.Artifacts, internalbuild.Artifact{
Path: path, Filename: name, Target: target,
SHA256: hex.EncodeToString(digest[:]),
})
}
return result, 2 * performanceArtifactSize
}

222
internal/git/blob_reader.go Normal file
View File

@@ -0,0 +1,222 @@
package git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"sync"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
)
var errBlobObjectMissing = errors.New("Git object is missing")
// BlobReader reads requested blobs through one lazy git cat-file --batch
// process. Reads are serialized; callers must close the reader when finished.
type BlobReader struct {
repositoryDir string
parent context.Context
mu sync.Mutex
child *process.PipedProcess
stdout *bufio.Reader
stderr bytes.Buffer
closed bool
aborted bool
terminal error
closeErr error
}
// NewBlobReader creates a lazy reader for one repository. It starts no child
// process until the first valid blob is requested.
func NewBlobReader(ctx context.Context, repositoryDir string) *BlobReader {
return &BlobReader{repositoryDir: repositoryDir, parent: ctx}
}
// Read returns one blob body from the candidate repository. The returned bytes
// are newly allocated and owned by the caller.
func (reader *BlobReader) Read(ctx context.Context, objectID string) ([]byte, error) {
if !validObjectID(objectID) {
return nil, fmt.Errorf("invalid blob object ID %q", objectID)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
reader.mu.Lock()
defer reader.mu.Unlock()
if reader.terminal != nil {
return nil, reader.terminal
}
if reader.closed {
return nil, fmt.Errorf("read blob %s: Git blob reader is closed", objectID)
}
if err := reader.startLocked(); err != nil {
return nil, fmt.Errorf("read blob %s: %w", objectID, err)
}
child := reader.child
callbackDone := make(chan struct{})
stopCancellation := context.AfterFunc(ctx, func() {
defer close(callbackDone)
child.Cancel()
_ = child.Stdin().Close()
_ = child.Stdout().Close()
})
body, err := reader.exchange(objectID)
if !stopCancellation() {
<-callbackDone
}
if contextErr := ctx.Err(); contextErr != nil {
err = contextErr
} else if contextErr := reader.parent.Err(); contextErr != nil {
err = contextErr
}
if errors.Is(err, errBlobObjectMissing) {
return nil, err
}
if err != nil {
return nil, reader.abortLocked(objectID, err)
}
return body, nil
}
// Close closes a healthy session cleanly or reaps an aborted session. It is
// safe to call more than once.
func (reader *BlobReader) Close() error {
reader.mu.Lock()
defer reader.mu.Unlock()
if reader.closed {
return reader.closeErr
}
reader.closed = true
if reader.aborted {
reader.closeErr = reader.terminal
return reader.closeErr
}
if reader.child == nil {
return nil
}
if err := reader.child.Stdin().Close(); err != nil {
reader.closeErr = reader.abortLocked("", fmt.Errorf("close Git cat-file input: %w", err))
return reader.closeErr
}
if _, err := io.Copy(io.Discard, reader.child.Stdout()); err != nil {
reader.closeErr = reader.abortLocked("", fmt.Errorf("finish Git cat-file output: %w", err))
return reader.closeErr
}
if err := reader.child.Wait(); err != nil {
reader.closeErr = reader.processError("close Git cat-file batch", err)
}
return reader.closeErr
}
func (reader *BlobReader) startLocked() error {
if reader.child != nil {
return nil
}
if err := reader.parent.Err(); err != nil {
return err
}
child, err := process.StartPiped(reader.parent, process.Invocation{
Args: []string{"git", "cat-file", "--batch"},
Dir: reader.repositoryDir,
Env: map[string]string{
"GIT_NO_LAZY_FETCH": "1",
"GIT_ALLOW_PROTOCOL": "",
},
Stderr: &reader.stderr,
})
if err != nil {
return err
}
reader.child = child
reader.stdout = bufio.NewReader(child.Stdout())
return nil
}
func (reader *BlobReader) exchange(objectID string) ([]byte, error) {
if _, err := io.WriteString(reader.child.Stdin(), objectID+"\n"); err != nil {
return nil, fmt.Errorf("write object request: %w", err)
}
header, err := reader.stdout.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read object header: %w", err)
}
fields := strings.Fields(strings.TrimSuffix(header, "\n"))
if len(fields) == 2 && strings.EqualFold(fields[0], objectID) && fields[1] == "missing" {
return nil, fmt.Errorf("read blob %s: %w", objectID, errBlobObjectMissing)
}
if len(fields) != 3 {
return nil, fmt.Errorf("invalid Git blob header %q", strings.TrimSuffix(header, "\n"))
}
if !strings.EqualFold(fields[0], objectID) {
return nil, fmt.Errorf("Git blob response object ID %q does not match request %q", fields[0], objectID)
}
if fields[1] != "blob" {
return nil, fmt.Errorf("Git object %s has type %q, want blob", objectID, fields[1])
}
size, err := strconv.ParseInt(fields[2], 10, strconv.IntSize)
if err != nil || size < 0 {
if err == nil {
err = errors.New("negative object size")
}
return nil, fmt.Errorf("invalid Git blob size %q: %w", fields[2], err)
}
body := make([]byte, int(size))
if _, err := io.ReadFull(reader.stdout, body); err != nil {
return nil, fmt.Errorf("read %d-byte Git blob body: %w", size, err)
}
framing, err := reader.stdout.ReadByte()
if err != nil {
return nil, fmt.Errorf("read Git blob framing: %w", err)
}
if framing != '\n' {
return nil, fmt.Errorf("invalid Git blob framing byte %q", framing)
}
return body, nil
}
func (reader *BlobReader) abortLocked(objectID string, cause error) error {
if reader.aborted {
return reader.terminal
}
reader.aborted = true
var waitErr error
if reader.child != nil {
reader.child.Cancel()
_ = reader.child.Stdin().Close()
_ = reader.child.Stdout().Close()
waitErr = reader.child.Wait()
}
if contextErr := reader.parent.Err(); contextErr != nil {
cause = contextErr
}
reader.terminal = reader.processError("read blob "+objectID, errors.Join(cause, relevantProcessError(waitErr, cause)))
return reader.terminal
}
func (reader *BlobReader) processError(operation string, cause error) error {
message := strings.TrimSpace(reader.stderr.String())
if message != "" {
return fmt.Errorf("%s: %s: %w", operation, message, cause)
}
return fmt.Errorf("%s: %w", operation, cause)
}
func relevantProcessError(waitErr, cause error) error {
if waitErr == nil || errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
return nil
}
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == -1 {
return nil
}
return waitErr
}

View File

@@ -0,0 +1,195 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBlobReaderReadsLooseAndPackedObjects(t *testing.T) {
for _, packed := range []bool{false, true} {
name := "loose"
if packed {
name = "packed"
}
t.Run(name, func(t *testing.T) {
fixture := newRemoteFixture(t)
binary := []byte{0, '\n', 0xff, 'b', '\n', 0}
writeRepoFile(t, fixture.work, "empty.dat", "")
writeRepoFile(t, fixture.work, "binary.dat", string(binary))
gitRun(t, fixture.work, "add", ".")
gitRun(t, fixture.work, "commit", "-m", "add binary fixtures")
want := map[string][]byte{
gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"): []byte("candidate"),
gitText(t, fixture.work, "rev-parse", "HEAD:empty.dat"): {},
gitText(t, fixture.work, "rev-parse", "HEAD:binary.dat"): binary,
}
if packed {
gitRun(t, fixture.work, "gc", "--aggressive", "--prune=now")
}
reader := NewBlobReader(t.Context(), fixture.work)
defer reader.Close()
for repeat := 0; repeat < 2; repeat++ {
for objectID, expected := range want {
got, err := reader.Read(t.Context(), objectID)
if err != nil {
t.Fatalf("Read(%s) error = %v", objectID, err)
}
if !bytes.Equal(got, expected) {
t.Errorf("Read(%s) = %v, want %v", objectID, got, expected)
}
}
}
})
}
}
func TestBlobReaderMissingObjectKeepsBatchSessionUsable(t *testing.T) {
fixture := newRemoteFixture(t)
objectID := gitText(t, fixture.work, "rev-parse", "HEAD:source.txt")
reader := NewBlobReader(t.Context(), fixture.work)
defer reader.Close()
missing := strings.Repeat("0", 40)
if _, err := reader.Read(t.Context(), missing); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Read(missing) error = %v, want contextual missing-object error", err)
}
contents, err := reader.Read(t.Context(), objectID)
if err != nil || string(contents) != "candidate" {
t.Fatalf("Read(existing) = %q, %v after missing request", contents, err)
}
}
func TestBlobReaderPreservesHexCaseAndArbitraryBodyBytes(t *testing.T) {
installFakeGit(t, `while IFS= read -r object; do
upper=$(printf '%s' "$object" | tr 'a-f' 'A-F')
printf '%s blob 4\n' "$upper"
printf 'a\000b\n\n'
done`)
objectID := strings.Repeat("abcdef0123", 4)
reader := NewBlobReader(t.Context(), t.TempDir())
contents, err := reader.Read(t.Context(), objectID)
if err != nil {
t.Fatal(err)
}
if want := []byte{'a', 0, 'b', '\n'}; !bytes.Equal(contents, want) {
t.Fatalf("Read() = %v, want %v", contents, want)
}
if err := reader.Close(); err != nil {
t.Fatal(err)
}
}
func TestBlobReaderRejectsInvalidHeadersAndFraming(t *testing.T) {
for _, testCase := range []struct {
name string
response string
want string
}{
{name: "wrong object", response: `printf '%040d blob 0\n\n' 0`, want: "does not match request"},
{name: "wrong type", response: `printf '%s tree 0\n\n' "$object"`, want: `has type "tree"`},
{name: "invalid size", response: `printf '%s blob -1\n' "$object"`, want: "invalid Git blob size"},
{name: "invalid framing", response: `printf '%s blob 0\nx' "$object"`, want: "invalid Git blob framing"},
} {
t.Run(testCase.name, func(t *testing.T) {
installFakeGit(t, "IFS= read -r object\n"+testCase.response)
reader := NewBlobReader(t.Context(), t.TempDir())
_, err := reader.Read(t.Context(), strings.Repeat("d", 40))
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("Read() error = %v, want %q", err, testCase.want)
}
if closeErr := reader.Close(); !strings.Contains(closeErr.Error(), testCase.want) {
t.Fatalf("Close() error = %v, want original protocol error", closeErr)
}
})
}
}
func TestBlobReaderTruncatedBodyAbortsAndKeepsOriginalCause(t *testing.T) {
installFakeGit(t, `IFS= read -r object
printf '%s blob 8\n' "$object"
printf 'short'
exit 0`)
reader := NewBlobReader(t.Context(), t.TempDir())
_, err := reader.Read(t.Context(), strings.Repeat("a", 40))
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("Read() error = %v, want truncated-body cause", err)
}
if closeErr := reader.Close(); !errors.Is(closeErr, io.ErrUnexpectedEOF) {
t.Fatalf("Close() error = %v, want original read cause", closeErr)
}
}
func TestBlobReaderCancellationDuringBodyReadReapsChild(t *testing.T) {
ready := filepath.Join(t.TempDir(), "ready")
installFakeGit(t, `IFS= read -r object
printf '%s blob 1048576\npartial' "$object"
: > "$RELEASEKIT_GIT_READER_READY"
exec sleep 60`)
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
ctx, cancel := context.WithCancel(t.Context())
reader := NewBlobReader(ctx, t.TempDir())
result := make(chan error, 1)
go func() {
_, err := reader.Read(ctx, strings.Repeat("b", 40))
result <- err
}()
waitForFile(t, ready)
cancel()
select {
case err := <-result:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Read() error = %v, want context cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Read() did not return after cancellation")
}
if err := reader.Close(); !errors.Is(err, context.Canceled) {
t.Fatalf("Close() error = %v, want original cancellation cause", err)
}
}
func TestBlobReaderPreservesGitFailureAndStderr(t *testing.T) {
installFakeGit(t, `printf 'fixture Git failure\n' >&2
exit 23`)
reader := NewBlobReader(t.Context(), t.TempDir())
_, err := reader.Read(t.Context(), strings.Repeat("c", 40))
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 23 {
t.Fatalf("Read() error = %v, want Git exit code 23", err)
}
if !strings.Contains(err.Error(), "fixture Git failure") {
t.Fatalf("Read() error = %v, want captured Git stderr", err)
}
}
func installFakeGit(t *testing.T, script string) {
t.Helper()
directory := t.TempDir()
path := filepath.Join(directory, "git")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script+"\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func waitForFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatal(fmt.Errorf("inspect readiness signal: %w", err))
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("child did not create readiness signal")
}

View File

@@ -0,0 +1,59 @@
package git
import (
"context"
"runtime"
"testing"
)
func BenchmarkPerformanceBlobReader(b *testing.B) {
fixture := newRemoteFixture(b)
objectID := gitText(b, fixture.work, "rev-parse", "HEAD:source.txt")
const expectedBytes = int64(len("candidate"))
b.Run("new-session-per-read", func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(expectedBytes)
b.ResetTimer()
b.ReportMetric(float64(expectedBytes), "bytes/op")
b.ReportMetric(1, "blobs/op")
for i := 0; i < b.N; i++ {
reader := NewBlobReader(context.Background(), fixture.work)
contents, err := reader.Read(context.Background(), objectID)
if err != nil {
b.Fatal(err)
}
if int64(len(contents)) != expectedBytes {
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
}
if err := reader.Close(); err != nil {
b.Fatal(err)
}
runtime.KeepAlive(contents)
}
})
b.Run("shared-session", func(b *testing.B) {
reader := NewBlobReader(context.Background(), fixture.work)
b.Cleanup(func() {
if err := reader.Close(); err != nil {
b.Error(err)
}
})
b.ReportAllocs()
b.SetBytes(expectedBytes)
b.ResetTimer()
b.ReportMetric(float64(expectedBytes), "bytes/op")
b.ReportMetric(1, "blobs/op")
for i := 0; i < b.N; i++ {
contents, err := reader.Read(context.Background(), objectID)
if err != nil {
b.Fatal(err)
}
if int64(len(contents)) != expectedBytes {
b.Fatalf("read %d bytes, want %d", len(contents), expectedBytes)
}
runtime.KeepAlive(contents)
}
})
}

View File

@@ -164,7 +164,7 @@ type remoteFixture struct {
commit string commit string
} }
func newRemoteFixture(t *testing.T) remoteFixture { func newRemoteFixture(t testing.TB) remoteFixture {
t.Helper() t.Helper()
base := t.TempDir() base := t.TempDir()
work := filepath.Join(base, "work") work := filepath.Join(base, "work")
@@ -194,7 +194,7 @@ func resolveFixture(t *testing.T, repo remoteFixture) Endpoint {
return endpoint return endpoint
} }
func gitRun(t *testing.T, dir string, args ...string) { func gitRun(t testing.TB, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
@@ -203,7 +203,7 @@ func gitRun(t *testing.T, dir string, args ...string) {
} }
} }
func gitText(t *testing.T, dir string, args ...string) string { func gitText(t testing.TB, dir string, args ...string) string {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
@@ -214,7 +214,7 @@ func gitText(t *testing.T, dir string, args ...string) string {
return strings.TrimSuffix(string(outputBytes), "\n") return strings.TrimSuffix(string(outputBytes), "\n")
} }
func writeRepoFile(t *testing.T, repoDir, name, contents string) { func writeRepoFile(t testing.TB, repoDir, name, contents string) {
t.Helper() t.Helper()
path := filepath.Join(repoDir, name) path := filepath.Join(repoDir, name)
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {

View File

@@ -1,12 +1,27 @@
package git package git
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"path" "path"
"strconv"
"strings" "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 { type TaggedSource struct {
Head string Head string
Status string Status string
@@ -74,3 +89,126 @@ func ReadCommitFile(ctx context.Context, repositoryDir, commit, name string) ([]
} }
return []byte(content), nil 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 ( import (
"context" "context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -28,6 +32,54 @@ 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)
}
reader := NewBlobReader(t.Context(), repository)
if _, err := reader.Read(t.Context(), blob); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("batch blob read error = %v, want missing-object inspection error", err)
}
if err := reader.Close(); err != nil {
t.Fatalf("close missing-object batch reader: %v", 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) { func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
fixture := newRemoteFixture(t) fixture := newRemoteFixture(t)
gitRun(t, fixture.work, "tag", "v1.2.3") gitRun(t, fixture.work, "tag", "v1.2.3")

View File

@@ -105,7 +105,7 @@ func PrepareBuildDependencies(ctx context.Context, directory string) error {
Stderr: captured, Stderr: captured,
}) })
if err != nil { if err != nil {
if output := strings.TrimSpace(string(captured.Bytes())); output != "" { if output := strings.TrimSpace(string(captured.take())); output != "" {
return fmt.Errorf("go mod download: %w: %s", err, output) return fmt.Errorf("go mod download: %w: %s", err, output)
} }
return fmt.Errorf("go mod download: %w", err) return fmt.Errorf("go mod download: %w", err)
@@ -121,8 +121,19 @@ func (tool *Tool) HostTarget() Target {
// Run executes a Go command with ReleaseKit's shared offline baseline. Native // Run executes a Go command with ReleaseKit's shared offline baseline. Native
// commands use the host target and effective cgo configuration. Static commands // commands use the host target and effective cgo configuration. Static commands
// use target and disable cgo. Output, when supplied, receives stdout and stderr // use target and disable cgo. Output, when supplied, receives stdout and stderr
// as the command runs; the returned bytes retain both streams for diagnostics. // as the command runs.
func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) { func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) error {
_, err := tool.execute(ctx, directory, mode, target, output, false, args...)
return err
}
// Output executes a Go command and returns its combined stdout and stderr.
// Output, when supplied, also receives the bytes as the command runs.
func (tool *Tool) Output(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, args ...string) ([]byte, error) {
return tool.execute(ctx, directory, mode, target, output, true, args...)
}
func (tool *Tool) execute(ctx context.Context, directory string, mode Mode, target Target, output io.Writer, keepOutput bool, args ...string) ([]byte, error) {
if len(args) == 0 { if len(args) == 0 {
return nil, errors.New("Go command requires arguments") return nil, errors.New("Go command requires arguments")
} }
@@ -136,14 +147,24 @@ func (tool *Tool) Run(ctx context.Context, directory string, mode Mode, target T
Stdout: captured, Stdout: captured,
Stderr: captured, Stderr: captured,
}) })
contents := captured.Bytes() if forwardErr := captured.forwardingError(); forwardErr != nil {
err = errors.Join(err, fmt.Errorf("forward Go output: %w", forwardErr))
}
contents := captured.take()
if err != nil { if err != nil {
if len(contents) != 0 { if len(contents) != 0 {
return contents, fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents))) return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(contents)))
} }
return contents, fmt.Errorf("go %s: %w", strings.Join(args, " "), err) return outputBytes(contents, keepOutput), fmt.Errorf("go %s: %w", strings.Join(args, " "), err)
} }
return contents, nil return outputBytes(contents, keepOutput), nil
}
func outputBytes(contents []byte, keep bool) []byte {
if keep {
return contents
}
return nil
} }
func (tool *Tool) environment(mode Mode, target Target) map[string]string { func (tool *Tool) environment(mode Mode, target Target) map[string]string {
@@ -206,9 +227,10 @@ func discover(ctx context.Context, directory string, environment map[string]stri
} }
type combinedWriter struct { type combinedWriter struct {
mu sync.Mutex mu sync.Mutex
output io.Writer output io.Writer
buffer bytes.Buffer buffer bytes.Buffer
forwardErr error
} }
func (writer *combinedWriter) Write(contents []byte) (int, error) { func (writer *combinedWriter) Write(contents []byte) (int, error) {
@@ -217,11 +239,29 @@ func (writer *combinedWriter) Write(contents []byte) (int, error) {
if writer.output == nil { if writer.output == nil {
return writer.buffer.Write(contents) return writer.buffer.Write(contents)
} }
return io.MultiWriter(&writer.buffer, writer.output).Write(contents) if _, err := writer.buffer.Write(contents); err != nil {
return 0, err
}
written, err := writer.output.Write(contents)
if err == nil && written != len(contents) {
err = io.ErrShortWrite
}
if err != nil && writer.forwardErr == nil {
writer.forwardErr = err
}
return written, err
} }
func (writer *combinedWriter) Bytes() []byte { func (writer *combinedWriter) forwardingError() error {
writer.mu.Lock() writer.mu.Lock()
defer writer.mu.Unlock() defer writer.mu.Unlock()
return append([]byte(nil), writer.buffer.Bytes()...) return writer.forwardErr
}
// take transfers the completed capture to the caller. It must only be used
// after the child process has stopped writing.
func (writer *combinedWriter) take() []byte {
writer.mu.Lock()
defer writer.mu.Unlock()
return writer.buffer.Bytes()
} }

View File

@@ -1,6 +1,7 @@
package gotool package gotool
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -62,6 +63,14 @@ func runGoPreparationFixture() int {
case "fail": case "fail":
fmt.Fprintln(os.Stderr, "fixture dependency failure") fmt.Fprintln(os.Stderr, "fixture dependency failure")
return 23 return 23
case "output":
fmt.Fprintln(os.Stdout, "fixture stdout")
fmt.Fprintln(os.Stderr, "fixture stderr")
return 0
case "large-fail":
_, _ = fmt.Fprint(os.Stdout, strings.Repeat("x", 8<<20))
fmt.Fprintln(os.Stderr, "fixture large failure")
return 23
case "wait": case "wait":
for { for {
time.Sleep(time.Second) time.Sleep(time.Second)
@@ -295,7 +304,7 @@ func TestPersistentSettingsPreserveCachesAndNativeCTools(t *testing.T) {
func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string { func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...string) map[string]string {
t.Helper() t.Helper()
args := append([]string{"env", "-json"}, names...) args := append([]string{"env", "-json"}, names...)
output, err := tool.Run(context.Background(), t.TempDir(), mode, target, nil, args...) output, err := tool.Output(context.Background(), t.TempDir(), mode, target, nil, args...)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -306,6 +315,122 @@ func readSettings(t *testing.T, tool *Tool, mode Mode, target Target, names ...s
return settings return settings
} }
func TestOutputReturnsAndForwardsCombinedOutput(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
tool := fixtureTool()
var forwarded bytes.Buffer
output, err := tool.Output(context.Background(), repository, Native, Target{}, &forwarded, "version")
if err != nil {
t.Fatalf("Output() error = %v", err)
}
if !bytes.Contains(output, []byte("fixture stdout")) || !bytes.Contains(output, []byte("fixture stderr")) {
t.Fatalf("combined output = %q", output)
}
if !bytes.Equal(forwarded.Bytes(), output) {
t.Fatalf("forwarded output differs from capture: forwarded %d bytes, captured %d", forwarded.Len(), len(output))
}
}
func TestRunForwardsOutputWithoutReturningCapture(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
tool := fixtureTool()
var forwarded bytes.Buffer
if err := tool.Run(context.Background(), repository, Native, Target{}, &forwarded, "version"); err != nil {
t.Fatalf("Run() error = %v", err)
}
if !bytes.Contains(forwarded.Bytes(), []byte("fixture stdout")) || !bytes.Contains(forwarded.Bytes(), []byte("fixture stderr")) {
t.Fatalf("forwarded output = %q", forwarded.String())
}
}
func TestRunPreservesFailureDiagnosticsAndCause(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "fail")
tool := fixtureTool()
err := tool.Run(context.Background(), repository, Native, Target{}, nil, "version")
var exitError *exec.ExitError
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture dependency failure") {
t.Fatalf("Run() error = %v", err)
}
}
func TestOutputReturnsLargeFailureDiagnosticsAndCause(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "large-fail")
tool := fixtureTool()
output, err := tool.Output(context.Background(), repository, Native, Target{}, nil, "version")
var exitError *exec.ExitError
wantSize := (8 << 20) + len("fixture large failure\n")
if len(output) != wantSize {
t.Fatalf("captured output = %d bytes, want %d", len(output), wantSize)
}
if !errors.As(err, &exitError) || exitError.ExitCode() != 23 || !strings.Contains(err.Error(), "fixture large failure") {
t.Fatalf("Output() error = %v", err)
}
}
func TestOutputReturnsCancellation(t *testing.T) {
repository, recordPath := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "wait")
tool := fixtureTool()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() {
_, err := tool.Output(ctx, repository, Native, Target{}, nil, "version")
done <- err
}()
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(recordPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("Go fixture did not start")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Output() error = %v; want cancellation", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Go fixture did not stop after cancellation")
}
}
func TestOutputPreservesForwardingWriterFailure(t *testing.T) {
repository, _ := installGoPreparationFixture(t)
t.Setenv("RELEASEKIT_GO_FIXTURE_MODE", "output")
tool := fixtureTool()
writerErr := errors.New("fixture output writer failed")
output, err := tool.Output(context.Background(), repository, Native, Target{}, failingOutputWriter{err: writerErr}, "version")
if !errors.Is(err, writerErr) || !strings.Contains(err.Error(), "go version") {
t.Fatalf("Output() error = %v; want wrapped writer error and command", err)
}
if len(output) == 0 {
t.Fatal("Output() returned no captured bytes after forwarding failed")
}
}
type failingOutputWriter struct{ err error }
func (writer failingOutputWriter) Write([]byte) (int, error) { return 0, writer.err }
func fixtureTool() *Tool {
return &Tool{host: Target{OS: runtime.GOOS, Arch: runtime.GOARCH}, settings: map[string]string{}}
}
func clearGoSettings(t *testing.T) { func clearGoSettings(t *testing.T) {
t.Helper() t.Helper()
for _, key := range []string{ for _, key := range []string{

View File

@@ -0,0 +1,55 @@
package gotool
import (
"bytes"
"io"
"testing"
)
const performanceDiagnosticSize = 8 << 20
func BenchmarkPerformanceCombinedOutput(b *testing.B) {
for _, testCase := range []struct {
name string
size int
chunkSize int
}{
{name: "modest", size: 4 << 10, chunkSize: 4 << 10},
{name: "diagnostic-8MiB", size: performanceDiagnosticSize, chunkSize: performanceDiagnosticSize},
{name: "diagnostic-8MiB-32KiB-chunks", size: performanceDiagnosticSize, chunkSize: 32 << 10},
} {
input := bytes.Repeat([]byte("go tool diagnostic output\n"), (testCase.size+25)/26)
input = input[:testCase.size]
b.Run(testCase.name+"/discard-forwarding", func(b *testing.B) {
// Verify capture and forwarding for this workload outside the measurement.
var forwarded bytes.Buffer
verification := &combinedWriter{output: &forwarded}
for offset := 0; offset < len(input); offset += testCase.chunkSize {
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
if written, err := verification.Write(chunk); err != nil || written != len(chunk) {
b.Fatalf("verify output write = %d, err %v; want %d bytes", written, err, len(chunk))
}
}
if !bytes.Equal(verification.take(), input) || !bytes.Equal(forwarded.Bytes(), input) {
b.Fatal("captured and forwarded output must equal the complete input")
}
b.ReportAllocs()
b.SetBytes(int64(len(input)))
b.ResetTimer()
b.ReportMetric(float64(len(input)), "bytes/op")
for i := 0; i < b.N; i++ {
writer := &combinedWriter{output: io.Discard}
for offset := 0; offset < len(input); offset += testCase.chunkSize {
chunk := input[offset:min(offset+testCase.chunkSize, len(input))]
if written, err := writer.Write(chunk); err != nil || written != len(chunk) {
b.Fatalf("write captured output = %d, err %v; want %d bytes", written, err, len(chunk))
}
}
captured := writer.take()
if len(captured) != len(input) {
b.Fatalf("captured output = %d bytes, want %d", len(captured), len(input))
}
}
})
}
}

View File

@@ -10,6 +10,7 @@ import (
"os/exec" "os/exec"
"sort" "sort"
"strings" "strings"
"sync"
) )
// Invocation describes one child process. UnsetEnv removes inherited values // Invocation describes one child process. UnsetEnv removes inherited values
@@ -19,6 +20,7 @@ type Invocation struct {
Dir string Dir string
Env map[string]string Env map[string]string
UnsetEnv []string UnsetEnv []string
Stdin io.Reader
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
} }
@@ -26,15 +28,11 @@ type Invocation struct {
// Run starts the command in the requested directory and waits for it to exit. // Run starts the command in the requested directory and waits for it to exit.
// Errors preserve the underlying exec error, including *exec.ExitError. // Errors preserve the underlying exec error, including *exec.ExitError.
func Run(ctx context.Context, invocation Invocation) error { func Run(ctx context.Context, invocation Invocation) error {
if len(invocation.Args) == 0 || invocation.Args[0] == "" { command, err := newCommand(ctx, invocation)
return errors.New("process invocation requires a command") if err != nil {
} return err
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
} }
command.Stdin = invocation.Stdin
command.Stdout = invocation.Stdout command.Stdout = invocation.Stdout
command.Stderr = invocation.Stderr command.Stderr = invocation.Stderr
@@ -47,6 +45,102 @@ func Run(ctx context.Context, invocation Invocation) error {
return nil return nil
} }
// PipedProcess owns the command started by StartPiped. Callers close Stdin,
// read Stdout to EOF, and call Wait once after a normal exchange. On an abort,
// close active pipes, call Cancel, and then Wait to reap the child.
type PipedProcess struct {
command *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
parent context.Context
cancel context.CancelFunc
waitOnce sync.Once
waitErr error
}
// Stdin returns the child process input pipe. The caller owns its closure.
func (child *PipedProcess) Stdin() io.WriteCloser {
return child.stdin
}
// Stdout returns the child process output pipe. The caller must consume it
// before Wait on a normal exit and owns its closure on an abort.
func (child *PipedProcess) Stdout() io.ReadCloser {
return child.stdout
}
// Cancel interrupts the child without cancelling its parent context.
func (child *PipedProcess) Cancel() {
child.cancel()
}
// Wait waits for and reaps the child. It preserves the parent's cancellation
// cause and otherwise returns the command's wrapped exit error.
func (child *PipedProcess) Wait() error {
child.waitOnce.Do(func() {
err := child.command.Wait()
child.cancel()
if err == nil {
return
}
if contextErr := child.parent.Err(); contextErr != nil {
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], contextErr)
return
}
child.waitErr = fmt.Errorf("run %q: %w", child.command.Args[0], err)
})
return child.waitErr
}
// StartPiped starts a child with stdin and stdout connected to pipes. Command
// settings come from invocation; its Stdin and Stdout fields are ignored, while
// Stderr remains routed as requested.
func StartPiped(ctx context.Context, invocation Invocation) (*PipedProcess, error) {
childContext, cancel := context.WithCancel(ctx)
command, err := newCommand(childContext, invocation)
if err != nil {
cancel()
return nil, err
}
command.Stderr = invocation.Stderr
stdin, err := command.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("pipe stdin for %q: %w", invocation.Args[0], err)
}
stdout, err := command.StdoutPipe()
if err != nil {
_ = stdin.Close()
cancel()
return nil, fmt.Errorf("pipe stdout for %q: %w", invocation.Args[0], err)
}
if err := command.Start(); err != nil {
_ = stdin.Close()
_ = stdout.Close()
cancel()
return nil, fmt.Errorf("start %q: %w", invocation.Args[0], err)
}
return &PipedProcess{
command: command,
stdin: stdin,
stdout: stdout,
parent: ctx,
cancel: cancel,
}, nil
}
func newCommand(ctx context.Context, invocation Invocation) (*exec.Cmd, error) {
if len(invocation.Args) == 0 || invocation.Args[0] == "" {
return nil, errors.New("process invocation requires a command")
}
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
command.Dir = invocation.Dir
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
}
return command, nil
}
func environmentEntries(overlay map[string]string, unset []string) []string { func environmentEntries(overlay map[string]string, unset []string) []string {
environment := make(map[string]string, len(os.Environ())+len(overlay)) environment := make(map[string]string, len(os.Environ())+len(overlay))
for _, entry := range os.Environ() { for _, entry := range os.Environ() {

View File

@@ -1,10 +1,12 @@
package process package process
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -117,6 +119,146 @@ func TestRunReturnsCancellation(t *testing.T) {
} }
} }
func TestStartPipedExchangesBeforeInputEOFAndPreservesSettings(t *testing.T) {
workdir := filepath.Join(t.TempDir(), "piped directory")
if err := os.Mkdir(workdir, 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("RELEASEKIT_PROCESS_OVERLAY", "parent")
t.Setenv("RELEASEKIT_PROCESS_INHERITED", "inherited")
var stderr bytes.Buffer
child, err := StartPiped(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "argument with spaces", "exchange"},
Dir: workdir,
Env: map[string]string{
"RELEASEKIT_PROCESS_HELPER": "1",
"RELEASEKIT_PROCESS_OVERLAY": "child",
},
Stderr: &stderr,
})
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(child.Stdin(), "request\n"); err != nil {
t.Fatal(err)
}
response, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil {
t.Fatalf("read response before closing input: %v", err)
}
want := fmt.Sprintf("request|%s|argument with spaces|child|inherited\n", workdir)
if response != want {
t.Fatalf("piped response = %q, want %q", response, want)
}
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
t.Fatal(err)
}
if err := child.Wait(); err != nil {
t.Fatal(err)
}
if got := stderr.String(); got != "child stderr\n" {
t.Fatalf("child stderr = %q, want %q", got, "child stderr\n")
}
if got := os.Getenv("RELEASEKIT_PROCESS_OVERLAY"); got != "parent" {
t.Fatalf("parent environment changed: got %q, want %q", got, "parent")
}
}
func TestStartPipedCancellationAfterReadinessReapsChild(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
child, err := StartPiped(ctx, Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
cancel()
t.Fatal(err)
}
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil || line != "started\n" {
cancel()
_ = child.Stdin().Close()
_ = child.Stdout().Close()
_ = child.Wait()
t.Fatalf("readiness = %q, %v; want started signal", line, err)
}
cancel()
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if err := child.Stdout().Close(); err != nil {
t.Fatal(err)
}
if err := child.Wait(); !errors.Is(err, context.Canceled) {
t.Fatalf("Wait() error = %v, want context cancellation", err)
}
}
func TestStartPipedCancelDoesNotCancelParentContext(t *testing.T) {
ctx := context.Background()
child, err := StartPiped(ctx, Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "wait"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
t.Fatal(err)
}
line, err := bufio.NewReader(child.Stdout()).ReadString('\n')
if err != nil || line != "started\n" {
child.Cancel()
_ = child.Stdin().Close()
_ = child.Stdout().Close()
_ = child.Wait()
t.Fatalf("readiness = %q, %v; want started signal", line, err)
}
child.Cancel()
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if err := child.Stdout().Close(); err != nil {
t.Fatal(err)
}
err = child.Wait()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("Wait() error = %v, want interrupted child exit", err)
}
if ctx.Err() != nil {
t.Fatalf("child cancellation changed parent context: %v", ctx.Err())
}
}
func TestStartPipedReportsStartFailure(t *testing.T) {
child, err := StartPiped(context.Background(), Invocation{Args: []string{"releasekit-command-that-does-not-exist"}})
if err == nil || child != nil || !strings.Contains(err.Error(), "releasekit-command-that-does-not-exist") {
t.Fatalf("StartPiped() = %#v, %v; want a missing executable error", child, err)
}
}
func TestStartPipedPreservesNonzeroExit(t *testing.T) {
child, err := StartPiped(context.Background(), Invocation{
Args: []string{helperExecutable(t), "-test.run=TestProcessHelper", "--", "unused", "exit"},
Env: map[string]string{"RELEASEKIT_PROCESS_HELPER": "1"},
})
if err != nil {
t.Fatal(err)
}
if err := child.Stdin().Close(); err != nil {
t.Fatal(err)
}
if _, err := io.Copy(io.Discard, child.Stdout()); err != nil {
t.Fatal(err)
}
err = child.Wait()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) || exitErr.ExitCode() != 7 {
t.Fatalf("Wait() error = %v, want exit code 7", err)
}
}
func TestProcessHelper(t *testing.T) { func TestProcessHelper(t *testing.T) {
if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" { if os.Getenv("RELEASEKIT_PROCESS_HELPER") != "1" {
return return
@@ -142,6 +284,21 @@ func TestProcessHelper(t *testing.T) {
for { for {
time.Sleep(time.Second) time.Sleep(time.Second)
} }
case "exchange":
reader := bufio.NewReader(os.Stdin)
request, err := reader.ReadString('\n')
if err != nil {
os.Exit(11)
}
workdir, err := os.Getwd()
if err != nil {
os.Exit(12)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s\n", strings.TrimSuffix(request, "\n"), workdir, args[len(args)-2], os.Getenv("RELEASEKIT_PROCESS_OVERLAY"), os.Getenv("RELEASEKIT_PROCESS_INHERITED"))
fmt.Fprintln(os.Stderr, "child stderr")
if _, err := io.Copy(io.Discard, reader); err != nil {
os.Exit(13)
}
default: default:
os.Exit(10) os.Exit(10)
} }

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/validation"
) )
type State string type State string
@@ -35,11 +36,17 @@ const (
const recoveryTimeout = 30 * time.Second 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 { type Request struct {
RepositoryDir string RepositoryDir string
Product string Product string
Version string Version string
Remote string Remote string
ExpectedPushURL string
Validation validation.Settings
AdditionalChecks func(context.Context, candidate.Candidate) error AdditionalChecks func(context.Context, candidate.Candidate) error
AfterValidate func(context.Context, candidate.Candidate) error AfterValidate func(context.Context, candidate.Candidate) error
Output io.Writer Output io.Writer
@@ -54,8 +61,10 @@ type Result struct {
State State State State
} }
// Publish validates one captured candidate and pushes only its annotated tag // Publish captures a candidate, resolves and checks the optional expected push
// object. It runs the standard Go suite, then AdditionalChecks when supplied. // 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 // A failed push receives one bounded authoritative tag query. Exact
// object equality confirms publication; absence and conflict are classified, // object equality confirms publication; absence and conflict are classified,
// while an unreadable result remains Unknown and preserves the push error. // 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, RepositoryDir: request.RepositoryDir,
Product: request.Product, Product: request.Product,
Version: request.Version, Version: request.Version,
Validation: request.Validation,
AdditionalChecks: request.AdditionalChecks, AdditionalChecks: request.AdditionalChecks,
Output: request.Output, 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) return result, fmt.Errorf("resolve publication endpoint: %w", err)
} }
result.Endpoint = endpoint.PushURL 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 { if err := endpoint.Recheck(ctx, captured.RepositoryDir); err != nil {
return result, fmt.Errorf("pre-validation endpoint guard: %w", err) return result, fmt.Errorf("pre-validation endpoint guard: %w", err)
} }

View File

@@ -0,0 +1,43 @@
package runtimeinfo
import (
"runtime/debug"
"testing"
)
var performanceVersionSink string
func BenchmarkPerformanceVersion(b *testing.B) {
b.Run("Injected", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
performanceVersionSink = version("v1.2.3", debug.ReadBuildInfo)
if performanceVersionSink != "v1.2.3" {
b.Fatalf("version() = %q, want injected version", performanceVersionSink)
}
}
})
b.Run("FallbackInitialization", func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
fallback := newFallbackVersion(debug.ReadBuildInfo)
performanceVersionSink = fallback()
if performanceVersionSink == "" {
b.Fatal("fallback returned an empty version")
}
}
})
b.Run("RealMetadataFallback", func(b *testing.B) {
_ = Version()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
performanceVersionSink = Version()
if performanceVersionSink == "" {
b.Fatal("Version() returned an empty version")
}
}
})
}

View File

@@ -2,14 +2,26 @@
// public buildinfo facade. // public buildinfo facade.
package runtimeinfo package runtimeinfo
import "runtime/debug" import (
"runtime/debug"
"sync"
)
var injectedVersion string var injectedVersion string
// Version returns the injected version or the running executable's main module // Version returns the injected version or the running executable's main module
// version, falling back to "dev" when neither provides a release identifier. // version, falling back to "dev" when neither provides a release identifier.
func Version() string { func Version() string {
return version(injectedVersion, debug.ReadBuildInfo) if injectedVersion != "" {
return injectedVersion
}
return fallbackVersion()
}
var fallbackVersion = newFallbackVersion(debug.ReadBuildInfo)
func newFallbackVersion(readBuildInfo func() (*debug.BuildInfo, bool)) func() string {
return sync.OnceValue(func() string { return version("", readBuildInfo) })
} }
func version(injected string, readBuildInfo func() (*debug.BuildInfo, bool)) string { func version(injected string, readBuildInfo func() (*debug.BuildInfo, bool)) string {

View File

@@ -2,6 +2,8 @@ package runtimeinfo
import ( import (
"runtime/debug" "runtime/debug"
"sync"
"sync/atomic"
"testing" "testing"
) )
@@ -55,3 +57,54 @@ func TestVersion(t *testing.T) {
}) })
} }
} }
func TestFallbackVersionReadsMetadataOnceConcurrently(t *testing.T) {
var reads atomic.Int32
fallback := newFallbackVersion(func() (*debug.BuildInfo, bool) {
reads.Add(1)
return &debug.BuildInfo{Main: debug.Module{Version: "v4.5.6"}}, true
})
const callers = 32
results := make(chan string, callers)
var workers sync.WaitGroup
workers.Add(callers)
for i := 0; i < callers; i++ {
go func() {
defer workers.Done()
results <- fallback()
}()
}
workers.Wait()
close(results)
for result := range results {
if result != "v4.5.6" {
t.Errorf("fallback() = %q, want stable version", result)
}
}
if got := reads.Load(); got != 1 {
t.Fatalf("metadata reader called %d times, want once", got)
}
}
func TestFallbackVersionCachesUnavailableAndDevelopmentMetadata(t *testing.T) {
for _, test := range []struct {
name string
read func() (*debug.BuildInfo, bool)
}{
{name: "unavailable", read: func() (*debug.BuildInfo, bool) { return nil, false }},
{name: "development", read: func() (*debug.BuildInfo, bool) {
return &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, true
}},
} {
t.Run(test.name, func(t *testing.T) {
fallback := newFallbackVersion(test.read)
if got := fallback(); got != "dev" {
t.Fatalf("fallback() = %q, want dev", got)
}
if got := fallback(); got != "dev" {
t.Fatalf("repeated fallback() = %q, want dev", got)
}
})
}
}

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,301 @@
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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,194 @@
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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,162 @@
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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},
}
cleanupCandidateTree(t, tree)
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 TestCheckGoFormatReportsEveryPathForRepeatedUnformattedContent(t *testing.T) {
repository := newTreeRepository(t)
contents := []byte("package app\nfunc value(){return}\n")
writeTreeFile(t, repository, "first.go", contents)
writeTreeFile(t, repository, "nested/second.go", contents)
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
err = checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("checkGoFormat() error = %v, want go-format findings", err)
}
if len(failure.Findings) != 2 || failure.Findings[0].Path != "first.go" || failure.Findings[1].Path != "nested/second.go" {
t.Fatalf("findings = %#v, want both paths in tree order", failure.Findings)
}
first, _ := tree.Entry("first.go")
second, _ := tree.Entry("nested/second.go")
if first.ObjectID != second.ObjectID {
t.Fatalf("fixture object IDs = %q and %q, want repeated content", first.ObjectID, second.ObjectID)
}
}
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,117 @@
package validation
import (
"context"
"fmt"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
"golang.org/x/mod/modfile"
)
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
var findings []Finding
replacementsByObjectID := make(map[string][]moduleReplacementSummary)
for _, entry := range tree.Entries() {
if err := ctx.Err(); err != nil {
return &Failure{Check: LocalReplacements, Findings: findings, Cause: err}
}
if path.Base(entry.Path) != "go.mod" {
continue
}
declaration, err := resolveModuleDeclaration(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),
}
}
replacements, ok := replacementsByObjectID[declaration.ObjectID]
if !ok {
contents, err := tree.ReadFile(ctx, declaration.Path)
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: read module declaration: %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),
}
}
replacements = make([]moduleReplacementSummary, 0, len(moduleFile.Replace))
for _, replacement := range moduleFile.Replace {
if replacement.New.Version != "" {
continue
}
line := 0
if replacement.Syntax != nil {
line = replacement.Syntax.Start.Line
}
replacements = append(replacements, moduleReplacementSummary{
target: strings.Clone(replacement.New.Path),
line: line,
})
}
contents = nil
moduleFile = nil
replacementsByObjectID[declaration.ObjectID] = replacements
}
for _, replacement := range replacements {
finding := Finding{
Path: entry.Path,
Message: "filesystem module replacement is committed",
Target: replacement.target,
Directive: "replace",
Line: replacement.line,
}
findings = append(findings, finding)
}
}
if len(findings) != 0 {
return &Failure{Check: LocalReplacements, Findings: findings}
}
return nil
}
type moduleReplacementSummary struct {
target string
line int
}
func resolveModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) (git.TreeEntry, error) {
if entry.Type != "blob" {
return git.TreeEntry{}, fmt.Errorf("entry has type %q, not a blob", entry.Type)
}
if entry.Mode != "120000" {
if entry.Mode != "100644" && entry.Mode != "100755" {
return git.TreeEntry{}, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
}
return entry, nil
}
target, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return git.TreeEntry{}, fmt.Errorf("read symlink declaration: %w", err)
}
destination, err := tree.ResolveDestination(ctx, entry.Path, target)
if err != nil {
return git.TreeEntry{}, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
}
if destination.Kind != DestinationEntry {
return git.TreeEntry{}, 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 git.TreeEntry{}, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
}
return resolved, nil
}

View File

@@ -0,0 +1,230 @@
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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 TestCheckLocalReplacementsSharesSummariesAcrossModuleSymlinks(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "shared/module.mod", []byte(
"module shared.example/module\n"+
"go 1.25.0\n"+
"replace example.org/one => ../one\n"+
"replace example.org/two => ../two\n",
))
for _, directory := range []string{"first", "second"} {
if err := os.MkdirAll(filepath.Join(repository, directory), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../shared/module.mod", filepath.Join(repository, directory, "go.mod")); err != nil {
t.Fatal(err)
}
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
err = checkLocalReplacements(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != LocalReplacements || len(failure.Findings) != 4 {
t.Fatalf("checkLocalReplacements() error = %v, want four reused findings", err)
}
want := []struct {
path string
line int
target string
}{
{"first/go.mod", 3, "../one"},
{"first/go.mod", 4, "../two"},
{"second/go.mod", 3, "../one"},
{"second/go.mod", 4, "../two"},
}
for index, finding := range failure.Findings {
if finding.Path != want[index].path || finding.Line != want[index].line || finding.Target != want[index].target {
t.Errorf("finding[%d] = %#v, want %#v", index, finding, want[index])
}
}
first, _ := tree.Entry("first/go.mod")
second, _ := tree.Entry("second/go.mod")
shared, _ := tree.Entry("shared/module.mod")
if first.ObjectID != second.ObjectID || first.ObjectID == shared.ObjectID {
t.Fatalf("declaration object IDs = %q, %q; module body = %q", first.ObjectID, second.ObjectID, shared.ObjectID)
}
}
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)
}
cleanupCandidateTree(t, tree)
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)
}
cleanupCandidateTree(t, tree)
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.TB, 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,189 @@
package validation
import (
"context"
"fmt"
"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
byPath map[string]*markdownDocument
byObjectID map[string]*markdownDocument
}
func newMarkdownCache(tree *CandidateTree) *markdownCache {
return &markdownCache{
tree: tree,
byPath: make(map[string]*markdownDocument),
byObjectID: make(map[string]*markdownDocument),
}
}
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
if document, ok := cache.byPath[path]; ok {
return document, nil
}
entry, ok := cache.tree.Entry(path)
if !ok || !isRegularFile(entry) {
return nil, fmt.Errorf("committed path %q is not a regular file", path)
}
if document, ok := cache.byObjectID[entry.ObjectID]; ok {
cache.byPath[path] = document
return document, nil
}
source, err := cache.tree.ReadFile(ctx, path)
if err != nil {
return nil, err
}
document := parseMarkdown(source)
source = nil
cache.byObjectID[entry.ObjectID] = document
cache.byPath[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: strings.Clone(string(node.Destination.Value(source))),
Title: strings.Clone(string(node.Title.Value(source))),
Reference: node.Reference != nil,
Location: markdownLocationAt(lineStarts, node.Pos()),
})
case *ast.Image:
document.links = append(document.links, markdownLink{
Destination: strings.Clone(string(node.Destination.Value(source))),
Title: strings.Clone(string(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: strings.Clone(string(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,321 @@
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 TestCheckMarkdownLinksUsesCurrentPathForSharedDocuments(t *testing.T) {
shared := []byte("[target](target.md)\n")
_, tree := committedMarkdownTree(t, map[string][]byte{
"one/guide.md": shared,
"one/target.md": []byte("# Target\n"),
"two/guide.md": shared,
})
err := checkMarkdownLinks(context.Background(), tree, false, nil)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
t.Fatalf("checkMarkdownLinks() error = %v, want one path-specific finding", err)
}
if finding := failure.Findings[0]; finding.Path != "two/guide.md" || finding.Target != "target.md" {
t.Fatalf("shared-document finding = %#v, want missing target relative to two/guide.md", 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)
}
cleanupCandidateTree(t, tree)
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,130 @@
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 TestMarkdownCacheSharesParsedDocumentsByBlobObjectID(t *testing.T) {
repository := newTreeRepository(t)
shared := []byte("# Guide\n[other](other.md \"title\")\n")
writeTreeFile(t, repository, "one/guide.md", shared)
writeTreeFile(t, repository, "two/guide.md", shared)
writeTreeFile(t, repository, "one/other.md", []byte("# Other\n"))
gitTree(t, repository, "add", "one/guide.md", "two/guide.md", "one/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)
}
cleanupCandidateTree(t, tree)
cache := newMarkdownCache(tree)
first, err := cache.document(context.Background(), "one/guide.md")
if err != nil {
t.Fatal(err)
}
second, err := cache.document(context.Background(), "two/guide.md")
if err != nil {
t.Fatal(err)
}
if first != second {
t.Fatal("identical Markdown blobs returned different parsed documents")
}
other, err := cache.document(context.Background(), "one/other.md")
if err != nil {
t.Fatal(err)
}
if other == first {
t.Fatal("different Markdown blobs shared a parsed document")
}
if _, ok := other.anchors["other"]; !ok {
t.Fatalf("other document anchors = %#v", other.anchors)
}
if got := first.links[0]; got.Destination != "other.md" || got.Title != "title" || got.Location.Line != 2 {
t.Fatalf("shared Markdown link = %#v, want content-relative destination, title, and location", got)
}
}

View File

@@ -0,0 +1,350 @@
package validation
import (
"bytes"
"context"
"fmt"
"io"
"runtime"
"strconv"
"strings"
"testing"
)
const performanceGoFileCount = 1000
const performanceMarkdownSize = 1 << 20
const performanceMarkdownLinkCount = 500
type performanceTree struct {
repository string
commit string
paths []string
expected map[string][]byte
totalBytes int64
}
func BenchmarkPerformanceValidation(b *testing.B) {
cases := []struct {
name string
repeated bool
packed bool
}{
{name: "unique-loose"},
{name: "unique-packed", packed: true},
{name: "repeated-loose", repeated: true},
{name: "repeated-packed", repeated: true, packed: true},
}
fixtures := make(map[string]performanceTree, len(cases))
for _, testCase := range cases {
fixtures[testCase.name] = newPerformanceTree(b, performanceGoFileCount, testCase.repeated, testCase.packed, false)
}
markdown := newPerformanceTree(b, 0, false, false, true)
for _, testCase := range cases {
fixture := fixtures[testCase.name]
b.Run("TreeMetadata/"+testCase.name, func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
b.ReportMetric(float64(len(fixture.paths)), "files/op")
for i := 0; i < b.N; i++ {
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
if err != nil {
b.Fatal(err)
}
if len(tree.Entries()) != len(fixture.paths) {
b.Fatalf("tree entries = %d, want %d", len(tree.Entries()), len(fixture.paths))
}
if err := tree.Close(); err != nil {
b.Fatal(err)
}
runtime.KeepAlive(tree)
}
})
b.Run("ContentAccess/"+testCase.name, func(b *testing.B) {
trees := freshPerformanceTrees(b, fixture, b.N)
b.ReportAllocs()
b.SetBytes(fixture.totalBytes)
b.ResetTimer()
b.ReportMetric(float64(fixture.totalBytes), "bytes/op")
b.ReportMetric(float64(len(fixture.paths)), "files/op")
b.ReportMetric(float64(performanceGoFileCount), "go-files/op")
for _, tree := range trees {
var bytesRead int64
for _, path := range fixture.paths {
contents, err := tree.ReadFile(context.Background(), path)
if err != nil {
b.Fatal(err)
}
bytesRead += int64(len(contents))
}
if bytesRead != fixture.totalBytes {
b.Fatalf("read %d bytes, want %d", bytesRead, fixture.totalBytes)
}
if err := tree.Close(); err != nil {
b.Fatal(err)
}
runtime.KeepAlive(tree)
}
runtime.KeepAlive(trees)
})
b.Run("GoFormat/"+testCase.name, func(b *testing.B) {
trees := freshPerformanceTrees(b, fixture, b.N)
b.ReportAllocs()
b.SetBytes(fixture.totalBytes)
b.ResetTimer()
b.ReportMetric(float64(fixture.totalBytes), "bytes/op")
b.ReportMetric(float64(performanceGoFileCount), "go-files/op")
for _, tree := range trees {
if err := checkGoFormat(context.Background(), tree); err != nil {
b.Fatal(err)
}
if err := tree.Close(); err != nil {
b.Fatal(err)
}
runtime.KeepAlive(tree)
}
runtime.KeepAlive(trees)
})
}
b.Run("Markdown/one-megabyte-repeated-links-and-identical-content", func(b *testing.B) {
trees := freshPerformanceTrees(b, markdown, b.N)
b.ReportAllocs()
b.SetBytes(markdown.totalBytes)
b.ResetTimer()
b.ReportMetric(float64(markdown.totalBytes), "bytes/op")
b.ReportMetric(float64(performanceMarkdownLinkCount*2), "repeated-links/op")
for _, tree := range trees {
if err := checkMarkdownLinks(context.Background(), tree, false, io.Discard); err != nil {
b.Fatal(err)
}
if err := tree.Close(); err != nil {
b.Fatal(err)
}
runtime.KeepAlive(tree)
}
runtime.KeepAlive(trees)
})
}
func BenchmarkPerformanceRetainedContent(b *testing.B) {
fixture := newPerformanceTree(b, performanceGoFileCount, false, false, true)
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = tree.Close() })
cache := newMarkdownCache(tree)
var markdownPaths []string
for _, entry := range tree.Entries() {
if strings.HasSuffix(strings.ToLower(entry.Path), ".md") {
markdownPaths = append(markdownPaths, entry.Path)
}
}
if len(markdownPaths) != 3 {
b.Fatalf("Markdown paths = %v, want README and two identical documents", markdownPaths)
}
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
b.ReportAllocs()
b.ReportMetric(float64(len(fixture.paths)), "go-files/op")
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := checkGoFormat(context.Background(), tree); err != nil {
b.Fatal(err)
}
for _, path := range markdownPaths {
if _, err := cache.document(context.Background(), path); err != nil {
b.Fatal(err)
}
}
}
b.StopTimer()
if err := tree.Close(); err != nil {
b.Fatal(err)
}
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
retained := int64(after.HeapAlloc) - int64(before.HeapAlloc)
if retained < 0 {
retained = 0
}
b.ReportMetric(float64(retained), "retained-B")
runtime.KeepAlive(tree)
runtime.KeepAlive(cache)
runtime.KeepAlive(fixture)
}
func newPerformanceTree(tb testing.TB, goFileCount int, repeated, packed, includeMarkdown bool) performanceTree {
tb.Helper()
repository := newTreeRepository(tb)
fixture := performanceTree{
repository: repository,
paths: make([]string, 0, goFileCount),
expected: make(map[string][]byte, goFileCount+3),
}
for i := 0; i < goFileCount; i++ {
name := fmt.Sprintf("source/file-%04d.go", i)
contents := performanceGoSource(i)
if repeated {
contents = []byte("package source\n")
}
writeTreeFile(tb, repository, name, contents)
fixture.paths = append(fixture.paths, name)
fixture.expected[name] = contents
fixture.totalBytes += int64(len(contents))
}
if includeMarkdown {
markdown := performanceMarkdownDocument()
readme := performanceMarkdownIndex()
for name, contents := range map[string][]byte{
"README.md": readme,
"archive/payload.md": markdown,
"docs/payload.md": markdown,
} {
writeTreeFile(tb, repository, name, contents)
fixture.expected[name] = contents
fixture.totalBytes += int64(len(contents))
}
}
fixture.commit = commitCandidateTree(tb, repository)
if packed {
gitTree(tb, repository, "gc", "--aggressive", "--prune=now")
}
verifyPerformanceTree(tb, fixture, repeated, packed, includeMarkdown)
return fixture
}
func freshPerformanceTrees(tb testing.TB, fixture performanceTree, count int) []*CandidateTree {
tb.Helper()
base, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
if err != nil {
tb.Fatal(err)
}
cleanupCandidateTree(tb, base)
trees := make([]*CandidateTree, count)
for index := range trees {
tree := *base
tree.symlinkTargets = make(map[string]string)
trees[index] = &tree
}
if err := base.Close(); err != nil {
tb.Fatal(err)
}
runtime.KeepAlive(base)
return trees
}
func verifyPerformanceTree(tb testing.TB, fixture performanceTree, repeated, packed, includeMarkdown bool) {
tb.Helper()
tree, err := ReadCandidateTree(context.Background(), fixture.repository, fixture.commit)
if err != nil {
tb.Fatal(err)
}
if len(tree.Entries()) != len(fixture.expected) {
tb.Fatalf("tree entries = %d, want %d", len(tree.Entries()), len(fixture.expected))
}
uniqueObjects := make(map[string]struct{}, len(fixture.paths))
for _, path := range fixture.paths {
contents, err := tree.ReadFile(context.Background(), path)
if err != nil {
tb.Fatal(err)
}
if !bytes.Equal(contents, fixture.expected[path]) {
tb.Fatalf("committed content at %q differs from fixture bytes", path)
}
uniqueObjects[tree.entries[tree.byPath[path]].ObjectID] = struct{}{}
}
wantObjects := len(fixture.paths)
if repeated {
wantObjects = 1
}
if len(uniqueObjects) != wantObjects {
tb.Fatalf("unique Go object IDs = %d, want %d", len(uniqueObjects), wantObjects)
}
if includeMarkdown {
for _, path := range []string{"README.md", "docs/payload.md", "archive/payload.md"} {
contents, err := tree.ReadFile(context.Background(), path)
if err != nil {
tb.Fatal(err)
}
if !bytes.Equal(contents, fixture.expected[path]) {
tb.Fatalf("committed Markdown content at %q differs from fixture bytes", path)
}
}
if len(fixture.expected["docs/payload.md"]) != performanceMarkdownSize {
tb.Fatalf("large Markdown fixture size = %d, want %d", len(fixture.expected["docs/payload.md"]), performanceMarkdownSize)
}
if !bytes.Equal(fixture.expected["docs/payload.md"], fixture.expected["archive/payload.md"]) {
tb.Fatal("Markdown copies differ")
}
if tree.entries[tree.byPath["docs/payload.md"]].ObjectID != tree.entries[tree.byPath["archive/payload.md"]].ObjectID {
tb.Fatal("identical Markdown copies do not share an object ID")
}
index := string(fixture.expected["README.md"])
if strings.Count(index, "docs/payload.md#shared-payload") != performanceMarkdownLinkCount || strings.Count(index, "archive/payload.md#shared-payload") != performanceMarkdownLinkCount {
tb.Fatal("Markdown link fixture does not contain the expected repeated targets")
}
}
if err := tree.Close(); err != nil {
tb.Fatal(err)
}
objects := gitTree(tb, fixture.repository, "count-objects", "-v")
loose, packedCount := performanceObjectCounts(tb, objects)
if packed {
if loose != 0 || packedCount == 0 {
tb.Fatalf("packed fixture object counts = loose %d, in-pack %d", loose, packedCount)
}
} else if loose == 0 || packedCount != 0 {
tb.Fatalf("loose fixture object counts = loose %d, in-pack %d", loose, packedCount)
}
}
func performanceObjectCounts(tb testing.TB, output string) (loose, packed int) {
tb.Helper()
for _, line := range strings.Split(output, "\n") {
key, value, ok := strings.Cut(line, ": ")
if !ok || (key != "count" && key != "in-pack") {
continue
}
count, err := strconv.Atoi(value)
if err != nil {
tb.Fatalf("parse git count-objects output %q: %v", line, err)
}
if key == "count" {
loose = count
} else {
packed = count
}
}
return loose, packed
}
func performanceGoSource(index int) []byte {
return []byte(fmt.Sprintf("package source\n\nconst value%04d = %q\n", index, fmt.Sprintf("payload-%04d", index)))
}
func performanceMarkdownDocument() []byte {
contents := make([]byte, performanceMarkdownSize)
prefix := []byte("# Shared Payload\n\n")
copy(contents, prefix)
for index := len(prefix); index < len(contents)-1; index++ {
contents[index] = 'x'
}
contents[len(contents)-1] = '\n'
return contents
}
func performanceMarkdownIndex() []byte {
var contents strings.Builder
for index := 0; index < performanceMarkdownLinkCount; index++ {
fmt.Fprintf(&contents, "- [payload %d](docs/payload.md#shared-payload)\n", index)
fmt.Fprintf(&contents, "- [copy %d](archive/payload.md#shared-payload)\n", index)
}
return []byte(contents.String())
}

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

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

@@ -0,0 +1,281 @@
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 {
context context.Context
repositoryDir string
entries []git.TreeEntry
byPath map[string]int
directories map[string]struct{}
blobReader *git.BlobReader
blobReads uint64
symlinkTargets map[string]string
closed bool
closeErr error
}
// ReadCandidateTree enumerates the captured commit without reading from the
// worktree. The supplied context also governs the scoped reader through closure.
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
entries, err := git.ReadTree(ctx, repositoryDir, commit)
if err != nil {
return nil, err
}
tree := &CandidateTree{
context: ctx,
repositoryDir: repositoryDir,
entries: entries,
byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}),
symlinkTargets: make(map[string]string),
}
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 newly allocated, caller-owned bytes for a committed blob.
// Regular-file content is not cached, so repeated reads request the object
// again through the tree's scoped blob reader.
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
if tree.closed {
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", path)
}
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 entry.Mode == "120000" {
target, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return nil, err
}
return []byte(target), nil
}
return tree.readBlob(ctx, entry)
}
func (tree *CandidateTree) readSymlinkTarget(ctx context.Context, entry git.TreeEntry) (string, error) {
if tree.closed {
return "", fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
}
if entry.Type != "blob" || entry.Mode != "120000" {
return "", fmt.Errorf("committed path %q is not a symlink blob", entry.Path)
}
if target, ok := tree.symlinkTargets[entry.ObjectID]; ok {
return target, nil
}
contents, err := tree.readBlob(ctx, entry)
if err != nil {
return "", err
}
target := string(contents)
if tree.symlinkTargets == nil {
tree.symlinkTargets = make(map[string]string)
}
tree.symlinkTargets[entry.ObjectID] = target
return target, nil
}
func (tree *CandidateTree) readBlob(ctx context.Context, entry git.TreeEntry) ([]byte, error) {
if tree.closed {
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
}
if tree.blobReader == nil {
// The operation context governs shutdown independently of each read.
operationContext := tree.context
if operationContext == nil {
operationContext = context.Background()
}
tree.blobReader = git.NewBlobReader(operationContext, tree.repositoryDir)
}
tree.blobReads++
contents, err := tree.blobReader.Read(ctx, entry.ObjectID)
if err != nil {
return nil, fmt.Errorf("read committed path %q: %w", entry.Path, err)
}
return contents, nil
}
// Close closes the scoped local Git blob reader, if content was requested.
// Metadata-only trees have no child process to close.
func (tree *CandidateTree) Close() error {
if tree.closed {
return tree.closeErr
}
tree.closed = true
if tree.blobReader != nil {
tree.closeErr = tree.blobReader.Close()
}
return tree.closeErr
}
// 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
}
entry, _ := tree.Entry(partial)
linkText, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
}
target := linkText
targetDirectory := directory
if path.IsAbs(target) {
relative, inside, err := tree.relativeAbsoluteDestination(target)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
}
targetDirectory, target = ".", relative
}
active[partial] = true
destination, err := tree.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
delete(active, partial)
if err != nil || len(remaining) == 0 {
return destination, err
}
switch destination.Kind {
case DestinationDirectory:
return tree.resolveDestination(ctx, destination.Path, remaining, active)
case DestinationEntry:
destination.Kind = DestinationMissing
case DestinationSymlinkCycle:
return destination, nil
}
destination.Path = unresolvedPath(destination.Path, remaining)
return destination, nil
}
if len(remaining) != 0 {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
return Destination{Kind: DestinationEntry, Path: partial}, nil
}
return Destination{Kind: DestinationDirectory, Path: directory}, nil
}
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(tree.repositoryDir)
if err != nil {
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
}
root = filepath.ToSlash(root)
if destination == root {
return ".", true, nil
}
// Preserve the suffix for component traversal, including symlinks before '..'.
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
return relative, inside, nil
}
// unresolvedPath preserves components that could not be traversed. Cleaning
// them would hide missing entries, non-directories, or checkout boundaries.
func unresolvedPath(base string, remaining []string) string {
if len(remaining) == 0 {
return base
}
return base + "/" + strings.Join(remaining, "/")
}

View File

@@ -0,0 +1,196 @@
package validation
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestRunAttributesUnexpectedReaderCloseFailureToLastContentCheck(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
commit := commitCandidateTree(t, repository)
installLifecycleTools(t, `while IFS= read -r object; do
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
printf '%s blob %s\n' "$object" "$size"
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
printf '\n'
done
printf 'batch process failed during cleanup\n' >&2
exit 23`)
for _, testCase := range []struct {
name string
markdown *MarkdownSettings
}{
{name: "formatting only"},
{name: "later Markdown check without files", markdown: &MarkdownSettings{}},
} {
t.Run(testCase.name, func(t *testing.T) {
request := lifecycleRequest(repository, commit)
request.Settings.Markdown = testCase.markdown
err := Run(context.Background(), request)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("Run() error = %v, want cleanup failure attributed to GoFormat", err)
}
var exitErr *exec.ExitError
if !errors.As(failure.Cause, &exitErr) || exitErr.ExitCode() != 23 {
t.Fatalf("cleanup cause = %v, want Git child exit code 23", failure.Cause)
}
if !strings.Contains(failure.Cause.Error(), "batch process failed during cleanup") {
t.Fatalf("cleanup cause = %v, want captured Git stderr", failure.Cause)
}
})
}
}
func TestRunPreservesCheckFailureDuringReaderCleanup(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "broken.go", []byte("package app\nfunc ( {\n"))
commit := commitCandidateTree(t, repository)
installLifecycleTools(t, `while IFS= read -r object; do
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
printf '%s blob %s\n' "$object" "$size"
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
printf '\n'
done
printf 'secondary cleanup failure\n' >&2
exit 24`)
err := Run(context.Background(), lifecycleRequest(repository, commit))
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
t.Fatalf("Run() error = %v, want original GoFormat check failure", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" {
t.Fatalf("Run() findings = %#v, want original broken.go finding", failure.Findings)
}
if strings.Contains(failure.Cause.Error(), "secondary cleanup failure") {
t.Fatalf("Run() replaced the check cause with cleanup output: %v", failure.Cause)
}
}
func TestRunPreservesCancellationDuringReaderCleanup(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
commit := commitCandidateTree(t, repository)
ready := filepath.Join(t.TempDir(), "reader-ready")
installLifecycleTools(t, `IFS= read -r object
: > "$RELEASEKIT_GIT_READER_READY"
exec sleep 60`)
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
ctx, cancel := context.WithCancel(t.Context())
result := make(chan error, 1)
go func() { result <- Run(ctx, lifecycleRequest(repository, commit)) }()
waitForLifecycleFile(t, ready)
cancel()
select {
case err := <-result:
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || !errors.Is(failure.Cause, context.Canceled) {
t.Fatalf("Run() error = %v, want GoFormat cancellation cause", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Run() did not return after candidate reader cancellation")
}
}
func TestRunPreservesCancellationDuringReaderShutdown(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "go.mod", []byte("module fixture.example/app\n\ngo 1.25.0\n"))
writeTreeFile(t, repository, "main.go", []byte("package app\n\nconst value = 1\n"))
commit := commitCandidateTree(t, repository)
ready := filepath.Join(t.TempDir(), "shutdown-ready")
installLifecycleTools(t, `while IFS= read -r object; do
size=$("$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file -s "$object")
printf '%s blob %s\n' "$object" "$size"
"$RELEASEKIT_REAL_GIT" -C "$PWD" cat-file blob "$object"
printf '\n'
done
printf '%s\n' "$$" > "$RELEASEKIT_GIT_READER_READY"
exec sleep 60`)
t.Setenv("RELEASEKIT_GIT_READER_READY", ready)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
result := make(chan error, 1)
go func() { result <- Run(ctx, lifecycleRequest(repository, commit)) }()
waitForLifecycleFile(t, ready)
pidBytes, err := os.ReadFile(ready)
if err != nil {
t.Fatal(err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
if err != nil {
t.Fatal(err)
}
child, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
cancel()
select {
case err := <-result:
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want GoFormat cancellation cause", err)
}
if err := child.Signal(syscall.Signal(0)); !errors.Is(err, syscall.ESRCH) && !errors.Is(err, os.ErrProcessDone) {
t.Fatalf("child signal after Run() = %v, want reaped child", err)
}
case <-time.After(5 * time.Second):
_ = child.Kill()
<-result
t.Fatal("Run() did not return after cancellation during reader shutdown")
}
}
func lifecycleRequest(repository, commit string) Request {
return Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{GoFormat: true},
}
}
func installLifecycleTools(t *testing.T, batchScript string) {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
installGoShim(t, "", false)
t.Setenv("RELEASEKIT_REAL_GIT", realGit)
directory := t.TempDir()
gitWrapper := filepath.Join(directory, "git")
script := "#!/bin/sh\nset -eu\nif [ \"${1-}\" = cat-file ] && [ \"${2-}\" = --batch ]; then\n" + batchScript + "\nfi\nexec \"$RELEASEKIT_REAL_GIT\" \"$@\"\n"
if err := os.WriteFile(gitWrapper, []byte(script), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", directory+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func waitForLifecycleFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatal(fmt.Errorf("inspect reader readiness signal: %w", err))
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("candidate blob reader did not reach its blocking exchange")
}

View File

@@ -0,0 +1,181 @@
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)
}
cleanupCandidateTree(t, tree)
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 TestReadFileReturnsOwnedBytesAndCachesOnlySymlinkTargets(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "source.txt", []byte("committed content"))
if err := os.Symlink("source.txt", filepath.Join(repository, "alias.txt")); err != nil {
t.Fatal(err)
}
commit := commitCandidateTree(t, repository)
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
cleanupCandidateTree(t, tree)
first, err := tree.ReadFile(context.Background(), "source.txt")
if err != nil {
t.Fatal(err)
}
first[0] = 'X'
second, err := tree.ReadFile(context.Background(), "source.txt")
if err != nil {
t.Fatal(err)
}
if string(second) != "committed content" {
t.Fatalf("second regular-file read = %q, want committed content", second)
}
if &first[0] == &second[0] {
t.Fatal("regular-file reads share a backing byte slice")
}
linkFirst, err := tree.ReadFile(context.Background(), "alias.txt")
if err != nil {
t.Fatal(err)
}
linkFirst[0] = 'X'
linkSecond, err := tree.ReadFile(context.Background(), "alias.txt")
if err != nil {
t.Fatal(err)
}
if string(linkSecond) != "source.txt" {
t.Fatalf("second symlink read = %q, want source.txt", linkSecond)
}
if len(tree.symlinkTargets) != 1 {
t.Fatalf("cached symlink target count = %d, want 1", len(tree.symlinkTargets))
}
destination, err := tree.ResolveDestination(context.Background(), "README.md", "alias.txt")
if err != nil || destination.Kind != DestinationEntry || destination.Path != "source.txt" {
t.Fatalf("ResolveDestination() = %#v, %v; want source.txt entry", destination, err)
}
}
func cleanupCandidateTree(tb testing.TB, tree *CandidateTree) {
tb.Helper()
tb.Cleanup(func() { _ = tree.Close() })
}
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.TB) 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.TB, 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.TB, 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,259 @@
// 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) (result 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}
}
lastContentCheck := Check("")
defer func() {
if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" {
result = &Failure{
Check: lastContentCheck,
Cause: fmt.Errorf("close candidate Git blob reader: %w", closeErr),
}
}
}()
if request.Settings.GoFormat {
readsBefore := tree.blobReads
if err := checkGoFormat(ctx, tree); err != nil {
return err
}
if tree.blobReads != readsBefore {
lastContentCheck = GoFormat
}
}
if request.Settings.ModuleDistribution.WorkspaceFiles {
if err := checkWorkspaceFiles(tree); err != nil {
return err
}
}
if request.Settings.ModuleDistribution.LocalReplacements {
readsBefore := tree.blobReads
if err := checkLocalReplacements(ctx, tree); err != nil {
return err
}
if tree.blobReads != readsBefore {
lastContentCheck = LocalReplacements
}
}
if request.Settings.FileSize != nil {
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
return err
}
}
if request.Settings.Markdown != nil {
readsBefore := tree.blobReads
if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil {
return err
}
if tree.blobReads != readsBefore {
lastContentCheck = MarkdownLinks
}
}
}
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
formattedByObjectID := make(map[string]bool)
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}
}
if formatted, ok := formattedByObjectID[entry.ObjectID]; ok {
if !formatted {
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
}
continue
}
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),
}
}
isFormatted := bytes.Equal(contents, formatted)
formattedByObjectID[entry.ObjectID] = isFormatted
contents = nil
formatted = nil
if !isFormatted {
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

@@ -0,0 +1,77 @@
package releasekit_test
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit"
)
func BenchmarkPerformanceFacadeCheck(b *testing.B) {
repository, moduleBytes := performanceCheckFixture(b)
warmRequest := releasekit.ReleaseRequest{
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
}
if _, err := releasekit.Check(context.Background(), warmRequest); err != nil {
b.Fatalf("warm benchmark Go build cache: %v", err)
}
b.Run("GoSuiteOnly", func(b *testing.B) {
benchmarkFacadeCheck(b, repository, moduleBytes, releasekit.ValidationSpec{})
})
b.Run("GoSuiteAndSelectedChecks", func(b *testing.B) {
benchmarkFacadeCheck(b, repository, moduleBytes, releasekit.ValidationSpec{
GoFormat: true,
Markdown: &releasekit.MarkdownValidationSpec{},
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
LocalReplacements: true,
},
})
})
}
func benchmarkFacadeCheck(b *testing.B, repository publishFixture, moduleBytes int64, validation releasekit.ValidationSpec) {
b.Helper()
request := releasekit.ReleaseRequest{
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: validation,
}
b.ReportAllocs()
b.ResetTimer()
b.ReportMetric(float64(moduleBytes), "module-bytes/op")
for i := 0; i < b.N; i++ {
if _, err := releasekit.Check(context.Background(), request); err != nil {
b.Fatal(err)
}
}
}
func performanceCheckFixture(b *testing.B) (publishFixture, int64) {
b.Helper()
repository := newPublishFixture(b)
writeFile(b, repository.work, "source.go", "package fixture\n\nconst name = \"candidate\"\n")
gitRun(b, repository.work, "add", "source.go")
gitRun(b, repository.work, "commit", "-m", "Add check benchmark source")
if status := gitText(b, repository.work, "status", "--porcelain"); status != "" {
b.Fatalf("benchmark candidate is not clean:\n%s", status)
}
b.Setenv("GOMODCACHE", b.TempDir())
b.Setenv("GOCACHE", b.TempDir())
b.Setenv("GOWORK", "off")
b.Setenv("GOPROXY", "off")
b.Setenv("GOSUMDB", "off")
var moduleBytes int64
for _, name := range []string{"go.mod", "source.go", "fixture_test.go", "race_fixture_test.go"} {
contents, err := os.ReadFile(filepath.Join(repository.work, name))
if err != nil {
b.Fatal(err)
}
moduleBytes += int64(len(contents))
}
return repository, moduleBytes
}

View File

@@ -0,0 +1,304 @@
package releasekit_test
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
releasekit "gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gitea"
)
const performanceDeliveryArtifactSize = 16 << 20
type deliveryBenchmarkAsset struct {
name string
data []byte
digest string
}
type deliveryBenchmarkServer struct {
mu sync.Mutex
baseURL string
commit string
assets []deliveryBenchmarkAsset
page bool
attached map[string]deliveryBenchmarkIdentity
}
type deliveryBenchmarkIdentity struct {
digest string
size int64
}
func BenchmarkPerformanceFacadeDeliver(b *testing.B) {
repository, build, expected := newDeliveryBenchmarkFixture(b)
state := &deliveryBenchmarkServer{commit: gitText(b, repository, "rev-parse", "HEAD"), assets: expected}
server := httptest.NewServer(http.HandlerFunc(state.serveHTTP))
state.baseURL = server.URL
b.Cleanup(server.Close)
request := releasekit.DeliveryRequest{
RepositoryDir: repository,
Product: "Sample App",
Version: "v1.2.3",
Destination: facadeDeliveryDestination(server.URL),
Prebuilt: &build,
}
totalBytes := int64(2 * performanceDeliveryArtifactSize)
b.Run("FreshUploads", func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(totalBytes)
b.ReportMetric(float64(totalBytes), "artifact-bytes/op")
b.ReportMetric(2, "binary-files/op")
for i := 0; i < b.N; i++ {
b.StopTimer()
state.reset(false)
b.StartTimer()
result, err := releasekit.Deliver(context.Background(), request)
b.StopTimer()
if err != nil {
b.Fatalf("Deliver() error = %v", err)
}
if len(result.Uploaded) != 3 {
b.Fatalf("uploaded %d assets, want two binaries and a manifest", len(result.Uploaded))
}
state.verifyUploads(b)
b.StartTimer()
}
})
b.Run("MatchingRetry", func(b *testing.B) {
b.StopTimer()
state.reset(true)
b.ReportAllocs()
b.SetBytes(totalBytes)
b.ReportMetric(float64(totalBytes), "artifact-bytes/op")
b.ReportMetric(2, "binary-files/op")
b.ResetTimer()
b.StartTimer()
for i := 0; i < b.N; i++ {
result, err := releasekit.Deliver(context.Background(), request)
if err != nil {
b.Fatalf("Deliver() error = %v", err)
}
if len(result.Uploaded) != 0 || len(result.Matching) != 3 {
b.Fatalf("matching retry uploaded %d and matched %d assets; want 0 and 3", len(result.Uploaded), len(result.Matching))
}
}
})
}
func newDeliveryBenchmarkFixture(b *testing.B) (string, releasekit.BuildResult, []deliveryBenchmarkAsset) {
b.Helper()
repository := filepath.Join(b.TempDir(), "repository")
if err := os.MkdirAll(repository, 0o755); err != nil {
b.Fatal(err)
}
gitRun(b, repository, "init", "-b", "main")
gitRun(b, repository, "config", "user.name", "ReleaseKit Benchmark")
gitRun(b, repository, "config", "user.email", "benchmark@example.invalid")
writeFile(b, repository, "docs/releases/v1.2.3.md", "# Sample App v1.2.3\n\nDelivery fixture.\n")
gitRun(b, repository, "add", ".")
gitRun(b, repository, "commit", "-m", "prepare delivery benchmark")
gitRun(b, repository, "tag", "v1.2.3")
data := make([]byte, performanceDeliveryArtifactSize)
for i := range data {
data[i] = byte((i*17 + i/127 + 29) % 251)
}
digest := sha256.Sum256(data)
digestText := hex.EncodeToString(digest[:])
outputDir := filepath.Join(b.TempDir(), "artifacts")
if err := os.MkdirAll(outputDir, 0o700); err != nil {
b.Fatal(err)
}
targets := []releasekit.Target{{OS: "linux", Arch: "amd64"}, {OS: "windows", Arch: "amd64"}}
build := releasekit.BuildResult{
RepositoryDir: repository, CandidateCommit: gitText(b, repository, "rev-parse", "HEAD"),
SourceVerified: true, Version: "v1.2.3", MainPackage: "./cmd/app",
BinaryName: "releasekit", Targets: targets, OutputDir: outputDir,
}
assets := make([]deliveryBenchmarkAsset, 0, len(targets)+1)
for _, target := range targets {
filename := fmt.Sprintf("releasekit-v1.2.3-%s-%s", target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
path := filepath.Join(outputDir, filename)
if err := os.WriteFile(path, data, 0o600); err != nil {
b.Fatal(err)
}
build.Artifacts = append(build.Artifacts, releasekit.BuildArtifact{Path: path, Filename: filename, Target: target, SHA256: digestText})
assets = append(assets, deliveryBenchmarkAsset{name: filename, data: data, digest: digestText})
}
manifest := performanceDeliveryManifest(build.Artifacts)
manifestHash := sha256.Sum256(manifest)
assets = append(assets, deliveryBenchmarkAsset{name: gitea.ManifestFilename, data: manifest, digest: hex.EncodeToString(manifestHash[:])})
return repository, build, assets
}
func performanceDeliveryManifest(artifacts []releasekit.BuildArtifact) []byte {
ordered := append([]releasekit.BuildArtifact(nil), artifacts...)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
var manifest strings.Builder
for _, artifact := range ordered {
fmt.Fprintf(&manifest, "%s %s\n", artifact.SHA256, artifact.Filename)
}
return []byte(manifest.String())
}
func (state *deliveryBenchmarkServer) reset(populated bool) {
state.mu.Lock()
defer state.mu.Unlock()
state.page = populated
state.attached = make(map[string]deliveryBenchmarkIdentity)
if populated {
for _, asset := range state.assets {
state.attached[asset.name] = deliveryBenchmarkIdentity{digest: asset.digest, size: int64(len(asset.data))}
}
}
}
func (state *deliveryBenchmarkServer) verifyUploads(b *testing.B) {
b.Helper()
state.mu.Lock()
defer state.mu.Unlock()
if !state.page || len(state.attached) != len(state.assets) {
b.Fatalf("fresh upload left release page %t and %d attached assets, want page and %d assets", state.page, len(state.attached), len(state.assets))
}
for _, asset := range state.assets {
identity, ok := state.attached[asset.name]
if !ok || identity.digest != asset.digest || identity.size != int64(len(asset.data)) {
b.Fatalf("uploaded %q identity = %#v, present %t; want digest %s and size %d", asset.name, identity, ok, asset.digest, len(asset.data))
}
}
}
func (state *deliveryBenchmarkServer) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/owner/example/tags/v1.2.3":
_, _ = fmt.Fprintf(w, `{"name":"v1.2.3","commit":{"sha":%q}}`, state.commit)
case "/api/v1/repos/owner/example/releases/tags/v1.2.3":
state.mu.Lock()
page := state.page
state.mu.Unlock()
if !page {
http.NotFound(w, r)
return
}
state.writeRelease(w)
case "/api/v1/repos/owner/example/releases":
state.mu.Lock()
state.page = true
state.mu.Unlock()
state.writeRelease(w)
case "/api/v1/repos/owner/example/releases/10/assets":
if r.Method == http.MethodGet {
state.writeAssetList(w)
return
}
state.receiveUpload(w, r)
default:
if strings.HasPrefix(r.URL.Path, "/files/") {
state.serveFile(w, r)
return
}
http.NotFound(w, r)
}
}
func (state *deliveryBenchmarkServer) writeRelease(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 10, "html_url": state.baseURL + "/releases/10", "tag_name": "v1.2.3",
"target_commitish": state.commit, "name": "Fixture v1.2.3",
"body": "# Sample App v1.2.3\n\nDelivery fixture.\n", "draft": false, "prerelease": false,
})
}
func (state *deliveryBenchmarkServer) writeAssetList(w http.ResponseWriter) {
state.mu.Lock()
assets := make([]map[string]any, 0, len(state.attached))
for name, identity := range state.attached {
assets = append(assets, map[string]any{
"id": len(assets) + 1, "name": name, "size": identity.size,
"browser_download_url": state.baseURL + "/files/" + name,
})
}
state.mu.Unlock()
_ = json.NewEncoder(w).Encode(assets)
}
func (state *deliveryBenchmarkServer) receiveUpload(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
reader, err := r.MultipartReader()
if err != nil {
http.Error(w, "invalid multipart request", http.StatusBadRequest)
return
}
var digest hash.Hash = sha256.New()
var size int64
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, "invalid multipart body", http.StatusBadRequest)
return
}
if part.FormName() == "attachment" {
size, err = io.Copy(digest, part)
if err != nil {
_ = part.Close()
http.Error(w, "cannot read attachment", http.StatusBadRequest)
return
}
}
_ = part.Close()
}
identity := deliveryBenchmarkIdentity{digest: hex.EncodeToString(digest.Sum(nil)), size: size}
var expected *deliveryBenchmarkAsset
for i := range state.assets {
if state.assets[i].name == name {
expected = &state.assets[i]
break
}
}
if expected == nil || identity.digest != expected.digest || identity.size != int64(len(expected.data)) {
http.Error(w, "attachment digest or size mismatch", http.StatusBadRequest)
return
}
state.mu.Lock()
state.attached[name] = identity
assetID := len(state.attached)
state.mu.Unlock()
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": assetID, "name": name, "size": size})
}
func (state *deliveryBenchmarkServer) serveFile(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/files/")
for _, asset := range state.assets {
if asset.name == name {
w.Header().Set("Content-Length", fmt.Sprint(len(asset.data)))
_, _ = io.Copy(w, bytes.NewReader(asset.data))
return
}
}
http.NotFound(w, r)
}

View File

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

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
t.Setenv("RELEASEKIT_FAIL_FIXTURE_SUITE", "1") 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") { if err == nil || !strings.Contains(err.Error(), "fixture standard suite failure") {
t.Fatalf("Publish() error = %v, want fixture standard suite failure", err) 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 { if checks != 0 || result.TagObject != "" || result.State != releasekit.PublicationNotAttempted {
t.Fatalf("failed standard suite result=%#v additional checks=%d", result, checks) 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) { func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) {
repo := newPublishFixture(t) repo := newPublishFixture(t)
suiteRuns := filepath.Join(t.TempDir(), "suite-runs") suiteRuns := filepath.Join(t.TempDir(), "suite-runs")
@@ -140,6 +376,10 @@ func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *te
if !errors.Is(err, wantErr) { if !errors.Is(err, wantErr) {
t.Fatalf("Publish() error = %v, want wrapped additional-check error", err) 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" { 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) t.Fatalf("standard suite runs = %q, error = %v; want one successful run", runs, readErr)
} }
@@ -557,7 +797,7 @@ func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
} }
func newPublishFixture(t *testing.T) publishFixture { func newPublishFixture(t testing.TB) publishFixture {
t.Helper() t.Helper()
base := t.TempDir() base := t.TempDir()
work := filepath.Join(base, "work") work := filepath.Join(base, "work")
@@ -591,6 +831,25 @@ func TestFixture(t *testing.T) {
t.Fatal("fixture standard suite failure") 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, "docs/releases/v1.2.3.md", releaseNote)
writeFile(t, work, "source.txt", "candidate") writeFile(t, work, "source.txt", "candidate")
@@ -611,7 +870,7 @@ func TestFixture(t *testing.T) {
return publishFixture{work: work, bare: bare, commit: commit, unrelatedCommit: other} return publishFixture{work: work, bare: bare, commit: commit, unrelatedCommit: other}
} }
func gitRun(t *testing.T, dir string, args ...string) { func gitRun(t testing.TB, dir string, args ...string) {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
@@ -620,7 +879,7 @@ func gitRun(t *testing.T, dir string, args ...string) {
} }
} }
func gitText(t *testing.T, dir string, args ...string) string { func gitText(t testing.TB, dir string, args ...string) string {
t.Helper() t.Helper()
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
@@ -642,7 +901,7 @@ func gitBytes(t *testing.T, dir string, args ...string) string {
return string(output) return string(output)
} }
func writeFile(t *testing.T, root, name, contents string) { func writeFile(t testing.TB, root, name, contents string) {
t.Helper() t.Helper()
path := filepath.Join(root, filepath.FromSlash(name)) path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {

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