Add support for composite actions (#514)
* Add support for composite actions * Fix to make more complex composite actions work * Fix to make more complex composite actions work * Let's validate the steps in the composite steps to fail on uses and run's without shell, like the real world * Add support for composite actions * Add workflow to test composite actions * Log instead of panicing when output is mismatched * Merge maps so environment variables are not lost * Remove Debug * Correect merge error * Remove invalid composite tests. * Fix composite test Co-authored-by: Casey Lee <cplee@nektos.com> Co-authored-by: monkers <mikem@msquaredconsulting.co.uk> Co-authored-by: Mike Moncrieffe <69815687+mikemonkers@users.noreply.github.com>
This commit is contained in:
parent
94d736a602
commit
b9a7bc6202
11 changed files with 236 additions and 16 deletions
22
.github/actions/composite/action.yml
vendored
Normal file
22
.github/actions/composite/action.yml
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
name: 'Test Composite Action'
|
||||||
|
description: 'Test Composite Action'
|
||||||
|
inputs:
|
||||||
|
input:
|
||||||
|
description: 'String to echo'
|
||||||
|
required: true
|
||||||
|
default: 'Hello World'
|
||||||
|
outputs:
|
||||||
|
output:
|
||||||
|
description: 'Output string'
|
||||||
|
value: ${{ steps.set-output.outputs.string }}
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- run: echo Hello ${{ inputs.input }}.
|
||||||
|
shell: bash
|
||||||
|
- id: set-output
|
||||||
|
run: echo "::set-output name=string::output string"
|
||||||
|
shell: bash
|
||||||
|
- run: |
|
||||||
|
${{ github.action_path }}/script.sh
|
||||||
|
shell: bash
|
1
.github/actions/composite/script.sh
vendored
Executable file
1
.github/actions/composite/script.sh
vendored
Executable file
|
@ -0,0 +1 @@
|
||||||
|
echo "Output from script"
|
25
.github/workflows/test-composite.yml
vendored
Normal file
25
.github/workflows/test-composite.yml
vendored
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
name: "Test composite actions"
|
||||||
|
on: push
|
||||||
|
|
||||||
|
env:
|
||||||
|
ACT: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-composite-actions:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: "✅ I should echo default 'Hello World'"
|
||||||
|
id: step0
|
||||||
|
uses: ./.github/actions/composite
|
||||||
|
|
||||||
|
- name: "Check action output"
|
||||||
|
if: steps.step0.outputs.output != 'output string'
|
||||||
|
run: echo "expected output to be 'output string' but it wasn't"
|
||||||
|
|
||||||
|
- name: "✅ I should echo 'Test string'"
|
||||||
|
id: step1
|
||||||
|
uses: ./.github/actions/composite
|
||||||
|
with:
|
||||||
|
input: 'Test string'
|
|
@ -20,10 +20,11 @@ func (a *ActionRunsUsing) UnmarshalYAML(unmarshal func(interface{}) error) error
|
||||||
// Force input to lowercase for case insensitive comparison
|
// Force input to lowercase for case insensitive comparison
|
||||||
format := ActionRunsUsing(strings.ToLower(using))
|
format := ActionRunsUsing(strings.ToLower(using))
|
||||||
switch format {
|
switch format {
|
||||||
case ActionRunsUsingNode12, ActionRunsUsingDocker:
|
case ActionRunsUsingNode12, ActionRunsUsingDocker, ActionRunsUsingComposite:
|
||||||
*a = format
|
*a = format
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf(fmt.Sprintf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
return fmt.Errorf(fmt.Sprintf("The runs.using key in action.yml must be one of: %v, got %s", []string{
|
||||||
|
ActionRunsUsingComposite,
|
||||||
ActionRunsUsingDocker,
|
ActionRunsUsingDocker,
|
||||||
ActionRunsUsingNode12,
|
ActionRunsUsingNode12,
|
||||||
}, format))
|
}, format))
|
||||||
|
@ -36,6 +37,8 @@ const (
|
||||||
ActionRunsUsingNode12 = "node12"
|
ActionRunsUsingNode12 = "node12"
|
||||||
// ActionRunsUsingDocker for running with docker
|
// ActionRunsUsingDocker for running with docker
|
||||||
ActionRunsUsingDocker = "docker"
|
ActionRunsUsingDocker = "docker"
|
||||||
|
// ActionRunsUsingComposite for running composite
|
||||||
|
ActionRunsUsingComposite = "composite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActionRuns are a field in Action
|
// ActionRuns are a field in Action
|
||||||
|
@ -46,6 +49,7 @@ type ActionRuns struct {
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image"`
|
||||||
Entrypoint []string `yaml:"entrypoint"`
|
Entrypoint []string `yaml:"entrypoint"`
|
||||||
Args []string `yaml:"args"`
|
Args []string `yaml:"args"`
|
||||||
|
Steps []Step `yaml:"steps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
// Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action.
|
||||||
|
@ -72,6 +76,7 @@ type Input struct {
|
||||||
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
// Output parameters allow you to declare data that an action sets. Actions that run later in a workflow can use the output data set in previously run actions. For example, if you had an action that performed the addition of two inputs (x + y = z), the action could output the sum (z) for other actions to use as an input.
|
||||||
type Output struct {
|
type Output struct {
|
||||||
Description string `yaml:"description"`
|
Description string `yaml:"description"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadAction reads an action from a reader
|
// ReadAction reads an action from a reader
|
||||||
|
|
|
@ -312,6 +312,15 @@ func (s *Step) Type() StepType {
|
||||||
return StepTypeUsesActionRemote
|
return StepTypeUsesActionRemote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Step) Validate() error {
|
||||||
|
if s.Type() != StepTypeRun {
|
||||||
|
return fmt.Errorf("(StepID: %s): Unexpected value 'uses'", s.String())
|
||||||
|
} else if s.Shell == "" {
|
||||||
|
return fmt.Errorf("(StepID: %s): Required property is missing: 'shell'", s.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
// ReadWorkflow returns a list of jobs for a given workflow file reader
|
||||||
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
func ReadWorkflow(in io.Reader) (*Workflow, error) {
|
||||||
w := new(Workflow)
|
w := new(Workflow)
|
||||||
|
|
17
pkg/runner/command.go
Normal file → Executable file
17
pkg/runner/command.go
Normal file → Executable file
|
@ -77,8 +77,21 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
||||||
rc.Env[kvPairs["name"]] = arg
|
rc.Env[kvPairs["name"]] = arg
|
||||||
}
|
}
|
||||||
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) {
|
||||||
common.Logger(ctx).Infof(" \U00002699 ::set-output:: %s=%s", kvPairs["name"], arg)
|
stepID := rc.CurrentStep
|
||||||
rc.StepResults[rc.CurrentStep].Outputs[kvPairs["name"]] = arg
|
outputName := kvPairs["name"]
|
||||||
|
if outputMapping, ok := rc.OutputMappings[MappableOutput{StepID: stepID, OutputName: outputName}]; ok {
|
||||||
|
stepID = outputMapping.StepID
|
||||||
|
outputName = outputMapping.OutputName
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := rc.StepResults[stepID]
|
||||||
|
if !ok {
|
||||||
|
common.Logger(ctx).Infof(" \U00002757 no outputs used step '%s'", stepID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Logger(ctx).Infof(" \U00002699 ::set-output:: %s=%s", outputName, arg)
|
||||||
|
result.Outputs[outputName] = arg
|
||||||
}
|
}
|
||||||
func (rc *RunContext) addPath(ctx context.Context, arg string) {
|
func (rc *RunContext) addPath(ctx context.Context, arg string) {
|
||||||
common.Logger(ctx).Infof(" \U00002699 ::add-path:: %s", arg)
|
common.Logger(ctx).Infof(" \U00002699 ::add-path:: %s", arg)
|
||||||
|
|
41
pkg/runner/run_context.go
Normal file → Executable file
41
pkg/runner/run_context.go
Normal file → Executable file
|
@ -20,17 +20,23 @@ import (
|
||||||
|
|
||||||
// RunContext contains info about current job
|
// RunContext contains info about current job
|
||||||
type RunContext struct {
|
type RunContext struct {
|
||||||
Name string
|
Name string
|
||||||
Config *Config
|
Config *Config
|
||||||
Matrix map[string]interface{}
|
Matrix map[string]interface{}
|
||||||
Run *model.Run
|
Run *model.Run
|
||||||
EventJSON string
|
EventJSON string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
ExtraPath []string
|
ExtraPath []string
|
||||||
CurrentStep string
|
CurrentStep string
|
||||||
StepResults map[string]*stepResult
|
StepResults map[string]*stepResult
|
||||||
ExprEval ExpressionEvaluator
|
ExprEval ExpressionEvaluator
|
||||||
JobContainer container.Container
|
JobContainer container.Container
|
||||||
|
OutputMappings map[MappableOutput]MappableOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappableOutput struct {
|
||||||
|
StepID string
|
||||||
|
OutputName string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) String() string {
|
func (rc *RunContext) String() string {
|
||||||
|
@ -372,10 +378,19 @@ func createContainerName(parts ...string) string {
|
||||||
if i == len(parts)-1 {
|
if i == len(parts)-1 {
|
||||||
name = append(name, pattern.ReplaceAllString(part, "-"))
|
name = append(name, pattern.ReplaceAllString(part, "-"))
|
||||||
} else {
|
} else {
|
||||||
name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen))
|
// If any part has a '-<number>' on the end it is likely part of a matrix job.
|
||||||
|
// Let's preserve the number to prevent clashes in container names.
|
||||||
|
re := regexp.MustCompile("-[0-9]+$")
|
||||||
|
num := re.FindStringSubmatch(part)
|
||||||
|
if len(num) > 0 {
|
||||||
|
name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen-len(num[0])))
|
||||||
|
name = append(name, num[0])
|
||||||
|
} else {
|
||||||
|
name = append(name, trimToLen(pattern.ReplaceAllString(part, "-"), partLen))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Trim(strings.Join(name, "-"), "-")
|
return strings.ReplaceAll(strings.Trim(strings.Join(name, "-"), "-"),"--","-")
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimToLen(s string, l int) string {
|
func trimToLen(s string, l int) string {
|
||||||
|
|
2
pkg/runner/runner_test.go
Normal file → Executable file
2
pkg/runner/runner_test.go
Normal file → Executable file
|
@ -97,6 +97,7 @@ func TestRunEvent(t *testing.T) {
|
||||||
{"testdata", "workdir", "push", "", platforms, "linux/amd64"},
|
{"testdata", "workdir", "push", "", platforms, "linux/amd64"},
|
||||||
// {"testdata", "issue-228", "push", "", platforms, "linux/amd64"}, // TODO [igni]: Remove this once everything passes
|
// {"testdata", "issue-228", "push", "", platforms, "linux/amd64"}, // TODO [igni]: Remove this once everything passes
|
||||||
{"testdata", "defaults-run", "push", "", platforms, "linux/amd64"},
|
{"testdata", "defaults-run", "push", "", platforms, "linux/amd64"},
|
||||||
|
{"testdata", "uses-composite", "push", "", platforms, "linux/amd64"},
|
||||||
|
|
||||||
// linux/arm64
|
// linux/arm64
|
||||||
{"testdata", "basic", "push", "", platforms, "linux/arm64"},
|
{"testdata", "basic", "push", "", platforms, "linux/arm64"},
|
||||||
|
@ -116,6 +117,7 @@ func TestRunEvent(t *testing.T) {
|
||||||
{"testdata", "workdir", "push", "", platforms, "linux/arm64"},
|
{"testdata", "workdir", "push", "", platforms, "linux/arm64"},
|
||||||
// {"testdata", "issue-228", "push", "", platforms, "linux/arm64"}, // TODO [igni]: Remove this once everything passes
|
// {"testdata", "issue-228", "push", "", platforms, "linux/arm64"}, // TODO [igni]: Remove this once everything passes
|
||||||
{"testdata", "defaults-run", "push", "", platforms, "linux/arm64"},
|
{"testdata", "defaults-run", "push", "", platforms, "linux/arm64"},
|
||||||
|
{"testdata", "uses-composite", "push", "", platforms, "linux/arm64"},
|
||||||
}
|
}
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
|
|
||||||
|
|
62
pkg/runner/step_context.go
Normal file → Executable file
62
pkg/runner/step_context.go
Normal file → Executable file
|
@ -489,10 +489,72 @@ func (sc *StepContext) runAction(actionDir string, actionPath string) common.Exe
|
||||||
).Finally(
|
).Finally(
|
||||||
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
stepContainer.Remove().IfBool(!rc.Config.ReuseContainers),
|
||||||
)(ctx)
|
)(ctx)
|
||||||
|
case model.ActionRunsUsingComposite:
|
||||||
|
for outputName, output := range action.Outputs {
|
||||||
|
re := regexp.MustCompile(`\${{ steps\.([a-zA-Z_][a-zA-Z0-9_-]+)\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]+) }}`)
|
||||||
|
matches := re.FindStringSubmatch(output.Value)
|
||||||
|
if len(matches) > 2 {
|
||||||
|
if sc.RunContext.OutputMappings == nil {
|
||||||
|
sc.RunContext.OutputMappings = make(map[MappableOutput]MappableOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
k := MappableOutput{StepID: matches[1], OutputName: matches[2]}
|
||||||
|
v := MappableOutput{StepID: step.ID, OutputName: outputName}
|
||||||
|
sc.RunContext.OutputMappings[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var executors []common.Executor
|
||||||
|
stepID := 0
|
||||||
|
for _, compositeStep := range action.Runs.Steps {
|
||||||
|
stepClone := compositeStep
|
||||||
|
// Take a copy of the run context structure (rc is a pointer)
|
||||||
|
// Then take the address of the new structure
|
||||||
|
rcCloneStr := *rc
|
||||||
|
rcClone := &rcCloneStr
|
||||||
|
if stepClone.ID == "" {
|
||||||
|
stepClone.ID = fmt.Sprintf("composite-%d", stepID)
|
||||||
|
stepID++
|
||||||
|
}
|
||||||
|
rcClone.CurrentStep = stepClone.ID
|
||||||
|
|
||||||
|
if err := compositeStep.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the outputs for the composite steps
|
||||||
|
if _, ok := rcClone.StepResults[stepClone.ID]; ! ok {
|
||||||
|
rcClone.StepResults[stepClone.ID] = &stepResult{
|
||||||
|
Success: true,
|
||||||
|
Outputs: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stepClone.Run = strings.ReplaceAll(stepClone.Run, "${{ github.action_path }}", filepath.Join(containerActionDir, actionName))
|
||||||
|
|
||||||
|
stepContext := StepContext{
|
||||||
|
RunContext: rcClone,
|
||||||
|
Step: &stepClone,
|
||||||
|
Env: mergeMaps(sc.Env, stepClone.Env),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolate the outer inputs into the composite step with items
|
||||||
|
exprEval := sc.NewExpressionEvaluator()
|
||||||
|
for k, v := range stepContext.Step.With {
|
||||||
|
|
||||||
|
if strings.Contains(v, "inputs") {
|
||||||
|
stepContext.Step.With[k] = exprEval.Interpolate(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executors = append(executors, stepContext.Executor())
|
||||||
|
}
|
||||||
|
return common.NewPipelineExecutor(executors...)(ctx)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{
|
return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{
|
||||||
model.ActionRunsUsingDocker,
|
model.ActionRunsUsingDocker,
|
||||||
model.ActionRunsUsingNode12,
|
model.ActionRunsUsingNode12,
|
||||||
|
model.ActionRunsUsingComposite,
|
||||||
}, action.Runs.Using))
|
}, action.Runs.Using))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
48
pkg/runner/testdata/uses-composite/composite_action/action.yml
vendored
Normal file
48
pkg/runner/testdata/uses-composite/composite_action/action.yml
vendored
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
---
|
||||||
|
name: "Test Composite Action"
|
||||||
|
description: "Test action uses composite"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
test_input_required:
|
||||||
|
description: "Required input"
|
||||||
|
required: true
|
||||||
|
test_input_optional:
|
||||||
|
description: "optional defaulted input"
|
||||||
|
required: false
|
||||||
|
default: "test_input_optional_value"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
test_output:
|
||||||
|
description: "Output value to pass up"
|
||||||
|
value: ${{ step.output.outputs.test_output }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
echo "#####################################"
|
||||||
|
echo "Inputs:"
|
||||||
|
echo "---"
|
||||||
|
echo "test_input_required=${{ inputs.test_input_required }}"
|
||||||
|
echo "test_input_optional=${{ inputs.test_input_optional }}"
|
||||||
|
echo "---"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Let's test the inputs
|
||||||
|
- run: |
|
||||||
|
if [ "${{ inputs.test_input_required }}" != "test_input_required_value" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
if [ "${{ inputs.test_input_optional }}" != "test_input_optional_value" ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Let's send up an output to test
|
||||||
|
- run: echo "::set-output name=test_output::test_output_value"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
|
18
pkg/runner/testdata/uses-composite/push.yml
vendored
Executable file
18
pkg/runner/testdata/uses-composite/push.yml
vendored
Executable file
|
@ -0,0 +1,18 @@
|
||||||
|
name: uses-docker-url
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ./uses-composite/composite_action
|
||||||
|
id: composite
|
||||||
|
with:
|
||||||
|
test_input_required: 'test_input_required_value'
|
||||||
|
test_input_optional: 'test_input_optional_value'
|
||||||
|
|
||||||
|
- if: steps.composite.outputs.test_output != "test_output_value"
|
||||||
|
run: |
|
||||||
|
echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}"
|
||||||
|
exit 1
|
||||||
|
|
Loading…
Add table
Reference in a new issue