Skip to content

Commit 2d884da

Browse files
Merge pull request #15 from actionforge/feature/steps-context
Add steps expression support
2 parents dd3d479 + 91a93bd commit 2d884da

61 files changed

Lines changed: 1905 additions & 277 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/workflow.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
- '*'
55
branches:
66
- main
7+
- "feature/docker-run-node"
78

89
pull_request:
910
branches:

core/context.go

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"github.com/google/uuid"
1212
)
1313

14+
const MAX_STEP_CACHE_ITEM_SIZE = 64 * 1024 // 64KB
15+
1416
type ContextVisit struct {
1517
Node NodeBaseInterface `json:"-"`
1618
NodeID string `json:"node_id"`
@@ -30,6 +32,82 @@ const (
3032
Ephemeral
3133
)
3234

35+
type StepCacheEntry struct {
36+
Conclusion string `json:"conclusion"`
37+
Outputs map[string]any `json:"outputs"`
38+
}
39+
40+
// HierarchicalMap is a memory-efficient map that chains to parent contexts.
41+
// Each context only stores its own entries; lookups traverse the parent chain.
42+
type HierarchicalMap[K comparable, V any] struct {
43+
data map[K]V
44+
parent *HierarchicalMap[K, V]
45+
}
46+
47+
// NewHierarchicalMap creates a new map, optionally chained to a parent.
48+
func NewHierarchicalMap[K comparable, V any](parent *HierarchicalMap[K, V]) *HierarchicalMap[K, V] {
49+
return &HierarchicalMap[K, V]{
50+
data: make(map[K]V),
51+
parent: parent,
52+
}
53+
}
54+
55+
// Get retrieves a value by key, traversing up the parent chain if not found locally.
56+
func (m *HierarchicalMap[K, V]) Get(key K) (V, bool) {
57+
if val, ok := m.data[key]; ok {
58+
return val, true
59+
}
60+
if m.parent != nil {
61+
return m.parent.Get(key)
62+
}
63+
var zero V
64+
return zero, false
65+
}
66+
67+
// GetLocal retrieves a value only from local data, not traversing parents.
68+
func (m *HierarchicalMap[K, V]) GetLocal(key K) (V, bool) {
69+
val, ok := m.data[key]
70+
return val, ok
71+
}
72+
73+
// Set stores a value in the current context only (does not affect parent).
74+
func (m *HierarchicalMap[K, V]) Set(key K, value V) {
75+
m.data[key] = value
76+
}
77+
78+
// All returns all entries merged from the entire chain (child entries override parent).
79+
func (m *HierarchicalMap[K, V]) All() map[K]V {
80+
result := make(map[K]V)
81+
m.collectAll(result)
82+
return result
83+
}
84+
85+
func (m *HierarchicalMap[K, V]) collectAll(result map[K]V) {
86+
if m.parent != nil {
87+
m.parent.collectAll(result)
88+
}
89+
maps.Copy(result, m.data)
90+
}
91+
92+
// StepCache is a type alias for the step output cache.
93+
type StepCache = HierarchicalMap[string, *StepCacheEntry]
94+
95+
// NewStepCache creates a new step cache, optionally chained to a parent.
96+
func NewStepCache(parent *StepCache) *StepCache {
97+
return NewHierarchicalMap(parent)
98+
}
99+
100+
// GetOrCreateStepEntry retrieves an existing local entry or creates a new one.
101+
// Only checks local cache to avoid races on shared parent entries.
102+
func GetOrCreateStepEntry(cache *StepCache, key string) *StepCacheEntry {
103+
if entry, ok := cache.GetLocal(key); ok {
104+
return entry
105+
}
106+
entry := &StepCacheEntry{Outputs: make(map[string]any)}
107+
cache.Set(key, entry)
108+
return entry
109+
}
110+
33111
// ExecutionState is a structure whose main purpose is to provide the correct output values
34112
// and environment variables requested by nodes that were executed in subsequent goroutines.
35113
//
@@ -85,6 +163,7 @@ type ExecutionState struct {
85163
OutputCacheLock *sync.RWMutex `json:"-"`
86164
DataOutputCache map[string]any `json:"dataOutputCache"`
87165
ExecutionOutputCache map[string]any `json:"executionOutputCache"`
166+
StepCache *StepCache `json:"stepCache"`
88167

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

161241
Visited: visited,
162242
DebugCallback: c.DebugCallback,
@@ -203,19 +283,44 @@ func (c *ExecutionState) GetDataFromOutputCache(nodeCacheId string, outputId str
203283
return nil, false
204284
}
205285

206-
func (c *ExecutionState) CacheDataOutput(nodeCacheId string, outputId string, value any, ct CacheType) {
286+
func (c *ExecutionState) CacheDataOutput(node NodeBaseInterface, outputId string, value any, outputType string, ct CacheType) {
207287
c.ContextStackLock.RLock()
208288
defer c.ContextStackLock.RUnlock()
209289

210290
c.OutputCacheLock.Lock()
211291
defer c.OutputCacheLock.Unlock()
212292

213-
cacheId := nodeCacheId + ":" + outputId
293+
cacheId := node.GetCacheId() + ":" + outputId
214294
if ct == Permanent {
215295
c.ExecutionOutputCache[cacheId] = value
216296
} else {
217297
c.DataOutputCache[cacheId] = value
218298
}
299+
300+
// Only cache primitive types under 64KB
301+
if isPrimitiveType(outputType) && isUnderCacheLimit(value) {
302+
stepEntry := GetOrCreateStepEntry(c.StepCache, node.GetId())
303+
stepEntry.Outputs[outputId] = value
304+
}
305+
}
306+
307+
// isPrimitiveType returns true if the output type is a primitive type that should be cached.
308+
func isPrimitiveType(outputType string) bool {
309+
switch outputType {
310+
case "string", "number", "bool":
311+
return true
312+
default:
313+
return false
314+
}
315+
}
316+
317+
// isUnderCacheLimit checks if a string value is under the cache size limit.
318+
// Non-string primitives always return true as they are small.
319+
func isUnderCacheLimit(value any) bool {
320+
if s, ok := value.(string); ok {
321+
return len(s) <= MAX_STEP_CACHE_ITEM_SIZE
322+
}
323+
return true
219324
}
220325

221326
func (c *ExecutionState) EmptyDataOutputCache() {

core/docker.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (d *DockerClient) PullImage(ctx context.Context, imageRef string) error {
100100
verbose := !IsTestE2eRunning()
101101

102102
if verbose {
103-
utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, imageRef)
103+
utils.LogOut.Infof("%sPulling image '%s'\n", utils.LogGhStartGroup, utils.SanitizeImageRef(imageRef))
104104
defer utils.LogOut.Infof(utils.LogGhEndGroup)
105105
}
106106

@@ -138,7 +138,7 @@ func (d *DockerClient) BuildImage(ctx context.Context, dockerfilePath, contextPa
138138
verbose := !IsTestE2eRunning()
139139

140140
if verbose {
141-
utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, tag, dockerfilePath)
141+
utils.LogOut.Infof("%sBuilding image '%s' from %s\n", utils.LogGhStartGroup, utils.SanitizeImageRef(tag), utils.SanitizeImageRef(dockerfilePath))
142142
defer utils.LogOut.Infof(utils.LogGhEndGroup)
143143
}
144144

core/executions.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ func (n *Executions) Execute(outputPort OutputId, ec *ExecutionState, err error)
3030

3131
dest, hasDest := n.GetExecutionTarget(outputPort)
3232

33+
// Set the step conclusion for the SOURCE node based on which output port is being executed.
34+
// This enables ${{ steps.X.conclusion }} syntax similar to GitHub Actions.
35+
// The conclusion is set BEFORE downstream nodes execute so they can read it.
36+
if hasDest && dest.SrcNode != nil {
37+
srcStepEntry := GetOrCreateStepEntry(ec.StepCache, dest.SrcNode.GetId())
38+
if err != nil {
39+
srcStepEntry.Conclusion = "failure"
40+
} else {
41+
srcStepEntry.Conclusion = "success"
42+
}
43+
}
44+
3345
// if this is the error path, and the error port is not connected
3446
// we return the error so it won't be silently ignored
3547
if err != nil {

core/github.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,9 +213,12 @@ func LoadGitHubContext(env map[string]string, inputs map[string]any, secrets map
213213
// Support for github.event.pull_request, github.event.commits, etc.
214214
eventData := make(map[string]any)
215215
if eventPath, ok := env["GITHUB_EVENT_PATH"]; ok && eventPath != "" {
216-
fileContent, err := os.ReadFile(eventPath)
216+
cleanPath, err := utils.ValidatePath(eventPath)
217217
if err == nil {
218-
_ = json.Unmarshal(fileContent, &eventData)
218+
fileContent, err := os.ReadFile(cleanPath)
219+
if err == nil {
220+
_ = json.Unmarshal(fileContent, &eventData)
221+
}
219222
}
220223
}
221224

@@ -439,9 +442,13 @@ func SetupGitHubActionsEnv(finalEnv map[string]string) error {
439442

440443
for _, filePath := range fileCommandFiles {
441444
if filePath != "" {
442-
f, err := os.Create(filePath)
445+
cleanPath, err := utils.ValidatePath(filePath)
446+
if err != nil {
447+
return CreateErr(nil, err, "invalid file command path %s", filePath)
448+
}
449+
f, err := os.Create(cleanPath)
443450
if err != nil {
444-
return CreateErr(nil, err, "failed to create file command file %s", filePath).
451+
return CreateErr(nil, err, "failed to create file command file %s", cleanPath).
445452
SetHint("Check that you have write permissions to the runner temp directory.")
446453
}
447454
f.Close()

core/github_evaluator.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"strconv"
1717
"strings"
1818

19+
"github.com/actionforge/actrun-cli/utils"
1920
"github.com/rhysd/actionlint"
2021
)
2122

@@ -318,9 +319,13 @@ func (e *Evaluator) hashFiles(patterns ...string) (string, error) {
318319
}
319320
var uniqueFiles []string
320321
for f := range fileSet {
321-
info, err := os.Stat(f)
322+
cleanPath, err := utils.ValidatePath(f)
323+
if err != nil {
324+
continue
325+
}
326+
info, err := os.Stat(cleanPath)
322327
if err == nil && info.Mode().IsRegular() {
323-
uniqueFiles = append(uniqueFiles, f)
328+
uniqueFiles = append(uniqueFiles, cleanPath)
324329
}
325330
}
326331
sort.Strings(uniqueFiles)
@@ -331,7 +336,11 @@ func (e *Evaluator) hashFiles(patterns ...string) (string, error) {
331336

332337
hasher := sha256.New()
333338
for _, filePath := range uniqueFiles {
334-
file, err := os.Open(filePath)
339+
cleanPath, err := utils.ValidatePath(filePath)
340+
if err != nil {
341+
continue
342+
}
343+
file, err := os.Open(cleanPath)
335344
if err != nil {
336345
continue
337346
}
@@ -356,7 +365,7 @@ func (e *Evaluator) resolveRootVar(name string) (any, error) {
356365
case "needs":
357366
return &GhNeedsProxy{GhNeeds: e.ctx.GhNeeds}, nil
358367
case "steps":
359-
return e.ctx.DataOutputCache, nil
368+
return e.ctx.StepCache, nil
360369
case "inputs":
361370
return &InputsProxy{ctx: e.ctx}, nil
362371
case "matrix":
@@ -475,6 +484,9 @@ func (e *Evaluator) evaluateArrayDeref(node *actionlint.ArrayDerefNode) (any, er
475484
receiverVal = v.Secrets
476485
case *InputsProxy:
477486
receiverVal = v.ctx.Inputs
487+
case *StepCache:
488+
// Only flatten for iteration (steps.*)
489+
return toSortedValues(v.All()), nil
478490
}
479491

480492
switch m := receiverVal.(type) {
@@ -514,6 +526,19 @@ func (e *Evaluator) evaluateObjectDeref(node *actionlint.ObjectDerefNode) (any,
514526
return val, nil
515527
}
516528

529+
case *StepCache:
530+
if entry, ok := v.Get(propName); ok {
531+
return entry, nil
532+
}
533+
534+
case *StepCacheEntry:
535+
switch propName {
536+
case "outputs":
537+
return v.Outputs, nil
538+
case "conclusion":
539+
return v.Conclusion, nil
540+
}
541+
517542
case map[string]any:
518543
return lookupCaseInsensitive(v, propName), nil
519544

0 commit comments

Comments
 (0)