Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow to spawn and run a local reusable workflow #1423

Merged
merged 11 commits into from
Dec 15, 2022
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)
Copy link
Contributor

@ChristopherHX ChristopherHX Dec 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this behind workflow_dispatch (line 349) ?, because workflow_call has precedence and unset inputs are filled up by workflow_dispatch inputs of the parent workflow.

The workflow_dispatch on field is probably wrong in this case...
Is github.event_name / ghc.EventName correctly set the parent / grandparent event_name? It is never workflow_call.
, the value is correct.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inputs in the expression evaluator are a bit messy currently.
We should consider refactoring the setup of the evaluator to be more flexible and factor in the context availability.
Currently we only have to kind of fixed setups for the expression evaluator which is too tightly coupled.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my research, the inputs ctx is available here as the workflow_dispatch inputs of the caller workflow.

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)
KnisterPeter marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -43,6 +43,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 @@ -55,7 +56,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 @@ -369,16 +376,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 @@ -428,6 +444,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