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",
},