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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ golangci-lint run
- `Parse` validates the whole step graph up front (empty or ambiguous steps, undefined references,
cycles through `pre`/`steps`/`post`) and reports every problem; any invalid step blocks all runs.
- Command strings and env values are templated through `vars.Vars.Process()`; unknown vars are left as-is.
- `cmd/` builds `confile.RunOptions`; every child receives the same vars, project envs, and flags.
Steps hold only configuration; execution uses the caller's context and does not read Viper globals.
- Child process env is step envs, then project envs, then the inherited process environment.
- Root completion parses `forge.yaml` to suggest step names with their `help` text.
- `cmd.Version` is injected at build time via `-ldflags`; empty means `dev-local`.
Expand Down
12 changes: 11 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,18 @@ func rootFunc(cmd *cobra.Command, args []string) error {
return fmt.Errorf("resolving template variables: %w", err)
}

options := confile.RunOptions{
Steps: conf.Steps,
ProjectEnvs: conf.Envs,
Vars: conf.Vars,
Scriber: scrb,
SkipPre: viper.GetBool("solo") || viper.GetBool("no-pre"),
SkipPost: viper.GetBool("solo") || viper.GetBool("no-post"),
Verbose: verbose,
}

for _, s := range args {
err := conf.Steps[s].Execute(s, conf.Steps, conf.Envs, conf.Vars, scrb)
err := conf.Steps[s].Execute(cmd.Context(), s, options)
if err != nil {
return fmt.Errorf("executing step %v: %w", s, err)
}
Expand Down
71 changes: 35 additions & 36 deletions confile/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ package confile

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"

"github.com/gomicro/forge/vars"

"github.com/gomicro/scribe"
"github.com/spf13/viper"
)

// Step represents details of single step to be executed by the cli.
Expand All @@ -20,23 +21,26 @@ type Step struct {
Post []string `yaml:"post,omitempty"`
Pre []string `yaml:"pre,omitempty"`
Steps []string `yaml:"steps,omitempty"`

projectEnvs map[string]string
vars *vars.Vars
}

// Execute runs the command that is specified for the step. It returns the output
// of the command and any errors it encounters.
func (s *Step) Execute(name string, allSteps map[string]*Step, projectEnvs map[string]string, vars *vars.Vars, scrb scribe.Scriber) error {
skipPre := viper.GetBool("solo") || viper.GetBool("no-pre")
skipPost := viper.GetBool("solo") || viper.GetBool("no-post")
// RunOptions carries shared execution settings through the entire step graph.
type RunOptions struct {
Steps map[string]*Step
ProjectEnvs map[string]string
Vars *vars.Vars
Scriber scribe.Scriber
SkipPre bool
SkipPost bool
Verbose bool
}

s.projectEnvs = projectEnvs
s.vars = vars
// Execute runs the step and its dependencies using the supplied settings.
func (s *Step) Execute(ctx context.Context, name string, options RunOptions) error {
scrb := options.Scriber

if len(s.Pre) > 0 && !skipPre {
if len(s.Pre) > 0 && !options.SkipPre {
scrb.BeginDescribe(name + ": pre")
err := s.executeSteps(s.Pre, allSteps, scrb)
err := s.executeSteps(ctx, s.Pre, options)
scrb.EndDescribe()
if err != nil {
return fmt.Errorf("step: execute pre: %w", err)
Expand All @@ -45,30 +49,30 @@ func (s *Step) Execute(name string, allSteps map[string]*Step, projectEnvs map[s

if len(s.Steps) > 0 {
scrb.BeginDescribe(name)
err := s.executeSteps(s.Steps, allSteps, scrb)
err := s.executeSteps(ctx, s.Steps, options)
scrb.EndDescribe()
if err != nil {
return fmt.Errorf("step: execute steps: %w", err)
}
} else if len(s.Cmds) > 0 {
scrb.BeginDescribe(name)
err := s.executeCmds(scrb)
err := s.executeCmds(ctx, options)
scrb.EndDescribe()
if err != nil {
return fmt.Errorf("step: execute cmds: %w", err)
}
} else {
scrb.BeginDescribe(name)
err := s.executeCmd(scrb)
err := executeCmd(ctx, s.Cmd, s.Envs, options)
scrb.EndDescribe()
if err != nil {
return fmt.Errorf("step: execute cmd: %w", err)
}
}

if len(s.Post) > 0 && !skipPost {
if len(s.Post) > 0 && !options.SkipPost {
scrb.BeginDescribe(name + ": post")
err := s.executeSteps(s.Post, allSteps, scrb)
err := s.executeSteps(ctx, s.Post, options)
scrb.EndDescribe()
if err != nil {
return fmt.Errorf("step: execute post: %w", err)
Expand All @@ -78,16 +82,9 @@ func (s *Step) Execute(name string, allSteps map[string]*Step, projectEnvs map[s
return nil
}

func (s *Step) executeCmd(scrb scribe.Scriber) error {
cmdString := s.vars.Process(s.Cmd)
return executeCmd(cmdString, s.Envs, s.projectEnvs, s.vars, scrb)
}

func (s *Step) executeCmds(scrb scribe.Scriber) error {
func (s *Step) executeCmds(ctx context.Context, options RunOptions) error {
for _, c := range s.Cmds {
cmdString := s.vars.Process(c)

err := executeCmd(cmdString, s.Envs, s.projectEnvs, s.vars, scrb)
err := executeCmd(ctx, c, s.Envs, options)
if err != nil {
return fmt.Errorf("cmds: cmd exec: %w", err)
}
Expand All @@ -96,32 +93,34 @@ func (s *Step) executeCmds(scrb scribe.Scriber) error {
return nil
}

func (s *Step) executeSteps(execList []string, allSteps map[string]*Step, scrb scribe.Scriber) error {
func (s *Step) executeSteps(ctx context.Context, execList []string, options RunOptions) error {
for _, stepName := range execList {
step, ok := allSteps[stepName]
step, ok := options.Steps[stepName]
if !ok {
return fmt.Errorf("step does not exist: %v", stepName)
}

err := step.Execute(stepName, allSteps, step.projectEnvs, step.vars, scrb)
err := step.Execute(ctx, stepName, options)
if err != nil {
return err
return fmt.Errorf("executeSteps: executing step %s: %w", stepName, err)
}
}

return nil
}

func executeCmd(cmdString string, stepEnvs, projectEnvs map[string]string, vars *vars.Vars, scrb scribe.Scriber) error {
func executeCmd(ctx context.Context, command string, stepEnvs map[string]string, options RunOptions) error {
cmdString := options.Vars.Process(command)
scrb := options.Scriber
scrb.Print(fmt.Sprintf("$ %s", cmdString))

cmd := exec.Command("bash", "-c", cmdString)
cmd := exec.CommandContext(ctx, "bash", "-c", cmdString)

cmd.Env = toSlice(stepEnvs)
cmd.Env = append(cmd.Env, toSlice(projectEnvs)...)
cmd.Env = append(cmd.Env, toSlice(options.ProjectEnvs)...)

for i := range cmd.Env {
cmd.Env[i] = vars.Process(cmd.Env[i])
cmd.Env[i] = options.Vars.Process(cmd.Env[i])
}

cmd.Env = append(cmd.Env, os.Environ()...)
Expand All @@ -142,7 +141,7 @@ func executeCmd(cmdString string, stepEnvs, projectEnvs map[string]string, vars
}

if stderr.Len() > 0 {
if viper.GetBool("verbose") {
if options.Verbose {
scrb.BeginDescribe("\033[1;31mstderr\033[0m")
scrb.PrintLines(&stderr)
scrb.EndDescribe()
Expand Down
136 changes: 136 additions & 0 deletions confile/step_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package confile

import (
"bytes"
"context"
"errors"
"os/exec"
"regexp"
"testing"

"github.com/gomicro/forge/vars"

"github.com/gomicro/scribe"
"github.com/stretchr/testify/assert"
)

func testScriber(t *testing.T, output *bytes.Buffer) scribe.Scriber {
t.Helper()

scrb, err := scribe.NewScribe(output, &scribe.Theme{
Describe: func(s string) string { return s },
Print: func(s string) string { return s },
Error: func(err error) string { return err.Error() },
})
if err != nil {
t.Fatalf("creating test output: %v", err)
}

return scrb
}

func TestStepExecuteChildContext(t *testing.T) {
t.Parallel()

steps := map[string]*Step{
"root": {Pre: []string{"pre"}, Steps: []string{"group"}, Post: []string{"post"}},
"pre": {Cmd: `printf 'pre:%s:%s\n' '{{.Project}}' "$FORGE_TEST_PROJECT_VALUE"`},
"group": {Steps: []string{"child"}},
"child": {Cmds: []string{
`printf 'child:%s:%s\n' '{{.Project}}' "$FORGE_TEST_PROJECT_VALUE"`,
`printf 'again:%s\n' '{{.Project}}'`,
}},
"post": {Cmd: `printf 'post:%s:%s\n' '{{.Project}}' "$FORGE_TEST_PROJECT_VALUE"`},
}
v := vars.Vars{"Project": "demo"}
envs := map[string]string{"FORGE_TEST_PROJECT_VALUE": "{{.Project}}-env"}
var output bytes.Buffer
scrb := testScriber(t, &output)

var err error
assert.NotPanics(t, func() {
err = steps["root"].Execute(context.Background(), "root", RunOptions{
Steps: steps, ProjectEnvs: envs, Vars: &v, Scriber: scrb,
})
})
assert.NoError(t, err)
assert.Contains(t, output.String(), "pre:demo:demo-env")
assert.Contains(t, output.String(), "child:demo:demo-env")
assert.Contains(t, output.String(), "again:demo")
assert.Contains(t, output.String(), "post:demo:demo-env")
}

func TestStepExecuteOptions(t *testing.T) {
t.Parallel()

tests := []struct {
name string
skipPre bool
skipPost bool
want []string
}{
{name: "all hooks", want: []string{"PRE", "NESTED_PRE", "BODY", "NESTED_POST", "POST"}},
{name: "skip pre at every level", skipPre: true, want: []string{"BODY", "NESTED_POST", "POST"}},
{name: "skip post at every level", skipPost: true, want: []string{"PRE", "NESTED_PRE", "BODY"}},
{name: "skip both at every level", skipPre: true, skipPost: true, want: []string{"BODY"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

steps := map[string]*Step{
"root": {Pre: []string{"pre"}, Steps: []string{"child"}, Post: []string{"post"}},
"child": {Pre: []string{"nested-pre"}, Cmd: "printf 'BODY\\n'", Post: []string{"nested-post"}},
"pre": {Cmd: "printf 'PRE\\n'"},
"post": {Cmd: "printf 'POST\\n'"},
"nested-pre": {Cmd: "printf 'NESTED_PRE\\n'"},
"nested-post": {Cmd: "printf 'NESTED_POST\\n'"},
}
v := vars.Vars{}
var output bytes.Buffer
options := RunOptions{
Steps: steps, Vars: &v, Scriber: testScriber(t, &output),
SkipPre: tt.skipPre, SkipPost: tt.skipPost,
}

err := steps["root"].Execute(context.Background(), "root", options)

assert.NoError(t, err)
lines := regexp.MustCompile(`(?m)^\s*(PRE|NESTED_PRE|BODY|NESTED_POST|POST)$`)
matches := lines.FindAllStringSubmatch(output.String(), -1)
var got []string
for _, match := range matches {
got = append(got, match[1])
}

assert.Equal(t, tt.want, got)
})
}
}

func TestStepExecuteFailure(t *testing.T) {
t.Parallel()

steps := map[string]*Step{
"root": {Steps: []string{"fail", "later"}, Post: []string{"later"}},
"fail": {Cmd: "printf 'failure-output\\n' >&2; exit 3"},
"later": {Cmd: "printf 'should-not-run\\n'"},
}
v := vars.Vars{}
var output bytes.Buffer
options := RunOptions{
Steps: steps, Vars: &v, Scriber: testScriber(t, &output), Verbose: true,
}

err := steps["root"].Execute(context.Background(), "root", options)

var exitErr *exec.ExitError
if assert.True(t, errors.As(err, &exitErr)) {
assert.Equal(t, 3, exitErr.ExitCode())
}

assert.ErrorContains(t, err, "executing step fail")
assert.Contains(t, output.String(), "failure-output")
assert.NotContains(t, output.String(), "should-not-run")
}
Loading