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
1 change: 1 addition & 0 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
- '*'
branches:
- main
- "feature/docker-run-node"

pull_request:
branches:
Expand Down
109 changes: 107 additions & 2 deletions core/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/google/uuid"
)

const MAX_STEP_CACHE_ITEM_SIZE = 64 * 1024 // 64KB

type ContextVisit struct {
Node NodeBaseInterface `json:"-"`
NodeID string `json:"node_id"`
Expand All @@ -30,6 +32,82 @@ const (
Ephemeral
)

type StepCacheEntry struct {
Conclusion string `json:"conclusion"`
Outputs map[string]any `json:"outputs"`
}

// HierarchicalMap is a memory-efficient map that chains to parent contexts.
// Each context only stores its own entries; lookups traverse the parent chain.
type HierarchicalMap[K comparable, V any] struct {
data map[K]V
parent *HierarchicalMap[K, V]
}

// NewHierarchicalMap creates a new map, optionally chained to a parent.
func NewHierarchicalMap[K comparable, V any](parent *HierarchicalMap[K, V]) *HierarchicalMap[K, V] {
return &HierarchicalMap[K, V]{
data: make(map[K]V),
parent: parent,
}
}

// Get retrieves a value by key, traversing up the parent chain if not found locally.
func (m *HierarchicalMap[K, V]) Get(key K) (V, bool) {
if val, ok := m.data[key]; ok {
return val, true
}
if m.parent != nil {
return m.parent.Get(key)
}
var zero V
return zero, false
}

// GetLocal retrieves a value only from local data, not traversing parents.
func (m *HierarchicalMap[K, V]) GetLocal(key K) (V, bool) {
val, ok := m.data[key]
return val, ok
}

// Set stores a value in the current context only (does not affect parent).
func (m *HierarchicalMap[K, V]) Set(key K, value V) {
m.data[key] = value
}

// All returns all entries merged from the entire chain (child entries override parent).
func (m *HierarchicalMap[K, V]) All() map[K]V {
result := make(map[K]V)
m.collectAll(result)
return result
}

func (m *HierarchicalMap[K, V]) collectAll(result map[K]V) {
if m.parent != nil {
m.parent.collectAll(result)
}
maps.Copy(result, m.data)
}

// StepCache is a type alias for the step output cache.
type StepCache = HierarchicalMap[string, *StepCacheEntry]

// NewStepCache creates a new step cache, optionally chained to a parent.
func NewStepCache(parent *StepCache) *StepCache {
return NewHierarchicalMap(parent)
}

// GetOrCreateStepEntry retrieves an existing local entry or creates a new one.
// Only checks local cache to avoid races on shared parent entries.
func GetOrCreateStepEntry(cache *StepCache, key string) *StepCacheEntry {
if entry, ok := cache.GetLocal(key); ok {
return entry
}
entry := &StepCacheEntry{Outputs: make(map[string]any)}
cache.Set(key, entry)
return entry
}

// ExecutionState is a structure whose main purpose is to provide the correct output values
// and environment variables requested by nodes that were executed in subsequent goroutines.
//
Expand Down Expand Up @@ -85,6 +163,7 @@ type ExecutionState struct {
OutputCacheLock *sync.RWMutex `json:"-"`
DataOutputCache map[string]any `json:"dataOutputCache"`
ExecutionOutputCache map[string]any `json:"executionOutputCache"`
StepCache *StepCache `json:"stepCache"`

DebugCallback DebugCallback `json:"-"`
}
Expand Down Expand Up @@ -157,6 +236,7 @@ func (c *ExecutionState) PushNewExecutionState(parentNode NodeBaseInterface) *Ex
OutputCacheLock: &sync.RWMutex{},
DataOutputCache: make(map[string]any),
ExecutionOutputCache: make(map[string]any),
StepCache: NewStepCache(c.StepCache),

Visited: visited,
DebugCallback: c.DebugCallback,
Expand Down Expand Up @@ -203,19 +283,44 @@ func (c *ExecutionState) GetDataFromOutputCache(nodeCacheId string, outputId str
return nil, false
}

func (c *ExecutionState) CacheDataOutput(nodeCacheId string, outputId string, value any, ct CacheType) {
func (c *ExecutionState) CacheDataOutput(node NodeBaseInterface, outputId string, value any, outputType string, ct CacheType) {
c.ContextStackLock.RLock()
defer c.ContextStackLock.RUnlock()

c.OutputCacheLock.Lock()
defer c.OutputCacheLock.Unlock()

cacheId := nodeCacheId + ":" + outputId
cacheId := node.GetCacheId() + ":" + outputId
if ct == Permanent {
c.ExecutionOutputCache[cacheId] = value
} else {
c.DataOutputCache[cacheId] = value
}

// Only cache primitive types under 64KB
if isPrimitiveType(outputType) && isUnderCacheLimit(value) {
stepEntry := GetOrCreateStepEntry(c.StepCache, node.GetId())
stepEntry.Outputs[outputId] = value
}
}

// isPrimitiveType returns true if the output type is a primitive type that should be cached.
func isPrimitiveType(outputType string) bool {
switch outputType {
case "string", "number", "bool":
return true
default:
return false
}
}

// isUnderCacheLimit checks if a string value is under the cache size limit.
// Non-string primitives always return true as they are small.
func isUnderCacheLimit(value any) bool {
if s, ok := value.(string); ok {
return len(s) <= MAX_STEP_CACHE_ITEM_SIZE
}
return true
}

func (c *ExecutionState) EmptyDataOutputCache() {
Expand Down
4 changes: 2 additions & 2 deletions core/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
verbose := !IsTestE2eRunning()

if verbose {
utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, imageRef)
utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, utils.SanitizeImageRef(imageRef))

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

Sensitive data returned by an access to AccessPassword
flows to a logging call.
Sensitive data returned by an access to PrivateKeyPassword
flows to a logging call.
Sensitive data returned by an access to Password
flows to a logging call.
Sensitive data returned by an access to secretKey
flows to a logging call.
defer utils.LogOut.Infof(utils.LogGhEndGroup)
}

Expand Down Expand Up @@ -138,7 +138,7 @@
verbose := !IsTestE2eRunning()

if verbose {
utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, tag, dockerfilePath)
utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, utils.SanitizeImageRef(tag), utils.SanitizeImageRef(dockerfilePath))

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

Sensitive data returned by an access to AccessPassword
flows to a logging call.
Sensitive data returned by an access to PrivateKeyPassword
flows to a logging call.
Sensitive data returned by an access to Password
flows to a logging call.
Sensitive data returned by an access to secretKey
flows to a logging call.
defer utils.LogOut.Infof(utils.LogGhEndGroup)
}

Expand Down
12 changes: 12 additions & 0 deletions core/executions.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ func (n *Executions) Execute(outputPort OutputId, ec *ExecutionState, err error)

dest, hasDest := n.GetExecutionTarget(outputPort)

// Set the step conclusion for the SOURCE node based on which output port is being executed.
// This enables ${{ steps.X.conclusion }} syntax similar to GitHub Actions.
// The conclusion is set BEFORE downstream nodes execute so they can read it.
if hasDest && dest.SrcNode != nil {
srcStepEntry := GetOrCreateStepEntry(ec.StepCache, dest.SrcNode.GetId())
if err != nil {
srcStepEntry.Conclusion = "failure"
} else {
srcStepEntry.Conclusion = "success"
}
}

// if this is the error path, and the error port is not connected
// we return the error so it won't be silently ignored
if err != nil {
Expand Down
15 changes: 11 additions & 4 deletions core/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,12 @@
// Support for github.event.pull_request, github.event.commits, etc.
eventData := make(map[string]any)
if eventPath, ok := env["GITHUB_EVENT_PATH"]; ok && eventPath != "" {
fileContent, err := os.ReadFile(eventPath)
cleanPath, err := utils.ValidatePath(eventPath)
if err == nil {
_ = json.Unmarshal(fileContent, &eventData)
fileContent, err := os.ReadFile(cleanPath)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err == nil {
_ = json.Unmarshal(fileContent, &eventData)
}
}
}

Expand Down Expand Up @@ -439,9 +442,13 @@

for _, filePath := range fileCommandFiles {
if filePath != "" {
f, err := os.Create(filePath)
cleanPath, err := utils.ValidatePath(filePath)
if err != nil {
return CreateErr(nil, err, "invalid file command path %s", filePath)
}
f, err := os.Create(cleanPath)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err != nil {
return CreateErr(nil, err, "failed to create file command file %s", filePath).
return CreateErr(nil, err, "failed to create file command file %s", cleanPath).
SetHint("Check that you have write permissions to the runner temp directory.")
}
f.Close()
Expand Down
33 changes: 29 additions & 4 deletions core/github_evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"strconv"
"strings"

"github.com/actionforge/actrun-cli/utils"
"github.com/rhysd/actionlint"
)

Expand Down Expand Up @@ -318,9 +319,13 @@
}
var uniqueFiles []string
for f := range fileSet {
info, err := os.Stat(f)
cleanPath, err := utils.ValidatePath(f)
if err != nil {
continue
}
info, err := os.Stat(cleanPath)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err == nil && info.Mode().IsRegular() {
uniqueFiles = append(uniqueFiles, f)
uniqueFiles = append(uniqueFiles, cleanPath)
}
}
sort.Strings(uniqueFiles)
Expand All @@ -331,7 +336,11 @@

hasher := sha256.New()
for _, filePath := range uniqueFiles {
file, err := os.Open(filePath)
cleanPath, err := utils.ValidatePath(filePath)
if err != nil {
continue
}
file, err := os.Open(cleanPath)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
if err != nil {
continue
}
Expand All @@ -356,7 +365,7 @@
case "needs":
return &GhNeedsProxy{GhNeeds: e.ctx.GhNeeds}, nil
case "steps":
return e.ctx.DataOutputCache, nil
return e.ctx.StepCache, nil
case "inputs":
return &InputsProxy{ctx: e.ctx}, nil
case "matrix":
Expand Down Expand Up @@ -475,6 +484,9 @@
receiverVal = v.Secrets
case *InputsProxy:
receiverVal = v.ctx.Inputs
case *StepCache:
// Only flatten for iteration (steps.*)
return toSortedValues(v.All()), nil
}

switch m := receiverVal.(type) {
Expand Down Expand Up @@ -514,6 +526,19 @@
return val, nil
}

case *StepCache:
if entry, ok := v.Get(propName); ok {
return entry, nil
}

case *StepCacheEntry:
switch propName {
case "outputs":
return v.Outputs, nil
case "conclusion":
return v.Conclusion, nil
}

case map[string]any:
return lookupCaseInsensitive(v, propName), nil

Expand Down
Loading
Loading