Expose Markdown validation selection

This commit is contained in:
2026-10-07 23:26:48 +00:00
parent 6397d41517
commit 40068af1bf
20 changed files with 403 additions and 23 deletions

View File

@@ -432,6 +432,9 @@ func TestBuildCancellationReturnsCompletedArtifacts(t *testing.T) {
func TestCheckBuildsAfterValidationAndKeepsOutputOutsideWorktree(t *testing.T) {
repository := newPublishFixture(t)
writeFile(t, repository.work, "guide.md", "# Guide\n[section](#guide)\n")
gitRun(t, repository.work, "add", "guide.md")
gitRun(t, repository.work, "commit", "-m", "add Markdown guide")
prepareCandidateBuild(t, repository.work, `package main
import (
"fmt"
@@ -445,6 +448,7 @@ func main() { if len(os.Args) == 2 && os.Args[1] == "version" { fmt.Printf("fixt
RepositoryDir: repository.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
validations++
return nil

View File

@@ -52,6 +52,10 @@ 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
// 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
@@ -60,6 +64,13 @@ type ValidationSpec struct {
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;
@@ -107,14 +118,19 @@ type GoValidationSpec struct {
// 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.
// 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
@@ -143,6 +159,7 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
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,
@@ -159,6 +176,13 @@ func candidateRequest(request ReleaseRequest) candidate.Request {
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

View File

@@ -130,12 +130,20 @@ type ValidationSettings struct {
// 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"`

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,"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 {
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)
}
@@ -152,7 +152,7 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if settings.Validation == nil || !settings.Validation.GoFormat || !settings.Validation.ModuleDistribution.WorkspaceFiles || !settings.Validation.ModuleDistribution.LocalReplacements || !settings.Validation.Go.Race || !settings.Validation.Go.FullVet {
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" {
@@ -166,6 +166,35 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
}
}
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

View File

@@ -172,6 +172,11 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
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 {

View File

@@ -119,6 +119,7 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
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"}},
@@ -130,6 +131,9 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
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)
}
@@ -150,6 +154,58 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
}
}
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"})

View File

@@ -44,6 +44,14 @@ modifying files. When `validation.module_distribution.workspace_files` is true,
and symlink entries, as `workspace-files`. This inspection is independent of
`GOWORK` and ignores uncommitted files. Any artifact build selected by the
separate `build` setting runs after shared validation and configured checks.
When `validation.markdown` is a non-null object, `check` checks committed local
Markdown destinations and supported heading fragments after file-size
inspection. An empty object enables the check; omission or `null` disables it.
Outside-checkout links are written as out-of-scope observations to standard
error by default. Setting `validation.markdown.enforce_checkout_boundary` to
true makes those links fail; submodule-content observations remain
nonfailures. Failures use the `markdown-links` identity with the source location
and target.
`check` writes errors and diagnostics to standard error. It returns status 0
after local validation, 1 after a candidate validation failure, and 2 for usage,
@@ -64,8 +72,9 @@ candidate: the single standard offline Go test suite runs first, with `-race`
when `validation.go.race` is true. When `validation.go.full_vet` is true,
test-time vet is disabled and `go vet -mod=readonly ./...` runs after successful
tests. Selected package compilation and committed Go formatting follow. The
selected workspace-file check then runs, followed by configured checks in
profile order. It then creates an annotated local tag
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
@@ -97,6 +106,11 @@ 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;

View File

@@ -46,6 +46,26 @@ 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. 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
@@ -71,6 +91,7 @@ For example:
"compile_targets": ["linux/amd64", "linux/arm64"]
},
"go_format": true,
"markdown": {},
"module_distribution": {
"workspace_files": true,
"local_replacements": true
@@ -130,6 +151,10 @@ 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.

View File

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

View File

@@ -78,6 +78,19 @@ blob size is its link text; gitlinks are omitted. This check measures only the
captured candidate and reports one oversized file with its measurement as
`file-size`; it stops publication before product checks or tag creation.
Set `Validation.Markdown` to check committed local Markdown destinations and
supported heading fragments during publication. Nil disables the check; an
empty object enables it with outside-checkout links reported as out of scope.
Set `EnforceCheckoutBoundary` to fail those links. Submodule contents remain
out of scope under either setting. Observations use the request's output writer;
failures use `markdown-links` and stop publication before product checks or tag
creation.
ReleaseKit-owned selections replace duplicate generic suite, vet, formatting,
distribution, and Markdown checks in configured product checks. Keep
release-note rules and consumer-specific assertions, such as maintained-example
tests, in `AdditionalChecks` or profile `checks`.
If `Build` selects targets, ReleaseKit verifies them after the standard suite
and additional checks, before creating the tag. The operation then rechecks the
candidate and remote guards, creates an annotated tag for the captured commit,

View File

@@ -46,7 +46,7 @@ reports these implemented packages:
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays and removals. |
| `internal/preparation` | Coordination for expected-commit tag checkout and Go module dependency preparation. |
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, internal Markdown parsing, destination and fragment checking, and per-document anchor caching; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/validation` | Shared Go validation with optional race, full-vet, and static package-compilation target selections, formatting, workspace-file and filesystem-replacement checks, per-blob size limits, and selected Markdown destination and fragment checking; `internal/candidate` retains capture, callback sequencing, and local-state rechecks. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. |
| `internal/versioncontract` | Shared stable-version rules and linker-symbol identity. |
@@ -78,8 +78,8 @@ not a separate release matrix:
| Capability | Implementation owner | Existing evidence |
| --- | --- | --- |
| Runtime version lookup and injected application version | `buildinfo`, `internal/runtimeinfo`, `internal/versioncontract`, `internal/build` | `TestVersionInConsumerExecutable`, `TestVersion` |
| Candidate capture, 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`, `TestLoadSettingsReadsGoValidation`, `TestCheckUsesSelectedRaceSuiteBeforeAdditionalChecks`, `TestCheckCapturesCandidateAndCallsAdditionalChecksOnce`, `TestCheckReturnsPublicValidationError`, `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishFailedStandardSuiteSkipsAdditionalChecksAndTagCreation`, `TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation`, `TestCheckCommandBindsConfiguredChecksToCapturedCandidate`, `TestPublishCommandPublishesConfiguredCandidate`, `TestCandidateCommandsPresentValidationDiagnosticsOnce`, `TestDeliverPageOnlyCreatesAndReusesRelease` |
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and causes | `internal/validation` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause` |
| 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` |
| Internal Markdown parsing, committed destinations, fragments, boundary observations, and public selection | `releasekit.Check`, `releasekit.Publish`, `internal/candidate`, `internal/validation`, `cmd/releasekit/internal/cli` | `TestParseMarkdownLinksAndSourceLocations`, `TestParseMarkdownDecodesEntitiesInDestinations`, `TestParseMarkdownHeadingAnchors`, `TestMarkdownCacheKeepsParsedDocumentsPerCandidatePath`, `TestCheckMarkdownLinksResolvesCommittedDestinationsAndFragments`, `TestCheckMarkdownLinksReportsMissingPathsAnchorsAndCycles`, `TestCheckMarkdownLinksUsesResolvedMarkdownIdentity`, `TestCheckMarkdownLinksReportsBoundaryAndSubmoduleObservations`, `TestCheckMarkdownLinksPreservesResolverCauseAndCancellation`, `TestCheckMarkdownLinksPreservesAnchorReadCause`, `TestRunSelectsMarkdownWithCombinedGoChecksAndReportsOutOfScopeLinks`, `TestMarkdownValidationRunsThroughCheckAndPublish`, `TestLoadSettingsPreservesMarkdownObjectSelection`, `TestValidationSpecPreservesMarkdownObjectSelection` |
| Shared Go settings for validation and builds | `internal/gotool`, `internal/process`, `internal/candidate`, `internal/build` | `TestDefaultValidationDoesNotInheritGOFLAGSRunFilter`, `TestNativeAndStaticCommandsUseOwnedBaseline`, `TestPersistentSettingsPreserveCachesAndNativeCTools`, `TestBuildIgnoresAmbientAndPersistentArchitectureTuning` |
| Authoritative endpoint and exact annotated tag publication | `releasekit.Publish`, `internal/publish`, `internal/git` | `TestPublishPushesOnlyTheCapturedAnnotatedTag`, `TestPublishRejectsUnexpectedPushURLBeforeValidation`, `TestPublishComparesExpectedURLWithResolvedInsteadOfDestination`, `TestPublishExpectedPushURLUsesPushURLWhenFetchAndPushDiffer`, `TestCheckIgnoresExpectedPushURL`, `TestResolveUsesPushURLAndQueriesWithoutMutation` |
| Publication partial and uncertain recovery | `internal/publish`, `internal/git` | `TestPublishRejectedPushKeepsLocalTagAndReportsNotPublished`, `TestPublishLostAcknowledgmentConfirmsExactRemoteObject`, `TestPublishLostAcknowledgmentWithChangedEndpointRemainsUnknown`, `TestPublishUnavailableReconciliationRemainsUnknownAndPreservesPushError` |

View File

@@ -41,6 +41,10 @@ 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.
@@ -61,8 +65,8 @@ 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 remains
in the [implementation plan](../roadmap/implementation.md).
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;
@@ -136,6 +140,7 @@ 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 [implementation plan](../roadmap/implementation.md) records the remaining
public Markdown selection and integration work. The internal checker uses the
committed-file and destination-resolution primitives described here.
The [implementation plan](../roadmap/implementation.md) records the completed
Markdown integration. The public selection is owned by the facade and profile;
the internal checker uses the committed-file and destination-resolution
primitives described here.

View File

@@ -446,8 +446,8 @@ Undefined references and HTML prose remain governed by parser interpretation.
**Completion:** Complete. The internal checker resolves committed destinations,
checks supported fragments, preserves inspection errors, and reports distinct
outside-checkout and submodule observations. Public selection and shared executor
wiring remain in Stage 11.
outside-checkout and submodule observations. Stage 11 exposes this checker
through public and profile selections and the shared executor.
**Outcome:** Committed Markdown navigation is checked with boundary observations.
@@ -477,6 +477,14 @@ missing-object, and traversal-cancellation cases.
## Stage 11: Expose Markdown selection and complete validation integration
**Completion:** Complete. `ValidationSpec.Markdown` and
`validation.markdown` select the shared committed-link checker through both
public operations and CLI profile commands. Omitted/null settings disable the
check; an empty object enables it, and the boundary option maps explicitly.
Warnings use the shared output writer, while Markdown failures stop product
checks and builds. GoDoc, profile, consumer, CLI, and internal documentation
now describe the operational behavior.
**Outcome:** All agreed validation selections work through both public operations.
1. Add `MarkdownValidationSpec`, `ValidationSpec.Markdown`, and their JSON fields.

View File

@@ -45,12 +45,16 @@ func TestValidStableVersion(t *testing.T) {
func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "guide.md", "# Guide\n[section](#guide)\n")
gitRun(t, repository, "add", "guide.md")
gitRun(t, repository, "commit", "-m", "add Markdown guide")
calls := 0
var seen Candidate
candidate, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{Markdown: &validation.MarkdownSettings{}},
AdditionalChecks: func(_ context.Context, captured Candidate) error {
calls++
seen = captured
@@ -108,6 +112,38 @@ func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T)
}
}
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")

View File

@@ -192,6 +192,49 @@ func TestCheckMarkdownLinksPreservesAnchorReadCause(t *testing.T) {
}
}
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)

View File

@@ -20,10 +20,16 @@ type Settings struct {
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
@@ -112,10 +118,13 @@ func Run(ctx context.Context, request Request) error {
}
}
}
if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil {
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 := FileSize
check := MarkdownLinks
if request.Settings.FileSize != nil {
check = FileSize
}
if request.Settings.ModuleDistribution.LocalReplacements {
check = LocalReplacements
}
@@ -147,6 +156,11 @@ func Run(ctx context.Context, request Request) error {
return err
}
}
if request.Settings.Markdown != nil {
if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil {
return err
}
}
}
return nil
}

View File

@@ -54,9 +54,14 @@ type PublicationResult struct {
// Validation.Go.FullVet runs go vet afterward instead of test-time vet, and
// Validation.Go.CompileTargets compiles all packages for each selected target
// with cgo disabled after tests and vet. Compilation produces no artifacts and
// does not execute target binaries. ExpectedPushURL, when nonempty, is compared
// exactly with the resolved effective push URL before validation; a mismatch
// returns ErrUnexpectedPushURL. It then calls AdditionalChecks when supplied.
// does not execute target binaries. The selected formatting, distribution,
// file-size, and Markdown checks then run before AdditionalChecks. Markdown is
// disabled when nil and enabled by a non-nil specification; outside-checkout
// destinations are reported as out of scope unless boundary enforcement is
// selected. Submodule contents remain out of scope. ExpectedPushURL, when
// nonempty, is compared exactly with the resolved effective push URL before
// validation; a mismatch returns ErrUnexpectedPushURL. It then calls
// AdditionalChecks when supplied.
// It optionally builds selected targets in temporary storage outside the
// worktree and pushes the exact annotated tag object to the selected remote.
// It returns a result with errors: preflight failures are not_attempted, failed

View File

@@ -302,6 +302,62 @@ func TestCheckReturnsPublicValidationError(t *testing.T) {
}
}
func TestMarkdownValidationRunsThroughCheckAndPublish(t *testing.T) {
addBrokenMarkdownDestination := func(t *testing.T, repo publishFixture) {
t.Helper()
writeFile(t, repo.work, "guide.md", "[missing](missing.md#top)\n")
gitRun(t, repo.work, "add", "guide.md")
gitRun(t, repo.work, "commit", "-m", "add broken Markdown destination")
}
assertMarkdownFailure := func(t *testing.T, err error) {
t.Helper()
var failure *releasekit.ValidationError
if !errors.As(err, &failure) || failure.Check != "markdown-links" {
t.Fatalf("operation error = %v, want public markdown-links failure", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "guide.md" || failure.Findings[0].Target != "missing.md#top" || failure.Findings[0].Line != 1 {
t.Fatalf("Markdown findings = %#v, want source location and target", failure.Findings)
}
}
t.Run("check", func(t *testing.T) {
repo := newPublishFixture(t)
addBrokenMarkdownDestination(t, repo)
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
})
assertMarkdownFailure(t, err)
})
t.Run("publish", func(t *testing.T) {
repo := newPublishFixture(t)
addBrokenMarkdownDestination(t, repo)
gitRun(t, repo.work, "config", "--unset", "remote.origin.mirror")
gitRun(t, repo.work, "push", "origin", "HEAD:refs/heads/main")
callbackCalls := 0
_, err := releasekit.Publish(context.Background(), releasekit.ReleaseRequest{
RepositoryDir: repo.work,
Product: "Sample App",
Version: "v1.2.3",
Validation: releasekit.ValidationSpec{Markdown: &releasekit.MarkdownValidationSpec{}},
AdditionalChecks: func(context.Context, releasekit.Candidate) error {
callbackCalls++
return nil
},
})
assertMarkdownFailure(t, err)
if callbackCalls != 0 {
t.Fatalf("additional checks called %d times after Markdown failure", callbackCalls)
}
if tag := gitText(t, repo.work, "tag", "--list", "v1.2.3"); tag != "" {
t.Fatalf("failed Markdown validation created tag %q", tag)
}
})
}
func TestPublishAdditionalCheckFailurePreservesCauseAndPreventsTagCreation(t *testing.T) {
repo := newPublishFixture(t)
suiteRuns := filepath.Join(t.TempDir(), "suite-runs")

View File

@@ -9,7 +9,7 @@ import (
// ValidationCheck identifies a shared validation check that failed.
// Current checks include "go-test", "go-vet", "package-compile", "go-format",
// "workspace-files", "local-replacements", and "file-size".
// "workspace-files", "local-replacements", "file-size", and "markdown-links".
type ValidationCheck string
// ValidationFinding describes a failed check observation at an optional

View File

@@ -13,11 +13,14 @@ 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, ModuleDistribution: ModuleDistributionValidationSpec{WorkspaceFiles: true, LocalReplacements: true}, FileSize: &FileSizeValidationSpec{LimitBytes: &limit, Exclusions: []string{"assets"}}},
}, 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)
}
@@ -32,6 +35,24 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
}
}
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{}}})