Skip to content

Commit a8e05cd

Browse files
feat: allow to spawn and run a local reusable workflow (#1423)
* 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>
1 parent d281230 commit a8e05cd

10 files changed

+472
-139
lines changed

pkg/model/workflow.go

+68
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,44 @@ func (w *Workflow) WorkflowDispatchConfig() *WorkflowDispatch {
100100
return &config
101101
}
102102

103+
type WorkflowCallInput struct {
104+
Description string `yaml:"description"`
105+
Required bool `yaml:"required"`
106+
Default string `yaml:"default"`
107+
Type string `yaml:"type"`
108+
}
109+
110+
type WorkflowCallOutput struct {
111+
Description string `yaml:"description"`
112+
Value string `yaml:"value"`
113+
}
114+
115+
type WorkflowCall struct {
116+
Inputs map[string]WorkflowCallInput `yaml:"inputs"`
117+
Outputs map[string]WorkflowCallOutput `yaml:"outputs"`
118+
}
119+
120+
func (w *Workflow) WorkflowCallConfig() *WorkflowCall {
121+
if w.RawOn.Kind != yaml.MappingNode {
122+
return nil
123+
}
124+
125+
var val map[string]yaml.Node
126+
err := w.RawOn.Decode(&val)
127+
if err != nil {
128+
log.Fatal(err)
129+
}
130+
131+
var config WorkflowCall
132+
node := val["workflow_call"]
133+
err = node.Decode(&config)
134+
if err != nil {
135+
log.Fatal(err)
136+
}
137+
138+
return &config
139+
}
140+
103141
// Job is the structure of one job in a workflow
104142
type Job struct {
105143
Name string `yaml:"name"`
@@ -115,6 +153,8 @@ type Job struct {
115153
Defaults Defaults `yaml:"defaults"`
116154
Outputs map[string]string `yaml:"outputs"`
117155
Uses string `yaml:"uses"`
156+
With map[string]interface{} `yaml:"with"`
157+
RawSecrets yaml.Node `yaml:"secrets"`
118158
Result string
119159
}
120160

@@ -169,6 +209,34 @@ func (s Strategy) GetFailFast() bool {
169209
return failFast
170210
}
171211

212+
func (j *Job) InheritSecrets() bool {
213+
if j.RawSecrets.Kind != yaml.ScalarNode {
214+
return false
215+
}
216+
217+
var val string
218+
err := j.RawSecrets.Decode(&val)
219+
if err != nil {
220+
log.Fatal(err)
221+
}
222+
223+
return val == "inherit"
224+
}
225+
226+
func (j *Job) Secrets() map[string]string {
227+
if j.RawSecrets.Kind != yaml.MappingNode {
228+
return nil
229+
}
230+
231+
var val map[string]string
232+
err := j.RawSecrets.Decode(&val)
233+
if err != nil {
234+
log.Fatal(err)
235+
}
236+
237+
return val
238+
}
239+
172240
// Container details for the job
173241
func (j *Job) Container() *ContainerSpec {
174242
var val *ContainerSpec

pkg/runner/expression.go

+55-2
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func (rc *RunContext) NewExpressionEvaluatorWithEnv(ctx context.Context, env map
5555
// todo: should be unavailable
5656
// but required to interpolate/evaluate the step outputs on the job
5757
Steps: rc.getStepsContext(),
58-
Secrets: rc.Config.Secrets,
58+
Secrets: getWorkflowSecrets(ctx, rc),
5959
Strategy: strategy,
6060
Matrix: rc.Matrix,
6161
Needs: using,
@@ -101,7 +101,7 @@ func (rc *RunContext) NewStepExpressionEvaluator(ctx context.Context, step step)
101101
Env: *step.getEnv(),
102102
Job: rc.getJobContext(),
103103
Steps: rc.getStepsContext(),
104-
Secrets: rc.Config.Secrets,
104+
Secrets: getWorkflowSecrets(ctx, rc),
105105
Strategy: strategy,
106106
Matrix: rc.Matrix,
107107
Needs: using,
@@ -315,6 +315,8 @@ func rewriteSubExpression(ctx context.Context, in string, forceFormat bool) (str
315315
func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]interface{} {
316316
inputs := map[string]interface{}{}
317317

318+
setupWorkflowInputs(ctx, &inputs, rc)
319+
318320
var env map[string]string
319321
if step != nil {
320322
env = *step.getEnv()
@@ -347,3 +349,54 @@ func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *mod
347349

348350
return inputs
349351
}
352+
353+
func setupWorkflowInputs(ctx context.Context, inputs *map[string]interface{}, rc *RunContext) {
354+
if rc.caller != nil {
355+
config := rc.Run.Workflow.WorkflowCallConfig()
356+
357+
for name, input := range config.Inputs {
358+
value := rc.caller.runContext.Run.Job().With[name]
359+
if value != nil {
360+
if str, ok := value.(string); ok {
361+
// evaluate using the calling RunContext (outside)
362+
value = rc.caller.runContext.ExprEval.Interpolate(ctx, str)
363+
}
364+
}
365+
366+
if value == nil && config != nil && config.Inputs != nil {
367+
value = input.Default
368+
if rc.ExprEval != nil {
369+
if str, ok := value.(string); ok {
370+
// evaluate using the called RunContext (inside)
371+
value = rc.ExprEval.Interpolate(ctx, str)
372+
}
373+
}
374+
}
375+
376+
(*inputs)[name] = value
377+
}
378+
}
379+
}
380+
381+
func getWorkflowSecrets(ctx context.Context, rc *RunContext) map[string]string {
382+
if rc.caller != nil {
383+
job := rc.caller.runContext.Run.Job()
384+
secrets := job.Secrets()
385+
386+
if secrets == nil && job.InheritSecrets() {
387+
secrets = rc.caller.runContext.Config.Secrets
388+
}
389+
390+
if secrets == nil {
391+
secrets = map[string]string{}
392+
}
393+
394+
for k, v := range secrets {
395+
secrets[k] = rc.caller.runContext.ExprEval.Interpolate(ctx, v)
396+
}
397+
398+
return secrets
399+
}
400+
401+
return rc.Config.Secrets
402+
}

pkg/runner/job_executor.go

+20
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
104104
err = info.stopContainer()(ctx)
105105
}
106106
setJobResult(ctx, info, rc, jobError == nil)
107+
setJobOutputs(ctx, rc)
108+
107109
return err
108110
})
109111

@@ -135,9 +137,27 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
135137
jobResultMessage = "failed"
136138
}
137139
info.result(jobResult)
140+
if rc.caller != nil {
141+
// set reusable workflow job result
142+
rc.caller.runContext.result(jobResult)
143+
}
138144
logger.WithField("jobResult", jobResult).Infof("\U0001F3C1 Job %s", jobResultMessage)
139145
}
140146

147+
func setJobOutputs(ctx context.Context, rc *RunContext) {
148+
if rc.caller != nil {
149+
// map outputs for reusable workflows
150+
callerOutputs := make(map[string]string)
151+
152+
ee := rc.NewExpressionEvaluator(ctx)
153+
for k, v := range rc.Run.Job().Outputs {
154+
callerOutputs[k] = ee.Interpolate(ctx, v)
155+
}
156+
157+
rc.caller.runContext.Run.Job().Outputs = callerOutputs
158+
}
159+
}
160+
141161
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
142162
return func(ctx context.Context) error {
143163
ctx = withStepLogger(ctx, stepModel.ID, rc.ExprEval.Interpolate(ctx, stepModel.String()), stage.String())

pkg/runner/job_executor_test.go

+9-9
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ import (
1515

1616
func TestJobExecutor(t *testing.T) {
1717
tables := []TestJobFileInfo{
18-
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms},
19-
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms},
20-
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms},
21-
{workdir, "uses-github-root", "push", "", platforms},
22-
{workdir, "uses-github-path", "push", "", platforms},
23-
{workdir, "uses-docker-url", "push", "", platforms},
24-
{workdir, "uses-github-full-sha", "push", "", platforms},
25-
{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},
26-
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms},
18+
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
19+
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
20+
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
21+
{workdir, "uses-github-root", "push", "", platforms, secrets},
22+
{workdir, "uses-github-path", "push", "", platforms, secrets},
23+
{workdir, "uses-docker-url", "push", "", platforms, secrets},
24+
{workdir, "uses-github-full-sha", "push", "", platforms, secrets},
25+
{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},
26+
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
2727
}
2828
// These tests are sufficient to only check syntax.
2929
ctx := common.WithDryrun(context.Background(), true)

pkg/runner/reusable_workflow.go

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package runner
2+
3+
import (
4+
"fmt"
5+
"path"
6+
7+
"github.com/nektos/act/pkg/common"
8+
"github.com/nektos/act/pkg/model"
9+
)
10+
11+
func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
12+
return newReusableWorkflowExecutor(rc, rc.Config.Workdir)
13+
}
14+
15+
func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
16+
return common.NewErrorExecutor(fmt.Errorf("remote reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)"))
17+
}
18+
19+
func newReusableWorkflowExecutor(rc *RunContext, directory string) common.Executor {
20+
planner, err := model.NewWorkflowPlanner(path.Join(directory, rc.Run.Job().Uses), true)
21+
if err != nil {
22+
return common.NewErrorExecutor(err)
23+
}
24+
25+
plan := planner.PlanEvent("workflow_call")
26+
27+
runner, err := NewReusableWorkflowRunner(rc)
28+
if err != nil {
29+
return common.NewErrorExecutor(err)
30+
}
31+
32+
return runner.NewPlanExecutor(plan)
33+
}
34+
35+
func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) {
36+
runner := &runnerImpl{
37+
config: rc.Config,
38+
eventJSON: rc.EventJSON,
39+
caller: &caller{
40+
runContext: rc,
41+
},
42+
}
43+
44+
return runner.configure()
45+
}

pkg/runner/run_context.go

+26-6
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type RunContext struct {
4646
Parent *RunContext
4747
Masks []string
4848
cleanUpJobContainer common.Executor
49+
caller *caller // job calling this RunContext (reusable workflows)
4950
}
5051

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

6061
func (rc *RunContext) String() string {
61-
return fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
62+
name := fmt.Sprintf("%s/%s", rc.Run.Workflow.Name, rc.Name)
63+
if rc.caller != nil {
64+
// prefix the reusable workflow with the caller job
65+
// this is required to create unique container names
66+
name = fmt.Sprintf("%s/%s", rc.caller.runContext.Run.JobID, name)
67+
}
68+
return name
6269
}
6370

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

400407
// Executor returns a pipeline executor for all the steps in the job
401408
func (rc *RunContext) Executor() common.Executor {
409+
var executor common.Executor
410+
411+
switch rc.Run.Job().Type() {
412+
case model.JobTypeDefault:
413+
executor = newJobExecutor(rc, &stepFactoryImpl{}, rc)
414+
case model.JobTypeReusableWorkflowLocal:
415+
executor = newLocalReusableWorkflowExecutor(rc)
416+
case model.JobTypeReusableWorkflowRemote:
417+
executor = newRemoteReusableWorkflowExecutor(rc)
418+
}
419+
402420
return func(ctx context.Context) error {
403-
isEnabled, err := rc.isEnabled(ctx)
421+
res, err := rc.isEnabled(ctx)
404422
if err != nil {
405423
return err
406424
}
407-
408-
if isEnabled {
409-
return newJobExecutor(rc, &stepFactoryImpl{}, rc)(ctx)
425+
if res {
426+
return executor(ctx)
410427
}
411-
412428
return nil
413429
}
414430
}
@@ -458,6 +474,10 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
458474
return false, nil
459475
}
460476

477+
if job.Type() != model.JobTypeDefault {
478+
return true, nil
479+
}
480+
461481
img := rc.platformImage(ctx)
462482
if img == "" {
463483
if job.RunsOn() == nil {

pkg/runner/runner.go

+12-5
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ type Config struct {
5353
ReplaceGheActionTokenWithGithubCom string // Token of private action repo on GitHub.
5454
}
5555

56+
type caller struct {
57+
runContext *RunContext
58+
}
59+
5660
type runnerImpl struct {
5761
config *Config
5862
eventJSON string
63+
caller *caller // the job calling this runner (caller of a reusable workflow)
5964
}
6065

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

72+
return runner.configure()
73+
}
74+
75+
func (runner *runnerImpl) configure() (Runner, error) {
6776
runner.eventJSON = "{}"
68-
if runnerConfig.EventPath != "" {
77+
if runner.config.EventPath != "" {
6978
log.Debugf("Reading event.json from %s", runner.config.EventPath)
7079
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
7180
if err != nil {
@@ -89,10 +98,6 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
8998
stageExecutor := make([]common.Executor, 0)
9099
job := run.Job()
91100

92-
if job.Uses != "" {
93-
return fmt.Errorf("reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)")
94-
}
95-
96101
if job.Strategy != nil {
97102
strategyRc := runner.newRunContext(ctx, run, nil)
98103
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
@@ -161,8 +166,10 @@ func (runner *runnerImpl) newRunContext(ctx context.Context, run *model.Run, mat
161166
EventJSON: runner.eventJSON,
162167
StepResults: make(map[string]*model.StepResult),
163168
Matrix: matrix,
169+
caller: runner.caller,
164170
}
165171
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
166172
rc.Name = rc.ExprEval.Interpolate(ctx, run.String())
173+
167174
return rc
168175
}

0 commit comments

Comments
 (0)