Map CLI profiles to selected modules

This commit is contained in:
2026-10-10 03:30:34 +00:00
parent d06b62efe2
commit f837871b77
12 changed files with 368 additions and 60 deletions

View File

@@ -16,7 +16,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
) )
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] --commit OID VERSION"
type Command string type Command string
@@ -31,14 +31,15 @@ const (
// Invocation contains parsed command settings owned by the CLI adapter. // Invocation contains parsed command settings owned by the CLI adapter.
type Invocation struct { type Invocation struct {
Command Command Command Command
Repository string Repository string
ConfigPath string ConfigPath string
OutputDir string OutputDir string
AssetsDir string AssetsDir string
Remote string Remote string
Commit string Commit string
Version string Version string
ExpectedModule string
} }
// Parse accepts only commands that have been implemented by the executable. // Parse accepts only commands that have been implemented by the executable.
@@ -67,6 +68,10 @@ func Parse(args []string) (Invocation, error) {
flags.StringVar(&assetsDir, "assets", "", "verified build assets directory") flags.StringVar(&assetsDir, "assets", "", "verified build assets directory")
} }
var remote *string var remote *string
expectedModule := ""
if action == PublishAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
}
expectedCommit := "" expectedCommit := ""
if action == CIDeliveryAction { if action == CIDeliveryAction {
remote = flags.String("remote", "origin", "Git remote") remote = flags.String("remote", "origin", "Git remote")
@@ -88,7 +93,7 @@ func Parse(args []string) (Invocation, error) {
if remote != nil { if remote != nil {
parsedRemote = *remote parsedRemote = *remote
} }
return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0)}, nil return Invocation{Command: action, Repository: *repository, ConfigPath: *configPath, OutputDir: outputDir, AssetsDir: assetsDir, Remote: parsedRemote, Commit: expectedCommit, Version: flags.Arg(0), ExpectedModule: expectedModule}, nil
default: default:
return Invocation{}, fmt.Errorf("unknown command %q", args[0]) return Invocation{}, fmt.Errorf("unknown command %q", args[0])
} }
@@ -116,6 +121,7 @@ type CheckCommand struct {
// validation selections, and check commands read from a 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"`
ModuleDir string `json:"module_dir,omitempty"`
Remote string `json:"remote,omitempty"` Remote string `json:"remote,omitempty"`
ExpectedPushURL string `json:"expected_push_url,omitempty"` ExpectedPushURL string `json:"expected_push_url,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"` Checks []CheckCommand `json:"checks,omitempty"`
@@ -126,7 +132,10 @@ type Settings struct {
// ValidationSettings selects shared candidate checks in a release profile. // ValidationSettings selects shared candidate checks in a release profile.
type ValidationSettings struct { type ValidationSettings struct {
Go GoValidationSettings `json:"go,omitempty"` // SharedFiles selects committed files relative to the repository root.
// Nil uses optional defaults; a non-nil empty slice selects no shared files.
SharedFiles []string `json:"shared_files"`
Go GoValidationSettings `json:"go,omitempty"`
// GoFormat checks the formatting of committed regular Go source files. // GoFormat checks the formatting of committed regular Go source files.
// Omitted or false disables the check. // Omitted or false disables the check.
GoFormat bool `json:"go_format,omitempty"` GoFormat bool `json:"go_format,omitempty"`
@@ -269,9 +278,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
return settings, nil return settings, nil
} }
// RunChecks runs configured argument vectors in repoDir. Each command receives // RunChecks runs configured argument vectors in dir. Each command receives
// its own environment overlay and inherits the remaining process environment. // its own environment overlay and inherits the remaining process environment.
func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error { func RunChecks(ctx context.Context, dir string, checks []CheckCommand, ownedEnv map[string]string, stdout, stderr io.Writer) error {
for i, check := range checks { for i, check := range checks {
environment := make(map[string]string, len(check.Env)+len(ownedEnv)) environment := make(map[string]string, len(check.Env)+len(ownedEnv))
for key, value := range check.Env { for key, value := range check.Env {
@@ -282,7 +291,7 @@ func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, owned
} }
if err := process.Run(ctx, process.Invocation{ if err := process.Run(ctx, process.Invocation{
Args: check.Args, Args: check.Args,
Dir: repoDir, Dir: dir,
Env: environment, Env: environment,
Stdout: stdout, Stdout: stdout,
Stderr: stderr, Stderr: stderr,

View File

@@ -33,6 +33,10 @@ func TestParse(t *testing.T) {
if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" { if publish.Command != PublishAction || publish.Repository != "candidate" || publish.ConfigPath != "releasekit.json" || publish.Version != "v1.2.4" {
t.Fatalf("Parse(publish) = %#v", publish) t.Fatalf("Parse(publish) = %#v", publish)
} }
publishMapped, err := Parse([]string{"publish", "--repo", "candidate", "--expected-module", "integrations/ckconfig", "v1.2.4"})
if err != nil || publishMapped.ExpectedModule != "integrations/ckconfig" {
t.Fatalf("Parse(publish --expected-module) = %#v, error = %v", publishMapped, err)
}
build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"}) build, err := Parse([]string{"build", "--repo", "candidate", "--config", "releasekit.json", "--output", "output with spaces", "v1.2.5"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -166,6 +170,56 @@ func TestLoadSettingsReadsGoValidation(t *testing.T) {
} }
} }
func TestLoadSettingsPreservesSharedFilesSelection(t *testing.T) {
repoDir := t.TempDir()
profileDir := filepath.Join(repoDir, "profiles")
if err := os.MkdirAll(profileDir, 0o700); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
profile string
wantNil bool
wantFile string
}{
{name: "omitted", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{}}`, wantNil: true},
{name: "null", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":null}}`, wantNil: true},
{name: "empty", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":[]}}`},
{name: "selected", profile: `{"product":"Example","module_dir":"integrations/ckconfig","validation":{"shared_files":["../README.md"]}}`, wantFile: "../README.md"},
} {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(profileDir, "release.json")
if err := os.WriteFile(path, []byte(test.profile), 0o600); err != nil {
t.Fatal(err)
}
settings, err := LoadSettings(repoDir, filepath.Join("profiles", "release.json"))
if err != nil {
t.Fatal(err)
}
if settings.ModuleDir != "integrations/ckconfig" {
t.Fatalf("module_dir = %q", settings.ModuleDir)
}
if settings.Validation == nil || (settings.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("shared_files = %#v, want nil=%t", settings.Validation, test.wantNil)
}
if test.wantFile != "" && (len(settings.Validation.SharedFiles) != 1 || settings.Validation.SharedFiles[0] != test.wantFile) {
t.Fatalf("shared_files = %#v, want %q", settings.Validation.SharedFiles, test.wantFile)
}
encoded, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
var roundTrip Settings
if err := json.Unmarshal(encoded, &roundTrip); err != nil {
t.Fatal(err)
}
if (roundTrip.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("serialized shared_files = %#v, want nil=%t; JSON=%s", roundTrip.Validation.SharedFiles, test.wantNil, encoded)
}
})
}
}
func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) { func TestLoadSettingsPreservesMarkdownObjectSelection(t *testing.T) {
repoDir := t.TempDir() repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json") profilePath := filepath.Join(repoDir, "releasekit.json")

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/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"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
func main() { func main() {
@@ -42,11 +43,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
request := candidateRequest(invocation, settings, stderr) request := candidateRequest(invocation, settings, stderr)
if invocation.Command == cli.PublishAction { if invocation.Command == cli.PublishAction {
if err := checkExpectedModule(invocation.Repository, invocation.ExpectedModule, settings.ModuleDir); err != nil {
fmt.Fprintf(stderr, "profile module selection: %v\n", err)
return 2
}
request.ExpectedPushURL = settings.ExpectedPushURL request.ExpectedPushURL = settings.ExpectedPushURL
fmt.Fprintf(stderr, "validating and publishing %s\n", invocation.Version) fmt.Fprintf(stderr, "validating and publishing module %s version %s\n", selectedModuleLabel(settings.ModuleDir), 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: %s\n", result.State, result.Phase, result.Version, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err)) fmt.Fprintf(stderr, "publication state=%s phase=%s module=%s module-dir=%s version=%s tag=%s commit=%s tag-object=%s endpoint=%q: %s\n", result.State, result.Phase, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.TagObject, result.Endpoint, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
@@ -55,18 +60,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "published %s at %s (tag object %s)\n", result.Version, result.CandidateCommit, result.TagObject) fmt.Fprintf(stdout, "published %s as %s at %s (tag object %s)\n", result.Version, result.Tag, result.CandidateCommit, result.TagObject)
return 0 return 0
} }
candidate, err := releasekit.Check(ctx, request) candidate, err := releasekit.Check(ctx, request)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "check failed: %s\n", candidateFailureText(err)) fmt.Fprintf(stderr, "check failed module=%s module-dir=%s version=%s tag=%s commit=%s: %s\n", candidate.ModulePath, candidate.ModuleDir, candidate.Version, candidate.Tag, candidate.Commit, candidateFailureText(err))
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2 return 2
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "local candidate %s at %s is valid\n", candidate.Version, candidate.Commit) fmt.Fprintf(stdout, "local candidate module %s version %s tag %s at %s is valid\n", candidate.ModulePath, candidate.Version, candidate.Tag, candidate.Commit)
return 0 return 0
case cli.BuildAction: case cli.BuildAction:
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath) settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
@@ -80,6 +85,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
result, err := releasekit.Build(ctx, releasekit.BuildRequest{ result, err := releasekit.Build(ctx, releasekit.BuildRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version, Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build), Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir, OutputDir: invocation.OutputDir,
@@ -92,7 +98,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
} }
} }
if err != nil { if err != nil {
fmt.Fprintf(stderr, "build failed at %s: %v\n", result.CandidateCommit, err) fmt.Fprintf(stderr, "build failed module=%s module-dir=%s version=%s tag=%s commit=%s: %v\n", result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, err)
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256) fmt.Fprintf(stderr, "built %s %s\n", artifact.Filename, artifact.SHA256)
} }
@@ -102,7 +108,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
if len(result.Targets) == 0 { if len(result.Targets) == 0 {
fmt.Fprintf(stdout, "no build targets selected for %s at %s\n", result.Version, result.CandidateCommit) fmt.Fprintf(stdout, "no build targets selected for module %s version %s tag %s at %s\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit)
return 0 return 0
} }
receipt := receiptFromBuild(result) receipt := receiptFromBuild(result)
@@ -120,7 +126,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings") fmt.Fprintln(stderr, "build receipt does not match the selected source and build settings")
return 1 return 1
} }
fmt.Fprintf(stdout, "built %d artifacts for %s at %s with %s\n", len(result.Artifacts), result.Version, result.CandidateCommit, result.Compiler) fmt.Fprintf(stdout, "built %d artifacts for module %s version %s tag %s at %s with %s\n", len(result.Artifacts), result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.Compiler)
for _, artifact := range result.Artifacts { for _, artifact := range result.Artifacts {
fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256) fmt.Fprintf(stdout, "%s %s\n", artifact.Filename, artifact.SHA256)
} }
@@ -137,6 +143,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest { func candidateRequest(invocation cli.Invocation, settings cli.Settings, output io.Writer) releasekit.ReleaseRequest {
request := releasekit.ReleaseRequest{ request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
Remote: settings.Remote, Remote: settings.Remote,
@@ -147,9 +154,13 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
if len(settings.Checks) != 0 { if len(settings.Checks) != 0 {
checks := settings.Checks checks := settings.Checks
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error { request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{ return cli.RunChecks(ctx, candidate.ModuleDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version, "RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit, "RELEASEKIT_CANDIDATE": candidate.Commit,
"RELEASEKIT_REPOSITORY_DIR": candidate.RepositoryDir,
"RELEASEKIT_MODULE_DIR": candidate.ModuleDir,
"RELEASEKIT_MODULE_PATH": candidate.ModulePath,
"RELEASEKIT_TAG": candidate.Tag,
}, output, output) }, output, output)
} }
} }
@@ -161,7 +172,8 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
return releasekit.ValidationSpec{} return releasekit.ValidationSpec{}
} }
result := releasekit.ValidationSpec{ result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat, SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{ ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles, WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
LocalReplacements: settings.ModuleDistribution.LocalReplacements, LocalReplacements: settings.ModuleDistribution.LocalReplacements,
@@ -199,6 +211,38 @@ func candidateFailureText(err error) string {
return err.Error() return err.Error()
} }
func checkExpectedModule(repositoryDir, expectedModule, profileModule string) error {
if expectedModule == "" {
return nil
}
expected, err := releaseidentity.ResolveSelection(repositoryDir, expectedModule)
if err != nil {
return fmt.Errorf("normalize expected module %q: %w", expectedModule, err)
}
selected, err := releaseidentity.ResolveSelection(repositoryDir, profileModule)
if err != nil {
return fmt.Errorf("normalize profile module %q: %w", profileModule, err)
}
if expected.ModuleSubdir != selected.ModuleSubdir {
return fmt.Errorf("expected module %q resolves to %q, but the profile selects %q", expectedModule, expected.ModuleSubdir, selected.ModuleSubdir)
}
return nil
}
func selectedModuleLabel(moduleDir string) string {
if moduleDir == "" {
return "."
}
return moduleDir
}
func copyStringsPreservingEmpty(values []string) []string {
if values == nil {
return nil
}
return append([]string{}, values...)
}
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 {
@@ -233,7 +277,10 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
return status return status
} }
if settings.Build != nil && len(settings.Build.Targets) != 0 { if settings.Build != nil && len(settings.Build.Targets) != 0 {
if err := releasekit.PrepareBuildDependencies(ctx, invocation.Repository); err != nil { if err := releasekit.PrepareBuildDependencies(ctx, releasekit.DependencyPreparationRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
}); err != nil {
fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err) fmt.Fprintf(stderr, "prepare build dependencies: %v\n", err)
return 1 return 1
} }
@@ -301,6 +348,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{ result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: invocation.Repository, RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product, Product: settings.Product,
Version: invocation.Version, Version: invocation.Version,
Destination: destination, Destination: destination,
@@ -309,7 +357,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
Output: stderr, Output: stderr,
}) })
if err != nil { if err != nil {
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err) fmt.Fprintf(stderr, "delivery state=%s module=%s module-dir=%s version=%s tag=%s commit=%s url=%s: %v\n", result.State, result.ModulePath, result.ModuleDir, result.Version, result.Tag, result.CandidateCommit, result.ReleaseURL, err)
writeDeliveryAssets(stderr, result) writeDeliveryAssets(stderr, result)
writeDeliveryRecovery(stderr, result) writeDeliveryRecovery(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) { if errors.Is(err, releasekit.ErrInvalidVersion) {
@@ -320,7 +368,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
} }
return 1 return 1
} }
fmt.Fprintf(stdout, "delivered %s at %s (state %s)\n", result.Version, result.CandidateCommit, result.State) fmt.Fprintf(stdout, "delivered module %s version %s tag %s at %s (state %s)\n", result.ModulePath, result.Version, result.Tag, result.CandidateCommit, result.State)
if result.ReleaseURL != "" { if result.ReleaseURL != "" {
fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL) fmt.Fprintf(stdout, "release: %s\n", result.ReleaseURL)
} }
@@ -342,13 +390,13 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) { func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
switch result.State { switch result.State {
case releasekit.DeliveryNotAttempted: case releasekit.DeliveryNotAttempted:
fmt.Fprintln(output, "recovery: fix the local or preflight failure before retrying; no remote write was attempted") fmt.Fprintf(output, "recovery: fix the local or preflight failure for module %s version %s tag %s before retrying; no remote write was attempted\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryPartial: case releasekit.DeliveryPartial:
fmt.Fprintln(output, "recovery: fix the reported cause, then retry this version") fmt.Fprintf(output, "recovery: fix the reported cause, then retry module %s version %s tag %s\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryConflict: case releasekit.DeliveryConflict:
fmt.Fprintln(output, "recovery: investigate conflicting hosted content; use a new version if the source or published contents need correction") fmt.Fprintf(output, "recovery: investigate conflicting hosted content for module %s version %s tag %s; use a new version if the source or published contents need correction\n", result.ModulePath, result.Version, result.Tag)
case releasekit.DeliveryUnknown: case releasekit.DeliveryUnknown:
fmt.Fprintln(output, "recovery: resolve the remote state before retrying") fmt.Fprintf(output, "recovery: resolve the remote state for module %s version %s tag %s before retrying\n", result.ModulePath, result.Version, result.Tag)
default: default:
return return
} }

View File

@@ -51,6 +51,13 @@ func TestUnknownCommand(t *testing.T) {
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) { func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable() raceSelected := racePrerequisitesAvailable()
repository := newCLIRepository(t) repository := newCLIRepository(t)
moduleDir := filepath.Join(repository, "integrations", "ckconfig")
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/docs/releases/v1.2.3.md", "# Example Product v1.2.3\n\nNested release note.\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/fixture_test.go", "package ckconfig\nimport \"testing\"\nfunc TestFixture(t *testing.T) {}\n")
writeRepositoryFile(t, repository, "integrations/ckconfig/race_fixture_test.go", "//go:build race\n\npackage ckconfig\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestRaceBuildSelection(t *testing.T) {\n\tmarker := os.Getenv(\"RELEASEKIT_RACE_MARKER\")\n\tif marker == \"\" { return }\n\tif err := os.WriteFile(marker, []byte(\"race enabled\"), 0o600); err != nil { t.Fatal(err) }\n}\n")
cliGitRun(t, repository, "add", "integrations/ckconfig")
cliGitRun(t, repository, "commit", "-m", "add nested module candidate")
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")
t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath) t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath)
@@ -70,9 +77,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
} }
} }
settings := cli.Settings{ settings := cli.Settings{
Product: "Example Product", Product: "Example Product",
Remote: "origin", ModuleDir: "integrations/ckconfig",
Checks: []cli.CheckCommand{check("first"), check("second")}, Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")},
} }
if raceSelected { if raceSelected {
settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}} settings.Validation = &cli.ValidationSettings{Go: cli.GoValidationSettings{Race: true}}
@@ -102,7 +110,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want { if got, want := strings.Count(stderr.String(), "v1.2.3|"+commit), 2; got != want {
t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String()) t.Fatalf("configured checks received captured candidate %d times, want %d; stderr=%q", got, want, stderr.String())
} }
if want := fmt.Sprintf("local candidate v1.2.3 at %s is valid\n", commit); stdout.String() != want { if !strings.Contains(stderr.String(), fmt.Sprintf("%s|%s|%s|example.com/ckconfig|integrations/ckconfig/v1.2.3", moduleDir, repository, moduleDir)) {
t.Fatalf("configured checks did not receive the selected module directory and identity: %q", stderr.String())
}
if want := fmt.Sprintf("local candidate module example.com/ckconfig version v1.2.3 tag integrations/ckconfig/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 raceSelected {
@@ -154,6 +165,40 @@ func TestCandidateRequestMapsGoValidation(t *testing.T) {
} }
} }
func TestCandidateRequestPreservesModuleAndSharedFileSelection(t *testing.T) {
for _, test := range []struct {
name string
shared []string
wantNil bool
wantValues []string
}{
{name: "nil", wantNil: true},
{name: "empty", shared: []string{}},
{name: "selected", shared: []string{"README.md", "docs/policy.md"}, wantValues: []string{"README.md", "docs/policy.md"}},
} {
t.Run(test.name, func(t *testing.T) {
request := candidateRequest(cli.Invocation{Repository: "repository", Version: "v1.2.3"}, cli.Settings{
ModuleDir: "integrations/ckconfig",
Validation: &cli.ValidationSettings{SharedFiles: test.shared},
}, nil)
if request.ModuleDir != "integrations/ckconfig" {
t.Fatalf("module selection = %q", request.ModuleDir)
}
if (request.Validation.SharedFiles == nil) != test.wantNil {
t.Fatalf("mapped shared_files = %#v, want nil=%t", request.Validation.SharedFiles, test.wantNil)
}
if len(request.Validation.SharedFiles) != len(test.wantValues) {
t.Fatalf("mapped shared_files = %#v, want %#v", request.Validation.SharedFiles, test.wantValues)
}
for i, path := range test.wantValues {
if request.Validation.SharedFiles[i] != path {
t.Fatalf("mapped shared_files = %#v, want %#v", request.Validation.SharedFiles, test.wantValues)
}
}
})
}
}
func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) { func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
@@ -172,6 +217,33 @@ func TestValidationSpecPreservesMarkdownObjectSelection(t *testing.T) {
} }
} }
func TestPublishExpectedModuleRejectsMismatchBeforeProductCommands(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
marker := filepath.Join(t.TempDir(), "product-command-ran")
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
Checks: []cli.CheckCommand{{Args: []string{"sh", "-c", "touch " + marker}}},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
cliGitRun(t, repository, "add", "releasekit.json", "integrations/ckconfig/go.mod")
cliGitRun(t, repository, "commit", "-m", "configure root release profile")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"publish", "--repo", repository, "--expected-module", "integrations/ckconfig", "v1.2.3"}, &stdout, &stderr)
if status != 2 || !strings.Contains(stderr.String(), "profile module selection") {
t.Fatalf("publish status=%d stdout=%q stderr=%q, want module-selection error", status, stdout.String(), stderr.String())
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("product command ran despite module mismatch: stat error=%v", err)
}
if err := exec.Command("git", "-C", repository, "show-ref", "--verify", "refs/tags/v1.2.3").Run(); err == nil {
t.Fatal("module mismatch created a release tag")
}
}
func TestCandidateCommandsRunMarkdownProfile(t *testing.T) { func TestCandidateCommandsRunMarkdownProfile(t *testing.T) {
repository := newCLIRepository(t) repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{ profile, err := json.Marshal(cli.Settings{
@@ -328,10 +400,10 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
} }
commit := cliGitOutput(t, repository, "rev-parse", "HEAD") commit := cliGitOutput(t, repository, "rev-parse", "HEAD")
tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3")
if want := fmt.Sprintf("published v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want { if want := fmt.Sprintf("published v1.2.3 as v1.2.3 at %s (tag object %s)\n", commit, tagObject); stdout.String() != want {
t.Fatalf("stdout = %q, want %q", stdout.String(), want) t.Fatalf("stdout = %q, want %q", stdout.String(), want)
} }
if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) { if !bytes.Contains(stderr.Bytes(), []byte("validating and publishing module . version v1.2.3")) || !bytes.Contains(stderr.Bytes(), []byte("example.com/cli-candidate")) {
t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String()) t.Fatalf("stderr does not show publication progress and validation: %q", stderr.String())
} }
if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject { if got := cliGitOutput(t, remote, "rev-parse", "refs/tags/v1.2.3"); got != tagObject {
@@ -351,6 +423,47 @@ printf '%s %s\n' "$version" "$candidate" >> "$RELEASEKIT_COMPATIBILITY_LOG"
} }
} }
func TestBuildCommandUsesProfileModuleSelection(t *testing.T) {
repository := newCLIRepository(t)
writeRepositoryFile(t, repository, "integrations/ckconfig/go.mod", "module example.com/ckconfig\n\ngo 1.25.0\n")
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
ModuleDir: "integrations/ckconfig",
Build: &cli.BuildSettings{},
})
if err != nil {
t.Fatal(err)
}
writeRepositoryFile(t, repository, "releasekit.json", string(profile))
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"build", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 0 {
t.Fatalf("build status=%d stdout=%q stderr=%q", status, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "no build targets selected for module example.com/ckconfig version v1.2.3 tag integrations/ckconfig/v1.2.3") {
t.Fatalf("build did not use profile module selection: %q", stdout.String())
}
}
func TestProfileSharedFilesRequireCommittedFilesWithoutEnablingChecks(t *testing.T) {
repository := newCLIRepository(t)
profile, err := json.Marshal(cli.Settings{
Product: "Example Product",
Validation: &cli.ValidationSettings{SharedFiles: []string{"docs/shared.md"}},
})
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 an explicit shared file")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"check", "--repo", repository, "v1.2.3"}, &stdout, &stderr)
if status != 1 || !strings.Contains(stderr.String(), "shared-files") || !strings.Contains(stderr.String(), "docs/shared.md") {
t.Fatalf("check status=%d stdout=%q stderr=%q, want missing shared-files failure", status, stdout.String(), stderr.String())
}
}
func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) { func TestBuildCommandWritesValidatedReceiptForConfiguredArtifacts(t *testing.T) {
repository := newCLIBuildRepository(t, `package main repository := newCLIBuildRepository(t, `package main
@@ -440,7 +553,12 @@ func TestCandidateCheckHelper(t *testing.T) {
os.Exit(9) os.Exit(9)
} }
} }
fmt.Fprintf(os.Stdout, "%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE")) workdir, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(9)
}
fmt.Fprintf(os.Stdout, "%s|%s|%s|%s|%s|%s|%s", os.Getenv("RELEASEKIT_VERSION"), os.Getenv("RELEASEKIT_CANDIDATE"), workdir, os.Getenv("RELEASEKIT_REPOSITORY_DIR"), os.Getenv("RELEASEKIT_MODULE_DIR"), os.Getenv("RELEASEKIT_MODULE_PATH"), os.Getenv("RELEASEKIT_TAG"))
os.Exit(0) os.Exit(0)
} }
@@ -760,7 +878,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{ {
name: "partial", name: "partial",
status: 1, status: 1,
recovery: []string{"recovery: fix the reported cause", "retry this version"}, recovery: []string{"recovery: fix the reported cause", "retry module example.com/cli-candidate version v1.2.3 tag v1.2.3"},
assetObservation: "pending helper-v1.2.3-linux-amd64", assetObservation: "pending helper-v1.2.3-linux-amd64",
}, },
{ {
@@ -773,7 +891,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{ {
name: "unknown", name: "unknown",
status: 3, status: 3,
recovery: []string{"resolve the remote state before retrying"}, recovery: []string{"recovery: resolve the remote state for module example.com/cli-candidate version v1.2.3 tag v1.2.3"},
assetObservation: "uncertain helper-v1.2.3-linux-amd64", assetObservation: "uncertain helper-v1.2.3-linux-amd64",
missingObservation: "pending SHA256SUMS", missingObservation: "pending SHA256SUMS",
}, },

View File

@@ -23,7 +23,7 @@ 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 and captured module, version, full tag, and commit. It checks the committed release note and
local Git state, runs the standard offline Go test suite (with `-race` when local Git state, runs the standard offline Go test suite (with `-race` when
`validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...` `validation.go.race` is true), and runs a separate `go vet -mod=readonly ./...`
pass after successful tests when `validation.go.full_vet` is true. It then runs pass after successful tests when `validation.go.full_vet` is true. It then runs
@@ -31,6 +31,15 @@ selected package compilation and committed Go formatting, followed by
configured additional checks in profile order. It does not inspect remote configured additional checks in profile order. It does not inspect remote
availability or publish a tag. availability or publish a tag.
The profile's optional `module_dir` selects a repository-relative physical
module directory; omission selects the root module. Validation and configured
product checks use that module selection. Additional check commands run with
the selected module as their working directory and receive
`RELEASEKIT_VERSION`, `RELEASEKIT_CANDIDATE`, `RELEASEKIT_REPOSITORY_DIR`,
`RELEASEKIT_MODULE_DIR`, `RELEASEKIT_MODULE_PATH`, and `RELEASEKIT_TAG` in their
environment. These values are added to each child environment without changing
the ReleaseKit process environment.
When `validation.go.compile_targets` is selected, `check` compiles all packages When `validation.go.compile_targets` is selected, `check` compiles all packages
for each configured `GOOS/GOARCH` pair after tests and full vet, using static 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 mode with cgo disabled. This check produces no artifacts and does not execute
@@ -63,7 +72,7 @@ Unsupported argument sequences print usage to standard error.
Publish a source tag: Publish a source tag:
```sh ```sh
go run ./cmd/releasekit publish [--repo DIR] [--config FILE] VERSION go run ./cmd/releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION
``` ```
`publish` reads the same product, remote, validation, and additional-check `publish` reads the same product, remote, validation, and additional-check
@@ -75,8 +84,8 @@ tests. Selected package compilation and committed Go formatting follow. The
selected file checks then run in order: workspace files, local replacements, selected file checks then run in order: workspace files, local replacements,
file size, and Markdown. Configured checks run afterward in profile order. It file size, and Markdown. Configured checks run afterward in profile order. It
then creates an annotated local tag then creates an annotated local tag
and pushes only its exact tag object. It prints the confirmed version, and pushes only its exact full tag object. It prints the confirmed semantic
candidate commit, and tag object version, full tag, 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
@@ -113,6 +122,11 @@ It reports outside-checkout links to standard error unless boundary enforcement
is selected; submodule contents remain out of scope. Markdown failures stop is selected; submodule contents remain out of scope. Markdown failures stop
publication before product checks or tag creation. publication before product checks or tag creation.
`--expected-module DIR` optionally checks a launcher's selected physical module
against the profile's `module_dir` before candidate work. Both paths are
normalized relative to the repository; the profile remains the source of module
selection. The root selection is `.`.
The optional profile field `expected_push_url` applies only to `publish`. A The optional profile field `expected_push_url` applies only to `publish`. A
nonempty value must exactly match the effective push URL resolved by Git; nonempty value must exactly match the effective push URL resolved by Git;
mismatches fail during preflight before Go validation or tag creation. Git URL mismatches fail during preflight before Go validation or tag creation. Git URL
@@ -136,8 +150,8 @@ go run ./cmd/releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSIO
targets with the requested version injected, checks the native `version` targets with the requested version injected, checks the native `version`
invocation, and writes binaries plus the private `.releasekit-build.json` invocation, and writes binaries plus the private `.releasekit-build.json`
receipt into `dist` by default. `--output` selects a different directory; receipt into `dist` by default. `--output` selects a different directory;
relative paths are resolved from the repository. Selected artifact filenames relative paths are resolved from the selected module directory. Selected
are regenerated while unrelated files are preserved. A complete nonempty build artifact filenames are regenerated while unrelated files are preserved. A complete nonempty build
writes a receipt only after compilation and smoke checking succeed. On partial writes a receipt only after compilation and smoke checking succeed. On partial
failure, standard error lists completed outputs and prior receipts in that failure, standard error lists completed outputs and prior receipts in that
output directory are invalidated; no new receipt is written. An output directory are invalidated; no new receipt is written. An

View File

@@ -9,6 +9,7 @@ The current profile fields are:
| Field | Required | Behavior | | Field | Required | Behavior |
| --- | --- | --- | | --- | --- | --- |
| `product` | Yes | Product name used by release operations. | | `product` | Yes | Product name used by release operations. |
| `module_dir` | No | Repository-relative physical Go module directory; omission selects `.` (the repository root). |
| `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`. | | `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. |
@@ -28,6 +29,11 @@ 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. The optional `validation` object selects shared checks implemented by ReleaseKit.
`validation.shared_files` is a committed file list resolved from the repository
root. Omission or `null` uses the optional defaults for enabled file checks;
`[]` selects no shared files. Each explicit file must exist in the candidate,
even when no file checks are enabled. Paths are literal and have no glob
semantics. This field does not enable any checks.
`validation.go.race` enables the race detector for the single standard Go test `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. `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 suite succeeds; ReleaseKit disables test-time vet for that suite to avoid

View File

@@ -35,9 +35,11 @@ if err != nil {
} }
``` ```
For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after For binary profiles, call `PrepareBuildDependencies` with a
checkout preparation and before delivery. It runs `go mod download` with `DependencyPreparationRequest` selecting the repository and module after
workspace discovery disabled and the available compatible local Go toolchain. checkout preparation and before delivery. It runs `go mod download` in that
module with workspace discovery disabled and the available compatible local Go
toolchain.
The operation may use the network and updates the Go module cache while The operation may use the network and updates the Go module cache while
preserving the caller's proxy, private-module, credential, and cache settings. preserving the caller's proxy, private-module, credential, and cache settings.
If checksums are missing, Go may update `go.sum`; see the If checksums are missing, Go may update `go.sum`; see the

View File

@@ -89,7 +89,7 @@ not a separate release matrix:
| 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, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` | | Cross-builds, version smoke checks, module paths, empty target sets, and partial outputs | `releasekit.Build`, `internal/build`, `internal/artifact`, `internal/tempdir`, `buildinfo` | `TestBuildCreatesSelectedTargetsAndInjectsVersion`, `TestBuildUsesNestedWorkingTreeModuleAndModuleRelativeOutput`, `TestBuildResolvesDirtyWorkingTreeDeclaration`, `TestBuildWithNoTargetsDoesNotInvokeCompiler`, `TestBuildReturnsPartialArtifactsAfterTargetFailure`, `TestNormalizeSettingsKeepsBuildInputsAndNormalizesTargets`, `TestOutsideFallsBackWhenTemporaryDirectoryIsInsideWorktree` |
| Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` | | Page-only and managed or prebuilt binary delivery | `releasekit.Deliver`, `internal/delivery`, `internal/gitea`, CLI | `TestDeliverPageOnlySkipsBuildAndHostedAssets`, `TestDeliverUsesCommittedModuleIdentityForSeparatePages`, `TestDeliverRejectsPrebuiltArtifactsForAnotherModuleBeforeHostedRequests`, `TestDeliverManagedBuildUploadsArtifactsAndCleansTemporaryOutput`, `TestDeliverBinaryConsumerWithBuildinfoAndSelectedTargets`, `TestDeliverPrebuiltArtifactsRemainCallerOwned`, `TestDeliverRejectsSameSizePrebuiltTamperingBeforeHostedWrites`, `TestDeliverManagedBuildFailureCleansTemporaryOutputBeforeHostedWrites`, `TestDeliverManagedBuildCancellationCleansTemporaryOutput`, `TestDeliverBuildsTaggedArtifactsAndReusesAssets`, `TestDeliverRejectsTamperedAssetsAndStaleReceiptBeforeRemoteWrites`, `TestEnsureReleaseEscapesSlashContainingTag` |
| 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`, `TestPrepareBuildDependenciesUsesSelectedModuleDirectory`, `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`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` | | CLI profiles and prepared Woodpecker sequences | `cmd/releasekit`, `cmd/releasekit/internal/cli`, `examples/woodpecker/` | `TestLoadSettingsRejectsInvalidProfiles`, `TestWoodpeckerTagDeliveryTemplate`, `TestWoodpeckerTagDeliveryUsesTaggedModuleVersion`, `TestWoodpeckerSourceDeliveryTemplateSelfBootstraps`, `TestReleaseKitSourceDeliveryWorkflowFromTaggedCheckout` |

View File

@@ -541,6 +541,17 @@ Missing explicit files fail without enabling checks. Verify nested failure
diagnostics and retain a minimal root profile with no new required fields. diagnostics and retain a minimal root profile with no new required fields.
Run CLI profile, command, preparation, and compiled API checks. Run CLI profile, command, preparation, and compiled API checks.
**Completed:** Profiles now map `module_dir` and `validation.shared_files`,
preserving nil versus explicit empty selections through JSON and facade copies.
Candidate commands run from the selected module with repository, module, path,
version, commit, and full-tag context. `publish --expected-module` compares
normalized profile selection before callbacks, and delivery/build map the profile
module through their facades. Dependency preparation now accepts a
repository/module request and runs in that module. Verified nested CLI checks and
build selection, explicit missing shared files, mismatch rejection before
product commands, module-targeted preparation, `go test ./...`,
`go run ./internal/apicheck`, and documentation whitespace checks.
## Stage 11: Implement full-tag CI delivery and prepared-checkout verification ## Stage 11: Implement full-tag CI delivery and prepared-checkout verification
**Outcome:** The CLI can prepare a complete event tag or reuse a verified **Outcome:** The CLI can prepare a complete event tag or reuse a verified

View File

@@ -35,9 +35,9 @@ func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) e
return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Version, remote, request.ExpectedCommit) return git.PrepareTaggedCheckout(ctx, request.RepositoryDir, request.Version, remote, request.ExpectedCommit)
} }
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error { func PrepareBuildDependencies(ctx context.Context, moduleDir string) error {
if strings.TrimSpace(repositoryDir) == "" { if strings.TrimSpace(moduleDir) == "" {
return errors.New("repository directory is required") return errors.New("module directory is required")
} }
return gotool.PrepareBuildDependencies(ctx, repositoryDir) return gotool.PrepareBuildDependencies(ctx, moduleDir)
} }

View File

@@ -2,8 +2,12 @@ package releasekit
import ( import (
"context" "context"
"errors"
"fmt"
"strings"
internalpreparation "gitea.maximumdirect.net/eric/ReleaseKit/internal/preparation" internalpreparation "gitea.maximumdirect.net/eric/ReleaseKit/internal/preparation"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
) )
// TaggedCheckoutRequest selects a stable tag and the full Git object ID // TaggedCheckoutRequest selects a stable tag and the full Git object ID
@@ -29,12 +33,28 @@ func PrepareTaggedCheckout(ctx context.Context, request TaggedCheckoutRequest) e
}) })
} }
// PrepareBuildDependencies runs `go mod download` in repositoryDir with // DependencyPreparationRequest selects the repository module whose dependencies
// should be prepared.
type DependencyPreparationRequest struct {
RepositoryDir string
// ModuleDir selects a repository-relative module directory; empty selects
// the repository root.
ModuleDir string
}
// PrepareBuildDependencies runs `go mod download` in the selected module with
// workspace discovery disabled and the locally available compatible Go // workspace discovery disabled and the locally available compatible Go
// toolchain selected. It may access module networks and updates the Go module // toolchain selected. It may access module networks and updates the Go module
// cache using the caller's Go proxy, private-module, credential, and cache // cache using the caller's Go proxy, private-module, credential, and cache
// settings. If required checksums are missing, the Go command may update // settings. If required checksums are missing, the Go command may update
// go.sum. Cancellation and Go command failures are returned to the caller. // go.sum. Cancellation and Go command failures are returned to the caller.
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error { func PrepareBuildDependencies(ctx context.Context, request DependencyPreparationRequest) error {
return internalpreparation.PrepareBuildDependencies(ctx, repositoryDir) if strings.TrimSpace(request.RepositoryDir) == "" {
return errors.New("repository directory is required")
}
selection, err := releaseidentity.ResolveSelection(request.RepositoryDir, request.ModuleDir)
if err != nil {
return fmt.Errorf("resolve dependency preparation module: %w", err)
}
return internalpreparation.PrepareBuildDependencies(ctx, selection.ModuleDir)
} }

View File

@@ -128,11 +128,37 @@ func TestPrepareTaggedCheckoutValidatesInputsBeforeFetching(t *testing.T) {
} }
func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) { func TestPrepareBuildDependenciesRequiresRepositoryDirectory(t *testing.T) {
if err := releasekit.PrepareBuildDependencies(context.Background(), " "); err == nil { if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{RepositoryDir: " "}); err == nil {
t.Fatal("PrepareBuildDependencies() accepted an empty repository directory") t.Fatal("PrepareBuildDependencies() accepted an empty repository directory")
} }
} }
func TestPrepareBuildDependenciesUsesSelectedModuleDirectory(t *testing.T) {
repository := t.TempDir()
moduleDir := filepath.Join(repository, "integrations", "ckconfig")
if err := os.MkdirAll(moduleDir, 0o700); err != nil {
t.Fatal(err)
}
toolDir := t.TempDir()
marker := filepath.Join(t.TempDir(), "go-directory")
tool := filepath.Join(toolDir, "go")
if err := os.WriteFile(tool, []byte("#!/bin/sh\nprintf '%s' \"$PWD\" > \"$PREPARE_DIR_MARKER\"\n"), 0o700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("PREPARE_DIR_MARKER", marker)
if err := releasekit.PrepareBuildDependencies(context.Background(), releasekit.DependencyPreparationRequest{
RepositoryDir: repository,
ModuleDir: "integrations/ckconfig",
}); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(marker)
if err != nil || string(got) != moduleDir {
t.Fatalf("go mod download directory = %q, error=%v, want %q", got, err, moduleDir)
}
}
func publishPreparationTag(t *testing.T, repository string) string { func publishPreparationTag(t *testing.T, repository string) string {
t.Helper() t.Helper()
remoteDir := t.TempDir() remoteDir := t.TempDir()