Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e030794969 | |||
| 10964bebd4 | |||
| e84d28d249 | |||
| 8314e0e4d8 | |||
| b6a490cefc | |||
| 2e0d302178 | |||
| 95599e96f6 | |||
| 1eb1cc6491 | |||
| edd670e62f | |||
| c8fa8ef85f | |||
| eee979db7b | |||
| b80d4ee071 | |||
| 40068af1bf | |||
| 6397d41517 | |||
| b3b959ce77 | |||
| f80970a9d4 | |||
| eed95896f4 | |||
| 45dd827dda | |||
| c5f925c949 | |||
| 368043984e | |||
| cabc56b134 | |||
| 1c41f7a0d1 | |||
| 30fe4fe206 | |||
| 89981abbe5 | |||
| 9086306498 | |||
| 3974a4686a | |||
| 1dfa774164 | |||
| 9d2ca66a79 | |||
| 02050016c1 | |||
| ff83af1e34 | |||
| 56e652aae3 | |||
| 1498ec7498 | |||
| f53354be5f | |||
| 89c5b2c1fd | |||
| 1ebd45c975 | |||
| 8bac90a9a8 | |||
| 5848307948 | |||
| 1cad13649e | |||
| 136a97fb6c | |||
| 1de7555b3e | |||
| 0c7129ab75 | |||
| 8c742cd11d | |||
| 7afc0de092 | |||
| 2a246990fe | |||
| f9f0349eca | |||
| f4a32dae73 | |||
| a4b1b6d5d2 | |||
| f55f6331cb | |||
| 8ff8efa4a2 | |||
| 6de0428675 | |||
| 9e480f0b4f | |||
| 60175d6690 |
@@ -6,9 +6,7 @@ local candidate check, source-tag publication API, selected-target build API,
|
|||||||
tagged-source delivery API, and the `version`, `check`, `publish`, `build`,
|
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 [v0.1.0 release note](docs/releases/v0.1.0.md) for the initial baseline
|
||||||
and installation commands.
|
and installation commands.
|
||||||
@@ -26,7 +24,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 +42,6 @@ failure recovery.
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Product roadmap](docs/roadmap/releasekit.md): consumer adoption goals and
|
|
||||||
remaining release and deployment work.
|
|
||||||
- [Contributor guide](docs/development.md): orientation, validation, and selective
|
- [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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
195
check.go
195
check.go
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,221 @@ 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) {
|
||||||
|
root := cliRepositoryRoot(t)
|
||||||
|
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "tag-delivery.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
|
proxy := writeReleaseKitModuleProxy(t)
|
||||||
|
|
||||||
|
t.Run("older tag retry uses its committed module selection", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.197\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "select the earlier ReleaseKit version")
|
||||||
|
oldCommit := cliGitOutput(t, repository, "rev-parse", "HEAD")
|
||||||
|
cliGitRun(t, repository, "tag", "v1.2.3")
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+module+" v0.2.198\n")
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "upgrade the default branch tool")
|
||||||
|
cliGitRun(t, repository, "init", "--bare", filepath.Join(filepath.Dir(repository), "origin.git"))
|
||||||
|
remote := filepath.Join(filepath.Dir(repository), "origin.git")
|
||||||
|
cliGitRun(t, repository, "remote", "add", "origin", remote)
|
||||||
|
cliGitRun(t, repository, "push", "origin", "refs/heads/main", "refs/tags/v1.2.3")
|
||||||
|
cliGitRun(t, repository, "checkout", "--detach", oldCommit)
|
||||||
|
if mainModule := cliGitOutput(t, repository, "show", "main:go.mod"); !strings.Contains(mainModule, module+" v0.2.198") {
|
||||||
|
t.Fatalf("default branch module = %q, want upgraded tool selection", mainModule)
|
||||||
|
}
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
commit := oldCommit
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
|
||||||
|
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
|
||||||
|
env["RELEASEKIT_SUM_LOG"] = sumLog
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.197") || !strings.Contains(output, "helper=tool-only dependency") || strings.Contains(output, "tool-version=v0.2.198") {
|
||||||
|
t.Fatalf("retry selected the wrong tool version: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||||
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("versioned replacement selects its declared source", func(t *testing.T) {
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
goMod := "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire " + module + " v0.2.197\n\nreplace " + module + " v0.2.197 => " + module + " v0.2.198\n"
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "select a versioned tool replacement")
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
moduleBefore := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod"))
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
env["GOPROXY"] = "file://" + filepath.ToSlash(proxy)
|
||||||
|
sumLog := filepath.Join(t.TempDir(), "tool-only-sums")
|
||||||
|
env["RELEASEKIT_SUM_LOG"] = sumLog
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "tool-version=v0.2.198") || !strings.Contains(output, "helper=tool-only dependency") {
|
||||||
|
t.Fatalf("versioned replacement was not selected: %q", output)
|
||||||
|
}
|
||||||
|
assertModuleBootstrap(t, repository, goLog, moduleBefore)
|
||||||
|
assertToolOnlyChecksums(t, sumLog)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWoodpeckerSourceDeliveryTemplateSelfBootstraps(t *testing.T) {
|
||||||
|
root := cliRepositoryRoot(t)
|
||||||
|
contents, err := os.ReadFile(filepath.Join(root, "examples", "woodpecker", "releasekit-source-delivery.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := extractWoodpeckerCommand(string(contents), "deliver")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
command := strings.ReplaceAll(block, "$${", "${")
|
||||||
|
repository := newCLIRepository(t)
|
||||||
|
const dependency = "example.com/releasekit-helper"
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
writeModuleProxyVersion(t, proxyDir, dependency, "v1.0.0", "module "+dependency+"\n\ngo 1.25.0\n", map[string]string{
|
||||||
|
"helper.go": "package helper\n\nconst Message = \"prepared tool dependency\"\n",
|
||||||
|
})
|
||||||
|
var proxyRequests atomic.Int64
|
||||||
|
files := http.FileServer(http.Dir(proxyDir))
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
proxyRequests.Add(1)
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
|
defer proxy.Close()
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", "module example.com/cli-candidate\n\ngo 1.25.0\n\nrequire "+dependency+" v1.0.0\n")
|
||||||
|
writeRepositoryFile(t, repository, "cmd/releasekit/main.go", "package main\n\nimport (\"fmt\"; \"os\"; helper \""+dependency+"\")\n\nfunc main() {\n\tfmt.Println(\"self-bootstrap \" + helper.Message)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n")
|
||||||
|
preparationEnv := map[string]string{
|
||||||
|
"GOPROXY": proxy.URL, "GOSUMDB": "off", "GONOPROXY": "none", "GOPRIVATE": "",
|
||||||
|
"GOWORK": "off", "GOTOOLCHAIN": "local", "GOENV": "off", "GOFLAGS": "-modcacherw",
|
||||||
|
"GOMODCACHE": t.TempDir(),
|
||||||
|
}
|
||||||
|
runWoodpeckerBlock(t, "go mod download "+dependency, repository, preparationEnv)
|
||||||
|
cliGitRun(t, repository, "add", "cmd/releasekit/main.go", "go.mod", "go.sum")
|
||||||
|
cliGitRun(t, repository, "commit", "-m", "add the self-bootstrapped command")
|
||||||
|
commit := pushTaggedCLIRepository(t, repository)
|
||||||
|
goDir, goLog := installGoBootstrapShim(t)
|
||||||
|
env := templateEnvironment(repository, commit, goDir, goLog)
|
||||||
|
for key, value := range preparationEnv {
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
env["GOMODCACHE"] = t.TempDir()
|
||||||
|
proxyRequests.Store(0)
|
||||||
|
env["CI_COMMIT_SHA"] = strings.Repeat("0", len(commit))
|
||||||
|
if output, err := executeWoodpeckerBlock(command, repository, env); err == nil {
|
||||||
|
t.Fatalf("wrong commit unexpectedly bootstrapped the tool: %s", output)
|
||||||
|
}
|
||||||
|
assertNoCommands(t, goLog)
|
||||||
|
if proxyRequests.Load() != 0 {
|
||||||
|
t.Fatal("wrong commit reached tool dependency preparation")
|
||||||
|
}
|
||||||
|
env["CI_COMMIT_SHA"] = commit
|
||||||
|
output := runWoodpeckerBlock(t, command, repository, env)
|
||||||
|
if !strings.Contains(output, "self-bootstrap prepared tool dependency") || !strings.Contains(output, `"`+commit+`"`) {
|
||||||
|
t.Fatalf("self-bootstrap output = %q", output)
|
||||||
|
}
|
||||||
|
if proxyRequests.Load() == 0 {
|
||||||
|
t.Fatal("self-bootstrap did not prepare its dependency from the local proxy")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, `"ci-deliver"`) || !strings.Contains(output, `"`+repository+`"`) || !strings.Contains(output, `"v1.2.3"`) {
|
||||||
|
t.Fatalf("self-bootstrapped command arguments = %q", output)
|
||||||
|
}
|
||||||
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, "build -mod=readonly -o ") || !strings.Contains(log, "./cmd/releasekit") {
|
||||||
|
t.Fatalf("self-bootstrap Go command = %q", log)
|
||||||
|
}
|
||||||
|
var executable string
|
||||||
|
for index, field := range strings.Fields(log) {
|
||||||
|
if field == "-o" && index+1 < len(strings.Fields(log)) {
|
||||||
|
executable = strings.Fields(log)[index+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if executable == "" {
|
||||||
|
t.Fatalf("self-bootstrap output path missing from Go command: %q", log)
|
||||||
|
}
|
||||||
|
if relative, err := filepath.Rel(repository, executable); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
|
||||||
|
t.Fatalf("self-bootstrap executable %q is not outside source %q: relative=%q error=%v", executable, repository, relative, err)
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("self-bootstrap changed the tagged source: %q", status)
|
||||||
|
}
|
||||||
|
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
|
||||||
|
t.Fatalf("self-bootstrap checkout branch = %q, want detached HEAD", branch)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-source.") {
|
||||||
|
t.Fatalf("self-bootstrap temporary directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cliRepositoryRoot(t *testing.T) string {
|
func cliRepositoryRoot(t *testing.T) string {
|
||||||
@@ -156,34 +358,22 @@ func extractWoodpeckerCommand(contents, stepName string) (string, error) {
|
|||||||
return "", fmt.Errorf("step %q was not found", stepName)
|
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 +381,79 @@ 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 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 +461,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.25.0\n\nrequire %s v0.2.0\n\nreplace %s => %s\n", module, module, strconv.Quote(releaseKitSource))
|
||||||
|
writeRepositoryFile(t, repository, "go.mod", goMod)
|
||||||
|
cliGitRun(t, repository, "add", "go.mod")
|
||||||
|
if includeSum {
|
||||||
|
writeRepositoryFile(t, repository, "go.sum", "")
|
||||||
|
cliGitRun(t, repository, "add", "go.sum")
|
||||||
}
|
}
|
||||||
return string(output)
|
cliGitRun(t, repository, "commit", "-m", "select ReleaseKit from module configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeReleaseKitModuleProxy(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
const module = "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
const toolDependency = "example.com/releasekit-helper"
|
||||||
|
proxyDir := t.TempDir()
|
||||||
|
toolModule := "module " + module + "\n\ngo 1.25.0\n\nrequire " + toolDependency + " v1.0.0\n"
|
||||||
|
for _, version := range []string{"v0.2.197", "v0.2.198"} {
|
||||||
|
files := map[string]string{
|
||||||
|
"cmd/releasekit/main.go": "package main\n\nimport (\"fmt\"; \"os\"; tooldep \"example.com/releasekit-helper\")\n\nfunc main() {\n\tfmt.Println(\"tool-version=" + version + " helper=\" + tooldep.Version)\n\tfmt.Printf(\"tool-args=%q\\n\", os.Args[1:])\n}\n",
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, module, version, toolModule, files)
|
||||||
|
}
|
||||||
|
writeModuleProxyVersion(t, proxyDir, toolDependency, "v1.0.0", "module "+toolDependency+"\n\ngo 1.25.0\n", map[string]string{"tooldep.go": "package tooldep\n\nconst Version = \"tool-only dependency\"\n"})
|
||||||
|
return proxyDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeModuleProxyVersion(t *testing.T, proxyDir, module, version, moduleFile string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
var escaped strings.Builder
|
||||||
|
for _, character := range module {
|
||||||
|
if character >= 'A' && character <= 'Z' {
|
||||||
|
escaped.WriteByte('!')
|
||||||
|
character += 'a' - 'A'
|
||||||
|
}
|
||||||
|
escaped.WriteRune(character)
|
||||||
|
}
|
||||||
|
moduleVersions := filepath.Join(proxyDir, filepath.FromSlash(escaped.String()), "@v")
|
||||||
|
if err := os.MkdirAll(moduleVersions, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(moduleVersions, "list")); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, "list"), []byte(version+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listPath := filepath.Join(moduleVersions, "list")
|
||||||
|
list := readWoodpeckerFixtureFile(t, listPath)
|
||||||
|
if !strings.Contains(list, version+"\n") {
|
||||||
|
if err := os.WriteFile(listPath, []byte(list+version+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, version+".mod"), []byte(moduleFile), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := fmt.Sprintf("{\"Version\":%q,\"Time\":\"2026-10-01T00:00:00Z\"}\n", version)
|
||||||
|
if err := os.WriteFile(filepath.Join(moduleVersions, version+".info"), []byte(info), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive, err := os.Create(filepath.Join(moduleVersions, version+".zip"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := zip.NewWriter(archive)
|
||||||
|
prefix := module + "@" + version + "/"
|
||||||
|
files["go.mod"] = moduleFile
|
||||||
|
for name, contents := range files {
|
||||||
|
file, err := writer.Create(prefix + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(contents)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := archive.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertModuleBootstrap(t *testing.T, repository, goLog, moduleBefore string, sumBefore ...string) {
|
||||||
|
t.Helper()
|
||||||
|
assertGoSelection(t, repository, goLog)
|
||||||
|
if got := readWoodpeckerFixtureFile(t, filepath.Join(repository, "go.mod")); got != moduleBefore {
|
||||||
|
t.Fatalf("source go.mod changed during bootstrap: got %q, want %q", got, moduleBefore)
|
||||||
|
}
|
||||||
|
sumPath := filepath.Join(repository, "go.sum")
|
||||||
|
if len(sumBefore) == 0 {
|
||||||
|
if _, err := os.Stat(sumPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("source go.sum state changed: stat error = %v", err)
|
||||||
|
}
|
||||||
|
} else if got := readWoodpeckerFixtureFile(t, sumPath); got != sumBefore[0] {
|
||||||
|
t.Fatalf("source go.sum changed during bootstrap: got %q, want %q", got, sumBefore[0])
|
||||||
|
}
|
||||||
|
if status := cliGitOutput(t, repository, "status", "--porcelain", "--untracked-files=all"); status != "" {
|
||||||
|
t.Fatalf("bootstrap changed tagged source: %q", status)
|
||||||
|
}
|
||||||
|
if branch := cliGitOutput(t, repository, "rev-parse", "--abbrev-ref", "HEAD"); branch != "HEAD" {
|
||||||
|
t.Fatalf("checkout branch = %q, want detached HEAD", branch)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Dir(repository))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), ".releasekit-bootstrap.") {
|
||||||
|
t.Fatalf("temporary bootstrap directory remains: %s", entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGoSelection(t *testing.T, repository, goLog string) {
|
||||||
|
t.Helper()
|
||||||
|
log := readWoodpeckerFixtureFile(t, goLog)
|
||||||
|
list := "list -mod=mod -modfile="
|
||||||
|
build := "build -mod=mod -modfile="
|
||||||
|
module := "gitea.maximumdirect.net/eric/ReleaseKit"
|
||||||
|
if !strings.Contains(log, "GOWORK=off GOTOOLCHAIN=local") || !strings.Contains(log, list) || !strings.Contains(log, "-m "+module) {
|
||||||
|
t.Fatalf("Go module selection command is missing required settings: %q", log)
|
||||||
|
}
|
||||||
|
if !strings.Contains(log, build) || !strings.Contains(log, module+"/cmd/releasekit") || strings.Contains(log, "@v0.2.0") {
|
||||||
|
t.Fatalf("Go build did not use the selected module without a version suffix: %q", log)
|
||||||
|
}
|
||||||
|
if strings.Index(log, list) > strings.Index(log, build) {
|
||||||
|
t.Fatalf("Go build ran before module selection: %q", log)
|
||||||
|
}
|
||||||
|
var modfile string
|
||||||
|
for _, field := range strings.Fields(log) {
|
||||||
|
if strings.HasPrefix(field, "-modfile=") {
|
||||||
|
modfile = strings.TrimPrefix(field, "-modfile=")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if modfile == "" {
|
||||||
|
t.Fatalf("Go commands did not receive an alternate module file: %q", log)
|
||||||
|
}
|
||||||
|
if relative, err := filepath.Rel(repository, modfile); err != nil || relative == "." || (!strings.HasPrefix(relative, ".."+string(filepath.Separator)) && relative != "..") {
|
||||||
|
t.Fatalf("alternate module file %q is not outside source %q: relative=%q error=%v", modfile, repository, relative, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoCommands(t *testing.T, goLog string) {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(goLog)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(contents) != 0 {
|
||||||
|
t.Fatalf("Go ran before tag/source verification: %q", contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertToolOnlyChecksums(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
sums := readWoodpeckerFixtureFile(t, path)
|
||||||
|
if !strings.Contains(sums, "example.com/releasekit-helper v1.0.0 h1:") {
|
||||||
|
t.Fatalf("temporary module sum file omitted the tool-only dependency checksum: %q", sums)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readWoodpeckerFixtureFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEnvironment(environment []string, key, value string) []string {
|
func setEnvironment(environment []string, key, value string) []string {
|
||||||
|
|||||||
76
docs/adr/0003-declarative-validation.md
Normal file
76
docs/adr/0003-declarative-validation.md
Normal 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.
|
||||||
68
docs/adr/0004-module-selected-tooling.md
Normal file
68
docs/adr/0004-module-selected-tooling.md
Normal 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.
|
||||||
100
docs/cli.md
100
docs/cli.md
@@ -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.
|
||||||
|
|||||||
145
docs/config.md
145
docs/config.md
@@ -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
|
||||||
|
|||||||
@@ -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,64 @@ 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -150,28 +150,48 @@ 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 Go 1.25 or newer, Git, and a local Go version compatible with the
|
||||||
module. Replace `RELEASEKIT_VERSION` with an immutable ReleaseKit revision or
|
consumer module. It uses `GOTOOLCHAIN=local`; Go does not download a different
|
||||||
published version. The example selects the initial `v0.1.0` baseline; update
|
toolchain. The tag's `go.mod` must select ReleaseKit as a module dependency.
|
||||||
that pin when adopting a later release. Configure the Woodpecker repository secret
|
Configure the Woodpecker repository secret `releasekit_gitea_token`; the
|
||||||
`releasekit_gitea_token`; the template maps it to `RELEASEKIT_GITEA_TOKEN` only
|
template maps it to `RELEASEKIT_GITEA_TOKEN` for delivery. The repository needs
|
||||||
for the delivery step. The repository needs a `delivery` profile. Page-only and
|
a `delivery` profile. Page-only and binary behavior both use this template; the
|
||||||
binary behavior both use this template; the profile's build target list selects
|
profile's build target list selects whether application binaries are built and
|
||||||
whether binaries are built and delivered.
|
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 +199,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -41,6 +41,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.
|
||||||
|
|||||||
@@ -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 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -40,12 +40,13 @@ these implemented packages:
|
|||||||
| `internal/build` | Candidate-build coordination, selected Go target compilation, version injection, native smoke check, and artifact digests. |
|
| `internal/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, prebuilt artifact validation, manifest assembly, and hosted delivery coordination. |
|
||||||
| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
| `internal/git` | Git command boundary, committed-tree and blob reads, push-destination resolution, exact ref inspection, tagged checkout preparation, and tag operations, including captured-endpoint verification during recovery. |
|
||||||
| `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
|
| `internal/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 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` | Private version lookup used by the runtime facade. |
|
||||||
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
|
||||||
@@ -58,6 +59,17 @@ dependency direction with `go run ./internal/apicheck`. These constraints are
|
|||||||
also recorded in [ADR 0001](../adr/0001-public-facade.md) and the
|
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,17 @@ 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` |
|
| 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`, `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 +100,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.
|
|
||||||
|
|||||||
143
docs/internal/validation.md
Normal file
143
docs/internal/validation.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Shared validation mechanics
|
||||||
|
|
||||||
|
`internal/validation` owns reusable checks for one captured candidate.
|
||||||
|
`internal/candidate` captures the clean source revision and keeps the
|
||||||
|
validation sequence shared by local checking and publication. It calls the
|
||||||
|
shared executor, runs product-specific `AdditionalChecks` once, and owns the
|
||||||
|
post-validation build callback and local-state recheck. `internal/publish`
|
||||||
|
adds its endpoint guards, tag creation, push, and recovery around that same
|
||||||
|
candidate path.
|
||||||
|
|
||||||
|
The executor runs one offline Go suite, optionally with the race detector when
|
||||||
|
selected. When full vet is selected, it disables test-time vet for that suite
|
||||||
|
and runs native `go vet -mod=readonly ./...` after the suite succeeds. It then
|
||||||
|
runs `go build -mod=readonly -o <os.DevNull> ./...` in static mode for each
|
||||||
|
selected package-compilation target, preserving target order and stopping on
|
||||||
|
the first failure. This compiles source without producing an artifact or
|
||||||
|
executing a target binary. Test, vet, and package-compilation failures retain
|
||||||
|
their `go-test`, `go-vet`, and `package-compile` identities, selected target
|
||||||
|
where applicable, and underlying causes. Its
|
||||||
|
request carries the captured repository directory and commit, output writer,
|
||||||
|
and internal settings. When selected, the Go-format check loads the candidate
|
||||||
|
tree once, reads regular Go blobs from the captured commit, and compares them
|
||||||
|
with go/format output. It includes vendor and nested-module files, skips
|
||||||
|
symlink blobs, reports all unformatted paths, and stops with a contextual cause
|
||||||
|
on parse or inspection errors. It runs before product checks and build callbacks.
|
||||||
|
`Request.ReadCandidateTree` lets selected file checks request candidate
|
||||||
|
contents; the Go-only path does not enumerate the tree.
|
||||||
|
|
||||||
|
The workspace-file check uses that same candidate inventory and reports blob
|
||||||
|
entries named go.work or go.work.sum, including nested paths and symlink blobs.
|
||||||
|
It does not read workspace contents or depend on the workspace selected for Go
|
||||||
|
commands.
|
||||||
|
The local-replacement check parses every committed go.mod blob with
|
||||||
|
`golang.org/x/mod/modfile`, including nested modules and vendor declarations.
|
||||||
|
It follows committed symlink declarations through the captured tree and fails
|
||||||
|
with an inspection cause when their selected contents are unavailable. A
|
||||||
|
replacement with no parsed version is reported with its go.mod path, line,
|
||||||
|
directive, and target; versioned replacements are allowed. It runs after the
|
||||||
|
workspace-file check using the same candidate inventory.
|
||||||
|
The optional file-size check uses each blob's captured Git size, including
|
||||||
|
symlink link-text bytes, and skips entries without a blob size such as gitlinks.
|
||||||
|
It applies normalized literal file/directory exclusions with path-boundary
|
||||||
|
matching, then returns the first oversized path with `FileSizeMeasurement`.
|
||||||
|
The optional Markdown check follows file-size inspection and uses the same
|
||||||
|
candidate tree. It parses regular Markdown blobs, resolves local destinations,
|
||||||
|
and checks supported anchors; outside-checkout and submodule observations are
|
||||||
|
written to the shared output writer. A nil writer keeps library use quiet.
|
||||||
|
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
|
||||||
|
lazily, and resolves destinations without consulting the worktree or
|
||||||
|
neighboring files.
|
||||||
|
|
||||||
|
The internal Markdown parser uses Goldmark to record inline, reference, and
|
||||||
|
autolinks, images, titles, and their source use sites, and to build heading
|
||||||
|
anchors from visible AST text. It supports ATX and Setext headings, decodes
|
||||||
|
Markdown text through the parser, and assigns collision-free numeric suffixes
|
||||||
|
to repeated anchors. Link locations use one-based line and byte-column values.
|
||||||
|
A per-path cache retains parsed records and anchors for candidate documents.
|
||||||
|
|
||||||
|
`checkMarkdownLinks` examines regular `.md` blobs from the captured tree. It
|
||||||
|
skips schemed and protocol-relative URIs, strips query components, and decodes
|
||||||
|
percent escapes once in local paths and fragments. It resolves destinations
|
||||||
|
through `CandidateTree.ResolveDestination`; decoded fragments are compared in
|
||||||
|
lowercase against anchors from the resolved regular Markdown document. Missing
|
||||||
|
paths, symlink cycles, and missing anchors produce `markdown-links` findings at
|
||||||
|
the source use site. Outside-checkout links produce out-of-scope observations by
|
||||||
|
default and findings when boundary enforcement is selected. Submodule-content
|
||||||
|
observations remain out of scope under either setting. Observations go to the
|
||||||
|
provided writer; a nil writer keeps library use quiet. Public selection is
|
||||||
|
defined in the [configuration reference](../config.md) and GoDoc.
|
||||||
|
|
||||||
|
Committed-tree metadata and blob reads disable lazy fetching and Git transports
|
||||||
|
for those child commands. Missing local objects produce inspection errors;
|
||||||
|
source preparation retains its separate network-capable operations. See Git's
|
||||||
|
[lazy-fetch controls](https://git-scm.com/docs/git) for the command mechanism.
|
||||||
|
|
||||||
|
Public validation errors convert their diagnostic fields to the internal
|
||||||
|
failure model for shared formatting. Converted failures retain their original
|
||||||
|
contextual cause and reuse its rendering when it already contains the same
|
||||||
|
diagnostics. The CLI presents the complete error chain, including candidate
|
||||||
|
context around callback-supplied diagnostics.
|
||||||
|
|
||||||
|
## Adding a shared check
|
||||||
|
|
||||||
|
When an optional check is accepted for implementation:
|
||||||
|
|
||||||
|
1. Define its typed Go setting in the root facade and its equivalent CLI-owned
|
||||||
|
JSON field. Specify zero-value and omitted-profile behavior in GoDoc and the
|
||||||
|
configuration reference. Keep future settings in the roadmap until their
|
||||||
|
check is implemented.
|
||||||
|
2. Add the corresponding internal setting and explicit conversions. Use the
|
||||||
|
same root request conversion for `Check` and `Publish`, and the shared CLI
|
||||||
|
request conversion for profile-based candidate commands.
|
||||||
|
3. Implement a plain check function owned by `internal/validation`. Keep its
|
||||||
|
rule semantics in the capability owner and pass only the captured candidate
|
||||||
|
inputs it needs. A file-oriented check can call `Request.ReadCandidateTree`
|
||||||
|
and read only the blobs it inspects.
|
||||||
|
4. Return an internal `*Failure` with the check identity, repository-relative
|
||||||
|
findings, useful locations where available, and the underlying cause. Let
|
||||||
|
the root facade convert those records to its own diagnostic types. Preserve
|
||||||
|
cancellation and process errors through `Unwrap`.
|
||||||
|
5. Add the check to the shared executor at the ordering point selected by its
|
||||||
|
capability. Stop on failure. Keep product assertions in `AdditionalChecks`
|
||||||
|
and profile `checks`; keep artifact building in `internal/build`.
|
||||||
|
6. Cover observable behavior through both the Go facade and profile path when
|
||||||
|
both are supported. Check default behavior, useful findings, cause
|
||||||
|
preservation, cancellation where relevant, and failure stopping. Reuse the
|
||||||
|
existing local Git and CLI fixtures.
|
||||||
|
7. Update only the canonical owners for behavior that has landed: GoDoc for the
|
||||||
|
API, configuration and CLI references for profile behavior, and this
|
||||||
|
inventory for internal ownership. Record proposed checks under
|
||||||
|
`docs/roadmap/`.
|
||||||
|
|
||||||
|
Do not add a public validator registration interface. The public specification
|
||||||
|
selects ReleaseKit-owned checks; `AdditionalChecks` remains the extension point
|
||||||
|
for product behavior.
|
||||||
|
|
||||||
|
## Go command environment reuse
|
||||||
|
|
||||||
|
The current executor uses the [shared Go command environment](gotool.md) for
|
||||||
|
its offline suite, vet pass, and selected-target package compilation.
|
||||||
|
|
||||||
|
Reuse `internal/gotool` for native and static environments and target handling.
|
||||||
|
Keep toolchain and module-cache discovery in `gotool`; do not reproduce its
|
||||||
|
environment logic in a checker. Artifact output names, version injection,
|
||||||
|
native smoke checks, and artifact digests remain owned by `internal/build`.
|
||||||
|
|
||||||
|
## File-check coordination
|
||||||
|
|
||||||
|
File checks consume the captured tree and return findings to the shared error
|
||||||
|
model. They do not read sibling worktrees or follow live symlink targets.
|
||||||
|
`CandidateTree.ResolveDestination` returns data describing entries,
|
||||||
|
directories, missing paths, checkout boundaries, submodule paths, and symlink
|
||||||
|
cycles; the caller selects which observations fail validation or are reported
|
||||||
|
as out of scope.
|
||||||
|
|
||||||
|
Resolution walks committed components and symlink targets in order. Active
|
||||||
|
symlink expansion detects cycles, and traversal honors cancellation. Absolute
|
||||||
|
destinations and symlink targets within the repository directory map back to
|
||||||
|
candidate-relative paths; existence still comes from committed entries.
|
||||||
|
|
||||||
|
The public selection is owned by the facade and profile;
|
||||||
|
the internal checker uses the committed-file and destination-resolution
|
||||||
|
primitives described here.
|
||||||
@@ -1,5 +1,30 @@
|
|||||||
# 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,
|
||||||
|
|||||||
27
docs/oss/licenses/BSD-3-Clause.txt
Normal file
27
docs/oss/licenses/BSD-3-Clause.txt
Normal 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
21
docs/oss/licenses/MIT.txt
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
|||||||
policy and link to it rather than introduce additional release requirements.
|
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
|
||||||
|
|||||||
@@ -121,6 +121,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
106
docs/releases/v0.2.0.md
Normal 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)
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
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
|
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}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- GO_IMAGE: golang:1.25.0-bookworm
|
- GO_IMAGE: golang:1.25.0-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}"
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -1,3 +1,8 @@
|
|||||||
module gitea.maximumdirect.net/eric/ReleaseKit
|
module gitea.maximumdirect.net/eric/ReleaseKit
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/yuin/goldmark/v2 v2.1.6
|
||||||
|
golang.org/x/mod v0.40.0
|
||||||
|
)
|
||||||
|
|||||||
4
go.sum
Normal file
4
go.sum
Normal 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=
|
||||||
6
ideas.md
6
ideas.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -28,6 +32,47 @@ func TestInspectTaggedSourceSupportsLightweightAndAnnotatedTags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidateObjectReadsStayLocalInPartialClones(t *testing.T) {
|
||||||
|
fixture := newRemoteFixture(t)
|
||||||
|
gitRun(t, fixture.work, "config", "uploadpack.allowFilter", "true")
|
||||||
|
blob := strings.TrimSpace(gitText(t, fixture.work, "rev-parse", "HEAD:source.txt"))
|
||||||
|
for _, filter := range []string{"blob:none", "tree:0"} {
|
||||||
|
t.Run(filter, func(t *testing.T) {
|
||||||
|
repository := filepath.Join(t.TempDir(), "partial")
|
||||||
|
gitRun(t, fixture.work, "clone", "--filter="+filter, "--no-checkout", "file://"+filepath.ToSlash(fixture.work), repository)
|
||||||
|
before := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
|
||||||
|
if strings.Contains(before, blob) {
|
||||||
|
t.Fatal("fixture contains the filtered blob")
|
||||||
|
}
|
||||||
|
gitRun(t, repository, "config", "remote.origin.url", "ssh://releasekit-test.invalid/source")
|
||||||
|
attempts := filepath.Join(t.TempDir(), "attempts")
|
||||||
|
transport := filepath.Join(t.TempDir(), "ssh")
|
||||||
|
if err := os.WriteFile(transport, []byte("#!/bin/sh\nprintf 'remote accessed\\n' >> \"$RELEASEKIT_GIT_ATTEMPTS\"\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("GIT_SSH_COMMAND", transport)
|
||||||
|
t.Setenv("RELEASEKIT_GIT_ATTEMPTS", attempts)
|
||||||
|
t.Setenv("GIT_NO_LAZY_FETCH", "0")
|
||||||
|
t.Setenv("GIT_ALLOW_PROTOCOL", "ssh")
|
||||||
|
if _, err := ReadTree(t.Context(), repository, fixture.commit); err == nil {
|
||||||
|
t.Fatal("tree inspection succeeded with unavailable objects")
|
||||||
|
}
|
||||||
|
_, err := ReadBlob(t.Context(), repository, blob)
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) {
|
||||||
|
t.Fatalf("blob error %v lost its Git process cause", err)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(attempts); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("object inspection invoked remote transport: %q, %v", data, err)
|
||||||
|
}
|
||||||
|
after := gitText(t, repository, "cat-file", "--batch-all-objects", "--batch-check=%(objectname)")
|
||||||
|
if before != after {
|
||||||
|
t.Fatal("object inspection populated the object database")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInspectTaggedSourceReportsDirtyCheckoutAndReadCommitFile(t *testing.T) {
|
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")
|
||||||
|
|||||||
@@ -19,6 +19,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
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ func Run(ctx context.Context, invocation Invocation) error {
|
|||||||
|
|
||||||
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
command := exec.CommandContext(ctx, invocation.Args[0], invocation.Args[1:]...)
|
||||||
command.Dir = invocation.Dir
|
command.Dir = invocation.Dir
|
||||||
|
command.Stdin = invocation.Stdin
|
||||||
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
if len(invocation.Env) != 0 || len(invocation.UnsetEnv) != 0 {
|
||||||
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
command.Env = environmentEntries(invocation.Env, invocation.UnsetEnv)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
204
internal/validation/compile_test.go
Normal file
204
internal/validation/compile_test.go
Normal 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
|
||||||
|
}
|
||||||
296
internal/validation/destination_test.go
Normal file
296
internal/validation/destination_test.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "README.md", []byte("root\n"))
|
||||||
|
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
||||||
|
writeTreeFile(t, repository, "docs/target.md", []byte("target\n"))
|
||||||
|
writeTreeFile(t, repository, "docs/nested/item.txt", []byte("nested\n"))
|
||||||
|
writeTreeFile(t, repository, "docs/dir/item.txt", []byte("directory\n"))
|
||||||
|
for name, target := range map[string]string{
|
||||||
|
"docs/inside": "target.md",
|
||||||
|
"docs/through-dir": "nested",
|
||||||
|
"docs/outside": "../../neighbor/file",
|
||||||
|
"docs/absolute": "/outside/file",
|
||||||
|
"docs/dangling": "missing-target",
|
||||||
|
"docs/cycle-a": "cycle-b",
|
||||||
|
"docs/cycle-b": "cycle-a",
|
||||||
|
} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
submoduleSource := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, submoduleSource, "sub.txt", []byte("submodule\n"))
|
||||||
|
gitTree(t, submoduleSource, "add", "--all")
|
||||||
|
gitTree(t, submoduleSource, "commit", "-m", "create submodule source")
|
||||||
|
submoduleOID := strings.TrimSpace(gitTree(t, submoduleSource, "rev-parse", "HEAD"))
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",docs/submodule")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture destination fixtures")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(repository, "docs", "inside")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "inside")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
destination string
|
||||||
|
kind DestinationKind
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "relative file", source: "docs/page.md", destination: "target.md", kind: DestinationEntry, path: "docs/target.md"},
|
||||||
|
{name: "parent traversal inside root", source: "docs/page.md", destination: "../README.md", kind: DestinationEntry, path: "README.md"},
|
||||||
|
{name: "outside root", source: "docs/page.md", destination: "../../neighbor/file", kind: DestinationOutsideCheckout, path: "../neighbor/file"},
|
||||||
|
{name: "repository root directory", source: "docs/page.md", destination: "..", kind: DestinationDirectory, path: "."},
|
||||||
|
{name: "directory", source: "docs/page.md", destination: "dir", kind: DestinationDirectory, path: "docs/dir"},
|
||||||
|
{name: "symlink to file", source: "docs/page.md", destination: "inside", kind: DestinationEntry, path: "docs/target.md"},
|
||||||
|
{name: "symlinked directory", source: "docs/page.md", destination: "through-dir/item.txt", kind: DestinationEntry, path: "docs/nested/item.txt"},
|
||||||
|
{name: "absolute symlink", source: "docs/page.md", destination: "absolute", kind: DestinationOutsideCheckout, path: "/outside/file"},
|
||||||
|
{name: "missing entry", source: "docs/page.md", destination: "missing", kind: DestinationMissing, path: "docs/missing"},
|
||||||
|
{name: "dangling symlink", source: "docs/page.md", destination: "dangling", kind: DestinationMissing, path: "docs/missing-target"},
|
||||||
|
{name: "symlink cycle", source: "docs/page.md", destination: "cycle-a", kind: DestinationSymlinkCycle, path: "docs/cycle-a"},
|
||||||
|
{name: "submodule contents", source: "docs/page.md", destination: "submodule/child", kind: DestinationSubmodule, path: "docs/submodule/child"},
|
||||||
|
}
|
||||||
|
for _, test := range cases {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := tree.ResolveDestination(context.Background(), test.source, test.destination)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveDestination() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Kind != test.kind || got.Path != test.path {
|
||||||
|
t.Fatalf("ResolveDestination() = %#v, want kind %q path %q", got, test.kind, test.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDestinationHandlesExpandingCyclesAndRepeatedLinks(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "README.md", []byte("source\n"))
|
||||||
|
for name, target := range map[string]string{
|
||||||
|
"direct": "direct/child",
|
||||||
|
"mutual-a": "mutual-b/child",
|
||||||
|
"mutual-b": "mutual-a/child",
|
||||||
|
"alias": ".",
|
||||||
|
} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(repository, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture recursive links")
|
||||||
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
destination string
|
||||||
|
kind DestinationKind
|
||||||
|
}{
|
||||||
|
{"direct", DestinationSymlinkCycle},
|
||||||
|
{"mutual-a", DestinationSymlinkCycle},
|
||||||
|
{"alias/alias/README.md", DestinationEntry},
|
||||||
|
} {
|
||||||
|
t.Run(test.destination, func(t *testing.T) {
|
||||||
|
type result struct {
|
||||||
|
destination Destination
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
destination, err := tree.ResolveDestination(ctx, "README.md", test.destination)
|
||||||
|
done <- result{destination, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.err != nil || got.destination.Kind != test.kind {
|
||||||
|
t.Fatalf("resolution = %#v, %v; want %s", got.destination, got.err, test.kind)
|
||||||
|
}
|
||||||
|
if test.kind == DestinationEntry && got.destination.Path != "README.md" {
|
||||||
|
t.Fatalf("finite repeated link resolved to %q", got.destination.Path)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("symlink resolution did not finish")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
if _, err := tree.ResolveDestination(ctx, "README.md", "alias/README.md"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled resolution error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
||||||
|
if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "outside")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture outside link")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotWithoutNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
neighbor := filepath.Join(filepath.Dir(repository), "neighbor", "file")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(neighbor), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(neighbor, []byte("outside source\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotWithNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotWithNeighbor != gotWithoutNeighbor || gotWithNeighbor.Kind != DestinationOutsideCheckout {
|
||||||
|
t.Fatalf("neighbor changed resolution: absent=%#v present=%#v", gotWithoutNeighbor, gotWithNeighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDestinationPreservesParentTraversalOrder(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
for _, name := range []string{"README.md", "docs/file.md", "nested/file.md", "nested/inner/item.txt"} {
|
||||||
|
writeTreeFile(t, repository, name, []byte("captured\n"))
|
||||||
|
}
|
||||||
|
for name, target := range map[string]string{
|
||||||
|
"docs/link": "../nested/inner",
|
||||||
|
"docs/parent-link": "link/../file.md",
|
||||||
|
"escape": "../neighbor",
|
||||||
|
} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture parent traversal")
|
||||||
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
destination string
|
||||||
|
kind DestinationKind
|
||||||
|
resolved string
|
||||||
|
}{
|
||||||
|
{"docs/link/../file.md", DestinationEntry, "nested/file.md"},
|
||||||
|
{"docs/parent-link", DestinationEntry, "nested/file.md"},
|
||||||
|
{"escape/../README.md", DestinationOutsideCheckout, ""},
|
||||||
|
{"missing/../README.md", DestinationMissing, ""},
|
||||||
|
{"README.md/../README.md", DestinationMissing, ""},
|
||||||
|
{"README.md/.", DestinationMissing, ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.destination, func(t *testing.T) {
|
||||||
|
got, err := tree.ResolveDestination(t.Context(), "README.md", test.destination)
|
||||||
|
if err != nil || got.Kind != test.kind || (test.resolved != "" && got.Path != test.resolved) {
|
||||||
|
t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
before, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
neighbor := filepath.Join(filepath.Dir(repository), "neighbor")
|
||||||
|
if err := os.MkdirAll(neighbor, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(neighbor, "file.md"), []byte("neighbor"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := tree.ResolveDestination(t.Context(), "README.md", "escape/../README.md")
|
||||||
|
if err != nil || before != after {
|
||||||
|
t.Fatalf("neighbor changed resolution: %#v -> %#v, %v", before, after, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDestinationMapsAbsolutePathsIntoCandidate(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
for _, name := range []string{"README.md", "docs/file.md", "nested/file.md", "nested/inner/item.txt"} {
|
||||||
|
writeTreeFile(t, repository, name, []byte("captured\n"))
|
||||||
|
}
|
||||||
|
for name, target := range map[string]string{
|
||||||
|
"absolute-file": filepath.ToSlash(filepath.Join(repository, "README.md")),
|
||||||
|
"absolute-directory": filepath.ToSlash(filepath.Join(repository, "nested", "inner")),
|
||||||
|
"absolute-outside": filepath.ToSlash(repository + "-neighbor/README.md"),
|
||||||
|
"docs/link": "../nested/inner",
|
||||||
|
"absolute-parent": filepath.ToSlash(repository) + "/docs/link/../file.md",
|
||||||
|
} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture absolute links")
|
||||||
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(repository, "README.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeTreeFile(t, repository, "live-only.md", []byte("not committed"))
|
||||||
|
neighbor := repository + "-neighbor"
|
||||||
|
if err := os.MkdirAll(neighbor, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(neighbor, "README.md"), []byte("outside"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.RemoveAll(neighbor); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
root := filepath.ToSlash(repository)
|
||||||
|
for _, test := range []struct {
|
||||||
|
destination string
|
||||||
|
kind DestinationKind
|
||||||
|
resolved string
|
||||||
|
}{
|
||||||
|
{root + "/README.md", DestinationEntry, "README.md"},
|
||||||
|
{root, DestinationDirectory, "."},
|
||||||
|
{root + "/nested/inner", DestinationDirectory, "nested/inner"},
|
||||||
|
{"absolute-file", DestinationEntry, "README.md"},
|
||||||
|
{"absolute-directory/item.txt", DestinationEntry, "nested/inner/item.txt"},
|
||||||
|
{root + "/docs/link/../file.md", DestinationEntry, "nested/file.md"},
|
||||||
|
{"absolute-parent", DestinationEntry, "nested/file.md"},
|
||||||
|
{root + "/live-only.md", DestinationMissing, "live-only.md"},
|
||||||
|
{root + "-neighbor/README.md", DestinationOutsideCheckout, root + "-neighbor/README.md"},
|
||||||
|
{"absolute-outside", DestinationOutsideCheckout, root + "-neighbor/README.md"},
|
||||||
|
} {
|
||||||
|
t.Run(test.destination, func(t *testing.T) {
|
||||||
|
got, err := tree.ResolveDestination(t.Context(), "README.md", test.destination)
|
||||||
|
if err != nil || got.Kind != test.kind || got.Path != test.resolved {
|
||||||
|
t.Fatalf("resolution = %#v, %v; want %s %q", got, err, test.kind, test.resolved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
112
internal/validation/diagnostic.go
Normal file
112
internal/validation/diagnostic.go
Normal 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)
|
||||||
49
internal/validation/diagnostic_test.go
Normal file
49
internal/validation/diagnostic_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/validation/file_size.go
Normal file
52
internal/validation/file_size.go
Normal 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
|
||||||
|
}
|
||||||
192
internal/validation/file_size_test.go
Normal file
192
internal/validation/file_size_test.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckFileSizeUsesStrictLimitsAndDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
limit *int64
|
||||||
|
size int64
|
||||||
|
wantLimit int64
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "below", limit: int64Pointer(10), size: 9, wantLimit: 10},
|
||||||
|
{name: "at", limit: int64Pointer(10), size: 10, wantLimit: 10},
|
||||||
|
{name: "above", limit: int64Pointer(10), size: 11, wantLimit: 10, wantError: true},
|
||||||
|
{name: "default at", size: 1_000_000, wantLimit: 1_000_000},
|
||||||
|
{name: "default above", size: 1_000_001, wantLimit: 1_000_000, wantError: true},
|
||||||
|
{name: "zero empty", limit: int64Pointer(0), size: 0},
|
||||||
|
{name: "zero nonempty", limit: int64Pointer(0), size: 1, wantError: true},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
tree := &CandidateTree{entries: []git.TreeEntry{{
|
||||||
|
Path: "file.bin", Type: "blob", Mode: "100644", Size: test.size, HasSize: true,
|
||||||
|
}}}
|
||||||
|
err := checkFileSize(tree, &FileSizeSettings{LimitBytes: test.limit})
|
||||||
|
if !test.wantError {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "file.bin" {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want file-size finding", err)
|
||||||
|
}
|
||||||
|
if failure.Measurement == nil || failure.Measurement.Bytes != test.size || failure.Measurement.LimitBytes != test.wantLimit {
|
||||||
|
t.Fatalf("measurement = %#v, want %d bytes against %d", failure.Measurement, test.size, test.wantLimit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFileSizeAppliesLiteralPathExclusions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
entries []git.TreeEntry
|
||||||
|
exclusions []string
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "directory boundary and normalized paths",
|
||||||
|
entries: []git.TreeEntry{
|
||||||
|
{Path: "assets/icon.png", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
{Path: "assets-other/icon.png", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
},
|
||||||
|
exclusions: []string{"./assets/"},
|
||||||
|
wantPath: "assets-other/icon.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "glob metacharacters are literal",
|
||||||
|
entries: []git.TreeEntry{
|
||||||
|
{Path: "[ab].dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
{Path: "a.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
},
|
||||||
|
exclusions: []string{"[ab].dat", "literal[set]"},
|
||||||
|
wantPath: "a.dat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory metacharacters are literal",
|
||||||
|
entries: []git.TreeEntry{
|
||||||
|
{Path: "literal[set]/file.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
{Path: "literalx/file.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
},
|
||||||
|
exclusions: []string{"literal[set]"},
|
||||||
|
wantPath: "literalx/file.dat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unmatched exclusion",
|
||||||
|
entries: []git.TreeEntry{
|
||||||
|
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
},
|
||||||
|
exclusions: []string{"not-present"},
|
||||||
|
wantPath: "selected.dat",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact file exclusion",
|
||||||
|
entries: []git.TreeEntry{
|
||||||
|
{Path: "selected.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
{Path: "other.dat", Type: "blob", Size: 20, HasSize: true},
|
||||||
|
},
|
||||||
|
exclusions: []string{"selected.dat"},
|
||||||
|
wantPath: "other.dat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := checkFileSize(&CandidateTree{entries: test.entries}, &FileSizeSettings{
|
||||||
|
LimitBytes: int64Pointer(10),
|
||||||
|
Exclusions: test.exclusions,
|
||||||
|
})
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != test.wantPath {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want only %q to exceed the limit", err, test.wantPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFileSizeUsesCommittedBlobSizes(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "small.bin", []byte{0, 1})
|
||||||
|
writeTreeFile(t, repository, "boundary.bin", []byte{0, 1, 2})
|
||||||
|
if err := os.Symlink("small.bin", filepath.Join(repository, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
submodule := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, submodule, "submodule.txt", []byte("submodule"))
|
||||||
|
gitTree(t, submodule, "add", "--all")
|
||||||
|
gitTree(t, submodule, "commit", "-m", "create nested source")
|
||||||
|
submoduleCommit := strings.TrimSpace(gitTree(t, submodule, "rev-parse", "HEAD"))
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleCommit+",submodule")
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"link"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want small binary and exact-limit blob to pass", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(3), Exclusions: []string{"small.bin", "boundary.bin"}})
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || failure.Findings[0].Path != "link" {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want symlink blob to exceed the limit", err)
|
||||||
|
}
|
||||||
|
if failure.Measurement == nil || failure.Measurement.Bytes != int64(len("small.bin")) {
|
||||||
|
t.Fatalf("symlink measurement = %#v, want link-text size %d", failure.Measurement, len("small.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(0), Exclusions: []string{"small.bin", "boundary.bin", "link"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want omitted gitlink size", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckFileSizeUsesOnlyCandidateFilesIndividually(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "historical-large.dat", []byte(strings.Repeat("h", 20)))
|
||||||
|
commitCandidateTree(t, repository)
|
||||||
|
if err := os.Remove(filepath.Join(repository, "historical-large.dat")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeTreeFile(t, repository, "one.dat", []byte(strings.Repeat("a", 6)))
|
||||||
|
writeTreeFile(t, repository, "two.dat", []byte(strings.Repeat("b", 6)))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := checkFileSize(tree, &FileSizeSettings{LimitBytes: int64Pointer(10)}); err != nil {
|
||||||
|
t.Fatalf("checkFileSize() error = %v, want current individual files to pass", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsNegativeFileSizeLimitBeforeGoChecks(t *testing.T) {
|
||||||
|
negative := int64(-1)
|
||||||
|
err := Run(context.Background(), Request{Settings: Settings{
|
||||||
|
FileSize: &FileSizeSettings{LimitBytes: &negative},
|
||||||
|
}})
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != FileSize || len(failure.Findings) != 1 || !strings.Contains(failure.Findings[0].Message, "negative") {
|
||||||
|
t.Fatalf("Run() error = %v, want invalid file-size limit failure", err)
|
||||||
|
}
|
||||||
|
if errors.Unwrap(failure) != nil {
|
||||||
|
t.Fatalf("invalid limit failure cause = %v, want none", failure.Cause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Pointer(value int64) *int64 { return &value }
|
||||||
133
internal/validation/format_test.go
Normal file
133
internal/validation/format_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "main.go", []byte("package main\n\nfunc main() {}\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\nfunc Value( ){return 1}\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/module/odd name.go", []byte("package nested\nfunc Value( ){return 2}\n"))
|
||||||
|
writeTreeFile(t, repository, "notes.txt", []byte("not Go source\n"))
|
||||||
|
if err := os.Symlink("main.go", filepath.Join(repository, "link.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture Go files")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
// Worktree edits must not affect the formatting result for the captured commit.
|
||||||
|
writeTreeFile(t, repository, "main.go", []byte("package main\nfunc main( ){}\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\n\nfunc Value() { return 1 }\n"))
|
||||||
|
worktreeMain, err := os.ReadFile(filepath.Join(repository, "main.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = checkGoFormat(context.Background(), tree)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoFormat {
|
||||||
|
t.Fatalf("checkGoFormat error = %v, want go-format failure", err)
|
||||||
|
}
|
||||||
|
wantPaths := []string{"nested/module/odd name.go", "vendor/example/format.go"}
|
||||||
|
if len(failure.Findings) != len(wantPaths) {
|
||||||
|
t.Fatalf("format findings = %#v, want paths %q", failure.Findings, wantPaths)
|
||||||
|
}
|
||||||
|
for index, path := range wantPaths {
|
||||||
|
if failure.Findings[index].Path != path {
|
||||||
|
t.Errorf("format finding %d path = %q, want %q", index, failure.Findings[index].Path, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
worktreeMainAfter, err := os.ReadFile(filepath.Join(repository, "main.go"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(worktreeMainAfter) != string(worktreeMain) {
|
||||||
|
t.Fatalf("format check changed worktree source: got %q, want %q", worktreeMainAfter, worktreeMain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
|
||||||
|
t.Run("parser", func(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "broken.go", []byte("package broken\nfunc ( {\n"))
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture invalid Go source")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = checkGoFormat(context.Background(), tree)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
|
||||||
|
t.Fatalf("parser failure = %#v, want go-format failure with cause", err)
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" {
|
||||||
|
t.Fatalf("parser findings = %#v, want broken.go", failure.Findings)
|
||||||
|
}
|
||||||
|
if !strings.Contains(failure.Cause.Error(), "broken.go") {
|
||||||
|
t.Fatalf("parser cause = %v, want committed filename context", failure.Cause)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inspection", func(t *testing.T) {
|
||||||
|
tree := &CandidateTree{
|
||||||
|
repositoryDir: t.TempDir(),
|
||||||
|
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
|
||||||
|
byPath: map[string]int{"missing.go": 0},
|
||||||
|
blobs: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
err := checkGoFormat(context.Background(), tree)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
|
||||||
|
t.Fatalf("inspection failure = %#v, want go-format failure with cause", err)
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.go" {
|
||||||
|
t.Fatalf("inspection findings = %#v, want missing.go", failure.Findings)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGoFormatIsOptionalAndStopsAfterFinding(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "unformatted.go", []byte("package fixture\nfunc Value( ){return 1}\n"))
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture unformatted Go source")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
logPath := installGoShim(t, "", false)
|
||||||
|
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
|
||||||
|
t.Fatalf("default validation = %v, want success with formatting disabled", err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(readGoShimLog(t, logPath), "\n"); !strings.Contains(got, "test -mod=readonly -count=1 ./...") {
|
||||||
|
t.Fatalf("default Go commands = %q, want standard suite", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath = installGoShim(t, "", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Commit: commit,
|
||||||
|
Settings: Settings{GoFormat: true},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoFormat {
|
||||||
|
t.Fatalf("selected formatting error = %v, want go-format failure", err)
|
||||||
|
}
|
||||||
|
commands := strings.Join(readGoShimLog(t, logPath), "\n")
|
||||||
|
if strings.Contains(commands, "build ") || strings.Contains(commands, "vet ") {
|
||||||
|
t.Fatalf("later Go commands ran after formatting failure: %q", commands)
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/validation/local_replacements.go
Normal file
92
internal/validation/local_replacements.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkLocalReplacements(ctx context.Context, tree *CandidateTree) error {
|
||||||
|
var findings []Finding
|
||||||
|
for _, entry := range tree.Entries() {
|
||||||
|
if path.Base(entry.Path) != "go.mod" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := readModuleDeclaration(ctx, tree, entry)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{
|
||||||
|
Check: LocalReplacements,
|
||||||
|
Findings: []Finding{{Path: entry.Path, Message: "could not inspect committed module declaration"}},
|
||||||
|
Cause: fmt.Errorf("inspect committed %s: %w", entry.Path, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moduleFile, err := modfile.Parse(entry.Path, contents, nil)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{
|
||||||
|
Check: LocalReplacements,
|
||||||
|
Findings: []Finding{{Path: entry.Path, Message: "could not parse committed module declaration"}},
|
||||||
|
Cause: fmt.Errorf("parse committed %s: %w", entry.Path, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, replacement := range moduleFile.Replace {
|
||||||
|
if replacement.New.Version != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finding := Finding{
|
||||||
|
Path: entry.Path,
|
||||||
|
Message: "filesystem module replacement is committed",
|
||||||
|
Target: replacement.New.Path,
|
||||||
|
Directive: "replace",
|
||||||
|
}
|
||||||
|
if replacement.Syntax != nil {
|
||||||
|
finding.Line = replacement.Syntax.Start.Line
|
||||||
|
}
|
||||||
|
findings = append(findings, finding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(findings) != 0 {
|
||||||
|
return &Failure{Check: LocalReplacements, Findings: findings}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readModuleDeclaration(ctx context.Context, tree *CandidateTree, entry git.TreeEntry) ([]byte, error) {
|
||||||
|
if entry.Type != "blob" {
|
||||||
|
return nil, fmt.Errorf("entry has type %q, not a blob", entry.Type)
|
||||||
|
}
|
||||||
|
if entry.Mode != "120000" {
|
||||||
|
if entry.Mode != "100644" && entry.Mode != "100755" {
|
||||||
|
return nil, fmt.Errorf("entry has unsupported mode %q", entry.Mode)
|
||||||
|
}
|
||||||
|
contents, err := tree.ReadFile(ctx, entry.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read module declaration: %w", err)
|
||||||
|
}
|
||||||
|
return contents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := tree.ReadFile(ctx, entry.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read symlink declaration: %w", err)
|
||||||
|
}
|
||||||
|
destination, err := tree.ResolveDestination(ctx, entry.Path, string(target))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve symlink declaration target %q: %w", target, err)
|
||||||
|
}
|
||||||
|
if destination.Kind != DestinationEntry {
|
||||||
|
return nil, fmt.Errorf("symlink declaration target %q resolves to %s", target, destination.Kind)
|
||||||
|
}
|
||||||
|
resolved, ok := tree.Entry(destination.Path)
|
||||||
|
if !ok || resolved.Type != "blob" || (resolved.Mode != "100644" && resolved.Mode != "100755") {
|
||||||
|
return nil, fmt.Errorf("symlink declaration target %q does not resolve to a regular committed file", target)
|
||||||
|
}
|
||||||
|
contents, err := tree.ReadFile(ctx, destination.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read resolved module declaration %q: %w", destination.Path, err)
|
||||||
|
}
|
||||||
|
return contents, nil
|
||||||
|
}
|
||||||
175
internal/validation/local_replacements_test.go
Normal file
175
internal/validation/local_replacements_test.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckLocalReplacementsParsesCommittedModuleDeclarations(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
absoluteTarget := filepath.Join(repository, "outside module")
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\n\nreplace example.com/one => \"../local module\" // retained comment\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/go.mod", []byte("module example.com/nested\n\nreplace (\n\texample.com/two => ../../local\n\texample.com/three => \""+absoluteTarget+"\"\n)\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/example/go.mod", []byte("module example.com/vendor\n\nreplace example.com/versioned v1.0.0 => example.com/fork v1.2.0\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = checkLocalReplacements(context.Background(), tree)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != LocalReplacements {
|
||||||
|
t.Fatalf("checkLocalReplacements error = %v, want local-replacements failure", err)
|
||||||
|
}
|
||||||
|
want := map[string]Finding{
|
||||||
|
"go.mod": {Path: "go.mod", Line: 3, Target: "../local module", Directive: "replace"},
|
||||||
|
"nested/go.mod:4": {Path: "nested/go.mod", Line: 4, Target: "../../local", Directive: "replace"},
|
||||||
|
"nested/go.mod:5": {Path: "nested/go.mod", Line: 5, Target: absoluteTarget, Directive: "replace"},
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != len(want) {
|
||||||
|
t.Fatalf("findings = %#v, want %d filesystem replacements", failure.Findings, len(want))
|
||||||
|
}
|
||||||
|
for _, finding := range failure.Findings {
|
||||||
|
key := finding.Path
|
||||||
|
if finding.Path != "go.mod" {
|
||||||
|
key += ":" + strconv.Itoa(finding.Line)
|
||||||
|
}
|
||||||
|
expected, ok := want[key]
|
||||||
|
if !ok || finding.Line != expected.Line || finding.Target != expected.Target || finding.Directive != expected.Directive {
|
||||||
|
t.Errorf("finding = %#v, want %q", finding, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLocalReplacementsFollowsCommittedModuleSymlinks(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "modules/real.mod", []byte("module example.com/real\n\nreplace example.com/local => ../checkout\n"))
|
||||||
|
if err := os.MkdirAll(filepath.Join(repository, "nested"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../modules/real.mod", filepath.Join(repository, "nested/go.mod")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = checkLocalReplacements(context.Background(), tree)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != LocalReplacements || len(failure.Findings) != 1 {
|
||||||
|
t.Fatalf("checkLocalReplacements error = %v, want one symlink declaration finding", err)
|
||||||
|
}
|
||||||
|
finding := failure.Findings[0]
|
||||||
|
if finding.Path != "nested/go.mod" || finding.Line != 3 || finding.Target != "../checkout" {
|
||||||
|
t.Fatalf("symlink finding = %#v, want source path and resolved declaration", finding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLocalReplacementsReportsUnavailableSymlinkContents(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
if err := os.Symlink("missing.mod", filepath.Join(repository, "go.mod")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = checkLocalReplacements(context.Background(), tree)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
|
||||||
|
t.Fatalf("checkLocalReplacements error = %v, want local-replacements inspection failure", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(failure.Cause.Error(), "missing") || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
|
||||||
|
t.Fatalf("failure = %#v, want path and unavailable target cause", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckLocalReplacementsPreservesParseCause(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/bad\nreplace =>\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = checkLocalReplacements(context.Background(), tree)
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != LocalReplacements || failure.Cause == nil || !strings.Contains(failure.Cause.Error(), "parse committed go.mod") {
|
||||||
|
t.Fatalf("checkLocalReplacements error = %v, want parse failure with cause", err)
|
||||||
|
}
|
||||||
|
if errors.Unwrap(failure) == nil || len(failure.Findings) != 1 || failure.Findings[0].Path != "go.mod" {
|
||||||
|
t.Fatalf("failure = %#v, want preserved parse cause and path", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSelectsDistributionChecksIndependentlyWithGoWorkspaceDisabled(t *testing.T) {
|
||||||
|
t.Setenv("GOWORK", "off")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
workspaceFiles bool
|
||||||
|
localReplacements bool
|
||||||
|
wantCheck Check
|
||||||
|
}{
|
||||||
|
{name: "neither"},
|
||||||
|
{name: "workspace", workspaceFiles: true, wantCheck: WorkspaceFiles},
|
||||||
|
{name: "replacements", localReplacements: true, wantCheck: LocalReplacements},
|
||||||
|
{name: "both", workspaceFiles: true, localReplacements: true, wantCheck: WorkspaceFiles},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "go.mod", []byte("module example.com/root\nreplace example.com/local => ../local\n"))
|
||||||
|
writeTreeFile(t, repository, "go.work", []byte("go 1.25.0\n"))
|
||||||
|
commit := commitCandidateTree(t, repository)
|
||||||
|
installGoShim(t, "", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Commit: commit,
|
||||||
|
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
|
||||||
|
WorkspaceFiles: test.workspaceFiles,
|
||||||
|
LocalReplacements: test.localReplacements,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if test.wantCheck == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run error = %v, want disabled checks", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != test.wantCheck {
|
||||||
|
t.Fatalf("Run error = %v, want %s failure", err, test.wantCheck)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAttributesCandidateTreeFailureToLocalReplacements(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
installGoShim(t, "", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Commit: "missing-commit",
|
||||||
|
Settings: Settings{ModuleDistribution: ModuleDistributionSettings{
|
||||||
|
LocalReplacements: true,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
failure, ok := err.(*Failure)
|
||||||
|
if !ok || failure.Check != LocalReplacements || failure.Cause == nil {
|
||||||
|
t.Fatalf("Run error = %v, want local-replacements candidate inspection failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitCandidateTree(t *testing.T, repository string) string {
|
||||||
|
t.Helper()
|
||||||
|
gitTree(t, repository, "add", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture module declarations")
|
||||||
|
return strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
}
|
||||||
176
internal/validation/markdown.go
Normal file
176
internal/validation/markdown.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/v2/ast"
|
||||||
|
"github.com/yuin/goldmark/v2/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type markdownLocation struct {
|
||||||
|
Line int
|
||||||
|
Column int
|
||||||
|
}
|
||||||
|
|
||||||
|
type markdownLink struct {
|
||||||
|
Destination string
|
||||||
|
Title string
|
||||||
|
Image bool
|
||||||
|
Reference bool
|
||||||
|
Location markdownLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
type markdownDocument struct {
|
||||||
|
links []markdownLink
|
||||||
|
anchors map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markdownCache struct {
|
||||||
|
tree *CandidateTree
|
||||||
|
documents map[string]*markdownDocument
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMarkdownCache(tree *CandidateTree) *markdownCache {
|
||||||
|
return &markdownCache{
|
||||||
|
tree: tree,
|
||||||
|
documents: make(map[string]*markdownDocument),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cache *markdownCache) document(ctx context.Context, path string) (*markdownDocument, error) {
|
||||||
|
if document, ok := cache.documents[path]; ok {
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
source, err := cache.tree.ReadFile(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
document := parseMarkdown(source)
|
||||||
|
cache.documents[path] = document
|
||||||
|
return document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMarkdown(source []byte) *markdownDocument {
|
||||||
|
root := parser.New().Parse(source)
|
||||||
|
document := &markdownDocument{
|
||||||
|
anchors: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
lineStarts := markdownLineStarts(source)
|
||||||
|
usedAnchors := make(map[string]struct{})
|
||||||
|
nextSuffix := make(map[string]int)
|
||||||
|
_ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
switch node := node.(type) {
|
||||||
|
case *ast.Link:
|
||||||
|
document.links = append(document.links, markdownLink{
|
||||||
|
Destination: node.Destination.Value(source),
|
||||||
|
Title: node.Title.Value(source),
|
||||||
|
Reference: node.Reference != nil,
|
||||||
|
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||||
|
})
|
||||||
|
case *ast.Image:
|
||||||
|
document.links = append(document.links, markdownLink{
|
||||||
|
Destination: node.Destination.Value(source),
|
||||||
|
Title: node.Title.Value(source),
|
||||||
|
Image: true,
|
||||||
|
Reference: node.Reference != nil,
|
||||||
|
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||||
|
})
|
||||||
|
// Image labels contribute alternative text, not navigable destinations.
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
case *ast.AutoLink:
|
||||||
|
document.links = append(document.links, markdownLink{
|
||||||
|
Destination: node.Destination.Value(source),
|
||||||
|
Location: markdownLocationAt(lineStarts, node.Pos()),
|
||||||
|
})
|
||||||
|
case *ast.Heading:
|
||||||
|
base := markdownAnchor(markdownHeadingText(node, source))
|
||||||
|
anchor := base
|
||||||
|
if _, exists := usedAnchors[anchor]; exists {
|
||||||
|
suffix, ok := nextSuffix[base]
|
||||||
|
if !ok {
|
||||||
|
suffix = 1
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
anchor = base + "-" + strconv.Itoa(suffix)
|
||||||
|
suffix++
|
||||||
|
if _, exists := usedAnchors[anchor]; !exists {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextSuffix[base] = suffix
|
||||||
|
}
|
||||||
|
usedAnchors[anchor] = struct{}{}
|
||||||
|
document.anchors[anchor] = struct{}{}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
})
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownHeadingText(heading *ast.Heading, source []byte) string {
|
||||||
|
var result strings.Builder
|
||||||
|
var appendNode func(ast.Node)
|
||||||
|
appendNode = func(node ast.Node) {
|
||||||
|
switch node := node.(type) {
|
||||||
|
case *ast.RawHTML:
|
||||||
|
return
|
||||||
|
case *ast.Text:
|
||||||
|
result.WriteString(node.Value.Value(source))
|
||||||
|
if node.SoftLineBreak() || node.HardLineBreak() {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case *ast.CodeSpan:
|
||||||
|
result.WriteString(node.Value.Value(source))
|
||||||
|
return
|
||||||
|
case *ast.AutoLink:
|
||||||
|
result.WriteString(node.Label.Value(source))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
appendNode(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for child := heading.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
appendNode(child)
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownAnchor(heading string) string {
|
||||||
|
heading = strings.ToLower(heading)
|
||||||
|
var result strings.Builder
|
||||||
|
for _, r := range heading {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' || r == ' ' {
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(result.String(), " ", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownLineStarts(source []byte) []int {
|
||||||
|
starts := []int{0}
|
||||||
|
for offset, value := range source {
|
||||||
|
if value == '\n' {
|
||||||
|
starts = append(starts, offset+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownLocationAt(lineStarts []int, offset int) markdownLocation {
|
||||||
|
if offset < 0 {
|
||||||
|
return markdownLocation{}
|
||||||
|
}
|
||||||
|
lineIndex := sort.Search(len(lineStarts), func(index int) bool {
|
||||||
|
return lineStarts[index] > offset
|
||||||
|
}) - 1
|
||||||
|
return markdownLocation{Line: lineIndex + 1, Column: offset - lineStarts[lineIndex] + 1}
|
||||||
|
}
|
||||||
184
internal/validation/markdown_check.go
Normal file
184
internal/validation/markdown_check.go
Normal 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
|
||||||
|
}
|
||||||
302
internal/validation/markdown_check_test.go
Normal file
302
internal/validation/markdown_check_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments(t *testing.T) {
|
||||||
|
guide := []byte("" +
|
||||||
|
"# Start\n" +
|
||||||
|
"[target](target.md?view=1#TARGET)\n" +
|
||||||
|
"[same](#START)\n" +
|
||||||
|
"[url heading](autolinks.md#httpsexampleinvalid)\n" +
|
||||||
|
"[email heading](autolinks.md#maintainerexampleinvalid)\n" +
|
||||||
|
"[top](#)\n" +
|
||||||
|
"[directory](assets/?view=1)\n" +
|
||||||
|
"[binary](image.bin#not-an-anchor)\n" +
|
||||||
|
"[encoded](docs/a%20b.md#CAF%C3%89)\n" +
|
||||||
|
"[query](target.md?view=%ZZ)\n" +
|
||||||
|
"[external](https://example.invalid/%ZZ#also%ZZ)\n" +
|
||||||
|
"[protocol-relative](//example.invalid/file.md)\n")
|
||||||
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": guide,
|
||||||
|
"target.md": []byte("# Target\n"),
|
||||||
|
"autolinks.md": []byte("# <https://example.invalid>\n# <maintainer@example.invalid>\n"),
|
||||||
|
"docs/a b.md": []byte("# Café\n"),
|
||||||
|
"assets/file.md": []byte("# Asset\n"),
|
||||||
|
"nested/guide.md": []byte("# Nested\n[same](#NESTED)\n[query](?view=1#nested)\n"),
|
||||||
|
"image.bin": []byte{0, 1, 2},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := checkMarkdownLinks(context.Background(), tree, false, nil); err != nil {
|
||||||
|
t.Fatalf("checkMarkdownLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := readCommitted(t, tree, "guide.md"); !bytes.Equal(got, guide) {
|
||||||
|
t.Fatal("checker changed committed Markdown bytes")
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(filepath.Join(repository, "guide.md")); err != nil || !bytes.Equal(got, guide) {
|
||||||
|
t.Fatalf("worktree Markdown changed: got %q, err %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksTreatsImageLabelsAsAlternativeText(t *testing.T) {
|
||||||
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("" +
|
||||||
|
"](image.png)\n" +
|
||||||
|
"](image.png)\n" +
|
||||||
|
"](image.png)\n" +
|
||||||
|
"](image.png)\n" +
|
||||||
|
"[](target.md#present)\n" +
|
||||||
|
"[](absent.md)\n"),
|
||||||
|
"image.png": []byte("image"),
|
||||||
|
"target.md": []byte("# Present\n"),
|
||||||
|
})
|
||||||
|
for _, enforceBoundary := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("boundary=%t", enforceBoundary), func(t *testing.T) {
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, enforceBoundary, nil)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 2 {
|
||||||
|
t.Fatalf("error = %v, want only the missing linked-image source and link target", err)
|
||||||
|
}
|
||||||
|
want := map[string]bool{"absent.png": true, "absent.md": true}
|
||||||
|
for _, finding := range failure.Findings {
|
||||||
|
if !want[finding.Target] {
|
||||||
|
t.Errorf("unexpected destination finding: %#v", finding)
|
||||||
|
}
|
||||||
|
delete(want, finding.Target)
|
||||||
|
}
|
||||||
|
if len(want) != 0 {
|
||||||
|
t.Errorf("missing findings for real destinations: %v", want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles(t *testing.T) {
|
||||||
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("" +
|
||||||
|
"[missing](absent.md)\n" +
|
||||||
|
"[anchor](target.md#missing)\n" +
|
||||||
|
"[cycle](cycle-a)\n"),
|
||||||
|
"target.md": []byte("# Present\n"),
|
||||||
|
})
|
||||||
|
repository := tree.repositoryDir
|
||||||
|
if err := os.Symlink("cycle-b", filepath.Join(repository, "cycle-a")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("cycle-a", filepath.Join(repository, "cycle-b")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "cycle-a", "cycle-b")
|
||||||
|
gitTree(t, repository, "commit", "-m", "add markdown link cycle")
|
||||||
|
tree = candidateTreeAtHead(t, repository)
|
||||||
|
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("checkMarkdownLinks() error = %v, want validation Failure", err)
|
||||||
|
}
|
||||||
|
if failure.Check != MarkdownLinks {
|
||||||
|
t.Fatalf("failure check = %q, want %q", failure.Check, MarkdownLinks)
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != 3 {
|
||||||
|
t.Fatalf("findings = %#v, want missing path, anchor, and cycle", failure.Findings)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"absent.md": "destination does not exist",
|
||||||
|
"target.md#missing": "heading anchor does not exist",
|
||||||
|
"cycle-a": "destination contains a symlink cycle",
|
||||||
|
}
|
||||||
|
for _, finding := range failure.Findings {
|
||||||
|
if finding.Path != "guide.md" || finding.Line < 1 || finding.Column < 1 {
|
||||||
|
t.Errorf("finding lacks source location: %#v", finding)
|
||||||
|
}
|
||||||
|
if want[finding.Target] != finding.Message {
|
||||||
|
t.Errorf("finding = %#v", finding)
|
||||||
|
}
|
||||||
|
delete(want, finding.Target)
|
||||||
|
}
|
||||||
|
if len(want) != 0 {
|
||||||
|
t.Fatalf("missing expected findings for %#v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksUsesResolvedMarkdownIdentity(t *testing.T) {
|
||||||
|
repository, _ := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("[valid](alias.md#present)\n[missing](alias.md#missing)\n"),
|
||||||
|
"target.md": []byte("# Present\n"),
|
||||||
|
})
|
||||||
|
if err := os.Symlink("target.md", filepath.Join(repository, "alias.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "alias.md")
|
||||||
|
gitTree(t, repository, "commit", "-m", "add Markdown alias")
|
||||||
|
tree := candidateTreeAtHead(t, repository)
|
||||||
|
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
||||||
|
t.Fatalf("resolved alias error = %v, want one missing-anchor finding", err)
|
||||||
|
}
|
||||||
|
if finding := failure.Findings[0]; finding.Path != "guide.md" || finding.Target != "alias.md#missing" || finding.Message != "heading anchor does not exist" {
|
||||||
|
t.Fatalf("resolved alias finding = %#v", finding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations(t *testing.T) {
|
||||||
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("[outside](../../outside.md)\n[submodule](deps/module/README.md)\n"),
|
||||||
|
})
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", fmt.Sprintf("160000,%s,deps/module", commit))
|
||||||
|
gitTree(t, repository, "commit", "-m", "add submodule entry")
|
||||||
|
tree = candidateTreeAtHead(t, repository)
|
||||||
|
|
||||||
|
var observed bytes.Buffer
|
||||||
|
if err := checkMarkdownLinks(context.Background(), tree, false, &observed); err != nil {
|
||||||
|
t.Fatalf("checkMarkdownLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := observed.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "uninspected submodule content") || strings.Count(got, "out of scope") != 2 {
|
||||||
|
t.Fatalf("out-of-scope observations = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
observed.Reset()
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, true, &observed)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || len(failure.Findings) != 1 {
|
||||||
|
t.Fatalf("enforced boundary error = %v, want one Markdown finding", err)
|
||||||
|
}
|
||||||
|
if finding := failure.Findings[0]; finding.Target != "../../outside.md" || finding.Message != "destination is outside the checkout" {
|
||||||
|
t.Fatalf("boundary finding = %#v", finding)
|
||||||
|
}
|
||||||
|
if got := observed.String(); !strings.Contains(got, "uninspected submodule content") || strings.Contains(got, "outside the checkout") {
|
||||||
|
t.Fatalf("enforced observations = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksPreservesResolverCauseAndCancellation(t *testing.T) {
|
||||||
|
repository, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("[target](broken.md)\n"),
|
||||||
|
})
|
||||||
|
if err := os.Symlink("target.md", filepath.Join(repository, "broken.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "broken.md")
|
||||||
|
gitTree(t, repository, "commit", "-m", "add Markdown symlink")
|
||||||
|
tree = candidateTreeAtHead(t, repository)
|
||||||
|
index := tree.byPath["broken.md"]
|
||||||
|
tree.entries[index].ObjectID = strings.Repeat("0", 40)
|
||||||
|
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
|
||||||
|
t.Fatalf("resolver inspection error = %v, want Markdown failure with cause", err)
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Line != 1 || failure.Findings[0].Target != "broken.md" {
|
||||||
|
t.Fatalf("resolver inspection finding = %#v", failure.Findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
err = checkMarkdownLinks(ctx, tree, false, nil)
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled Markdown check error = %v, want preserved cancellation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) {
|
||||||
|
_, tree := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("[target](target.md#present)\n"),
|
||||||
|
"target.md": []byte("# Present\n"),
|
||||||
|
})
|
||||||
|
tree.entries[tree.byPath["target.md"]].ObjectID = strings.Repeat("0", 40)
|
||||||
|
|
||||||
|
err := checkMarkdownLinks(context.Background(), tree, false, nil)
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != MarkdownLinks || failure.Cause == nil {
|
||||||
|
t.Fatalf("anchor inspection error = %v, want Markdown failure with cause", err)
|
||||||
|
}
|
||||||
|
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "target.md#present" || failure.Findings[0].Message != "could not inspect destination anchor" {
|
||||||
|
t.Fatalf("anchor inspection finding = %#v", failure.Findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks(t *testing.T) {
|
||||||
|
repository, _ := committedMarkdownTree(t, map[string][]byte{
|
||||||
|
"guide.md": []byte("[outside](../../outside.md)\n"),
|
||||||
|
})
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
goLog := installGoShim(t, "", false)
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: repository,
|
||||||
|
Commit: commit,
|
||||||
|
Output: &output,
|
||||||
|
Settings: Settings{
|
||||||
|
Race: true,
|
||||||
|
FullVet: true,
|
||||||
|
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}},
|
||||||
|
Markdown: &MarkdownSettings{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v, want out-of-scope observation to succeed", err)
|
||||||
|
}
|
||||||
|
if got := output.String(); !strings.Contains(got, "outside the checkout") || !strings.Contains(got, "out of scope") {
|
||||||
|
t.Fatalf("Run() output = %q, want outside-checkout observation", got)
|
||||||
|
}
|
||||||
|
commands := readGoShimLog(t, goLog)
|
||||||
|
var testCommands []string
|
||||||
|
var validationCommands []string
|
||||||
|
for _, command := range commands {
|
||||||
|
if strings.HasPrefix(command, "test ") {
|
||||||
|
testCommands = append(testCommands, command)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") || strings.HasPrefix(command, "build ") {
|
||||||
|
validationCommands = append(validationCommands, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(testCommands) != 1 || !strings.Contains(testCommands[0], "-race") || !strings.Contains(testCommands[0], "-vet=off") {
|
||||||
|
t.Fatalf("Go test commands = %#v, want one race-enabled suite with test-time vet disabled", testCommands)
|
||||||
|
}
|
||||||
|
if len(validationCommands) != 3 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") || !strings.HasPrefix(validationCommands[2], "build ") {
|
||||||
|
t.Fatalf("Go validation commands = %#v, want test, full vet, and target compilation before Markdown", validationCommands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func committedMarkdownTree(t *testing.T, files map[string][]byte) (string, *CandidateTree) {
|
||||||
|
t.Helper()
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
for name, content := range files {
|
||||||
|
writeTreeFile(t, repository, name, content)
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--", ".")
|
||||||
|
gitTree(t, repository, "commit", "-m", "add Markdown fixture")
|
||||||
|
return repository, candidateTreeAtHead(t, repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateTreeAtHead(t *testing.T, repository string) *CandidateTree {
|
||||||
|
t.Helper()
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
func readCommitted(t *testing.T, tree *CandidateTree, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := tree.ReadFile(context.Background(), name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return contents
|
||||||
|
}
|
||||||
124
internal/validation/markdown_test.go
Normal file
124
internal/validation/markdown_test.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMarkdownLinksAndSourceLocations(t *testing.T) {
|
||||||
|
source := []byte("" +
|
||||||
|
"é [inline](docs/a\\(b\\).md \"inline title\").\n" +
|
||||||
|
"\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&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é & 東京 `Code Here` <em>Bold</em>!\n" +
|
||||||
|
"Setext **Heading**\n" +
|
||||||
|
"---\n" +
|
||||||
|
"# Foo\n" +
|
||||||
|
"# Foo\n" +
|
||||||
|
"# Foo-1\n" +
|
||||||
|
"# Foo\n" +
|
||||||
|
"# Foo-1\n" +
|
||||||
|
"# <https://example.invalid>\n" +
|
||||||
|
"# <maintainer@example.invalid>\n" +
|
||||||
|
"# Contact <maintainer@example.invalid> Today\n")
|
||||||
|
document := parseMarkdown(source)
|
||||||
|
for _, anchor := range []string{
|
||||||
|
"café--東京-code-here-bold",
|
||||||
|
"setext-heading",
|
||||||
|
"foo",
|
||||||
|
"foo-1",
|
||||||
|
"foo-1-1",
|
||||||
|
"foo-2",
|
||||||
|
"foo-1-2",
|
||||||
|
"httpsexampleinvalid",
|
||||||
|
"maintainerexampleinvalid",
|
||||||
|
"contact-maintainerexampleinvalid-today",
|
||||||
|
} {
|
||||||
|
if _, ok := document.anchors[anchor]; !ok {
|
||||||
|
t.Errorf("missing anchor %q in %#v", anchor, document.anchors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(document.anchors) != 10 {
|
||||||
|
t.Fatalf("found %d anchors, want 10: %#v", len(document.anchors), document.anchors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "guide.md", []byte("# Guide\n[other](other.md)\n"))
|
||||||
|
writeTreeFile(t, repository, "other.md", []byte("# Other\n"))
|
||||||
|
gitTree(t, repository, "add", "guide.md", "other.md")
|
||||||
|
gitTree(t, repository, "commit", "-m", "add markdown files")
|
||||||
|
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := newMarkdownCache(tree)
|
||||||
|
first, err := cache.document(context.Background(), "guide.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := cache.document(context.Background(), "guide.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatal("repeated document lookup returned a different parsed document")
|
||||||
|
}
|
||||||
|
other, err := cache.document(context.Background(), "other.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if other == first {
|
||||||
|
t.Fatal("different candidate paths shared a parsed document")
|
||||||
|
}
|
||||||
|
if _, ok := other.anchors["other"]; !ok {
|
||||||
|
t.Fatalf("other document anchors = %#v", other.anchors)
|
||||||
|
}
|
||||||
|
}
|
||||||
307
internal/validation/race_test.go
Normal file
307
internal/validation/race_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunUsesOneSuiteWithGoValidationSettings(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
race bool
|
||||||
|
fullVet bool
|
||||||
|
}{
|
||||||
|
{name: "default"},
|
||||||
|
{name: "race", race: true},
|
||||||
|
{name: "full vet", fullVet: true},
|
||||||
|
{name: "race and full vet", race: true, fullVet: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
logPath := installGoShim(t, "", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: t.TempDir(),
|
||||||
|
Settings: Settings{Race: test.race, FullVet: test.fullVet},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
commands := readGoShimLog(t, logPath)
|
||||||
|
var tests, vets []string
|
||||||
|
var testPosition, vetPosition int
|
||||||
|
for i, command := range commands {
|
||||||
|
if strings.HasPrefix(command, "test ") {
|
||||||
|
tests = append(tests, command)
|
||||||
|
testPosition = i
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(command, "vet ") {
|
||||||
|
vets = append(vets, command)
|
||||||
|
vetPosition = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tests) != 1 {
|
||||||
|
t.Fatalf("Go test invocations = %q, want exactly one", tests)
|
||||||
|
}
|
||||||
|
command := " " + tests[0] + " "
|
||||||
|
for _, argument := range []string{"test", "-mod=readonly", "-count=1", "./..."} {
|
||||||
|
if !strings.Contains(command, " "+argument+" ") {
|
||||||
|
t.Errorf("Go test command %q does not contain %q", tests[0], argument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := strings.Contains(command, " -race "); got != test.race {
|
||||||
|
t.Errorf("Go test command %q has race=%t, want %t", tests[0], got, test.race)
|
||||||
|
}
|
||||||
|
if got := strings.Contains(command, " -vet=off "); got != test.fullVet {
|
||||||
|
t.Errorf("Go test command %q disables test-time vet=%t, want %t", tests[0], got, test.fullVet)
|
||||||
|
}
|
||||||
|
wantVets := 0
|
||||||
|
if test.fullVet {
|
||||||
|
wantVets = 1
|
||||||
|
}
|
||||||
|
if len(vets) != wantVets {
|
||||||
|
t.Fatalf("Go vet invocations = %q, want %d", vets, wantVets)
|
||||||
|
}
|
||||||
|
if test.fullVet {
|
||||||
|
vetCommand := " " + vets[0] + " "
|
||||||
|
for _, argument := range []string{"vet", "-mod=readonly", "./..."} {
|
||||||
|
if !strings.Contains(vetCommand, " "+argument+" ") {
|
||||||
|
t.Errorf("Go vet command %q does not contain %q", vets[0], argument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vetPosition <= testPosition {
|
||||||
|
t.Errorf("Go vet command %q did not follow the test suite %q", vets[0], tests[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunReportsRacePrerequisiteFailureWithoutFallback(t *testing.T) {
|
||||||
|
logPath := installGoShim(t, "race", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: t.TempDir(),
|
||||||
|
Settings: Settings{Race: true},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoTest {
|
||||||
|
t.Fatalf("Run() error = %v, want a go-test validation failure", err)
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) {
|
||||||
|
t.Fatalf("Run() error = %v, want the command failure preserved", err)
|
||||||
|
}
|
||||||
|
commands := readGoShimLog(t, logPath)
|
||||||
|
var tests []string
|
||||||
|
for _, command := range commands {
|
||||||
|
if strings.HasPrefix(command, "test ") {
|
||||||
|
tests = append(tests, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tests) != 1 || !strings.Contains(" "+tests[0]+" ", " -race ") {
|
||||||
|
t.Fatalf("Go test invocations = %q, want one race-enabled invocation and no fallback", tests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsAfterGoTestFailure(t *testing.T) {
|
||||||
|
logPath := installGoShim(t, "test", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: t.TempDir(),
|
||||||
|
Settings: Settings{FullVet: true},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoTest {
|
||||||
|
t.Fatalf("Run() error = %v, want a go-test validation failure", err)
|
||||||
|
}
|
||||||
|
for _, command := range readGoShimLog(t, logPath) {
|
||||||
|
if strings.HasPrefix(command, "vet ") {
|
||||||
|
t.Fatalf("Go vet ran after Go test failed: %q", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAttributesFullVetFailure(t *testing.T) {
|
||||||
|
logPath := installGoShim(t, "vet", false)
|
||||||
|
err := Run(context.Background(), Request{
|
||||||
|
RepositoryDir: t.TempDir(),
|
||||||
|
Settings: Settings{FullVet: true},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoVet {
|
||||||
|
t.Fatalf("Run() error = %v, want a go-vet validation failure", err)
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if !errors.As(err, &exitError) {
|
||||||
|
t.Fatalf("Run() error = %v, want the command failure preserved", err)
|
||||||
|
}
|
||||||
|
commands := readGoShimLog(t, logPath)
|
||||||
|
var validationCommands []string
|
||||||
|
for _, command := range commands {
|
||||||
|
if strings.HasPrefix(command, "test ") || strings.HasPrefix(command, "vet ") {
|
||||||
|
validationCommands = append(validationCommands, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validationCommands) != 2 || !strings.HasPrefix(validationCommands[0], "test ") || !strings.HasPrefix(validationCommands[1], "vet ") {
|
||||||
|
t.Fatalf("validation command sequence = %q, want test followed by vet", validationCommands)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPreservesFullVetCancellation(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the controlled Go command fixture uses a POSIX shell")
|
||||||
|
}
|
||||||
|
installGoShim(t, "", true)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := Run(ctx, Request{
|
||||||
|
RepositoryDir: t.TempDir(),
|
||||||
|
Settings: Settings{FullVet: true},
|
||||||
|
})
|
||||||
|
var failure *Failure
|
||||||
|
if !errors.As(err, &failure) || failure.Check != GoVet {
|
||||||
|
t.Fatalf("Run() error = %v, want a go-vet validation failure", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("Run() error = %v, want deadline cause preserved", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunExecutesRaceEnabledSuiteOnHost(t *testing.T) {
|
||||||
|
if !raceSupported(runtime.GOOS, runtime.GOARCH) {
|
||||||
|
t.Skipf("Go race detector is not supported on %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
|
cgo, err := exec.Command("go", "env", "CGO_ENABLED").Output()
|
||||||
|
if err != nil || strings.TrimSpace(string(cgo)) != "1" {
|
||||||
|
t.Skip("Go race detector requires CGO_ENABLED=1")
|
||||||
|
}
|
||||||
|
cc, err := exec.Command("go", "env", "CC").Output()
|
||||||
|
if err != nil || len(strings.Fields(string(cc))) == 0 {
|
||||||
|
t.Skip("Go race detector requires a C compiler")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(strings.Fields(string(cc))[0]); err != nil {
|
||||||
|
t.Skip("Go race detector C compiler is unavailable")
|
||||||
|
}
|
||||||
|
module := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(module, "go.mod"), []byte("module example.com/racefixture\n\ngo 1.25.0\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(module, "race_test.go"), []byte(`package racefixture
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRaceFixture(t *testing.T) {}
|
||||||
|
`), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Run(context.Background(), Request{RepositoryDir: module, Settings: Settings{Race: true}}); err != nil {
|
||||||
|
t.Fatalf("race-enabled Go suite failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installGoShim(t *testing.T, failure string, delayVet bool) string {
|
||||||
|
t.Helper()
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the controlled Go command fixture uses a POSIX shell")
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
logPath := filepath.Join(directory, "go-commands")
|
||||||
|
envLogPath := filepath.Join(directory, "go-environment")
|
||||||
|
settings := map[string]string{
|
||||||
|
"GOHOSTOS": runtime.GOOS,
|
||||||
|
"GOHOSTARCH": runtime.GOARCH,
|
||||||
|
"CC": "", "CXX": "", "CGO_CFLAGS": "", "CGO_CPPFLAGS": "",
|
||||||
|
"CGO_CXXFLAGS": "", "CGO_ENABLED": "1", "CGO_LDFLAGS": "",
|
||||||
|
"GOCACHE": "", "GOCACHEPROG": "", "GOMODCACHE": "", "GOPATH": "",
|
||||||
|
"GOROOT": "", "GOTMPDIR": "", "PKG_CONFIG": "",
|
||||||
|
}
|
||||||
|
encodedSettings, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vetDelay := ""
|
||||||
|
if delayVet {
|
||||||
|
vetDelay = `
|
||||||
|
if [ "${1-}" = "vet" ]; then
|
||||||
|
exec sleep 5
|
||||||
|
fi
|
||||||
|
`
|
||||||
|
}
|
||||||
|
failBlock := fmt.Sprintf(`
|
||||||
|
if [ "${1-}" = "test" ]; then
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [ "%s" = "race" ] && [ "$argument" = "-race" ]; then
|
||||||
|
echo "race detector prerequisite unavailable" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "%s" = "test" ]; then
|
||||||
|
echo "Go test failed" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "${1-}" = "vet" ] && [ "%s" = "vet" ]; then
|
||||||
|
echo "Go vet failed" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ "${1-}" = "build" ]; then
|
||||||
|
printf '%%s/%%s|%%s\n' "${GOOS-}" "${GOARCH-}" "${CGO_ENABLED-}" >> "$RELEASEKIT_GO_ENV_LOG"
|
||||||
|
if [ "%s" = "build" ] || [ "%s" = "${GOOS-}/${GOARCH-}" ]; then
|
||||||
|
echo "Go build failed" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
%s
|
||||||
|
`, failure, failure, failure, failure, failure, vetDelay)
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
printf '%%s\n' "$*" >> "$RELEASEKIT_GO_LOG"
|
||||||
|
if [ "${1-}" = "env" ]; then
|
||||||
|
printf '%%s\n' '%s'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
%s
|
||||||
|
exit 0
|
||||||
|
`, encodedSettings, failBlock)
|
||||||
|
shimPath := filepath.Join(directory, "go")
|
||||||
|
if err := os.WriteFile(shimPath, []byte(script), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := directory
|
||||||
|
if current := os.Getenv("PATH"); current != "" {
|
||||||
|
path += string(os.PathListSeparator) + current
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", path)
|
||||||
|
t.Setenv("RELEASEKIT_GO_LOG", logPath)
|
||||||
|
t.Setenv("RELEASEKIT_GO_ENV_LOG", envLogPath)
|
||||||
|
return logPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func readGoShimLog(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return strings.Split(strings.TrimSpace(string(contents)), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func raceSupported(goos, goarch string) bool {
|
||||||
|
switch goos {
|
||||||
|
case "darwin":
|
||||||
|
return goarch == "amd64" || goarch == "arm64"
|
||||||
|
case "freebsd", "linux":
|
||||||
|
return goarch == "amd64" || goarch == "arm64" || goarch == "ppc64le"
|
||||||
|
case "windows":
|
||||||
|
return goarch == "amd64"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
216
internal/validation/tree.go
Normal file
216
internal/validation/tree.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CandidateTree is an inventory of one captured commit with blob contents
|
||||||
|
// loaded lazily by object ID.
|
||||||
|
type CandidateTree struct {
|
||||||
|
repositoryDir string
|
||||||
|
entries []git.TreeEntry
|
||||||
|
byPath map[string]int
|
||||||
|
directories map[string]struct{}
|
||||||
|
blobs map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCandidateTree enumerates the captured commit without reading from the
|
||||||
|
// worktree.
|
||||||
|
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
|
||||||
|
entries, err := git.ReadTree(ctx, repositoryDir, commit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tree := &CandidateTree{
|
||||||
|
repositoryDir: repositoryDir,
|
||||||
|
entries: entries,
|
||||||
|
byPath: make(map[string]int, len(entries)),
|
||||||
|
directories: make(map[string]struct{}),
|
||||||
|
blobs: make(map[string][]byte),
|
||||||
|
}
|
||||||
|
tree.directories["."] = struct{}{}
|
||||||
|
for index, entry := range entries {
|
||||||
|
tree.byPath[entry.Path] = index
|
||||||
|
for directory := path.Dir(entry.Path); directory != "."; directory = path.Dir(directory) {
|
||||||
|
tree.directories[directory] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries returns the committed tree metadata in Git's tree order.
|
||||||
|
func (tree *CandidateTree) Entries() []git.TreeEntry {
|
||||||
|
return append([]git.TreeEntry(nil), tree.entries...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadFile returns the committed blob bytes for path. Contents are cached by
|
||||||
|
// object ID for later reads from this tree.
|
||||||
|
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
|
||||||
|
index, ok := tree.byPath[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("committed path %q does not exist", path)
|
||||||
|
}
|
||||||
|
entry := tree.entries[index]
|
||||||
|
if entry.Type != "blob" {
|
||||||
|
return nil, fmt.Errorf("committed path %q is a %s entry, not a blob", path, entry.Type)
|
||||||
|
}
|
||||||
|
if contents, ok := tree.blobs[entry.ObjectID]; ok {
|
||||||
|
return append([]byte(nil), contents...), nil
|
||||||
|
}
|
||||||
|
contents, err := git.ReadBlob(ctx, tree.repositoryDir, entry.ObjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read committed path %q: %w", path, err)
|
||||||
|
}
|
||||||
|
tree.blobs[entry.ObjectID] = append([]byte(nil), contents...)
|
||||||
|
return contents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry returns the committed metadata for path when present.
|
||||||
|
func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
|
||||||
|
index, ok := tree.byPath[path]
|
||||||
|
if !ok {
|
||||||
|
return git.TreeEntry{}, false
|
||||||
|
}
|
||||||
|
return tree.entries[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestinationKind classifies a committed local destination.
|
||||||
|
type DestinationKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DestinationEntry DestinationKind = "entry"
|
||||||
|
DestinationDirectory DestinationKind = "directory"
|
||||||
|
DestinationMissing DestinationKind = "missing"
|
||||||
|
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
||||||
|
DestinationSubmodule DestinationKind = "submodule"
|
||||||
|
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Destination records the resolved identity and its classification.
|
||||||
|
type Destination struct {
|
||||||
|
Kind DestinationKind
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDestination resolves local destinations using the captured Git tree.
|
||||||
|
// Relative destinations start at the source directory; absolute destinations
|
||||||
|
// within the repository directory map back to committed paths.
|
||||||
|
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
directory := path.Dir(sourcePath)
|
||||||
|
if path.IsAbs(destination) {
|
||||||
|
relative, inside, err := tree.relativeAbsoluteDestination(destination)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
if !inside {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
|
||||||
|
}
|
||||||
|
directory, destination = ".", relative
|
||||||
|
}
|
||||||
|
return tree.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *CandidateTree) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) {
|
||||||
|
for index, component := range components {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
remaining := components[index+1:]
|
||||||
|
switch component {
|
||||||
|
case "", ".":
|
||||||
|
continue
|
||||||
|
case "..":
|
||||||
|
if directory == "." {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil
|
||||||
|
}
|
||||||
|
directory = path.Dir(directory)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
partial := path.Join(directory, component)
|
||||||
|
entry, exists := tree.Entry(partial)
|
||||||
|
if !exists {
|
||||||
|
if _, isDirectory := tree.directories[partial]; !isDirectory {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
directory = partial
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Mode == "160000" {
|
||||||
|
return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
if entry.Mode == "120000" {
|
||||||
|
if active[partial] {
|
||||||
|
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
||||||
|
}
|
||||||
|
linkText, err := tree.ReadFile(ctx, partial)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
||||||
|
}
|
||||||
|
target := string(linkText)
|
||||||
|
targetDirectory := directory
|
||||||
|
if path.IsAbs(target) {
|
||||||
|
relative, inside, err := tree.relativeAbsoluteDestination(target)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
|
if !inside {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
|
||||||
|
}
|
||||||
|
targetDirectory, target = ".", relative
|
||||||
|
}
|
||||||
|
active[partial] = true
|
||||||
|
destination, err := tree.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
|
||||||
|
delete(active, partial)
|
||||||
|
if err != nil || len(remaining) == 0 {
|
||||||
|
return destination, err
|
||||||
|
}
|
||||||
|
switch destination.Kind {
|
||||||
|
case DestinationDirectory:
|
||||||
|
return tree.resolveDestination(ctx, destination.Path, remaining, active)
|
||||||
|
case DestinationEntry:
|
||||||
|
destination.Kind = DestinationMissing
|
||||||
|
case DestinationSymlinkCycle:
|
||||||
|
return destination, nil
|
||||||
|
}
|
||||||
|
destination.Path = unresolvedPath(destination.Path, remaining)
|
||||||
|
return destination, nil
|
||||||
|
}
|
||||||
|
if len(remaining) != 0 {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationDirectory, Path: directory}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
|
||||||
|
root, err := filepath.Abs(tree.repositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
|
||||||
|
}
|
||||||
|
root = filepath.ToSlash(root)
|
||||||
|
if destination == root {
|
||||||
|
return ".", true, nil
|
||||||
|
}
|
||||||
|
// Preserve the suffix for component traversal, including symlinks before '..'.
|
||||||
|
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
|
||||||
|
return relative, inside, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unresolvedPath preserves components that could not be traversed. Cleaning
|
||||||
|
// them would hide missing entries, non-directories, or checkout boundaries.
|
||||||
|
func unresolvedPath(base string, remaining []string) string {
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return base + "/" + strings.Join(remaining, "/")
|
||||||
|
}
|
||||||
125
internal/validation/tree_test.go
Normal file
125
internal/validation/tree_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadCandidateTreeUsesCapturedCommitAndEnumeratesGitEntries(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "README.md", []byte("captured\n"))
|
||||||
|
writeTreeFile(t, repository, "vendor/library/go.mod", []byte("module vendor.example\n"))
|
||||||
|
writeTreeFile(t, repository, "nested/module/go.mod", []byte("module nested.example\n"))
|
||||||
|
writeTreeFile(t, repository, "binary.dat", []byte{0, 1, 255, 2})
|
||||||
|
unusualPath := "space tab\tline\nname.txt"
|
||||||
|
writeTreeFile(t, repository, unusualPath, []byte("unusual path\n"))
|
||||||
|
if err := os.Symlink("README.md", filepath.Join(repository, "readme-link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
submoduleCommit := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, submoduleCommit, "sub.txt", []byte("submodule\n"))
|
||||||
|
gitTree(t, submoduleCommit, "add", "--all")
|
||||||
|
gitTree(t, submoduleCommit, "commit", "-m", "create submodule source")
|
||||||
|
submoduleOID := strings.TrimSpace(gitTree(t, submoduleCommit, "rev-parse", "HEAD"))
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",submodule")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture candidate files")
|
||||||
|
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
|
||||||
|
|
||||||
|
writeTreeFile(t, repository, "README.md", []byte("worktree changed\n"))
|
||||||
|
statusBefore := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
||||||
|
tree, err := ReadCandidateTree(context.Background(), repository, commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadCandidateTree() error = %v", err)
|
||||||
|
}
|
||||||
|
entries := make(map[string]gitTreeEntry, len(tree.Entries()))
|
||||||
|
for _, entry := range tree.Entries() {
|
||||||
|
entries[entry.Path] = gitTreeEntry{mode: entry.Mode, kind: entry.Type, size: entry.Size, hasSize: entry.HasSize}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"vendor/library/go.mod", "nested/module/go.mod", unusualPath} {
|
||||||
|
if _, ok := entries[path]; !ok {
|
||||||
|
t.Errorf("committed tree is missing %q", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := entries["binary.dat"]; got.size != 4 || !got.hasSize {
|
||||||
|
t.Errorf("binary metadata = %#v, want size 4", got)
|
||||||
|
}
|
||||||
|
if got := entries["readme-link"]; got.mode != "120000" || got.kind != "blob" || !got.hasSize {
|
||||||
|
t.Errorf("symlink metadata = %#v", got)
|
||||||
|
}
|
||||||
|
if got := entries["submodule"]; got.mode != "160000" || got.kind != "commit" || got.hasSize {
|
||||||
|
t.Errorf("gitlink metadata = %#v", got)
|
||||||
|
}
|
||||||
|
contents, err := tree.ReadFile(context.Background(), "README.md")
|
||||||
|
if err != nil || string(contents) != "captured\n" {
|
||||||
|
t.Fatalf("captured README = %q, error = %v", contents, err)
|
||||||
|
}
|
||||||
|
binary, err := tree.ReadFile(context.Background(), "binary.dat")
|
||||||
|
if err != nil || !bytes.Equal(binary, []byte{0, 1, 255, 2}) {
|
||||||
|
t.Fatalf("captured binary = %v, error = %v", binary, err)
|
||||||
|
}
|
||||||
|
unusual, err := tree.ReadFile(context.Background(), unusualPath)
|
||||||
|
if err != nil || string(unusual) != "unusual path\n" {
|
||||||
|
t.Fatalf("unusual path contents = %q, error = %v", unusual, err)
|
||||||
|
}
|
||||||
|
if _, err := tree.ReadFile(context.Background(), "submodule"); err == nil {
|
||||||
|
t.Fatal("ReadFile() returned submodule commit contents")
|
||||||
|
}
|
||||||
|
statusAfter := gitTree(t, repository, "status", "--porcelain=v1", "--untracked-files=all")
|
||||||
|
if statusAfter != statusBefore {
|
||||||
|
t.Fatalf("tree inspection changed worktree state: before %q, after %q", statusBefore, statusAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCandidateTreePreservesGitProcessError(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
_, err := ReadCandidateTree(context.Background(), repository, strings.Repeat("f", 40))
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("ReadCandidateTree() error = %v, want Git process error preserved", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitTreeEntry struct {
|
||||||
|
mode string
|
||||||
|
kind string
|
||||||
|
size int64
|
||||||
|
hasSize bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTreeRepository(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
repository := t.TempDir()
|
||||||
|
gitTree(t, repository, "init", "-b", "main")
|
||||||
|
gitTree(t, repository, "config", "user.name", "ReleaseKit Test")
|
||||||
|
gitTree(t, repository, "config", "user.email", "releasekit@example.invalid")
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTreeFile(t *testing.T, repository, name string, content []byte) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(repository, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitTree(t *testing.T, repository string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
command := exec.Command("git", args...)
|
||||||
|
command.Dir = repository
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
|
||||||
|
}
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
227
internal/validation/validation.go
Normal file
227
internal/validation/validation.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
// Package validation owns the shared checks run against a captured candidate.
|
||||||
|
package validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Settings selects shared validation behavior.
|
||||||
|
type Settings struct {
|
||||||
|
Race bool
|
||||||
|
FullVet bool
|
||||||
|
CompileTargets []Target
|
||||||
|
GoFormat bool
|
||||||
|
Markdown *MarkdownSettings
|
||||||
|
ModuleDistribution ModuleDistributionSettings
|
||||||
|
FileSize *FileSizeSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkdownSettings selects committed Markdown destination and fragment checks.
|
||||||
|
type MarkdownSettings struct {
|
||||||
|
EnforceCheckoutBoundary bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileSizeSettings selects a per-blob limit and literal path exclusions.
|
||||||
|
type FileSizeSettings struct {
|
||||||
|
LimitBytes *int64
|
||||||
|
Exclusions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleDistributionSettings selects candidate distribution checks.
|
||||||
|
type ModuleDistributionSettings struct {
|
||||||
|
WorkspaceFiles bool
|
||||||
|
LocalReplacements bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target selects one package-compilation target.
|
||||||
|
type Target struct {
|
||||||
|
OS string
|
||||||
|
Arch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request identifies the captured source and output used by shared validation.
|
||||||
|
type Request struct {
|
||||||
|
RepositoryDir string
|
||||||
|
Commit string
|
||||||
|
Output io.Writer
|
||||||
|
Settings Settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCandidateTree loads the committed-file inventory when a selected check
|
||||||
|
// needs file-oriented input.
|
||||||
|
func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) {
|
||||||
|
return ReadCandidateTree(ctx, request.RepositoryDir, request.Commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes shared validation for the captured candidate.
|
||||||
|
func Run(ctx context.Context, request Request) error {
|
||||||
|
if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 {
|
||||||
|
return &Failure{
|
||||||
|
Check: FileSize,
|
||||||
|
Findings: []Finding{{Message: "file size limit must not be negative"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range request.Settings.CompileTargets {
|
||||||
|
if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") {
|
||||||
|
return &Failure{
|
||||||
|
Check: PackageCompile,
|
||||||
|
Findings: []Finding{{
|
||||||
|
Target: target.OS + "/" + target.Arch,
|
||||||
|
Message: "target must be one complete GOOS/GOARCH pair",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool, err := gotool.New(ctx, request.RepositoryDir)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{Check: GoTest, Cause: err}
|
||||||
|
}
|
||||||
|
args := []string{"test", "-mod=readonly", "-count=1"}
|
||||||
|
if request.Settings.Race {
|
||||||
|
args = append(args, "-race")
|
||||||
|
}
|
||||||
|
if request.Settings.FullVet {
|
||||||
|
args = append(args, "-vet=off")
|
||||||
|
}
|
||||||
|
args = append(args, "./...")
|
||||||
|
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{Check: GoTest, Cause: err}
|
||||||
|
}
|
||||||
|
if request.Settings.FullVet {
|
||||||
|
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...")
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{Check: GoVet, Cause: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, target := range request.Settings.CompileTargets {
|
||||||
|
selected := gotool.Target{OS: target.OS, Arch: target.Arch}
|
||||||
|
_, err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output,
|
||||||
|
"build", "-mod=readonly", "-o", os.DevNull, "./...")
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{
|
||||||
|
Check: PackageCompile,
|
||||||
|
Findings: []Finding{{
|
||||||
|
Target: fmt.Sprintf("%s/%s", target.OS, target.Arch),
|
||||||
|
Message: "package compilation failed",
|
||||||
|
}},
|
||||||
|
Cause: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil {
|
||||||
|
tree, err := request.ReadCandidateTree(ctx)
|
||||||
|
if err != nil {
|
||||||
|
check := MarkdownLinks
|
||||||
|
if request.Settings.FileSize != nil {
|
||||||
|
check = FileSize
|
||||||
|
}
|
||||||
|
if request.Settings.ModuleDistribution.LocalReplacements {
|
||||||
|
check = LocalReplacements
|
||||||
|
}
|
||||||
|
if request.Settings.ModuleDistribution.WorkspaceFiles {
|
||||||
|
check = WorkspaceFiles
|
||||||
|
}
|
||||||
|
if request.Settings.GoFormat {
|
||||||
|
check = GoFormat
|
||||||
|
}
|
||||||
|
return &Failure{Check: check, Cause: err}
|
||||||
|
}
|
||||||
|
if request.Settings.GoFormat {
|
||||||
|
if err := checkGoFormat(ctx, tree); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Settings.ModuleDistribution.WorkspaceFiles {
|
||||||
|
if err := checkWorkspaceFiles(tree); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Settings.ModuleDistribution.LocalReplacements {
|
||||||
|
if err := checkLocalReplacements(ctx, tree); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Settings.FileSize != nil {
|
||||||
|
if err := checkFileSize(tree, request.Settings.FileSize); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.Settings.Markdown != nil {
|
||||||
|
if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkWorkspaceFiles(tree *CandidateTree) error {
|
||||||
|
var findings []Finding
|
||||||
|
for _, entry := range tree.Entries() {
|
||||||
|
if entry.Type != "blob" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch path.Base(entry.Path) {
|
||||||
|
case "go.work", "go.work.sum":
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
Path: entry.Path,
|
||||||
|
Message: "workspace file is committed in the candidate",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(findings) != 0 {
|
||||||
|
return &Failure{Check: WorkspaceFiles, Findings: findings}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
|
||||||
|
var findings []Finding
|
||||||
|
for _, entry := range tree.Entries() {
|
||||||
|
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return &Failure{Check: GoFormat, Cause: err}
|
||||||
|
}
|
||||||
|
contents, err := tree.ReadFile(ctx, entry.Path)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{
|
||||||
|
Check: GoFormat,
|
||||||
|
Findings: []Finding{{
|
||||||
|
Path: entry.Path,
|
||||||
|
Message: "unable to inspect committed Go source",
|
||||||
|
}},
|
||||||
|
Cause: fmt.Errorf("inspect committed Go file %q: %w", entry.Path, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formatted, err := format.Source(contents)
|
||||||
|
if err != nil {
|
||||||
|
return &Failure{
|
||||||
|
Check: GoFormat,
|
||||||
|
Findings: []Finding{{
|
||||||
|
Path: entry.Path,
|
||||||
|
Message: "committed Go source cannot be formatted",
|
||||||
|
}},
|
||||||
|
Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bytes.Equal(contents, formatted) {
|
||||||
|
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(findings) != 0 {
|
||||||
|
return &Failure{Check: GoFormat, Findings: findings}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
100
internal/validation/workspace_test.go
Normal file
100
internal/validation/workspace_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
publish.go
62
publish.go
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
259
publish_test.go
259
publish_test.go
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
127
validation_error.go
Normal file
127
validation_error.go
Normal 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
62
validation_error_test.go
Normal 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
156
validation_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user