Inspect committed workspace files

This commit is contained in:
2026-10-07 22:45:47 +00:00
parent c5f925c949
commit 45dd827dda
17 changed files with 307 additions and 30 deletions

View File

@@ -52,6 +52,17 @@ type ValidationSpec struct {
// GoFormat checks the formatting of committed regular Go source files. It
// does not modify repository contents and is disabled when false.
GoFormat bool
// ModuleDistribution selects checks for files that affect module-source
// distribution. Its zero value disables those checks.
ModuleDistribution ModuleDistributionValidationSpec
}
// 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
}
// GoValidationSpec selects checks performed by the Go toolchain.
@@ -79,6 +90,9 @@ type GoValidationSpec struct {
// 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 the other selected shared checks. Its zero value disables the
// distribution check and does not change Go's workspace environment.
// 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
@@ -106,6 +120,9 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
FullVet: request.Validation.Go.FullVet,
CompileTargets: make([]validation.Target, len(request.Validation.Go.CompileTargets)),
GoFormat: request.Validation.GoFormat,
ModuleDistribution: validation.ModuleDistributionSettings{
WorkspaceFiles: request.Validation.ModuleDistribution.WorkspaceFiles,
},
},
AdditionalChecks: candidateChecks(request.AdditionalChecks),
AfterValidate: internalbuild.CandidateCallback(internalBuildSpec(request.Build), request.Output),

View File

@@ -129,7 +129,15 @@ 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"`
GoFormat bool `json:"go_format,omitempty"`
ModuleDistribution ModuleDistributionSettings `json:"module_distribution,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"`
}
// GoValidationSettings selects checks performed by the Go toolchain.

View File

@@ -144,7 +144,7 @@ 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,"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil {
if err := os.WriteFile(profilePath, []byte(`{"product":"Example","expected_push_url":"ssh://git.example/owner/repo.git","validation":{"go_format":true,"module_distribution":{"workspace_files":true},"go":{"race":true,"full_vet":true,"compile_targets":["linux/amd64","linux/arm64","darwin/arm64"]}}}`), 0o600); err != nil {
t.Fatal(err)
}
@@ -152,8 +152,8 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
t.Fatalf("validation settings = %#v, want Go formatting, race, and full vet enabled", settings.Validation)
if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.ModuleDistribution.WorkspaceFiles || !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 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)

View File

@@ -162,6 +162,9 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
}
result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
},
Go: releasekit.GoValidationSpec{
Race: settings.Go.Race,
FullVet: settings.Go.FullVet,

View File

@@ -117,8 +117,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
Product: "Example Product",
ExpectedPushURL: "publication-only value",
Validation: &cli.ValidationSettings{
GoFormat: true,
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
GoFormat: true,
Go: cli.GoValidationSettings{Race: true, FullVet: true, CompileTargets: []string{"linux/amd64", "darwin/arm64"}},
ModuleDistribution: cli.ModuleDistributionSettings{WorkspaceFiles: true},
},
}, nil)
if !request.Validation.Go.Race || !request.Validation.Go.FullVet {
@@ -127,6 +128,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
if !request.Validation.GoFormat {
t.Fatalf("candidate request GoFormat = false, want true")
}
if !request.Validation.ModuleDistribution.WorkspaceFiles {
t.Fatalf("candidate request workspace files = false, want true")
}
if request.ExpectedPushURL != "" {
t.Fatalf("candidate request carried publication-only expected push URL %q", request.ExpectedPushURL)
}

View File

@@ -39,8 +39,11 @@ 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. Any artifact build selected by the separate `build`
setting runs after shared validation and configured checks.
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.
`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,
@@ -60,8 +63,9 @@ settings as `check`. It checks the authoritative remote and validates the
candidate: the single standard offline Go test suite runs first, with `-race`
when `validation.go.race` is true. When `validation.go.full_vet` is true,
test-time vet is disabled and `go vet -mod=readonly ./...` runs after successful
tests. Selected package compilation and committed Go formatting follow, then
configured checks run in profile order. It then creates an annotated local tag
tests. Selected package compilation and committed Go formatting follow. The
selected workspace-file check then runs, followed by configured checks in
profile order. It then creates an annotated local tag
and pushes only its exact tag object. It prints the confirmed version,
candidate commit, and tag object
on success. Validation
@@ -77,8 +81,9 @@ 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. A failure is reported as `go-format` with affected repository paths;
parser and source-inspection causes are retained.
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.
The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git;

View File

@@ -45,6 +45,12 @@ 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.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.
For example:
```json
@@ -56,7 +62,10 @@ For example:
"full_vet": true,
"compile_targets": ["linux/amd64", "linux/arm64"]
},
"go_format": true
"go_format": true,
"module_distribution": {
"workspace_files": true
}
}
}
```
@@ -84,10 +93,14 @@ run native `go vet -mod=readonly ./...` after successful tests. They then run
`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. 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.
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.
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.

View File

@@ -41,6 +41,12 @@ 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`.
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

View File

@@ -55,6 +55,13 @@ worktree changes and symlink blobs, and reports unformatted paths as
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.
If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the
candidate and remote guards, creates an annotated tag for the captured commit,

View File

@@ -25,6 +25,11 @@ 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.
`CandidateTree` gets metadata and bytes from Git objects, reads blob bytes
lazily, and resolves destinations without consulting the worktree or
neighboring files.

View File

@@ -337,6 +337,12 @@ behavior, API/profile parity, and failure before later product/build work.
## Stage 6: Select committed workspace-file inspection
**Completion:** Complete. `Validation.ModuleDistribution.WorkspaceFiles` and
`validation.module_distribution.workspace_files` report committed blob entries
named `go.work` or `go.work.sum`, including nested paths and symlinks. The rule
inspects the captured candidate independently of `GOWORK` and leaves the Go
workspace environment and vendor behavior unchanged.
**Outcome:** One distribution rule works independently of the Go environment.
1. Add `ModuleDistributionValidationSpec.WorkspaceFiles` and the matching JSON

View File

@@ -108,6 +108,52 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T)
}
}
func TestCheckReportsCommittedWorkspaceFilesWhenGoWorkspaceIsDisabled(t *testing.T) {
t.Setenv("GOWORK", "off")
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "go.work", "go 1.25.0\n")
writeTestFile(t, repository, "nested/go.work.sum", "workspace-sum\n")
if err := os.MkdirAll(filepath.Join(repository, "linked"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink("../go.work", filepath.Join(repository, "linked", "go.work")); err != nil {
t.Fatal(err)
}
gitRun(t, repository, "add", "go.work", "nested/go.work.sum", "linked/go.work")
gitRun(t, repository, "commit", "-m", "add workspace files")
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{ModuleDistribution: validation.ModuleDistributionSettings{
WorkspaceFiles: true,
}},
})
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.WorkspaceFiles {
t.Fatalf("Check error = %v, want workspace-files failure", err)
}
want := map[string]bool{
"go.work": false,
"linked/go.work": false,
"nested/go.work.sum": false,
}
if len(failure.Findings) != len(want) {
t.Fatalf("workspace findings = %#v, want %d paths", failure.Findings, len(want))
}
for _, finding := range failure.Findings {
if _, ok := want[finding.Path]; !ok {
t.Errorf("unexpected workspace path %q", finding.Path)
}
want[finding.Path] = true
}
for path, found := range want {
if !found {
t.Errorf("workspace path %q was not reported", path)
}
}
}
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer

View File

@@ -17,6 +17,8 @@ const (
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"
)
// Finding describes a validation observation at an optional source location.

View File

@@ -8,6 +8,7 @@ import (
"go/format"
"io"
"os"
"path"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool"
@@ -15,10 +16,16 @@ import (
// Settings selects shared validation behavior.
type Settings struct {
Race bool
FullVet bool
CompileTargets []Target
GoFormat bool
Race bool
FullVet bool
CompileTargets []Target
GoFormat bool
ModuleDistribution ModuleDistributionSettings
}
// ModuleDistributionSettings selects candidate distribution checks.
type ModuleDistributionSettings struct {
WorkspaceFiles bool
}
// Target selects one package-compilation target.
@@ -91,14 +98,45 @@ func Run(ctx context.Context, request Request) error {
}
}
}
if request.Settings.GoFormat {
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles {
tree, err := request.ReadCandidateTree(ctx)
if err != nil {
return &Failure{Check: GoFormat, Cause: err}
check := WorkspaceFiles
if request.Settings.GoFormat {
check = GoFormat
}
return &Failure{Check: check, Cause: err}
}
if err := checkGoFormat(ctx, tree); err != nil {
return 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
}
}
}
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
}

View File

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

View File

@@ -8,8 +8,8 @@ import (
)
// ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test", "go-vet", "package-compile", and
// "go-format".
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
// and "workspace-files".
type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional

View File

@@ -12,10 +12,10 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
Validation: ValidationSpec{Go: GoValidationSpec{
Race: true, FullVet: true,
CompileTargets: []Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}},
}, GoFormat: true},
}, GoFormat: true, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true}},
})
if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat {
t.Fatalf("candidate request validation = %#v, want race, full vet, and formatting enabled", request.Validation)
if !request.Validation.Race || !request.Validation.FullVet || !request.Validation.GoFormat || !request.Validation.ModuleDistribution.WorkspaceFiles {
t.Fatalf("candidate request validation = %#v, want race, full vet, formatting, and workspace inspection enabled", request.Validation)
}
want := []validation.Target{{OS: "linux", Arch: "amd64"}, {OS: "darwin", Arch: "arm64"}}
if len(request.Validation.CompileTargets) != len(want) {
@@ -61,3 +61,20 @@ func TestValidationErrorPreservesGoFormatIdentityAndFindings(t *testing.T) {
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)
}
}