Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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: %v\n", step.ActionName, err)
}
}
}

// 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: %v\n", step.ActionName, err)
Comment thread Fixed
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