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
4 changes: 2 additions & 2 deletions .github/workflows/graphs/test_env.act
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ nodes:
y: 450
- id: gh-actionforge-test-action-node-main-lemon-penguin-yellow
type: >-
github.com/actionforge/test-action-node@22db00979573158856d37b2a91c05deccde3b41b
github.com/actionforge/test-action-node@26491c0042d95182826dacd3c64d8254c69dadcb
position:
x: 1020
y: 510
Expand Down Expand Up @@ -49,7 +49,7 @@ nodes:
fi
- id: gh-actionforge-test-action-node-main-blueberry-monkey-plum
type: >-
github.com/actionforge/test-action-node@22db00979573158856d37b2a91c05deccde3b41b
github.com/actionforge/test-action-node@26491c0042d95182826dacd3c64d8254c69dadcb
position:
x: 1020
y: 990
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/graphs/test_input_output.act
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ nodes:
y: 1360
- id: gh-actionforge-test-action-node-main-lemon-penguin-yellow
type: >-
github.com/actionforge/test-action-node@22db00979573158856d37b2a91c05deccde3b41b
github.com/actionforge/test-action-node@26491c0042d95182826dacd3c64d8254c69dadcb
position:
x: 290
y: 1220
Expand Down Expand Up @@ -45,7 +45,7 @@ nodes:
env[0]: ''
- id: gh-actionforge-test-action-registry-main-cat-purple-watermelon
type: >-
github.com/actionforge/test-action-registry@6075e108fe3a7edd08d11178b02d96f5eef3e835
github.com/actionforge/test-action-registry@fd5c912c37d3e7bf2ab1d8f8662418032ed8f55e
position:
x: 1980
y: 710
Expand Down Expand Up @@ -84,7 +84,7 @@ nodes:
fi
- id: gh-actionforge-test-action-dockerfile-main-purple-pear-kangaroo
type: >-
github.com/actionforge/test-action-dockerfile@c3bd2754b38ec3500b786a3cca9279322d6ee5dd
github.com/actionforge/test-action-dockerfile@b534d25852e5eb0612450225b2d169bd47ecabe8
position:
x: 3880
y: 250
Expand Down
6 changes: 6 additions & 0 deletions core/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ type ExecutionState struct {
ExecutionOutputCache map[string]any `json:"executionOutputCache"`
StepCache *StepCache `json:"stepCache"`

PostSteps *PostStepQueue `json:"-"`
JobConclusion string `json:"jobConclusion"`

DebugCallback DebugCallback `json:"-"`
}

Expand Down Expand Up @@ -238,6 +241,9 @@ func (c *ExecutionState) PushNewExecutionState(parentNode NodeBaseInterface) *Ex
ExecutionOutputCache: make(map[string]any),
StepCache: NewStepCache(c.StepCache),

PostSteps: c.PostSteps,
JobConclusion: c.JobConclusion,

Visited: visited,
DebugCallback: c.DebugCallback,
}
Expand Down
197 changes: 197 additions & 0 deletions core/gh_post_steps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package core

import (
"fmt"
"os"
"strings"
"sync"

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

// PostStepRunner is implemented by node types that support post-step execution.
type PostStepRunner interface {
RunPost(c *ExecutionState, env map[string]string) error
}

// PostStep holds the information needed to execute a post step.
type PostStep struct {
ActionName string
NodeID string
PostIf string
Runner PostStepRunner
StateFilePath string
EnvSnapshot map[string]string
}

// PostStepQueue is a thread-safe queue for post steps.
type PostStepQueue struct {
mu sync.Mutex
steps []PostStep
}

// NewPostStepQueue creates a new empty PostStepQueue.
func NewPostStepQueue() *PostStepQueue {
return &PostStepQueue{}
}

// Register adds a post step to the queue.
func (q *PostStepQueue) Register(step PostStep) {
q.mu.Lock()
defer q.mu.Unlock()
q.steps = append(q.steps, step)
}

// Len returns the number of registered post steps.
func (q *PostStepQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.steps)
}

// DrainLIFO returns all post steps in LIFO order and clears the queue.
func (q *PostStepQueue) DrainLIFO() []PostStep {
q.mu.Lock()
defer q.mu.Unlock()

result := make([]PostStep, len(q.steps))
for i, step := range q.steps {
result[len(q.steps)-1-i] = step
}
q.steps = nil
return result
}

// executePostSteps runs post steps in order, logging errors but continuing.
func executePostSteps(c *ExecutionState, steps []PostStep) {
for _, step := range steps {
utils.LogOut.Infof("Running post step: %s (%s)\n", step.ActionName, step.NodeID)

if !evaluatePostIf(c, step) {
utils.LogOut.Infof("Post step skipped (post-if condition not met): %s\n", step.ActionName)
continue
}

env := step.EnvSnapshot
if step.StateFilePath != "" {
injectStateVars(env, step.StateFilePath)
}

if err := step.Runner.RunPost(c, env); err != nil {
utils.LogErr.Errorf("Post step failed: %s\n", step.ActionName)
}
}
}

// evaluatePostIf evaluates the post-if condition for a post step.
// Returns true if the step should run.
func evaluatePostIf(c *ExecutionState, step PostStep) bool {
condition := step.PostIf
if condition == "" {
// Default: always()
return true
}

// Wrap in ${{ }} if not already wrapped
if !strings.Contains(condition, "${{") {
condition = "${{ " + condition + " }}"
}

evaluator := NewEvaluator(c)
result, err := evaluator.Evaluate(condition)
if err != nil {
utils.LogErr.Errorf("Failed to evaluate post-if condition for %s\n", step.ActionName)
return false
}

return isTruthy(result)
}

// injectStateVars reads the GITHUB_STATE file and injects STATE_* env vars.
func injectStateVars(env map[string]string, stateFilePath string) {
if stateFilePath == "" {
return
}

stateVars, err := ParseKeyValueFile(stateFilePath)
if err != nil {
utils.LogErr.Errorf("Failed to read state file %s: %v\n", stateFilePath, err)
return
}

for key, value := range stateVars {
env[fmt.Sprintf("STATE_%s", key)] = value
}
}

// ParseKeyValueFile parses a GitHub Actions file command output file.
// Supports both NAME=VALUE and NAME<<DELIMITER heredoc styles.
func ParseKeyValueFile(filePath string) (map[string]string, error) {
cleanPath, err := utils.ValidatePath(filePath)
if err != nil {
return nil, err
}

b, err := os.ReadFile(cleanPath)
if err != nil {
return nil, err
}

return ParseKeyValueString(string(b))
}

// ParseKeyValueString parses a GitHub Actions key-value string.
// Supports both NAME=VALUE and NAME<<DELIMITER heredoc styles.
func ParseKeyValueString(input string) (map[string]string, error) {
results := make(map[string]string)
lines := strings.Split(input, "\n")

for i := 0; i < len(lines); i++ {
line := lines[i]
if line == "" {
continue
}

var key, value string
equalsIndex := strings.Index(line, "=")
heredocIndex := strings.Index(line, "<<")

// Normal style: NAME=VALUE
if equalsIndex >= 0 && (heredocIndex < 0 || equalsIndex < heredocIndex) {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 || parts[0] == "" {
return nil, CreateErr(nil, nil, "invalid format '%s'. Name must not be empty", line)
}
key, value = parts[0], parts[1]
} else if heredocIndex >= 0 && (equalsIndex < 0 || heredocIndex < equalsIndex) {
// Heredoc style: NAME<<EOF
parts := strings.SplitN(line, "<<", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, CreateErr(nil, nil, "invalid format '%s'. Name must not be empty", line)
}
key = parts[0]
delimiter := strings.TrimRight(parts[1], " \t\n\r")

var heredocValue strings.Builder
for i++; i < len(lines); i++ {
if strings.TrimRight(lines[i], " \t\n\r") == delimiter {
break
}
heredocValue.WriteString(lines[i])
if i < len(lines)-1 {
heredocValue.WriteString("\n")
}
}
if i >= len(lines) {
return nil, CreateErr(nil, nil, "invalid value. Matching delimiter not found '%s'", delimiter)
}
value = heredocValue.String()
} else {
return nil, CreateErr(nil, nil, "invalid format '%s'", line)
}

results[key] = value
}

return results, nil
}
6 changes: 3 additions & 3 deletions core/github_evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,11 @@ func (e *Evaluator) callFunction(name string, args []any) (any, error) {
case "always":
return true, nil
case "success":
return true, nil
return e.ctx.JobConclusion == "success", nil
case "failure":
return false, nil
return e.ctx.JobConclusion == "failure", nil
case "cancelled":
return false, nil
return e.ctx.JobConclusion == "cancelled", nil

case "fromjson":
if len(args) < 1 {
Expand Down
1 change: 1 addition & 0 deletions core/github_evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

func TestEvaluate_GitHubParity(t *testing.T) {
ctx := ExecutionState{
JobConclusion: "success",
GhNeeds: map[string]any{
"setup": map[string]any{
"outputs": map[string]any{
Expand Down
16 changes: 13 additions & 3 deletions core/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ func NewExecutionState(
DataOutputCache: make(map[string]any),
ExecutionOutputCache: make(map[string]any),
StepCache: NewStepCache(nil),

PostSteps: NewPostStepQueue(),
JobConclusion: "success",
}
}

Expand Down Expand Up @@ -481,11 +484,11 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R
}
rs, srvErr := server.StartServer(server.Config{StorageDir: storageDir})
if srvErr != nil {
return CreateErr(nil, srvErr, "failed to start local GitHub Actions server")
return CreateErr(nil, srvErr, "failed to start GitHub Actions mock server")
}
defer rs.Stop()
rs.InjectEnv(finalEnv)
utils.LogOut.Infof("local GitHub Actions server started at %s\n", rs.URL)
utils.LogOut.Infof("GitHub Actions mock server started at %s\n", rs.URL)
}

// Use the updated GITHUB_WORKSPACE as the working directory.
Expand Down Expand Up @@ -554,7 +557,14 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R
c.PushNodeVisit(entryNode, true)
}

return entry.ExecuteEntry(c, nil, opts.Args)
mainErr := entry.ExecuteEntry(c, nil, opts.Args)
if mainErr != nil {
c.JobConclusion = "failure"
}
if c.PostSteps.Len() > 0 {
executePostSteps(c, c.PostSteps.DrainLIFO())
}
return mainErr
}

func LoadGraph(graphYaml map[string]any, parent NodeBaseInterface, parentId string, validate bool, opts RunOpts) (ActionGraph, []error) {
Expand Down
2 changes: 1 addition & 1 deletion github/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
"path/filepath"
)

// Config holds parameters for starting a local GitHub Actions server.
// Config holds parameters for starting a GitHub Actions mock server.
type Config struct {
StorageDir string // Directory for blob storage (created if missing)
OIDCIssuer string // OIDC token issuer (defaults to GitHub's)
Expand Down
Loading
Loading