Skip to content

Commit

Permalink
feat: allow to spawn and run a local reusable workflow (nektos#1423)
Browse files Browse the repository at this point in the history
* feat: allow to spawn and run a local reusable workflow

This change contains the ability to parse/plan/run a local
reusable workflow.
There are still numerous things missing:

- inputs
- secrets
- outputs

* feat: add workflow_call inputs

* test: improve inputs test

* feat: add input defaults

* feat: allow expressions in inputs

* feat: use context specific expression evaluator

* refactor: prepare for better re-usability

* feat: add secrets for reusable workflows

* test: use secrets during test run

* feat: handle reusable workflow outputs

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
KnisterPeter and mergify[bot] authored Dec 15, 2022
1 parent d281230 commit a8e05cd
Show file tree
Hide file tree
Showing 10 changed files with 472 additions and 139 deletions.
68 changes: 68 additions & 0 deletions pkg/model/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,44 @@ func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
return &config
}

type WorkflowCallInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default string `yaml:"default"`
Type string `yaml:"type"`
}

type WorkflowCallOutput struct {
Description string `yaml:"description"`
Value string `yaml:"value"`
}

type WorkflowCall struct {
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
}

func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
if w.RawOn.Kind != yaml.MappingNode {
return nil
}

var val map[string]yaml.Node
err := w.RawOn.Decode(&val)
if err != nil {
log.Fatal(err)
}

var config WorkflowCall
node := val["workflow_call"]
err = node.Decode(&config)
if err != nil {
log.Fatal(err)
}

return &config
}

// Job is the structure of one job in a workflow
type Job struct {
Name string `yaml:"name"`
Expand All @@ -115,6 +153,8 @@ type Job struct {
Defaults Defaults `yaml:"defaults"`
Outputs map[string]string `yaml:"outputs"`
Uses string `yaml:"uses"`
With map[string]interface{} `yaml:"with"`
RawSecrets yaml.Node `yaml:"secrets"`
Result string
}

Expand Down Expand Up @@ -169,6 +209,34 @@ func (s Strategy) GetFailFast() bool {
return failFast
}

func (j *Job) InheritSecrets() bool {
if j.RawSecrets.Kind != yaml.ScalarNode {
return false
}

var val string
err := j.RawSecrets.Decode(&val)
if err != nil {
log.Fatal(err)
}

return val == "inherit"
}

func (j *Job) Secrets() map[string]string {
if j.RawSecrets.Kind != yaml.MappingNode {
return nil
}

var val map[string]string
err := j.RawSecrets.Decode(&val)
if err != nil {
log.Fatal(err)
}

return val
}

// Container details for the job
func (j *Job) Container() *ContainerSpec {
var val *ContainerSpec
Expand Down
57 changes: 55 additions & 2 deletions pkg/runner/expression.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
// todo: should be unavailable
// but required to interpolate/evaluate the step outputs on the job
Steps: rc.getStepsContext(),
Secrets: rc.Config.Secrets,
Secrets: getWorkflowSecrets(ctx, rc),
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
Expand Down Expand Up @@ -101,7 +101,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
Env: *step.getEnv(),
Job: rc.getJobContext(),
Steps: rc.getStepsContext(),
Secrets: rc.Config.Secrets,
Secrets: getWorkflowSecrets(ctx, rc),
Strategy: strategy,
Matrix: rc.Matrix,
Needs: using,
Expand Down Expand Up @@ -315,6 +315,8 @@ func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (str
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} {
inputs := map[string]interface{}{}

setupWorkflowInputs(ctx, &inputs, rc)

var env map[string]string
if step != nil {
env = *step.getEnv()
Expand Down Expand Up @@ -347,3 +349,54 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod

return inputs
}

func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) {
if rc.caller != nil {
config := rc.Run.Workflow.WorkflowCallConfig()

for name, input := range config.Inputs {
value := rc.caller.runContext.Run.Job().With[name]
if value != nil {
if str, ok := value.(string); ok {
// evaluate using the calling RunContext (outside)
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
}
}

if value == nil && config != nil && config.Inputs != nil {
value = input.Default
if rc.ExprEval != nil {
if str, ok := value.(string); ok {
// evaluate using the called RunContext (inside)
value = rc.ExprEval.Interpolate(ctx, str)
}
}
}

(*inputs)[name] = value
}
}
}

func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
if rc.caller != nil {
job := rc.caller.runContext.Run.Job()
secrets := job.Secrets()

if secrets == nil && job.InheritSecrets() {
secrets = rc.caller.runContext.Config.Secrets
}

if secrets == nil {
secrets = map[string]string{}
}

for k, v := range secrets {
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
}

return secrets
}

return rc.Config.Secrets
}
20 changes: 20 additions & 0 deletions pkg/runner/job_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
err = info.stopContainer()(ctx)
}
setJobResult(ctx, info, rc, jobError == nil)
setJobOutputs(ctx, rc)

return err
})

Expand Down Expand Up @@ -135,9 +137,27 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
jobResultMessage = "failed"
}
info.result(jobResult)
if rc.caller != nil {
// set reusable workflow job result
rc.caller.runContext.result(jobResult)
}
logger.WithField("jobResult", jobResult).Infof("\U0001F3C1 Job %s", jobResultMessage)
}

func setJobOutputs(ctx context.Context, rc *RunContext) {
if rc.caller != nil {
// map outputs for reusable workflows
callerOutputs := make(map[string]string)

ee := rc.NewExpressionEvaluator(ctx)
for k, v := range rc.Run.Job().Outputs {
callerOutputs[k] = ee.Interpolate(ctx, v)
}

rc.caller.runContext.Run.Job().Outputs = callerOutputs
}
}

func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error {
ctx = withStepLogger(ctx, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())
Expand Down
18 changes: 9 additions & 9 deletions pkg/runner/job_executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ import (

func TestJobExecutor(t *testing.T) {
tables := []TestJobFileInfo{
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms},
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms},
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms},
{workdir, "uses-github-root", "push", "", platforms},
{workdir, "uses-github-path", "push", "", platforms},
{workdir, "uses-docker-url", "push", "", platforms},
{workdir, "uses-github-full-sha", "push", "", platforms},
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms},
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms},
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
{workdir, "uses-github-root", "push", "", platforms, secrets},
{workdir, "uses-github-path", "push", "", platforms, secrets},
{workdir, "uses-docker-url", "push", "", platforms, secrets},
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
{workdir, "uses-github-short-sha", "push", "Unable to resolve action `actions/hello-world-docker-action@b136eb8`, the provided ref `b136eb8` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `b136eb8894c5cb1dd5807da824be97ccdf9b5423` instead", platforms, secrets},
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
}
// These tests are sufficient to only check syntax.
ctx := common.WithDryrun(context.Background(), true)
Expand Down
45 changes: 45 additions & 0 deletions pkg/runner/reusable_workflow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package runner

import (
"fmt"
"path"

"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model"
)

func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
return newReusableWorkflowExecutor(rc, rc.Config.Workdir)
}

func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
return common.NewErrorExecutor(fmt.Errorf("remote reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)"))
}

func newReusableWorkflowExecutor(rc *RunContext, directory string) common.Executor {
planner, err := model.NewWorkflowPlanner(path.Join(directory, rc.Run.Job().Uses), true)
if err != nil {
return common.NewErrorExecutor(err)
}

plan := planner.PlanEvent("workflow_call")

runner, err := NewReusableWorkflowRunner(rc)
if err != nil {
return common.NewErrorExecutor(err)
}

return runner.NewPlanExecutor(plan)
}

func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
runner := &runnerImpl{
config: rc.Config,
eventJSON: rc.EventJSON,
caller: &caller{
runContext: rc,
},
}

return runner.configure()
}
32 changes: 26 additions & 6 deletions pkg/runner/run_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type RunContext struct {
Parent *RunContext
Masks []string
cleanUpJobContainer common.Executor
caller *caller // job calling this RunContext (reusable workflows)
}

func (rc *RunContext) AddMask(mask string) {
Expand All @@ -58,7 +59,13 @@ type MappableOutput struct {
}

func (rc *RunContext) String() string {
return fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
name := fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
if rc.caller != nil {
// prefix the reusable workflow with the caller job
// this is required to create unique container names
name = fmt.Sprintf("%s/%s", rc.caller.runContext.Run.JobID, name)
}
return name
}

// GetEnv returns the env for the context
Expand Down Expand Up @@ -399,16 +406,25 @@ func (rc *RunContext) steps() []*model.Step {

// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) Executor() common.Executor {
var executor common.Executor

switch rc.Run.Job().Type() {
case model.JobTypeDefault:
executor = newJobExecutor(rc, &stepFactoryImpl{}, rc)
case model.JobTypeReusableWorkflowLocal:
executor = newLocalReusableWorkflowExecutor(rc)
case model.JobTypeReusableWorkflowRemote:
executor = newRemoteReusableWorkflowExecutor(rc)
}

return func(ctx context.Context) error {
isEnabled, err := rc.isEnabled(ctx)
res, err := rc.isEnabled(ctx)
if err != nil {
return err
}

if isEnabled {
return newJobExecutor(rc, &stepFactoryImpl{}, rc)(ctx)
if res {
return executor(ctx)
}

return nil
}
}
Expand Down Expand Up @@ -458,6 +474,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
return false, nil
}

if job.Type() != model.JobTypeDefault {
return true, nil
}

img := rc.platformImage(ctx)
if img == "" {
if job.RunsOn() == nil {
Expand Down
17 changes: 12 additions & 5 deletions pkg/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ type Config struct {
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
}

type caller struct {
runContext *RunContext
}

type runnerImpl struct {
config *Config
eventJSON string
caller *caller // the job calling this runner (caller of a reusable workflow)
}

// New Creates a new Runner
Expand All @@ -64,8 +69,12 @@ func New(runnerConfig *Config) (Runner, error) {
config: runnerConfig,
}

return runner.configure()
}

func (runner *runnerImpl) configure() (Runner, error) {
runner.eventJSON = "{}"
if runnerConfig.EventPath != "" {
if runner.config.EventPath != "" {
log.Debugf("Reading event.json from %s", runner.config.EventPath)
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
if err != nil {
Expand All @@ -89,10 +98,6 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
stageExecutor := make([]common.Executor, 0)
job := run.Job()

if job.Uses != "" {
return fmt.Errorf("reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)")
}

if job.Strategy != nil {
strategyRc := runner.newRunContext(ctx, run, nil)
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
Expand Down Expand Up @@ -161,8 +166,10 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
EventJSON: runner.eventJSON,
StepResults: make(map[string]*model.StepResult),
Matrix: matrix,
caller: runner.caller,
}
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())

return rc
}
Loading

0 comments on commit a8e05cd

Please sign in to comment.