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
20 changes: 19 additions & 1 deletion .github/aw/actions-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@
"actions-ecosystem/action-add-labels@v1": {
"repo": "actions-ecosystem/action-add-labels",
"version": "v1",
"sha": "18f1af5e3544586314bbe15c0273249c770b2daf"
"sha": "18f1af5e3544586314bbe15c0273249c770b2daf",
"inputs": {
"github-token": {
"description": "The GitHub token for authentication.",
"default": "${{ github.token }}"
},
"labels": {
"description": "The labels' name to be added. Must be separated with line breaks if there're multiple labels.",
"required": true
},
"number": {
"description": "The number of the issue or pull request."
},
"repo": {
"description": "The owner and name of the repository. Defaults to the current repository.",
"default": "${{ github.repository }}"
}
},
"action_description": "A GitHub Action to add GitHub labels to pull requests and issues"
},
"actions/ai-inference@v2.0.7": {
"repo": "actions/ai-inference",
Expand Down
16 changes: 12 additions & 4 deletions .github/workflows/smoke-codex.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7871,6 +7871,32 @@
"type": "string"
},
"examples": [{ "GITHUB_TOKEN": "${{ github.token }}" }]
},
"inputs": {
"type": "object",
"description": "Explicit input definitions for the action, overriding the auto-fetched action.yml schema. Use this to ensure deterministic compilation without requiring network access at compile time.",
"patternProperties": {
"^[a-zA-Z_][a-zA-Z0-9_-]*$": {
"type": "object",
"description": "Definition for a single action input.",
"properties": {
"description": {
"type": "string",
"description": "Description of the input."
},
"required": {
"type": "boolean",
"description": "Whether the input is required."
},
"default": {
"type": "string",
"description": "Default value for the input."
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"required": ["uses"],
Expand Down
105 changes: 98 additions & 7 deletions pkg/workflow/action_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ const (

// ActionCacheEntry represents a cached action pin resolution.
type ActionCacheEntry struct {
Repo string `json:"repo"`
Version string `json:"version"`
SHA string `json:"sha"`
Repo string `json:"repo"`
Version string `json:"version"`
SHA string `json:"sha"`
Inputs map[string]*ActionYAMLInput `json:"inputs,omitempty"` // cached inputs from action.yml
ActionDescription string `json:"action_description,omitempty"` // cached description from action.yml
}

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

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

ActionCacheEntry.Inputs is persisted to actions-lock.json using encoding/json, but ActionYAMLInput currently has no json struct tags. This will cause newly-written cache files to emit fields like "Description"/"Required"/"Default" (capitalized) rather than the existing lowercase keys ("description", "required", "default") used in the repo JSON. Consider adding json tags to ActionYAMLInput (or a dedicated cache DTO) to keep the on-disk format stable and deterministic.

Suggested change
// actionInputDTO is a dedicated cache representation for ActionYAMLInput
// that preserves the existing lowercase JSON keys used on disk.
type actionInputDTO struct {
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
Default string `json:"default,omitempty"`
}
// MarshalJSON customizes JSON encoding for ActionCacheEntry to ensure that
// cached inputs are written using lowercase keys ("description",
// "required", "default") to keep the on-disk format stable.
func (e ActionCacheEntry) MarshalJSON() ([]byte, error) {
// Build a DTO for serialization that mirrors the existing cache layout.
type cacheEntryDTO struct {
Repo string `json:"repo"`
Version string `json:"version"`
SHA string `json:"sha"`
Inputs map[string]*actionInputDTO `json:"inputs,omitempty"`
}
dto := cacheEntryDTO{
Repo: e.Repo,
Version: e.Version,
SHA: e.SHA,
}
if len(e.Inputs) > 0 {
dto.Inputs = make(map[string]*actionInputDTO, len(e.Inputs))
for name, input := range e.Inputs {
if input == nil {
continue
}
dto.Inputs[name] = &actionInputDTO{
Description: input.Description,
Required: input.Required,
Default: input.Default,
}
}
}
return json.Marshal(dto)
}

Copilot uses AI. Check for mistakes.
// ActionCache manages cached action pin resolutions.
Expand Down Expand Up @@ -189,7 +191,9 @@ func (c *ActionCache) FindEntryBySHA(repo, sha string) (ActionCacheEntry, bool)
return ActionCacheEntry{}, false
}

// Set stores a new cache entry
// Set stores a new cache entry, preserving any already-cached inputs when the SHA
// is unchanged. If the SHA changes (e.g. a moving tag points to a new commit),
// cached inputs are cleared to stay consistent with the newly-pinned commit.
func (c *ActionCache) Set(repo, version, sha string) {
key := formatActionCacheKey(repo, version)

Expand All @@ -208,14 +212,101 @@ func (c *ActionCache) Set(repo, version, sha string) {
}

actionCacheLog.Printf("Setting cache entry: key=%s, sha=%s", key, sha)

// Preserve previously-cached inputs only when the SHA is unchanged. If the SHA
// changes (e.g. for a moving tag that now points to a new commit), drop any
// existing inputs so they stay consistent with the pinned commit.
existing := c.Entries[key]
var inputs map[string]*ActionYAMLInput
var description string
if existing.SHA == sha {
inputs = existing.Inputs
description = existing.ActionDescription
} else if existing.SHA != "" {
// Log when an existing entry's SHA is being changed (covers both the case
// where cached inputs exist and where they don't, for consistent observability).
actionCacheLog.Printf("Clearing cached inputs for key=%s due to SHA change (%s -> %s)", key, existing.SHA, sha)
}
c.Entries[key] = ActionCacheEntry{
Repo: repo,
Version: version,
SHA: sha,
Repo: repo,
Version: version,
SHA: sha,
Inputs: inputs,
ActionDescription: description,
}
c.dirty = true // Mark cache as modified
}

// GetInputs retrieves the cached action inputs for the given repo and version.
// Returns the inputs map and true if cached inputs exist, otherwise nil and false.
func (c *ActionCache) GetInputs(repo, version string) (map[string]*ActionYAMLInput, bool) {
key := formatActionCacheKey(repo, version)
entry, exists := c.Entries[key]
if !exists || entry.Inputs == nil {
actionCacheLog.Printf("No cached inputs for key=%s", key)
return nil, false
}
actionCacheLog.Printf("Cache hit for inputs: key=%s, inputs=%d", key, len(entry.Inputs))
return entry.Inputs, true
}

// SetInputs stores the action inputs in the cache entry for the given repo and version.
// If no cache entry exists for the key, a new entry is created with an empty SHA so that
// inputs fetched from the network are persisted even before the SHA is resolved.
func (c *ActionCache) SetInputs(repo, version string, inputs map[string]*ActionYAMLInput) {
key := formatActionCacheKey(repo, version)
entry, exists := c.Entries[key]
if !exists {
actionCacheLog.Printf("No cache entry for key=%s, creating new entry to store inputs", key)
entry = ActionCacheEntry{
Repo: repo,
Version: version,
}
}
entry.Inputs = inputs
c.Entries[key] = entry
c.dirty = true
actionCacheLog.Printf("Cached inputs for key=%s, inputs=%d", key, len(inputs))
}

// GetActionDescription retrieves the cached action description for the given repo and version.
// Returns the description and true if a non-empty description is cached, otherwise "" and false.
func (c *ActionCache) GetActionDescription(repo, version string) (string, bool) {
key := formatActionCacheKey(repo, version)
entry, exists := c.Entries[key]
if !exists || entry.ActionDescription == "" {
return "", false
}
return entry.ActionDescription, true
}

// SetActionDescription stores the action description in the cache entry for the given repo and version.
// If no cache entry exists for the key, a new entry is created.
// Empty descriptions are not stored; actions without a description string are treated the same as
// actions whose description has not yet been fetched, so we avoid caching an empty string that
// would prevent a later fetch from populating the field.
func (c *ActionCache) SetActionDescription(repo, version, description string) {
if description == "" {
// Skip persisting empty descriptions; callers that want to distinguish
// "no description fetched" from "action has no description" should use
// a sentinel value. For our use case (action.yml display text), omitting
// empty values is intentional to keep the cache file tidy.
return
}
key := formatActionCacheKey(repo, version)
entry, exists := c.Entries[key]
if !exists {
entry = ActionCacheEntry{
Repo: repo,
Version: version,
}
}
entry.ActionDescription = description
c.Entries[key] = entry
c.dirty = true
actionCacheLog.Printf("Cached description for key=%s", key)
}

// GetCachePath returns the path to the cache file
func (c *ActionCache) GetCachePath() string {
return c.path
Expand Down
67 changes: 67 additions & 0 deletions pkg/workflow/action_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,3 +545,70 @@ func TestActionCacheFindEntryBySHA(t *testing.T) {
t.Error("Expected not to find entry for different repo")
}
}

// TestActionCacheInputs tests caching and retrieving action inputs
func TestActionCacheInputs(t *testing.T) {
tmpDir := testutil.TempDir(t, "test-*")
cache := NewActionCache(tmpDir)
cache.Set("owner/repo", "v1", "abc123sha456789012345678901234567890123")

// Initially no inputs cached
inputs, ok := cache.GetInputs("owner/repo", "v1")
if ok {
t.Error("Expected no cached inputs, got some")
}
if inputs != nil {
t.Error("Expected nil inputs, got non-nil")
}

// Store inputs
toCache := map[string]*ActionYAMLInput{
"labels": {Description: "Labels to add.", Required: true},
"number": {Description: "PR number."},
}
cache.SetInputs("owner/repo", "v1", toCache)

// Retrieve inputs
inputs, ok = cache.GetInputs("owner/repo", "v1")
if !ok {
t.Fatal("Expected cached inputs to exist")
}
if len(inputs) != 2 {
t.Errorf("Expected 2 inputs, got %d", len(inputs))
}
if inputs["labels"] == nil || !inputs["labels"].Required {
t.Error("Expected 'labels' input to be required")
}

// Set with the SAME SHA - inputs must be preserved
cache.Set("owner/repo", "v1", "abc123sha456789012345678901234567890123")
inputs, ok = cache.GetInputs("owner/repo", "v1")
if !ok {
t.Error("Expected inputs to survive Set() with same SHA")
}
if len(inputs) != 2 {
t.Errorf("Expected inputs to be preserved after Set() with same SHA, got %d", len(inputs))
}

// Set with a NEW SHA - inputs must be cleared (stale inputs no longer match pinned commit)
cache.Set("owner/repo", "v1", "newsha456789012345678901234567890123456")
inputs, ok = cache.GetInputs("owner/repo", "v1")
if ok {
t.Error("Expected inputs to be cleared after Set() with new SHA")
}
if inputs != nil {
t.Error("Expected nil inputs after SHA change, got non-nil")
}

// SetInputs on a missing key now creates a new entry
cache.SetInputs("owner/repo", "v99", map[string]*ActionYAMLInput{
"x": {Description: "x"},
})
inputs, ok = cache.GetInputs("owner/repo", "v99")
if !ok {
t.Error("Expected SetInputs on missing key to create entry")
}
if len(inputs) != 1 || inputs["x"] == nil {
t.Error("Expected created entry to have the given inputs")
}
}
7 changes: 4 additions & 3 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ func formatActionCacheKey(repo, version string) string {

// ActionPin represents a pinned GitHub Action with its commit SHA
type ActionPin struct {
Repo string `json:"repo"` // e.g., "actions/checkout"
Version string `json:"version"` // e.g., "v5" - the golden/default version
SHA string `json:"sha"` // Full commit SHA for the pinned version
Repo string `json:"repo"` // e.g., "actions/checkout"
Version string `json:"version"` // e.g., "v5" - the golden/default version
SHA string `json:"sha"` // Full commit SHA for the pinned version
Inputs map[string]*ActionYAMLInput `json:"inputs,omitempty"` // optional cached inputs (not used for SHA pinning)
}

// ActionPinsData represents the structure of the embedded JSON file
Expand Down
20 changes: 19 additions & 1 deletion pkg/workflow/data/action_pins.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,25 @@
"actions-ecosystem/action-add-labels@v1": {
"repo": "actions-ecosystem/action-add-labels",
"version": "v1",
"sha": "18f1af5e3544586314bbe15c0273249c770b2daf"
"sha": "18f1af5e3544586314bbe15c0273249c770b2daf",
"inputs": {
"github-token": {
"description": "The GitHub token for authentication.",
"default": "${{ github.token }}"
},
"labels": {
"description": "The labels' name to be added. Must be separated with line breaks if there're multiple labels.",
"required": true
},
"number": {
"description": "The number of the issue or pull request."
},
"repo": {
"description": "The owner and name of the repository. Defaults to the current repository.",
"default": "${{ github.repository }}"
}
},
"action_description": "A GitHub Action to add GitHub labels to pull requests and issues"
},
"actions/ai-inference@v2.0.7": {
"repo": "actions/ai-inference",
Expand Down
Loading
Loading