Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions cmd/gh-aw/compile_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,20 @@ func TestCompileOptionsPropagateForceRefreshContainerPins(t *testing.T) {
t.Fatal("expected ForceRefreshContainerPins to be propagated to CompileConfig")
}
}

func TestCompileOptionsPropagateModels(t *testing.T) {
t.Parallel()

modelsFlag := compileCmd.Flags().Lookup("models")
if modelsFlag == nil {
t.Fatal("expected --models flag on compile command")
}
if modelsFlag.DefValue != "false" {
t.Fatalf("expected --models default to be false, got %s", modelsFlag.DefValue)
}

config := (&compileCmdOptions{models: true}).toCompileConfig(nil)
if !config.Models {
t.Fatal("expected Models to be propagated to CompileConfig")
}
}
11 changes: 8 additions & 3 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ type compileCmdOptions struct {
showAllErrors bool
fix bool
stats bool
models bool
failFast bool
noCheckUpdate bool
staged bool
Expand Down Expand Up @@ -472,6 +473,7 @@ func getCompileCmdOptions(cmd *cobra.Command) compileCmdOptions {
showAllErrors, _ := cmd.Flags().GetBool("show-all")
fix, _ := cmd.Flags().GetBool("fix")
stats, _ := cmd.Flags().GetBool("stats")
models, _ := cmd.Flags().GetBool("models")
failFast, _ := cmd.Flags().GetBool("fail-fast")
noCheckUpdate, _ := cmd.Flags().GetBool("no-check-update")
scheduleSeed, _ := cmd.Flags().GetString("schedule-seed")
Expand All @@ -488,7 +490,7 @@ func getCompileCmdOptions(cmd *cobra.Command) compileCmdOptions {
validate: validate, watch: watch, noEmit: noEmit, purge: purge, strict: strict, trial: trial, dependabot: dependabot,
forceOverwrite: forceOverwrite, refreshStopTime: refreshStopTime, forceRefreshActionPins: forceRefreshActionPins, forceRefreshContainerPins: forceRefreshContainerPins, allowActionRefs: allowActionRefs,
zizmor: zizmor, poutine: poutine, actionlint: actionlint, runnerGuard: runnerGuard, syft: syft, grype: grype, grant: grant, yamllint: yamllint, shellcheck: shellcheck,
jsonOutput: jsonOutput, showAllErrors: showAllErrors, fix: fix, stats: stats, failFast: failFast, noCheckUpdate: noCheckUpdate,
jsonOutput: jsonOutput, showAllErrors: showAllErrors, fix: fix, stats: stats, models: models, failFast: failFast, noCheckUpdate: noCheckUpdate,
staged: staged, approve: approve, validateImages: validateImages, ghes: ghes, verbose: verbose, useSamples: useSamples,
}
}
Expand Down Expand Up @@ -521,7 +523,7 @@ func (o *compileCmdOptions) toCompileConfig(args []string) cli.CompileConfig {
Dependabot: o.dependabot, ForceOverwrite: o.forceOverwrite, RefreshStopTime: o.refreshStopTime, ForceRefreshActionPins: o.forceRefreshActionPins, ForceRefreshContainerPins: o.forceRefreshContainerPins,
AllowActionRefs: o.allowActionRefs, Zizmor: o.zizmor, Poutine: o.poutine, Actionlint: o.actionlint, RunnerGuard: o.runnerGuard,
Syft: o.syft, Grype: o.grype, Grant: o.grant, Yamllint: o.yamllint, Shellcheck: o.shellcheck, JSONOutput: o.jsonOutput, ShowAllErrors: o.showAllErrors,
Stats: o.stats, FailFast: o.failFast, ScheduleSeed: o.scheduleSeed, Staged: o.staged, Approve: o.approve,
Stats: o.stats, Models: o.models, FailFast: o.failFast, ScheduleSeed: o.scheduleSeed, Staged: o.staged, Approve: o.approve,
ValidateImages: o.validateImages, PriorManifestFile: o.priorManifestFile, GHESCompat: o.ghes, UseSamples: o.useSamples,
}
}
Expand All @@ -546,7 +548,9 @@ func runCompileCmd(cmd *cobra.Command, args []string) error {
return err
}
}
if _, err := cli.CompileWorkflows(cmd.Context(), opts.toCompileConfig(args)); err != nil {
config := opts.toCompileConfig(args)
cli.PrepareCompileModelValidation(cmd.Context(), &config)
if _, err := cli.CompileWorkflows(cmd.Context(), config); err != nil {
return err
}
return nil
Expand Down Expand Up @@ -793,6 +797,7 @@ func configureCompileToolFlags() {
compileCmd.Flags().BoolP("json", "j", false, "Output results in JSON format")
compileCmd.Flags().Bool("show-all", false, "Display all compilation errors instead of only the highest-priority subset (default: top 5)")
compileCmd.Flags().Bool("stats", false, "Display statistics table sorted by workflow file size (shows jobs, steps, scripts, and shells)")
compileCmd.Flags().Bool("models", false, "Warn when models configured in models or engine.models are absent from the active model inventory")
compileCmd.Flags().Bool("fail-fast", false, "Stop at the first validation error instead of collecting all errors")
compileCmd.Flags().Bool("no-check-update", false, "Skip checking for gh-aw updates")
compileCmd.Flags().String("schedule-seed", "", "Override the repository slug (owner/repo) used as seed for fuzzy schedule scattering (e.g., \"github/gh-aw\"). Bypasses git remote detection entirely. Use this when your git remote is not named \"origin\" and you have multiple remotes configured")
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,12 @@ If the repository root contains an [`aw.yml` manifest](/gh-aw/reference/aw-yml-p

Unlike `gh aw upgrade`, `gh aw compile` does not run codemods unless you pass `--fix`.

**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--force-refresh-container-pins`, `--gh-aw-ref`, `--ghes`, `--grant`, `--grype`, `--json/-j`, `--logical-repo/-l`, `--no-check-update`, `--no-emit`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--shellcheck`, `--show-all`, `--staged`, `--stats`, `--strict`, `--syft`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--yamllint`, `--zizmor`
**Options:** `--action-mode`, `--action-tag`, `--actionlint`, `--actions-repo`, `--allow-action-refs`, `--approve`, `--dependabot`, `--dir/-d`, `--engine/-e`, `--fail-fast`, `--fix`, `--force/-f`, `--force-refresh-action-pins`, `--force-refresh-container-pins`, `--gh-aw-ref`, `--ghes`, `--grant`, `--grype`, `--json/-j`, `--logical-repo/-l`, `--models`, `--no-check-update`, `--no-emit`, `--poutine`, `--purge`, `--refresh-stop-time`, `--runner-guard`, `--schedule-seed`, `--shellcheck`, `--show-all`, `--staged`, `--stats`, `--strict`, `--syft`, `--trial`, `--validate`, `--validate-images`, `--watch/-w`, `--yamllint`, `--zizmor`

**`--gh-aw-ref` flag:** Convenience alias for `--action-mode release --action-tag <ref>`. Accepts a branch name, tag, or commit SHA targeting the `github/gh-aw` repository. Branch and tag names are resolved to their full commit SHA at compile time, so the baked-in reference is immutable and reproducible. Useful for E2E-testing workflows compiled against a specific gh-aw revision.

**`--models` flag:** Refreshes the observed model inventory using the same data sources as `gh aw models`, then warns when `models.allowed`, `models.blocked`, or `engine.models` references an unknown model. Built-in and workflow model aliases are accepted. If no observed model data is available, the check is skipped.

**`--approve` flag:** When compiling a workflow that already has a lock file, the compiler enforces *safe update mode* — any newly added secrets or custom actions not present in the previous manifest require explicit approval. Pass `--approve` to accept these changes and regenerate the manifest baseline. On first compile (no existing lock file), enforcement is skipped automatically and `--approve` is not needed.

**Error Reporting:** Displays detailed error messages with file paths, line numbers, column positions, and contextual code snippets.
Expand Down
9 changes: 9 additions & 0 deletions pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler {
workflow.WithEngineOverride(config.EngineOverride),
workflow.WithFailFast(config.FailFast),
)
if config.activeModels != nil {
compiler.SetConfiguredModelValidator(func(data *workflow.WorkflowData) []string {
return unknownConfiguredModelMessages(data, config.activeModels)
})
}
compileCompilerSetupLog.Print("Created compiler instance")

// Configure compiler flags
Expand Down Expand Up @@ -153,6 +158,10 @@ func configureCompilerFlags(compiler *workflow.Compiler, config CompileConfig) {
compiler.SetUseSamples(true)
}

configureCompilerMaintenanceFlags(compiler, config)
}

func configureCompilerMaintenanceFlags(compiler *workflow.Compiler, config CompileConfig) {
// Set refresh stop time flag
compiler.SetRefreshStopTime(config.RefreshStopTime)
if config.RefreshStopTime {
Expand Down
2 changes: 2 additions & 0 deletions pkg/cli/compile_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ type CompileConfig struct {
ActionTag string // Pin action refs to this SHA or version tag (e.g. v1, <full-sha>). Sets release mode unless ActionMode is already "action". Mutually exclusive with GHAwRef at the CLI layer.
ActionsRepo string // Override the external actions repository (default: github/gh-aw-actions)
Stats bool // Display statistics table sorted by file size
Models bool // Warn about configured models absent from the observed active model inventory
FailFast bool // Stop at first error instead of collecting all errors
ScheduleSeed string // Override repository slug used for fuzzy schedule scattering (e.g. owner/repo)
Approve bool // Approve all safe update changes, skipping safe update enforcement regardless of strict mode setting.
ValidateImages bool // Require Docker to be available for container image validation (fail instead of skipping when Docker is unavailable)
PriorManifestFile string // Path to a JSON file containing pre-cached manifests (map[lockFile]*GHAWManifest) collected at MCP server startup; takes precedence over git HEAD / filesystem reads for safe update enforcement
GHESCompat bool // Enable GHES-compatible v3 artifact actions (overrides aw.json ghes field)
activeModels *activeModelInventory
}

func (c CompileConfig) shellcheckEnabled() bool {
Expand Down
156 changes: 156 additions & 0 deletions pkg/cli/compile_model_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package cli

import (
"context"
"path"
"slices"
"strings"

"github.com/github/gh-aw/pkg/modelsdev"
"github.com/github/gh-aw/pkg/workflow"
)

type activeModelInventory struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pkg/cli/compile_model_validation.go:13: yagni: activeModelInventory wrapper with one consumer and a bespoke contains method. Replace it with a plain normalized map and inline the matching logic in findUnknownConfiguredModels.

models []string
aliases map[string]struct{}
}

func buildActiveModelInventory(report modelsReport) *activeModelInventory {
if len(report.Observed) == 0 {
return nil
}

models := make(map[string]struct{})
for _, row := range report.Observed {
model := modelsdev.NormalizeComparableModelID(row.Model)
if model == "" {
continue
}
models[model] = struct{}{}
if row.Provider != "" {
provider := modelsdev.NormalizeProvider(row.Provider)
models[modelsdev.NormalizeComparableModelID(path.Join(provider, row.Model))] = struct{}{}
if provider == "github-copilot" {
for _, alias := range []string{"copilot", "github", "github_models"} {
models[modelsdev.NormalizeComparableModelID(path.Join(alias, row.Model))] = struct{}{}
}
}
}
}

aliases := make(map[string]struct{}, len(report.Aliases))
for _, row := range report.Aliases {
aliases[modelsdev.NormalizeComparableModelID(row.Alias)] = struct{}{}
}

activeModels := make([]string, 0, len(models))
for model := range models {
activeModels = append(activeModels, model)
}
slices.Sort(activeModels)
return &activeModelInventory{models: activeModels, aliases: aliases}
}

func (i *activeModelInventory) contains(candidate string, workflowAliases map[string][]string) bool {
base, _, _ := strings.Cut(strings.TrimSpace(candidate), "?")
if base == "" || strings.Contains(base, "${{") {
return true
}

normalized := modelsdev.NormalizeComparableModelID(base)
if _, ok := i.aliases[normalized]; ok {
return true
}
for alias := range workflowAliases {
if modelsdev.NormalizeComparableModelID(alias) == normalized {
return true
}
}

for _, model := range i.models {
if matched, err := path.Match(normalized, model); err == nil && matched {
return true
}
}
return false
}

func findUnknownConfiguredModels(data *workflow.WorkflowData, inventory *activeModelInventory) []ValidationIssue {
if data == nil || inventory == nil {
return nil
}

candidates := make(map[string][]string)
add := func(field string, values []string) {
for _, value := range values {
if !inventory.contains(value, data.ModelMappings) {
candidates[value] = append(candidates[value], field)
}
}
}

add("models.allowed", data.ModelPolicyAllowed)
add("models.blocked", data.ModelPolicyBlocked)

if engine, ok := data.RawFrontmatter["engine"].(map[string]any); ok {
if models, ok := engine["models"].(map[string]any); ok {
if value, ok := models["default"].(string); ok {
add("engine.models.default", []string{value})
}
add("engine.models.supported", stringSlice(models["supported"]))
}
Comment on lines +95 to +101
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findUnknownConfiguredModels never checks data.Model (the top-level model: frontmatter field) or engine.model (the singular string override consumed by resolveEngineModel in pkg/workflow/engine.go). In this repo, model: is used directly in 219+ workflow files (e.g. .github/workflows/ace-editor.md: model: openai/gpt-5.4), while engine.models.default/supported (the only engine-level field validated here) has zero real usages under .github/workflows/. As written, --models silently skips validating the model configuration for the overwhelming majority of existing workflows, which contradicts the PR's stated goal of warning when models.allowed, models.blocked, or configured engine models are absent from the active inventory. Please also validate data.Model and engine.model (falling back to top-level model:), and add a test case covering the common single-string model: frontmatter form.

@copilot please address this.


values := make([]string, 0, len(candidates))
for value := range candidates {
values = append(values, value)
}
slices.Sort(values)

warnings := make([]ValidationIssue, 0, len(values))
for _, value := range values {
fields := candidates[value]
slices.Sort(fields)
warnings = append(warnings, ValidationIssue{
Type: "unknown_model",
Message: "Model " + value + " referenced by " + strings.Join(fields, ", ") + " was not found in the active model inventory",
})
}
return warnings
}

// PrepareCompileModelValidation builds the active model inventory used by compile --models.
func PrepareCompileModelValidation(ctx context.Context, config *CompileConfig) {
if !config.Models {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] --models silently no-ops whenever report.Observed is empty (e.g. wrong --logs-dir, no local artifacts, or refresh failure) — activeModels stays nil, so SetConfiguredModelValidator is never wired up and the compiler prints nothing. A user cannot distinguish "no unknown models" from "validation never ran."

💡 Suggested improvement

Surface this state explicitly, e.g. emit a one-line stderr notice ("--models: no observed model data available, skipping validation") from PrepareCompileModelValidation when config.activeModels == nil, and add a test asserting that notice appears. This also ties into the existing review comment on this same function about refresh failures being silently swallowed — both stem from the same root cause: buildModelsReport warnings are discarded here instead of being surfaced to the compile user.

@copilot please address this.

return
}
report := buildModelsReport(ctx, modelsReportOptions{
logsDir: defaultLogsOutputDir,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] PrepareCompileModelValidation hardcodes logsDir: defaultLogsOutputDir and omits repoOverride, unlike runModelsCommand which reads both from CLI flags. There's no test exercising a non-default --logs-dir or --repo through compile --models, so this divergence from gh aw models wasn't caught.

💡 Suggested test
func TestPrepareCompileModelValidationRespectsLogsDirOverride(t *testing.T) {
    // arrange: config with a custom logs dir containing fixture observed-model data
    // act: PrepareCompileModelValidation(ctx, &config)
    // assert: config.activeModels reflects the fixture data, not the default dir
}

If the omission is intentional (e.g. compile --models is meant to always use the default logs dir), consider documenting why in a comment, since it's a subtle behavioral difference from gh aw models --logs-dir.

@copilot please address this.

refreshObserved: true,
refreshCount: defaultModelsRefreshCount,
})
config.activeModels = buildActiveModelInventory(report)
Comment on lines +127 to +132
}

func unknownConfiguredModelMessages(data *workflow.WorkflowData, inventory *activeModelInventory) []string {
issues := findUnknownConfiguredModels(data, inventory)
messages := make([]string, 0, len(issues))
for _, issue := range issues {
messages = append(messages, issue.Message)
}
return messages
Comment on lines +135 to +141
}

func stringSlice(value any) []string {
raw, ok := value.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(raw))
for _, entry := range raw {
if text, ok := entry.(string); ok {
result = append(result, text)
}
}
return result
}
70 changes: 70 additions & 0 deletions pkg/cli/compile_model_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//go:build !integration

package cli

import (
"testing"

"github.com/github/gh-aw/pkg/workflow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildActiveModelInventoryDoesNothingWithoutObservedData(t *testing.T) {
t.Parallel()

assert.Nil(t, buildActiveModelInventory(modelsReport{
Catalog: []modelCatalogRow{{Provider: "openai", Model: "gpt-5.4"}},
Aliases: []modelAliasRow{{Alias: "large"}},
}))
}

func TestFindUnknownConfiguredModels(t *testing.T) {
t.Parallel()

inventory := buildActiveModelInventory(modelsReport{
Aliases: []modelAliasRow{{Alias: "large"}},
Observed: []observedModelRow{
{Provider: "github-copilot", Model: "gpt-5.4"},
{Provider: "anthropic", Model: "claude-sonnet-4.6"},
},
})
require.NotNil(t, inventory)

data := &workflow.WorkflowData{
ModelMappings: map[string][]string{"custom-alias": {"gpt-5.4"}},
ModelPolicyAllowed: []string{
"gpt-5.4",
"copilot/gpt-5.*",
"large",
"custom-alias",
"missing-policy-model",
},
ModelPolicyBlocked: []string{"anthropic/claude-sonnet-4.6"},
RawFrontmatter: map[string]any{
"engine": map[string]any{
"models": map[string]any{
"default": "large",
"supported": []any{"gpt-5.4?effort=high", "${{ inputs.model }}", "missing-engine-model"},
},
},
},
}

warnings := findUnknownConfiguredModels(data, inventory)
require.Len(t, warnings, 2)
assert.Equal(t, "unknown_model", warnings[0].Type)
assert.Contains(t, warnings[0].Message, "missing-engine-model")
assert.Contains(t, warnings[0].Message, "engine.models.supported")
assert.Contains(t, warnings[1].Message, "missing-policy-model")
assert.Contains(t, warnings[1].Message, "models.allowed")
}

func TestFindUnknownConfiguredModelsSkipsMissingInventory(t *testing.T) {
t.Parallel()

data := &workflow.WorkflowData{
ModelPolicyAllowed: []string{"unknown"},
}
assert.Empty(t, findUnknownConfiguredModels(data, nil))
}
Loading
Loading