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"
)
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
@@ -31,14 +31,15 @@ const (
// Invocation contains parsed command settings owned by the CLI adapter.
type Invocation struct {
Command Command
Repository string
ConfigPath string
OutputDir string
AssetsDir string
Remote string
Commit string
Version string
Command Command
Repository string
ConfigPath string
OutputDir string
AssetsDir string
Remote string
Commit string
Version string
ExpectedModule string
}
// 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")
}
var remote *string
expectedModule := ""
if action == PublishAction {
flags.StringVar(&expectedModule, "expected-module", "", "expected module directory from a launcher mapping")
}
expectedCommit := ""
if action == CIDeliveryAction {
remote = flags.String("remote", "origin", "Git remote")
@@ -88,7 +93,7 @@ func Parse(args []string) (Invocation, error) {
if remote != nil {
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:
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.
type Settings struct {
Product string `json:"product"`
ModuleDir string `json:"module_dir,omitempty"`
Remote string `json:"remote,omitempty"`
ExpectedPushURL string `json:"expected_push_url,omitempty"`
Checks []CheckCommand `json:"checks,omitempty"`
@@ -126,7 +132,10 @@ type Settings struct {
// ValidationSettings selects shared candidate checks in a release profile.
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.
// Omitted or false disables the check.
GoFormat bool `json:"go_format,omitempty"`
@@ -269,9 +278,9 @@ func LoadSettings(repoDir, configPath string) (Settings, error) {
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.
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 {
environment := make(map[string]string, len(check.Env)+len(ownedEnv))
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{
Args: check.Args,
Dir: repoDir,
Dir: dir,
Env: environment,
Stdout: stdout,
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" {
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"})
if err != nil {
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) {
repoDir := t.TempDir()
profilePath := filepath.Join(repoDir, "releasekit.json")

View File

@@ -14,6 +14,7 @@ import (
"gitea.maximumdirect.net/eric/ReleaseKit"
"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo"
"gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit/internal/cli"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
)
func main() {
@@ -42,11 +43,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
request := candidateRequest(invocation, settings, stderr)
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
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)
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) {
return 2
}
@@ -55,18 +60,18 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
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
}
candidate, err := releasekit.Check(ctx, request)
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) {
return 2
}
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
case cli.BuildAction:
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{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Version: invocation.Version,
Spec: *configuredBuildSpec(settings.Build),
OutputDir: invocation.OutputDir,
@@ -92,7 +98,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
}
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 {
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
}
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
}
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")
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 {
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 {
request := releasekit.ReleaseRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product,
Version: invocation.Version,
Remote: settings.Remote,
@@ -147,9 +154,13 @@ func candidateRequest(invocation cli.Invocation, settings cli.Settings, output i
if len(settings.Checks) != 0 {
checks := settings.Checks
request.AdditionalChecks = func(ctx context.Context, candidate releasekit.Candidate) error {
return cli.RunChecks(ctx, candidate.RepositoryDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version,
"RELEASEKIT_CANDIDATE": candidate.Commit,
return cli.RunChecks(ctx, candidate.ModuleDir, checks, map[string]string{
"RELEASEKIT_VERSION": candidate.Version,
"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)
}
}
@@ -161,7 +172,8 @@ func validationSpec(settings *cli.ValidationSettings) releasekit.ValidationSpec
return releasekit.ValidationSpec{}
}
result := releasekit.ValidationSpec{
GoFormat: settings.GoFormat,
SharedFiles: copyStringsPreservingEmpty(settings.SharedFiles),
GoFormat: settings.GoFormat,
ModuleDistribution: releasekit.ModuleDistributionValidationSpec{
WorkspaceFiles: settings.ModuleDistribution.WorkspaceFiles,
LocalReplacements: settings.ModuleDistribution.LocalReplacements,
@@ -199,6 +211,38 @@ func candidateFailureText(err error) string {
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 {
settings, err := cli.LoadSettings(invocation.Repository, invocation.ConfigPath)
if err != nil {
@@ -233,7 +277,10 @@ func runCIDelivery(ctx context.Context, invocation cli.Invocation, stdout, stder
return status
}
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)
return 1
}
@@ -301,6 +348,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
result, err := releasekit.Deliver(ctx, releasekit.DeliveryRequest{
RepositoryDir: invocation.Repository,
ModuleDir: settings.ModuleDir,
Product: settings.Product,
Version: invocation.Version,
Destination: destination,
@@ -309,7 +357,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
Output: stderr,
})
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)
writeDeliveryRecovery(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) {
@@ -320,7 +368,7 @@ func deliverWithDestination(ctx context.Context, invocation cli.Invocation, sett
}
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 != "" {
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) {
switch result.State {
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:
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:
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:
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:
return
}

View File

@@ -51,6 +51,13 @@ func TestUnknownCommand(t *testing.T) {
func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
raceSelected := racePrerequisitesAvailable()
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")
orderPath := filepath.Join(t.TempDir(), "check-order")
t.Setenv("RELEASEKIT_CHECK_ORDER_FILE", orderPath)
@@ -70,9 +77,10 @@ func TestCheckCommandBindsConfiguredChecksToCapturedCandidate(t *testing.T) {
}
}
settings := cli.Settings{
Product: "Example Product",
Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")},
Product: "Example Product",
ModuleDir: "integrations/ckconfig",
Remote: "origin",
Checks: []cli.CheckCommand{check("first"), check("second")},
}
if raceSelected {
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 {
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)
}
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) {
for _, test := range []struct {
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) {
repository := newCLIRepository(t)
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")
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)
}
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())
}
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) {
repository := newCLIBuildRepository(t, `package main
@@ -440,7 +553,12 @@ func TestCandidateCheckHelper(t *testing.T) {
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)
}
@@ -760,7 +878,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{
name: "partial",
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",
},
{
@@ -773,7 +891,7 @@ func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *test
{
name: "unknown",
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",
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. The repository defaults to the current directory, and the profile
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
`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
@@ -31,6 +31,15 @@ selected package compilation and committed Go formatting, followed by
configured additional checks in profile order. It does not inspect remote
availability or publish a tag.
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
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
@@ -63,7 +72,7 @@ Unsupported argument sequences print usage to standard error.
Publish a source tag:
```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
@@ -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,
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
and pushes only its exact full tag object. It prints the confirmed semantic
version, full tag, candidate commit, and tag object
on success. Validation
progress and failed publication state, including the captured publication
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
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
nonempty value must exactly match the effective push URL resolved by Git;
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`
invocation, and writes binaries plus the private `.releasekit-build.json`
receipt into `dist` by default. `--output` selects a different directory;
relative paths are resolved from the repository. Selected artifact filenames
are regenerated while unrelated files are preserved. A complete nonempty build
relative paths are resolved from the selected module directory. Selected
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
failure, standard error lists completed outputs and prior receipts in that
output directory are invalidated; no new receipt is written. An

View File

@@ -9,6 +9,7 @@ The current profile fields are:
| Field | Required | Behavior |
| --- | --- | --- |
| `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`. |
| `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. |
@@ -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.
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
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

View File

@@ -35,9 +35,11 @@ if err != nil {
}
```
For binary profiles, call `PrepareBuildDependencies(ctx, repositoryDir)` after
checkout preparation and before delivery. It runs `go mod download` with
workspace discovery disabled and the available compatible local Go toolchain.
For binary profiles, call `PrepareBuildDependencies` with a
`DependencyPreparationRequest` selecting the repository and module after
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
preserving the caller's proxy, private-module, credential, and cache settings.
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` |
| 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` |
| 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` |
| 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.
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
**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)
}
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error {
if strings.TrimSpace(repositoryDir) == "" {
return errors.New("repository directory is required")
func PrepareBuildDependencies(ctx context.Context, moduleDir string) error {
if strings.TrimSpace(moduleDir) == "" {
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 (
"context"
"errors"
"fmt"
"strings"
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
@@ -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
// toolchain selected. It may access module networks and updates the Go module
// cache using the caller's Go proxy, private-module, credential, and cache
// settings. If required checksums are missing, the Go command may update
// go.sum. Cancellation and Go command failures are returned to the caller.
func PrepareBuildDependencies(ctx context.Context, repositoryDir string) error {
return internalpreparation.PrepareBuildDependencies(ctx, repositoryDir)
func PrepareBuildDependencies(ctx context.Context, request DependencyPreparationRequest) error {
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) {
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")
}
}
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 {
t.Helper()
remoteDir := t.TempDir()