diff --git a/pkg/cli/add_package_manifest.go b/pkg/cli/add_package_manifest.go index 82455e64a7f..5136a3d4b01 100644 --- a/pkg/cli/add_package_manifest.go +++ b/pkg/cli/add_package_manifest.go @@ -79,7 +79,7 @@ func (e packageRemoteNotFoundError) Unwrap() []error { func resolveRepositoryPackage(ctx context.Context, repoSpec *RepoSpec, host string) (*resolvedRepositoryPackage, error) { parts := strings.SplitN(repoSpec.RepoSlug, "/", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid repository slug: %s", repoSpec.RepoSlug) + return nil, fmt.Errorf("repository slug %q is not in 'owner/repo' format. Example: owner/repo", repoSpec.RepoSlug) } owner := parts[0] @@ -153,7 +153,7 @@ func resolveRepositoryPackage(ctx context.Context, repoSpec *RepoSpec, host stri warnings = append(warnings, agentWarnings...) if len(installationSources) == 0 && len(skillFiles) == 0 && len(agentFiles) == 0 { - return nil, fmt.Errorf("repository %q does not contain any installable workflows, skills, or agents (either explicitly declared or auto-discovered)", repositoryPackageIdentifier(repoSpec.RepoSlug, packagePath)) + return nil, fmt.Errorf("repository %q does not contain any installable workflows, skills, or agents (either explicitly declared or auto-discovered). Add workflows under 'workflows/', skills under 'skills/', or agents under 'agents/', or declare them explicitly in aw.yml", repositoryPackageIdentifier(repoSpec.RepoSlug, packagePath)) } return &resolvedRepositoryPackage{ @@ -179,12 +179,12 @@ func loadRepositoryPackageManifestFile(ctx context.Context, owner, repo, package content, err := downloadPackageFileFromGitHubForHost(ctx, owner, repo, manifestPath, ref, host) if err != nil { if !isRepositoryFileNotFound(err) { - return "", nil, fmt.Errorf("failed to read manifest %q from %s/%s@%s: %w", manifestPath, owner, repo, ref, err) + return "", nil, fmt.Errorf("failed to read manifest %q from %s/%s@%s (check the repository, ref, and network connectivity): %w", manifestPath, owner, repo, ref, err) } if packagePath != "" { - return "", nil, fmt.Errorf("%w: repository %q is not a valid Agentic Workflow package: no aw.yml manifest found in %q; add %s or use an explicit workflow path", errRepositoryPackageManifestNotFound, packageID, packagePath, manifestPath) + return "", nil, fmt.Errorf("%w: repository %q is not a valid Agentic Workflow package: no aw.yml manifest found in %q. Add %s or use an explicit workflow path", errRepositoryPackageManifestNotFound, packageID, packagePath, manifestPath) } - return "", nil, fmt.Errorf("%w: repository %q is not a valid Agentic Workflow package: no aw.yml manifest found at the repository root; add aw.yml or use an explicit workflow path", errRepositoryPackageManifestNotFound, repoSlug) + return "", nil, fmt.Errorf("%w: repository %q is not a valid Agentic Workflow package: no aw.yml manifest found at the repository root. Add aw.yml or use an explicit workflow path", errRepositoryPackageManifestNotFound, repoSlug) } return manifestPath, content, nil @@ -207,19 +207,19 @@ type repositoryPackageManifest struct { func parseRepositoryPackageManifest(manifestPath string, content []byte) (*repositoryPackageManifest, []string, error) { var raw any if err := yaml.Unmarshal(content, &raw); err != nil { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: %s", manifestPath, parser.FormatYAMLError(err, 1, string(content))) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: %s. Ensure the manifest is valid YAML. Example:\nname: My Package", manifestPath, parser.FormatYAMLError(err, 1, string(content))) } root, ok := raw.(map[string]any) if !ok { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: top-level document must be a mapping", manifestPath) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: top-level document must be a mapping, not a list or scalar. Example:\nname: My Package", manifestPath) } // Validate name before schema validation to provide a clear error message for // the most common manifest authoring error (missing or empty name). name, ok := stringValue(root["name"]) if !ok || strings.TrimSpace(name) == "" { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: name must be a non-empty string", manifestPath) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: name must be a non-empty string. Example:\nname: My Package", manifestPath) } if err := parser.ValidateRepositoryPackageManifestWithSchemaAndLocation(root, manifestPath); err != nil { @@ -240,15 +240,15 @@ func parseRepositoryPackageManifest(manifestPath string, content []byte) (*repos if minVersion, ok := stringValue(root["min-version"]); ok { manifest.MinVersion = strings.TrimSpace(minVersion) if !isSupportedManifestMinVersion(manifest.MinVersion) { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version must use vMAJOR.minor.patch, got %q", manifestPath, minVersion) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version must use vMAJOR.minor.patch, got %q. Example:\nmin-version: v1.2.3", manifestPath, minVersion) } currentVersion := GetVersion() if !semverutil.IsValid(currentVersion) { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version validation requires a semantic-versioned compiler, but the current compiler version %q is not a valid semantic version (this indicates a build issue)", manifestPath, currentVersion) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version validation requires a semantic-versioned compiler, but the current compiler version %q is not a valid semantic version. This indicates a build issue; rebuild gh-aw with a proper version tag. Example: v1.2.3", manifestPath, currentVersion) } currentVersion = semverutil.NormalizeGitDescribeSemver(currentVersion) if semverutil.Compare(currentVersion, manifest.MinVersion) < 0 { - return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version %q requires gh-aw %s or newer (current: %s)", manifestPath, manifest.MinVersion, manifest.MinVersion, currentVersion) + return nil, nil, fmt.Errorf("invalid Agentic Workflow manifest %q: min-version %q requires gh-aw %s or newer (current: %s). Upgrade gh-aw, or lower min-version in aw.yml to a version at or below the current one. Example:\nmin-version: %s", manifestPath, manifest.MinVersion, manifest.MinVersion, currentVersion, currentVersion) } } @@ -597,7 +597,7 @@ func resolvePackageSkillFiles(ctx context.Context, owner, repo, packagePath, ref warnings = append(warnings, fmt.Sprintf("Skill directory %q is missing required %s marker file", skillDir, packageSkillMarkerFile)) continue } - return nil, nil, fmt.Errorf("failed to validate skill marker %q: %w", markerPath, err) + return nil, nil, fmt.Errorf("failed to validate skill marker %q (check the repository, ref, and network connectivity): %w", markerPath, err) } } skillName := filepath.Base(skillDir) @@ -609,7 +609,7 @@ func resolvePackageSkillFiles(ctx context.Context, owner, repo, packagePath, ref warnings = append(warnings, fmt.Sprintf("Skill directory %q not found in package, skipping", skillDir)) continue } - return nil, nil, fmt.Errorf("failed to list files in skill directory %q: %w", skillDir, err) + return nil, nil, fmt.Errorf("failed to list files in skill directory %q (check the repository, ref, and network connectivity): %w", skillDir, err) } for _, file := range files { skillFiles = append(skillFiles, resolvedPackageSkillFile{ @@ -641,7 +641,7 @@ func resolvePackageAgentFiles(ctx context.Context, owner, repo, packagePath, ref if isRepositoryFileNotFound(err) { continue } - return nil, nil, fmt.Errorf("failed to scan agents directory %q: %w", agentsDir, err) + return nil, nil, fmt.Errorf("failed to scan agents directory %q (check the repository, ref, and network connectivity): %w", agentsDir, err) } for _, f := range files { if strings.HasSuffix(strings.ToLower(f), ".md") { @@ -663,7 +663,7 @@ func scanPackageSkillDirs(ctx context.Context, owner, repo, packagePath, ref, ho if isRepositoryFileNotFound(err) { continue } - return nil, fmt.Errorf("failed to scan skills directory %q: %w", skillsDir, err) + return nil, fmt.Errorf("failed to scan skills directory %q (check the repository, ref, and network connectivity): %w", skillsDir, err) } for _, subdir := range subdirs { markerPath := joinRepositoryPackagePath(subdir, packageSkillMarkerFile) @@ -686,7 +686,7 @@ func scanRepositoryPackageInstallablePaths(ctx context.Context, owner, repo, pac if isRepositoryFileNotFound(err) { continue } - return nil, fmt.Errorf("failed to scan %q in %s/%s@%s: %w", sourcePath, owner, repo, ref, err) + return nil, fmt.Errorf("failed to scan %q in %s/%s@%s (check the repository, ref, and network connectivity): %w", sourcePath, owner, repo, ref, err) } for _, file := range files { @@ -719,9 +719,9 @@ func resolveRepositoryPackageDocsPath(ctx context.Context, owner, repo, packageP if _, err := downloadPackageFileFromGitHubForHost(ctx, owner, repo, readmePath, ref, host); err == nil { return readmePath, nil } else if isRepositoryFileNotFound(err) { - return "", fmt.Errorf("repository %q is not a valid Agentic Workflow package: missing required README.md at %q", packageID, readmePath) + return "", fmt.Errorf("repository %q is not a valid Agentic Workflow package: missing required README.md at %q. Add a README.md describing the package. Example:\n# My Package\n\nDescribe what this package does.", packageID, readmePath) } else { - return "", fmt.Errorf("failed to read package README %q from %s/%s@%s: %w", readmePath, owner, repo, ref, err) + return "", fmt.Errorf("failed to read package README %q from %s/%s@%s (check the repository, ref, and network connectivity): %w", readmePath, owner, repo, ref, err) } } @@ -771,7 +771,7 @@ func validateManifestInstallableWorkflowPrivacy(manifestPath string, installatio privateValue, hasPrivate := ExtractWorkflowPrivateSetting(string(content)) if hasPrivate && privateValue { - return fmt.Errorf("invalid Agentic Workflow manifest %q: workflow %q sets private: true and cannot be included because private workflows cannot be added", manifestPath, installationSource) + return fmt.Errorf("invalid Agentic Workflow manifest %q: workflow %q sets private: true and cannot be included because private workflows cannot be added. Remove 'private: true' from the workflow frontmatter or exclude it from the manifest. Example:\n---\nprivate: false\n---", manifestPath, installationSource) } } @@ -829,7 +829,7 @@ func parseRepositoryPackageSpec(spec string) (*RepoSpec, bool, error) { if cleanedPath == "." { packagePath = "" } else if cleanedPath == ".." || strings.HasPrefix(cleanedPath, "../") { - return nil, true, fmt.Errorf("invalid repository package path %q", packagePath) + return nil, true, fmt.Errorf("invalid repository package path %q: path traversal outside the repository is not allowed. Use a path relative to the repository root. Example: packages/my-package", packagePath) } else { packagePath = cleanedPath } @@ -883,7 +883,7 @@ func validateUniqueManifestWorkflowFilenames(paths []string, manifestPath string continue } if previous, exists := seen[key]; exists { - return fmt.Errorf("invalid Agentic Workflow manifest %q: duplicate workflow filename %q in files entries %q and %q (filenames must be unique across a package)", manifestPath, filenameWithoutExt, previous, installPath) + return fmt.Errorf("invalid Agentic Workflow manifest %q: duplicate workflow filename %q in files entries %q and %q. Filenames must be unique across a package; rename one of the workflow files. Example:\nfiles:\n - workflows/%s.md\n - workflows/%s-2.md", manifestPath, filenameWithoutExt, previous, installPath, filenameWithoutExt, filenameWithoutExt) } seen[key] = installPath } @@ -952,7 +952,7 @@ func resolveRepositoryPackageDefaultBranch(ctx context.Context, repoSlug, host s if targetHost == "" { targetHost = "the configured host" } - return "", fmt.Errorf("repository %s on %s returned an empty default branch; ensure the repository exists and is accessible", repoSlug, targetHost) + return "", fmt.Errorf("repository %s on %s returned an empty default branch. Ensure the repository exists and is accessible", repoSlug, targetHost) } return branch, nil } diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index db77d22c5c6..2caa89e3802 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -127,7 +127,7 @@ func parseRepoSpec(repoSpec string) (*RepoSpec, error) { repoURL, err := url.Parse(repo) if err != nil { specLog.Printf("Failed to parse GitHub URL: %v", err) - return nil, fmt.Errorf("invalid GitHub URL: %w", err) + return nil, fmt.Errorf("could not parse GitHub URL %q (use a URL like https://github.com/owner/repo): %w", repo, err) } // Extract owner/repo from path @@ -145,7 +145,7 @@ func parseRepoSpec(repoSpec string) (*RepoSpec, error) { currentRepo, err := GetCurrentRepoSlug() if err != nil { specLog.Printf("Failed to get current repo: %v", err) - return nil, fmt.Errorf("failed to get current repository info: %w", err) + return nil, fmt.Errorf("failed to get current repository info (run this command from inside a git repository with a GitHub remote, or specify 'owner/repo' explicitly): %w", err) } repo = currentRepo specLog.Printf("Resolved current repo: %s", repo) @@ -181,15 +181,15 @@ func parseGitHubURL(spec string) (*WorkflowSpec, error) { parsedURL, err := url.Parse(spec) if err != nil { specLog.Printf("Failed to parse URL: %v", err) - return nil, fmt.Errorf("invalid URL: %w", err) + return nil, fmt.Errorf("could not parse URL %q (use a URL like https://github.com/owner/repo/blob/main/workflows/workflow.md): %w", spec, err) } if parsedURL.Host == "" { - return nil, fmt.Errorf("URL must include a host: %s", spec) + return nil, fmt.Errorf("URL %q is missing a host. Use a full URL. Example: https://github.com/owner/repo/blob/main/workflows/workflow.md", spec) } if !isGitHubHost(parsedURL.Host) { - return nil, fmt.Errorf("URL must be from github.com or a GitHub Enterprise host (*.ghe.com), got %q", parsedURL.Host) + return nil, fmt.Errorf("URL host %q is not supported. Expected github.com or a GitHub Enterprise host (*.ghe.com). Example: https://github.com/owner/repo/blob/main/workflows/workflow.md", parsedURL.Host) } owner, repo, ref, filePath, err := parser.ParseRepoFileURL(spec) @@ -202,12 +202,12 @@ func parseGitHubURL(spec string) (*WorkflowSpec, error) { // Ensure the file path ends with .md if !strings.HasSuffix(filePath, ".md") { - return nil, errors.New("GitHub URL must point to a .md file") + return nil, errors.New("GitHub URL must point to a .md file. Example: https://github.com/owner/repo/blob/main/workflows/workflow.md") } // Validate owner and repo if !parser.IsValidGitHubIdentifier(owner) || !parser.IsValidGitHubRepositoryName(repo) { - return nil, fmt.Errorf("invalid GitHub URL: '%s/%s' does not look like a valid GitHub repository", owner, repo) + return nil, fmt.Errorf("GitHub URL contains '%s/%s', which does not look like a valid GitHub repository. Expected owner and repository names with only letters, numbers, hyphens, and underscores", owner, repo) } // For raw.githubusercontent.com content, the API host is github.com. @@ -294,10 +294,10 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { // Non-GitHub HTTP(S) URL: return a generic URL spec whose content will be // fetched at resolution time and dispatched on Content-Type. if urlErr != nil { - return nil, fmt.Errorf("invalid URL %q: %w", spec, urlErr) + return nil, fmt.Errorf("could not parse URL %q (use a fully qualified http(s) URL): %w", spec, urlErr) } if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return nil, fmt.Errorf("unsupported URL scheme %q: only http and https are supported", parsedURL.Scheme) + return nil, fmt.Errorf("URL scheme %q is not supported. Only http and https are supported. Example: https://example.com/workflow.md", parsedURL.Scheme) } specLog.Printf("Detected generic import URL: %s", spec) return &WorkflowSpec{ @@ -341,7 +341,7 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { // Must have at least 3 parts: owner/repo/workflow-path if len(slashParts) < 3 { - return nil, errors.New("workflow specification must be in format 'owner/repo/workflow-name[@version]'") + return nil, errors.New("workflow specification format is not recognized. Expected 'owner/repo/workflow-name[@version]'. Example: github/gh-aw/ci-doctor") } owner := slashParts[0] @@ -367,12 +367,12 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { // Validate owner and repo parts are not empty if owner == "" || repo == "" { - return nil, errors.New("invalid workflow specification: owner and repo cannot be empty") + return nil, errors.New("workflow specification is missing owner or repo. Expected 'owner/repo/workflow-name[@version]'. Example: github/gh-aw/ci-doctor") } // Basic validation that owner and repo look like GitHub identifiers if !parser.IsValidGitHubIdentifier(owner) || !parser.IsValidGitHubRepositoryName(repo) { - return nil, fmt.Errorf("invalid workflow specification: '%s/%s' does not look like a valid GitHub repository", owner, repo) + return nil, fmt.Errorf("workflow specification contains '%s/%s', which does not look like a valid GitHub repository. Expected owner and repository names with only letters, numbers, hyphens, and underscores", owner, repo) } repoSlug := fmt.Sprintf("%s/%s", owner, repo) @@ -407,7 +407,7 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { // Four or more parts: owner/repo/workflows/workflow-name or owner/repo/path/to/workflow-name // Require .md extension to be explicit if !strings.HasSuffix(workflowPath, ".md") { - return nil, fmt.Errorf("workflow specification with path must end with '.md' extension: %s", workflowPath) + return nil, fmt.Errorf("workflow specification path %q must end with '.md' extension. Example: owner/repo/workflows/ci-doctor.md", workflowPath) } } @@ -428,7 +428,7 @@ func parseLocalWorkflowSpec(spec string) (*WorkflowSpec, error) { // Validate that it's a .md file if !strings.HasSuffix(spec, ".md") { specLog.Printf("Invalid extension for local workflow: %s", spec) - return nil, fmt.Errorf("local workflow specification must end with '.md' extension: %s", spec) + return nil, fmt.Errorf("local workflow specification %q must end with '.md' extension. Example: ./workflows/ci-doctor.md", spec) } specLog.Printf("Parsed local workflow: path=%s", spec) diff --git a/pkg/workflow/compiler_pre_activation_job.go b/pkg/workflow/compiler_pre_activation_job.go index 7eca4bae9c7..2d202ea90c6 100644 --- a/pkg/workflow/compiler_pre_activation_job.go +++ b/pkg/workflow/compiler_pre_activation_job.go @@ -25,7 +25,7 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec // Extract custom steps and outputs from jobs.pre-activation if present. customSteps, customOutputs, err := c.extractPreActivationCustomFields(data.Jobs) if err != nil { - return nil, fmt.Errorf("failed to extract pre-activation custom fields: %w", err) + return nil, fmt.Errorf("failed to extract pre-activation custom fields (check that jobs.pre_activation and jobs.activation only use 'steps', 'outputs', and 'pre-steps' fields): %w", err) } setupActionRef := c.resolveActionReference("./actions/setup", data) @@ -379,7 +379,7 @@ func (c *Compiler) injectPreActivationOnSteps(data *WorkflowData, steps, customS for i, stepMap := range data.OnSteps { stepYAML, err := ConvertStepToYAML(stepMap) if err != nil { - return nil, nil, fmt.Errorf("failed to convert on.steps[%d] to YAML: %w", i, err) + return nil, nil, fmt.Errorf("failed to convert on.steps[%d] to YAML (ensure the step is a valid GitHub Actions step object with 'name', 'uses', or 'run' fields): %w", i, err) } steps = append(steps, stepYAML) if id, ok := stepMap["id"].(string); ok && id != "" { @@ -674,7 +674,7 @@ func validatePreActivationJobConfig(jobs map[string]any, jobName string) (map[st configMap, ok := preActivationJob.(map[string]any) if !ok { - return nil, fmt.Errorf("jobs.%s must be an object, got %T", jobName, preActivationJob) + return nil, fmt.Errorf("jobs.%s must be an object, got %T. Example:\njobs:\n %s:\n steps:\n - run: echo hello", jobName, preActivationJob, jobName) } allowedFields := map[string]struct{}{ @@ -685,12 +685,12 @@ func validatePreActivationJobConfig(jobs map[string]any, jobName string) (map[st for field := range configMap { if field == "setup-steps" { return nil, fmt.Errorf( - "jobs.%s.setup-steps is not allowed: setup-steps are refused for activation/pre-activation jobs because they can short-circuit protections", - jobName, + "jobs.%s.setup-steps is not allowed for activation/pre-activation jobs because it can short-circuit protections. Use 'steps', 'outputs', or 'pre-steps' instead. Example:\njobs:\n %s:\n steps:\n - run: echo hello", + jobName, jobName, ) } if !setutil.Contains(allowedFields, field) { - return nil, fmt.Errorf("jobs.%s: unsupported field '%s' - only 'steps', 'outputs', and 'pre-steps' are allowed", jobName, field) + return nil, fmt.Errorf("jobs.%s: unsupported field '%s'. Only 'steps', 'outputs', and 'pre-steps' are allowed. Example:\njobs:\n %s:\n steps:\n - run: echo hello", jobName, field, jobName) } } return configMap, nil @@ -705,18 +705,18 @@ func extractPreActivationJobSteps(jobName string, configMap map[string]any) ([]s stepsList, ok := stepsValue.([]any) if !ok { - return nil, fmt.Errorf("jobs.%s.steps must be an array, got %T", jobName, stepsValue) + return nil, fmt.Errorf("jobs.%s.steps must be an array of step objects, got %T. Example:\njobs:\n %s:\n steps:\n - run: echo hello", jobName, stepsValue, jobName) } var steps []string for i, step := range stepsList { stepMap, ok := step.(map[string]any) if !ok { - return nil, fmt.Errorf("jobs.%s.steps[%d] must be an object, got %T", jobName, i, step) + return nil, fmt.Errorf("jobs.%s.steps[%d] must be an object, got %T. Example:\njobs:\n %s:\n steps:\n - run: echo hello", jobName, i, step, jobName) } stepYAML, err := ConvertStepToYAML(stepMap) if err != nil { - return nil, fmt.Errorf("failed to convert jobs.%s.steps[%d] to YAML: %w", jobName, i, err) + return nil, fmt.Errorf("failed to convert jobs.%s.steps[%d] to YAML (ensure the step is a valid GitHub Actions step object with 'name', 'uses', or 'run' fields): %w", jobName, i, err) } steps = append(steps, stepYAML) } @@ -733,7 +733,7 @@ func extractPreActivationJobOutputs(jobName string, configMap map[string]any) (m outputsMap, ok := outputsValue.(map[string]any) if !ok { - return nil, fmt.Errorf("jobs.%s.outputs must be an object, got %T", jobName, outputsValue) + return nil, fmt.Errorf("jobs.%s.outputs must be an object mapping output names to expressions, got %T. Example:\njobs:\n %s:\n outputs:\n result: ${{ steps.my_step.outputs.result }}", jobName, outputsValue, jobName) } // If the same output key is defined in both variants, the second one (pre_activation) wins. @@ -741,7 +741,7 @@ func extractPreActivationJobOutputs(jobName string, configMap map[string]any) (m for key, val := range outputsMap { valStr, ok := val.(string) if !ok { - return nil, fmt.Errorf("jobs.%s.outputs.%s must be a string, got %T", jobName, key, val) + return nil, fmt.Errorf("jobs.%s.outputs.%s must be a string, got %T. Example:\njobs:\n %s:\n outputs:\n %s: ${{ steps.my_step.outputs.result }}", jobName, key, val, jobName, key) } result[key] = valStr } @@ -839,14 +839,14 @@ func extractOnSteps(frontmatter map[string]any) ([]map[string]any, error) { stepsList, ok := stepsValue.([]any) if !ok { - return nil, fmt.Errorf("on.steps must be an array, got %T", stepsValue) + return nil, fmt.Errorf("on.steps must be an array of step objects, got %T. Example:\non:\n steps:\n - run: echo hello", stepsValue) } result := make([]map[string]any, 0, len(stepsList)) for i, step := range stepsList { stepMap, ok := step.(map[string]any) if !ok { - return nil, fmt.Errorf("on.steps[%d] must be an object, got %T", i, step) + return nil, fmt.Errorf("on.steps[%d] must be an object, got %T. Example:\non:\n steps:\n - run: echo hello", i, step) } result = append(result, stepMap) } @@ -917,7 +917,7 @@ func extractOnRestoreMemory(frontmatter map[string]any) (bool, error) { restoreMemory, ok := restoreMemoryValue.(bool) if !ok { - return false, fmt.Errorf("on.restore-memory must be a boolean, got %T", restoreMemoryValue) + return false, fmt.Errorf("on.restore-memory must be a boolean, got %T. Example:\non:\n restore-memory: true", restoreMemoryValue) } return restoreMemory, nil @@ -935,14 +935,14 @@ func parseOnNeedsValues(onMap map[string]any) ([]string, error) { needsList, ok := needsValue.([]any) if !ok { - return nil, fmt.Errorf("on.needs must be an array, got %T", needsValue) + return nil, fmt.Errorf("on.needs must be an array of job names, got %T. Example:\non:\n needs: [\"build\"]", needsValue) } result := make([]string, 0, len(needsList)) for i, need := range needsList { needStr, ok := need.(string) if !ok { - return nil, fmt.Errorf("on.needs[%d] must be a string, got %T", i, need) + return nil, fmt.Errorf("on.needs[%d] must be a string job name, got %T. Example:\non:\n needs: [\"build\"]", i, need) } result = append(result, needStr) } diff --git a/pkg/workflow/evals_config.go b/pkg/workflow/evals_config.go index 5ee69402c05..0f48410128f 100644 --- a/pkg/workflow/evals_config.go +++ b/pkg/workflow/evals_config.go @@ -77,7 +77,7 @@ func (c *Compiler) parseEvalsFromFrontmatter(frontmatter map[string]any) (*Evals if questionsRaw, ok := v["questions"]; ok { questionsList, ok := questionsRaw.([]any) if !ok { - return nil, fmt.Errorf("evals.questions: must be a list of question objects, got %T", questionsRaw) + return nil, fmt.Errorf("evals.questions must be a list of question objects, got %T. Example:\nevals:\n questions:\n - id: readme\n question: Does the README explain setup?", questionsRaw) } questions, err := parseEvalDefinitions(questionsList) if err != nil { @@ -90,7 +90,7 @@ func (c *Compiler) parseEvalsFromFrontmatter(frontmatter map[string]any) (*Evals if modelRaw, ok := v["model"]; ok { modelStr, ok := modelRaw.(string) if !ok { - return nil, fmt.Errorf("evals.model: must be a string, got %T", modelRaw) + return nil, fmt.Errorf("evals.model must be a string, got %T. Example:\nevals:\n model: small", modelRaw) } cfg.Model = strings.TrimSpace(modelStr) } @@ -101,7 +101,7 @@ func (c *Compiler) parseEvalsFromFrontmatter(frontmatter map[string]any) (*Evals } default: - return nil, errors.New("evals: must be a list of questions or an object with a questions list") + return nil, errors.New("evals must be a list of questions or an object with a questions list. Example:\nevals:\n - id: readme\n question: Does the README explain setup?") } if err := validateEvals(cfg); err != nil { @@ -124,7 +124,7 @@ func parseEvalDefinitions(items []any) ([]EvalDefinition, error) { for i, item := range items { m, ok := item.(map[string]any) if !ok { - return nil, fmt.Errorf("item %d must be an object with id and question fields", i) + return nil, fmt.Errorf("evals item %d must be an object with 'id' and 'question' fields, got a different type. Example:\nevals:\n - id: readme\n question: Does the README explain setup?", i) } def, err := parseEvalDefinition(m, i) if err != nil { @@ -141,20 +141,20 @@ func parseEvalDefinition(m map[string]any, idx int) (EvalDefinition, error) { questionRaw, hasQuestion := m["question"] if !hasID { - return EvalDefinition{}, fmt.Errorf("item %d: missing required field 'id'", idx) + return EvalDefinition{}, fmt.Errorf("evals item %d is missing the required 'id' field. Example:\nevals:\n - id: readme\n question: Does the README explain setup?", idx) } if !hasQuestion { - return EvalDefinition{}, fmt.Errorf("item %d: missing required field 'question'", idx) + return EvalDefinition{}, fmt.Errorf("evals item %d is missing the required 'question' field. Example:\nevals:\n - id: readme\n question: Does the README explain setup?", idx) } id, ok := idRaw.(string) if !ok || strings.TrimSpace(id) == "" { - return EvalDefinition{}, fmt.Errorf("item %d: 'id' must be a non-empty string", idx) + return EvalDefinition{}, fmt.Errorf("evals item %d has an 'id' that is not a non-empty string. Example:\nevals:\n - id: readme\n question: Does the README explain setup?", idx) } question, ok := questionRaw.(string) if !ok || strings.TrimSpace(question) == "" { - return EvalDefinition{}, fmt.Errorf("item %d: 'question' must be a non-empty string", idx) + return EvalDefinition{}, fmt.Errorf("evals item %d has a 'question' that is not a non-empty string. Example:\nevals:\n - id: readme\n question: Does the README explain setup?", idx) } def := EvalDefinition{ @@ -166,7 +166,7 @@ func parseEvalDefinition(m map[string]any, idx int) (EvalDefinition, error) { if modelRaw, ok := m["model"]; ok { modelStr, ok := modelRaw.(string) if !ok { - return EvalDefinition{}, fmt.Errorf("item %d: 'model' must be a string, got %T", idx, modelRaw) + return EvalDefinition{}, fmt.Errorf("evals item %d has a 'model' that must be a string, got %T. Example:\nevals:\n - id: readme\n question: Does the README explain setup?\n model: small", idx, modelRaw) } def.Model = strings.TrimSpace(modelStr) } @@ -180,18 +180,18 @@ func validateEvals(cfg *EvalsConfig) error { return nil } if len(cfg.Questions) == 0 { - return errors.New("evals: at least one question is required when evals is configured") + return errors.New("evals requires at least one question when configured. Example:\nevals:\n - id: readme\n question: Does the README explain setup?") } seen := make(map[string]struct{}, len(cfg.Questions)) for i, q := range cfg.Questions { if _, dup := seen[q.ID]; dup { - return fmt.Errorf("evals: duplicate id %q at index %d", q.ID, i) + return fmt.Errorf("evals has a duplicate id %q at index %d. Use a unique 'id' for each question. Example:\nevals:\n - id: readme\n question: Does the README explain setup?\n - id: security\n question: Are secrets handled safely?", q.ID, i) } seen[q.ID] = struct{}{} if strings.TrimSpace(q.Question) == "" { - return fmt.Errorf("evals: question for id %q must be non-empty", q.ID) + return fmt.Errorf("evals question for id %q must be non-empty. Example:\nevals:\n - id: %s\n question: Does the README explain setup?", q.ID, q.ID) } } return nil diff --git a/pkg/workflow/tools_validation_github.go b/pkg/workflow/tools_validation_github.go index 42b06461869..781e5c7484f 100644 --- a/pkg/workflow/tools_validation_github.go +++ b/pkg/workflow/tools_validation_github.go @@ -22,7 +22,7 @@ func validateGitHubReadOnly(tools *Tools, workflowName string) error { if !tools.GitHub.ReadOnly { toolsValidationLog.Printf("Invalid read-only configuration in workflow: %s", workflowName) - return errors.New("invalid GitHub tool configuration: 'tools.github.read-only: false' is not allowed. The GitHub MCP server always operates in read-only mode. Remove the 'read-only' field or set it to 'true'") + return errors.New("'tools.github.read-only: false' is not supported because the GitHub MCP server always operates in read-only mode. Remove the 'read-only' field or set it to 'true'. Example:\ntools:\n github:\n read-only: true") } return nil @@ -38,7 +38,7 @@ func validateGitHubToolConfig(tools *Tools, workflowName string) error { if tools.GitHub.GitHubApp != nil && tools.GitHub.GitHubToken != "" { toolsValidationLog.Printf("Invalid GitHub tool configuration in workflow: %s", workflowName) - return errors.New("invalid GitHub tool configuration: 'tools.github.github-app' and 'tools.github.github-token' cannot both be set. Use one authentication method: either 'github-app' (GitHub App) or 'github-token' (personal access token)") + return errors.New("'tools.github.github-app' and 'tools.github.github-token' cannot both be set. Use one authentication method: either 'github-app' (GitHub App) or 'github-token' (personal access token). Example:\ntools:\n github:\n github-token: \"${{ secrets.GITHUB_TOKEN }}\"") } return nil @@ -136,7 +136,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { // blocked-users, trusted-users, and approval-labels require a guard policy (min-integrity) if (hasBlockedUsers || hasApprovalLabels || hasTrustedUsers) && !hasMinIntegrity { toolsValidationLog.Printf("blocked-users/trusted-users/approval-labels without guard policy in workflow: %s", workflowName) - return errors.New("invalid guard policy: 'github.blocked-users', 'github.trusted-users', and 'github.approval-labels' require 'github.min-integrity' to be set") + return errors.New("'github.blocked-users', 'github.trusted-users', and 'github.approval-labels' require 'github.min-integrity' to be set. Example:\ntools:\n github:\n min-integrity: approved\n blocked-users: [\"spammer\"]") } // No guard policy fields present - nothing to validate @@ -158,7 +158,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { // Validate min-integrity field (required when repos is set) if !hasMinIntegrity { toolsValidationLog.Printf("Missing min-integrity in guard policy for workflow: %s", workflowName) - return errors.New("invalid guard policy: 'github.min-integrity' is required. Valid values: 'none', 'unapproved', 'approved', 'merged'") + return errors.New("'github.min-integrity' is required when 'github.allowed-repos' is set. Valid values: 'none', 'unapproved', 'approved', 'merged'. Example:\ntools:\n github:\n allowed-repos: all\n min-integrity: approved") } // Validate min-integrity value @@ -171,14 +171,14 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { if !validIntegrityLevels[github.MinIntegrity] { toolsValidationLog.Printf("Invalid min-integrity level '%s' in workflow: %s", github.MinIntegrity, workflowName) - return errors.New("invalid guard policy: 'github.min-integrity' must be one of: 'none', 'unapproved', 'approved', 'merged'. Got: '" + string(github.MinIntegrity) + "'") + return errors.New("'github.min-integrity' must be one of: 'none', 'unapproved', 'approved', 'merged'. Got: '" + string(github.MinIntegrity) + "'. Example:\ntools:\n github:\n min-integrity: approved") } // Validate blocked-users (must be non-empty strings; expressions are accepted as-is) for i, user := range github.BlockedUsers { if user == "" { toolsValidationLog.Printf("Empty blocked-users entry at index %d in workflow: %s", i, workflowName) - return errors.New("invalid guard policy: 'github.blocked-users' entries must not be empty strings") + return errors.New("'github.blocked-users' entries must not be empty strings. Example:\ntools:\n github:\n blocked-users: [\"spammer\"]") } } @@ -186,7 +186,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { for i, label := range github.ApprovalLabels { if label == "" { toolsValidationLog.Printf("Empty approval-labels entry at index %d in workflow: %s", i, workflowName) - return errors.New("invalid guard policy: 'github.approval-labels' entries must not be empty strings") + return errors.New("'github.approval-labels' entries must not be empty strings. Example:\ntools:\n github:\n approval-labels: [\"approved\"]") } } @@ -194,7 +194,7 @@ func validateGitHubGuardPolicy(tools *Tools, workflowName string) error { for i, user := range github.TrustedUsers { if user == "" { toolsValidationLog.Printf("Empty trusted-users entry at index %d in workflow: %s", i, workflowName) - return errors.New("invalid guard policy: 'github.trusted-users' entries must not be empty strings") + return errors.New("'github.trusted-users' entries must not be empty strings. Example:\ntools:\n github:\n trusted-users: [\"octocat\"]") } } @@ -207,7 +207,7 @@ func validateReposScope(repos any, workflowName string) error { if reposStr, ok := repos.(string); ok { if reposStr != "all" && reposStr != "public" && !isExactGitHubRepositoryExpression(reposStr) { toolsValidationLog.Printf("Invalid repos string '%s' in workflow: %s", reposStr, workflowName) - return errors.New("invalid guard policy: 'github.allowed-repos' string must be 'all', 'public', or '${{ github.repository }}'. Got: '" + reposStr + "'") + return errors.New("'github.allowed-repos' string must be 'all', 'public', or '${{ github.repository }}'. Got: '" + reposStr + "'. Example:\ntools:\n github:\n allowed-repos: all") } return nil } @@ -216,14 +216,14 @@ func validateReposScope(repos any, workflowName string) error { if reposArray, ok := repos.([]any); ok { if len(reposArray) == 0 { toolsValidationLog.Printf("Empty repos array in workflow: %s", workflowName) - return errors.New("invalid guard policy: 'github.allowed-repos' array cannot be empty. Provide at least one repository pattern") + return errors.New("'github.allowed-repos' array cannot be empty. Provide at least one repository pattern. Example:\ntools:\n github:\n allowed-repos: [\"owner/repo\"]") } for i, item := range reposArray { pattern, ok := item.(string) if !ok { toolsValidationLog.Printf("Non-string item in repos array at index %d in workflow: %s", i, workflowName) - return errors.New("invalid guard policy: 'github.allowed-repos' array must contain only strings") + return errors.New("'github.allowed-repos' array must contain only strings. Example:\ntools:\n github:\n allowed-repos: [\"owner/repo\"]") } if err := validateRepoPattern(pattern, workflowName); err != nil { @@ -238,7 +238,7 @@ func validateReposScope(repos any, workflowName string) error { if reposArray, ok := repos.([]string); ok { if len(reposArray) == 0 { toolsValidationLog.Printf("Empty repos array in workflow: %s", workflowName) - return errors.New("invalid guard policy: 'github.allowed-repos' array cannot be empty. Provide at least one repository pattern") + return errors.New("'github.allowed-repos' array cannot be empty. Provide at least one repository pattern. Example:\ntools:\n github:\n allowed-repos: [\"owner/repo\"]") } for _, pattern := range reposArray { @@ -252,7 +252,7 @@ func validateReposScope(repos any, workflowName string) error { // Invalid type toolsValidationLog.Printf("Invalid repos type in workflow: %s", workflowName) - return errors.New("invalid guard policy: 'github.allowed-repos' must be 'all', 'public', or an array of repository patterns") + return errors.New("'github.allowed-repos' has an unsupported type. Expected 'all', 'public', or an array of repository patterns. Example:\ntools:\n github:\n allowed-repos: [\"owner/repo\"]") } // validateRepoPattern validates a single repository pattern @@ -264,7 +264,7 @@ func validateRepoPattern(pattern string, workflowName string) error { // Pattern must be lowercase if strings.ToLower(pattern) != pattern { toolsValidationLog.Printf("Repository pattern '%s' is not lowercase in workflow: %s", pattern, workflowName) - return errors.New("invalid guard policy: repository pattern '" + pattern + "' must be lowercase") + return errors.New("repository pattern '" + pattern + "' must be lowercase. Example: 'owner/repo' instead of 'Owner/Repo'") } // Check for valid pattern formats: @@ -274,7 +274,7 @@ func validateRepoPattern(pattern string, workflowName string) error { parts := strings.Split(pattern, "/") if len(parts) != 2 { toolsValidationLog.Printf("Invalid repository pattern '%s' in workflow: %s", pattern, workflowName) - return errors.New("invalid guard policy: repository pattern '" + pattern + "' must be in format 'owner/repo', 'owner/*', or 'owner/prefix*'") + return errors.New("repository pattern '" + pattern + "' must be in format 'owner/repo', 'owner/*', or 'owner/prefix*'. Example: 'owner/repo'") } owner := parts[0] @@ -282,26 +282,26 @@ func validateRepoPattern(pattern string, workflowName string) error { // Validate owner part (must be non-empty and contain only valid characters) if owner == "" { - return errors.New("invalid guard policy: repository pattern '" + pattern + "' has empty owner") + return errors.New("repository pattern '" + pattern + "' has an empty owner. Expected 'owner/repo' format. Example: 'owner/repo'") } if !isValidOwnerOrRepo(owner) { - return errors.New("invalid guard policy: repository pattern '" + pattern + "' has invalid owner. Must contain only lowercase letters, numbers, hyphens, and underscores") + return errors.New("repository pattern '" + pattern + "' has an unsupported owner. Expected only lowercase letters, numbers, hyphens, and underscores. Example: 'owner/repo'") } // Validate repo part if repo == "" { - return errors.New("invalid guard policy: repository pattern '" + pattern + "' has empty repository name") + return errors.New("repository pattern '" + pattern + "' has an empty repository name. Expected 'owner/repo' format. Example: 'owner/repo'") } // Allow wildcard '*' or prefix with trailing '*' if repo != "*" && !isValidOwnerOrRepo(strings.TrimSuffix(repo, "*")) { - return errors.New("invalid guard policy: repository pattern '" + pattern + "' has invalid repository name. Must contain only lowercase letters, numbers, hyphens, underscores, or be '*' or 'prefix*'") + return errors.New("repository pattern '" + pattern + "' has an unsupported repository name. Expected only lowercase letters, numbers, hyphens, underscores, or a wildcard like '*' or 'prefix*'. Example: 'owner/repo' or 'owner/prefix*'") } // Validate that wildcard is only at the end (not in the middle) if strings.Contains(strings.TrimSuffix(repo, "*"), "*") { - return errors.New("invalid guard policy: repository pattern '" + pattern + "' has wildcard in the middle. Wildcards only allowed at the end (e.g., 'prefix*')") + return errors.New("repository pattern '" + pattern + "' has a wildcard in the middle. Wildcards are only allowed at the end. Example: 'owner/prefix*'") } return nil